Make presets more powerful

This commit is contained in:
ArabCoders 2025-03-07 02:14:10 +03:00
parent 61fd6d45f6
commit cc21a93cb5
12 changed files with 439 additions and 235 deletions

View file

@ -14,7 +14,7 @@ from .config import Config
from .Emitter import Emitter from .Emitter import Emitter
from .ffprobe import ffprobe from .ffprobe import ffprobe
from .ItemDTO import ItemDTO from .ItemDTO import ItemDTO
from .Utils import get_opts, merge_config from .YTDLPOpts import YTDLPOpts
LOG = logging.getLogger("download") LOG = logging.getLogger("download")
@ -114,22 +114,30 @@ class Download:
def _download(self): def _download(self):
try: try:
params: dict = get_opts(self.preset, merge_config(self.default_ytdl_opts, self.ytdl_opts)) params = (
params.update( YTDLPOpts.get_instance()
{ .preset(self.preset)
"color": "no_color", .add(self.ytdl_opts, from_user=True)
"paths": {"home": self.download_dir, "temp": self.temp_path}, .add(
"outtmpl": {"default": self.template, "chapter": self.template_chapter}, {
"noprogress": True, "color": "no_color",
"break_on_existing": True, "paths": {"home": self.download_dir, "temp": self.temp_path},
"progress_hooks": [self._progress_hook], "outtmpl": {"default": self.template, "chapter": self.template_chapter},
"postprocessor_hooks": [self._postprocessor_hook], "noprogress": True,
"ignoreerrors": False, "break_on_existing": True,
} "ignoreerrors": False,
},
from_user=False,
)
.get_all()
) )
if "format" not in params and self.default_ytdl_opts.get("format", None): params.update(
params["format"] = "best" {
"progress_hooks": [self._progress_hook],
"postprocessor_hooks": [self._postprocessor_hook],
}
)
if self.debug: if self.debug:
params["verbose"] = True params["verbose"] = True
@ -137,7 +145,9 @@ class Download:
if self.info.cookies: if self.info.cookies:
try: try:
with open(os.path.join(self.temp_path, f"cookie_{self.info._id}.txt"), "w") as f: cookie_file = os.path.join(self.temp_path, f"cookie_{self.info._id}.txt")
LOG.debug(f"Creating cookie file for '{self.info.id}: {self.info.title}' - '{cookie_file}'.")
with open(cookie_file, "w") as f:
f.write(self.info.cookies) f.write(self.info.cookies)
params["cookiefile"] = f.name params["cookiefile"] = f.name
except ValueError as e: except ValueError as e:
@ -175,6 +185,7 @@ class Download:
self.status_queue.put({"id": self.id, "status": "finished" if ret == 0 else "error"}) self.status_queue.put({"id": self.id, "status": "finished" if ret == 0 else "error"})
except Exception as exc: except Exception as exc:
LOG.exception(exc)
self.status_queue.put({"id": self.id, "status": "error", "msg": str(exc), "error": str(exc)}) self.status_queue.put({"id": self.id, "status": "error", "msg": str(exc), "error": str(exc)})
LOG.info(f'Task id="{self.info.id}" PID="{os.getpid()}" title="{self.info.title}" completed.') LOG.info(f'Task id="{self.info.id}" PID="{os.getpid()}" title="{self.info.title}" completed.')

View file

