Merge pull request #197 from arabcoders/dev

Manage presets via WebUI
This commit is contained in:
Abdulmohsen 2025-03-04 02:18:46 +03:00 committed by GitHub
commit 48c849da5a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 967 additions and 135 deletions

View file

@ -13,6 +13,9 @@ class Events:
The events that can be emitted.
"""
STARTUP = "startup"
SHUTDOWN = "shutdown"
ADDED = "added"
UPDATED = "updated"
COMPLETED = "completed"
@ -41,7 +44,9 @@ 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"
@dataclass(kw_only=True)

View file

@ -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:
"""
@ -640,7 +720,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 +785,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(

View file

@ -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(),
}

View file

@ -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

136
app/library/Scheduler.py Normal file
View file

@ -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

View file

@ -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

View file

@ -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}")

View file

@ -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 {},

View file

@ -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:

View file

@ -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
}
]

View file

@ -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)

View file

@ -221,7 +221,7 @@ hr {
padding-top: 0.5em;
}
.play-overlay {
.play-overlay, .is-pointer {
cursor: pointer;
}

View file

@ -0,0 +1,362 @@
<template>
<main class="columns mt-2 is-multiline">
<div class="column is-12">
<h1 class="is-pointer is-unselectable title is-5" @click="importExpanded = !importExpanded">
<span class="icon-text">
<span class="icon"><i class="fa-solid" :class="importExpanded ? 'fa-arrow-up' : 'fa-arrow-down'" /></span>
<span>Import preset from JSON string.</span>
</span>
</h1>
<form id="importForm" @submit.prevent="importPreset()" v-if="importExpanded">
<div class="box">
<label class="label" for="json_preset">
JSON encoded preset.
</label>
<div class="field has-addons">
<div class="control has-icons-left is-expanded">
<input type="text" class="input" id="json_preset" v-model="json_preset">
<span class="icon is-small is-left"><i class="fa-solid fa-j" /></span>
</div>
<div class="control">
<button form="importForm" class="button is-primary" :disabled="!json_preset" type="submit">
<span class="icon"><i class="fa-solid fa-add" /></span>
<span>Import</span>
</button>
</div>
</div>
</div>
</form>
</div>
<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 id="convertOpts" @submit.prevent="convertOptions()" v-if="convertExpanded">
<div class="box">
<label class="label" for="opts">
yt-dlp CLI options
</label>
<div class="field has-addons">
<div class="control has-icons-left is-expanded">
<input type="text" class="input" id="opts" v-model="opts"
placeholder="-x --audio-format mp3 -f bestaudio">
<span class="icon is-small is-left"><i class="fa-solid fa-n" /></span>
</div>
<div class="control">
<button form="convertOpts" class="button is-primary" :disabled="convertInProgress || !opts" type="submit"
:class="{ 'is-loading': convertInProgress }">
<span class="icon"><i class="fa-solid fa-cog" /></span>
<span>Convert</span>
</button>
</div>
</div>
<p class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Convert yt-dlp CLI options to JSON format. This will overwrite the current form fields.</span>
</p>
</div>
</form>
</div>
<div class="column is-12">
<form id="presetForm" @submit.prevent="checkInfo()">
<div class="box">
<div class="columns is-multiline is-mobile">
<div class="column is-12">
<h1 class="title is-6" style="border-bottom: 1px solid #dbdbdb;">
<span class="icon-text">
<span class="icon"><i class="fa-solid" :class="reference ? 'fa-cog' : 'fa-plus'" /></span>
<span>{{ reference ? 'Edit' : 'Add' }}</span>
</span>
</h1>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="name">
Preset name
</label>
<div class="control has-icons-left">
<input type="text" class="input" id="name" v-model="form.name" :disabled="addInProgress">
<span class="icon is-small is-left"><i class="fa-solid fa-n" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The name to refers to this custom settings.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="format">
Format
</label>
<div class="control has-icons-left">
<input type="text" class="input" id="format" v-model="form.format" :disabled="addInProgress">
<span class="icon is-small is-left"><i class="fa-solid fa-f" /></span>
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>The yt-dlp <code>[--format, -f]</code> video format code. see <NuxtLink
href="https://github.com/yt-dlp/yt-dlp?tab=readme-ov-file#format-selection" target="blank">this
url</NuxtLink> for more info.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="args" v-tooltip="'Extends current global yt-dlp config. (JSON)'">
JSON yt-dlp config
</label>
<div class="control">
<textarea class="textarea" id="args" v-model="form.args" :disabled="addInProgress" placeholder="{}" />
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>Extends current global yt-dlp config with given options. Some fields are ignored like
<code>cookiefile</code>, <code>paths</code>, and <code>outtmpl</code> etc. Warning: Use with caution
some of those options can break yt-dlp or the frontend.</span>
</span>
</div>
</div>
<div class="column is-6-tablet is-12-mobile">
<div class="field">
<label class="label is-inline" for="postprocessors" v-tooltip="'Things to do after download is done.'">
JSON yt-dlp Post-Processors
</label>
<div class="control">
<textarea class="textarea" id="postprocessors" v-model="form.postprocessors"
:disabled="addInProgress" />
</div>
<span class="help">
<span class="icon"><i class="fa-solid fa-info" /></span>
<span>
Post-processing operations, refer to <NuxtLink
href="https://github.com/yt-dlp/yt-dlp/tree/master/yt_dlp/postprocessor" target="blank">this url
</NuxtLink> for more info. It's easier for you to use the <b>Convert CLI options</b> to get what you
want and it will auto-populate the fields if necessary.
</span>
</span>
</div>
</div>
<div class="column is-12">
<div class="field is-grouped is-grouped-right">
<p class="control">
<button class="button is-primary" :disabled="addInProgress" type="submit"
:class="{ 'is-loading': addInProgress }" form="presetForm">
<span class="icon"><i class="fa-solid fa-save" /></span>
<span>Save</span>
</button>
</p>
<p class="control">
<button class="button is-danger" @click="emitter('cancel')" :disabled="addInProgress" type="button">
<span class="icon"><i class="fa-solid fa-times" /></span>
<span>Cancel</span>
</button>
</p>
</div>
</div>
</div>
</div>
</form>
</div>
</main>
</template>
<script setup>
const emitter = defineEmits(['cancel', 'submit']);
const props = defineProps({
reference: {
type: String,
required: false,
default: null,
},
preset: {
type: Object,
required: true,
},
addInProgress: {
type: Boolean,
required: false,
default: false,
},
presets: {
type: Array,
required: false,
default: () => [],
},
})
const toast = useToast();
const convertInProgress = ref(false);
const form = reactive(props.preset);
const opts = ref('');
const json_preset = ref('');
const importExpanded = ref(false);
const convertExpanded = ref(false);
onMounted(() => {
if (props.preset?.args && (typeof props.preset.args === 'object')) {
form.args = JSON.stringify(props.preset.args, null, 2);
}
if (props.preset?.postprocessors && (typeof props.preset.postprocessors === 'object')) {
form.postprocessors = JSON.stringify(props.preset.postprocessors, null, 2);
}
})
const checkInfo = async () => {
const required = ['name', 'format'];
for (const key of required) {
if (!form[key]) {
toast.error(`The ${key} field is required.`);
return;
}
}
let copy = JSON.parse(JSON.stringify(form));
let usedName = false;
let name = String(form.name).trim().toLowerCase();
props.presets.forEach(p => {
if (p.id === props.reference) {
return;
}
if (String(p.name).toLowerCase() === name) {
usedName = true;
}
});
if (true === usedName) {
toast.error('The preset name is already in use.');
return;
}
if (typeof copy.args === 'object') {
copy.config = JSON.stringify(copy.args, null, 2);
}
if (typeof copy.postprocessors === 'object') {
copy.postprocessors = JSON.stringify(copy.postprocessors, null, 2);
}
if (copy.args) {
try {
copy.args = JSON.parse(copy.args)
} catch (e) {
toast.error(`Invalid JSON yt-dlp config. ${e.message}`)
return;
}
}
if (copy.postprocessors) {
try {
copy.postprocessors = JSON.parse(copy.postprocessors)
} catch (e) {
toast.error(`Invalid JSON yt-dlp Post-Processors. ${e.message}`)
return;
}
}
emitter('submit', { reference: toRaw(props.reference), preset: toRaw(copy) });
}
const convertOptions = async () => {
if (convertInProgress.value) {
return
}
if (form.format || form.args || form.postprocessors) {
if (false === confirm('This will overwrite the current form fields. Are you sure?')) {
return
}
}
try {
convertInProgress.value = true
const response = await convertCliOptions(opts.value)
if (!response.opts) {
toast.error('Failed to convert options.')
return
}
if (response.opts.format) {
form.format = response.opts.format
delete response.opts.format
}
if (response.opts.postprocessors) {
form.postprocessors = JSON.stringify(response.opts.postprocessors, null, 2)
delete response.opts.postprocessors
}
form.args = JSON.stringify(response.opts, null, 2)
opts.value = ''
} catch (e) {
toast.error(e.message)
} finally {
convertInProgress.value = false
}
}
const importPreset = async () => {
if (!json_preset) {
return
}
if (form.format || form.args || form.postprocessors) {
if (false === confirm('This will overwrite the current form fields. Are you sure?')) {
return
}
}
try {
const preset = JSON.parse(json_preset.value)
if (preset.name) {
form.name = preset.name
}
if (preset.format) {
form.format = preset.format
}
if (preset.args) {
form.args = JSON.stringify(preset.args, null, 2)
}
if (preset.postprocessors) {
form.postprocessors = JSON.stringify(preset.postprocessors, null, 2)
}
json_preset.value = ''
} catch (e) {
toast.error(`Failed to import preset. ${e.message}`)
}
}
</script>

View file

@ -23,6 +23,12 @@
</NuxtLink>
</div>
<div class="navbar-item" v-if="!config.app.basic_mode">
<NuxtLink class="button is-dark has-tooltip-bottom" to="/presets" v-tooltip.bottom="'Presets'">
<span class="icon"><i class="fa-solid fa-sliders" /></span>
</NuxtLink>
</div>
<div class="navbar-item" v-if="!config.app.basic_mode">
<NuxtLink class="button is-dark has-tooltip-bottom" to="/console" v-tooltip.bottom="'Terminal'">
<span class="icon"><i class="fa-solid fa-terminal" /></span>
@ -56,7 +62,7 @@
</button>
</div>
<div class="navbar-item">
<div class="navbar-item is-hidden-mobile">
<button class="button is-dark" @click="reloadPage">
<span class="icon"><i class="fas fa-refresh"></i></span>
</button>

255
ui/pages/presets.vue Normal file
View file

@ -0,0 +1,255 @@
<style scoped>
table.is-fixed {
table-layout: fixed;
}
div.table-container {
overflow: hidden;
}
div.is-centered {
justify-content: center;
}
</style>
<template>
<div>
<div class="mt-1 columns is-multiline">
<div class="column is-12 is-clearfix is-unselectable">
<span class="title is-4">
<span class="icon-text">
<span class="icon"><i class="fa-solid fa-sliders" /></span>
<span>Presets</span>
</span>
</span>
<div class="is-pulled-right">
<div class="field is-grouped">
<p class="control">
<button class="button is-primary" @click="resetForm(false); toggleForm = !toggleForm">
<span class="icon"><i class="fas fa-add"></i></span>
</button>
</p>
<p class="control">
<button class="button is-info" @click="reloadContent" :class="{ 'is-loading': isLoading }"
:disabled="!socket.isConnected || isLoading" v-if="presets && presets.length > 0">
<span class="icon"><i class="fas fa-refresh"></i></span>
</button>
</p>
</div>
</div>
<div class="is-hidden-mobile">
<span class="subtitle">Custom presets. The presets are simply pre-defined yt-dlp settings that you want to
apply to given download.</span>
</div>
</div>
<div class="column is-12" v-if="toggleForm">
<PresetForm :addInProgress="addInProgress" :reference="presetRef" :preset="preset" @cancel="resetForm(true);"
@submit="updateItem" :presets="presets" />
</div>
<div class="column is-12" v-if="!toggleForm">
<div class="columns is-multiline" v-if="presetsNoDefault && presetsNoDefault.length > 0">
<div class="column is-6" v-for="item in presetsNoDefault" :key="item.id">
<div class="card">
<header class="card-header">
<div class="card-header-title is-text-overflow is-block">
<NuxtLink target="_blank" :href="item.url">{{ item.name }}</NuxtLink>
</div>
<div class="card-header-icon">
<a class="has-text-primary" v-tooltip="'Copy preset.'"
@click.prevent="copyText(JSON.stringify(item))">
<span class="icon"><i class="fa-solid fa-copy" /></span>
</a>
<button @click="item.raw = !item.raw">
<span class="icon"><i class="fa-solid"
:class="{ 'fa-arrow-down': !item?.raw, 'fa-arrow-up': item?.raw }" /></span>
</button>
</div>
</header>
<div class="card-content">
<div class="content">
<p>
<span class="icon"><i class="fa-solid fa-f" /></span>
<span v-text="item.format" />
</p>
</div>
</div>
<div class="card-content" v-if="item?.raw">
<div class="content">
<pre><code>{{ filterItem(item) }}</code></pre>
</div>
</div>
<div class="card-footer">
<div class="card-footer-item">
<button class="button is-warning is-fullwidth" @click="editItem(item);">
<span class="icon"><i class="fa-solid fa-trash-can" /></span>
<span>Edit</span>
</button>
</div>
<div class="card-footer-item">
<button class="button is-danger is-fullwidth" @click="deleteItem(item)">
<span class="icon"><i class="fa-solid fa-trash" /></span>
<span>Delete</span>
</button>
</div>
</div>
</div>
</div>
</div>
<Message title="No presets" message="There are no custom defined presets."
class="is-background-warning-80 has-text-dark" icon="fas fa-exclamation-circle"
v-if="!presets || presets.length < 1" />
</div>
</div>
</div>
</template>
<script setup>
import { request } from '~/utils/index'
const toast = useToast()
const config = useConfigStore()
const socket = useSocketStore()
const presets = ref([])
const preset = ref({})
const presetRef = ref('')
const toggleForm = ref(false)
const isLoading = ref(false)
const initialLoad = ref(true)
const addInProgress = ref(false)
const presetsNoDefault = computed(() => presets.value.filter(t => !t.default))
watch(() => config.app.basic_mode, async () => {
if (!config.app.basic_mode) {
return
}
await navigateTo('/')
})
watch(() => socket.isConnected, async () => {
if (socket.isConnected && initialLoad.value) {
await reloadContent(true)
initialLoad.value = false
}
})
const reloadContent = async (fromMounted = false) => {
try {
isLoading.value = true
const response = await request('/api/presets')
if (fromMounted && !response.ok) {
return
}
const data = await response.json()
if (data.length < 1) {
return
}
presets.value = data
} catch (e) {
if (fromMounted) {
return
}
console.error(e)
toast.error('Failed to fetch tasks.')
} finally {
isLoading.value = false
}
}
const resetForm = (closeForm = false) => {
preset.value = {}
presetRef.value = null
addInProgress.value = false
if (closeForm) {
toggleForm.value = false
}
}
const updatePresets = async items => {
let data;
try {
addInProgress.value = true
const response = await request('/api/presets', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(items.filter(t => !t.default)),
})
data = await response.json()
if (200 !== response.status) {
toast.error(`Failed to update presets. ${data.error}`);
return false
}
presets.value = data
resetForm(true)
return true
} catch (e) {
toast.error(`Failed to update presets. ${data?.error}. ${e.message}`);
} finally {
addInProgress.value = false
}
}
const deleteItem = async item => {
if (true !== confirm(`Delete preset '${item.name}'?`)) {
return
}
const index = presets.value.findIndex(t => t?.id === item.id)
if (index > -1) {
presets.value.splice(index, 1)
} else {
toast.error('Preset not found.')
return
}
const status = await updatePresets(presets.value)
if (!status) {
return
}
toast.success('Preset deleted.')
}
const updateItem = async ({ reference, preset }) => {
if (reference) {
const index = presets.value.findIndex((t) => t?.id === reference)
if (index > -1) {
presets.value[index] = preset
}
} else {
presets.value.push(preset)
}
const status = await updatePresets(presets.value)
if (!status) {
return
}
toast.success(`Preset ${reference ? 'updated' : 'added'}.`)
resetForm(true)
}
const filterItem = item => {
const { raw, ...rest } = item
return JSON.stringify(rest, null, 2)
}
const editItem = item => {
preset.value = item
presetRef.value = item.id
toggleForm.value = true
}
onMounted(async () => socket.isConnected ? await reloadContent(true) : '')
</script>

View file

@ -124,6 +124,7 @@ export const useSocketStore = defineStore('socket', () => {
});
});
socket.value.on('presets_update', data => config.update('presets', JSON.parse(data)));
}
const on = (event, callback) => socket.value.on(event, callback);