diff --git a/.vscode/settings.json b/.vscode/settings.json index d65acdda..ff695099 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -30,6 +30,7 @@ "dateparser", "daterange", "defusedxml", + "dlfields", "dotenv", "edgechromium", "engineio", 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..7d45d29b --- /dev/null +++ b/app/library/dl_fields.py @@ -0,0 +1,329 @@ +import json +import logging +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""" + + 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._default + 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 not isinstance(item.get("extras", {}), dict): + msg = "Extras must be a dictionary." + raise ValueError(msg) # noqa: TRY004 + + 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..d8ac9ce7 --- /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(DLFields, 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/ui/app/types/dl_fields.d.ts b/ui/app/types/dl_fields.d.ts new file mode 100644 index 00000000..848592c0 --- /dev/null +++ b/ui/app/types/dl_fields.d.ts @@ -0,0 +1,38 @@ +enum DLFieldType { STRING = "string", TEXT = "text", BOOL = "bool" } + +type DLField = { + /** The id of the field */ + id: string; + + /** The name of the preset */ + name: string; + + /** The description of the preset */ + description: string; + + /** The yt-dlp field to use in long format */ + field: string; + + /** The kind of the field. i.e. string, bool */ + kind: DLFieldType; + + /** The default value of the field, It's currently unused */ + value: string; + + /** Additional options for the field */ + extras: Record; +} + +/** + * Request payload for creating/updating DLField + */ +type DLFieldRequest = { + name: string; + description: string; + field: string; + kind: DLFieldType; + value?: string; + extras?: Record; +} + +export type { DLField, DLFieldRequest, DLFieldType };