+ + +
+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 @@
+
+
+
+
+ Import preset from JSON string.
+
+
+
+
+
+
+
+ Convert yt-dlp cli options.
+
+
+
+
+