@ -19,8 +19,10 @@ from .Download import Download
from .Emitter import Emitter from .Emitter import Emitter
from .EventsSubscriber import Events from .EventsSubscriber import Events
from .ItemDTO import ItemDTO from .ItemDTO import ItemDTO
from .Presets import Presets
from .Singleton import Singleton from .Singleton import Singleton
from .Utils import calc_download_path, extract_info, get_opts, is_downloaded, merge_config from .Utils import calc_download_path, extract_info, is_downloaded
from .YTDLPOpts import YTDLPOpts
LOG = logging.getLogger("DownloadQueue") LOG = logging.getLogger("DownloadQueue")
@ -352,9 +354,21 @@ class DownloadQueue(metaclass=Singleton):
template: str = "", template: str = "",
already=None, already=None,
): ):
_preset = Presets.get_instance().get(name=preset)
config = config if config else {} config = config if config else {}
folder = str(folder) if folder else "" folder = str(folder) if folder else ""
if _preset:
if _preset.folder and not folder:
folder = _preset.folder
if _preset.template and not template:
template = _preset.template
if _preset.cookies and not cookies:
cookies = _preset.cookies
filePath = calc_download_path(base_path=self.config.download_path, folder=folder) filePath = calc_download_path(base_path=self.config.download_path, folder=folder)
yt_conf = {} yt_conf = {}
cookie_file = os.path.join(self.config.temp_path, f"c_{uuid.uuid4().hex}.txt") cookie_file = os.path.join(self.config.temp_path, f"c_{uuid.uuid4().hex}.txt")
@ -395,7 +409,7 @@ class DownloadQueue(metaclass=Singleton):
"func": lambda _, msg: logs.append(msg), "func": lambda _, msg: logs.append(msg),
"level": logging.WARNING, "level": logging.WARNING,
}, },
**get_opts(preset, merge_config(self.config.ytdl_options, config)), **YTDLPOpts.get_instance().preset(name=preset).add(config=config, from_user=True).get_all(),
} }
if cookies: if cookies:

View file

@ -708,7 +708,7 @@ class HttpAPI(Common):
item["id"] = str(uuid.uuid4()) item["id"] = str(uuid.uuid4())
if not item.get("args", None) or str(item.get("args")).strip() == "": if not item.get("args", None) or str(item.get("args")).strip() == "":
item["config"] = {} item["args"] = {}
if item.get("args", None) and isinstance(item.get("args"), str): if item.get("args", None) and isinstance(item.get("args"), str):
item["args"] = json.loads(item.get("args")) item["args"] = json.loads(item.get("args"))

View file

@ -27,12 +27,21 @@ class Preset:
format: str format: str
"""The format of the preset.""" """The format of the preset."""
args: dict[str, list[str] | bool] | None = field(default_factory=dict) args: dict[str, list[str] | bool] = field(default_factory=dict)
"""The arguments of the preset.""" """The arguments of the preset."""
postprocessors: list | None = field(default_factory=list) postprocessors: list = field(default_factory=list)
"""The postprocessors of the preset.""" """The postprocessors of the preset."""
folder: str = ""
"""The default download folder to use if non is given."""
template: str = ""
"""The default template to use if non is given."""
cookies: str = ""
"""The default cookies to use if non is given."""
default: bool = False default: bool = False
def serialize(self) -> dict: def serialize(self) -> dict:

View file

