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

View file

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

View file

@ -13,7 +13,7 @@ Web GUI for [yt-dlp](https://github.com/yt-dlp/yt-dlp) with playlist & channel s
* Can Handle live streams. * Can Handle live streams.
* Scheduler to queue channels or playlists to be downloaded automatically at a specified time. * Scheduler to queue channels or playlists to be downloaded automatically at a specified time.
* Send notification to targets based on selected events. * 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. * Queue multiple URLs separated by comma.
* Simple file browser. `Disabled by default` * 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**. * 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 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. * Support for both advanced and basic mode for WebUI.
* Bundled tools in container: curl-cffi, ffmpeg, ffprobe, aria2, rtmpdump, mkvtoolsnix, mp4box. * 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 Starting with versions tagged `*-20250330-*` I have deprecated the JSON yt-dlp config and all of it's related settings.
{
"windowsfilenames": true,
"continue_dl": true,
"live_from_start": true,
"format_sort": [ "codec:avc:m4a" ],
}
```
> [!NOTE] 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
> 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. 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 # 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 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 We strongly recommend not use this file for options that aren't **truly global**, everything that can be done via the file
can be done via a preset, which only effects the download that uses it. Example of good basic `ytdlp.json` 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 ```bash
{ --continue --windows-filenames --live-from-start
"windowsfilenames": true,
"continue_dl": true,
"live_from_start": true,
"format_sort": [ "codec:avc:m4a" ],
}
``` ```
Everything else can be done via the presets, and it's more flexible and easier to manage. You can convert your Everything else can be done via the presets, and it's more flexible and easier to manage.
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).
# Authentication # Authentication
@ -275,7 +294,7 @@ What does the basic mode do? it hides the the following features from the WebUI.
### Header ### Header
It disables everything except the `theme switcher` and `reload` button. It disables everything except the `settings button` and `reload` button.
### Add form ### Add form

View file

@ -135,6 +135,12 @@ class Download:
self.status_queue.put({"id": self.id, "status": "finished", "filename": filename}) 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): def _download(self):
try: try:
params = ( params = (
@ -178,6 +184,7 @@ class Download:
{ {
"progress_hooks": [self._progress_hook], "progress_hooks": [self._progress_hook],
"postprocessor_hooks": [self._postprocessor_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.' 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) params["logger"] = NestedLogger(self.logger)

View file

@ -21,7 +21,7 @@ from .Events import EventBus, Events
from .ItemDTO import ItemDTO from .ItemDTO import ItemDTO
from .Presets import Presets from .Presets import Presets
from .Singleton import Singleton 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 from .YTDLPOpts import YTDLPOpts
LOG = logging.getLogger("DownloadQueue") LOG = logging.getLogger("DownloadQueue")
@ -177,6 +177,7 @@ class DownloadQueue(metaclass=Singleton):
cookies: str = "", cookies: str = "",
template: str = "", template: str = "",
extras: dict | None = None, extras: dict | None = None,
cli: str = "",
already=None, already=None,
): ):
""" """
@ -190,9 +191,9 @@ class DownloadQueue(metaclass=Singleton):
cookies (str): The cookies to use for the download. cookies (str): The cookies to use for the download.
template (str): The output template 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. 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. already (set): The set of already downloaded items.
Returns: Returns:
dict: The status of the operation. dict: The status of the operation.
@ -244,6 +245,7 @@ class DownloadQueue(metaclass=Singleton):
cookies=cookies, cookies=cookies,
template=template, template=template,
extras=extras, extras=extras,
cli=cli,
already=already, already=already,
) )
) )
@ -320,6 +322,7 @@ class DownloadQueue(metaclass=Singleton):
is_live=is_live, is_live=is_live,
live_in=live_in, live_in=live_in,
options=options, options=options,
cli=cli,
extras=extras, extras=extras,
) )
@ -352,6 +355,7 @@ class DownloadQueue(metaclass=Singleton):
cookies=cookies, cookies=cookies,
template=template, template=template,
extras=extras, extras=extras,
cli=cli,
already=already, already=already,
) )
@ -365,12 +369,39 @@ class DownloadQueue(metaclass=Singleton):
config: dict | None = None, config: dict | None = None,
cookies: str = "", cookies: str = "",
template: str = "", template: str = "",
cli: str = "",
extras: dict | None = None, extras: dict | None = None,
already=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) _preset = Presets.get_instance().get(name=preset)
config = config if config else {} 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 "" folder = str(folder) if folder else ""
if not extras: if not extras:
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") cookie_file = os.path.join(self.config.temp_path, f"c_{uuid.uuid4().hex}.txt")
LOG.info( 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): if isinstance(config, str):
@ -486,6 +517,7 @@ class DownloadQueue(metaclass=Singleton):
template=template, template=template,
already=already, already=already,
extras=extras, extras=extras,
cli=cli,
) )
async def cancel(self, ids: list[str]) -> dict[str, str]: async def cancel(self, ids: list[str]) -> dict[str, str]:

View file

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

View file

@ -35,7 +35,7 @@ from .Segments import Segments
from .Subtitle import Subtitle from .Subtitle import Subtitle
from .Tasks import Task, Tasks from .Tasks import Task, Tasks
from .Utils import ( from .Utils import (
IGNORED_KEYS, REMOVE_KEYS,
StreamingError, StreamingError,
arg_converter, arg_converter,
decrypt_data, decrypt_data,
@ -445,7 +445,7 @@ class HttpAPI(Common):
try: try:
response = {"opts": {}, "output_template": None, "download_path": None} response = {"opts": {}, "output_template": None, "download_path": None}
data = arg_converter(args) data = arg_converter(args, dumps=True)
if "outtmpl" in data and "default" in data["outtmpl"]: if "outtmpl" in data and "default" in data["outtmpl"]:
response["output_template"] = data["outtmpl"]["default"] response["output_template"] = data["outtmpl"]["default"]
@ -453,20 +453,29 @@ class HttpAPI(Common):
if "paths" in data and "home" in data["paths"]: if "paths" in data and "home" in data["paths"]:
response["download_path"] = data["paths"]["home"] 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: for key in data:
if key in IGNORED_KEYS: if key in bad_options.items():
removed_options.append(bad_options[key])
continue continue
if not key.startswith("_"): if not key.startswith("_"):
response["opts"][key] = data[key] response["opts"][key] = data[key]
if len(removed_options) > 0:
response["opts"]["removed"] = ", ".join(removed_options)
return web.json_response(data=response, status=web.HTTPOk.status_code) return web.json_response(data=response, status=web.HTTPOk.status_code)
except Exception as e: except Exception as e:
err = str(e).strip() err = str(e).strip()
err = err.split("\n")[-1] if "\n" in err else err err = err.split("\n")[-1] if "\n" in err else err
LOG.error(f"Failed to convert args. '{err}'.") err = err.replace("main.py: error: ", "").strip().capitalize()
LOG.exception(e)
return web.json_response( 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") @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() == "": if not item.get("timer", None) or str(item.get("timer")).strip() == "":
item["timer"] = f"{random.randint(1,59)} */1 * * *" # noqa: S311 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): if not item.get("template", None):
item["template"] = "" item["template"] = ""
if not item.get("cli", None):
item["cli"] = ""
try: try:
ins.validate(item) ins.validate(item)
except ValueError as e: except ValueError as e:

View file

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

View file

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

View file

@ -41,6 +41,9 @@ class Preset:
cookies: str = "" cookies: str = ""
"""The default cookies to use if non is given.""" """The default cookies to use if non is given."""
cli: str = ""
"""yt-dlp cli command line arguments."""
default: bool = False default: bool = False
def serialize(self) -> dict: def serialize(self) -> dict:
@ -220,6 +223,15 @@ class Presets(metaclass=Singleton):
msg = "Invalid postprocessors type. expected list." msg = "Invalid postprocessors type. expected list."
raise ValueError(msg) 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 return True
def save(self, presets: list[Preset | dict]) -> "Presets": def save(self, presets: list[Preset | dict]) -> "Presets":

View file

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

View file

@ -21,7 +21,42 @@ from .LogWrapper import LogWrapper
LOG = logging.getLogger("Utils") LOG = logging.getLogger("Utils")
IGNORED_KEYS: tuple[str] = ("paths", "outtmpl", "progress_hooks", "postprocessor_hooks", "download_archive") REMOVE_KEYS: list = [
{
"paths": "-P, --paths",
"outtmpl": "-o, --output",
"progress_hooks": "--progress_hooks",
"postprocessor_hooks": "--postprocessor_hooks",
"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 YTDLP_INFO_CLS: yt_dlp.YoutubeDL = None
ALLOWED_SUBS_EXTENSIONS: tuple[str] = (".srt", ".vtt", ".ass") ALLOWED_SUBS_EXTENSIONS: tuple[str] = (".srt", ".vtt", ".ass")
@ -434,12 +469,20 @@ def validate_url(url: str) -> bool:
return True 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. Convert yt-dlp options to a dictionary.
Args: Args:
args (str): yt-dlp options string. 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: Returns:
dict: yt-dlp options dictionary. dict: yt-dlp options dictionary.
@ -465,16 +508,44 @@ def arg_converter(args: str) -> dict:
if "postprocessors" in diff: if "postprocessors" in diff:
diff["postprocessors"] = [pp for pp in diff["postprocessors"] if pp not in default_opts["postprocessors"]] diff["postprocessors"] = [pp for pp in diff["postprocessors"] if pp not in default_opts["postprocessors"]]
if "match_filter" in diff: if "_warnings" in diff:
import inspect diff.pop("_warnings", None)
matchFilter = inspect.getclosurevars(diff["match_filter"].func).nonlocals["filters"] if level is True or isinstance(level, int):
if isinstance(matchFilter, set): bad_options = {}
diff["match_filter"] = {"filters": list(matchFilter)} 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: def validate_uuid(uuid_str: str, version: int = 4) -> bool:

View file

@ -4,7 +4,7 @@ from pathlib import Path
from .config import Config from .config import Config
from .Presets import Presets from .Presets import Presets
from .Singleton import Singleton from .Singleton import Singleton
from .Utils import IGNORED_KEYS, calc_download_path, merge_dict from .Utils import REMOVE_KEYS, arg_converter, calc_download_path, merge_dict
LOG = logging.getLogger("YTDLPOpts") LOG = logging.getLogger("YTDLPOpts")
@ -48,11 +48,22 @@ class YTDLPOpts(metaclass=Singleton):
YTDLPOpts: The instance of the class YTDLPOpts: The instance of the class
""" """
bad_options = {}
if from_user:
bad_options = {k: v for d in REMOVE_KEYS for k, v in d.items()}
removed_options = []
for key, value in config.items(): for key, value in config.items():
if key in IGNORED_KEYS and from_user: if from_user and key in bad_options:
removed_options.append(bad_options[key])
continue continue
self._item_opts[key] = value self._item_opts[key] = value
if len(removed_options) > 0:
LOG.warning("Removed the following options: '%s'.", ", ".join(removed_options))
return self return self
def preset(self, name: str, with_cookies: bool = False) -> "YTDLPOpts": def preset(self, name: str, with_cookies: bool = False) -> "YTDLPOpts":
@ -71,6 +82,19 @@ class YTDLPOpts(metaclass=Singleton):
if not preset or "default" == name: if not preset or "default" == name:
return self 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: if preset.cookies and with_cookies:
file = Path(self._config.config_path, "cookies", f"{preset.id}.txt") file = Path(self._config.config_path, "cookies", f"{preset.id}.txt")
@ -94,14 +118,24 @@ class YTDLPOpts(metaclass=Singleton):
"temp": self._config.temp_path, "temp": self._config.temp_path,
} }
if preset.postprocessors and isinstance(preset.postprocessors, list) and len(preset.postprocessors) > 0: # @Deprecated - To be removed in future versions.
self._preset_opts["postprocessors"] = preset.postprocessors 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: if preset.args and isinstance(preset.args, dict) and len(preset.args) > 0:
for key, value in preset.args.items(): bad_options = {k: v for d in REMOVE_KEYS for k, v in d.items()}
if key in IGNORED_KEYS: removed_options = []
continue for key, value in preset.args.items():
self._preset_opts[key] = value 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 return self
@ -132,6 +166,7 @@ class YTDLPOpts(metaclass=Singleton):
if "format" in data and data["format"] in ["not_set", "default"]: if "format" in data and data["format"] in ["not_set", "default"]:
data["format"] = None 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): if "daterange" in data and isinstance(data["daterange"], dict):
from yt_dlp.utils import DateRange from yt_dlp.utils import DateRange

View file

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

View file

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

View file

@ -6,6 +6,7 @@
"folder": "", "folder": "",
"template": "", "template": "",
"cookies": "", "cookies": "",
"cli": "",
"default": true "default": true
}, },
{ {
@ -15,69 +16,37 @@
"folder": "", "folder": "",
"template": "", "template": "",
"cookies": "", "cookies": "",
"cli": "",
"default": true "default": true
}, },
{ {
"id": "441675ed-b739-40f0-a0b0-1ecfcb9dc48b", "id": "441675ed-b739-40f0-a0b0-1ecfcb9dc48b",
"name": "1080p H264/m4a or best available", "name": "1080p H264/m4a or best available",
"format": "bv[height<=1080][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]", "format": "bv[height<=1080][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]",
"args": {
"format_sort": [
"vcodec:h264"
]
},
"folder": "", "folder": "",
"template": "", "template": "",
"cookies": "", "cookies": "",
"cli": "-S vcodec:h264",
"default": true "default": true
}, },
{ {
"id": "9719fcc3-4cf2-4d88-b1e4-74dff3dba00e", "id": "9719fcc3-4cf2-4d88-b1e4-74dff3dba00e",
"name": "720p h264/m4a or best available", "name": "720p h264/m4a or best available",
"format": "bv[height<=720][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]", "format": "bv[height<=720][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]",
"args": {
"format_sort": [
"vcodec:h264"
]
},
"folder": "", "folder": "",
"template": "", "template": "",
"cookies": "", "cookies": "",
"cli": "-S vcodec:h264",
"default": true "default": true
}, },
{ {
"id": "a6fd4b25-2b3e-458d-bb57-b75e41cc4330", "id": "a6fd4b25-2b3e-458d-bb57-b75e41cc4330",
"name": "Audio only", "name": "Audio only",
"format": "bestaudio/best", "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": "", "folder": "",
"template": "", "template": "",
"cookies": "", "cookies": "",
"cli": "--extract-audio --add-chapters --embed-metadata --embed-thumbnail",
"default": true "default": true
} }
] ]

