Merge pull request #243 from arabcoders/dev

deprecate JSON yt-dlp config, and encourage cli options.
This commit is contained in:
Abdulmohsen 2025-03-31 23:26:01 +03:00 committed by GitHub
commit 6cf0b9120e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 727 additions and 572 deletions

View file

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

View file

@ -35,7 +35,7 @@ ENV XDG_CONFIG_HOME=/config
ENV XDG_CACHE_HOME=/tmp
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 && \
rm -rf /var/cache/apk/*

View file

@ -13,7 +13,7 @@ Web GUI for [yt-dlp](https://github.com/yt-dlp/yt-dlp) with playlist & channel s
* Can Handle live streams.
* Scheduler to queue channels or playlists to be downloaded automatically at a specified time.
* Send notification to targets based on selected events.
* Support per link `yt-dlp JSON config or cli options`, `cookies` & `output format`.
* Support per link `cli options` & `cookies`
* Queue multiple URLs separated by comma.
* Simple file browser. `Disabled by default`
* A built in video player that can play any video file regardless of the format. **With support for sidecar external subtitles**.
@ -25,22 +25,47 @@ Web GUI for [yt-dlp](https://github.com/yt-dlp/yt-dlp) with playlist & channel s
* Support for curl_cffi, see [yt-dlp documentation](https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#impersonation)
* Support for both advanced and basic mode for WebUI.
* Bundled tools in container: curl-cffi, ffmpeg, ffprobe, aria2, rtmpdump, mkvtoolsnix, mp4box.
# Recommended basic `ytdlp.json` file settings
Your `/config/ytdlp.json` config should include the following basic options for optimal working conditions.
# Breaking changes
```json
{
"windowsfilenames": true,
"continue_dl": true,
"live_from_start": true,
"format_sort": [ "codec:avc:m4a" ],
}
```
Starting with versions tagged `*-20250330-*` I have deprecated the JSON yt-dlp config and all of it's related settings.
> [!NOTE]
> Note, the `format_sort`, forces YouTube to use x264 instead of vp9 codec, you can ignore it if you want. i prefer the media in x264.
To be frank, it was pain to manage and hard for users to understand how to map cli options to json options. So, I have
decided to just use the cli options directly. This means that you can now use the same options you would use in the command line
directly in the WebUI, presets, and tasks.
## `ytdlp.json` vs `ytdlp.cli`
I have also added a new `ytdlp.cli` file that will be used to store the global options for yt-dlp. This file is located in the `/config` directory
and will be used to store the global options for yt-dlp. This file is not required and presets can fill the gap for most of the
use cases. But, if you want to use the same options for all your downloads, you can use this file to store the global options.
So, if you have `ytdlp.cli` file it will take priority over the `ytdlp.json` file if both exists. As mitigation, I have implemented
fallback to `ytdlp.json` file if `ytdlp.cli` file is not found.
## Presets
I have deprecated the `JSON yt-dlp config` and `JSON yt-dlp Post-Processors` fields, and added new `Command arguments for yt-dlp`
field to the presets. This field will be used to store the command line options for yt-dlp. I have also have added fallback,
if you have the `JSON yt-dlp config/Post-Processors` field set, the logic is, if `Command arguments for yt-dlp` is set
it will take priority over the `JSON yt-dlp config/Post-Processors` field. If not set, it will fallback to the `JSON yt-dlp config/Post-Processors` field.
If you are adding new presets, the deprecated fields will not show up anymore, they will only show up if actually have content in them
and editing old preset. So, Please migrate your presets to the new format.
## Tasks
I have also removed the `JSON yt-dlp config` and replaced it with `Command arguments for yt-dlp` field. Sadly, there is
no fallback, and once you upgrade to any version after `*-20250330-*` your `tasks.json` file will be updated to remove the
`config` key. so, please make sure to backup your `tasks.json` file before upgrading.
## closing statement
I know it's a painful breaking change, but for the sake of maintainability and ease of use, I have decided to
to make it happen sooner than later, the `JSON yt-dlp config`, was hard to manage and some features weren't really working
as expected, for example `--match-filter` and `--date` arguments etc.
So, Starting with `*-2025040*-*` tagged versions, the all the fallbacks and backwards compatibility will be removed.
# Run using docker command
@ -240,25 +265,19 @@ A Docker image can be built locally (it will build the UI too):
docker build . -t ytptube
```
# ytdlp.json file
# ytdlp.cli file
The `config/ytdlp.json`, is a json file which can be used to alter the default `yt-dlp` config settings globally.
The `config/ytdlp.cli`, is a command line options file for `yt-dlp` it will be globally applied to all downloads.
We recommend not use this file for options that aren't **truly global**, everything that can be done via the `ytdlp.json` file
can be done via a preset, which only effects the download that uses it. Example of good basic `ytdlp.json` file.
We strongly recommend not use this file for options that aren't **truly global**, everything that can be done via the file
can also be done via the presets which is dynamic can be altered per download. Example of good global options are to be
used for all downloads are:
```json
{
"windowsfilenames": true,
"continue_dl": true,
"live_from_start": true,
"format_sort": [ "codec:avc:m4a" ],
}
```bash
--continue --windows-filenames --live-from-start
```
Everything else can be done via the presets, and it's more flexible and easier to manage. You can convert your
own yt-dlp command arguments into a preset using the box found in the presets add page. For reference, The options can be found
at [yt-dlp YoutubeDL.py](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/YoutubeDL.py#L214) file. And for the postprocessors at [yt-dlp postprocessor](https://github.com/yt-dlp/yt-dlp/tree/master/yt_dlp/postprocessor).
Everything else can be done via the presets, and it's more flexible and easier to manage.
# Authentication
@ -275,7 +294,7 @@ What does the basic mode do? it hides the the following features from the WebUI.
### Header
It disables everything except the `theme switcher` and `reload` button.
It disables everything except the `settings button` and `reload` button.
### Add form

View file

@ -135,6 +135,12 @@ class Download:
self.status_queue.put({"id": self.id, "status": "finished", "filename": filename})
def post_hooks(self, filename: str | None = None):
if not filename:
return
self.status_queue.put({"id": self.id, "filename": filename})
def _download(self):
try:
params = (
@ -178,6 +184,7 @@ class Download:
{
"progress_hooks": [self._progress_hook],
"postprocessor_hooks": [self._postprocessor_hook],
"post_hooks": [self.post_hooks],
}
)
@ -215,7 +222,7 @@ class Download:
f'Task id="{self.info.id}" PID="{os.getpid()}" title="{self.info.title}" preset="{self.preset}" started.'
)
self.logger.debug("Params before passing to yt-dlp.", extra=params)
self.logger.debug(f"Params before passing to yt-dlp. {params}")
params["logger"] = NestedLogger(self.logger)

View file

@ -21,7 +21,7 @@ from .Events import EventBus, Events
from .ItemDTO import ItemDTO
from .Presets import Presets
from .Singleton import Singleton
from .Utils import calc_download_path, extract_info, is_downloaded
from .Utils import arg_converter, calc_download_path, extract_info, is_downloaded
from .YTDLPOpts import YTDLPOpts
LOG = logging.getLogger("DownloadQueue")
@ -177,6 +177,7 @@ class DownloadQueue(metaclass=Singleton):
cookies: str = "",
template: str = "",
extras: dict | None = None,
cli: str = "",
already=None,
):
"""
@ -190,9 +191,9 @@ class DownloadQueue(metaclass=Singleton):
cookies (str): The cookies to use for the download.
template (str): The output template to use for the download.
extras (dict): The extra information to add to the download.
cli (str): The yt-dlp command line options to use for the download.
already (set): The set of already downloaded items.
Returns:
dict: The status of the operation.
@ -244,6 +245,7 @@ class DownloadQueue(metaclass=Singleton):
cookies=cookies,
template=template,
extras=extras,
cli=cli,
already=already,
)
)
@ -320,6 +322,7 @@ class DownloadQueue(metaclass=Singleton):
is_live=is_live,
live_in=live_in,
options=options,
cli=cli,
extras=extras,
)
@ -352,6 +355,7 @@ class DownloadQueue(metaclass=Singleton):
cookies=cookies,
template=template,
extras=extras,
cli=cli,
already=already,
)
@ -365,12 +369,39 @@ class DownloadQueue(metaclass=Singleton):
config: dict | None = None,
cookies: str = "",
template: str = "",
cli: str = "",
extras: dict | None = None,
already=None,
):
"""
Add an item to the download queue.
Args:
url (str): The url to be added to the queue.
preset (str): The preset to be used for the download.
folder (str): The folder to save the download to.
config (dict): The yt-dlp config to be used for the download.
cookies (str): The cookies to be used for the download.
template (str): The template to be used for the download.
cli (str): The yt-dlp cli options to be used for the download.
extras (dict): Extra data to be added to the download
already (set): Set of already downloaded items.
Returns:
dict[str, str]: The status of the download.
{ "status": "text" }
"""
_preset = Presets.get_instance().get(name=preset)
config = config if config else {}
if cli:
try:
config = arg_converter(args=cli, level=True)
except Exception as e:
LOG.error(f"Invalid cli options '{cli}'. {e!s}")
return {"status": "error", "msg": f"Invalid cli options '{cli}'. {e!s}"}
folder = str(folder) if folder else ""
if not extras:
extras = {}
@ -390,7 +421,7 @@ class DownloadQueue(metaclass=Singleton):
cookie_file = os.path.join(self.config.temp_path, f"c_{uuid.uuid4().hex}.txt")
LOG.info(
f"Adding 'URL: {url}' to 'Folder: {filePath}' with 'Preset: {preset}' 'Naming: {template}', 'Cookies: {len(cookies)}/chars' 'YTConfig: {config}' 'Extras: {extras}'."
f"Adding 'URL: {url}' to 'Folder: {filePath}' with 'Preset: {preset}' 'Naming: {template}', 'Cookies: {len(cookies)}/chars' 'CLI: {cli}' 'Extras: {extras}'."
)
if isinstance(config, str):
@ -486,6 +517,7 @@ class DownloadQueue(metaclass=Singleton):
template=template,
already=already,
extras=extras,
cli=cli,
)
async def cancel(self, ids: list[str]) -> dict[str, str]:

View file

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

View file

@ -35,7 +35,7 @@ from .Segments import Segments
from .Subtitle import Subtitle
from .Tasks import Task, Tasks
from .Utils import (
IGNORED_KEYS,
REMOVE_KEYS,
StreamingError,
arg_converter,
decrypt_data,
@ -445,7 +445,7 @@ class HttpAPI(Common):
try:
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"]:
response["output_template"] = data["outtmpl"]["default"]
@ -453,20 +453,29 @@ class HttpAPI(Common):
if "paths" in data and "home" in data["paths"]:
response["download_path"] = data["paths"]["home"]
if "format" in data:
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:
if key in IGNORED_KEYS:
if key in bad_options.items():
removed_options.append(bad_options[key])
continue
if not key.startswith("_"):
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)
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}'.")
LOG.exception(e)
err = err.replace("main.py: error: ", "").strip().capitalize()
return web.json_response(
data={"error": f"Failed to convert args. '{err}'."}, status=web.HTTPBadRequest.status_code
data={"error": f"Failed to command line arguments for yt-dlp. '{err}'."}, status=web.HTTPBadRequest.status_code
)
@route("GET", "api/yt-dlp/url/info")
@ -872,15 +881,12 @@ class HttpAPI(Common):
if not item.get("timer", None) or str(item.get("timer")).strip() == "":
item["timer"] = f"{random.randint(1,59)} */1 * * *" # noqa: S311
if not item.get("cookies", None):
item["cookies"] = ""
if not item.get("config", None) or str(item.get("config")).strip() == "":
item["config"] = {}
if not item.get("template", None):
item["template"] = ""
if not item.get("cli", None):
item["cli"] = ""
try:
ins.validate(item)
except ValueError as e:

