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 @@ + + + diff --git a/ui/app/components/NewDownload.vue b/ui/app/components/NewDownload.vue index 55cd9f34..1ee4760f 100644 --- a/ui/app/components/NewDownload.vue +++ b/ui/app/components/NewDownload.vue @@ -88,96 +88,82 @@
-
-
- - -
- - - Ignore archive and re-download. - +
+
-
-
- - -
- - - Whether to start the download automatically. - +
+
-
-
- -
-
- -
-
- - - All output template naming options can be found at this page. - -
-
-
- -
-