@ -31,52 +31,7 @@ class StreamingError(Exception):
"""Raised when an error occurs during streaming.""" """Raised when an error occurs during streaming."""
def get_opts(preset: str, ytdl_opts: dict) -> dict: def get_video_info(url: str, ytdlp_opts: dict | None = None, no_archive: bool = True) -> Any | dict[str, Any] | None:
"""
Returns ytdlp options download options
Args:
preset (str): the name of the preset selected.
ytdl_opts (dict): current options selected
Returns:
ytdl extra options
"""
if "format" in ytdl_opts and len(ytdl_opts["format"]) > 2:
format = ytdl_opts["format"]
LOG.info(f"Format '{format}' was given via yt-dlp options. Therefore, the preset will be ignored.")
return ytdl_opts
opts = copy.deepcopy(ytdl_opts)
if "default" == preset:
LOG.debug("Using default preset.")
return opts
from .Presets import Presets
p = Presets.get_instance().get(name=preset)
if not p:
LOG.error(f"Preset '{preset}' is not defined as preset.")
return opts
opts["format"] = p.get("format")
postprocessors = p.get("postprocessors", [])
if isinstance(postprocessors, list) and len(postprocessors) > 0:
opts["postprocessors"] = postprocessors
args = p.get("args", {})
if isinstance(args, dict) and len(args) > 0:
for key, value in args.items():
opts[key] = value
LOG.debug(f"Using preset '{preset}', altered options: {opts}")
return opts
def get_video_info(url: str, ytdlp_opts: dict = None, no_archive: bool = True) -> Any | dict[str, Any] | None:
""" """
Extracts video information from the given URL. Extracts video information from the given URL.
@ -111,6 +66,11 @@ def calc_download_path(base_path: str, folder: str | None = None, create_path: b
""" """
Calculates download path and prevents folder traversal. Calculates download path and prevents folder traversal.
Args:
base_path (str): Base download path.
folder (str): Folder to add to the base path.
create_path (bool): Create the path if it does not exist.
Returns: Returns:
Download path with base folder factored in. Download path with base folder factored in.
@ -135,29 +95,33 @@ def calc_download_path(base_path: str, folder: str | None = None, create_path: b
def extract_info(config: dict, url: str, debug: bool = False) -> dict: def extract_info(config: dict, url: str, debug: bool = False) -> dict:
"""
Extracts video information from the given URL.
Args:
config (dict): Configuration options.
url (str): URL to extract information from.
debug (bool): Enable debug logging.
Returns:
dict: Video information.
"""
log_wrapper = LogWrapper() log_wrapper = LogWrapper()
params: dict = { params: dict = {
**config,
"color": "no_color", "color": "no_color",
"extract_flat": True, "extract_flat": True,
"skip_download": True, "skip_download": True,
"ignoreerrors": True, "ignoreerrors": True,
"ignore_no_formats_error": True, "ignore_no_formats_error": True,
**config,
} }
# Remove keys that are not needed for info extraction. # Remove keys that are not needed for info extraction.
keys: list = [ keys_to_remove = [key for key in params if str(key).startswith("write") or key in ["postprocessors"]]
"writeinfojson", for key in keys_to_remove:
"writethumbnail", params.pop(key, None)
"writedescription",
"writeautomaticsub",
"postprocessors",
]
for key in keys:
if key in params:
params.pop(key)
log_wrapper.add_target(target=logging.getLogger("yt-dlp"), level=logging.DEBUG if debug else logging.WARNING) log_wrapper.add_target(target=logging.getLogger("yt-dlp"), level=logging.DEBUG if debug else logging.WARNING)
if debug: if debug:
@ -166,7 +130,6 @@ def extract_info(config: dict, url: str, debug: bool = False) -> dict:
params["quiet"] = True params["quiet"] = True
if "callback" in params: if "callback" in params:
# callback can be a function or dict with {level: level, func: target}
if isinstance(params["callback"], dict): if isinstance(params["callback"], dict):
log_wrapper.add_target( log_wrapper.add_target(
target=params["callback"]["func"], target=params["callback"]["func"],
@ -186,47 +149,56 @@ def extract_info(config: dict, url: str, debug: bool = False) -> dict:
def merge_dict(source: dict, destination: dict) -> dict: def merge_dict(source: dict, destination: dict) -> dict:
"""Merge data from source into destination""" """
Merge data from source into destination safely.
Args:
source (dict): Source data
destination (dict): Destination data
Returns:
dict: The merged dictionary
"""
if not isinstance(source, dict) or not isinstance(destination, dict):
msg = "Both source and destination must be dictionaries."
raise TypeError(msg)
destination_copy = copy.deepcopy(destination) destination_copy = copy.deepcopy(destination)
for key, value in source.items(): for key, value in source.items():
destination_key_value = destination_copy.get(key) if key in {"__class__", "__dict__", "__globals__", "__builtins__"}:
if isinstance(value, dict) and isinstance(destination_key_value, dict): continue
destination_copy[key] = merge_dict(source=value, destination=destination_copy.setdefault(key, {}))
elif isinstance(value, list) and isinstance(destination_key_value, list): destination_value = destination_copy.get(key)
destination_copy[key] = destination_key_value + value
# Recursively merge dictionaries
if isinstance(value, dict) and isinstance(destination_value, dict):
destination_copy[key] = merge_dict(value, destination_value)
# Safely extend lists without reference issues
elif isinstance(value, list) and isinstance(destination_value, list):
destination_copy[key] = copy.deepcopy(destination_value) + copy.deepcopy(value)
else: else:
destination_copy[key] = value destination_copy[key] = copy.deepcopy(value)
return destination_copy return destination_copy
def merge_config(config: dict, new_config: dict) -> dict: def is_downloaded(archive_file: str, url: str) -> tuple[bool, dict[str | None, str | None, str | None]]:
""" """
Merge user provided config into default config Check if the video is already downloaded.
Args: Args:
config (dict): Default config archive_file (str): Archive file path.
new_config (dict): User provided config url (str): URL to check.
Returns: Returns:
dict: Merged config bool: True if the video is already downloaded.
dict: Video information.
""" """
for key in IGNORED_KEYS:
if key in new_config:
LOG.error(f"Key '{key}' is not allowed to be manually set via config.")
del new_config[key]
conf = merge_dict(new_config, config)
if "impersonate" in conf:
conf["impersonate"] = ImpersonateTarget.from_str(conf["impersonate"])
return conf
def is_downloaded(archive_file: str, url: str) -> tuple[bool, dict[str | None, str | None, str | None]]:
global YTDLP_INFO_CLS # noqa: PLW0603 global YTDLP_INFO_CLS # noqa: PLW0603
idDict = { idDict = {
@ -301,11 +273,7 @@ def load_file(file: str, check_type=None) -> tuple[dict | list, bool, str]:
if check_type: if check_type:
assert isinstance(opts, check_type) # noqa: S101 assert isinstance(opts, check_type) # noqa: S101
return ( return (opts, True, "")
opts,
True,
"",
)
except Exception: except Exception:
with open(file) as json_data: with open(file) as json_data:
from pyjson5 import load as json5_load from pyjson5 import load as json5_load
@ -316,23 +284,11 @@ def load_file(file: str, check_type=None) -> tuple[dict | list, bool, str]:
if check_type: if check_type:
assert isinstance(opts, check_type) # noqa: S101 assert isinstance(opts, check_type) # noqa: S101
return ( return (opts, True, "")
opts,
True,
"",
)
except AssertionError: except AssertionError:
return ( return ({}, False, f"Failed to assert that the contents '{type(opts)}' are of type '{check_type}'.")
{},
False,
f"Failed to assert that the contents '{type(opts)}' are of type '{check_type}'.",
)
except Exception as e: except Exception as e:
return ( return ({}, False, f"{e}")
{},
False,
f"{e}",
)
def check_id(file: pathlib.Path) -> bool | str: def check_id(file: pathlib.Path) -> bool | str:
@ -340,10 +296,12 @@ def check_id(file: pathlib.Path) -> bool | str:
Check if we are able to get an id from the file name. Check if we are able to get an id from the file name.
if so check if any video file with the same id exists. if so check if any video file with the same id exists.
:param basePath: Base path to strip. Args:
:param file: File to check. file (pathlib.Path): File to check.
Returns:
bool|str: False if no file found, else the file path.
:return: False if no id found, otherwise the id.
""" """
match = re.search(r"(?<=\[)(?:youtube-)?(?P<id>[a-zA-Z0-9\-_]{11})(?=\])", file.stem, re.IGNORECASE) match = re.search(r"(?<=\[)(?:youtube-)?(?P<id>[a-zA-Z0-9\-_]{11})(?=\])", file.stem, re.IGNORECASE)
if not match: if not match:
@ -371,14 +329,18 @@ def ag(array: dict | list, path: list[str | int] | str | int, default: Any = Non
""" """
dict/array getter: Retrieve a value from a nested dict or object using a path. dict/array getter: Retrieve a value from a nested dict or object using a path.
:param array_or_object: dict-like or object from which to retrieve values Args:
:param path: string, list, or None. Represents the path to retrieve: array (dict|list): dict-like or object from which to retrieve values.
- If None or empty string, returns the entire structure. path (list|str|int): Represents the path to retrieve:
- If list, tries each path and returns the first found. - If None or empty string, returns the entire structure.
- If string, navigates through nested dict keys separated by `separator`. - If list, tries each path and returns the first found.
:param default: Value (or callable) returned if nothing is found. - If string, navigates through nested dict keys separated by `separator`.
:param separator: Separator for nested paths in strings. default (Any): Value (or callable) returned if nothing is found.
:return: The found value or the default if not found. separator (str): Separator for nested paths in strings.
Returns:
Any: The found value or the default if not found.
""" """
if path is None or path == "": if path is None or path == "":
return array return array
@ -523,8 +485,12 @@ def get_sidecar_subtitles(file: pathlib.Path) -> list[dict]:
""" """
Get sidecar files for the given file. Get sidecar files for the given file.
:param file: File to get sidecar files for. Args:
:return: List of sidecar files. file (pathlib.Path): The video file.
Returns:
list: List of sidecar files.
""" """
files = [] files = []

102
app/library/YTDLPOpts.py Normal file
View file

@ -0,0 +1,102 @@
from pathlib import Path
from .config import Config
from .Presets import Presets
from .Singleton import Singleton
from .Utils import IGNORED_KEYS, calc_download_path, merge_dict
class YTDLPOpts(metaclass=Singleton):
item_opts: dict = {}
preset_opts: dict = {}
_instance = None
"""The instance of the class."""
def __init__(self):
self._config = Config.get_instance()
@staticmethod
def get_instance() -> "YTDLPOpts":
"""
Get the instance of the class.
Returns:
Presets: The instance of the class
"""
if not YTDLPOpts._instance:
YTDLPOpts._instance = YTDLPOpts()
return YTDLPOpts._instance
def add(self, config: dict, from_user: bool = False):
for key, value in config.items():
if key in IGNORED_KEYS and from_user:
continue
self.item_opts[key] = value
return self
def preset(self, name: str, with_cookies: bool = False) -> "YTDLPOpts":
preset = Presets.get_instance().get(name=name)
if not preset or "default" == name:
return self
if preset.cookies and with_cookies:
file = Path(self._config.config_path, "cookies", f"{preset.id}.txt")
if not file.parent.exists():
file.parent.mkdir(parents=True)
with open(file, "w") as f:
f.write(preset.cookies)
self.preset_opts["cookiefile"] = str(file)
if preset.format:
self.preset_opts["format"] = preset.format
if preset.template:
self.preset_opts["outtmpl"] = {"default": preset.template, "chapter": self._config.output_template_chapter}
if preset.folder:
self.preset_opts["paths"] = {
"home": calc_download_path(base_path=self._config.download_path, folder=preset.folder),
"temp": self._config.temp_path,
}
if preset.postprocessors and isinstance(preset.postprocessors, list) and len(preset.postprocessors) > 0:
self.preset_opts["postprocessors"] = preset.postprocessors
if preset.args and isinstance(preset.args, dict) and len(preset.args) > 0:
for key, value in preset.args.items():
if key in IGNORED_KEYS:
continue
self.preset_opts[key] = value
return self
def get_all(self, keep: bool = False) -> dict:
default_opts = self._config.ytdl_options
default_opts["paths"] = {"home": self._config.download_path, "temp": self._config.temp_path}
default_opts["outtmpl"] = {
"default": self._config.output_template,
"chapter": self._config.output_template_chapter,
}
if "format" in default_opts or "format" in self.item_opts:
return merge_dict(default_opts, self.item_opts)
data = merge_dict(merge_dict(self.preset_opts, default_opts), self.item_opts)
if not keep:
self.presets_opts = {}
self.item_opts = {}
if "impersonate" in data:
from yt_dlp.networking.impersonate import ImpersonateTarget
data["impersonate"] = ImpersonateTarget.from_str(data["impersonate"])
return data

View file

@ -3,12 +3,18 @@
"id": "3e163c6c-64eb-4448-924f-814b629b3810", "id": "3e163c6c-64eb-4448-924f-814b629b3810",
"name": "default", "name": "default",
"format": "default", "format": "default",
"folder": "",
"template": "",
"cookies": "",
"default": true "default": true
}, },
{ {
"id": "5bf9c42b-8852-468a-99f5-915622dfba25", "id": "5bf9c42b-8852-468a-99f5-915622dfba25",
"name": "Best video and audio", "name": "Best video and audio",
"format": "bv+ba/b", "format": "bv+ba/b",
"folder": "",
"template": "",
"cookies": "",
"default": true "default": true
}, },
{ {
@ -20,6 +26,9 @@
"vcodec:h264" "vcodec:h264"
] ]
}, },
"folder": "",
"template": "",
"cookies": "",
"default": true "default": true
}, },
{ {
@ -31,6 +40,9 @@
"vcodec:h264" "vcodec:h264"
] ]
}, },
"folder": "",
"template": "",
"cookies": "",
"default": true "default": true
}, },
{ {
@ -63,6 +75,9 @@
"when": "playlist" "when": "playlist"
} }
], ],
"folder": "",
"template": "",
"cookies": "",
"default": true "default": true
} }
] ]

