diff --git a/.vscode/settings.json b/.vscode/settings.json
index 39644ba2..ff695099 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -30,9 +30,11 @@
"dateparser",
"daterange",
"defusedxml",
+ "dlfields",
"dotenv",
"edgechromium",
"engineio",
+ "Errno",
"euuo",
"faststart",
"finaldir",
diff --git a/FAQ.md b/FAQ.md
new file mode 100644
index 00000000..a605e451
--- /dev/null
+++ b/FAQ.md
@@ -0,0 +1,41 @@
+# I cant download anything
+
+If you are receiving errors like:
+- "OSError: [Errno 5] I/O error"
+- "OSError: [Errno 18] Cross-device link: '/tmp/random_id/name.webm' -> '/downloads/name.webm'
+- "Operation not permitted: '/downloads/name.webm'
+
+This indicates an error with your mounts and how they interact with the container. So, the basic solution is to do the following:
+
+```yaml
+services:
+ ytptube:
+ user: "${UID:-1000}:${UID:-1000}" # change this to your user id and group id, for example: "1000:1000"
+ image: ghcr.io/arabcoders/ytptube:latest
+ container_name: ytptube
+ restart: unless-stopped
+ environment:
+ - YTP_TEMP_PATH=/downloads/tmp
+ - YTP_DOWNLOAD_PATH=/downloads/files
+ ports:
+ - "8081:8081"
+ volumes:
+ - ./config:/config:rw
+ - ./downloads:/downloads:rw
+```
+
+Then run the following command to create the necessary directories and start the container:
+
+```bash
+mkdir -p ./config && mkdir -p ./downloads/{tmp,files} && docker compose -f compose.yaml up -d
+```
+
+Reference: [Issue #363](https://github.com/arabcoders/ytptube/issues/363)
+
+# I want to use link with playlist but only download the video not all the videos in the playlist?
+
+Simply create a preset, and in the `Command options for yt-dlp` field set `--no-playlist`. Then select the preset
+whenever the link includes a playlist id.
+
+> [!NOTE]
+> You can also do the same via advanced options `Command options for yt-dlp` field, but presets are more convenient.
diff --git a/app/library/Events.py b/app/library/Events.py
index 7f51c0cc..a987386d 100644
--- a/app/library/Events.py
+++ b/app/library/Events.py
@@ -58,6 +58,9 @@ class Events:
PRESETS_ADD: str = "presets_add"
PRESETS_UPDATE: str = "presets_update"
+ DLFIELDS_ADD: str = "dlfields_add"
+ DLFIELDS_UPDATE: str = "dlfields_update"
+
SCHEDULE_ADD: str = "schedule_add"
CONDITIONS_ADD: str = "conditions_add"
@@ -104,6 +107,7 @@ class Events:
Events.CLI_CLOSE,
Events.CLI_OUTPUT,
Events.PRESETS_UPDATE,
+ Events.DLFIELDS_UPDATE,
]
def only_debug() -> list:
diff --git a/app/library/dl_fields.py b/app/library/dl_fields.py
new file mode 100644
index 00000000..15ee8fdc
--- /dev/null
+++ b/app/library/dl_fields.py
@@ -0,0 +1,344 @@
+import json
+import logging
+import re
+import uuid
+from dataclasses import dataclass, field
+from enum import Enum
+from pathlib import Path
+from typing import Any
+
+from aiohttp import web
+
+from .config import Config
+from .encoder import Encoder
+from .Events import EventBus, Events
+from .Singleton import Singleton
+from .Utils import init_class
+
+LOG = logging.getLogger("DLFields")
+
+
+class FieldType(str, Enum):
+ STRING = "string"
+ TEXT = "text"
+ BOOL = "bool"
+
+ @classmethod
+ def all(cls) -> list[str]:
+ return [member.value for member in cls]
+
+ @classmethod
+ def from_value(cls, value: str) -> "FieldType":
+ """
+ Returns the FieldType enum member corresponding to the given value.
+
+ Args:
+ value (str): The value to match against the enum members.
+
+ Returns:
+ StoreType: The enum member that matches the value.
+
+ Raises:
+ ValueError: If the value does not match any member.
+
+ """
+ for member in cls:
+ if member.value == value:
+ return member
+
+ msg = f"Invalid StoreType value: {value}"
+ raise ValueError(msg)
+
+ def __str__(self) -> str:
+ return self.value
+
+
+@dataclass(kw_only=True)
+class DLField:
+ id: str = field(default_factory=lambda: str(uuid.uuid4()))
+ """The id of the field."""
+
+ name: str
+ """The name of the preset."""
+
+ description: str
+ """The description of the preset."""
+
+ field: str
+ """The yt-dlp field to use in long format."""
+
+ kind: FieldType = FieldType.TEXT
+ """The kind of the field. i.e. string, bool"""
+
+ icon: str = ""
+ """The icon of the field, it can be a font-awesome icon"""
+
+ order: int = 0
+ """The order of the field, used to sort the fields in the UI."""
+
+ value: str = ""
+ """The default value of the field, It's currently unused."""
+
+ extras: dict = field(default_factory=dict)
+ """Additional options for the field."""
+
+ def serialize(self) -> dict:
+ dct = self.__dict__
+ dct["kind"] = str(self.kind)
+ dct["extras"] = {k: v for k, v in self.extras.items() if v is not None}
+ return dct
+
+ def json(self) -> str:
+ return Encoder().encode(self.serialize())
+
+ def get(self, key: str, default: Any = None) -> Any:
+ return self.serialize().get(key, default)
+
+
+class DLFields(metaclass=Singleton):
+ """
+ This class is used to manage the DLFields.
+ """
+
+ _items: list[DLField] = []
+ """The list of items."""
+
+ _instance = None
+ """The instance of the class."""
+
+ _config: Config = None
+ """The config instance."""
+
+ def __init__(self, file: str | Path | None = None, config: Config | None = None):
+ DLFields._instance = self
+
+ self._config = config or Config.get_instance()
+
+ self._file: Path = Path(file) if file else Path(self._config.config_path).joinpath("dl_fields.json")
+
+ if self._file.exists() and "600" != self._file.stat().st_mode:
+ try:
+ self._file.chmod(0o600)
+ except Exception:
+ pass
+
+ def event_handler(_, __):
+ msg = "Not implemented"
+ raise Exception(msg)
+
+ EventBus.get_instance().subscribe(Events.DLFIELDS_ADD, event_handler, f"{__class__.__name__}.add")
+
+ @staticmethod
+ def get_instance() -> "DLFields":
+ """
+ Get the instance of the class.
+
+ Returns:
+ DLFields: The instance of the class
+
+ """
+ if not DLFields._instance:
+ DLFields._instance = DLFields()
+
+ return DLFields._instance
+
+ async def on_shutdown(self, _: web.Application):
+ pass
+
+ def attach(self, _: web.Application):
+ """
+ Attach the class to the aiohttp application.
+
+ Args:
+ _ (web.Application): The aiohttp application.
+
+ Returns:
+ None
+
+ """
+ self.load()
+
+ def get_all(self) -> list[DLField]:
+ """Return the items."""
+ return self._items
+
+ def load(self) -> "DLFields":
+ """
+ Load the items.
+
+ Returns:
+ Presets: The current instance.
+
+ """
+ has: int = len(self._items)
+ self.clear()
+
+ if not self._file.exists() or self._file.stat().st_size < 10:
+ return self
+
+ try:
+ LOG.info(f"{'Reloading' if has else 'Loading'} '{self._file}'.")
+ items: dict = json.loads(self._file.read_text())
+ except Exception as e:
+ LOG.error(f"Failed to parse '{self._file}'. '{e}'.")
+ return self
+
+ if not items or len(items) < 1:
+ return self
+
+ need_save = False
+
+ for i, item in enumerate(items):
+ try:
+ if "id" not in item:
+ item["id"] = str(uuid.uuid4())
+ need_save = True
+
+ item: DLField = init_class(DLField, item)
+
+ self._items.append(item)
+ except Exception as e:
+ LOG.error(f"Failed to parse '{self._file}:{i}'. '{e!s}'.")
+ continue
+
+ if need_save:
+ LOG.info(f"Saving '{self._file}'.")
+ self.save(self._items)
+
+ return self
+
+ def clear(self) -> "DLFields":
+ """
+ Clear all items.
+
+ Returns:
+ DLFields: The current instance.
+
+ """
+ if len(self._items) < 1:
+ return self
+
+ self._items.clear()
+
+ return self
+
+ def validate(self, item: DLField | dict) -> bool:
+ """
+ Validate the item.
+
+ Args:
+ item (DLField|dict): The item to validate.
+
+ Returns:
+ bool: True if valid
+
+ Raises:
+ ValueError: If the item is not valid.
+
+ """
+ if not isinstance(item, dict):
+ if not isinstance(item, DLField):
+ msg = f"Unexpected '{type(item).__name__}' type was given."
+ raise ValueError(msg) # noqa: TRY004
+
+ item = item.serialize()
+
+ for key in ["id", "name", "description", "field", "kind"]:
+ if key not in item:
+ msg = f"Missing required key '{key}'."
+ raise ValueError(msg)
+
+ if item.get("kind") not in FieldType.all():
+ msg = f"Invalid field type '{item.get('kind')}'."
+ raise ValueError(msg)
+
+ if item.get("extras") and not isinstance(item.get("extras"), dict):
+ msg = "Extras must be a dictionary."
+ raise ValueError(msg)
+
+ if item.get("value") and not isinstance(item.get("value"), str):
+ msg = "Value must be a string."
+ raise ValueError(msg)
+
+ if item.get("order") is not None and not isinstance(item.get("order"), int):
+ msg = "Order must be an integer."
+ raise ValueError(msg)
+
+ if not isinstance(item.get("extras", {}), dict):
+ msg = "Extras must be a dictionary."
+ raise ValueError(msg) # noqa: TRY004
+
+ if re.match(r"^--[a-zA-Z0-9\-]+$", item.get("field", "").strip()) is None:
+ msg = "Invalid yt-dlp option field it must starts with '--' and contain only alphanumeric characters."
+ raise ValueError(msg)
+
+ return True
+
+ def save(self, items: list[DLField | dict]) -> "DLFields":
+ """
+ Save the items.
+
+ Args:
+ items (list[DLField]): The items to save.
+
+ Returns:
+ Presets: The current instance.
+
+ """
+ for i, item in enumerate(items):
+ try:
+ if not isinstance(item, DLField):
+ item: DLField = init_class(DLField, item)
+ items[i] = item
+ except Exception as e:
+ LOG.error(f"Failed to save item '{i}' due to parsing error. '{e!s}'.")
+ continue
+
+ try:
+ self.validate(item)
+ except ValueError as e:
+ LOG.error(f"Failed to validate item '{i}: {item.name}'. '{e}'.")
+ continue
+
+ try:
+ self._file.write_text(json.dumps(obj=[item.serialize() for item in items], indent=4))
+ LOG.info(f"Saved '{self._file}'.")
+ except Exception as e:
+ LOG.error(f"Failed to save '{self._file}'. '{e!s}'.")
+
+ return self
+
+ def has(self, id_or_name: str) -> bool:
+ """
+ Check if the item exists by id or name.
+
+ Args:
+ id_or_name (str): The id or name of the item.
+
+ Returns:
+ bool: True if exists, False otherwise.
+
+ """
+ return self.get(id_or_name) is not None
+
+ def get(self, id_or_name: str) -> DLField | None:
+ """
+ Get the item by id or name.
+
+ Args:
+ id_or_name (str): The id or name of the item.
+
+ Returns:
+ Preset|None: The item if found, None otherwise.
+
+ """
+ if not id_or_name:
+ return None
+
+ for item in self.get_all():
+ if id_or_name not in (item.id, item.name):
+ continue
+
+ return item
+
+ return None
diff --git a/app/main.py b/app/main.py
index 549ace11..c960d0e1 100644
--- a/app/main.py
+++ b/app/main.py
@@ -19,6 +19,7 @@ from aiohttp import web
from app.library.BackgroundWorker import BackgroundWorker
from app.library.conditions import Conditions
from app.library.config import Config
+from app.library.dl_fields import DLFields
from app.library.DownloadQueue import DownloadQueue
from app.library.Events import EventBus, Events
from app.library.HttpAPI import HttpAPI
@@ -127,6 +128,7 @@ class Main:
Presets.get_instance().attach(self._app)
Notification.get_instance().attach(self._app)
Conditions.get_instance().attach(self._app)
+ DLFields.get_instance().attach(self._app)
self._background_worker.attach(self._app)
EventBus.get_instance().sync_emit(
diff --git a/app/routes/api/dl_fields.py b/app/routes/api/dl_fields.py
new file mode 100644
index 00000000..87f67dd2
--- /dev/null
+++ b/app/routes/api/dl_fields.py
@@ -0,0 +1,100 @@
+import logging
+import uuid
+
+from aiohttp import web
+from aiohttp.web import Request, Response
+
+from app.library.dl_fields import DLField, DLFields
+from app.library.encoder import Encoder
+from app.library.Events import EventBus, Events
+from app.library.router import route
+from app.library.Utils import init_class, validate_uuid
+
+LOG: logging.Logger = logging.getLogger(__name__)
+
+
+@route("GET", "api/dl_fields/", "dl_fields")
+async def dl_fields(request: Request, encoder: Encoder) -> Response:
+ """
+ Get the dl_fields.
+
+ Args:
+ request (Request): The request object.
+ encoder (Encoder): The encoder instance.
+
+ Returns:
+ Response: The response object.
+
+ """
+ data: list[DLField] = DLFields.get_instance().get_all()
+ filter_fields: str | None = request.query.get("filter", None)
+
+ if filter_fields:
+ fields: list[str] = [field.strip() for field in filter_fields.split(",")]
+ data = [{key: value for key, value in preset.serialize().items() if key in fields} for preset in data]
+
+ return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode)
+
+
+@route("PUT", "api/dl_fields/", "dl_fields_add")
+async def dl_fields_add(request: Request, encoder: Encoder, notify: EventBus) -> Response:
+ """
+ Add presets.
+
+ Args:
+ request (Request): The request object.
+ encoder (Encoder): The encoder instance.
+ notify (EventBus): The event bus instance.
+
+ 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,
+ )
+
+ items: list[DLField] = []
+
+ cls = DLFields.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("id", None) or not validate_uuid(item.get("id"), version=4):
+ item["id"] = str(uuid.uuid4())
+
+ 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,
+ )
+
+ items.append(init_class(DLField, item))
+
+ try:
+ items = cls.save(items=items).load().get_all()
+ except Exception as e:
+ LOG.exception(e)
+ return web.json_response(
+ {"error": "Failed to save download fields.", "message": str(e)},
+ status=web.HTTPInternalServerError.status_code,
+ )
+
+ await notify.emit(Events.DLFIELDS_UPDATE, data=items)
+ return web.json_response(data=items, status=web.HTTPOk.status_code, dumps=encoder.encode)
diff --git a/app/routes/socket/connection.py b/app/routes/socket/connection.py
index bce02997..37c2b74e 100644
--- a/app/routes/socket/connection.py
+++ b/app/routes/socket/connection.py
@@ -6,6 +6,7 @@ from typing import Any
import socketio
from app.library.config import Config
+from app.library.dl_fields import DLFields
from app.library.DownloadQueue import DownloadQueue
from app.library.Events import EventBus, Events
from app.library.Presets import Presets
@@ -26,6 +27,7 @@ async def connect(config: Config, queue: DownloadQueue, notify: EventBus, sid: s
**queue.get(),
"config": config.frontend(),
"presets": Presets.get_instance().get_all(),
+ "dl_fields": DLFields.get_instance().get_all(),
"paused": queue.is_paused(),
}
diff --git a/ui/app/components/DLFields.vue b/ui/app/components/DLFields.vue
new file mode 100644
index 00000000..e9545844
--- /dev/null
+++ b/ui/app/components/DLFields.vue
@@ -0,0 +1,276 @@
+
+
+
+
+
diff --git a/ui/app/components/DLInput.vue b/ui/app/components/DLInput.vue
new file mode 100644
index 00000000..e06797cc
--- /dev/null
+++ b/ui/app/components/DLInput.vue
@@ -0,0 +1,47 @@
+
+