diff --git a/app/features/conditions/models.py b/app/features/conditions/models.py index fc0604d2..088cb0b2 100644 --- a/app/features/conditions/models.py +++ b/app/features/conditions/models.py @@ -17,7 +17,7 @@ class ConditionModel(Base): ) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) - name: Mapped[str] = mapped_column(String(255), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) filter: Mapped[str] = mapped_column(Text, nullable=False) cli: Mapped[str] = mapped_column(Text, nullable=False, default="") enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) diff --git a/app/features/conditions/repository.py b/app/features/conditions/repository.py index 08eee466..32def7dd 100644 --- a/app/features/conditions/repository.py +++ b/app/features/conditions/repository.py @@ -19,7 +19,7 @@ from sqlalchemy import delete, func, or_, select from app.features.conditions.models import ConditionModel from app.features.core.deps import get_session -LOG: logging.Logger = logging.getLogger("app.features.conditions.repository") +LOG: logging.Logger = logging.getLogger(__name__) class ConditionsRepository(metaclass=Singleton): diff --git a/app/features/conditions/service.py b/app/features/conditions/service.py index 716512a3..1ede3af8 100644 --- a/app/features/conditions/service.py +++ b/app/features/conditions/service.py @@ -7,7 +7,6 @@ from app.features.conditions.repository import ConditionsRepository from app.library.Events import EventBus, Events from app.library.mini_filter import match_str from app.library.Singleton import Singleton -from app.library.Utils import arg_converter LOG: logging.Logger = logging.getLogger("feature.conditions") @@ -32,62 +31,6 @@ class Conditions(metaclass=Singleton): async def get_all(self) -> list[ConditionModel]: return await self._repo.list() - def validate(self, item: ConditionModel | dict) -> bool: - if not isinstance(item, dict): - if not isinstance(item, ConditionModel): - msg: str = f"Unexpected '{type(item).__name__}' item type." - raise ValueError(msg) - - item = item.serialize() - - if not item.get("id"): - msg: str = "No id found." - raise ValueError(msg) - - if not item.get("name"): - msg: str = "No name found." - raise ValueError(msg) - - if not item.get("filter"): - msg: str = "No filter found." - raise ValueError(msg) - - try: - match_str(item.get("filter"), {}) - except Exception as e: - msg: str = f"Invalid filter. '{e!s}'." - raise ValueError(msg) from e - - if item.get("cli"): - try: - arg_converter(args=item.get("cli")) - except Exception as e: - msg = f"Invalid command options for yt-dlp. '{e!s}'." - raise ValueError(msg) from e - - if not isinstance(item.get("extras"), dict): - msg = "Extras must be a dictionary." - raise ValueError(msg) - - if item.get("enabled") is not None and not isinstance(item.get("enabled"), bool): - msg = "Enabled must be a boolean." - raise ValueError(msg) - - if item.get("priority") is not None: - priority = item.get("priority") - if not isinstance(priority, int): - msg = "Priority must be an integer." - raise ValueError(msg) - if priority < 0: - msg = "Priority must be >= 0." - raise ValueError(msg) - - if item.get("description") and not isinstance(item.get("description"), str): - msg = "Description must be a string." - raise ValueError(msg) - - return True - async def save(self, item: ConditionModel | dict) -> ConditionModel: """ Save the item. diff --git a/app/features/conditions/tests/test_repository.py b/app/features/conditions/tests/test_conditions_repository.py similarity index 100% rename from app/features/conditions/tests/test_repository.py rename to app/features/conditions/tests/test_conditions_repository.py diff --git a/app/features/dl_fields/__init__.py b/app/features/dl_fields/__init__.py new file mode 100644 index 00000000..3a0532ca --- /dev/null +++ b/app/features/dl_fields/__init__.py @@ -0,0 +1 @@ +"""DL Fields Feature""" diff --git a/app/features/dl_fields/deps.py b/app/features/dl_fields/deps.py new file mode 100644 index 00000000..3cb9af30 --- /dev/null +++ b/app/features/dl_fields/deps.py @@ -0,0 +1,5 @@ +from app.features.dl_fields.repository import DLFieldsRepository + + +def get_dl_fields_repo() -> DLFieldsRepository: + return DLFieldsRepository.get_instance() diff --git a/app/features/dl_fields/migration.py b/app/features/dl_fields/migration.py new file mode 100644 index 00000000..31c5e2a9 --- /dev/null +++ b/app/features/dl_fields/migration.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from app.features.core.migration import Migration as FeatureMigration +from app.features.dl_fields.schemas import DLField +from app.library.config import Config + +if TYPE_CHECKING: + from app.features.dl_fields.repository import DLFieldsRepository + +LOG: logging.Logger = logging.getLogger(__name__) + + +class Migration(FeatureMigration): + name: str = "dl_fields" + + def __init__(self, repo: DLFieldsRepository, config: Config | None = None): + self._config: Config = config or Config.get_instance() + super().__init__(config=self._config) + self._repo: DLFieldsRepository = repo + self._source_file: Path = Path(self._config.config_path) / "dl_fields.json" + + async def should_run(self) -> bool: + return self._source_file.exists() + + async def migrate(self) -> None: + if await self._repo.count() > 0: + LOG.warning("DL fields already exist in the database; skipping migration.") + await self._move_file(self._source_file) + return + + try: + items: list[dict[str, Any]] | None = json.loads(self._source_file.read_text()) + except Exception as exc: + LOG.exception("Failed to read %s: %s. Ignoring", self._source_file, exc) + await self._move_file(self._source_file) + return + + if items is None: + LOG.warning("No dl fields found in %s; skipping migration.", self._source_file) + await self._move_file(self._source_file) + return + + inserted = 0 + for index, item in enumerate(items): + if not (normalized := self._normalize(item, index)): + continue + try: + await self._repo.create(normalized) + inserted += 1 + except Exception as exc: + LOG.exception("Failed to insert dl field '%s': %s", normalized["name"], exc) + + LOG.info("Migrated %s dl field(s) from %s.", inserted, self._source_file) + await self._move_file(self._source_file) + + def _normalize(self, item: Any, index: int) -> dict[str, Any] | None: + if not isinstance(item, dict): + LOG.warning("Skipping dl field at index %s due to invalid type.", index) + return None + + name: str | None = item.get("name") + if not name or not isinstance(name, str): + LOG.warning("Skipping dl field at index %s due to missing name.", index) + return None + + description: str = item.get("description") if isinstance(item.get("description"), str) else "" + field_value: str | None = item.get("field") + if not field_value or not isinstance(field_value, str): + LOG.warning("Skipping dl field '%s' due to missing field value.", name) + return None + + kind: str = item.get("kind") if isinstance(item.get("kind"), str) else "text" + icon: str = item.get("icon") if isinstance(item.get("icon"), str) else "" + value: str = item.get("value") if isinstance(item.get("value"), str) else "" + extras: dict[str, Any] = item.get("extras") if isinstance(item.get("extras"), dict) else {} + + order_value: int = 0 + if isinstance(item.get("order"), int) and not isinstance(item.get("order"), bool): + order_value = item.get("order", 0) + + payload = { + "name": name, + "description": description, + "field": field_value, + "kind": kind, + "icon": icon, + "order": order_value, + "value": value, + "extras": extras, + } + + try: + DLField.model_validate(payload) + except Exception as exc: + LOG.warning("Skipping dl field '%s' due to validation error: %s", name, exc) + return None + + return payload diff --git a/app/features/dl_fields/models.py b/app/features/dl_fields/models.py new file mode 100644 index 00000000..1c831903 --- /dev/null +++ b/app/features/dl_fields/models.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from datetime import datetime # noqa: TC003 + +from sqlalchemy import JSON, Index, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.features.core.models import Base, UTCDateTime, utcnow + + +class DLFieldModel(Base): + __tablename__: str = "dl_fields" + __table_args__: tuple[Index, ...] = ( + Index("ix_dl_fields_name", "name"), + Index("ix_dl_fields_order", "order"), + Index("ix_dl_fields_kind", "kind"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) + description: Mapped[str] = mapped_column(Text, nullable=False, default="") + field: Mapped[str] = mapped_column(String(255), nullable=False) + kind: Mapped[str] = mapped_column(String(50), nullable=False, default="text") + icon: Mapped[str] = mapped_column(String(255), nullable=False, default="") + order: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + value: Mapped[str] = mapped_column(Text, nullable=False, default="") + extras: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict) + created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, onupdate=utcnow, nullable=False) diff --git a/app/features/dl_fields/repository.py b/app/features/dl_fields/repository.py new file mode 100644 index 00000000..96502c6e --- /dev/null +++ b/app/features/dl_fields/repository.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from sqlalchemy import delete, func, or_, select + +from app.features.core.deps import get_session +from app.features.dl_fields.migration import Migration +from app.features.dl_fields.models import DLFieldModel +from app.library.Singleton import Singleton + +if TYPE_CHECKING: + from collections.abc import AsyncGenerator, Iterable + + from sqlalchemy.engine.result import Result + from sqlalchemy.ext.asyncio import AsyncSession + from sqlalchemy.sql.elements import ColumnElement + from sqlalchemy.sql.selectable import Select + +LOG: logging.Logger = logging.getLogger(__name__) + + +class DLFieldsRepository(metaclass=Singleton): + def __init__(self, session: AsyncGenerator[AsyncSession] | None = None) -> None: + self._migrated = False + self.session: AsyncGenerator[AsyncSession] = session or get_session + + async def run_migrations(self) -> None: + if self._migrated: + return + + self._migrated = True + await Migration(repo=self).run() + + @staticmethod + def get_instance() -> DLFieldsRepository: + return DLFieldsRepository() + + async def list(self) -> list[DLFieldModel]: + async with self.session() as session: + result: Result[tuple[DLFieldModel]] = await session.execute( + select(DLFieldModel).order_by(DLFieldModel.order.asc(), DLFieldModel.name.asc()) + ) + return list(result.scalars().all()) + + async def list_paginated(self, page: int, per_page: int) -> tuple[list[DLFieldModel], int, int, int]: + async with self.session() as session: + total: int = await self.count() + total_pages: int = (total + per_page - 1) // per_page if total > 0 else 1 + + if page > total_pages and total > 0: + page = total_pages + + query: Select[tuple[DLFieldModel]] = ( + select(DLFieldModel) + .order_by(DLFieldModel.order.asc(), DLFieldModel.name.asc()) + .limit(per_page) + .offset((page - 1) * per_page) + ) + result: Result[tuple[DLFieldModel]] = await session.execute(query) + return list(result.scalars().all()), total, page, total_pages + + async def count(self) -> int: + async with self.session() as session: + result: Result[tuple[int]] = await session.execute(select(func.count()).select_from(DLFieldModel)) + return int(result.scalar_one()) + + async def get(self, identifier: int | str) -> DLFieldModel | None: + async with self.session() as session: + if not identifier: + return None + + if isinstance(identifier, int): + clause: ColumnElement[bool] = DLFieldModel.id == identifier + elif isinstance(identifier, str) and identifier.isdigit(): + clause = or_(DLFieldModel.id == int(identifier), DLFieldModel.name == identifier) + else: + clause = DLFieldModel.name == identifier + + result: Result[tuple[DLFieldModel]] = await session.execute(select(DLFieldModel).where(clause).limit(1)) + return result.scalar_one_or_none() + + async def get_by_name(self, name: str, exclude_id: int | None = None) -> DLFieldModel | None: + async with self.session() as session: + query: Select[tuple[DLFieldModel]] = select(DLFieldModel).where(DLFieldModel.name == name) + if exclude_id is not None: + query = query.where(DLFieldModel.id != exclude_id) + + result: Result[tuple[DLFieldModel]] = await session.execute(query.limit(1)) + return result.scalar_one_or_none() + + async def create(self, payload: DLFieldModel | dict) -> DLFieldModel: + async with self.session() as session: + model: DLFieldModel = DLFieldModel(**payload) if isinstance(payload, dict) else payload + + if await self.get_by_name(name=model.name) is not None: + msg: str = f"DL field with name '{model.name}' already exists." + raise ValueError(msg) + + session.add(model) + await session.commit() + await session.refresh(model) + return model + + async def update(self, identifier: int | str, payload: dict[str, Any]) -> DLFieldModel: + async with self.session() as session: + if isinstance(identifier, int): + clause: ColumnElement[bool] = DLFieldModel.id == identifier + elif isinstance(identifier, str) and identifier.isdigit(): + clause = or_(DLFieldModel.id == int(identifier), DLFieldModel.name == identifier) + else: + clause = DLFieldModel.name == identifier + + result: Result[tuple[DLFieldModel]] = await session.execute(select(DLFieldModel).where(clause).limit(1)) + model: DLFieldModel | None = result.scalar_one_or_none() + + if not model: + msg: str = f"DL field '{identifier}' not found." + raise KeyError(msg) + + payload.pop("id", None) + + if "name" in payload and await self.get_by_name(name=payload["name"], exclude_id=model.id) is not None: + msg = f"DL field with name '{payload['name']}' already exists." + raise ValueError(msg) + + for key, value in payload.items(): + if hasattr(model, key): + setattr(model, key, value) + + await session.commit() + await session.refresh(model) + return model + + async def delete(self, identifier: int | str) -> DLFieldModel: + async with self.session() as session: + if isinstance(identifier, int): + clause: ColumnElement[bool] = DLFieldModel.id == identifier + elif isinstance(identifier, str) and identifier.isdigit(): + clause = or_(DLFieldModel.id == int(identifier), DLFieldModel.name == identifier) + else: + clause = DLFieldModel.name == identifier + + result: Result[tuple[DLFieldModel]] = await session.execute(select(DLFieldModel).where(clause).limit(1)) + model = result.scalar_one_or_none() + + if not model: + msg: str = f"DL field '{identifier}' not found." + raise KeyError(msg) + + await session.delete(model) + await session.commit() + return model + + async def replace_all(self, items: Iterable[dict | DLFieldModel]) -> list[DLFieldModel]: + async with self.session() as session: + try: + await session.execute(delete(DLFieldModel)) + models: list[DLFieldModel] = [ + DLFieldModel(**item) if isinstance(item, dict) else item for item in items + ] + session.add_all(models) + await session.commit() + except Exception: + await session.rollback() + raise + return models diff --git a/app/features/dl_fields/router.py b/app/features/dl_fields/router.py new file mode 100644 index 00000000..ae5c484a --- /dev/null +++ b/app/features/dl_fields/router.py @@ -0,0 +1,169 @@ +import logging +from typing import Any + +from aiohttp import web +from aiohttp.web import Request, Response +from pydantic import ValidationError + +from app.features.core.schemas import Pagination +from app.features.core.utils import build_pagination, format_validation_errors, normalize_pagination +from app.features.dl_fields.schemas import DLField, DLFieldList, DLFieldPatch +from app.features.dl_fields.service import DLFields +from app.library.encoder import Encoder +from app.library.Events import EventBus, Events +from app.library.router import route + +LOG: logging.Logger = logging.getLogger(__name__) + + +def _model(model: Any) -> DLField: + return DLField.model_validate(model) + + +def _serialize(model: Any) -> dict: + return _model(model).model_dump() + + +@route("GET", "api/dl_fields/", "dl_fields") +async def dl_fields_list(request: Request, encoder: Encoder) -> Response: + repo = DLFields.get_instance()._repo + page, per_page = normalize_pagination(request) + items, total, current_page, total_pages = await repo.list_paginated(page, per_page) + return web.json_response( + data=DLFieldList( + items=[_model(model) for model in items], + pagination=Pagination.model_validate(build_pagination(total, current_page, per_page, total_pages)), + ), + status=web.HTTPOk.status_code, + dumps=encoder.encode, + ) + + +@route("POST", "api/dl_fields/", "dl_fields_add") +async def dl_fields_add(request: Request, encoder: Encoder, notify: EventBus) -> Response: + data = await request.json() + + if not isinstance(data, dict): + return web.json_response( + {"error": "Invalid request body expecting dict."}, + status=web.HTTPBadRequest.status_code, + ) + + try: + item: DLField = DLField.model_validate(data) + except ValidationError as exc: + return web.json_response( + data={"error": "Failed to validate dl field.", "detail": format_validation_errors(exc)}, + status=web.HTTPBadRequest.status_code, + ) + + try: + saved = _serialize(await DLFields.get_instance().save(item=item.model_dump())) + except ValueError as exc: + return web.json_response({"error": str(exc)}, status=web.HTTPBadRequest.status_code) + + notify.emit(Events.DLFIELDS_UPDATE, data=[saved]) + + return web.json_response(data=saved, status=web.HTTPOk.status_code, dumps=encoder.encode) + + +@route("GET", "api/dl_fields/{id}", "dl_fields_get") +async def dl_fields_get(request: Request, encoder: Encoder) -> Response: + if not (identifier := request.match_info.get("id")): + return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code) + + if not (model := await DLFields.get_instance().get(identifier)): + return web.json_response({"error": "DL field not found"}, status=web.HTTPNotFound.status_code) + + return web.json_response(data=_serialize(model), status=web.HTTPOk.status_code, dumps=encoder.encode) + + +@route("DELETE", "api/dl_fields/{id}", "dl_fields_delete") +async def dl_fields_delete(request: Request, encoder: Encoder) -> Response: + if not (identifier := request.match_info.get("id")): + return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code) + + try: + return web.json_response( + data=_serialize(await DLFields.get_instance()._repo.delete(identifier)), + status=web.HTTPOk.status_code, + dumps=encoder.encode, + ) + except KeyError as exc: + return web.json_response({"error": str(exc)}, status=web.HTTPNotFound.status_code) + + +@route("PATCH", "api/dl_fields/{id}", "dl_fields_patch") +async def dl_fields_patch(request: Request, encoder: Encoder) -> Response: + if not (identifier := request.match_info.get("id")): + return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code) + + if not (model := await DLFields.get_instance().get(identifier)): + return web.json_response({"error": "DL field not found"}, status=web.HTTPNotFound.status_code) + + data = await request.json() + + if not isinstance(data, dict): + return web.json_response( + {"error": "Invalid request body expecting dict."}, + status=web.HTTPBadRequest.status_code, + ) + + try: + validated = DLFieldPatch.model_validate(data) + except ValidationError as exc: + return web.json_response( + data={"error": "Failed to validate dl field.", "detail": format_validation_errors(exc)}, + status=web.HTTPBadRequest.status_code, + ) + + if validated.name and await DLFields.get_instance()._repo.get_by_name(validated.name, exclude_id=model.id): + return web.json_response( + data={"error": f"DL field with name '{validated.name}' already exists."}, + status=web.HTTPConflict.status_code, + ) + + dct = validated.model_dump(exclude_unset=True) + + return web.json_response( + data=_serialize(await DLFields.get_instance()._repo.update(model.id, dct)), + status=web.HTTPOk.status_code, + dumps=encoder.encode, + ) + + +@route("PUT", "api/dl_fields/{id}", "dl_fields_update") +async def dl_fields_update(request: Request, encoder: Encoder) -> Response: + if not (identifier := request.match_info.get("id")): + return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code) + + if not (model := await DLFields.get_instance().get(identifier)): + return web.json_response({"error": "DL field not found"}, status=web.HTTPNotFound.status_code) + + data = await request.json() + + if not isinstance(data, dict): + return web.json_response( + {"error": "Invalid request body expecting dict."}, + status=web.HTTPBadRequest.status_code, + ) + + try: + validated = DLField.model_validate(data) + except ValidationError as exc: + return web.json_response( + data={"error": "Failed to validate dl field.", "detail": format_validation_errors(exc)}, + status=web.HTTPBadRequest.status_code, + ) + + if validated.name and await DLFields.get_instance()._repo.get_by_name(validated.name, exclude_id=model.id): + return web.json_response( + data={"error": f"DL field with name '{validated.name}' already exists."}, + status=web.HTTPConflict.status_code, + ) + + return web.json_response( + data=_serialize(await DLFields.get_instance()._repo.update(model.id, validated.model_dump(exclude_unset=True))), + status=web.HTTPOk.status_code, + dumps=encoder.encode, + ) diff --git a/app/features/dl_fields/schemas.py b/app/features/dl_fields/schemas.py new file mode 100644 index 00000000..666237a4 --- /dev/null +++ b/app/features/dl_fields/schemas.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from enum import Enum +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + +from app.features.core.schemas import Pagination +from app.features.core.utils import parse_int + + +class FieldType(str, Enum): + STRING = "string" + TEXT = "text" + BOOL = "bool" + + +class DLField(BaseModel): + model_config = ConfigDict(str_strip_whitespace=True, from_attributes=True) + + id: int | None = None + name: str = Field(min_length=1) + description: str = "" + field: str = Field(min_length=1) + kind: FieldType = FieldType.TEXT + icon: str = "" + order: int = 0 + value: str = "" + extras: dict[str, Any] = Field(default_factory=dict) + + @field_validator("field") + @classmethod + def _validate_field(cls, value: str) -> str: + if not value.startswith("--"): + msg: str = "Field must start with '--'." + raise ValueError(msg) + if " " in value: + msg = "Field must not contain spaces." + raise ValueError(msg) + return value + + @field_validator("order", mode="before") + @classmethod + def _normalize_order(cls, value: Any) -> int: + return parse_int(value, field="Order", minimum=0) + + @model_validator(mode="after") + def _normalize_extras(self) -> DLField: + if not isinstance(self.extras, dict): + msg: str = "Extras must be a dictionary." + raise ValueError(msg) + return self + + +class DLFieldPatch(BaseModel): + model_config = ConfigDict(str_strip_whitespace=True, from_attributes=True) + + name: str | None = None + description: str | None = None + field: str | None = None + kind: FieldType | None = None + icon: str | None = None + order: int | None = None + value: str | None = None + extras: dict[str, Any] | None = None + + @field_validator("field") + @classmethod + def _validate_field(cls, value: str | None) -> str | None: + if value is None: + return value + if not value.startswith("--"): + msg: str = "Field must start with '--'." + raise ValueError(msg) + if " " in value: + msg = "Field must not contain spaces." + raise ValueError(msg) + return value + + @field_validator("order", mode="before") + @classmethod + def _normalize_order(cls, value: Any) -> int | None: + if value is None: + return None + return parse_int(value, field="Order", minimum=0) + + @model_validator(mode="after") + def _normalize_extras(self) -> DLFieldPatch: + if self.extras is None: + return self + if not isinstance(self.extras, dict): + msg: str = "Extras must be a dictionary." + raise ValueError(msg) + return self + + +class DLFieldList(BaseModel): + model_config = ConfigDict(from_attributes=True) + + items: list[DLField] = Field(default_factory=list) + pagination: Pagination diff --git a/app/features/dl_fields/service.py b/app/features/dl_fields/service.py new file mode 100644 index 00000000..77e2b261 --- /dev/null +++ b/app/features/dl_fields/service.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from app.features.dl_fields.models import DLFieldModel +from app.features.dl_fields.repository import DLFieldsRepository +from app.features.dl_fields.schemas import DLField +from app.library.Events import EventBus, Events +from app.library.Singleton import Singleton + +if TYPE_CHECKING: + from aiohttp import web + +LOG: logging.Logger = logging.getLogger("feature.dl_fields") + + +class DLFields(metaclass=Singleton): + def __init__(self): + self._repo: DLFieldsRepository = DLFieldsRepository.get_instance() + + @staticmethod + def get_instance() -> DLFields: + return DLFields() + + async def on_shutdown(self, _: web.Application) -> None: + pass + + def attach(self, _: web.Application) -> None: + async def handle_event(_, __): + await self._repo.run_migrations() + + EventBus.get_instance().subscribe(Events.STARTED, handle_event, "DLFieldsRepository.run_migrations") + + async def get_all(self) -> list[DLFieldModel]: + return await self._repo.list() + + async def get_all_serialized(self) -> list[dict[str, Any]]: + items = await self._repo.list() + return [DLField.model_validate(item).model_dump() for item in items] + + async def save(self, item: DLField | dict) -> DLFieldModel: + try: + if not isinstance(item, DLField): + item = DLField.model_validate(item) + except Exception as exc: + msg: str = f"Failed to parse item. '{exc!s}'" + raise ValueError(msg) from exc + + try: + repo = self._repo + if item.id is None or 0 == item.id: + model = await repo.create(item.model_dump(exclude_unset=True)) + else: + model = await repo.update(item.id, item.model_dump(exclude_unset=True)) + except KeyError as exc: + raise ValueError(str(exc)) from exc + + return model + + async def get(self, identifier: int | str) -> DLFieldModel | None: + return await self._repo.get(identifier) diff --git a/app/features/dl_fields/tests/test_dl_fields.py b/app/features/dl_fields/tests/test_dl_fields.py new file mode 100644 index 00000000..9f7c408e --- /dev/null +++ b/app/features/dl_fields/tests/test_dl_fields.py @@ -0,0 +1,76 @@ +"""Tests for dl_fields feature service.""" + +from __future__ import annotations + +import pytest +import pytest_asyncio + +from app.features.dl_fields.repository import DLFieldsRepository +from app.features.dl_fields.service import DLFields +from app.library.sqlite_store import SqliteStore + + +@pytest_asyncio.fixture +async def repo(tmp_path): + """Provide a fresh repository instance with initialized database for each test.""" + DLFieldsRepository._reset_singleton() + DLFields._reset_singleton() + SqliteStore._reset_singleton() + + store = SqliteStore(db_path=":memory:") + await store.get_connection() + + repository = DLFieldsRepository.get_instance() + + yield repository + + if store._conn: + await store._conn.close() + if store._engine: + await store._engine.dispose() + + DLFieldsRepository._reset_singleton() + DLFields._reset_singleton() + SqliteStore._reset_singleton() + + +class TestDLFieldsService: + """Test suite for DLFields service methods.""" + + @pytest.mark.asyncio + async def test_save_creates_field(self, repo): + """Save should create a new dl field when ID is missing.""" + service = DLFields.get_instance() + payload = { + "name": "quality", + "description": "Video quality", + "field": "--format", + "kind": "string", + "order": 1, + "extras": {"options": ["best", "worst"]}, + } + + model = await service.save(payload) + + assert model.id is not None, "Should create new dl field" + assert model.name == "quality", "Should store name correctly" + + @pytest.mark.asyncio + async def test_get_all_serialized(self, repo): + """Get all serialized returns list of dictionaries.""" + service = DLFields.get_instance() + await service.save( + { + "name": "audio_only", + "description": "Audio", + "field": "--extract-audio", + "kind": "bool", + "order": 2, + } + ) + + items = await service.get_all_serialized() + + assert len(items) == 1, "Should return one dl field" + assert items[0]["name"] == "audio_only", "Should serialize name" + assert isinstance(items[0]["id"], int), "Should serialize integer ID" diff --git a/app/features/dl_fields/tests/test_dl_fields_repository.py b/app/features/dl_fields/tests/test_dl_fields_repository.py new file mode 100644 index 00000000..031b1d83 --- /dev/null +++ b/app/features/dl_fields/tests/test_dl_fields_repository.py @@ -0,0 +1,270 @@ +"""Tests for DLFieldsRepository.""" + +from __future__ import annotations + +import pytest +import pytest_asyncio + +from app.features.dl_fields.models import DLFieldModel +from app.features.dl_fields.repository import DLFieldsRepository +from app.library.sqlite_store import SqliteStore + + +@pytest_asyncio.fixture +async def repo(tmp_path): + """Provide a fresh repository instance with initialized database for each test.""" + DLFieldsRepository._reset_singleton() + SqliteStore._reset_singleton() + + store = SqliteStore(db_path=str(":memory:")) + await store.get_connection() + + repository = DLFieldsRepository.get_instance() + + yield repository + + if store._conn: + await store._conn.close() + if store._engine: + await store._engine.dispose() + + DLFieldsRepository._reset_singleton() + SqliteStore._reset_singleton() + + +class TestDLFieldsRepository: + """Test suite for DLFieldsRepository database operations.""" + + @pytest.mark.asyncio + async def test_repository_singleton(self, repo): + """Verify repository follows singleton pattern.""" + instance1 = DLFieldsRepository.get_instance() + instance2 = DLFieldsRepository.get_instance() + assert instance1 is instance2, "Should return same singleton instance" + + @pytest.mark.asyncio + async def test_list_empty(self, repo): + """List returns empty when no fields exist.""" + fields = await repo.list() + assert fields == [], "Should return empty list when no fields" + + @pytest.mark.asyncio + async def test_count_empty(self, repo): + """Count returns 0 when no fields exist.""" + count = await repo.count() + assert count == 0, "Should return 0 when no fields exist" + + @pytest.mark.asyncio + async def test_create_field(self, repo): + """Create field with valid data.""" + data = { + "name": "quality", + "description": "Video quality setting", + "field": "--format", + "kind": "string", + "icon": "fa-video", + "order": 1, + "value": "best", + "extras": {"options": ["best", "worst"]}, + } + + model = await repo.create(data) + + assert model.id is not None, "Should generate ID for new field" + assert model.name == "quality", "Should store name correctly" + assert model.description == "Video quality setting", "Should store description correctly" + assert model.field == "--format", "Should store field correctly" + assert model.kind == "string", "Should store kind correctly" + assert model.icon == "fa-video", "Should store icon correctly" + assert model.order == 1, "Should store order correctly" + assert model.value == "best", "Should store value correctly" + assert model.extras == {"options": ["best", "worst"]}, "Should store extras as dict" + + @pytest.mark.asyncio + async def test_create_with_defaults(self, repo): + """Create field with minimal data uses defaults.""" + data = { + "name": "minimal", + "description": "Minimal", + "field": "--minimal", + "kind": "text", + } + + model = await repo.create(data) + + assert model.icon == "", "Should default icon to empty string" + assert model.order == 0, "Should default order to 0" + assert model.value == "", "Should default value to empty string" + assert model.extras == {}, "Should default extras to empty dict" + + @pytest.mark.asyncio + async def test_get_by_id(self, repo): + """Get field by integer ID.""" + created = await repo.create( + { + "name": "get_test", + "description": "Get test", + "field": "--get-test", + "kind": "text", + } + ) + + retrieved = await repo.get(created.id) + + assert retrieved is not None, "Should retrieve created field" + assert retrieved.id == created.id, "Should retrieve correct field by ID" + assert retrieved.name == "get_test", "Should match created field name" + + @pytest.mark.asyncio + async def test_get_by_name(self, repo): + """Get field by string name.""" + await repo.create( + { + "name": "named_test", + "description": "Named test", + "field": "--named-test", + "kind": "text", + } + ) + + retrieved = await repo.get("named_test") + + assert retrieved is not None, "Should retrieve by name" + assert retrieved.name == "named_test", "Should match field name" + + @pytest.mark.asyncio + async def test_get_nonexistent(self, repo): + """Get nonexistent field returns None.""" + result = await repo.get(99999) + assert result is None, "Should return None for nonexistent ID" + + result = await repo.get("nonexistent") + assert result is None, "Should return None for nonexistent name" + + @pytest.mark.asyncio + async def test_update_field(self, repo): + """Update existing field.""" + created = await repo.create( + { + "name": "update_test", + "description": "Update test", + "field": "--update-test", + "kind": "text", + } + ) + + updated = await repo.update( + created.id, + { + "name": "updated_name", + "order": 5, + "extras": {"updated": True}, + }, + ) + + assert updated.name == "updated_name", "Should update name" + assert updated.order == 5, "Should update order" + assert updated.extras == {"updated": True}, "Should update extras" + assert updated.field == "--update-test", "Should preserve unchanged field" + + @pytest.mark.asyncio + async def test_update_nonexistent_raises(self, repo): + """Update nonexistent field raises KeyError.""" + with pytest.raises(KeyError, match="not found"): + await repo.update(99999, {"name": "should_fail"}) + + @pytest.mark.asyncio + async def test_delete_field(self, repo): + """Delete existing field.""" + created = await repo.create( + { + "name": "delete_test", + "description": "Delete test", + "field": "--delete-test", + "kind": "text", + } + ) + + deleted = await repo.delete(created.id) + + assert deleted.id == created.id, "Should return deleted field" + + result = await repo.get(created.id) + assert result is None, "Deleted field should not be retrievable" + + @pytest.mark.asyncio + async def test_delete_nonexistent_raises(self, repo): + """Delete nonexistent field raises KeyError.""" + with pytest.raises(KeyError, match="not found"): + await repo.delete(99999) + + @pytest.mark.asyncio + async def test_list_paginated(self, repo): + """List paginated returns correct subset.""" + for i in range(5): + await repo.create( + { + "name": f"item_{i}", + "description": "desc", + "field": f"--item-{i}", + "kind": "text", + "order": i, + } + ) + + items, total, page, total_pages = await repo.list_paginated(page=1, per_page=2) + + assert len(items) == 2, "Should return 2 items per page" + assert total == 5, "Should report total count of 5" + assert page == 1, "Should be on page 1" + assert total_pages == 3, "Should have 3 pages total" + + @pytest.mark.asyncio + async def test_list_ordering(self, repo): + """List orders by order asc then name asc.""" + await repo.create({"name": "b", "description": "b", "field": "--b", "kind": "text", "order": 1}) + await repo.create({"name": "a", "description": "a", "field": "--a", "kind": "text", "order": 1}) + await repo.create({"name": "c", "description": "c", "field": "--c", "kind": "text", "order": 0}) + + items = await repo.list() + + assert items[0].name == "c", "Lowest order should be first" + assert items[1].name == "a", "Same order sorted alphabetically" + assert items[2].name == "b", "Same order sorted alphabetically" + + @pytest.mark.asyncio + async def test_get_by_name_excludes_id(self, repo): + """Get by name can exclude specific ID.""" + first = await repo.create( + { + "name": "duplicate", + "description": "duplicate", + "field": "--duplicate", + "kind": "text", + } + ) + + result = await repo.get_by_name("duplicate", exclude_id=first.id) + assert result is None, "Should not find when excluding only match" + + result = await repo.get_by_name("duplicate", exclude_id=None) + assert result is not None, "Should find without exclusion" + + @pytest.mark.asyncio + async def test_replace_all(self, repo): + """Replace all fields atomically.""" + await repo.create({"name": "old_1", "description": "old", "field": "--old-1", "kind": "text"}) + await repo.create({"name": "old_2", "description": "old", "field": "--old-2", "kind": "text"}) + + new_items = [ + {"name": "new_1", "description": "new", "field": "--new-1", "kind": "text"}, + {"name": "new_2", "description": "new", "field": "--new-2", "kind": "text"}, + ] + + result = await repo.replace_all(new_items) + + assert len(result) == 2, "Should create 2 new fields" + + all_items = await repo.list() + assert len(all_items) == 2, "Should only have new fields" + assert {item.name for item in all_items} == {"new_1", "new_2"}, "Should only have new items" diff --git a/app/library/dl_fields.py b/app/library/dl_fields.py deleted file mode 100644 index f901c4d3..00000000 --- a/app/library/dl_fields.py +++ /dev/null @@ -1,333 +0,0 @@ -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 diff --git a/app/main.py b/app/main.py index cb53ca5b..0f5e5b06 100644 --- a/app/main.py +++ b/app/main.py @@ -15,10 +15,10 @@ import magic from aiohttp import web from app.features.conditions.service import Conditions +from app.features.dl_fields.service import DLFields from app.library.BackgroundWorker import BackgroundWorker from app.library.cache import Cache from app.library.config import Config -from app.library.dl_fields import DLFields from app.library.downloads import DownloadQueue from app.library.Events import EventBus, Events from app.library.HttpAPI import HttpAPI diff --git a/app/migrations/20260119162444_add_dl_fields_table.py b/app/migrations/20260119162444_add_dl_fields_table.py new file mode 100644 index 00000000..d318976b --- /dev/null +++ b/app/migrations/20260119162444_add_dl_fields_table.py @@ -0,0 +1,40 @@ +""" +This module contains a db migration. + +Migration Name: add_dl_fields_table +Migration Version: 20260119162444 +""" + +from sqlalchemy import text + + +async def upgrade(c): + sql: list[str] = [ + """ + CREATE TABLE IF NOT EXISTS "dl_fields" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "name" TEXT NOT NULL UNIQUE, + "description" TEXT NOT NULL DEFAULT '', + "field" TEXT NOT NULL, + "kind" TEXT NOT NULL DEFAULT 'text', + "icon" TEXT NOT NULL DEFAULT '', + "order" INTEGER NOT NULL DEFAULT 0, + "value" TEXT NOT NULL DEFAULT '', + "extras" JSON NOT NULL DEFAULT '{}', + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + """, + 'CREATE INDEX IF NOT EXISTS "ix_dl_fields_name" ON "dl_fields" ("name");', + 'CREATE INDEX IF NOT EXISTS "ix_dl_fields_order" ON "dl_fields" ("order");', + 'CREATE INDEX IF NOT EXISTS "ix_dl_fields_kind" ON "dl_fields" ("kind");', + ] + for sql_stmt in sql: + await c.execute(text(sql_stmt)) + + +async def downgrade(c): + sql = """ + DROP TABLE IF EXISTS "dl_fields"; + """ + await c.execute(text(sql)) diff --git a/app/routes/api/dl_fields.py b/app/routes/api/dl_fields.py index c8b0fac2..5bd99bb4 100644 --- a/app/routes/api/dl_fields.py +++ b/app/routes/api/dl_fields.py @@ -1,100 +1,3 @@ -import logging -import uuid +"""Migrated API routes for dl_fields feature.""" -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, - ) - - notify.emit(Events.DLFIELDS_UPDATE, data=items) - return web.json_response(data=items, status=web.HTTPOk.status_code, dumps=encoder.encode) +import app.features.dl_fields.router # noqa: F401 diff --git a/app/routes/socket/connection.py b/app/routes/socket/connection.py index dd761fb9..b5f80a09 100644 --- a/app/routes/socket/connection.py +++ b/app/routes/socket/connection.py @@ -5,8 +5,8 @@ from typing import Any import socketio +from app.features.dl_fields.service import DLFields from app.library.config import Config -from app.library.dl_fields import DLFields from app.library.downloads import DownloadQueue from app.library.Events import EventBus, Events from app.library.Presets import Presets @@ -28,7 +28,7 @@ async def connect(config: Config, queue: DownloadQueue, notify: EventBus, sid: s data={ "config": config.frontend(), "presets": Presets.get_instance().get_all(), - "dl_fields": DLFields.get_instance().get_all(), + "dl_fields": await DLFields.get_instance().get_all_serialized(), "paused": queue.is_paused(), }, title="Client connected", diff --git a/app/tests/test_dl_fields.py b/app/tests/test_dl_fields.py deleted file mode 100644 index ba7a9e6c..00000000 --- a/app/tests/test_dl_fields.py +++ /dev/null @@ -1,621 +0,0 @@ -""" -Tests for dl_fields.py - Download fields management. - -This test suite provides comprehensive coverage for the dl_fields module: -- Tests FieldType enum functionality -- Tests DLField dataclass functionality -- Tests DLFields singleton class behavior -- Tests field loading, saving, and validation -- Tests field CRUD operations -- Tests error handling and edge cases - -Total test functions: 25+ -All dl_fields management functionality and edge cases are covered. -""" - -import json -import tempfile -import uuid -from pathlib import Path -from unittest.mock import MagicMock, patch - -import pytest - -from app.library.dl_fields import DLField, DLFields, FieldType - - -class TestFieldType: - """Test the FieldType enum.""" - - def test_field_type_values(self): - """Test FieldType enum values.""" - assert FieldType.STRING == "string" - assert FieldType.TEXT == "text" - assert FieldType.BOOL == "bool" - - def test_field_type_all(self): - """Test FieldType.all() method.""" - all_types = FieldType.all() - expected = ["string", "text", "bool"] - assert all_types == expected - assert len(all_types) == 3 - - def test_field_type_from_value_valid(self): - """Test FieldType.from_value() with valid values.""" - assert FieldType.from_value("string") == FieldType.STRING - assert FieldType.from_value("text") == FieldType.TEXT - assert FieldType.from_value("bool") == FieldType.BOOL - - def test_field_type_from_value_invalid(self): - """Test FieldType.from_value() with invalid values.""" - with pytest.raises(ValueError, match="Invalid StoreType value"): - FieldType.from_value("invalid") - - with pytest.raises(ValueError, match="Invalid StoreType value"): - FieldType.from_value("number") - - with pytest.raises(ValueError, match="Invalid StoreType value"): - FieldType.from_value("") - - def test_field_type_str(self): - """Test FieldType string conversion.""" - assert str(FieldType.STRING) == "string" - assert str(FieldType.TEXT) == "text" - assert str(FieldType.BOOL) == "bool" - - -class TestDLField: - """Test the DLField dataclass.""" - - def test_dl_field_creation_with_defaults(self): - """Test creating a DLField with default values.""" - field = DLField(name="test_field", description="Test description", field="--test-option") - - assert field.id, "Check that ID is generated" - assert isinstance(field.id, str) - - # Check required fields - assert field.name == "test_field" - assert field.description == "Test description" - assert field.field == "--test-option" - - assert field.kind == FieldType.TEXT, "Check defaults" - assert field.icon == "" - assert field.order == 0 - assert field.value == "" - assert field.extras == {} - - def test_dl_field_creation_with_all_fields(self): - """Test creating a DLField with all fields specified.""" - test_id = str(uuid.uuid4()) - extras = {"key": "value", "number": 42} - - field = DLField( - id=test_id, - name="custom_field", - description="Custom description", - field="--custom-option", - kind=FieldType.BOOL, - icon="fa-check", - order=5, - value="default_value", - extras=extras, - ) - - assert field.id == test_id - assert field.name == "custom_field" - assert field.description == "Custom description" - assert field.field == "--custom-option" - assert field.kind == FieldType.BOOL - assert field.icon == "fa-check" - assert field.order == 5 - assert field.value == "default_value" - assert field.extras == extras - - def test_dl_field_serialize(self): - """Test DLField serialization.""" - extras = {"key": "value", "none_value": None} - field = DLField(name="test", description="Test field", field="--test", kind=FieldType.STRING, extras=extras) - - serialized = field.serialize() - - assert serialized["name"] == "test" - assert serialized["description"] == "Test field" - assert serialized["field"] == "--test" - assert serialized["kind"] == "string" - assert serialized["extras"] == {"key": "value"} # None values filtered out - - def test_dl_field_json(self): - """Test DLField JSON encoding.""" - field = DLField(name="json_test", description="JSON test field", field="--json-test") - - json_str = field.json() - assert isinstance(json_str, str) - - # Should be valid JSON - parsed = json.loads(json_str) - assert parsed["name"] == "json_test" - assert parsed["description"] == "JSON test field" - assert parsed["field"] == "--json-test" - - def test_dl_field_get_method(self): - """Test DLField get method.""" - field = DLField(name="get_test", description="Get test field", field="--get-test", order=3) - - assert field.get("name") == "get_test" - assert field.get("order") == 3 - assert field.get("nonexistent") is None - assert field.get("nonexistent", "default") == "default" - - -class TestDLFields: - """Test the DLFields class.""" - - def setup_method(self): - """Set up test fixtures.""" - DLFields._reset_singleton() - - @pytest.fixture - def temp_file(self): - """Create a temporary file for testing.""" - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - temp_path = Path(f.name) - yield temp_path - if temp_path.exists(): - temp_path.unlink() - - @pytest.fixture - def sample_fields_data(self): - """Sample field data for testing.""" - return [ - { - "id": str(uuid.uuid4()), - "name": "quality", - "description": "Video quality setting", - "field": "--format", - "kind": "string", - "icon": "fa-video", - "order": 1, - "value": "best", - "extras": {"options": ["best", "worst", "720p"]}, - }, - { - "id": str(uuid.uuid4()), - "name": "audio_only", - "description": "Extract audio only", - "field": "--extract-audio", - "kind": "bool", - "icon": "fa-music", - "order": 2, - "value": "", - "extras": {}, - }, - ] - - @patch("app.library.dl_fields.Config") - def test_dl_fields_singleton_behavior(self, mock_config): - """Test that DLFields follows singleton pattern.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - fields1 = DLFields() - fields2 = DLFields() - assert fields1 is fields2 - - @patch("app.library.dl_fields.Config") - def test_dl_fields_get_instance(self, mock_config): - """Test DLFields.get_instance() method.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - fields1 = DLFields.get_instance() - fields2 = DLFields.get_instance() - assert fields1 is fields2 - - @patch("app.library.dl_fields.Config") - def test_dl_fields_initialization(self, mock_config): - """Test DLFields initialization.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - with tempfile.TemporaryDirectory() as temp_dir: - temp_file = Path(temp_dir) / "test_fields.json" - - fields = DLFields(file=str(temp_file)) - - assert fields._file == temp_file - - @patch("app.library.dl_fields.Config") - def test_dl_fields_load_empty_file(self, mock_config, temp_file): - """Test loading from empty/non-existent file.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - fields = DLFields(file=str(temp_file)) - result = fields.load() - - assert result is fields - assert fields.get_all() == [] - - @patch("app.library.dl_fields.Config") - def test_dl_fields_load_valid_file(self, mock_config, temp_file, sample_fields_data): - """Test loading from valid JSON file.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - # Write sample data to temp file - temp_file.write_text(json.dumps(sample_fields_data, indent=2)) - - fields = DLFields(file=str(temp_file)) - fields.load() - - loaded_fields = fields.get_all() - assert len(loaded_fields) == 2 - - # Check first field - field1 = loaded_fields[0] - assert field1.name == "quality" - assert field1.description == "Video quality setting" - assert field1.field == "--format" - assert field1.kind == FieldType.STRING - - # Check second field - field2 = loaded_fields[1] - assert field2.name == "audio_only" - assert field2.kind == FieldType.BOOL - - @patch("app.library.dl_fields.Config") - def test_dl_fields_load_invalid_json(self, mock_config, temp_file): - """Test loading from invalid JSON file.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - # Write invalid JSON - temp_file.write_text("invalid json content") - - fields = DLFields(file=str(temp_file)) - fields.load() - - assert fields.get_all() == [], "Should handle error gracefully and return empty list" - - @patch("app.library.dl_fields.Config") - def test_dl_fields_load_missing_id_auto_generation(self, mock_config, temp_file): - """Test that missing IDs are auto-generated during load.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - # Sample data without IDs - data_without_ids = [ - {"name": "test_field", "description": "Test description", "field": "--test", "kind": "string"} - ] - - temp_file.write_text(json.dumps(data_without_ids)) - - with patch.object(DLFields, "save") as mock_save: - fields = DLFields(file=str(temp_file)) - fields.load() - - # Should auto-generate ID and trigger save - loaded_fields = fields.get_all() - assert len(loaded_fields) == 1 - assert loaded_fields[0].id is not None - mock_save.assert_called_once() - - @patch("app.library.dl_fields.Config") - def test_dl_fields_clear(self, mock_config): - """Test clearing all fields.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - fields = DLFields() - - # Add some fields manually - fields._items = [ - DLField(name="test1", description="Test 1", field="--test1"), - DLField(name="test2", description="Test 2", field="--test2"), - ] - - assert len(fields.get_all()) == 2 - - result = fields.clear() - assert result is fields - assert len(fields.get_all()) == 0 - - @patch("app.library.dl_fields.Config") - def test_dl_fields_clear_empty(self, mock_config): - """Test clearing when already empty.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - fields = DLFields() - assert len(fields.get_all()) == 0 - - result = fields.clear() - assert result is fields - assert len(fields.get_all()) == 0 - - @patch("app.library.dl_fields.Config") - def test_dl_fields_validate_valid_field(self, mock_config): - """Test validation with valid DLField.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - fields = DLFields() - - valid_field = DLField( - name="valid_field", description="Valid description", field="--valid-option", kind=FieldType.STRING - ) - - assert fields.validate(valid_field) is True - - @patch("app.library.dl_fields.Config") - def test_dl_fields_validate_valid_dict(self, mock_config): - """Test validation with valid dictionary.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - fields = DLFields() - - valid_dict = { - "id": str(uuid.uuid4()), - "name": "test_field", - "description": "Test description", - "field": "--test-field", - "kind": "text", - } - - assert fields.validate(valid_dict) is True - - @patch("app.library.dl_fields.Config") - def test_dl_fields_validate_missing_required_fields(self, mock_config): - """Test validation with missing required fields.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - fields = DLFields() - - # Missing name - invalid_dict = { - "id": str(uuid.uuid4()), - "description": "Test description", - "field": "--test-field", - "kind": "text", - } - - with pytest.raises(ValueError, match="Missing required key 'name'"): - fields.validate(invalid_dict) - - @patch("app.library.dl_fields.Config") - def test_dl_fields_validate_invalid_field_type(self, mock_config): - """Test validation with invalid field type.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - fields = DLFields() - - invalid_dict = { - "id": str(uuid.uuid4()), - "name": "test_field", - "description": "Test description", - "field": "--test-field", - "kind": "invalid_type", - } - - with pytest.raises(ValueError, match="Invalid field type"): - fields.validate(invalid_dict) - - @patch("app.library.dl_fields.Config") - def test_dl_fields_validate_invalid_yt_dlp_field(self, mock_config): - """Test validation with invalid yt-dlp field format.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - fields = DLFields() - - invalid_dict = { - "id": str(uuid.uuid4()), - "name": "test_field", - "description": "Test description", - "field": "invalid-field", # Missing -- - "kind": "text", - } - - with pytest.raises(ValueError, match="Invalid yt-dlp option field"): - fields.validate(invalid_dict) - - @patch("app.library.dl_fields.Config") - def test_dl_fields_validate_invalid_extras_type(self, mock_config): - """Test validation with invalid extras type.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - fields = DLFields() - - invalid_dict = { - "id": str(uuid.uuid4()), - "name": "test_field", - "description": "Test description", - "field": "--test-field", - "kind": "text", - "extras": "not_a_dict", - } - - with pytest.raises(ValueError, match="Extras must be a dictionary"): - fields.validate(invalid_dict) - - @patch("app.library.dl_fields.Config") - def test_dl_fields_validate_invalid_value_type(self, mock_config): - """Test validation with invalid value type.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - fields = DLFields() - - invalid_dict = { - "id": str(uuid.uuid4()), - "name": "test_field", - "description": "Test description", - "field": "--test-field", - "kind": "text", - "value": 123, # Should be string - } - - with pytest.raises(ValueError, match="Value must be a string"): - fields.validate(invalid_dict) - - @patch("app.library.dl_fields.Config") - def test_dl_fields_validate_invalid_order_type(self, mock_config): - """Test validation with invalid order type.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - fields = DLFields() - - invalid_dict = { - "id": str(uuid.uuid4()), - "name": "test_field", - "description": "Test description", - "field": "--test-field", - "kind": "text", - "order": "not_an_int", - } - - with pytest.raises(ValueError, match="Order must be an integer"): - fields.validate(invalid_dict) - - @patch("app.library.dl_fields.Config") - def test_dl_fields_validate_unexpected_type(self, mock_config): - """Test validation with unexpected item type.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - fields = DLFields() - - with pytest.raises(ValueError, match="Unexpected 'str' type was given"): - fields.validate("not_a_field_or_dict") - - @patch("app.library.dl_fields.Config") - def test_dl_fields_save_valid_fields(self, mock_config, temp_file): - """Test saving valid fields.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - fields = DLFields(file=str(temp_file)) - - test_fields = [DLField(name="test_field", description="Test description", field="--test-option")] - - fields.save(test_fields) - - assert temp_file.exists(), "Verify file was written" - - # Verify content - saved_data = json.loads(temp_file.read_text()) - assert len(saved_data) == 1 - assert saved_data[0]["name"] == "test_field" - - @patch("app.library.dl_fields.Config") - def test_dl_fields_save_mixed_types(self, mock_config, temp_file): - """Test saving mix of DLField objects and dicts.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - fields = DLFields(file=str(temp_file)) - - test_items = [ - DLField(name="field1", description="Field 1", field="--field1"), - {"id": str(uuid.uuid4()), "name": "field2", "description": "Field 2", "field": "--field2", "kind": "text"}, - ] - - fields.save(test_items) - - # Verify both items were saved - saved_data = json.loads(temp_file.read_text()) - assert len(saved_data) == 2 - - @patch("app.library.dl_fields.Config") - def test_dl_fields_get_by_id(self, mock_config): - """Test getting field by ID.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - fields = DLFields() - - test_id = str(uuid.uuid4()) - test_field = DLField(id=test_id, name="test_field", description="Test description", field="--test-option") - - fields._items = [test_field] - - result = fields.get(test_id) - assert result is test_field - - @patch("app.library.dl_fields.Config") - def test_dl_fields_get_by_name(self, mock_config): - """Test getting field by name.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - fields = DLFields() - - test_field = DLField(name="test_field", description="Test description", field="--test-option") - - fields._items = [test_field] - - result = fields.get("test_field") - assert result is test_field - - @patch("app.library.dl_fields.Config") - def test_dl_fields_get_not_found(self, mock_config): - """Test getting non-existent field.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - fields = DLFields() - - result = fields.get("nonexistent") - assert result is None - - @patch("app.library.dl_fields.Config") - def test_dl_fields_get_empty_string(self, mock_config): - """Test getting with empty string.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - fields = DLFields() - - result = fields.get("") - assert result is None - - @patch("app.library.dl_fields.Config") - def test_dl_fields_has_by_id(self, mock_config): - """Test checking field existence by ID.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - fields = DLFields() - - test_id = str(uuid.uuid4()) - test_field = DLField(id=test_id, name="test_field", description="Test description", field="--test-option") - - fields._items = [test_field] - - assert fields.has(test_id) is True - assert fields.has("nonexistent") is False - - @patch("app.library.dl_fields.Config") - def test_dl_fields_has_by_name(self, mock_config): - """Test checking field existence by name.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - fields = DLFields() - - test_field = DLField(name="test_field", description="Test description", field="--test-option") - - fields._items = [test_field] - - assert fields.has("test_field") is True - assert fields.has("nonexistent") is False - - @patch("app.library.dl_fields.Config") - def test_dl_fields_attach_method(self, mock_config): - """Test attach method calls load.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - fields = DLFields() - - with patch.object(fields, "load") as mock_load: - mock_app = MagicMock() - fields.attach(mock_app) - mock_load.assert_called_once() - - @patch("app.library.dl_fields.Config") - def test_dl_fields_on_shutdown(self, mock_config): - """Test on_shutdown method.""" - mock_config.get_instance.return_value.config_path = "/tmp" - - fields = DLFields() - mock_app = MagicMock() - - # Should not raise any exceptions - import asyncio - - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - try: - loop.run_until_complete(fields.on_shutdown(mock_app)) - finally: - loop.close() diff --git a/ui/app/components/DLFieldForm.vue b/ui/app/components/DLFieldForm.vue new file mode 100644 index 00000000..7e652a1f --- /dev/null +++ b/ui/app/components/DLFieldForm.vue @@ -0,0 +1,315 @@ + + + diff --git a/ui/app/components/NewDownload.vue b/ui/app/components/NewDownload.vue index 8673be1c..b6f41029 100644 --- a/ui/app/components/NewDownload.vue +++ b/ui/app/components/NewDownload.vue @@ -176,7 +176,7 @@
- + Custom Fields @@ -208,7 +208,7 @@
- @@ -253,7 +253,7 @@
-
@@ -385,7 +385,7 @@ const updateItem = async ({ reference, item: updatedItem }: { }): Promise => { updatedItem = cleanObject(updatedItem, remove_keys) as Condition const cb = (resp: APIResponse) => { - if (resp.success){ + if (resp.success) { resetForm(true) } } diff --git a/ui/app/pages/dl_fields.vue b/ui/app/pages/dl_fields.vue new file mode 100644 index 00000000..8f961499 --- /dev/null +++ b/ui/app/pages/dl_fields.vue @@ -0,0 +1,298 @@ + + + diff --git a/ui/app/types/dl_fields.d.ts b/ui/app/types/dl_fields.d.ts index 5f29c4f4..a4c4ee72 100644 --- a/ui/app/types/dl_fields.d.ts +++ b/ui/app/types/dl_fields.d.ts @@ -2,7 +2,7 @@ type DLFieldType = "string" | "text" | "bool"; type DLField = { /** The id of the field */ - id?: string; + id?: number; /** The name of the preset */ name: string;