View file

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

View file

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

View file

@ -1,43 +1,5 @@
<template> <template>
<main class="columns mt-2 is-multiline"> <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"> <div class="column is-12">
<form autocomplete="off" id="presetForm" @submit.prevent="checkInfo()"> <form autocomplete="off" id="presetForm" @submit.prevent="checkInfo()">
<div class="card"> <div class="card">
@ -157,43 +119,21 @@
</div> </div>
</div> </div>
<div class="column is-6-tablet is-12-mobile"> <div class="column is-12">
<div class="field"> <div class="field">
<label class="label is-inline" for="args" v-tooltip="'Extends current global yt-dlp config. (JSON)'"> <label class="label is-inline" for="cli_options">
JSON yt-dlp config Command arguments for yt-dlp
</label> </label>
<div class="control"> <div class="control">
<textarea class="textarea" id="args" v-model="form.args" :disabled="addInProgress" <input type="text" class="input" v-model="form.cli" id="cli_options" :disabled="addInProgress"
placeholder="{}" /> placeholder="command options to use, e.g. --no-embed-metadata --no-embed-thumbnail">
</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>
<span>Extends current global yt-dlp config with given options. Some fields are ignored like <span>yt-dlp cli arguments. Check <NuxtLink target="_blank"
<code>cookiefile</code>, <code>paths</code>, and <code>outtmpl</code> etc. Warning: Use with to="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#general-options">this page</NuxtLink>.
caution For more info. <span class="has-text-danger">Not all options are supported some are ignored. Use
some of those options can break yt-dlp or the frontend.</span> with caution those arguments 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>
</span> </span>
</div> </div>
@ -217,6 +157,56 @@
</span> </span>
</div> </div>
</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>
</div> </div>
@ -239,6 +229,7 @@
</div> </div>
</form> </form>
</div> </div>
<datalist id="folders" v-if="config?.folders"> <datalist id="folders" v-if="config?.folders">
<option v-for="dir in config.folders" :key="dir" :value="dir" /> <option v-for="dir in config.folders" :key="dir" :value="dir" />
</datalist> </datalist>
@ -273,14 +264,15 @@ const props = defineProps({
const config = useConfigStore() const config = useConfigStore()
const toast = useToast() const toast = useToast()
const convertInProgress = ref(false)
const form = reactive(JSON.parse(JSON.stringify(props.preset))) const form = reactive(JSON.parse(JSON.stringify(props.preset)))
const opts = ref('')
const import_string = ref('') const import_string = ref('')
const showImport = useStorage('showImport', false); const showImport = useStorage('showImport', false);
const convertExpanded = ref(false)
onMounted(() => { onMounted(() => {
if (props.preset?.cli && '' !== props.preset?.cli) {
return
}
if (props.preset?.args && (typeof props.preset.args === 'object')) { if (props.preset?.args && (typeof props.preset.args === 'object')) {
form.args = JSON.stringify(props.preset.args, null, 2) 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 copy = JSON.parse(JSON.stringify(form));
let usedName = false; let usedName = false;
@ -319,29 +319,47 @@ const checkInfo = async () => {
return; return;
} }
if (typeof copy.args === 'object') { if (form?.cli && '' !== form.cli) {
copy.args = JSON.stringify(copy.args, null, 2); 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') { if (copy?.args) {
copy.postprocessors = JSON.stringify(copy.postprocessors, null, 2); delete copy.args
} }
if (copy.args) { if (copy?.postprocessors) {
try { delete copy.postprocessors
copy.args = JSON.parse(copy.args)
} catch (e) {
toast.error(`Invalid JSON yt-dlp config. ${e.message}`)
return;
} }
} }
else {
if (typeof copy.args === 'object') {
copy.args = JSON.stringify(copy.args, null, 2);
}
if (copy.postprocessors) { if (typeof copy.postprocessors === 'object') {
try { copy.postprocessors = JSON.stringify(copy.postprocessors, null, 2);
copy.postprocessors = JSON.parse(copy.postprocessors) }
} catch (e) {
toast.error(`Invalid JSON yt-dlp Post-Processors. ${e.message}`) if (copy?.args) {
return; 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) }); emitter('submit', { reference: toRaw(props.reference), preset: toRaw(copy) });
} }
const convertOptions = async () => { const convertOptions = async args => {
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
}
}
try { try {
convertInProgress.value = true const response = await convertCliOptions(args)
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
}
if (response.output_template) { if (response.output_template) {
form.template = response.output_template form.template = response.output_template
@ -388,18 +385,16 @@ const convertOptions = async () => {
form.folder = response.download_path form.folder = response.download_path
} }
if (response.opts.postprocessors) { if (response.format) {
form.postprocessors = JSON.stringify(response.opts.postprocessors, null, 2) form.format = response.format
delete response.opts.postprocessors
} }
form.args = JSON.stringify(response.opts, null, 2) return response.opts
opts.value = ''
} catch (e) { } catch (e) {
toast.error(e.message) toast.error(e.message)
} finally {
convertInProgress.value = false
} }
return null;
} }
const importItem = async () => { const importItem = async () => {
@ -422,13 +417,15 @@ const importItem = async () => {
try { try {
const item = JSON.parse(val) const item = JSON.parse(val)
console.log(item)
if ('preset' !== item._type) { if ('preset' !== item._type) {
toast.error(`Invalid import string. Expected type 'preset', got '${item._type}'.`) toast.error(`Invalid import string. Expected type 'preset', got '${item._type}'.`)
import_string.value = '' import_string.value = ''
return 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?')) { if (false === confirm('This will overwrite the current form fields. Are you sure?')) {
return return
} }
@ -442,16 +439,12 @@ const importItem = async () => {
form.format = item.format form.format = item.format
} }
if (item.args) { if (item.cli) {
form.args = JSON.stringify(item.args, null, 2) form.cli = item.cli
} }
if (item.postprocessors) { if (item.template) {
form.postprocessors = JSON.stringify(item.postprocessors, null, 2) form.template = item.template
}
if (item.output_template) {
form.template = item.output_template
} }
if (item.folder) { if (item.folder) {
@ -465,4 +458,5 @@ const importItem = async () => {
toast.error(`Failed to string. ${e.message}`) toast.error(`Failed to string. ${e.message}`)
} }
} }
</script> </script>

View file

@ -50,7 +50,6 @@
</span> </span>
</div> </div>
<div class="column is-6-tablet is-12-mobile"> <div class="column is-6-tablet is-12-mobile">
<div class="field"> <div class="field">
<label class="label is-inline" for="name" v-text="'Name'" /> <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="column is-6-tablet is-12-mobile">
<div class="field"> <div class="field">
<label class="label is-inline" for="folder"> <label class="label is-inline" for="preset">Preset</label>
Preset
</label>
<div class="control has-icons-left"> <div class="control has-icons-left">
<div class="select is-fullwidth"> <div class="select is-fullwidth">
<select id="preset" class="is-fullwidth" v-model="form.preset" <select id="preset" class="is-fullwidth" v-model="form.preset"
:disabled="addInProgress || hasFormatInConfig" :disabled="addInProgress || hasFormatInConfig"
v-tooltip.bottom="hasFormatInConfig ? 'Presets are disabled. Format key is present in the config.' : ''"> v-tooltip.bottom="hasFormatInConfig ? 'Presets are disabled. Format key is present in the command arguments for yt-dlp.' : ''">
<option v-for="item in config.presets" :key="item.name" :value="item.name"> <optgroup label="Default presets">
{{ item.name }} <option v-for="item in filter_presets(true)" :key="item.name" :value="item.name">
</option> {{ 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> </select>
</div> </div>
<span class="icon is-small is-left"><i class="fa-solid fa-tv" /></span> <span class="icon is-small is-left"><i class="fa-solid fa-tv" /></span>
</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>
<span>Select the preset to use for this URL. The preset will be ignored if format key is present in <span>Select the preset to use for this URL. <span class="text-has-danger">If the
config.</span> <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> </span>
</div> </div>
</div> </div>
@ -126,7 +132,7 @@
</div> </div>
</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"> <div class="field">
<label class="label is-inline" for="folder"> <label class="label is-inline" for="folder">
Download path Download path
@ -138,24 +144,25 @@
</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>
<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> </span>
</div> </div>
</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"> <div class="field">
<label class="label is-inline" for="output_template"> <label class="label is-inline" for="output_template">
Output template Output template
</label> </label>
<div class="control has-icons-left"> <div class="control has-icons-left">
<input type="text" class="input" id="output_template" placeholder="The output template to use" <input type="text" class="input" id="output_template" :disabled="addInProgress"
v-model="form.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> <span class="icon is-small is-left"><i class="fa-solid fa-file" /></span>
</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>
<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>. <code>{{ config.app.output_template }}</code>.
For more information <NuxtLink href="https://github.com/yt-dlp/yt-dlp#output-template" For more information <NuxtLink href="https://github.com/yt-dlp/yt-dlp#output-template"
target="_blank">visit this url</NuxtLink>. target="_blank">visit this url</NuxtLink>.
@ -164,45 +171,25 @@
</div> </div>
</div> </div>
<div class="column is-6-tablet is-12-mobile" v-if="showAdvanced"> <div class="column is-12">
<div class="field"> <div class="field">
<label class="label is-inline" for="config" <label class="label is-inline" for="cli_options">
v-tooltip="'Extends current global yt-dlp config. (JSON)'"> Command arguments for yt-dlp
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> </label>
<div class="control"> <div class="control">
<textarea class="textarea" id="config" v-model="form.config" :disabled="addInProgress" <input type="text" class="input" v-model="form.cli" id="cli_options" :disabled="addInProgress"
placeholder="--no-embed-metadata --no-embed-thumbnail"></textarea> placeholder="command options to use, e.g. --no-embed-metadata --no-embed-thumbnail">
</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>
<span> Extends current global yt-dlp config with given options. Some fields are ignored like <span>yt-dlp cli arguments. Check <NuxtLink target="_blank"
<code>cookiefile</code>, <code>paths</code>, and <code>outtmpl</code> etc. Warning: Use with to="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#general-options">this page</NuxtLink>.
caution For more info. <span class="has-text-danger">Not all options are supported some are ignored. Use
some of those options can break yt-dlp or the frontend.</span> with caution those arguments can break yt-dlp or the frontend.</span>
</span>
</span> </span>
</div> </div>
</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>
</div> </div>
@ -221,12 +208,6 @@
<span>Cancel</span> <span>Cancel</span>
</button> </button>
</p> </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>
</div> </div>
</form> </form>
@ -244,8 +225,6 @@ import { CronExpressionParser } from 'cron-parser'
const emitter = defineEmits(['cancel', 'submit']); const emitter = defineEmits(['cancel', 'submit']);
const toast = useToast(); const toast = useToast();
const config = useConfigStore(); const config = useConfigStore();
const convertInProgress = ref(false);
const showAdvanced = useStorage('task.showAdvanced', false);
const showImport = useStorage('showImport', false); const showImport = useStorage('showImport', false);
const import_string = ref(''); const import_string = ref('');
@ -269,10 +248,6 @@ const props = defineProps({
const form = reactive(props.task); const form = reactive(props.task);
onMounted(() => { 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) { if (!props.task?.preset || '' === props.task.preset) {
form.preset = toRaw(config.app.default_preset); form.preset = toRaw(config.app.default_preset);
} }
@ -304,60 +279,17 @@ const checkInfo = async () => {
return; return;
} }
if (typeof form.config === 'object') { if (form?.cli && '' !== form.cli) {
form.config = JSON.stringify(form.config, null, 4); const options = await convertOptions(form.cli);
} if (null === options) {
return
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;
} }
form.cli = form.cli.trim(" ")
} }
emitter('submit', { reference: toRaw(props.reference), task: toRaw(form) }); 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 () => { const importItem = async () => {
let val = import_string.value.trim() let val = import_string.value.trim()
if (!val) { if (!val) {
@ -384,7 +316,7 @@ const importItem = async () => {
return 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?')) { if (false === confirm('This will overwrite the current form fields. Are you sure?')) {
return return
} }
@ -398,8 +330,8 @@ const importItem = async () => {
form.url = item.url form.url = item.url
} }
if (item.preset) { if (item.template) {
form.preset = item.preset form.template = item.template
} }
if (item.timer) { if (item.timer) {
@ -410,12 +342,19 @@ const importItem = async () => {
form.folder = item.folder form.folder = item.folder
} }
if (item.template) { if (item.cli) {
form.template = item.template form.cli = item.cli
} }
if (item.config) { if (item.preset) {
form.config = JSON.stringify(item.config, null, 2) // -- 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 = '' import_string.value = ''
@ -424,4 +363,35 @@ const importItem = async () => {
toast.error(`Failed to import string. ${e.message}`) 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> </script>

View file

@ -71,7 +71,17 @@ div.is-centered {
</header> </header>
<div class="card-content"> <div class="card-content">
<div class="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 class="icon"><i class="fa-solid fa-f" /></span>
<span v-text="item.format" /> <span v-text="item.format" />
</p> </p>
@ -83,6 +93,10 @@ div.is-centered {
<span class="icon"><i class="fa-solid fa-file" /></span> <span class="icon"><i class="fa-solid fa-file" /></span>
<span>{{ item.template }}</span> <span>{{ item.template }}</span>
</p> </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"> <p class="is-text-overflow" v-if="item.cookies">
<span class="icon"><i class="fa-solid fa-cookie" /></span> <span class="icon"><i class="fa-solid fa-cookie" /></span>
<span>Has cookies</span> <span>Has cookies</span>
@ -293,12 +307,9 @@ const exportItem = item => {
} }
}) })
if (data.args && typeof data.args === "string") { if (has_data(data?.args) || has_data(data?.postprocessors)) {
data.args = JSON.parse(data.args) toast.error("v1.0 presets are no longer supported target for export. Please update your preset.")
} return
if (data.postprocessors && typeof data.postprocessors === "string") {
data.postprocessors = JSON.parse(data.postprocessors)
} }
let userData = {} let userData = {}
@ -311,7 +322,7 @@ const exportItem = item => {
} }
userData['_type'] = 'preset' userData['_type'] = 'preset'
userData['_version'] = '1.0' userData['_version'] = '2.0'
return copyText(base64UrlEncode(JSON.stringify(userData))) return copyText(base64UrlEncode(JSON.stringify(userData)))
} }

View file

@ -39,8 +39,7 @@ div.is-centered {
</div> </div>
<div class="is-hidden-mobile"> <div class="is-hidden-mobile">
<span class="subtitle"> <span class="subtitle">
The task runner is simple queue system that allows you to schedule downloads. The tasks are run at the The task runner is simple queue system that allows you to schedule downloads to run at the specific time.
scheduled time.
</span> </span>
</div> </div>
</div> </div>
@ -62,10 +61,6 @@ div.is-centered {
<a class="has-text-primary" v-tooltip="'Export task.'" @click.prevent="exportItem(item)"> <a class="has-text-primary" v-tooltip="'Export task.'" @click.prevent="exportItem(item)">
<span class="icon"><i class="fa-solid fa-file-export" /></span> <span class="icon"><i class="fa-solid fa-file-export" /></span>
</a> </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> </div>
</header> </header>
<div class="card-content"> <div class="card-content">
@ -86,11 +81,10 @@ div.is-centered {
<span class="icon"><i class="fa-solid fa-tv" /></span> <span class="icon"><i class="fa-solid fa-tv" /></span>
<span>{{ item.preset ?? config.app.default_preset }}</span> <span>{{ item.preset ?? config.app.default_preset }}</span>
</p> </p>
</div> <p class="is-text-overflow">
</div> <span class="icon"><i class="fa-solid fa-terminal" /></span>
<div class="card-content" v-if="item?.raw"> <span>{{ item.cli }}</span>
<div class="content"> </p>
<pre><code>{{ filterItem(item) }}</code></pre>
</div> </div>
</div> </div>
<div class="card-footer"> <div class="card-footer">
@ -120,17 +114,6 @@ div.is-centered {
icon="fas fa-exclamation-circle" v-if="!tasks || tasks.length < 1" /> icon="fas fa-exclamation-circle" v-if="!tasks || tasks.length < 1" />
</div> </div>
</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> </div>
</template> </template>
@ -202,6 +185,8 @@ const resetForm = (closeForm = false) => {
} }
const updateTasks = async items => { const updateTasks = async items => {
let data = {}
try { try {
addInProgress.value = true addInProgress.value = true
@ -213,7 +198,7 @@ const updateTasks = async items => {
body: JSON.stringify(items), body: JSON.stringify(items),
}) })
const data = await response.json() data = await response.json()
if (200 !== response.status) { if (200 !== response.status) {
toast.error(`Failed to update task. ${data.error}`); toast.error(`Failed to update task. ${data.error}`);
@ -224,7 +209,7 @@ const updateTasks = async items => {
resetForm(true) resetForm(true)
return true return true
} catch (e) { } catch (e) {
toast.error(`Failed to update task. ${data.error}`); toast.error(`Failed to update task. ${data?.error ?? e.message}`);
} finally { } finally {
addInProgress.value = false addInProgress.value = false
} }
@ -272,11 +257,6 @@ const updateItem = async ({ reference, task }) => {
resetForm(true) resetForm(true)
} }
const filterItem = item => {
const { raw, ...rest } = item
return JSON.stringify(rest, null, 2)
}
const editItem = item => { const editItem = item => {
task.value = item task.value = item
taskRef.value = item.id taskRef.value = item.id
@ -327,21 +307,8 @@ const runNow = item => {
data.template = item.template data.template = item.template
} }
if (item.cookies) { if (item.cli) {
data.cookies = item.cookies data.cli = item.cli
}
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
}
} }
socket.emit('add_url', data) socket.emit('add_url', data)
@ -359,69 +326,26 @@ const statusHandler = async stream => {
} }
const exportItem = async item => { 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)) const info = JSON.parse(JSON.stringify(item))
preset = JSON.parse(JSON.stringify(preset))
let data = { let data = {
name: info.name, name: info.name,
url: info.url, url: info.url,
preset: 'default', preset: info.preset,
timer: info.timer, timer: info.timer,
folder: info.folder, folder: info.folder,
template: info.template,
config: info.config,
} }
// -- merge preset options with task args. if (info.template) {
let args = {} data.template = info.template
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 (preset.postprocessors && preset.postprocessors.length > 0) { if (info.cli) {
args.postprocessors = preset.postprocessors data.cli = info.cli
}
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]
} }
data['_type'] = 'task' data['_type'] = 'task'
data['_version'] = '1.1' data['_version'] = '2.0'
return copyText(base64UrlEncode(JSON.stringify(data))); return copyText(base64UrlEncode(JSON.stringify(data)));
} }

View file

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

View file

@ -428,6 +428,38 @@ const base64UrlDecode = input => {
return atob(base64); 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 { export {
ag_set, ag_set,
ag, ag,
@ -452,4 +484,5 @@ export {
convertCliOptions, convertCliOptions,
base64UrlEncode, base64UrlEncode,
base64UrlDecode, base64UrlDecode,
has_data,
} }