View file

@ -18,7 +18,7 @@ from .DownloadQueue import DownloadQueue
from .encoder import Encoder
from .Events import Event, EventBus, Events, error
from .Presets import Presets
from .Utils import arg_converter, is_downloaded
from .Utils import is_downloaded
LOG = logging.getLogger("socket_api")
@ -285,25 +285,3 @@ class HttpSocket(Common):
async def resume(self, *_, **__):
self.queue.resume()
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

@ -27,7 +27,6 @@ class ItemDTO:
temp_dir: str | None = None
status: str | None = None
cookies: str | None = None
config: dict = field(default_factory=dict)
template: str | None = None
template_chapter: str | None = None
timestamp: float = field(default_factory=lambda: time.time_ns())
@ -37,6 +36,7 @@ class ItemDTO:
file_size: int | None = None
options: dict = field(default_factory=dict)
extras: dict = field(default_factory=dict)
cli: str = ""
# yt-dlp injected fields.
tmpfilename: str | None = None
@ -49,12 +49,13 @@ class ItemDTO:
speed: str | None = None
eta: str | None = None
# DEPRECATED: These fields are deprecated and will be removed in the future.
# @Deprecated: These fields are deprecated and will be removed in the future.
thumbnail: str | None = None
ytdlp_cookies: str | None = None
ytdlp_config: dict = field(default_factory=dict)
output_template: str | None = None
output_template_chapter: str | None = None
config: dict = field(default_factory=dict)
def serialize(self) -> dict:
deprecated: tuple = (
@ -65,6 +66,7 @@ class ItemDTO:
"ytdlp_config",
"output_template",
"output_template_chapter",
"config",
)
if self.thumbnail and "thumbnail" not in self.extras:

View file

@ -41,6 +41,9 @@ class Preset:
cookies: str = ""
"""The default cookies to use if non is given."""
cli: str = ""
"""yt-dlp cli command line arguments."""
default: bool = False
def serialize(self) -> dict:
@ -220,6 +223,15 @@ class Presets(metaclass=Singleton):
msg = "Invalid postprocessors type. expected list."
raise ValueError(msg)
if preset.get("cli"):
try:
from .Utils import arg_converter
arg_converter(args=preset.get("cli"))
except Exception as e:
msg = f"Invalid cli options. '{e!s}'."
raise ValueError(msg) from e
return True
def save(self, presets: list[Preset | dict]) -> "Presets":

View file

