From 49bc28edc0dd93cd7d2a13371bdd086558a8cdda Mon Sep 17 00:00:00 2001 From: ArabCoders Date: Tue, 4 Mar 2025 02:11:17 +0300 Subject: [PATCH] Make it possible to manage presets via webui --- app/library/EventsSubscriber.py | 3 +- app/library/HttpAPI.py | 84 +++++++- app/library/HttpSocket.py | 3 +- app/library/Presets.py | 61 +++++- app/library/Utils.py | 28 +-- app/library/common.py | 2 +- app/library/config.py | 45 ---- app/library/presets.json | 24 ++- ui/assets/css/style.css | 2 +- ui/components/PresetForm.vue | 362 ++++++++++++++++++++++++++++++++ ui/layouts/default.vue | 8 +- ui/pages/presets.vue | 255 ++++++++++++++++++++++ ui/stores/SocketStore.js | 1 + 13 files changed, 794 insertions(+), 84 deletions(-) create mode 100644 ui/components/PresetForm.vue create mode 100644 ui/pages/presets.vue diff --git a/app/library/EventsSubscriber.py b/app/library/EventsSubscriber.py index 21e96adc..755fd471 100644 --- a/app/library/EventsSubscriber.py +++ b/app/library/EventsSubscriber.py @@ -44,7 +44,8 @@ class Events: TASK_FINISHED = "task_finished" TASK_ERROR = "task_error" - PRESETS_ADD = "preset_add" + PRESETS_ADD = "presets_add" + PRESETS_UPDATE = "presets_update" SCHEDULE_ADD = "schedule_add" diff --git a/app/library/HttpAPI.py b/app/library/HttpAPI.py index 43a982c8..0687db10 100644 --- a/app/library/HttpAPI.py +++ b/app/library/HttpAPI.py @@ -30,7 +30,7 @@ from .ffprobe import ffprobe from .M3u8 import M3u8 from .Notifications import Notification, NotificationEvents from .Playlist import Playlist -from .Presets import Presets +from .Presets import Preset, Presets from .Segments import Segments from .Subtitle import Subtitle from .Tasks import Task, Tasks @@ -317,7 +317,10 @@ class HttpAPI(Common): @web.middleware async def middleware_handler(request: Request, handler: RequestHandler) -> Response: if request.path.startswith("/api/download"): - realFile, status = get_file(download_path=download_path, file=request.path.replace("/api/download/", "")) + realFile, status = get_file( + download_path=download_path, + file=request.path.replace("/api/download/", ""), + ) if web.HTTPFound.status_code == status: return Response( status=status, @@ -627,6 +630,83 @@ class HttpAPI(Common): data=Presets.get_instance().get_all(), status=web.HTTPOk.status_code, dumps=self.encoder.encode ) + @route("PUT", "api/presets") + async def presets_add(self, request: Request) -> Response: + """ + Add presets. + + Args: + request (Request): The request object. + + Returns: + Response: The response object + + """ + data = await request.json() + + if not isinstance(data, list): + return web.json_response( + {"error": "Invalid request body expecting list with dicts."}, + status=web.HTTPBadRequest.status_code, + ) + + presets: list = [] + + cls = Presets.get_instance() + + for item in data: + if not isinstance(item, dict): + return web.json_response( + {"error": "Invalid request body expecting list with dicts."}, + status=web.HTTPBadRequest.status_code, + ) + + if not item.get("name"): + return web.json_response( + {"error": "name is required.", "data": item}, status=web.HTTPBadRequest.status_code + ) + + if not item.get("format"): + return web.json_response( + {"error": "format is required.", "data": item}, status=web.HTTPBadRequest.status_code + ) + + if not item.get("id", None) or not validate_uuid(item.get("id"), version=4): + item["id"] = str(uuid.uuid4()) + + if not item.get("args", None) or str(item.get("args")).strip() == "": + item["config"] = {} + + if item.get("args", None) and isinstance(item.get("args"), str): + item["args"] = json.loads(item.get("args")) + + if not item.get("postprocessors", None) or str(item.get("postprocessors")).strip() == "": + item["postprocessors"] = [] + + if item.get("postprocessors", None) and isinstance(item.get("postprocessors"), str): + item["postprocessors"] = json.loads(item.get("postprocessors")) + + try: + cls.validate(item) + except ValueError as e: + return web.json_response( + {"error": f"Failed to validate preset '{item.get('name')}'. '{e!s}'"}, + status=web.HTTPBadRequest.status_code, + ) + + presets.append(Preset(**item)) + try: + presets = cls.save(presets=presets).load().get_all() + except Exception as e: + LOG.exception(e) + return web.json_response( + {"error": "Failed to save presets.", "message": str(e)}, + status=web.HTTPInternalServerError.status_code, + ) + + await self.emitter.emit(Events.PRESETS_UPDATE, presets) + return web.json_response(data=presets, status=web.HTTPOk.status_code, dumps=self.encoder.encode) + @route("GET", "api/tasks") async def tasks(self, _: Request) -> Response: """ diff --git a/app/library/HttpSocket.py b/app/library/HttpSocket.py index 435204ca..4ba96b6f 100644 --- a/app/library/HttpSocket.py +++ b/app/library/HttpSocket.py @@ -19,6 +19,7 @@ from .DownloadQueue import DownloadQueue from .Emitter import Emitter from .encoder import Encoder from .EventsSubscriber import Event, Events, EventsSubscriber +from .Presets import Presets from .Utils import arg_converter, is_downloaded LOG = logging.getLogger("socket_api") @@ -266,7 +267,7 @@ class HttpSocket(Common): data = { **self.queue.get(), "config": self.config.frontend(), - "presets": self.config.presets, + "presets": Presets.get_instance().get_all(), "paused": self.queue.is_paused(), } diff --git a/app/library/Presets.py b/app/library/Presets.py index 3e850b6c..5e45859a 100644 --- a/app/library/Presets.py +++ b/app/library/Presets.py @@ -1,7 +1,7 @@ -import asyncio import json import logging import os +import uuid from dataclasses import dataclass, field from typing import Any @@ -18,6 +18,9 @@ LOG = logging.getLogger("presets") @dataclass(kw_only=True) class Preset: + id: str = field(default_factory=lambda: str(uuid.uuid4())) + """The id of the preset.""" + name: str """The name of the preset.""" @@ -30,6 +33,8 @@ class Preset: postprocessors: list | None = field(default_factory=list) """The postprocessors of the preset.""" + default: bool = False + def serialize(self) -> dict: return self.__dict__ @@ -51,19 +56,14 @@ class Presets(metaclass=Singleton): _instance = None """The instance of the class.""" - def __init__( - self, - file: str | None = None, - emitter: Emitter | None = None, - loop: asyncio.AbstractEventLoop | None = None, - config: Config | None = None, - ): + _default_presets: list[Preset] = [] + + def __init__(self, file: str | None = None, emitter: Emitter | None = None, config: Config | None = None): Presets._instance = self config = config or Config.get_instance() self._file: str = file or os.path.join(config.config_path, "presets.json") - self._loop: asyncio.AbstractEventLoop = loop or asyncio.get_event_loop() self._emitter: Emitter = emitter or Emitter.get_instance() if os.path.exists(self._file) and "600" != oct(os.stat(self._file).st_mode)[-3:]: @@ -75,6 +75,9 @@ class Presets(metaclass=Singleton): def handle_event(_, e: Event): self.save(**e.data) + with open(os.path.join(os.path.dirname(__file__), "presets.json")) as f: + self._default_presets = [Preset(**preset) for preset in json.load(f)] + EventsSubscriber.get_instance().subscribe(Events.PRESETS_ADD, f"{__class__}.save", handle_event) @staticmethod @@ -109,7 +112,7 @@ class Presets(metaclass=Singleton): def get_all(self) -> list[Preset]: """Return the presets.""" - return self._presets + return self._default_presets + self._presets def load(self) -> "Presets": """ @@ -136,14 +139,24 @@ class Presets(metaclass=Singleton): LOG.info(f"No presets were defined in '{self._file}'.") return self + needSaving = False + for i, preset in enumerate(presets): try: + if "id" not in preset: + preset["id"] = str(uuid.uuid4()) + needSaving = True + preset = Preset(**preset) self._presets.append(preset) except Exception as e: LOG.error(f"Failed to parse preset at list position '{i}'. '{e!s}'.") continue + if needSaving: + LOG.info("Saving presets due to missing ids.") + self.save(self._presets) + return self def clear(self) -> "Presets": @@ -179,6 +192,10 @@ class Presets(metaclass=Singleton): preset = preset.serialize() + if not preset.get("id"): + msg = "No id found." + raise ValueError(msg) + if not preset.get("name"): msg = "No name found." raise ValueError(msg) @@ -232,3 +249,27 @@ class Presets(metaclass=Singleton): LOG.error(f"Failed to save presets to '{self._file}'. '{e!s}'.") return self + + def get(self, id: str | None = None, name: str | None = None) -> Preset | None: + """ + Get the preset by id or name. + + Args: + id (str|None): The id of the preset. + name (str|None): The name of the preset. + + Returns: + Preset|None: The preset if found, None otherwise. + + """ + if not id and not name: + return None + + for preset in self.get_all(): + if preset.id == id: + return preset + + if preset.name == name: + return preset + + return None diff --git a/app/library/Utils.py b/app/library/Utils.py index fbe889a4..66a5b616 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -52,28 +52,22 @@ def get_opts(preset: str, ytdl_opts: dict) -> dict: LOG.debug("Using default preset.") return opts - from .config import Config + from .Presets import Presets - presets = Config.get_instance().presets - - found = False - for _preset in presets: - if _preset["name"] == preset: - found = True - preset_opts = _preset - break - - if not found: - LOG.error(f"Preset '{preset}' is not defined in the presets.") + p = Presets.get_instance().get(name=preset) + if not p: + LOG.error(f"Preset '{preset}' is not defined as preset.") return opts - opts["format"] = preset_opts.get("format") + opts["format"] = p.get("format") - if "postprocessors" in preset_opts: - opts["postprocessors"] = preset_opts["postprocessors"] + postprocessors = p.get("postprocessors", []) + if isinstance(postprocessors, list) and len(postprocessors) > 0: + opts["postprocessors"] = postprocessors - if "args" in preset_opts: - for key, value in preset_opts["args"].items(): + args = p.get("args", {}) + if isinstance(args, dict) and len(args) > 0: + for key, value in args.items(): opts[key] = value LOG.debug(f"Using preset '{preset}', altered options: {opts}") diff --git a/app/library/common.py b/app/library/common.py index 884bf1ce..440aa263 100644 --- a/app/library/common.py +++ b/app/library/common.py @@ -50,7 +50,7 @@ class Common: return await self.queue.add( url=url, - preset=preset if preset else "default", + preset=preset if preset else self.config.default_preset, folder=folder, cookies=cookies, config=config if isinstance(config, dict) else {}, diff --git a/app/library/config.py b/app/library/config.py index 68b0826b..624e1740 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -129,11 +129,6 @@ class Config: basic_mode: bool = False "Run the frontend in basic mode." - presets: list = [ - {"name": "default", "format": "default"}, - ] - "The presets to use for downloading." - default_preset: str = "default" "The default preset to use when no preset is specified." @@ -161,7 +156,6 @@ class Config: "new_version_available", "ytdlp_version", "started", - "presets", ) "The variables that are immutable." @@ -323,45 +317,6 @@ class Config: else: LOG.info(f"No yt-dlp custom options found at '{optsFile}'.") - # Load default presets. - with open(os.path.join(os.path.dirname(__file__), "presets.json")) as f: - self.presets.extend(json.load(f)) - - # Load user defined presets. - presetsFile = os.path.join(self.config_path, "presets.json") - if os.path.exists(presetsFile) and os.path.getsize(presetsFile) > 0: - LOG.info(f"Loading user presets from '{presetsFile}'.") - try: - (presets, status, error) = load_file(presetsFile, list) - if not status: - LOG.error(f"Could not load presets file from '{presetsFile}'. '{error}'.") - sys.exit(1) - - if not isinstance(presets, list): - LOG.error(f"Invalid presets file '{presetsFile}'. It's expected to be a list of objects.") - sys.exit(1) - - for preset in presets: - if "name" not in preset: - LOG.error(f"Missing 'name' key in preset '{preset}'.") - continue - - if "format" not in preset: - LOG.error(f"Missing 'format' key in preset '{preset}'.") - continue - - if "args" in preset and not isinstance(preset["args"], dict): - LOG.error(f"Invalid 'args' key in preset '{preset}' it's expected to be dict.") - continue - - if "postprocessors" in preset and not isinstance(preset["postprocessors"], list): - LOG.error(f"Invalid 'postprocessors' key in preset '{preset}' it's expected to be list.") - continue - - self.presets.append(preset) - except Exception: - pass - self.ytdl_options["socket_timeout"] = self.socket_timeout if self.keep_archive: diff --git a/app/library/presets.json b/app/library/presets.json index e3223210..dcc33cc3 100644 --- a/app/library/presets.json +++ b/app/library/presets.json @@ -1,27 +1,40 @@ [ { - "name": "Best video and audio", - "format": "bv+ba/b" + "id": "3e163c6c-64eb-4448-924f-814b629b3810", + "name": "default", + "format": "default", + "default": true }, { + "id": "5bf9c42b-8852-468a-99f5-915622dfba25", + "name": "Best video and audio", + "format": "bv+ba/b", + "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" ] - } + }, + "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" ] - } + }, + "default": true }, { + "id": "a6fd4b25-2b3e-458d-bb57-b75e41cc4330", "name": "Audio only", "format": "bestaudio/best", "args": { @@ -49,6 +62,7 @@ "only_multi_video": true, "when": "playlist" } - ] + ], + "default": true } ] diff --git a/ui/assets/css/style.css b/ui/assets/css/style.css index 20e9fd69..83dfe4ea 100644 --- a/ui/assets/css/style.css +++ b/ui/assets/css/style.css @@ -221,7 +221,7 @@ hr { padding-top: 0.5em; } -.play-overlay { +.play-overlay, .is-pointer { cursor: pointer; } diff --git a/ui/components/PresetForm.vue b/ui/components/PresetForm.vue new file mode 100644 index 00000000..b155187c --- /dev/null +++ b/ui/components/PresetForm.vue @@ -0,0 +1,362 @@ +