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. """ def __init__(self, file: str | Path | None = None, config: Config | None = None): self._items: list[DLField] = [] "The list of items." config: Config = config or Config.get_instance() "The configuration instance." self._file: Path = Path(file) if file else Path(config.config_path).joinpath("dl_fields.json") "The path to the file where the items are stored." if self._file.exists() and "600" != self._file.stat().st_mode: try: self._file.chmod(0o600) except Exception: pass @staticmethod def get_instance(file: str | Path | None = None, config: Config | None = None) -> "DLFields": """ Get the instance of the class. Returns: DLFields: The instance of the class """ return DLFields(file=file, config=config) 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() async def event_handler(_, __): msg = "Not implemented" raise Exception(msg) EventBus.get_instance().subscribe(Events.DLFIELDS_ADD, event_handler, f"{__class__.__name__}.add") 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) 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) 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