@ -3,7 +3,7 @@ import json
import logging
import os
import time
from dataclasses import dataclass, field
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import Any
@ -28,8 +28,7 @@ class Task:
preset: str = ""
timer: str = ""
template: str = ""
cookies: str = ""
config: dict[str, str] = field(default_factory=dict)
cli: str = ""
def serialize(self) -> dict:
return self.__dict__
@ -134,16 +133,20 @@ class Tasks(metaclass=Singleton):
with open(self._file) as f:
tasks = json.load(f)
except Exception as e:
LOG.error(f"Failed to parse tasks from '{self._file}'. '{e}'.")
LOG.error(f"Failed to parse tasks from '{self._file}'. '{e!s}'.")
return self
if not tasks or len(tasks) < 1:
LOG.info(f"No tasks were defined in '{self._file}'.")
return self
need_update = False
for i, task in enumerate(tasks):
try:
task, task_status = self.clean_task(task)
task = Task(**task)
if task_status:
need_update = True
except Exception as e:
LOG.error(f"Failed to parse task at list position '{i}'. '{e!s}'.")
continue
@ -157,6 +160,10 @@ class Tasks(metaclass=Singleton):
LOG.exception(e)
LOG.error(f"Failed to queue task '{i}: {task.name}'. '{e!s}'.")
if need_update:
LOG.info("Updating tasks file to remove old keys.")
self.save(self.get_all())
return self
def clear(self, shutdown: bool = False) -> "Tasks":
@ -213,17 +220,13 @@ class Tasks(metaclass=Singleton):
msg = "No URL found."
raise ValueError(msg)
if not isinstance(task.get("cookies"), str):
msg = "Invalid cookies type."
raise ValueError(msg) # noqa: TRY004
if not isinstance(task.get("config"), dict):
msg = "Invalid config type."
raise ValueError(msg) # noqa: TRY004
if not isinstance(task.get("template"), str):
msg = "Invalid template type."
raise ValueError(msg) # noqa: TRY004
if task.get("cli"):
try:
from .Utils import arg_converter
arg_converter(args=task.get("cli"))
except Exception as e:
msg = f"Invalid cli options. '{e!s}'."
raise ValueError(msg) from e
return True
@ -263,6 +266,27 @@ class Tasks(metaclass=Singleton):
return self
def clean_task(self, task: dict) -> tuple[dict, bool]:
"""
Clean the task from old keys.
Args:
task (dict): The task to clean.
Returns:
tuple[dict, bool]: The cleaned task and a status if the task was cleaned.
"""
status = False
removedKeys = ["cookies", "config"]
for key in removedKeys:
if key in task:
status = True
task.pop(key)
return task, status
async def _runner(self, task: Task):
"""
Run the task.
@ -283,16 +307,8 @@ class Tasks(metaclass=Singleton):
preset: str = str(task.preset or self._default_preset)
folder: str = task.folder if task.folder else ""
cookies: str = str(task.cookies) if task.cookies else ""
template: str = task.template if task.template else ""
config = task.config if task.config else {}
if isinstance(config, str) and config:
try:
config = json.loads(config)
except Exception as e:
LOG.error(f"Failed to parse json yt-dlp config for '{task.name}'. {e!s}")
return
cli: str = task.cli if task.cli else ""
LOG.info(f"Task '{task.id}: {task.name}' dispatched at '{timeNow}'.")
@ -307,9 +323,8 @@ class Tasks(metaclass=Singleton):
"url": task.url,
"preset": preset,
"folder": folder,
"cookies": cookies,
"config": config,
"template": template,
"cli": cli,
},
id=task.id,
),

View file

@ -21,7 +21,42 @@ from .LogWrapper import LogWrapper
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",
"post_hooks": "--post_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
ALLOWED_SUBS_EXTENSIONS: tuple[str] = (".srt", ".vtt", ".ass")
@ -434,12 +469,20 @@ def validate_url(url: str) -> bool:
return True
def arg_converter(args: str) -> 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.
Args:
args (str): yt-dlp options string.
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:
dict: yt-dlp options dictionary.
@ -465,16 +508,44 @@ def arg_converter(args: str) -> dict:
if "postprocessors" in diff:
diff["postprocessors"] = [pp for pp in diff["postprocessors"] if pp not in default_opts["postprocessors"]]
if "match_filter" in diff:
import inspect
if "_warnings" in diff:
diff.pop("_warnings", None)
matchFilter = inspect.getclosurevars(diff["match_filter"].func).nonlocals["filters"]
if isinstance(matchFilter, set):
diff["match_filter"] = {"filters": list(matchFilter)}
if level is True or isinstance(level, int):
bad_options = {}
if isinstance(level, bool) or not isinstance(level, int):
level = len(REMOVE_KEYS)
from .encoder import Encoder
for i, item in enumerate(REMOVE_KEYS):
if i > level:
break
return json.loads(json.dumps(diff, cls=Encoder))
bad_options.update(item.items())
LOG.debug("Removed %i the following options: '%s'.", level, ", ".join(bad_options.values()))
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
if "match_filter" in diff:
import inspect
matchFilter = inspect.getclosurevars(diff["match_filter"].func).nonlocals["filters"]
if isinstance(matchFilter, set):
diff["match_filter"] = {"filters": list(matchFilter)}
return json.loads(json.dumps(diff, cls=Encoder))
return diff
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 .Presets import Presets
from .Singleton import Singleton
from .Utils import IGNORED_KEYS, calc_download_path, merge_dict
from .Utils import REMOVE_KEYS, arg_converter, calc_download_path, merge_dict
LOG = logging.getLogger("YTDLPOpts")
@ -48,11 +48,22 @@ class YTDLPOpts(metaclass=Singleton):
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():
if key in IGNORED_KEYS and from_user:
if from_user and key in bad_options:
removed_options.append(bad_options[key])
continue
self._item_opts[key] = value
if len(removed_options) > 0:
LOG.warning("Removed the following options: '%s'.", ", ".join(removed_options))
return self
def preset(self, name: str, with_cookies: bool = False) -> "YTDLPOpts":
@ -71,6 +82,19 @@ class YTDLPOpts(metaclass=Singleton):
if not preset or "default" == name:
return self
if preset.cli:
try:
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:
msg = f"Invalid cli options for preset '{preset.name}'. '{e!s}'."
raise ValueError(msg) from e
if preset.cookies and with_cookies:
file = Path(self._config.config_path, "cookies", f"{preset.id}.txt")
@ -94,14 +118,24 @@ class YTDLPOpts(metaclass=Singleton):
"temp": self._config.temp_path,
}
if preset.postprocessors and isinstance(preset.postprocessors, list) and len(preset.postprocessors) > 0:
self._preset_opts["postprocessors"] = preset.postprocessors
# @Deprecated - To be removed in future versions.
if not preset.cli:
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
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():
if key in bad_options:
removed_options.append(bad_options[key])
continue
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
@ -132,6 +166,7 @@ class YTDLPOpts(metaclass=Singleton):
if "format" in data and data["format"] in ["not_set", "default"]:
data["format"] = None
# @Deprecated - To be removed in future versions, All the checks below this line are deprecated.
if "daterange" in data and isinstance(data["daterange"], dict):
from yt_dlp.utils import DateRange

View file

@ -4,6 +4,7 @@ import logging
from .config import Config
from .DownloadQueue import DownloadQueue
from .encoder import Encoder
from .Utils import arg_converter
LOG = logging.getLogger("common")
@ -27,7 +28,16 @@ class Common:
self.default_preset = config.default_preset
async def add(
self, url: str, preset: str, folder: str, cookies: str, config: dict, template: str, extras: dict | None = None
self,
url: str,
preset: str,
folder: str,
cookies: str,
# @deprecated: config: dict,
config: dict,
template: str,
extras: dict | None = None,
cli: str = "",
) -> dict[str, str]:
"""
Add an item to the download queue.
@ -40,6 +50,7 @@ class Common:
config (dict): The yt-dlp config to be used for the download.
template (str): The template to be used for the download.
extras (dict): Extra data to be added to the download
cli (str): The yt-dlp cli options to be used for the download.
Returns:
dict[str, str]: The status of the download.
@ -56,6 +67,7 @@ class Common:
cookies=cookies,
config=config if isinstance(config, dict) else {},
template=template,
cli=cli,
extras=extras,
)
@ -94,6 +106,19 @@ class Common:
msg = f"Failed to parse json yt-dlp config for '{url}'. {e!s}"
raise ValueError(msg) from e
cli = item.get("cli")
if cli and len(cli) > 2:
try:
removed_options = []
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))
config = {}
except Exception as e:
msg = f"Failed to parse yt-dlp cli options. {e!s}"
raise ValueError(msg) from e
return {
"url": url,
"preset": preset,
@ -101,5 +126,6 @@ class Common:
"cookies": cookies,
"config": config if isinstance(config, dict) else {},
"template": template,
"cli": cli if isinstance(cli, str) else "",
"extras": extras if isinstance(extras, dict) else {},
}