View file

@ -221,7 +221,8 @@ hr {
padding-top: 0.5em; padding-top: 0.5em;
} }
.play-overlay, .is-pointer { .play-overlay,
.is-pointer {
cursor: pointer; cursor: pointer;
} }
@ -254,3 +255,7 @@ hr {
object-fit: cover; object-fit: cover;
object-position: top; object-position: top;
} }
.is-pre {
white-space: pre;
}

View file

@ -107,8 +107,8 @@
Cookies Cookies
</label> </label>
<div class="control"> <div class="control">
<textarea class="textarea" id="ytdlpCookies" v-model="ytdlpCookies" <textarea class="textarea is-pre" id="ytdlpCookies" v-model="ytdlpCookies"
:disabled="!socket.isConnected || addInProgress"></textarea> :disabled="!socket.isConnected || addInProgress" />
</div> </div>
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>

View file

@ -75,100 +75,162 @@
<div class="column is-12"> <div class="column is-12">
<form id="presetForm" @submit.prevent="checkInfo()"> <form id="presetForm" @submit.prevent="checkInfo()">
<div class="box"> <div class="card">
<div class="columns is-multiline is-mobile"> <div class="card-header">
<div class="column is-12"> <div class="card-header-title">
<h1 class="title is-6" style="border-bottom: 1px solid #dbdbdb;"> <span class="icon-text">
<span class="icon-text"> <span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span>
<span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span> <span>{{ reference ? 'Edit' : 'Add' }}</span>
<span>{{ reference ? 'Edit' : 'Add' }}</span> </span>
</span>
</h1>
</div> </div>
</div>
<div class="column is-6-tablet is-12-mobile"> <div class="card-content">
<div class="field"> <div class="columns is-multiline is-mobile">
<label class="label is-inline" for="name" v-text="'Name'" /> <div class="column is-6-tablet is-12-mobile">
<div class="control has-icons-left"> <div class="field">
<input type="text" class="input" id="name" v-model="form.name" :disabled="addInProgress"> <label class="label is-inline" for="name" v-text="'Name'" />
<span class="icon is-small is-left"><i class="fa-solid fa-n" /></span> <div class="control has-icons-left">
</div> <input type="text" class="input" id="name" v-model="form.name" :disabled="addInProgress">
<span class="help"> <span class="icon is-small is-left"><i class="fa-solid fa-n" /></span>
<span class="icon"><i class="fa-solid fa-info" /></span> </div>
<span>The name to refers to this custom settings.</span> <span class="help">
</span> <span class="icon"><i class="fa-solid fa-info" /></span>
</div> <span>The name to refers to this custom settings.</span>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="format" v-text="'Format'" />
<div class="control has-icons-left">
<input type="text" class="input" id="format" v-model="form.format" :disabled="addInProgress">
<span class="icon is-small is-left"><i class="fa-solid fa-f" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The yt-dlp <code>[--format, -f]</code> video format code. see <NuxtLink
href="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#format-selection" target="blank">this
url</NuxtLink> for more info.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="args" v-tooltip="'Extends current global yt-dlp config. (JSON)'">
JSON yt-dlp config
</label>
<div class="control">
<textarea class="textarea" id="args" v-model="form.args" :disabled="addInProgress" placeholder="{}" />
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Extends current global yt-dlp config with given options. Some fields are ignored like
<code>cookiefile</code>, <code>paths</code>, and <code>outtmpl</code> etc. Warning: Use with caution
some of those options can break yt-dlp or the frontend.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="postprocessors" v-tooltip="'Things to do after download is done.'">
JSON yt-dlp Post-Processors
</label>
<div class="control">
<textarea class="textarea" id="postprocessors" v-model="form.postprocessors"
:disabled="addInProgress" />
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>
Post-processing operations, refer to <NuxtLink
href="https://github.com/yt-dlp/yt-dlp/tree/master/yt_dlp/postprocessor" target="blank">this url
</NuxtLink> for more info. It's easier for you to use the <b>Convert CLI options</b> to get what you
want and it will auto-populate the fields if necessary.
</span> </span>
</span> </div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="format" v-text="'Format'" />
<div class="control has-icons-left">
<input type="text" class="input" id="format" v-model="form.format" :disabled="addInProgress">
<span class="icon is-small is-left"><i class="fa-solid fa-f" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The yt-dlp <code>[--format, -f]</code> video format code. see <NuxtLink
href="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#format-selection" target="blank">this
url</NuxtLink> for more info.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="folder">
Default Download path
</label>
<div class="control has-icons-left">
<input type="text" class="input" id="folder" placeholder="Leave empty to use default download path"
v-model="form.folder" :disabled="addInProgress" list="folders">
<span class="icon is-small is-left"><i class="fa-solid fa-folder" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Use this folder if non is given with URL. Leave empty to use default download path. Default
download path <code>{{ config.app.download_path }}</code>.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="output_template">
Default Output template
</label>
<div class="control has-icons-left">
<input type="text" class="input" id="output_template" :disabled="addInProgress"
placeholder="Leave empty to use default template." v-model="form.template">
<span class="icon is-small is-left"><i class="fa-solid fa-file" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Use this output template if non are given with URL. if not set, it will defaults to
<code>{{ config.app.output_template }}</code>.
For more information <NuxtLink href="https://github.com/yt-dlp/yt-dlp#output-template"
target="_blank">visit this url</NuxtLink>.
</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="args" v-tooltip="'Extends current global yt-dlp config. (JSON)'">
JSON yt-dlp config
</label>
<div class="control">
<textarea class="textarea" id="args" v-model="form.args" :disabled="addInProgress"
placeholder="{}" />
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Extends current global yt-dlp config with given options. Some fields are ignored like
<code>cookiefile</code>, <code>paths</code>, and <code>outtmpl</code> etc. Warning: Use with
caution
some of those options can break yt-dlp or the frontend.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="postprocessors"
v-tooltip="'Things to do after download is done.'">
JSON yt-dlp Post-Processors
</label>
<div class="control">
<textarea class="textarea" id="postprocessors" v-model="form.postprocessors"
:disabled="addInProgress" placeholder="[]" />
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>
Post-processing operations, refer to <NuxtLink
href="https://github.com/yt-dlp/yt-dlp/tree/master/yt_dlp/postprocessor" target="blank">this url
</NuxtLink> for more info. It's easier for you to use the <b>Convert CLI options</b> to get what
you
want and it will auto-populate the fields if necessary.
</span>
</span>
</div>
</div>
<div class="column is-12">
<div class="field">
<label class="label is-inline" for="cookies"
v-tooltip="'Netscape HTTP Cookie format.'">Cookies</label>
<div class="control">
<textarea class="textarea is-pre" id="cookies" v-model="form.cookies" :disabled="addInProgress"
placeholder="Leave empty to use default cookies" />
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Use this cookies if non are given with the URL. Use the <NuxtLink target="_blank"
to="https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp">
Recommended addon</NuxtLink> by yt-dlp to export cookies. The cookies MUST be in Netscape HTTP
Cookie format.
</span>
</span>
</div>
</div> </div>
</div> </div>
<div class="column is-12"> <div class="card-footer">
<div class="field is-grouped is-grouped-right"> <div class="card-footer-item">
<p class="control"> <button class="button is-fullwidth is-primary" :disabled="addInProgress" type="submit"
<button class="button is-primary" :disabled="addInProgress" type="submit" :class="{ 'is-loading': addInProgress }" form="presetForm">
:class="{ 'is-loading': addInProgress }" form="presetForm"> <span class="icon"><i class="fa-solid fa-save" /></span>
<span class="icon"><i class="fa-solid fa-save" /></span> <span>Save</span>
<span>Save</span> </button>
</button> </div>
</p> <div class="card-footer-item">
<p class="control"> <button class="button is-fullwidth is-danger" @click="emitter('cancel')" :disabled="addInProgress"
<button class="button is-danger" @click="emitter('cancel')" :disabled="addInProgress" type="button"> type="button">
<span class="icon"><i class="fa-solid fa-times" /></span> <span class="icon"><i class="fa-solid fa-times" /></span>
<span>Cancel</span> <span>Cancel</span>
</button> </button>
</p>
</div> </div>
</div> </div>
@ -176,6 +238,9 @@
</div> </div>
</form> </form>
</div> </div>
<datalist id="folders" v-if="config?.folders">
<option v-for="dir in config.folders" :key="dir" :value="dir" />
</datalist>
</main> </main>
</template> </template>
@ -204,6 +269,7 @@ const props = defineProps({
}, },
}) })
const config = useConfigStore()
const toast = useToast() const toast = useToast()
const convertInProgress = ref(false) const convertInProgress = ref(false)
const form = reactive(JSON.parse(JSON.stringify(props.preset))) const form = reactive(JSON.parse(JSON.stringify(props.preset)))
@ -252,7 +318,7 @@ const checkInfo = async () => {
} }
if (typeof copy.args === 'object') { if (typeof copy.args === 'object') {
copy.config = JSON.stringify(copy.args, null, 2); copy.args = JSON.stringify(copy.args, null, 2);
} }
if (typeof copy.postprocessors === 'object') { if (typeof copy.postprocessors === 'object') {
@ -285,7 +351,7 @@ const convertOptions = async () => {
return return
} }
if (form.format || form.args || form.postprocessors) { if (form.format || form.args || form.postprocessors || form.template || form.folder) {
if (false === confirm('This will overwrite the current form fields. Are you sure?')) { if (false === confirm('This will overwrite the current form fields. Are you sure?')) {
return return
} }
@ -305,6 +371,14 @@ const convertOptions = async () => {
delete response.opts.format delete response.opts.format
} }
if (response.output_template) {
form.template = response.output_template
}
if (response.download_path) {
form.folder = response.download_path
}
if (response.opts.postprocessors) { if (response.opts.postprocessors) {
form.postprocessors = JSON.stringify(response.opts.postprocessors, null, 2) form.postprocessors = JSON.stringify(response.opts.postprocessors, null, 2)
delete response.opts.postprocessors delete response.opts.postprocessors
@ -349,6 +423,14 @@ const importPreset = async () => {
form.postprocessors = JSON.stringify(preset.postprocessors, null, 2) form.postprocessors = JSON.stringify(preset.postprocessors, null, 2)
} }
if (preset.output_template) {
form.template = response.output_template
}
if (preset.folder) {
form.folder = response.folder
}
json_preset.value = '' json_preset.value = ''
} catch (e) { } catch (e) {

View file

@ -149,7 +149,7 @@
<div class="field"> <div class="field">
<label class="label is-inline" for="cookies" v-tooltip="'Netscape HTTP Cookie format.'">Cookies</label> <label class="label is-inline" for="cookies" v-tooltip="'Netscape HTTP Cookie format.'">Cookies</label>
<div class="control"> <div class="control">
<textarea class="textarea" id="cookies" v-model="form.cookies" :disabled="addInProgress"></textarea> <textarea class="textarea is-pre" id="cookies" v-model="form.cookies" :disabled="addInProgress" />
</div> </div>
<span class="help"> <span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span> <span class="icon"><i class="fa-solid fa-info" /></span>

View file

@ -252,7 +252,7 @@ onMounted(async () => socket.isConnected ? await reloadContent(true) : '')
const copyItem = item => { const copyItem = item => {
let data = JSON.parse(JSON.stringify(item)) let data = JSON.parse(JSON.stringify(item))
const keys = ['id', 'default', 'raw'] const keys = ['id', 'default', 'raw', 'cookies']
keys.forEach(key => { keys.forEach(key => {
if (key in data) { if (key in data) {
delete data[key] delete data[key]