+ + +
+diff --git a/app/library/EventsSubscriber.py b/app/library/EventsSubscriber.py
index 45d8b659..755fd471 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"
@@ -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)
diff --git a/app/library/HttpAPI.py b/app/library/HttpAPI.py
index afaa3390..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:
"""
@@ -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(
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/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/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/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)
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 @@
+
+
+
+
+ Import preset from JSON string.
+
+
+
+
+
+
+
+ Convert yt-dlp cli options.
+
+
+
+
+