View file

@ -12,7 +12,7 @@ import coloredlogs
from dotenv import load_dotenv
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
@ -218,7 +218,7 @@ class Config:
"instance_title",
"sentry_dsn",
"console_enabled",
"browser_enabled"
"browser_enabled",
)
"The variables that are relevant to the frontend."
@ -321,8 +321,38 @@ class Config:
except Exception as 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")
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=1,
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}'.")
(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}")
sys.exit(1)
if isinstance(opts, dict):
for key in IGNORED_KEYS:
if key in opts:
LOG.error(f"Key '{key}' is not allowed to be loaded via 'ytdlp.json' file.")
del opts[key]
bad_options = {k: v for d in REMOVE_KEYS for k, v in d.items()}
removed_options = []
for key in opts.copy():
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)
else:
LOG.error(f"Invalid yt-dlp custom options file '{optsFile}'.")
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
@ -373,7 +411,6 @@ class Config:
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:
with open(key_file, "rb") as f:
self.secret_key = f.read().strip()

View file

@ -6,6 +6,7 @@
"folder": "",
"template": "",
"cookies": "",
"cli": "",
"default": true
},
{
@ -15,69 +16,37 @@
"folder": "",
"template": "",
"cookies": "",
"cli": "",
"default": true
},
{
"id": "441675ed-b739-40f0-a0b0-1ecfcb9dc48b",
"name": "1080p H264/m4a or best available",
"format": "bv[height<=1080][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]",
"args": {
"format_sort": [
"vcodec:h264"
]
},
"folder": "",
"template": "",
"cookies": "",
"cli": "-S vcodec:h264",
"default": true
},
{
"id": "9719fcc3-4cf2-4d88-b1e4-74dff3dba00e",
"name": "720p h264/m4a or best available",
"format": "bv[height<=720][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]",
"args": {
"format_sort": [
"vcodec:h264"
]
},
"folder": "",
"template": "",
"cookies": "",
"cli": "-S vcodec:h264",
"default": true
},
{
"id": "a6fd4b25-2b3e-458d-bb57-b75e41cc4330",
"name": "Audio only",
"format": "bestaudio/best",
"args": {
"writethumbnail": true
},
"postprocessors": [
{
"key": "FFmpegExtractAudio",
"preferredcodec": "best",
"preferredquality": "5",
"nopostoverwrites": false
},
{
"key": "FFmpegMetadata",
"add_chapters": true,
"add_metadata": true,
"add_infojson": "if_exists"
},
{
"key": "EmbedThumbnail",
"already_have_thumbnail": false
},
{
"key": "FFmpegConcat",
"only_multi_video": true,
"when": "playlist"
}
],
"folder": "",
"template": "",
"cookies": "",
"cli": "--extract-audio --add-chapters --embed-metadata --embed-thumbnail",
"default": true
}
]

View file

