From d88ee150604801146c1640c3d9e5976040767616 Mon Sep 17 00:00:00 2001 From: ArabCoders Date: Sun, 2 Mar 2025 21:49:52 +0300 Subject: [PATCH 1/2] separated the scheduler from tasks, to allow us to schedule non add url tasks in the future --- app/library/EventsSubscriber.py | 4 + app/library/HttpAPI.py | 4 +- app/library/Scheduler.py | 136 ++++++++++++++++++++++++++++++++ app/library/Tasks.py | 74 ++++++----------- app/main.py | 6 +- 5 files changed, 173 insertions(+), 51 deletions(-) create mode 100644 app/library/Scheduler.py diff --git a/app/library/EventsSubscriber.py b/app/library/EventsSubscriber.py index 45d8b659..21e96adc 100644 --- a/app/library/EventsSubscriber.py +++ b/app/library/EventsSubscriber.py @@ -13,6 +13,9 @@ class Events: The events that can be emitted. """ + STARTUP = "startup" + SHUTDOWN = "shutdown" + ADDED = "added" UPDATED = "updated" COMPLETED = "completed" @@ -42,6 +45,7 @@ class Events: TASK_ERROR = "task_error" PRESETS_ADD = "preset_add" + SCHEDULE_ADD = "schedule_add" @dataclass(kw_only=True) diff --git a/app/library/HttpAPI.py b/app/library/HttpAPI.py index afaa3390..43a982c8 100644 --- a/app/library/HttpAPI.py +++ b/app/library/HttpAPI.py @@ -640,7 +640,7 @@ class HttpAPI(Common): """ return web.json_response( - data=Tasks.get_instance().get_tasks(), status=web.HTTPOk.status_code, dumps=self.encoder.encode + data=Tasks.get_instance().get_all(), status=web.HTTPOk.status_code, dumps=self.encoder.encode ) @route("PUT", "api/tasks") @@ -705,7 +705,7 @@ class HttpAPI(Common): tasks.append(Task(**item)) try: - tasks = ins.save(tasks=tasks).load().get_tasks() + tasks = ins.save(tasks=tasks).load().get_all() except Exception as e: LOG.exception(e) return web.json_response( diff --git a/app/library/Scheduler.py b/app/library/Scheduler.py new file mode 100644 index 00000000..215bb1d8 --- /dev/null +++ b/app/library/Scheduler.py @@ -0,0 +1,136 @@ +import asyncio +import logging + +from aiocron import Cron +from aiohttp import web + +from .EventsSubscriber import Event, Events, EventsSubscriber +from .Singleton import Singleton + +LOG = logging.getLogger("scheduler") + + +class Scheduler(metaclass=Singleton): + """ + This class is used to manage the schedule. + """ + + _jobs: dict[str, Cron] = {} + """The scheduled jobs.""" + + _instance = None + """The instance of the class.""" + + def __init__(self, loop: asyncio.AbstractEventLoop | None = None): + Scheduler._instance = self + + self._loop = loop or asyncio.get_event_loop() + + def handle_event(_, e: Event): + self.add(**e.data) + + EventsSubscriber.get_instance().subscribe(Events.SCHEDULE_ADD, f"{__class__}.add", handle_event) + + @staticmethod + def get_instance() -> "Scheduler": + """ + Get the instance of the class. + + Returns: + Scheduler: The instance of the class + + """ + if not Scheduler._instance: + Scheduler._instance = Scheduler() + return Scheduler._instance + + async def on_shutdown(self, _: web.Application): + """ + Do any jobs before shutdown. + + Args: + _: The aiohttp application. + + """ + LOG.debug("Shutting down the scheduler.") + + for job in self._jobs: + try: + job.stop() + except Exception as e: + LOG.exception(e) + LOG.error(f"Failed to stop job '{job}'. Error message '{e!s}'.") + + self._jobs = {} + + LOG.debug("Scheduler has been shut down.") + + def attach(self, app: web.Application): + app.on_shutdown.append(self.on_shutdown) + + def get_all(self) -> dict[str, Cron]: + """Return the jobs.""" + return self._jobs + + def get(self, id: str) -> Cron | None: + """Return the job by id.""" + return self._jobs.get(id) + + def add(self, timer: str, func: callable, args: tuple = (), + kwargs: dict | None = None, id: str | None = None) -> str: + """ + Add a job to the schedule. + + Args: + timer (str): The timer for the job. + func (callable): The function to call. + args (tuple): The arguments to pass to the function. + kwargs (dict): The keyword arguments to pass to the function. + id (str): The id of the job. + + Returns: + str: The id of the job + + """ + if id and id in self._jobs: + self.remove(id) + + job = Cron(spec=timer, func=func, args=args, kwargs=kwargs, uuid=id, start=True, loop=self._loop) + + job_id = str(job.uuid) + + self._jobs[job_id] = job + + LOG.debug(f"Added job {job_id} to the schedule.") + + return job_id + + def remove(self, id: str | list[str]) -> bool: + """ + Remove a job from the schedule. + + Args: + id (str|list[str]): The id of the job to remove. + + Returns: + bool: True if the job was removed, False otherwise + + """ + if isinstance(id, list): + for i in id: + self.remove(i) + return True + + if id in self._jobs: + try: + self._jobs[id].stop() + except Exception as e: + LOG.exception(e) + LOG.error(f"Failed to stop job '{id}'. Error message '{e!s}'.") + return False + + del self._jobs[id] + LOG.debug(f"Removed job {id} from the schedule.") + return True + + return False diff --git a/app/library/Tasks.py b/app/library/Tasks.py index e8c56555..0550590b 100644 --- a/app/library/Tasks.py +++ b/app/library/Tasks.py @@ -8,13 +8,13 @@ from datetime import UTC, datetime from typing import Any import httpx -from aiocron import Cron, crontab from aiohttp import web from .config import Config from .Emitter import Emitter from .encoder import Encoder from .EventsSubscriber import Event, Events, EventsSubscriber +from .Scheduler import Scheduler from .Singleton import Singleton LOG = logging.getLogger("tasks") @@ -42,21 +42,13 @@ class Task: return self.serialize().get(key, default) -@dataclass(kw_only=True) -class Job: - id: str - name: str - task: Task - job: Cron - - class Tasks(metaclass=Singleton): """ This class is used to manage the tasks. """ - _jobs: list[Job] = [] - """The jobs for the tasks.""" + _tasks: list[Task] = [] + """The tasks.""" _instance = None """The instance of the Tasks.""" @@ -69,6 +61,7 @@ class Tasks(metaclass=Singleton): config: Config | None = None, encoder: Encoder | None = None, client: httpx.AsyncClient | None = None, + scheduler: Scheduler | None = None, ): Tasks._instance = self @@ -76,11 +69,12 @@ class Tasks(metaclass=Singleton): self._debug = config.debug self._default_preset = config.default_preset - self._file: str = file or os.path.join(config.config_path, "tasks.json") - self._client: httpx.AsyncClient = client or httpx.AsyncClient() - self._encoder: Encoder = encoder or Encoder() - self._loop: asyncio.AbstractEventLoop = loop or asyncio.get_event_loop() - self._emitter: Emitter = emitter or Emitter.get_instance() + self._file = file or os.path.join(config.config_path, "tasks.json") + self._client = client or httpx.AsyncClient() + self._encoder = encoder or Encoder() + self._loop = loop or asyncio.get_event_loop() + self._emitter = emitter or Emitter.get_instance() + self._scheduler = scheduler or Scheduler.get_instance() if os.path.exists(self._file) and "600" != oct(os.stat(self._file).st_mode)[-3:]: try: @@ -96,27 +90,19 @@ class Tasks(metaclass=Singleton): @staticmethod def get_instance() -> "Tasks": """ - Get the instance of the Tasks. + Get the instance of the class. Returns: - Tasks: The instance of the Tasks + Tasks: The instance of the class. """ if not Tasks._instance: Tasks._instance = Tasks() + return Tasks._instance async def on_shutdown(self, _: web.Application): - """ - Shutdown the socket server. - - Args: - _: The aiohttp application. - - """ - LOG.debug("Shutting down tasks runner.") - self.clear(shutdown=True) - LOG.debug("Tasks runner has been shut down.") + pass def attach(self, _: web.Application): """ @@ -125,15 +111,12 @@ class Tasks(metaclass=Singleton): Args: _ (web.Application): The aiohttp application. - Returns: - None - """ self.load() - def get_tasks(self) -> list[Task]: + def get_all(self) -> list[Task]: """Return the tasks.""" - return [job.task for job in self._jobs] + return self._tasks def load(self) -> "Tasks": """ @@ -168,18 +151,13 @@ class Tasks(metaclass=Singleton): continue try: - self._jobs.append( - Job( - id=task.id, - name=task.name, - task=task, - job=crontab(spec=task.timer, func=self._runner, args=(task,), start=True, loop=self._loop), - ) - ) + self._scheduler.add(timer=task.timer, func=self._runner, args=(task,), id=task.id) + self._tasks.append(task) + LOG.info(f"Task '{i}: {task.name}' queued to be executed every '{task.timer}'.") except Exception as 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}'.") return self @@ -191,19 +169,19 @@ class Tasks(metaclass=Singleton): Tasks: The current instance. """ - if len(self._jobs) < 1: + if len(self._tasks) < 1: return self - for task in self._jobs: + for task in self._tasks: try: - LOG.info(f"Stopping job '{task.id}: {task.name}'.") - task.job.stop() + LOG.info(f"Stopping task '{task.id}: {task.name}'.") + self._scheduler.remove(task.id) except Exception as e: if not shutdown: LOG.exception(e) - LOG.error(f"Failed to stop job '{task.id}: {task.name}'. '{e!s}'.") + LOG.error(f"Failed to stop task '{task.id}: {task.name}'. '{e!s}'.") - self._jobs.clear() + self._tasks.clear() return self diff --git a/app/main.py b/app/main.py index 10c4b658..8262428f 100644 --- a/app/main.py +++ b/app/main.py @@ -12,12 +12,13 @@ from aiohttp import web from library.config import Config from library.DownloadQueue import DownloadQueue from library.Emitter import Emitter -from library.EventsSubscriber import EventsSubscriber +from library.EventsSubscriber import Events, EventsSubscriber from library.HttpAPI import HttpAPI from library.HttpSocket import HttpSocket from library.Notifications import Notification from library.PackageInstaller import PackageInstaller from library.Presets import Presets +from library.Scheduler import Scheduler from library.Tasks import Tasks LOG = logging.getLogger("app") @@ -93,9 +94,12 @@ class Main: """ Start the application. """ + EventsSubscriber.get_instance().emit(Events.STARTUP, data={"app": self._app}) + self._socket.attach(self._app) self._http.attach(self._app) self._queue.attach(self._app) + Scheduler.get_instance().attach(self._app) Tasks.get_instance().attach(self._app) Presets.get_instance().attach(self._app) From 49bc28edc0dd93cd7d2a13371bdd086558a8cdda Mon Sep 17 00:00:00 2001 From: ArabCoders Date: Tue, 4 Mar 2025 02:11:17 +0300 Subject: [PATCH 2/2] 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 @@ +