updated the conversion process of cli to config

This commit is contained in:
arabcoders 2025-03-31 20:46:55 +03:00
parent cbc158f477
commit 77de30609b
13 changed files with 188 additions and 69 deletions

View file

@ -19,11 +19,16 @@
"attl", "attl",
"autonumber", "autonumber",
"changeslog", "changeslog",
"consoletitle",
"cookiesfrombrowser",
"copyts", "copyts",
"daterange", "daterange",
"dotenv", "dotenv",
"finaldir", "finaldir",
"flac", "flac",
"forcejson",
"forceprint",
"fribidi",
"getpid", "getpid",
"gpac", "gpac",
"httpx", "httpx",
@ -41,6 +46,7 @@
"postprocessor", "postprocessor",
"preferredcodec", "preferredcodec",
"preferredquality", "preferredquality",
"printtraffic",
"quicktime", "quicktime",
"tmpfilename", "tmpfilename",
"urandom", "urandom",
@ -56,7 +62,6 @@
"en" "en"
], ],
"spellright.documentTypes": [ "spellright.documentTypes": [
"latex", "latex"
"plaintext"
], ],
} }

View file

@ -35,7 +35,7 @@ ENV XDG_CONFIG_HOME=/config
ENV XDG_CACHE_HOME=/tmp ENV XDG_CACHE_HOME=/tmp
RUN mkdir /config /downloads && ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime && echo ${TZ} > /etc/timezone && \ RUN mkdir /config /downloads && ln -snf /usr/share/zoneinfo/${TZ} /etc/localtime && echo ${TZ} > /etc/timezone && \
apk add --update --no-cache bash mkvtoolnix patch aria2 coreutils curl shadow sqlite tzdata libmagic ffmpeg rtmpdump && \ apk add --update --no-cache bash mkvtoolnix patch aria2 coreutils curl shadow sqlite tzdata libmagic ffmpeg rtmpdump fribidi && \
useradd -u ${USER_ID:-1000} -U -d /app -s /bin/bash app && \ useradd -u ${USER_ID:-1000} -U -d /app -s /bin/bash app && \
rm -rf /var/cache/apk/* rm -rf /var/cache/apk/*

View file

@ -114,6 +114,8 @@ class Download:
def _progress_hook(self, data: dict): def _progress_hook(self, data: dict):
dataDict = {k: v for k, v in data.items() if k in self._ytdlp_fields} dataDict = {k: v for k, v in data.items() if k in self._ytdlp_fields}
LOG.debug("Status: %s", data)
if "finished" == data.get("status") and data.get("info_dict", {}).get("filename", None): if "finished" == data.get("status") and data.get("info_dict", {}).get("filename", None):
dataDict["filename"] = data["info_dict"]["filename"] dataDict["filename"] = data["info_dict"]["filename"]

View file

@ -90,7 +90,6 @@ class Events:
LOG_SUCCESS = "log_success" LOG_SUCCESS = "log_success"
INITIAL_DATA = "initial_data" INITIAL_DATA = "initial_data"
YTDLP_CONVERT = "ytdlp_convert"
ITEM_DELETE = "item_delete" ITEM_DELETE = "item_delete"
ITEM_CANCEL = "item_cancel" ITEM_CANCEL = "item_cancel"
STATUS = "status" STATUS = "status"

View file

@ -35,7 +35,7 @@ from .Segments import Segments
from .Subtitle import Subtitle from .Subtitle import Subtitle
from .Tasks import Task, Tasks from .Tasks import Task, Tasks
from .Utils import ( from .Utils import (
IGNORED_KEYS, REMOVE_KEYS,
StreamingError, StreamingError,
arg_converter, arg_converter,
decrypt_data, decrypt_data,
@ -445,7 +445,7 @@ class HttpAPI(Common):
try: try:
response = {"opts": {}, "output_template": None, "download_path": None} response = {"opts": {}, "output_template": None, "download_path": None}
data = arg_converter(args) data = arg_converter(args, dumps=True)
if "outtmpl" in data and "default" in data["outtmpl"]: if "outtmpl" in data and "default" in data["outtmpl"]:
response["output_template"] = data["outtmpl"]["default"] response["output_template"] = data["outtmpl"]["default"]
@ -456,12 +456,19 @@ class HttpAPI(Common):
if "format" in data: if "format" in data:
response["format"] = data["format"] response["format"] = data["format"]
bad_options = {k: v for d in REMOVE_KEYS for k, v in d.items()}
removed_options = []
for key in data: for key in data:
if key in IGNORED_KEYS: if key in bad_options.items():
removed_options.append(bad_options[key])
continue continue
if not key.startswith("_"): if not key.startswith("_"):
response["opts"][key] = data[key] response["opts"][key] = data[key]
if len(removed_options) > 0:
response["opts"]["removed"] = ", ".join(removed_options)
return web.json_response(data=response, status=web.HTTPOk.status_code) return web.json_response(data=response, status=web.HTTPOk.status_code)
except Exception as e: except Exception as e:
err = str(e).strip() err = str(e).strip()

View file

@ -18,7 +18,7 @@ from .DownloadQueue import DownloadQueue
from .encoder import Encoder from .encoder import Encoder
from .Events import Event, EventBus, Events, error from .Events import Event, EventBus, Events, error
from .Presets import Presets from .Presets import Presets
from .Utils import arg_converter, is_downloaded from .Utils import is_downloaded
LOG = logging.getLogger("socket_api") LOG = logging.getLogger("socket_api")
@ -285,25 +285,3 @@ class HttpSocket(Common):
async def resume(self, *_, **__): async def resume(self, *_, **__):
self.queue.resume() self.queue.resume()
await self._notify.emit(Events.PAUSED, data={"paused": False, "at": time.time()}) await self._notify.emit(Events.PAUSED, data={"paused": False, "at": time.time()})
@ws_event
async def ytdlp_convert(self, sid: str, data: dict):
if not isinstance(data, dict) or "args" not in data:
await self._notify.emit(Events.ERROR, data=error("Invalid request or no options were given."), to=sid)
return
args: str | None = data.get("args")
if not args:
await self._notify.emit(Events.ERROR, data=error("no options were given."), to=sid)
return
try:
await self._notify.emit(Events.YTDLP_CONVERT, data=arg_converter(args), to=sid)
except Exception as e:
err = str(e).strip()
err = err.split("\n")[-1] if "\n" in err else err
LOG.error(f"Failed to convert args. '{err}'.")
await self._notify.emit(Events.ERROR, data=error(f"Failed to convert options. '{err}'."), to=sid)
return

View file

@ -21,7 +21,39 @@ from .LogWrapper import LogWrapper
LOG = logging.getLogger("Utils") LOG = logging.getLogger("Utils")
IGNORED_KEYS: tuple[str] = ("paths", "outtmpl", "progress_hooks", "postprocessor_hooks", "download_archive") REMOVE_KEYS: list = [
{
"paths": "-P, --paths",
"outtmpl": "-o, --output",
"progress_hooks": "--progress_hooks",
"postprocessor_hooks": "--postprocessor_hooks",
"download_archive": "--download_archive",
},
{
"quiet": "-q, --quiet",
"no_warnings": "--no-warnings",
"skip_download": "--skip-download",
"forceprint": "-O, --print",
"simulate": "--simulate",
"noprogress": "--no-progress",
"wait_for_video": "--wait-for-video",
"mark_watched": "--mark-watched",
"color": "--color",
"verbose": "-v, --verbose",
"debug_printtraffic": "--print-traffic",
"write_pages": "--write-pages",
"dump_intermediate_pages": "--dump-pages",
"progress_delta": " --progress-delta",
"progress_template": "--progress-template",
"consoletitle": "--console-title",
"progress_with_newline": "--newline",
"forcejson": "-j, --dump-json",
"print_to_file": "--print-to-file",
"cookiesfrombrowser": "--cookies-from-browser",
"cookiefile": "--cookies",
},
]
YTDLP_INFO_CLS: yt_dlp.YoutubeDL = None YTDLP_INFO_CLS: yt_dlp.YoutubeDL = None
ALLOWED_SUBS_EXTENSIONS: tuple[str] = (".srt", ".vtt", ".ass") ALLOWED_SUBS_EXTENSIONS: tuple[str] = (".srt", ".vtt", ".ass")
@ -434,13 +466,20 @@ def validate_url(url: str) -> bool:
return True return True
def arg_converter(args: str, remove_options: bool = False) -> dict: def arg_converter(
args: str,
level: int | bool | None = None,
dumps: bool = False,
removed_options: list | None = None,
) -> dict:
""" """
Convert yt-dlp options to a dictionary. Convert yt-dlp options to a dictionary.
Args: Args:
args (str): yt-dlp options string. args (str): yt-dlp options string.
remove_options (bool): Remove default options. level (int|bool|None): Level of options to remove, True for all.
dumps (bool): Dump options as JSON.
removed_options (list|None): List of removed options.
Returns: Returns:
dict: yt-dlp options dictionary. dict: yt-dlp options dictionary.
@ -473,17 +512,33 @@ def arg_converter(args: str, remove_options: bool = False) -> dict:
if isinstance(matchFilter, set): if isinstance(matchFilter, set):
diff["match_filter"] = {"filters": list(matchFilter)} diff["match_filter"] = {"filters": list(matchFilter)}
if remove_options:
for key in diff.copy():
if key in IGNORED_KEYS:
diff.pop(key, None)
if "_warnings" in diff: if "_warnings" in diff:
diff.pop("_warnings", None) diff.pop("_warnings", None)
from .encoder import Encoder if level:
bad_options = {}
level = len(REMOVE_KEYS) if not isinstance(level, int) else level
return json.loads(json.dumps(diff, cls=Encoder)) for i, item in enumerate(REMOVE_KEYS):
if i > level:
break
bad_options.update(item.items())
for key in diff.copy():
if key not in bad_options:
continue
if isinstance(removed_options, list):
removed_options.append(bad_options[key])
diff.pop(key, None)
if dumps is True:
from .encoder import Encoder
return json.loads(json.dumps(diff, cls=Encoder))
return diff
def validate_uuid(uuid_str: str, version: int = 4) -> bool: def validate_uuid(uuid_str: str, version: int = 4) -> bool:

View file

@ -4,7 +4,7 @@ from pathlib import Path
from .config import Config from .config import Config
from .Presets import Presets from .Presets import Presets
from .Singleton import Singleton from .Singleton import Singleton
from .Utils import IGNORED_KEYS, arg_converter, calc_download_path, merge_dict from .Utils import REMOVE_KEYS, arg_converter, calc_download_path, merge_dict
LOG = logging.getLogger("YTDLPOpts") LOG = logging.getLogger("YTDLPOpts")
@ -48,11 +48,22 @@ class YTDLPOpts(metaclass=Singleton):
YTDLPOpts: The instance of the class YTDLPOpts: The instance of the class
""" """
bad_options = {}
if from_user:
bad_options = {k: v for d in REMOVE_KEYS for k, v in d.items()}
removed_options = []
for key, value in config.items(): for key, value in config.items():
if key in IGNORED_KEYS and from_user: if from_user and key in bad_options:
removed_options.append(bad_options[key])
continue continue
self._item_opts[key] = value self._item_opts[key] = value
if len(removed_options) > 0:
LOG.warning("Removed the following options: '%s'.", ", ".join(removed_options))
return self return self
def preset(self, name: str, with_cookies: bool = False) -> "YTDLPOpts": def preset(self, name: str, with_cookies: bool = False) -> "YTDLPOpts":
@ -73,7 +84,13 @@ class YTDLPOpts(metaclass=Singleton):
if preset.cli: if preset.cli:
try: try:
self._preset_opts = arg_converter(args=preset.cli, remove_options=True) removed_options = []
self._preset_opts = arg_converter(args=preset.cli, level=True, removed_options=removed_options)
if len(removed_options) > 0:
LOG.warning(
"Removed the following options '%s' from preset '%s'.", ", ".join(removed_options), preset.name
)
except Exception as e: except Exception as e:
msg = f"Invalid cli options for preset '{preset.name}'. '{e!s}'." msg = f"Invalid cli options for preset '{preset.name}'. '{e!s}'."
raise ValueError(msg) from e raise ValueError(msg) from e
@ -106,11 +123,19 @@ class YTDLPOpts(metaclass=Singleton):
self._preset_opts["postprocessors"] = preset.postprocessors self._preset_opts["postprocessors"] = preset.postprocessors
if preset.args and isinstance(preset.args, dict) and len(preset.args) > 0: if preset.args and isinstance(preset.args, dict) and len(preset.args) > 0:
bad_options = {k: v for d in REMOVE_KEYS for k, v in d.items()}
removed_options = []
for key, value in preset.args.items(): for key, value in preset.args.items():
if key in IGNORED_KEYS: if key in bad_options:
removed_options.append(bad_options[key])
continue continue
self._preset_opts[key] = value self._preset_opts[key] = value
if len(removed_options) > 0:
LOG.warning(
"Removed the following options '%s' from '%s' args.", ", ".join(removed_options), preset.name
)
return self return self
def get_all(self, keep: bool = False) -> dict: def get_all(self, keep: bool = False) -> dict:

View file

@ -98,7 +98,11 @@ class Common:
cli = item.get("cli") cli = item.get("cli")
if cli and len(cli) > 1: if cli and len(cli) > 1:
try: try:
config = arg_converter(args=cli, remove_options=True) removed_options = []
config = arg_converter(args=cli, level=True, removed_options=removed_options)
if len(removed_options) > 0:
LOG.warning("Removed the following options '%s'.", ", ".join(removed_options))
except Exception as e: except Exception as e:
msg = f"Failed to parse yt-dlp cli options. {e!s}" msg = f"Failed to parse yt-dlp cli options. {e!s}"
raise ValueError(msg) from e raise ValueError(msg) from e

View file

@ -12,7 +12,7 @@ import coloredlogs
from dotenv import load_dotenv from dotenv import load_dotenv
from yt_dlp.version import __version__ as YTDLP_VERSION from yt_dlp.version import __version__ as YTDLP_VERSION
from .Utils import IGNORED_KEYS, load_file, merge_dict from .Utils import REMOVE_KEYS, arg_converter, load_file, merge_dict
from .version import APP_VERSION from .version import APP_VERSION
@ -218,7 +218,7 @@ class Config:
"instance_title", "instance_title",
"sentry_dsn", "sentry_dsn",
"console_enabled", "console_enabled",
"browser_enabled" "browser_enabled",
) )
"The variables that are relevant to the frontend." "The variables that are relevant to the frontend."
@ -321,8 +321,38 @@ class Config:
except Exception as e: except Exception as e:
LOG.error(f"Error starting debugpy server at '0.0.0.0:{self.debugpy_port}'. {e}") LOG.error(f"Error starting debugpy server at '0.0.0.0:{self.debugpy_port}'. {e}")
ytdlp_cli: str = os.path.join(self.config_path, "ytdlp.cli")
optsFile: str = os.path.join(self.config_path, "ytdlp.json") optsFile: str = os.path.join(self.config_path, "ytdlp.json")
if os.path.exists(optsFile) and os.path.getsize(optsFile) > 5: if os.path.exists(ytdlp_cli) and os.path.getsize(ytdlp_cli) > 2:
LOG.info(f"Loading yt-dlp custom options from '{ytdlp_cli}'.")
with open(ytdlp_cli) as f:
ytdlp_cli_opts = f.read().strip()
if ytdlp_cli_opts:
try:
removed_options = []
self.ytdl_options = arg_converter(
args=ytdlp_cli_opts,
level=True,
removed_options=removed_options,
)
try:
LOG.debug("Parsed yt-dlp cli options '%s'.", self.ytdl_options)
except Exception:
pass
if len(removed_options) > 0:
LOG.warning(
"Removed the following options: '%s' from '%s'", ", ".join(removed_options), ytdlp_cli
)
except Exception as e:
msg = f"Failed to parse yt-dlp cli options from '{ytdlp_cli}'. '{e!s}'."
raise ValueError(msg) from e
else:
LOG.warning(f"Empty yt-dlp custom options file '{ytdlp_cli}'.")
# @Deprecated - To be removed in future versions.
elif os.path.exists(optsFile) and os.path.getsize(optsFile) > 5:
LOG.warning("The JSON ytdlp.json options file is deprecated, please use 'ytdlp.cli' file instead.")
LOG.info(f"Loading yt-dlp custom options from '{optsFile}'.") LOG.info(f"Loading yt-dlp custom options from '{optsFile}'.")
(opts, status, error) = load_file(optsFile, dict) (opts, status, error) = load_file(optsFile, dict)
@ -330,16 +360,24 @@ class Config:
LOG.error(f"Could not load yt-dlp custom options from '{optsFile}'. {error}") LOG.error(f"Could not load yt-dlp custom options from '{optsFile}'. {error}")
sys.exit(1) sys.exit(1)
if isinstance(opts, dict): if isinstance(opts, dict):
for key in IGNORED_KEYS: bad_options = {k: v for d in REMOVE_KEYS for k, v in d.items()}
if key in opts: removed_options = []
LOG.error(f"Key '{key}' is not allowed to be loaded via 'ytdlp.json' file.") for key in opts.copy():
del opts[key] if key not in bad_options:
continue
removed_options.append(bad_options[key])
opts.pop(key, None)
if len(removed_options) > 0:
LOG.warning(
"Removed the following options: '%s' from 'ytdlp.json' file.", ", ".join(removed_options)
)
self.ytdl_options = merge_dict(self.ytdl_options, opts) self.ytdl_options = merge_dict(self.ytdl_options, opts)
else: else:
LOG.error(f"Invalid yt-dlp custom options file '{optsFile}'.") LOG.error(f"Invalid yt-dlp custom options file '{optsFile}'.")
else: else:
LOG.info(f"No yt-dlp custom options found at '{optsFile}'.") LOG.info(f"No yt-dlp custom options found at '{ytdlp_cli}'.")
self.ytdl_options["socket_timeout"] = self.socket_timeout self.ytdl_options["socket_timeout"] = self.socket_timeout
@ -373,7 +411,6 @@ class Config:
key_file: str = os.path.join(self.config_path, "secret.key") key_file: str = os.path.join(self.config_path, "secret.key")
# save key as bytes.
if os.path.exists(key_file) and os.path.getsize(key_file) > 5: if os.path.exists(key_file) and os.path.getsize(key_file) > 5:
with open(key_file, "rb") as f: with open(key_file, "rb") as f:
self.secret_key = f.read().strip() self.secret_key = f.read().strip()

View file

@ -141,17 +141,17 @@
</td> </td>
<td class="is-vcentered is-items-center"> <td class="is-vcentered is-items-center">
<div class="field is-grouped is-grouped-centered"> <div class="field is-grouped is-grouped-centered">
<div class="control" v-if="'finished' === item.status || isEmbedable(item.url)"> <div class="control" v-if="('finished' === item.status && item.filename) || isEmbedable(item.url)">
<button v-if="'finished' === item.status" @click="playVideo(item)" v-tooltip="'Play video'" <button v-if="'finished' === item.status && item.filename" @click="playVideo(item)"
class="button is-danger is-light is-small"> v-tooltip="'Play video'" class="button is-danger is-light is-small">
<span class="icon"><i class="fa-solid fa-play" /></span> <span class="icon"><i class="fa-solid fa-play" /></span>
</button> </button>
<button v-else @click="embed_url = getEmbedable(item.url)" v-tooltip="'Play video'" <button v-else @click="embed_url = getEmbedable(item.url)" v-tooltip="'Play video'"
class="button is-danger is-light is-small"> class="button is-danger is-small">
<span class="icon"><i class="fa-solid fa-play" /></span> <span class="icon"><i class="fa-solid fa-play" /></span>
</button> </button>
</div> </div>
<div class="control" v-if="item.status != 'finished'"> <div class="control" v-if="item.status != 'finished' || !item.filename">
<button class="button is-warning is-fullwidth is-small" v-tooltip="'Re-queue video'" <button class="button is-warning is-fullwidth is-small" v-tooltip="'Re-queue video'"
@click="reQueueItem(item)"> @click="reQueueItem(item)">
<span class="icon"><i class="fa-solid fa-rotate-right" /></span> <span class="icon"><i class="fa-solid fa-rotate-right" /></span>
@ -203,7 +203,7 @@
<div class="card-header-icon"> <div class="card-header-icon">
<span v-if="hideThumbnail"> <span v-if="hideThumbnail">
<a v-if="'finished' === item.status" href="#" @click.prevent="playVideo(item)" <a v-if="'finished' === item.status && item.filename" href="#" @click.prevent="playVideo(item)"
v-tooltip="'Play video.'"> v-tooltip="'Play video.'">
<span class="icon"><i class="fa-solid fa-play" /></span> <span class="icon"><i class="fa-solid fa-play" /></span>
</a> </a>
@ -223,7 +223,7 @@
</header> </header>
<div v-if="false === hideThumbnail" class="card-image"> <div v-if="false === hideThumbnail" class="card-image">
<figure class="image is-3by1"> <figure class="image is-3by1">
<span v-if="'finished' === item.status" @click="playVideo(item)" class="play-overlay"> <span v-if="'finished' === item.status && item.filename" @click="playVideo(item)" class="play-overlay">
<div class="play-icon"></div> <div class="play-icon"></div>
<img @load="e => pImg(e)" :src="'/api/thumbnail?url=' + encodePath(item.extras.thumbnail)" <img @load="e => pImg(e)" :src="'/api/thumbnail?url=' + encodePath(item.extras.thumbnail)"
v-if="item.extras?.thumbnail" /> v-if="item.extras?.thumbnail" />
@ -282,7 +282,7 @@
</div> </div>
</div> </div>
<div class="columns is-mobile is-multiline"> <div class="columns is-mobile is-multiline">
<div class="column is-half-mobile" v-if="item.status != 'finished'"> <div class="column is-half-mobile" v-if="item.status != 'finished' || !item.filename">
<a class="button is-warning is-fullwidth" @click="reQueueItem(item)"> <a class="button is-warning is-fullwidth" @click="reQueueItem(item)">
<span class="icon-text is-block"> <span class="icon-text is-block">
<span class="icon"><i class="fa-solid fa-rotate-right" /></span> <span class="icon"><i class="fa-solid fa-rotate-right" /></span>
@ -512,6 +512,9 @@ const clearIncomplete = () => {
const setIcon = item => { const setIcon = item => {
if ('finished' === item.status) { if ('finished' === item.status) {
if (!item.filename) {
return 'fa-solid fa-exclamation'
}
return item.is_live ? 'fa-solid fa-globe' : 'fa-solid fa-circle-check' return item.is_live ? 'fa-solid fa-globe' : 'fa-solid fa-circle-check'
} }
@ -524,7 +527,7 @@ const setIcon = item => {
} }
if ('not_live' === item.status) { if ('not_live' === item.status) {
return 'fa-solid fa-hourglass-half fa-spin' return 'fa-solid fa-headset'
} }
return 'fa-solid fa-circle' return 'fa-solid fa-circle'
@ -532,6 +535,9 @@ const setIcon = item => {
const setIconColor = item => { const setIconColor = item => {
if ('finished' === item.status) { if ('finished' === item.status) {
if (!item.filename) {
return 'has-text-warning'
}
return 'has-text-success' return 'has-text-success'
} }
@ -548,6 +554,9 @@ const setIconColor = item => {
const setStatus = item => { const setStatus = item => {
if ('finished' === item.status) { if ('finished' === item.status) {
if (!item.filename) {
return 'Skipped?'
}
return item.is_live ? 'Live Ended' : 'Completed' return item.is_live ? 'Live Ended' : 'Completed'
} }

View file

@ -276,9 +276,8 @@ const hasFormatInConfig = computed(() => {
if (!ytdlp_cli.value) { if (!ytdlp_cli.value) {
return false return false
} }
if (ytdlp_cli.value.includes('-f') || ytdlp_cli.value.includes('--format')) {
return true return /(?<!\w)(-f|--format)(=|:)?(?!\w)/.test(ytdlp_cli.value)
}
}) })
const filter_presets = (flag = true) => config.presets.filter(item => item.default === flag) const filter_presets = (flag = true) => config.presets.filter(item => item.default === flag)

View file

@ -388,9 +388,8 @@ const hasFormatInConfig = computed(() => {
if (!form?.cli) { if (!form?.cli) {
return false return false
} }
if (form.cli.includes('-f') || form.cli.includes('--format')) {
return true return /(?<!\w)(-f|--format)(=|:)?(?!\w)/.test(form.cli)
}
}) })
const filter_presets = (flag = true) => config.presets.filter(item => item.default === flag) const filter_presets = (flag = true) => config.presets.filter(item => item.default === flag)