@ -141,17 +141,17 @@
</td>
<td class="is-vcentered is-items-center">
<div class="field is-grouped is-grouped-centered">
<div class="control" v-if="'finished' === item.status || isEmbedable(item.url)">
<button v-if="'finished' === item.status" @click="playVideo(item)" v-tooltip="'Play video'"
class="button is-danger is-light is-small">
<div class="control" v-if="('finished' === item.status && item.filename) || isEmbedable(item.url)">
<button v-if="'finished' === item.status && item.filename" @click="playVideo(item)"
v-tooltip="'Play video'" class="button is-danger is-light is-small">
<span class="icon"><i class="fa-solid fa-play" /></span>
</button>
<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>
</button>
</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'"
@click="reQueueItem(item)">
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
@ -203,7 +203,7 @@
<div class="card-header-icon">
<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.'">
<span class="icon"><i class="fa-solid fa-play" /></span>
</a>
@ -223,7 +223,7 @@
</header>
<div v-if="false === hideThumbnail" class="card-image">
<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>
<img @load="e => pImg(e)" :src="'/api/thumbnail?url=' + encodePath(item.extras.thumbnail)"
v-if="item.extras?.thumbnail" />
@ -282,7 +282,7 @@
</div>
</div>
<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)">
<span class="icon-text is-block">
<span class="icon"><i class="fa-solid fa-rotate-right" /></span>
@ -512,6 +512,9 @@ const clearIncomplete = () => {
const setIcon = item => {
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'
}
@ -524,7 +527,7 @@ const setIcon = item => {
}
if ('not_live' === item.status) {
return 'fa-solid fa-hourglass-half fa-spin'
return 'fa-solid fa-headset'
}
return 'fa-solid fa-circle'
@ -532,6 +535,9 @@ const setIcon = item => {
const setIconColor = item => {
if ('finished' === item.status) {
if (!item.filename) {
return 'has-text-warning'
}
return 'has-text-success'
}
@ -548,6 +554,9 @@ const setIconColor = item => {
const setStatus = item => {
if ('finished' === item.status) {
if (!item.filename) {
return 'Skipped?'
}
return item.is_live ? 'Live Ended' : 'Completed'
}
@ -617,9 +626,9 @@ const reQueueItem = item => {
url: item.url,
preset: item.preset,
folder: item.folder,
config: item.config,
cookies: item.cookies,
template: item.template,
cli: item?.cli,
extras: extras
})
}

View file

@ -22,10 +22,17 @@
<div class="select is-fullwidth">
<select id="preset" class="is-fullwidth"
:disabled="!socket.isConnected || addInProgress || hasFormatInConfig" v-model="selectedPreset"
v-tooltip.bottom="hasFormatInConfig ? 'Presets are disabled. Format key is present in the config.' : ''">
<option v-for="item in config.presets" :key="item.name" :value="item.name">
{{ item.name }}
</option>
v-tooltip.bottom="hasFormatInConfig ? 'Presets are disabled. Format key is present in the Command arguments for yt-dlp.' : ''">
<optgroup label="Default presets">
<option v-for="item in filter_presets(true)" :key="item.name" :value="item.name">
{{ item.name }}
</option>
</optgroup>
<optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0">
<option v-for="item in filter_presets(false)" :key="item.name" :value="item.name">
{{ item.name }}
</option>
</optgroup>
</select>
</div>
</div>
@ -59,16 +66,18 @@
</div>
</div>
<div class="columns is-multiline is-mobile" v-if="showAdvanced && !config.app.basic_mode">
<div class="column is-12">
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="output_format"
v-tooltip="'Default Format: ' + config.app.output_template">
Output Template
</label>
<div class="control">
<div class="control has-icons-left">
<input type="text" class="input" v-model="output_template" id="output_format"
:disabled="!socket.isConnected || addInProgress"
placeholder="Uses default output template naming if empty.">
<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>
@ -77,32 +86,31 @@
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="ytdlpConfig"
v-tooltip="'Extends current global yt-dlp config. (JSON)'">
JSON yt-dlp config or CLI options.
<NuxtLink v-if="ytdlpConfig && ytdlpConfig.trim() && !ytdlpConfig.trim().startsWith('{')"
@click="convertOptions()">
Convert to JSON
</NuxtLink>
<label class="label is-inline" for="cli_options">
Command arguments for yt-dlp
</label>
<div class="control">
<textarea class="textarea" id="ytdlpConfig" v-model="ytdlpConfig"
:disabled="!socket.isConnected || addInProgress || convertInProgress"
placeholder="--no-embed-metadata --no-embed-thumbnail"></textarea>
<div class="control has-icons-left">
<input type="text" class="input" v-model="ytdlp_cli" id="cli_options"
:disabled="!socket.isConnected || addInProgress"
placeholder="command options to use, e.g. --no-embed-metadata --no-embed-thumbnail">
<span class="icon is-small is-left"><i class="fa-solid fa-terminal" /></span>
</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. If <code>Format</code> key is present
in the config, <span class="has-text-danger">the preset and all it's options will be
ignored</span>.</span>
<span>yt-dlp cli arguments. Check <NuxtLink target="_blank"
to="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#general-options">this page</NuxtLink>.
For more info. <span class="has-text-danger">Not all options are supported some are ignored. Use
with caution those arguments can break yt-dlp or the frontend.</span>
</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="column is-12">
<div class="field">
<label class="label is-inline" for="ytdlpCookies" v-tooltip="'Netscape HTTP Cookie format'">
Cookies
@ -152,7 +160,6 @@
<script setup>
import { useStorage } from '@vueuse/core'
import { request } from '~/utils/index'
const emitter = defineEmits(['getInfo'])
const config = useConfigStore()
@ -160,31 +167,25 @@ const socket = useSocketStore()
const toast = useToast()
const selectedPreset = useStorage('selectedPreset', config.app.default_preset)
const ytdlpConfig = useStorage('ytdlp_config', '')
const ytdlpCookies = useStorage('ytdlp_cookies', '')
const ytdlp_cli = useStorage('ytdlp_cli', '')
const output_template = useStorage('output_template', null)
const downloadPath = useStorage('downloadPath', null)
const url = useStorage('downloadUrl', null)
const showAdvanced = useStorage('show_advanced', false)
const addInProgress = ref(false)
const convertInProgress = ref(false)
const addDownload = async () => {
// -- send request to convert cli options to JSON
if (ytdlpConfig.value && !ytdlpConfig.value.trim().startsWith('{')) {
await convertOptions()
}
if (ytdlpConfig.value) {
try {
JSON.parse(ytdlpConfig.value)
} catch (e) {
toast.error(`Invalid JSON yt-dlp config. ${e.message}`)
if (ytdlp_cli.value && '' !== ytdlp_cli.value) {
const options = await convertOptions(ytdlp_cli.value)
if (null === options) {
return
}
}
addInProgress.value = true
url.value.split(',').forEach(url => {
if (!url.trim()) {
return
@ -193,9 +194,9 @@ const addDownload = async () => {
url: url,
preset: config.app.basic_mode ? config.app.default_preset : selectedPreset.value,
folder: config.app.basic_mode ? null : downloadPath.value,
config: config.app.basic_mode ? '' : ytdlpConfig.value,
cookies: config.app.basic_mode ? '' : ytdlpCookies.value,
template: config.app.basic_mode ? null : output_template.value,
cli: config.app.basic_mode ? null : ytdlp_cli.value,
})
})
}
@ -206,7 +207,6 @@ const resetConfig = () => {
}
selectedPreset.value = config.app.default_preset.value
ytdlpConfig.value = ''
ytdlpCookies.value = ''
output_template.value = null
url.value = null
@ -239,27 +239,24 @@ const unlockDownload = async stream => {
}
}
const convertOptions = async () => {
if (convertInProgress.value) {
return
}
const convertOptions = async args => {
try {
convertInProgress.value = true
const response = await convertCliOptions(ytdlpConfig.value)
ytdlpConfig.value = JSON.stringify(response.opts, null, 2)
const response = await convertCliOptions(args)
if (response.output_template) {
output_template.value = response.output_template
}
if (response.download_path) {
downloadPath.value = response.download_path
}
return response.opts
} catch (e) {
toast.error(e.message)
} finally {
convertInProgress.value = false
}
return null;
}
onMounted(() => {
@ -276,14 +273,13 @@ onUnmounted(() => {
})
const hasFormatInConfig = computed(() => {
if (!ytdlpConfig.value) {
return false
}
try {
const config = JSON.parse(ytdlpConfig.value)
return "format" in config
} catch (e) {
if (!ytdlp_cli.value) {
return false
}
return /(?<!\w)(-f|--format)(=|:)?(?!\w)/.test(ytdlp_cli.value)
})
const filter_presets = (flag = true) => config.presets.filter(item => item.default === flag)
</script>

View file

@ -1,43 +1,5 @@
<template>
<main class="columns mt-2 is-multiline">
<div class="column is-12">
<h1 class="is-unselectable is-pointer title is-5" @click="convertExpanded = !convertExpanded">
<span class="icon-text">
<span class="icon"><i class="fa-solid" :class="convertExpanded ? 'fa-arrow-up' : 'fa-arrow-down'" /></span>
<span>Convert yt-dlp cli options.</span>
</span>
</h1>
<form autocomplete="off" id="convertOpts" @submit.prevent="convertOptions()" v-if="convertExpanded">
<div class="box">
<label class="label" for="opts">
yt-dlp CLI options
</label>
<div class="field has-addons">
<div class="control has-icons-left is-expanded">
<input type="text" class="input" id="opts" v-model="opts"
placeholder="-x --audio-format mp3 -f bestaudio">
<span class="icon is-small is-left"><i class="fa-solid fa-n" /></span>
</div>
<div class="control">
<button form="convertOpts" class="button is-primary" :disabled="convertInProgress || !opts" type="submit"
:class="{ 'is-loading': convertInProgress }">
<span class="icon"><i class="fa-solid fa-cog" /></span>
<span>Convert</span>
</button>
</div>
</div>
<p class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Convert yt-dlp CLI options to JSON format. This will overwrite the current form fields.</span>
</p>
</div>
</form>
</div>
<div class="column is-12">
<form autocomplete="off" id="presetForm" @submit.prevent="checkInfo()">
<div class="card">
@ -157,43 +119,21 @@
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="column is-12">
<div class="field">
<label class="label is-inline" for="args" v-tooltip="'Extends current global yt-dlp config. (JSON)'">
JSON yt-dlp config
<label class="label is-inline" for="cli_options">
Command arguments for yt-dlp
</label>
<div class="control">
<textarea class="textarea" id="args" v-model="form.args" :disabled="addInProgress"
placeholder="{}" />
<input type="text" class="input" v-model="form.cli" id="cli_options" :disabled="addInProgress"
placeholder="command options to use, e.g. --no-embed-metadata --no-embed-thumbnail">
</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>yt-dlp cli arguments. Check <NuxtLink target="_blank"
to="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#general-options">this page</NuxtLink>.
For more info. <span class="has-text-danger">Not all options are supported some are ignored. Use
with caution those arguments can break yt-dlp or the frontend.</span>
</span>
</span>
</div>
@ -217,6 +157,56 @@
</span>
</div>
</div>
<div class="column is-12" v-if="has_data(form?.args) || has_data(form?.postprocessors)">
<Message title="Deprecation Warning" class="is-background-warning-80 has-text-dark"
icon="fas fa-exclamation-circle">
<ul>
<li>
The <code>JSON yt-dlp config</code> and <code>JSON yt-dlp Post-Processors</code> fields are
deprecated and will be removed in the future. Please use the <b>Command arguments for yt-dlp</b>
field instead. The deprecated fields will still be working for now but they will stop, we suggest
that you migrate to the new field. as soon as possible to avoid any issues. No support will be
given for the deprecated fields.
</li>
<li>
If both fields are set, the <b>Command arguments for yt-dlp</b> field will take precedence over
the deprecated fields. and when you click save it will remove the deprecated fields.
</li>
</ul>
</Message>
</div>
<div class="column is-6-tablet is-12-mobile" v-if="has_data(form?.args)">
<div class="field">
<label class="label is-inline" for="args" v-tooltip="'Extends current global yt-dlp config. (JSON)'">
JSON yt-dlp config <span class="has-text-danger">(DEPRECATED)</span>
</label>
<div class="control">
<textarea class="textarea" id="args" v-model="form.args" :disabled="addInProgress"
placeholder="{}" />
</div>
<span class="help has-text-danger">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Deprecated, use <b>Command arguments for yt-dlp</b> field instead. </span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile" v-if="has_data(form?.postprocessors)">
<div class="field">
<label class="label is-inline" for="postprocessors"
v-tooltip="'Things to do after download is done.'">
JSON yt-dlp Post-Processors <span class="has-text-danger">(DEPRECATED)</span>
</label>
<div class="control">
<textarea class="textarea" id="postprocessors" v-model="form.postprocessors"
:disabled="addInProgress" placeholder="[]" />
</div>
<span class="help has-text-danger">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Deprecated, use <b>Command arguments for yt-dlp</b> field instead. </span>
</span>
</div>
</div>
</div>
</div>
@ -239,6 +229,7 @@
</div>
</form>
</div>
<datalist id="folders" v-if="config?.folders">
<option v-for="dir in config.folders" :key="dir" :value="dir" />
</datalist>
@ -273,14 +264,15 @@ const props = defineProps({
const config = useConfigStore()
const toast = useToast()
const convertInProgress = ref(false)
const form = reactive(JSON.parse(JSON.stringify(props.preset)))
const opts = ref('')
const import_string = ref('')
const showImport = useStorage('showImport', false);
const convertExpanded = ref(false)
onMounted(() => {
if (props.preset?.cli && '' !== props.preset?.cli) {
return
}
if (props.preset?.args && (typeof props.preset.args === 'object')) {
form.args = JSON.stringify(props.preset.args, null, 2)
}
@ -299,6 +291,14 @@ const checkInfo = async () => {
}
}
if (form?.cli && '' !== form.cli) {
const options = await convertOptions(form.cli);
if (null === options) {
return
}
form.cli = form.cli.trim()
}
let copy = JSON.parse(JSON.stringify(form));
let usedName = false;
@ -319,29 +319,47 @@ const checkInfo = async () => {
return;
}
if (typeof copy.args === 'object') {
copy.args = JSON.stringify(copy.args, null, 2);
}
if (form?.cli && '' !== form.cli) {
if ((has_data(copy?.args) || has_data(copy?.postprocessors))) {
if (false === confirm('cli options are set, this will remove the JSON yt-dlp config and Post-Processors. Are you sure?')) {
toast.warning('User cancelled the operation.')
return
}
}
if (typeof copy.postprocessors === 'object') {
copy.postprocessors = JSON.stringify(copy.postprocessors, null, 2);
}
if (copy?.args) {
delete copy.args
}
if (copy.args) {
try {
copy.args = JSON.parse(copy.args)
} catch (e) {
toast.error(`Invalid JSON yt-dlp config. ${e.message}`)
return;
if (copy?.postprocessors) {
delete copy.postprocessors
}
}
else {
if (typeof copy.args === 'object') {
copy.args = JSON.stringify(copy.args, null, 2);
}
if (copy.postprocessors) {
try {
copy.postprocessors = JSON.parse(copy.postprocessors)
} catch (e) {
toast.error(`Invalid JSON yt-dlp Post-Processors. ${e.message}`)
return;
if (typeof copy.postprocessors === 'object') {
copy.postprocessors = JSON.stringify(copy.postprocessors, null, 2);
}
if (copy?.args) {
try {
copy.args = JSON.parse(copy.args)
} catch (e) {
toast.error(`Invalid JSON yt-dlp config. ${e.message}`)
return;
}
}
if (copy?.postprocessors) {
try {
copy.postprocessors = JSON.parse(copy.postprocessors)
} catch (e) {
toast.error(`Invalid JSON yt-dlp Post-Processors. ${e.message}`)
return;
}
}
}
@ -355,30 +373,9 @@ const checkInfo = async () => {
emitter('submit', { reference: toRaw(props.reference), preset: toRaw(copy) });
}
const convertOptions = async () => {
if (convertInProgress.value) {
return
}
if (form.format || form.args || form.postprocessors || form.template || form.folder) {
if (false === confirm('This will overwrite the current form fields. Are you sure?')) {
return
}
}
const convertOptions = async args => {
try {
convertInProgress.value = true
const response = await convertCliOptions(opts.value)
if (!response.opts) {
toast.error('Failed to convert options.')
return
}
if (response.opts.format) {
form.format = response.opts.format
delete response.opts.format
}
const response = await convertCliOptions(args)
if (response.output_template) {
form.template = response.output_template
@ -388,18 +385,16 @@ const convertOptions = async () => {
form.folder = response.download_path
}
if (response.opts.postprocessors) {
form.postprocessors = JSON.stringify(response.opts.postprocessors, null, 2)
delete response.opts.postprocessors
if (response.format) {
form.format = response.format
}
form.args = JSON.stringify(response.opts, null, 2)
opts.value = ''
return response.opts
} catch (e) {
toast.error(e.message)
} finally {
convertInProgress.value = false
}
return null;
}
const importItem = async () => {
@ -422,13 +417,15 @@ const importItem = async () => {
try {
const item = JSON.parse(val)
console.log(item)
if ('preset' !== item._type) {
toast.error(`Invalid import string. Expected type 'preset', got '${item._type}'.`)
import_string.value = ''
return
}
if (form.format || form.args || form.postprocessors) {
if (form.format || form.cli) {
if (false === confirm('This will overwrite the current form fields. Are you sure?')) {
return
}
@ -442,16 +439,12 @@ const importItem = async () => {
form.format = item.format
}
if (item.args) {
form.args = JSON.stringify(item.args, null, 2)
if (item.cli) {
form.cli = item.cli
}
if (item.postprocessors) {
form.postprocessors = JSON.stringify(item.postprocessors, null, 2)
}
if (item.output_template) {
form.template = item.output_template
if (item.template) {
form.template = item.template
}
if (item.folder) {
@ -465,4 +458,5 @@ const importItem = async () => {
toast.error(`Failed to string. ${e.message}`)
}
}
</script>

View file

@ -50,7 +50,6 @@
</span>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="name" v-text="'Name'" />
@ -81,25 +80,32 @@
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="folder">
Preset
</label>
<label class="label is-inline" for="preset">Preset</label>
<div class="control has-icons-left">
<div class="select is-fullwidth">
<select id="preset" class="is-fullwidth" v-model="form.preset"
:disabled="addInProgress || hasFormatInConfig"
v-tooltip.bottom="hasFormatInConfig ? 'Presets are disabled. Format key is present in the config.' : ''">
<option v-for="item in config.presets" :key="item.name" :value="item.name">
{{ item.name }}
</option>
v-tooltip.bottom="hasFormatInConfig ? 'Presets are disabled. Format key is present in the command arguments for yt-dlp.' : ''">
<optgroup label="Default presets">
<option v-for="item in filter_presets(true)" :key="item.name" :value="item.name">
{{ item.name }}
</option>
</optgroup>
<optgroup label="Custom presets" v-if="config?.presets.filter(p => !p?.default).length > 0">
<option v-for="item in filter_presets(false)" :key="item.name" :value="item.name">
{{ item.name }}
</option>
</optgroup>
</select>
</div>
<span class="icon is-small is-left"><i class="fa-solid fa-tv" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Select the preset to use for this URL. The preset will be ignored if format key is present in
config.</span>
<span>Select the preset to use for this URL. <span class="text-has-danger">If the
<code>-f, --format</code> argument is present in the command line options, the preset and all
it's options will be ignored.</span>
</span>
</span>
</div>
</div>
@ -126,7 +132,7 @@
</div>
</div>
<div class="column is-6-tablet is-12-mobile" v-if="showAdvanced">
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="folder">
Download path
@ -138,24 +144,25 @@
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Downloads are relative to download path, defaults to root path if not set.</span>
<span>Paths are relative to global download path, defaults to preset download path if set otherwise,
fallback root path if not set.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile" v-if="showAdvanced">
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="output_template">
Output template
</label>
<div class="control has-icons-left">
<input type="text" class="input" id="output_template" placeholder="The output template to use"
v-model="form.template" :disabled="addInProgress">
<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>The output template to use, if not set, it will defaults to
<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>.
@ -164,45 +171,25 @@
</div>
</div>
<div class="column is-6-tablet is-12-mobile" v-if="showAdvanced">
<div class="column is-12">
<div class="field">
<label class="label is-inline" for="config"
v-tooltip="'Extends current global yt-dlp config. (JSON)'">
JSON yt-dlp config or CLI options. <NuxtLink
v-if="form.config && (typeof form.config === 'string') && !form.config.trim().startsWith('{')"
@click="convertOptions()">Convert to JSON</NuxtLink>
<label class="label is-inline" for="cli_options">
Command arguments for yt-dlp
</label>
<div class="control">
<textarea class="textarea" id="config" v-model="form.config" :disabled="addInProgress"
placeholder="--no-embed-metadata --no-embed-thumbnail"></textarea>
<input type="text" class="input" v-model="form.cli" id="cli_options" :disabled="addInProgress"
placeholder="command options to use, e.g. --no-embed-metadata --no-embed-thumbnail">
</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>yt-dlp cli arguments. Check <NuxtLink target="_blank"
to="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#general-options">this page</NuxtLink>.
For more info. <span class="has-text-danger">Not all options are supported some are ignored. Use
with caution those arguments can break yt-dlp or the frontend.</span>
</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile" v-if="showAdvanced">
<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" />
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>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>
@ -221,12 +208,6 @@
<span>Cancel</span>
</button>
</p>
<p class="card-footer-item">
<button class="button is-fullwidth is-info" type="button" @click="showAdvanced = !showAdvanced">
<span class="icon"><i class="fa-solid fa-cog" /></span>
<span>Advanced</span>
</button>
</p>
</div>
</div>
</form>
@ -244,8 +225,6 @@ import { CronExpressionParser } from 'cron-parser'
const emitter = defineEmits(['cancel', 'submit']);
const toast = useToast();
const config = useConfigStore();
const convertInProgress = ref(false);
const showAdvanced = useStorage('task.showAdvanced', false);
const showImport = useStorage('showImport', false);
const import_string = ref('');
@ -269,10 +248,6 @@ const props = defineProps({
const form = reactive(props.task);
onMounted(() => {
if (props.task?.config && (typeof props.task.config === 'object')) {
form.config = JSON.stringify(props.task.config, null, 4);
}
if (!props.task?.preset || '' === props.task.preset) {
form.preset = toRaw(config.app.default_preset);
}
@ -304,60 +279,17 @@ const checkInfo = async () => {
return;
}
if (typeof form.config === 'object') {
form.config = JSON.stringify(form.config, null, 4);
}
if (form.config && form.config && !form.config.trim().startsWith('{')) {
await convertOptions();
}
if (form.config) {
try {
form.config = JSON.parse(form.config)
} catch (e) {
toast.error(`Invalid JSON yt-dlp config. ${e.message}`)
return;
if (form?.cli && '' !== form.cli) {
const options = await convertOptions(form.cli);
if (null === options) {
return
}
form.cli = form.cli.trim(" ")
}
emitter('submit', { reference: toRaw(props.reference), task: toRaw(form) });
}
const convertOptions = async () => {
if (convertInProgress.value) {
return
}
try {
convertInProgress.value = true
const response = await convertCliOptions(form.config)
form.config = JSON.stringify(response.opts, null, 2)
if (response.output_template) {
form.template = response.output_template
}
if (response.download_path) {
form.folder = response.download_path
}
} catch (e) {
toast.error(e.message)
} finally {
convertInProgress.value = false
}
}
const hasFormatInConfig = computed(() => {
if (!form.config) {
return false
}
try {
const config = JSON.parse(form.config)
return "format" in config
} catch (e) {
return false
}
})
const importItem = async () => {
let val = import_string.value.trim()
if (!val) {
@ -384,7 +316,7 @@ const importItem = async () => {
return
}
if (form.config || form.url || form.timer) {
if (form.url || form.timer) {
if (false === confirm('This will overwrite the current form fields. Are you sure?')) {
return
}
@ -398,8 +330,8 @@ const importItem = async () => {
form.url = item.url
}
if (item.preset) {
form.preset = item.preset
if (item.template) {
form.template = item.template
}
if (item.timer) {
@ -410,12 +342,19 @@ const importItem = async () => {
form.folder = item.folder
}
if (item.template) {
form.template = item.template
if (item.cli) {
form.cli = item.cli
}
if (item.config) {
form.config = JSON.stringify(item.config, null, 2)
if (item.preset) {
// -- check if the preset exists in config.presets
const preset = config.presets.find(p => p.name === item.preset)
if (!preset) {
toast.warning(`Preset '${item.preset}' not found. Preset will be set to default.`)
form.preset = 'default'
} else {
form.preset = item.preset
}
}
import_string.value = ''
@ -424,4 +363,35 @@ const importItem = async () => {
toast.error(`Failed to import string. ${e.message}`)
}
}
const convertOptions = async args => {
try {
const response = await convertCliOptions(args)
if (response.output_template) {
form.template = response.output_template
}
if (response.download_path) {
form.folder = response.download_path
}
return response.opts
} catch (e) {
toast.error(e.message)
}
return null;
}
const hasFormatInConfig = computed(() => {
if (!form?.cli) {
return false
}
return /(?<!\w)(-f|--format)(=|:)?(?!\w)/.test(form.cli)
})
const filter_presets = (flag = true) => config.presets.filter(item => item.default === flag)
</script>

View file

@ -71,7 +71,17 @@ div.is-centered {
</header>
<div class="card-content">
<div class="content">
<p class="is-text-overflow">
<template v-if="has_data(item?.args) || has_data(item.postprocessors)">
<p class="has-text-danger is-5 has-text-bold">
<span class="icon"><i class="fa-solid fa-triangle-exclamation" /></span>
<span>The preset is using deprecated options. It is recommended to update the preset to use the
new options. It will cease to work in future versions.</span>
</p>
<hr>
</template>
<p class="is-text-overflow"
v-if="item?.format && false === ['default', 'not_set'].includes(item.format)">
<span class="icon"><i class="fa-solid fa-f" /></span>
<span v-text="item.format" />
</p>
@ -83,6 +93,10 @@ div.is-centered {
<span class="icon"><i class="fa-solid fa-file" /></span>
<span>{{ item.template }}</span>
</p>
<p class="is-text-overflow" v-if="item.cli">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>{{ item.cli }}</span>
</p>
<p class="is-text-overflow" v-if="item.cookies">
<span class="icon"><i class="fa-solid fa-cookie" /></span>
<span>Has cookies</span>
@ -293,12 +307,9 @@ const exportItem = item => {
}
})
if (data.args && typeof data.args === "string") {
data.args = JSON.parse(data.args)
}
if (data.postprocessors && typeof data.postprocessors === "string") {
data.postprocessors = JSON.parse(data.postprocessors)
if (has_data(data?.args) || has_data(data?.postprocessors)) {
toast.error("v1.0 presets are no longer supported target for export. Please update your preset.")
return
}
let userData = {}
@ -311,7 +322,7 @@ const exportItem = item => {
}
userData['_type'] = 'preset'
userData['_version'] = '1.0'
userData['_version'] = '2.0'
return copyText(base64UrlEncode(JSON.stringify(userData)))
}

View file

@ -39,8 +39,7 @@ div.is-centered {
</div>
<div class="is-hidden-mobile">
<span class="subtitle">
The task runner is simple queue system that allows you to schedule downloads. The tasks are run at the
scheduled time.
The task runner is simple queue system that allows you to schedule downloads to run at the specific time.
</span>
</div>
</div>
@ -62,10 +61,6 @@ div.is-centered {
<a class="has-text-primary" v-tooltip="'Export task.'" @click.prevent="exportItem(item)">
<span class="icon"><i class="fa-solid fa-file-export" /></span>
</a>
<button @click="item.raw = !item.raw">
<span class="icon"><i class="fa-solid"
:class="{ 'fa-arrow-down': !item?.raw, 'fa-arrow-up': item?.raw }" /></span>
</button>
</div>
</header>
<div class="card-content">
@ -86,11 +81,10 @@ div.is-centered {
<span class="icon"><i class="fa-solid fa-tv" /></span>
<span>{{ item.preset ?? config.app.default_preset }}</span>
</p>
</div>
</div>
<div class="card-content" v-if="item?.raw">
<div class="content">
<pre><code>{{ filterItem(item) }}</code></pre>
<p class="is-text-overflow">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
<span>{{ item.cli }}</span>
</p>
</div>
</div>
<div class="card-footer">
@ -120,17 +114,6 @@ div.is-centered {
icon="fas fa-exclamation-circle" v-if="!tasks || tasks.length < 1" />
</div>
</div>
<div class="column is-12" v-if="tasks && tasks.length > 0 && !toggleForm">
<Message message_class="has-background-info-90 has-text-dark" title="Tips" icon="fas fa-info-circle">
<ul>
<li>
When you export task, the preset settings is automatically merged into the task and the preset set to
<code>default</code> to be more portable. The exporter doesn't include <code>Cookies</code> field for
security reasons.
</li>
</ul>
</Message>
</div>
</div>
</template>
@ -202,6 +185,8 @@ const resetForm = (closeForm = false) => {
}
const updateTasks = async items => {
let data = {}
try {
addInProgress.value = true
@ -213,7 +198,7 @@ const updateTasks = async items => {
body: JSON.stringify(items),
})
const data = await response.json()
data = await response.json()
if (200 !== response.status) {
toast.error(`Failed to update task. ${data.error}`);
@ -224,7 +209,7 @@ const updateTasks = async items => {
resetForm(true)
return true
} catch (e) {
toast.error(`Failed to update task. ${data.error}`);
toast.error(`Failed to update task. ${data?.error ?? e.message}`);
} finally {
addInProgress.value = false
}
@ -272,11 +257,6 @@ const updateItem = async ({ reference, task }) => {
resetForm(true)
}
const filterItem = item => {
const { raw, ...rest } = item
return JSON.stringify(rest, null, 2)
}
const editItem = item => {
task.value = item
taskRef.value = item.id
@ -327,21 +307,8 @@ const runNow = item => {
data.template = item.template
}
if (item.cookies) {
data.cookies = item.cookies
}
if (item.config) {
if (typeof item.config === 'object') {
data.config = JSON.stringify(item.config)
}
try {
JSON.parse(data.config)
} catch (e) {
toast.error(`Invalid JSON yt-dlp config. ${e.message}`)
return
}
if (item.cli) {
data.cli = item.cli
}
socket.emit('add_url', data)
@ -359,69 +326,26 @@ const statusHandler = async stream => {
}
const exportItem = async item => {
let preset = config.presets.find(p => p.name === item.preset)
if (!preset) {
toast.error('Preset not found.')
return
}
const info = JSON.parse(JSON.stringify(item))
preset = JSON.parse(JSON.stringify(preset))
let data = {
name: info.name,
url: info.url,
preset: 'default',
preset: info.preset,
timer: info.timer,
folder: info.folder,
template: info.template,
config: info.config,
}
// -- merge preset options with task args.
let args = {}
if (preset.args && Object.keys(preset.args).length > 0) {
for (const key of Object.keys(preset.args)) {
args[key] = preset.args[key]
}
}
const defaults = ['default', 'not_set']
if (preset.format && !defaults.includes(preset.format)) {
args.format = preset.format
if (info.template) {
data.template = info.template
}
if (preset.postprocessors && preset.postprocessors.length > 0) {
args.postprocessors = preset.postprocessors
}
if (preset.folder && !info.folder) {
data.folder = preset.folder
}
if (preset.template && !info.template) {
data.template = preset.template
}
if (!data.config || Object.keys(data.config).length < 1) {
data.config = {}
}
for (const key of Object.keys(args)) {
if (key in data.config && Array.isArray(args[key]) && Array.isArray(data.config[key])) {
data.config[key] = data.config[key].concat(args[key])
continue
}
if (data?.config[key]) {
continue
}
data.config[key] = args[key]
if (info.cli) {
data.cli = info.cli
}
data['_type'] = 'task'
data['_version'] = '1.1'
data['_version'] = '2.0'
return copyText(base64UrlEncode(JSON.stringify(data)));
}

View file

@ -22,7 +22,12 @@ const CONFIG_KEYS = {
presets: [
{
'name': 'default',
'format': 'default'
'format': 'default',
'folder': '',
'template': '',
'cookies': '',
'cli': '',
'default': true
}
],
folders: [],

View file

@ -428,6 +428,38 @@ const base64UrlDecode = input => {
return atob(base64);
}
/**
* Check if array or object has data.
*
* @param {string} item - The item to check
* @returns {boolean} - True if the item has data, false otherwise.
*/
const has_data = item => {
if (!item) {
return false
}
if (typeof item === 'string') {
try {
item = JSON.parse(item)
} catch (e) {
return true
}
}
try {
if (typeof item === 'object') {
return Object.keys(item).length > 0
}
return item.length > 0
} catch (e) {
console.error(e)
}
return false
}
export {
ag_set,
ag,
@ -452,4 +484,5 @@ export {
convertCliOptions,
base64UrlEncode,
base64UrlDecode,
has_data,
}