From a09343938da58a3c64cb5cfa3fb559c8b2a83060 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Sun, 18 Jan 2026 22:17:07 +0300 Subject: [PATCH] refactor: conditions feature to db model --- app/features/conditions/__init__.py | 1 + app/features/conditions/deps.py | 5 + app/features/conditions/migration.py | 97 ++ app/features/conditions/models.py | 28 + app/features/conditions/repository.py | 178 ++++ app/features/conditions/router.py | 329 +++++++ app/features/conditions/schemas.py | 93 ++ app/features/conditions/service.py | 204 ++++ .../conditions/tests/test_repository.py | 230 +++++ app/features/core/deps.py | 12 + app/features/core/migration.py | 50 + app/features/core/models.py | 39 + app/features/core/schemas.py | 12 + app/features/core/utils.py | 77 ++ app/library/conditions.py | 377 -------- app/library/downloads/item_adder.py | 16 +- app/library/encoder.py | 11 +- app/main.py | 2 +- .../20260117184822_add_conditions_table.py | 39 + app/routes/api/conditions.py | 207 +--- app/tests/test_conditions.py | 893 ------------------ pyproject.toml | 1 + ui/app/components/ConditionForm.vue | 19 +- ui/app/composables/useConditions.ts | 437 +++++++++ ui/app/pages/conditions.vue | 220 ++--- ui/app/types/conditions.d.ts | 28 +- ui/app/types/index.d.ts | 6 + ui/app/types/responses.d.ts | 22 +- ui/app/utils/index.ts | 53 +- ui/tests/composables/useConditions.test.ts | 553 +++++++++++ .../composables/useTaskDefinitions.test.ts | 2 +- ui/tests/utils/index.test.ts | 4 +- ui/tests/utils/ytdlp.test.ts | 2 +- 33 files changed, 2575 insertions(+), 1672 deletions(-) create mode 100644 app/features/conditions/__init__.py create mode 100644 app/features/conditions/deps.py create mode 100644 app/features/conditions/migration.py create mode 100644 app/features/conditions/models.py create mode 100644 app/features/conditions/repository.py create mode 100644 app/features/conditions/router.py create mode 100644 app/features/conditions/schemas.py create mode 100644 app/features/conditions/service.py create mode 100644 app/features/conditions/tests/test_repository.py create mode 100644 app/features/core/deps.py create mode 100644 app/features/core/migration.py create mode 100644 app/features/core/models.py create mode 100644 app/features/core/schemas.py create mode 100644 app/features/core/utils.py delete mode 100644 app/library/conditions.py create mode 100644 app/migrations/20260117184822_add_conditions_table.py delete mode 100644 app/tests/test_conditions.py create mode 100644 ui/app/composables/useConditions.ts create mode 100644 ui/app/types/index.d.ts create mode 100644 ui/tests/composables/useConditions.test.ts diff --git a/app/features/conditions/__init__.py b/app/features/conditions/__init__.py new file mode 100644 index 00000000..c9f5d96b --- /dev/null +++ b/app/features/conditions/__init__.py @@ -0,0 +1 @@ +"""Conditions Feature""" diff --git a/app/features/conditions/deps.py b/app/features/conditions/deps.py new file mode 100644 index 00000000..81f5b1f2 --- /dev/null +++ b/app/features/conditions/deps.py @@ -0,0 +1,5 @@ +from app.features.conditions.repository import ConditionsRepository + + +def get_conditions_repo() -> ConditionsRepository: + return ConditionsRepository.get_instance() diff --git a/app/features/conditions/migration.py b/app/features/conditions/migration.py new file mode 100644 index 00000000..4f2ccf5c --- /dev/null +++ b/app/features/conditions/migration.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from app.features.conditions.models import ConditionModel +from app.features.core.migration import Migration as FeatureMigration +from app.library.config import Config + +if TYPE_CHECKING: + from app.features.conditions.repository import ConditionsRepository + +LOG: logging.Logger = logging.getLogger(__name__) + + +class Migration(FeatureMigration): + name: str = "conditions" + + def __init__(self, repo: ConditionsRepository, config: Config | None = None): + self._config: Config = config or Config.get_instance() + super().__init__(config=self._config) + self._repo: ConditionsRepository = repo + self._source_file: Path = Path(self._config.config_path) / "conditions.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("Conditions already exist in the database; skipping migration.") + await self._move_file(self._source_file) + return + + try: + items: list[dict] | 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 conditions 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 := await self._normalize(item, index)): + continue + try: + await self._repo.create(normalized) + inserted += 1 + except Exception as exc: + LOG.exception("Failed to insert condition '%s': %s", normalized.name, exc) + + LOG.info("Migrated %s condition(s) from %s.", inserted, self._source_file) + await self._move_file(self._source_file) + + async def _normalize(self, item: Any, index: int) -> ConditionModel | None: + if not isinstance(item, dict): + LOG.warning("Skipping condition 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 condition at index %s due to missing name.", index) + return None + + extras: dict[str, Any] = item.get("extras") if isinstance(item.get("extras"), dict) else {} + filter_value: str | None = item.get("filter") + if (not filter_value or not isinstance(filter_value, str)) and len(extras) == 0: + LOG.warning("Skipping condition '%s' due to missing filter.", name) + return None + + cli: str | None = item.get("cli") + if not isinstance(cli, str): + cli = "" + + enabled: bool = item.get("enabled") if isinstance(item.get("enabled"), bool) else True + priority: int = item.get("priority") if isinstance(item.get("priority"), int) else 0 + if isinstance(priority, bool) or 0 > priority: + priority = 0 + + description: str = item.get("description") if isinstance(item.get("description"), str) else "" + + return ConditionModel( + id=index, + name=name, + filter=filter_value, + cli=cli, + extras=extras, + enabled=bool(enabled), + priority=priority, + description=description, + ) diff --git a/app/features/conditions/models.py b/app/features/conditions/models.py new file mode 100644 index 00000000..fc0604d2 --- /dev/null +++ b/app/features/conditions/models.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from datetime import datetime # noqa: TC003 + +from sqlalchemy import JSON, Boolean, Index, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.features.core.models import Base, UTCDateTime, utcnow + + +class ConditionModel(Base): + __tablename__: str = "conditions" + __table_args__: tuple[Index, ...] = ( + Index("ix_conditions_name", "name"), + Index("ix_conditions_enabled", "enabled"), + Index("ix_conditions_priority", "priority"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + 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) + priority: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + description: 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/conditions/repository.py b/app/features/conditions/repository.py new file mode 100644 index 00000000..08eee466 --- /dev/null +++ b/app/features/conditions/repository.py @@ -0,0 +1,178 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from app.features.conditions.migration import Migration +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 + +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") + + +class ConditionsRepository(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, config=None).run() + + @staticmethod + def get_instance() -> ConditionsRepository: + return ConditionsRepository() + + async def list(self) -> list[ConditionModel]: + async with self.session() as session: + result: Result[tuple[ConditionModel]] = await session.execute( + select(ConditionModel).order_by(ConditionModel.priority.desc(), ConditionModel.name.asc()) + ) + return list(result.scalars().all()) + + async def list_paginated(self, page: int, per_page: int) -> tuple[list[ConditionModel], 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[ConditionModel]] = ( + select(ConditionModel) + .order_by(ConditionModel.priority.desc(), ConditionModel.name.asc()) + .limit(per_page) + .offset((page - 1) * per_page) + ) + result: Result[tuple[ConditionModel]] = 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(ConditionModel)) + return int(result.scalar_one()) + + async def get(self, identifier: int | str) -> ConditionModel | None: + async with self.session() as session: + if not identifier: + return None + + if isinstance(identifier, int): + clause: ColumnElement[bool] = ConditionModel.id == identifier + elif isinstance(identifier, str) and identifier.isdigit(): + clause: ColumnElement[bool] = or_( + ConditionModel.id == int(identifier), ConditionModel.name == identifier + ) + else: + clause: ColumnElement[bool] = ConditionModel.name == identifier + + result: Result[tuple[ConditionModel]] = await session.execute(select(ConditionModel).where(clause).limit(1)) + return result.scalar_one_or_none() + + async def get_by_name(self, name: str, exclude_id: int | None = None) -> ConditionModel | None: + async with self.session() as session: + query: Select[tuple[ConditionModel]] = select(ConditionModel).where(ConditionModel.name == name) + if exclude_id is not None: + query = query.where(ConditionModel.id != exclude_id) + + result: Result[tuple[ConditionModel]] = await session.execute(query.limit(1)) + return result.scalar_one_or_none() + + async def create(self, payload: ConditionModel | dict) -> ConditionModel: + async with self.session() as session: + model: ConditionModel = ConditionModel(**payload) if isinstance(payload, dict) else payload + if model.id is not None: + model.id = None + + if await self.get_by_name(name=model.name) is not None: + msg: str = f"Condition 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]) -> ConditionModel: + async with self.session() as session: + if isinstance(identifier, int): + clause: ColumnElement[bool] = ConditionModel.id == identifier + elif isinstance(identifier, str) and identifier.isdigit(): + clause: ColumnElement[bool] = or_( + ConditionModel.id == int(identifier), ConditionModel.name == identifier + ) + else: + clause: ColumnElement[bool] = ConditionModel.name == identifier + + result: Result[tuple[ConditionModel]] = await session.execute(select(ConditionModel).where(clause).limit(1)) + model: ConditionModel | None = result.scalar_one_or_none() + + if not model: + msg: str = f"Condition '{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: str = f"Condition 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) -> ConditionModel: + async with self.session() as session: + # Query within this session + if isinstance(identifier, int): + clause: ColumnElement[bool] = ConditionModel.id == identifier + elif isinstance(identifier, str) and identifier.isdigit(): + clause: ColumnElement[bool] = or_( + ConditionModel.id == int(identifier), ConditionModel.name == identifier + ) + else: + clause: ColumnElement[bool] = ConditionModel.name == identifier + + result: Result[tuple[ConditionModel]] = await session.execute(select(ConditionModel).where(clause).limit(1)) + model = result.scalar_one_or_none() + + if not model: + msg: str = f"Condition '{identifier}' not found." + raise KeyError(msg) + + await session.delete(model) + await session.commit() + return model + + async def replace_all(self, items: Iterable[dict | ConditionModel]) -> list[ConditionModel]: + async with self.session() as session: + try: + await session.execute(delete(ConditionModel)) + models: list[ConditionModel] = [ + ConditionModel(**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/conditions/router.py b/app/features/conditions/router.py new file mode 100644 index 00000000..1eb54391 --- /dev/null +++ b/app/features/conditions/router.py @@ -0,0 +1,329 @@ +import logging +from collections import OrderedDict +from typing import Any + +from aiohttp import web +from aiohttp.web import Request, Response +from pydantic import ValidationError + +from app.features.conditions.schemas import Condition, ConditionList, ConditionPatch +from app.features.conditions.service import Conditions +from app.features.core.schemas import Pagination +from app.features.core.utils import build_pagination, format_validation_errors, normalize_pagination +from app.library.cache import Cache +from app.library.config import Config +from app.library.encoder import Encoder +from app.library.router import route + +LOG: logging.Logger = logging.getLogger(__name__) + + +def _model(model: Any) -> Condition: + return Condition.model_validate(model) + + +def _serialize(model: Any) -> dict: + return _model(model).model_dump() + + +@route("GET", "api/conditions/", name="conditions_list") +async def conditions_list(request: Request, encoder: Encoder) -> Response: + """ + Get the conditions + + Args: + request (Request): The request object. + encoder (Encoder): The encoder instance. + + Returns: + Response: The response object. + + """ + repo = Conditions.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=ConditionList( + 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/conditions/test/", name="condition_test") +async def conditions_test(request: Request, encoder: Encoder, cache: Cache, config: Config) -> Response: + """ + Test condition against URL. + + Args: + request (Request): The request object. + encoder (Encoder): The encoder instance. + cache (Cache): The cache instance. + config (Config): The configuration instance. + + Returns: + Response: The response object + + """ + params = await request.json() + + if not isinstance(params, dict): + return web.json_response( + {"error": "Invalid request body expecting dict."}, + status=web.HTTPBadRequest.status_code, + ) + + if not (url := params.get("url")): + return web.json_response({"error": "url is required."}, status=web.HTTPBadRequest.status_code) + + if not (cond := params.get("condition")): + return web.json_response({"error": "condition is required."}, status=web.HTTPBadRequest.status_code) + + try: + preset: str = params.get("preset", config.default_preset) + key: str = cache.hash(url + str(preset)) + if not cache.has(key): + from app.library.Utils import fetch_info + from app.library.YTDLPOpts import YTDLPOpts + + data: dict | None = await fetch_info( + config=YTDLPOpts.get_instance().preset(name=preset).get_all(), + url=url, + debug=False, + no_archive=True, + follow_redirect=True, + sanitize_info=True, + ) + + if not data: + return web.json_response( + data={"error": f"Failed to extract info from '{url!s}'."}, + status=web.HTTPBadRequest.status_code, + ) + cache.set(key=key, value=data, ttl=600) + else: + data = cache.get(key) + except Exception as e: + LOG.exception(e) + return web.json_response( + data={"error": f"Failed to extract video info. '{e!s}'"}, + status=web.HTTPInternalServerError.status_code, + ) + + try: + from app.library.mini_filter import match_str + + status: bool = match_str(cond, data) + except Exception as e: + LOG.exception(e) + return web.json_response( + data={"error": str(e)}, + status=web.HTTPBadRequest.status_code, + ) + + return web.json_response( + data={ + "status": status, + "condition": cond, + "data": OrderedDict(sorted(data.items(), key=lambda item: len(str(item[1])))), + }, + status=web.HTTPOk.status_code, + dumps=encoder.encode, + ) + + +@route("POST", "api/conditions/", name="condition_add") +async def conditions_add(request: Request, encoder: Encoder) -> Response: + """ + Add Condition. + + 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, dict): + return web.json_response( + {"error": "Invalid request body expecting list with dicts."}, + status=web.HTTPBadRequest.status_code, + ) + + try: + item: Condition = Condition.model_validate(data) + except ValidationError as exc: + return web.json_response( + data={"error": "Failed to validate condition.", "detail": format_validation_errors(exc)}, + status=web.HTTPBadRequest.status_code, + ) + + try: + data = _serialize(await Conditions.get_instance().save(item=item.model_dump())) + except ValueError as exc: + return web.json_response({"error": str(exc)}, status=web.HTTPBadRequest.status_code) + + return web.json_response( + data=data, + status=web.HTTPOk.status_code, + dumps=encoder.encode, + ) + + +@route("GET", "api/conditions/{id}", name="condition_get") +async def conditions_get(request: Request, encoder: Encoder) -> Response: + """ + Get the conditions + + Args: + request (Request): The request object. + encoder (Encoder): The encoder instance. + + Returns: + Response: The response object. + + """ + if not (id := request.match_info.get("id")): + return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code) + + if not (model := await Conditions.get_instance().get(id)): + return web.json_response({"error": "Condition 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/conditions/{id}", name="condition_delete") +async def conditions_delete(request: Request, encoder: Encoder) -> Response: + """ + Delete Condition. + + Args: + request (Request): The request object. + encoder (Encoder): The encoder instance. + + Returns: + Response: The response object. + + """ + if not (id := 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 Conditions.get_instance()._repo.delete(id)), + 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/conditions/{id}", name="condition_patch") +async def conditions_patch(request: Request, encoder: Encoder) -> Response: + """ + Patch Condition. + + Args: + request (Request): The request object. + encoder (Encoder): The encoder instance. + + Returns: + Response: The response object. + + """ + if not (id := request.match_info.get("id")): + return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code) + + if not (model := await Conditions.get_instance().get(id)): + return web.json_response({"error": "Condition 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 list with dicts."}, + status=web.HTTPBadRequest.status_code, + ) + + service = Conditions.get_instance() + + try: + validated = ConditionPatch.model_validate(data) + except ValidationError as exc: + return web.json_response( + data={"error": "Failed to validate condition.", "detail": format_validation_errors(exc)}, + status=web.HTTPBadRequest.status_code, + ) + + if validated.name and await service._repo.get_by_name(validated.name, exclude_id=model.id): + return web.json_response( + data={"error": f"Condition with name '{validated.name}' already exists."}, + status=web.HTTPConflict.status_code, + ) + + dct = validated.model_dump(exclude_unset=True) + + LOG.info(f"Patching Condition '{model.id}' with data: {data!s} - validated: {dct!s}") + + return web.json_response( + data=_serialize(await service._repo.update(model.id, dct)), + status=web.HTTPOk.status_code, + dumps=encoder.encode, + ) + + +@route("PUT", "api/conditions/{id}", name="condition_update") +async def conditions_update(request: Request, encoder: Encoder) -> Response: + """ + Update Condition. + + Args: + request (Request): The request object. + encoder (Encoder): The encoder instance. + + Returns: + Response: The response object. + + """ + if not (id := request.match_info.get("id")): + return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code) + + if not (model := await Conditions.get_instance().get(id)): + return web.json_response({"error": "Condition 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 list with dicts."}, + status=web.HTTPBadRequest.status_code, + ) + + service = Conditions.get_instance() + + try: + validated = Condition.model_validate(data) + except ValidationError as exc: + return web.json_response( + data={"error": "Failed to validate condition.", "detail": format_validation_errors(exc)}, + status=web.HTTPBadRequest.status_code, + ) + + if validated.name and await service._repo.get_by_name(validated.name, exclude_id=model.id): + return web.json_response( + data={"error": f"Condition with name '{validated.name}' already exists."}, + status=web.HTTPConflict.status_code, + ) + + return web.json_response( + data=_serialize(await service._repo.update(model.id, validated.model_dump(exclude_unset=True))), + status=web.HTTPOk.status_code, + dumps=encoder.encode, + ) diff --git a/app/features/conditions/schemas.py b/app/features/conditions/schemas.py new file mode 100644 index 00000000..08f552d2 --- /dev/null +++ b/app/features/conditions/schemas.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +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 +from app.library.mini_filter import match_str +from app.library.Utils import arg_converter + + +class Condition(BaseModel): + model_config = ConfigDict(str_strip_whitespace=True, from_attributes=True) + + id: int | None = None + name: str = Field(min_length=1) + filter: str = "" + cli: str = "" + extras: dict[str, Any] = Field(default_factory=dict) + enabled: bool = True + priority: int = 0 + description: str = "" + + @field_validator("filter") + @classmethod + def _validate_filter(cls, value: str) -> str: + try: + match_str(value, {}) + except Exception as exc: + msg: str = f"Invalid filter. '{exc!s}'." + raise ValueError(msg) from exc + return value + + @field_validator("cli") + @classmethod + def _validate_cli(cls, value: str) -> str: + if not value: + return "" + try: + arg_converter(args=value) + except ModuleNotFoundError: + return value + except Exception as exc: + msg: str = f"Invalid command options for yt-dlp. '{exc!s}'." + raise ValueError(msg) from exc + return value + + @field_validator("priority", mode="before") + @classmethod + def _normalize_priority(cls, value: Any) -> int: + return parse_int(value, field="Priority", minimum=0) + + @field_validator("enabled", mode="before") + @classmethod + def _validate_enabled(cls, value: Any) -> bool: + if not isinstance(value, bool): + msg: str = "Enabled must be a boolean." + raise ValueError(msg) + return value + + @model_validator(mode="after") + def _validate_filter_or_extras(self) -> Condition: + if not self.filter and not self.extras: + msg: str = "Either filter or extras must be set." + raise ValueError(msg) + return self + + +class ConditionPatch(Condition): + """ + Model for patching Condition fields. All fields are optional. + """ + + name: str | None = None + filter: str | None = None + cli: str | None = None + extras: dict[str, Any] | None = None + enabled: bool | None = None + priority: int | None = None + description: str | None = None + + @model_validator(mode="after") + def _validate_filter_or_extras(self) -> ConditionPatch: + # Skip validation for PATCH - allow partial updates + return self + + +class ConditionList(BaseModel): + model_config = ConfigDict(from_attributes=True) + + items: list[Condition] = Field(default_factory=list) + pagination: Pagination diff --git a/app/features/conditions/service.py b/app/features/conditions/service.py new file mode 100644 index 00000000..716512a3 --- /dev/null +++ b/app/features/conditions/service.py @@ -0,0 +1,204 @@ +import logging + +from aiohttp import web + +from app.features.conditions.models import ConditionModel +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") + + +class Conditions(metaclass=Singleton): + def __init__(self): + self._repo: ConditionsRepository = ConditionsRepository.get_instance() + + @staticmethod + def get_instance() -> "Conditions": + return Conditions() + + async def on_shutdown(self, _: web.Application): + pass + + def attach(self, _: web.Application): + async def handle_event(_, __): + await self._repo.run_migrations() + + EventBus.get_instance().subscribe(Events.STARTED, handle_event, "ConditionsRepository.run_migrations") + + 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. + + Args: + item (Condition|dict): The items to save. + + Returns: + ConditionModel: The current instance. + + """ + try: + if not isinstance(item, ConditionModel): + item: ConditionModel = ConditionModel(**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) + else: + model = await repo.update(item.id, item.serialize()) + except KeyError as exc: + raise ValueError(str(exc)) from exc + + return model + + async def has(self, identifier: str) -> bool: + """ + Check if the item exists by id or name. + + Args: + identifier (str): The id or name of the preset. + + Returns: + bool: True if the item exists, False otherwise. + + """ + return await self.get(identifier) is not None + + async def get(self, identifier: str) -> ConditionModel | None: + """ + Get the item by id or name. + + Args: + identifier (str): The id or name of the preset. + + Returns: + ConditionModel|None: The item if found, None otherwise. + + """ + repo = self._repo + return await repo.get(identifier) + + async def match(self, info: dict) -> ConditionModel | None: + """ + Check if any condition matches the info dict. + + Args: + info (dict): The info dict to check. + + Returns: + Condition|None: The condition if found, None otherwise. + + """ + if not info or not isinstance(info, dict) or len(info) < 1: + return None + + repo = self._repo + items: list[ConditionModel] = await repo.list() + if len(items) < 1: + return None + + for item in sorted(items, key=lambda x: x.priority, reverse=True): + if not item.enabled: + continue + + if not item.filter: + LOG.error(f"Filter is empty for '{item.name}'.") + continue + + try: + if not match_str(item.filter, info): + continue + + LOG.debug(f"Matched '{item.id}: {item.name}' with filter '{item.filter}'.") + return item + except Exception as e: + LOG.error(f"Failed to evaluate '{item.id}: {item.name}'. '{e!s}'.") + continue + + return None + + async def single_match(self, identifier: int | str, info: dict) -> ConditionModel | None: + """ + Check if condition matches the info dict. + + Args: + identifier (int|str): The id or name of the item. + info (dict): The info dict to check. + + Returns: + ConditionModel|None: The condition if found, None otherwise. + + """ + if not info or not isinstance(info, dict) or len(info) < 1: + return None + + if not (item := await self.get(identifier)) or not item.enabled or not item.filter: + return None + + return item if match_str(item.filter, info) else None diff --git a/app/features/conditions/tests/test_repository.py b/app/features/conditions/tests/test_repository.py new file mode 100644 index 00000000..1f44ecdb --- /dev/null +++ b/app/features/conditions/tests/test_repository.py @@ -0,0 +1,230 @@ +"""Tests for ConditionsRepository.""" + +from __future__ import annotations + +import pytest +import pytest_asyncio + +from app.features.conditions.models import ConditionModel +from app.features.conditions.repository import ConditionsRepository +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.""" + # Reset singletons + ConditionsRepository._reset_singleton() + SqliteStore._reset_singleton() + + # Initialize database with temp path + test_db_path = tmp_path / "test.db" + store = SqliteStore(db_path=str(test_db_path)) + await store.get_connection() + + # Create repository + repository = ConditionsRepository.get_instance() + + yield repository + + # Cleanup - close connections properly + if store._conn: + await store._conn.close() + if store._engine: + await store._engine.dispose() + + # Reset singletons + ConditionsRepository._reset_singleton() + SqliteStore._reset_singleton() + + +class TestConditionsRepository: + """Test suite for ConditionsRepository database operations.""" + + @pytest.mark.asyncio + async def test_repository_singleton(self, repo): + """Verify repository follows singleton pattern.""" + instance1 = ConditionsRepository.get_instance() + instance2 = ConditionsRepository.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 conditions exist.""" + conditions = await repo.list() + assert conditions == [], "Should return empty list when no conditions" + + @pytest.mark.asyncio + async def test_count_empty(self, repo): + """Count returns 0 when no conditions exist.""" + count = await repo.count() + assert count == 0, "Should return 0 when no conditions exist" + + @pytest.mark.asyncio + async def test_create_condition(self, repo): + """Create condition with valid data.""" + data = { + "name": "Test Condition", + "filter": "duration > 60", + "cli": "--format best", + "enabled": True, + "priority": 10, + "description": "Test description", + "extras": {"key": "value"}, + } + + model = await repo.create(data) + + assert model.id is not None, "Should generate ID for new condition" + assert model.name == "Test Condition", "Should store name correctly" + assert model.filter == "duration > 60", "Should store filter correctly" + assert model.cli == "--format best", "Should store CLI correctly" + assert model.enabled is True, "Should store enabled flag correctly" + assert model.priority == 10, "Should store priority correctly" + assert model.description == "Test description", "Should store description correctly" + assert model.extras == {"key": "value"}, "Should store extras as dict" + + @pytest.mark.asyncio + async def test_create_with_defaults(self, repo): + """Create condition with minimal data uses defaults.""" + data = { + "name": "Minimal", + "filter": "duration > 30", + } + + model = await repo.create(data) + + assert model.cli == "", "Should default CLI to empty string" + assert model.enabled is True, "Should default enabled to True" + assert model.priority == 0, "Should default priority to 0" + assert model.description == "", "Should default description to empty" + assert model.extras == {}, "Should default extras to empty dict" + + @pytest.mark.asyncio + async def test_get_by_id(self, repo): + """Get condition by integer ID.""" + created = await repo.create({"name": "Get Test", "filter": "duration > 40"}) + + retrieved = await repo.get(created.id) + + assert retrieved is not None, "Should retrieve created condition" + assert retrieved.id == created.id, "Should retrieve correct condition by ID" + assert retrieved.name == "Get Test", "Should match created condition name" + + @pytest.mark.asyncio + async def test_get_by_name(self, repo): + """Get condition by string name.""" + await repo.create({"name": "Named Test", "filter": "duration > 50"}) + + retrieved = await repo.get("Named Test") + + assert retrieved is not None, "Should retrieve by name" + assert retrieved.name == "Named Test", "Should match condition name" + + @pytest.mark.asyncio + async def test_get_nonexistent(self, repo): + """Get nonexistent condition 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_condition(self, repo): + """Update existing condition.""" + created = await repo.create({"name": "Update Test", "filter": "duration > 60"}) + + updated = await repo.update( + created.id, + { + "name": "Updated Name", + "priority": 5, + "extras": {"updated": True}, + }, + ) + + assert updated.name == "Updated Name", "Should update name" + assert updated.priority == 5, "Should update priority" + assert updated.extras == {"updated": True}, "Should update extras" + assert updated.filter == "duration > 60", "Should preserve unchanged filter" + + @pytest.mark.asyncio + async def test_update_nonexistent_raises(self, repo): + """Update nonexistent condition raises KeyError.""" + with pytest.raises(KeyError, match="not found"): + await repo.update(99999, {"name": "Should Fail"}) + + @pytest.mark.asyncio + async def test_delete_condition(self, repo): + """Delete existing condition.""" + created = await repo.create({"name": "Delete Test", "filter": "duration > 70"}) + + deleted = await repo.delete(created.id) + + assert deleted.id == created.id, "Should return deleted condition" + + result = await repo.get(created.id) + assert result is None, "Deleted condition should not be retrievable" + + @pytest.mark.asyncio + async def test_delete_nonexistent_raises(self, repo): + """Delete nonexistent condition 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}", "filter": "duration > 10", "priority": 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 priority desc then name asc.""" + await repo.create({"name": "B", "filter": "test", "priority": 1}) + await repo.create({"name": "A", "filter": "test", "priority": 1}) + await repo.create({"name": "C", "filter": "test", "priority": 2}) + + items = await repo.list() + + assert items[0].name == "C", "Highest priority should be first" + assert items[1].name == "A", "Same priority sorted alphabetically" + assert items[2].name == "B", "Same priority 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", "filter": "test"}) + + 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 conditions atomically.""" + await repo.create({"name": "Old 1", "filter": "test"}) + await repo.create({"name": "Old 2", "filter": "test"}) + + new_items = [ + {"name": "New 1", "filter": "duration > 10"}, + {"name": "New 2", "filter": "duration > 20"}, + ] + + result = await repo.replace_all(new_items) + + assert len(result) == 2, "Should create 2 new conditions" + + all_items = await repo.list() + assert len(all_items) == 2, "Should only have new conditions" + assert all_items[0].name in ["New 1", "New 2"], "Should only have new items" diff --git a/app/features/core/deps.py b/app/features/core/deps.py new file mode 100644 index 00000000..6e468dbe --- /dev/null +++ b/app/features/core/deps.py @@ -0,0 +1,12 @@ +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.library.sqlite_store import SqliteStore + + +@asynccontextmanager +async def get_session() -> AsyncGenerator[AsyncSession]: + async with SqliteStore.get_instance().sessionmaker()() as session: + yield session diff --git a/app/features/core/migration.py b/app/features/core/migration.py new file mode 100644 index 00000000..afd3f110 --- /dev/null +++ b/app/features/core/migration.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +import abc +import logging +import shutil +import time +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from app.library.config import Config + +LOG: logging.Logger = logging.getLogger(__name__) + + +class Migration(abc.ABC): + name: str = "" + + def __init__(self, config: Config): + self._migrated_dir: Path = Path(config.config_path) / "migrated" + + async def run(self) -> bool: + if not await self.should_run(): + return False + + self._migrated_dir.mkdir(parents=True, exist_ok=True) + try: + await self.migrate() + except Exception as exc: + LOG.exception("Feature migration '%s' failed: %s", self.name, exc) + return False + + return True + + @abc.abstractmethod + async def should_run(self) -> bool: + raise NotImplementedError + + @abc.abstractmethod + async def migrate(self) -> None: + raise NotImplementedError + + async def _move_file(self, source: Path) -> Path: + destination: Path = self._migrated_dir / source.name + if destination.exists(): + timestamp = int(time.time()) + destination: Path = self._migrated_dir / f"{source.stem}_{timestamp}{source.suffix}" + + shutil.move(str(source), str(destination)) + return destination diff --git a/app/features/core/models.py b/app/features/core/models.py new file mode 100644 index 00000000..a09d8160 --- /dev/null +++ b/app/features/core/models.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from datetime import UTC, datetime + +from sqlalchemy import DateTime as SQLADateTime +from sqlalchemy import TypeDecorator +from sqlalchemy.orm import DeclarativeBase + + +def utcnow() -> datetime: + """ + Return current UTC time as timezone-aware datetime. + + This is the canonical way to get current UTC time for database fields. + """ + return datetime.now(tz=UTC) + + +class UTCDateTime(TypeDecorator): + impl = SQLADateTime + cache_ok = True + + def process_bind_param(self, value: datetime | None, _dialect) -> datetime | None: + """Convert datetime to UTC before storing.""" + if value is not None: + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value.astimezone(UTC).replace(tzinfo=None) + return value + + def process_result_value(self, value: datetime | None, _dialect) -> datetime | None: + """Ensure datetime is timezone-aware (UTC) when loading.""" + if value is not None and value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value + + +class Base(DeclarativeBase): + pass diff --git a/app/features/core/schemas.py b/app/features/core/schemas.py new file mode 100644 index 00000000..4fbe8d9b --- /dev/null +++ b/app/features/core/schemas.py @@ -0,0 +1,12 @@ +from __future__ import annotations + +from pydantic import BaseModel + + +class Pagination(BaseModel): + page: int + per_page: int + total: int + total_pages: int + has_next: bool + has_prev: bool diff --git a/app/features/core/utils.py b/app/features/core/utils.py new file mode 100644 index 00000000..f71570ca --- /dev/null +++ b/app/features/core/utils.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from aiohttp.web import Request + from pydantic import ValidationError + + +def parse_int(value: Any, *, field: str, minimum: int | None = None) -> int: + try: + parsed = int(value) + except Exception as exc: + msg = f"{field} must be an integer." + raise ValueError(msg) from exc + + if minimum is not None and parsed < minimum: + msg = f"{field} must be >= {minimum}." + raise ValueError(msg) + + return parsed + + +def normalize_pagination(request: Request, page: int | None = None, per_page: int | None = None) -> tuple[int, int]: + if page is None: + page = int(request.query.get("page", 1)) + + if per_page is None: + per_page = int(request.query.get("per_page", 0)) + + if 0 == per_page: + from app.library.config import Config + + items_per_page: int = int(Config.get_instance().default_pagination) + per_page = items_per_page // 2 if items_per_page > 50 else items_per_page + + if page < 1: + msg = "page must be >= 1." + raise ValueError(msg) + + if per_page < 1 or per_page > 1000: + msg = "per_page must be between 1 and 1000." + raise ValueError(msg) + + return int(page), int(per_page) + + +def build_pagination(total: int, page: int, per_page: int, total_pages: int) -> dict: + return { + "page": page, + "per_page": per_page, + "total": total, + "total_pages": total_pages, + "has_next": page < total_pages, + "has_prev": page > 1, + } + + +def format_validation_errors(exc: ValidationError) -> list[dict[str, Any]]: + """ + Format Pydantic ValidationError. + + Args: + exc: The ValidationError from Pydantic + + Returns: + List of dicts with loc, msg, and type + + """ + return [ + { + "loc": list(error.get("loc", [])), + "msg": error.get("msg", "Validation error"), + "type": error.get("type", "value_error"), + } + for error in exc.errors() + ] diff --git a/app/library/conditions.py b/app/library/conditions.py deleted file mode 100644 index 281e2657..00000000 --- a/app/library/conditions.py +++ /dev/null @@ -1,377 +0,0 @@ -import json -import logging -import uuid -from dataclasses import dataclass, field -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 .mini_filter import match_str -from .Singleton import Singleton -from .Utils import arg_converter, init_class - -LOG: logging.Logger = logging.getLogger("conditions") - - -@dataclass(kw_only=True) -class Condition: - id: str = field(default_factory=lambda: str(uuid.uuid4())) - """The id of the condition.""" - - name: str - """The name of the condition.""" - - filter: str - """The filter to run on info dict.""" - - cli: str = "" - """If matched append this to the download request and retry.""" - - extras: dict[str, Any] = field(default_factory=dict) - """Any extra data to store with the condition.""" - - enabled: bool = True - """Whether the condition is enabled.""" - - priority: int = 0 - """Priority of the condition.""" - - description: str = "" - """A description of what the condition does.""" - - def serialize(self) -> dict: - return self.__dict__ - - 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 Conditions(metaclass=Singleton): - """ - This class is used to manage the download conditions. - """ - - def __init__(self, file: Path | str | None = None, config: Config | None = None): - self._items: list[Condition] = [] - "The list of items." - - config = config or Config.get_instance() - - self._file: Path = Path(file) if file else Path(config.config_path) / "conditions.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: Path | str | None = None, config: Config | None = None) -> "Conditions": - """ - Get the instance of the class. - - Returns: - Conditions: The instance of the class - - """ - return Conditions(file=file, config=config) - - async def on_shutdown(self, _: web.Application): - pass - - def attach(self, _: web.Application): - """ - Attach the work 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.CONDITIONS_ADD, event_handler, f"{__class__.__name__}.save") - - def get_all(self) -> list[Condition]: - """Return the items.""" - return self._items - - def load(self) -> "Conditions": - """ - Load the items. - - Returns: - Conditions: The current instance. - - """ - self.clear() - - if not self._file.exists() or self._file.stat().st_size < 1: - return self - - try: - LOG.info(f"Loading '{self._file}'.") - items = json.loads(self._file.read_text()) - except Exception as e: - LOG.exception(e) - LOG.error(f"Error loading '{self._file}'. '{e}'.") - return self - - if not items or len(items) < 1: - LOG.debug(f"No items were found in '{self._file}'.") - 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 - - if "extras" not in item: - item["extras"] = {} - need_save = True - - if "enabled" not in item: - item["enabled"] = True - need_save = True - - if "priority" not in item: - item["priority"] = 0 - need_save = True - - if "description" not in item: - item["description"] = "" - need_save = True - - item: Condition = init_class(Condition, item) - - self._items.append(item) - except Exception as e: - LOG.error(f"Failed to parse condition at list position '{i}'. '{e!s}'.") - continue - - if need_save: - LOG.warning("Saving conditions due to schema changes.") - self.save(self._items) - - return self - - def clear(self) -> "Conditions": - """ - Clear all items - - Returns: - conditions: The current instance. - - """ - if len(self._items) < 1: - return self - - self._items.clear() - - return self - - def validate(self, item: Condition | dict) -> bool: - """ - Validate item. - - Args: - item (Condition|dict): The item to validate. - - Returns: - bool: True if valid, False otherwise. - - """ - if not isinstance(item, dict): - if not isinstance(item, Condition): - msg = f"Unexpected '{type(item).__name__}' item type." - raise ValueError(msg) - - item = item.serialize() - - if not item.get("id"): - msg = "No id found." - raise ValueError(msg) - - if not item.get("name"): - msg = "No name found." - raise ValueError(msg) - - if not item.get("filter"): - msg = "No filter found." - raise ValueError(msg) - - try: - match_str(item.get("filter"), {}) - except Exception as e: - msg = 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 - - def save(self, items: list[Condition | dict]) -> "Conditions": - """ - Save the items. - - Args: - items (list[Condition]): The items to save. - - Returns: - Conditions: The current instance. - - """ - for i, item in enumerate(items): - try: - if not isinstance(item, Condition): - item: Condition = init_class(Condition, item) - items[i] = item - except Exception as e: - LOG.error(f"Failed to save '{i}' due to parsing error. '{e!s}'.") - continue - - try: - self.validate(item) - except ValueError as e: - LOG.error(f"Failed to validate '{i}: {item.name}'. '{e!s}'.") - continue - - try: - self._file.write_text(json.dumps(obj=[item.serialize() for item in items], indent=4)) - LOG.info(f"Updated '{self._file}'.") - except Exception as e: - LOG.error(f"Error saving '{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 preset. - - Returns: - bool: True if the item exists, False otherwise. - - """ - return self.get(id_or_name) is not None - - def get(self, id_or_name: str) -> Condition | None: - """ - Get the item by id or name. - - Args: - id_or_name (str): The id or name of the preset. - - Returns: - Condition|None: The item if found, None otherwise. - - """ - if not id_or_name: - return None - - for condition in self.get_all(): - if id_or_name not in (condition.id, condition.name): - continue - - return condition - - return None - - def match(self, info: dict) -> Condition | None: - """ - Check if any condition matches the info dict. - - Args: - info (dict): The info dict to check. - - Returns: - Condition|None: The condition if found, None otherwise. - - """ - if len(self._items) < 1 or not info or not isinstance(info, dict) or len(info) < 1: - return None - - for item in sorted(self.get_all(), key=lambda x: x.priority, reverse=True): - if not item.enabled: - continue - - if not item.filter: - LOG.error(f"Filter is empty for '{item.name}'.") - continue - - try: - if match_str(item.filter, info): - LOG.debug(f"Matched '{item.name}' with filter '{item.filter}'.") - - return item - except Exception as e: - LOG.error(f"Failed to evaluate '{item.name}'. '{e!s}'.") - continue - - return None - - def single_match(self, name: str, info: dict) -> Condition | None: - """ - Check if condition matches the info dict. - - Args: - name (str): The condition name to check. - info (dict): The info dict to check. - - Returns: - Condition|None: The condition if found, None otherwise. - - """ - if len(self._items) < 1 or not info or not isinstance(info, dict) or len(info) < 1: - return None - - item = self.get(name) - if not item or not item.enabled or not item.filter: - return None - - return item if match_str(item.filter, info) else None diff --git a/app/library/downloads/item_adder.py b/app/library/downloads/item_adder.py index 53356789..e0942232 100644 --- a/app/library/downloads/item_adder.py +++ b/app/library/downloads/item_adder.py @@ -1,15 +1,3 @@ -""" -Item addition and entry routing. - -This module handles the complete flow of adding items to the download queue: -- Preset and CLI validation -- Cookie file management -- Archive checking (early and post-extraction) -- Info extraction with yt-dlp -- Conditions matching and re-queuing -- Entry type routing (playlist, video, URL) -""" - import asyncio import logging import time @@ -19,7 +7,7 @@ from typing import TYPE_CHECKING import yt_dlp.utils -from app.library.conditions import Conditions +from app.features.conditions.service import Conditions from app.library.Events import Events from app.library.ItemDTO import ItemDTO from app.library.Presets import Presets @@ -279,7 +267,7 @@ async def add( ) return {"status": "error", "msg": message, "hidden": True} - if not item.requeued and (condition := Conditions.get_instance().match(info=entry)): + if not item.requeued and (condition := await Conditions.get_instance().match(info=entry)): already.pop() message = f"Condition '{condition.name}' matched for '{item!r}'." diff --git a/app/library/encoder.py b/app/library/encoder.py index d16aaf12..72ba8e0b 100644 --- a/app/library/encoder.py +++ b/app/library/encoder.py @@ -16,16 +16,10 @@ class Encoder(json.JSONEncoder): def default(self, o): from .ItemDTO import ItemDTO - if isinstance(o, Path): - return str(o) - if isinstance(o, DateRange): return {"start": str(o.start).replace("-", ""), "end": str(o.end).replace("-", "")} - if isinstance(o, date): - return str(o) - - if isinstance(o, ImpersonateTarget): + if isinstance(o, (Path, date, ImpersonateTarget, ValueError)): return str(o) if isinstance(o, ItemDTO): @@ -35,6 +29,9 @@ class Encoder(json.JSONEncoder): if hasattr(o, "serialize"): return o.serialize() + if hasattr(o, "model_dump"): + return o.model_dump() + if hasattr(o, "__dict__"): return o.__dict__ diff --git a/app/main.py b/app/main.py index c3b3c036..cb53ca5b 100644 --- a/app/main.py +++ b/app/main.py @@ -14,9 +14,9 @@ from pathlib import Path import magic from aiohttp import web +from app.features.conditions.service import Conditions from app.library.BackgroundWorker import BackgroundWorker from app.library.cache import Cache -from app.library.conditions import Conditions from app.library.config import Config from app.library.dl_fields import DLFields from app.library.downloads import DownloadQueue diff --git a/app/migrations/20260117184822_add_conditions_table.py b/app/migrations/20260117184822_add_conditions_table.py new file mode 100644 index 00000000..7b30f7d0 --- /dev/null +++ b/app/migrations/20260117184822_add_conditions_table.py @@ -0,0 +1,39 @@ +""" +This module contains a db migration. + +Migration Name: add_conditions_table +Migration Version: 20260117184822 +""" + +from sqlalchemy import text + + +async def upgrade(c): + sql: list[str] = [ + """ + CREATE TABLE IF NOT EXISTS "conditions" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "name" TEXT NOT NULL UNIQUE, + "filter" TEXT NOT NULL, + "cli" TEXT NOT NULL DEFAULT '', + "extras" JSON NOT NULL DEFAULT '{}', + "enabled" INTEGER NOT NULL DEFAULT 1, + "priority" INTEGER NOT NULL DEFAULT 0, + "description" TEXT 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_conditions_name" ON "conditions" ("name");', + 'CREATE INDEX IF NOT EXISTS "ix_conditions_enabled" ON "conditions" ("enabled");', + 'CREATE INDEX IF NOT EXISTS "ix_conditions_priority" ON "conditions" ("priority");', + ] + for sql_stmt in sql: + await c.execute(text(sql_stmt)) + + +async def downgrade(c): + sql = """ + DROP TABLE IF EXISTS "conditions"; + """ + await c.execute(text(sql)) diff --git a/app/routes/api/conditions.py b/app/routes/api/conditions.py index cdfce765..33c43457 100644 --- a/app/routes/api/conditions.py +++ b/app/routes/api/conditions.py @@ -1,206 +1,3 @@ -import logging -import uuid -from collections import OrderedDict +"""Migrated API routes for feature submodule.""" -from aiohttp import web -from aiohttp.web import Request, Response - -from app.library.cache import Cache -from app.library.conditions import Condition, Conditions -from app.library.config import Config -from app.library.encoder import Encoder -from app.library.Events import EventBus, Events -from app.library.mini_filter import match_str -from app.library.router import route -from app.library.Utils import fetch_info, init_class, validate_uuid -from app.library.YTDLPOpts import YTDLPOpts - -LOG: logging.Logger = logging.getLogger(__name__) - - -@route("GET", "api/conditions/", "conditions_list") -async def conditions_list(encoder: Encoder) -> Response: - """ - Get the conditions - - Args: - encoder (Encoder): The encoder instance. - - Returns: - Response: The response object. - - """ - return web.json_response( - data=Conditions.get_instance().get_all(), - status=web.HTTPOk.status_code, - dumps=encoder.encode, - ) - - -@route("PUT", "api/conditions/", "conditions_add") -async def conditions_add(request: Request, encoder: Encoder, notify: EventBus) -> Response: - """ - Save Conditions. - - 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 = [] - - cls = Conditions.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("filter"): - return web.json_response( - {"error": "Filter is required.", "data": item}, status=web.HTTPBadRequest.status_code - ) - - if not item.get("cli") and len(item.get("extras", {}).keys()) < 1: - return web.json_response( - {"error": "A Condition Must have cli options or extras", "data": item}, - status=web.HTTPBadRequest.status_code, - ) - - if not item.get("cli"): - item["cli"] = "" - - if not item.get("extras"): - item["extras"] = {} - - if "enabled" not in item: - item["enabled"] = True - - if "priority" not in item: - item["priority"] = 0 - - if "description" not in item: - item["description"] = "" - - 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 condition '{item.get('name')}'. '{e!s}'"}, - status=web.HTTPBadRequest.status_code, - ) - - items.append(init_class(Condition, 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 conditions.", "message": str(e)}, - status=web.HTTPInternalServerError.status_code, - ) - - notify.emit(Events.CONDITIONS_UPDATE, data=items) - return web.json_response(data=items, status=web.HTTPOk.status_code, dumps=encoder.encode) - - -@route("POST", "api/conditions/test/", "conditions_test") -async def conditions_test(request: Request, encoder: Encoder, cache: Cache, config: Config) -> Response: - """ - Test condition against URL. - - Args: - request (Request): The request object. - encoder (Encoder): The encoder instance. - cache (Cache): The cache instance. - config (Config): The configuration instance. - - Returns: - Response: The response object - - """ - params = await request.json() - - if not isinstance(params, dict): - return web.json_response( - {"error": "Invalid request body expecting dict."}, - status=web.HTTPBadRequest.status_code, - ) - - url: str | None = params.get("url") - if not url: - return web.json_response({"error": "url is required."}, status=web.HTTPBadRequest.status_code) - - cond: str | None = params.get("condition") - if not cond: - return web.json_response({"error": "condition is required."}, status=web.HTTPBadRequest.status_code) - - try: - preset: str = params.get("preset", config.default_preset) - key: str = cache.hash(url + str(preset)) - if not cache.has(key): - data: dict | None = await fetch_info( - config=YTDLPOpts.get_instance().preset(name=preset).get_all(), - url=url, - debug=False, - no_archive=True, - follow_redirect=True, - sanitize_info=True, - ) - - if not data: - return web.json_response( - data={"error": f"Failed to extract info from '{url!s}'."}, - status=web.HTTPBadRequest.status_code, - ) - cache.set(key=key, value=data, ttl=600) - else: - data = cache.get(key) - except Exception as e: - LOG.exception(e) - return web.json_response( - data={"error": f"Failed to extract video info. '{e!s}'"}, - status=web.HTTPInternalServerError.status_code, - ) - - try: - status: bool = match_str(cond, data) - except Exception as e: - LOG.exception(e) - return web.json_response( - data={"error": str(e)}, - status=web.HTTPBadRequest.status_code, - ) - - return web.json_response( - data={ - "status": status, - "condition": cond, - "data": OrderedDict(sorted(data.items(), key=lambda item: len(str(item[1])))), - }, - status=web.HTTPOk.status_code, - dumps=encoder.encode, - ) +import app.features.conditions.router # noqa: F401 diff --git a/app/tests/test_conditions.py b/app/tests/test_conditions.py deleted file mode 100644 index 2021c5e0..00000000 --- a/app/tests/test_conditions.py +++ /dev/null @@ -1,893 +0,0 @@ -""" -Tests for conditions.py - Download conditions management. - -This test suite provides comprehensive coverage for the conditions module: -- Tests Condition dataclass functionality -- Tests Conditions singleton class behavior -- Tests condition loading, saving, and validation -- Tests condition matching against info dicts -- Tests error handling and edge cases - -Total test functions: 15+ -All condition 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.conditions import Condition, Conditions - - -class TestCondition: - """Test the Condition dataclass.""" - - def test_condition_creation_with_defaults(self): - """Test creating a condition with default values.""" - condition = Condition(name="test", filter="duration > 60") - - assert condition.id, "Check that ID is generated" - assert isinstance(condition.id, str) - - # Check required fields - assert condition.name == "test" - assert condition.filter == "duration > 60" - - assert condition.cli == "", "Check defaults" - assert condition.extras == {} - assert condition.enabled is True - - def test_condition_creation_with_all_fields(self): - """Test creating a condition with all fields specified.""" - test_id = str(uuid.uuid4()) - extras = {"key": "value", "number": 42} - - condition = Condition( - id=test_id, name="full_test", filter="uploader = 'test'", cli="--format best", extras=extras, enabled=False - ) - - assert condition.id == test_id - assert condition.name == "full_test" - assert condition.filter == "uploader = 'test'" - assert condition.cli == "--format best" - assert condition.extras == extras - assert condition.enabled is False - - def test_condition_serialize(self): - """Test condition serialization to dict.""" - condition = Condition( - name="serialize_test", filter="title ~= 'test'", cli="--audio-quality 0", extras={"tag": "music"} - ) - - serialized = condition.serialize() - - assert isinstance(serialized, dict) - assert serialized["name"] == "serialize_test" - assert serialized["filter"] == "title ~= 'test'" - assert serialized["cli"] == "--audio-quality 0" - assert serialized["extras"] == {"tag": "music"} - assert "id" in serialized - - def test_condition_json(self): - """Test condition JSON serialization.""" - condition = Condition(name="json_test", filter="duration < 300") - - json_str = condition.json() - - assert isinstance(json_str, str) - # Should be valid JSON - data = json.loads(json_str) - assert data["name"] == "json_test" - assert data["filter"] == "duration < 300" - - def test_condition_get_method(self): - """Test condition get method for accessing fields.""" - condition = Condition(name="get_test", filter="view_count > 1000", extras={"category": "popular"}) - - assert condition.get("name") == "get_test" - assert condition.get("filter") == "view_count > 1000" - assert condition.get("extras") == {"category": "popular"} - assert condition.get("nonexistent") is None - assert condition.get("nonexistent", "default") == "default" - - -class TestConditions: - """Test the Conditions singleton class.""" - - def setup_method(self): - """Set up test fixtures by clearing singleton instances.""" - Conditions._reset_singleton() - - def test_conditions_singleton(self): - """Test that Conditions follows singleton pattern.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "test_conditions.json" - - instance1 = Conditions(file=file_path) - instance2 = Conditions.get_instance() - - assert instance1 is instance2 - - def test_conditions_initialization(self): - """Test Conditions initialization with file path.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "test_conditions.json" - - conditions = Conditions(file=file_path) - - assert conditions._file == file_path - assert isinstance(conditions._items, list) - - @patch("app.library.conditions.Config.get_instance") - def test_conditions_default_file_path(self, mock_config): - """Test Conditions uses default config path when no file specified.""" - mock_config_instance = MagicMock() - mock_config_instance.config_path = "/test/config" - mock_config.return_value = mock_config_instance - - conditions = Conditions() - - expected_path = Path("/test/config") / "conditions.json" - assert conditions._file == expected_path - - def test_get_all_empty(self): - """Test get_all returns empty list when no conditions loaded.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "empty_conditions.json" - conditions = Conditions(file=file_path) - - result = conditions.get_all() - - assert isinstance(result, list) - assert len(result) == 0 - - def test_clear(self): - """Test clearing all conditions.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "clear_test.json" - conditions = Conditions(file=file_path) - - # Add some test conditions - conditions._items = [ - Condition(name="test1", filter="duration > 60"), - Condition(name="test2", filter="uploader = 'test'"), - ] - - result = conditions.clear() - - assert result is conditions # Should return self - assert len(conditions._items) == 0 - - def test_clear_when_already_empty(self): - """Test clearing when conditions list is already empty.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "already_empty.json" - conditions = Conditions(file=file_path) - - result = conditions.clear() - - assert result is conditions - assert len(conditions._items) == 0 - - def test_load_nonexistent_file(self): - """Test loading from non-existent file.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "nonexistent.json" - conditions = Conditions(file=file_path) - - result = conditions.load() - - assert result is conditions - assert len(conditions._items) == 0 - - def test_load_empty_file(self): - """Test loading from empty file.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "empty.json" - file_path.touch() # Create empty file - - conditions = Conditions(file=file_path) - result = conditions.load() - - assert result is conditions - assert len(conditions._items) == 0 - - def test_load_valid_conditions(self): - """Test loading valid conditions from file.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "valid_conditions.json" - - # Create test data - test_data = [ - { - "id": str(uuid.uuid4()), - "name": "short_videos", - "filter": "duration < 300", - "cli": "--format worst", - "extras": {"category": "short"}, - "enabled": True, - "priority": 0, - "description": "Download short videos", - }, - { - "id": str(uuid.uuid4()), - "name": "music_videos", - "filter": "title ~= 'music'", - "cli": "--audio-quality 0", - "extras": {"type": "audio"}, - "enabled": False, - "priority": 5, - "description": "High priority music videos", - }, - ] - - file_path.write_text(json.dumps(test_data, indent=4)) - - conditions = Conditions(file=file_path) - result = conditions.load() - - assert result is conditions - assert len(conditions._items) == 2 - - assert conditions._items[0].name == "short_videos", "Check first condition" - assert conditions._items[0].filter == "duration < 300" - assert conditions._items[0].cli == "--format worst" - assert conditions._items[0].extras == {"category": "short"} - assert conditions._items[0].enabled is True - assert conditions._items[0].priority == 0 - assert conditions._items[0].description == "Download short videos" - - assert conditions._items[1].name == "music_videos", "Check second condition" - assert conditions._items[1].filter == "title ~= 'music'" - assert conditions._items[1].enabled is False - assert conditions._items[1].priority == 5 - assert conditions._items[1].description == "High priority music videos" - - def test_load_conditions_without_id(self): - """Test loading conditions that don't have ID (should generate ID).""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "no_id_conditions.json" - - # Create test data without ID - test_data = [{"name": "no_id_test", "filter": "duration > 120"}] - - file_path.write_text(json.dumps(test_data)) - - with patch.object(Conditions, "save") as mock_save: - conditions = Conditions(file=file_path) - conditions.load() - - assert len(conditions._items) == 1, "Should have generated ID" - assert conditions._items[0].id - assert conditions._items[0].name == "no_id_test" - - # Should call save due to changes - mock_save.assert_called_once() - - def test_load_conditions_without_extras(self): - """Test loading conditions that don't have extras field.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "no_extras_conditions.json" - - test_data = [{"id": str(uuid.uuid4()), "name": "no_extras_test", "filter": "uploader = 'test'"}] - - file_path.write_text(json.dumps(test_data)) - - with patch.object(Conditions, "save") as mock_save: - conditions = Conditions(file=file_path) - conditions.load() - - assert len(conditions._items) == 1, "Should have generated empty extras" - assert conditions._items[0].extras == {} - - # Should call save due to changes - mock_save.assert_called_once() - - def test_load_conditions_without_enabled(self): - """Test loading conditions that don't have enabled field (should default to True).""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "no_enabled_conditions.json" - - test_data = [{"id": str(uuid.uuid4()), "name": "no_enabled_test", "filter": "duration > 60", "extras": {}}] - - file_path.write_text(json.dumps(test_data)) - - with patch.object(Conditions, "save") as mock_save: - conditions = Conditions(file=file_path) - conditions.load() - - assert len(conditions._items) == 1, "Should have generated enabled=True" - assert conditions._items[0].enabled is True - - # Should call save due to changes - mock_save.assert_called_once() - - def test_load_conditions_without_priority(self): - """Test loading conditions that don't have priority field (should default to 0).""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "no_priority_conditions.json" - - test_data = [{"id": str(uuid.uuid4()), "name": "no_priority_test", "filter": "duration > 60", "extras": {}}] - - file_path.write_text(json.dumps(test_data)) - - with patch.object(Conditions, "save") as mock_save: - conditions = Conditions(file=file_path) - conditions.load() - - assert len(conditions._items) == 1, "Should have generated priority=0" - assert conditions._items[0].priority == 0 - - # Should call save due to changes - mock_save.assert_called_once() - - def test_load_conditions_without_description(self): - """Test loading conditions that don't have description field (should default to empty string).""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "no_description_conditions.json" - - test_data = [ - {"id": str(uuid.uuid4()), "name": "no_description_test", "filter": "duration > 60", "extras": {}} - ] - - file_path.write_text(json.dumps(test_data)) - - with patch.object(Conditions, "save") as mock_save: - conditions = Conditions(file=file_path) - conditions.load() - - assert len(conditions._items) == 1, "Should have generated description=''" - assert conditions._items[0].description == "" - - # Should call save due to changes - mock_save.assert_called_once() - - def test_load_invalid_json(self): - """Test loading file with invalid JSON.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "invalid.json" - file_path.write_text("invalid json content") - - conditions = Conditions(file=file_path) - result = conditions.load() - - assert result is conditions - assert len(conditions._items) == 0 - - def test_load_invalid_condition_data(self): - """Test loading file with invalid condition data.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "invalid_data.json" - - # Missing required fields - test_data = [ - {"id": "valid", "name": "valid", "filter": "duration > 60"}, - {"invalid": "data"}, # Missing required fields - ] - - file_path.write_text(json.dumps(test_data)) - - conditions = Conditions(file=file_path) - result = conditions.load() - - assert result is conditions, "Should load only valid conditions" - assert len(conditions._items) == 1 - assert conditions._items[0].name == "valid" - - def test_validate_valid_condition_dict(self): - """Test validating a valid condition dictionary.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "validate_test.json" - conditions = Conditions(file=file_path) - - valid_condition = { - "id": str(uuid.uuid4()), - "name": "valid_test", - "filter": "duration > 60", - "cli": "--format best", - "extras": {"key": "value"}, - } - - result = conditions.validate(valid_condition) - assert result is True - - def test_validate_valid_condition_object(self): - """Test validating a valid Condition object.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "validate_obj_test.json" - conditions = Conditions(file=file_path) - - valid_condition = Condition(name="valid_obj_test", filter="uploader = 'test'") - - result = conditions.validate(valid_condition) - assert result is True - - def test_validate_missing_id(self): - """Test validating condition without ID.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "no_id_validate.json" - conditions = Conditions(file=file_path) - - invalid_condition = {"name": "no_id_test", "filter": "duration > 60"} - - with pytest.raises(ValueError, match="No id found"): - conditions.validate(invalid_condition) - - def test_validate_missing_name(self): - """Test validating condition without name.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "no_name_validate.json" - conditions = Conditions(file=file_path) - - invalid_condition = {"id": str(uuid.uuid4()), "filter": "duration > 60"} - - with pytest.raises(ValueError, match="No name found"): - conditions.validate(invalid_condition) - - def test_validate_missing_filter(self): - """Test validating condition without filter.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "no_filter_validate.json" - conditions = Conditions(file=file_path) - - invalid_condition = {"id": str(uuid.uuid4()), "name": "no_filter_test"} - - with pytest.raises(ValueError, match="No filter found"): - conditions.validate(invalid_condition) - - def test_validate_invalid_filter(self): - """Test validating condition with invalid filter syntax.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "invalid_filter.json" - conditions = Conditions(file=file_path) - - # Use a filter that will cause a syntax error in the parser - invalid_condition = { - "id": str(uuid.uuid4()), - "name": "invalid_filter_test", - "filter": "duration > & < 60", # Invalid syntax with consecutive operators - "cli": "", - "extras": {}, - } - - with pytest.raises(ValueError, match="Invalid filter"): - conditions.validate(invalid_condition) - - def test_validate_invalid_cli(self): - """Test validating condition with invalid CLI options.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "invalid_cli.json" - conditions = Conditions(file=file_path) - - invalid_condition = { - "id": str(uuid.uuid4()), - "name": "invalid_cli_test", - "filter": "duration > 60", - "cli": "--invalid-option-that-does-not-exist", - } - - with pytest.raises(ValueError, match="Invalid command options"): - conditions.validate(invalid_condition) - - def test_validate_invalid_extras_type(self): - """Test validating condition with non-dict extras.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "invalid_extras.json" - conditions = Conditions(file=file_path) - - invalid_condition = { - "id": str(uuid.uuid4()), - "name": "invalid_extras_test", - "filter": "duration > 60", - "extras": "not a dict", - } - - with pytest.raises(ValueError, match="Extras must be a dictionary"): - conditions.validate(invalid_condition) - - def test_validate_invalid_enabled_type(self): - """Test validating condition with non-boolean enabled field.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "invalid_enabled.json" - conditions = Conditions(file=file_path) - - invalid_condition = { - "id": str(uuid.uuid4()), - "name": "invalid_enabled_test", - "filter": "duration > 60", - "extras": {}, - "enabled": "yes", # Should be boolean, not string - } - - with pytest.raises(ValueError, match="Enabled must be a boolean"): - conditions.validate(invalid_condition) - - def test_validate_invalid_priority_type(self): - """Test validating condition with non-integer priority field.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "invalid_priority_type.json" - conditions = Conditions(file=file_path) - - invalid_condition = { - "id": str(uuid.uuid4()), - "name": "invalid_priority_type_test", - "filter": "duration > 60", - "extras": {}, - "priority": "10", # Should be integer, not string - } - - with pytest.raises(ValueError, match="Priority must be an integer"): - conditions.validate(invalid_condition) - - def test_validate_invalid_priority_negative(self): - """Test validating condition with negative priority.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "invalid_priority_negative.json" - conditions = Conditions(file=file_path) - - invalid_condition = { - "id": str(uuid.uuid4()), - "name": "invalid_priority_negative_test", - "filter": "duration > 60", - "extras": {}, - "priority": -5, # Should be >= 0 - } - - with pytest.raises(ValueError, match="Priority must be >= 0"): - conditions.validate(invalid_condition) - - def test_validate_invalid_description_type(self): - """Test validating condition with non-string description field.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "invalid_description.json" - conditions = Conditions(file=file_path) - - invalid_condition = { - "id": str(uuid.uuid4()), - "name": "invalid_description_test", - "filter": "duration > 60", - "extras": {}, - "description": 123, # Should be string, not integer - } - - with pytest.raises(ValueError, match="Description must be a string"): - conditions.validate(invalid_condition) - - def test_validate_invalid_item_type(self): - """Test validating invalid item type.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "invalid_type.json" - conditions = Conditions(file=file_path) - - with pytest.raises(ValueError, match=r"Unexpected.*item type"): - conditions.validate("invalid type") - - def test_save_conditions(self): - """Test saving conditions to file.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "save_test.json" - conditions = Conditions(file=file_path) - - test_conditions = [ - Condition(name="save_test1", filter="duration > 60"), - Condition(name="save_test2", filter="uploader = 'test'"), - ] - - result = conditions.save(test_conditions) - - assert result is conditions - assert file_path.exists() - - # Verify file content - saved_data = json.loads(file_path.read_text()) - assert len(saved_data) == 2 - assert saved_data[0]["name"] == "save_test1" - assert saved_data[1]["name"] == "save_test2" - - def test_save_conditions_dict_format(self): - """Test saving conditions in dictionary format.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "save_dict_test.json" - conditions = Conditions(file=file_path) - - test_conditions = [ - {"id": str(uuid.uuid4()), "name": "dict_test", "filter": "duration < 300", "cli": "", "extras": {}} - ] - - conditions.save(test_conditions) - - assert file_path.exists() - saved_data = json.loads(file_path.read_text()) - assert len(saved_data) == 1 - assert saved_data[0]["name"] == "dict_test" - - def test_has_condition_by_id(self): - """Test checking if condition exists by ID.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "has_test.json" - conditions = Conditions(file=file_path) - - test_id = str(uuid.uuid4()) - test_condition = Condition(id=test_id, name="has_test", filter="duration > 60") - conditions._items = [test_condition] - - assert conditions.has(test_id) is True - assert conditions.has("nonexistent") is False - - def test_has_condition_by_name(self): - """Test checking if condition exists by name.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "has_name_test.json" - conditions = Conditions(file=file_path) - - test_condition = Condition(name="has_name_test", filter="uploader = 'test'") - conditions._items = [test_condition] - - assert conditions.has("has_name_test") is True - assert conditions.has("nonexistent_name") is False - - def test_get_condition_by_id(self): - """Test getting condition by ID.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "get_id_test.json" - conditions = Conditions(file=file_path) - - test_id = str(uuid.uuid4()) - test_condition = Condition(id=test_id, name="get_id_test", filter="duration > 120") - conditions._items = [test_condition] - - result = conditions.get(test_id) - assert result is test_condition - - assert conditions.get("nonexistent") is None - - def test_get_condition_by_name(self): - """Test getting condition by name.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "get_name_test.json" - conditions = Conditions(file=file_path) - - test_condition = Condition(name="get_name_test", filter="title ~= 'music'") - conditions._items = [test_condition] - - result = conditions.get("get_name_test") - assert result is test_condition - - def test_get_condition_empty_id_or_name(self): - """Test getting condition with empty ID or name.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "empty_get_test.json" - conditions = Conditions(file=file_path) - - assert conditions.get("") is None - assert conditions.get(None) is None - - @patch("app.library.conditions.match_str") - def test_match_condition_found(self, mock_match_str): - """Test matching condition against info dict.""" - mock_match_str.return_value = True - - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "match_test.json" - conditions = Conditions(file=file_path) - - test_condition = Condition(name="match_test", filter="duration > 60") - conditions._items = [test_condition] - - info_dict = {"duration": 120, "title": "Test Video"} - result = conditions.match(info_dict) - - assert result is test_condition - mock_match_str.assert_called_once_with("duration > 60", info_dict) - - @patch("app.library.conditions.match_str") - def test_match_condition_not_found(self, mock_match_str): - """Test matching when no condition matches.""" - mock_match_str.return_value = False - - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "no_match_test.json" - conditions = Conditions(file=file_path) - - test_condition = Condition(name="no_match_test", filter="duration > 300") - conditions._items = [test_condition] - - info_dict = {"duration": 60, "title": "Short Video"} - result = conditions.match(info_dict) - - assert result is None - - def test_match_empty_conditions(self): - """Test matching with empty conditions list.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "empty_match_test.json" - conditions = Conditions(file=file_path) - - info_dict = {"duration": 120} - result = conditions.match(info_dict) - - assert result is None - - def test_match_invalid_info_dict(self): - """Test matching with invalid info dict.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "invalid_info_test.json" - conditions = Conditions(file=file_path) - - test_condition = Condition(name="test", filter="duration > 60") - conditions._items = [test_condition] - - assert conditions.match(None) is None, "Test with None" - - assert conditions.match({}) is None, "Test with empty dict" - - assert conditions.match("not a dict") is None, "Test with non-dict" - - @patch("app.library.conditions.match_str") - def test_match_filter_evaluation_error(self, mock_match_str): - """Test matching when filter evaluation raises exception.""" - mock_match_str.side_effect = Exception("Filter error") - - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "filter_error_test.json" - conditions = Conditions(file=file_path) - - test_condition = Condition(name="error_test", filter="invalid filter") - conditions._items = [test_condition] - - info_dict = {"duration": 120} - result = conditions.match(info_dict) - - assert result is None - - @patch("app.library.conditions.match_str") - def test_match_disabled_condition(self, mock_match_str): - """Test that disabled conditions are skipped during matching.""" - mock_match_str.return_value = True - - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "disabled_match_test.json" - conditions = Conditions(file=file_path) - - # Add disabled condition - disabled_condition = Condition(name="disabled_test", filter="duration > 60", enabled=False) - enabled_condition = Condition(name="enabled_test", filter="duration > 120", enabled=True) - conditions._items = [disabled_condition, enabled_condition] - - info_dict = {"duration": 150} - result = conditions.match(info_dict) - - assert result is enabled_condition, "Should skip disabled condition and match enabled one" - # Should only call match_str once for enabled condition - mock_match_str.assert_called_once_with("duration > 120", info_dict) - - @patch("app.library.conditions.match_str") - def test_match_priority_sorting(self, mock_match_str): - """Test that conditions are evaluated by priority (highest first).""" - # All filters will match, so we test that highest priority wins - mock_match_str.return_value = True - - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "priority_test.json" - conditions = Conditions(file=file_path) - - # Add conditions with different priorities (not in priority order) - low_priority = Condition(name="low", filter="duration > 60", priority=1) - high_priority = Condition(name="high", filter="duration > 60", priority=10) - default_priority = Condition(name="default", filter="duration > 60", priority=0) - - # Add in wrong order to verify sorting - conditions._items = [low_priority, default_priority, high_priority] - - info_dict = {"duration": 120} - result = conditions.match(info_dict) - - assert result is high_priority, "Should match high_priority first (priority=10)" - # Should only call match_str once for highest priority condition - mock_match_str.assert_called_once_with("duration > 60", info_dict) - - def test_match_empty_filter(self): - """Test matching with condition that has empty filter.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "empty_filter_test.json" - conditions = Conditions(file=file_path) - - test_condition = Condition(name="empty_filter", filter="") - conditions._items = [test_condition] - - info_dict = {"duration": 120} - result = conditions.match(info_dict) - - assert result is None - - @patch("app.library.conditions.match_str") - def test_single_match_found(self, mock_match_str): - """Test single condition matching.""" - mock_match_str.return_value = True - - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "single_match_test.json" - conditions = Conditions(file=file_path) - - test_condition = Condition(name="single_test", filter="uploader = 'test'") - conditions._items = [test_condition] - - info_dict = {"uploader": "test", "title": "Test Video"} - result = conditions.single_match("single_test", info_dict) - - assert result is test_condition - mock_match_str.assert_called_once_with("uploader = 'test'", info_dict) - - @patch("app.library.conditions.match_str") - def test_single_match_not_found(self, mock_match_str): - """Test single condition matching when condition doesn't match.""" - mock_match_str.return_value = False - - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "single_no_match_test.json" - conditions = Conditions(file=file_path) - - test_condition = Condition(name="single_no_match", filter="uploader = 'other'") - conditions._items = [test_condition] - - info_dict = {"uploader": "test", "title": "Test Video"} - result = conditions.single_match("single_no_match", info_dict) - - assert result is None - - def test_single_match_nonexistent_condition(self): - """Test single matching with non-existent condition name.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "nonexistent_single_test.json" - conditions = Conditions(file=file_path) - - info_dict = {"duration": 120} - result = conditions.single_match("nonexistent", info_dict) - - assert result is None - - def test_single_match_condition_no_filter(self): - """Test single matching with condition that has no filter.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "no_filter_single_test.json" - conditions = Conditions(file=file_path) - - test_condition = Condition(name="no_filter_single", filter="") - conditions._items = [test_condition] - - info_dict = {"duration": 120} - result = conditions.single_match("no_filter_single", info_dict) - - assert result is None - - def test_single_match_disabled_condition(self): - """Test single matching with disabled condition.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "disabled_single_test.json" - conditions = Conditions(file=file_path) - - test_condition = Condition(name="disabled_single", filter="duration > 60", enabled=False) - conditions._items = [test_condition] - - info_dict = {"duration": 120} - result = conditions.single_match("disabled_single", info_dict) - - assert result is None, "Should return None because condition is disabled" - - def test_single_match_invalid_inputs(self): - """Test single matching with invalid inputs.""" - with tempfile.TemporaryDirectory() as temp_dir: - file_path = Path(temp_dir) / "invalid_single_test.json" - conditions = Conditions(file=file_path) - - assert conditions.single_match("test", {"duration": 120}) is None, "Test with empty conditions" - - assert conditions.single_match("test", None) is None, "Test with None info" - - assert conditions.single_match("test", {}) is None, "Test with empty info dict" - - assert conditions.single_match("test", "not a dict") is None, "Test with non-dict info" diff --git a/pyproject.toml b/pyproject.toml index 72d2b805..f08acec9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -163,6 +163,7 @@ ignore = [ "PT011", "RUF001", "S311", + "TC001" ] fixable = ["ALL"] diff --git a/ui/app/components/ConditionForm.vue b/ui/app/components/ConditionForm.vue index dfe0bd2c..d05d2c0e 100644 --- a/ui/app/components/ConditionForm.vue +++ b/ui/app/components/ConditionForm.vue @@ -335,17 +335,18 @@ import 'assets/css/bulma-switch.css' import { useStorage } from '@vueuse/core' import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue' import type { AutoCompleteOptions } from '~/types/autocomplete'; -import type { ConditionItem, ImportedConditionItem } from '~/types/conditions' +import type { Condition } from '~/types/conditions' import { useConfirm } from '~/composables/useConfirm' +import type { ImportedItem } from '~/types'; const emitter = defineEmits<{ (e: 'cancel'): void - (e: 'submit', payload: { reference: string | null | undefined, item: ConditionItem }): void + (e: 'submit', payload: { reference: number | null | undefined, item: Condition }): void }>() const props = defineProps<{ - reference?: string | null - item: ConditionItem + reference?: number | null + item: Condition addInProgress?: boolean }>() @@ -354,7 +355,7 @@ const showImport = useStorage('showImport', false) const box = useConfirm() const config = useConfigStore() -const form = reactive(JSON.parse(JSON.stringify(props.item))) +const form = reactive(JSON.parse(JSON.stringify(props.item))) const import_string = ref('') const newExtraKey = ref('') const newExtraValue = ref('') @@ -394,7 +395,7 @@ watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions watch(() => form.filter, () => test_data.value.changed = true) const checkInfo = async (): Promise => { - const required: (keyof ConditionItem)[] = ['name', 'filter'] + const required: (keyof Condition)[] = ['name', 'filter'] for (const key of required) { if (!form[key]) { @@ -416,7 +417,7 @@ const checkInfo = async (): Promise => { form.cli = form.cli.trim() } - const copy: ConditionItem = JSON.parse(JSON.stringify(form)) + const copy: Condition = JSON.parse(JSON.stringify(form)) for (const key in copy) { if (typeof (copy as any)[key] !== 'string') { @@ -483,9 +484,9 @@ const importItem = async (): Promise => { } try { - const item = decode(val) as ImportedConditionItem + const item = decode(val) as Condition & ImportedItem - if (!item?._type || item._type !== 'condition') { + if (!item._type || item._type !== 'condition') { toast.error(`Invalid import string. Expected type 'condition', got '${item._type ?? 'unknown'}'.`) return } diff --git a/ui/app/composables/useConditions.ts b/ui/app/composables/useConditions.ts new file mode 100644 index 00000000..eb913c27 --- /dev/null +++ b/ui/app/composables/useConditions.ts @@ -0,0 +1,437 @@ +import { ref, readonly } from 'vue' + +import { useNotification } from '~/composables/useNotification' +import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils' +import type { + Condition, + ConditionTestRequest, + ConditionTestResponse, + Pagination, +} from '~/types/conditions' +import type { APIResponse } from '~/types/responses' + +/** + * List of all conditions in memory. + */ +const conditions = ref>([]) +/** + * Pagination state for conditions list. + */ +const pagination = ref({ + page: 1, + per_page: 50, + total: 0, + total_pages: 0, + has_next: false, + has_prev: false, +}) +/** + * Indicates if a request is in progress. + */ +const isLoading = ref(false) + +/** + * Indicates if an add/update operation is in progress. + * Used separately from isLoading for finer control. + */ +const addInProgress = ref(false) + +/** + * Stores the last error message, if any. + */ +const lastError = ref(null) + +/** + * If true, methods will throw errors instead of returning null/false (for testing) + */ +const throwInstead = ref(false) + +/** + * Notification composable for showing success/error messages. + */ +const notify = useNotification() + +/** + * Sorts conditions by priority (descending - higher number first), then name (A-Z). + * @param items Array of Condition + * @returns Sorted array of Condition + */ +const sortConditions = (items: Array): Array => { + return [...items].sort((a, b) => { + if (a.priority === b.priority) { + return a.name.localeCompare(b.name) + } + + return b.priority - a.priority + }) +} + +/** + * Safely reads JSON from a Response, returns null on error. + * @param response Fetch Response object + * @returns Parsed JSON or null + */ +const readJson = async (response: Response): Promise => { + try { + const clone = response.clone() + return await clone.json() + } + catch { + return null + } +} + +/** + * Throws an error if the response is not OK, using API error message if available. + * @param response Fetch Response object + * @throws Error with message from API or status code + */ +const ensureSuccess = async (response: Response): Promise => { + if (response.ok) { + return + } + + const payload = await readJson(response) + const message = await parse_api_error(payload) + throw new Error(message) +} + +/** + * Handles errors by updating lastError and showing a notification. + * @param error Error object or unknown + */ +const handleError = (error: unknown): void => { + const message = error instanceof Error ? error.message : 'Unexpected error occurred.' + lastError.value = message + notify.error(message) +} + +/** + * Updates or adds a condition in the conditions list, keeping sort order. + * Also increments pagination.total if it's a new condition. + * @param condition Condition to update/add + */ +const updateConditions = (condition: Condition): void => { + const isNew = !conditions.value.some(item => item.id === condition.id) + conditions.value = sortConditions([ + ...conditions.value.filter(item => item.id !== condition.id), + condition, + ]) + if (isNew) { + pagination.value.total++ + } +} + +/** + * Removes a condition from the conditions list by ID. + * Also decrements pagination.total. + * @param id Condition ID + */ +const removeCondition = (id: number) => { + const initialLength = conditions.value.length + conditions.value = conditions.value.filter(item => item.id !== id) + if (conditions.value.length < initialLength) { + pagination.value.total = Math.max(0, pagination.value.total - 1) + } +} + +/** + * Loads all conditions from the API with pagination support. + * Updates conditions, pagination, and lastError. + * @param page Page number + * @param perPage Items per page + */ +const loadConditions = async (page: number = 1, perPage: number | undefined = undefined): Promise => { + isLoading.value = true + try { + let url = `/api/conditions/?page=${page}` + if (perPage !== undefined) { + url += `&per_page=${perPage}` + } + const response = await request(url) + await ensureSuccess(response) + + const json = await response.json() + const { items, pagination: paginationData } = await parse_list_response(json) + + conditions.value = sortConditions(items) + pagination.value = paginationData + lastError.value = null + } + catch (error) { + handleError(error) + if (throwInstead.value) throw error + } + finally { + isLoading.value = false + } +} + +/** + * Fetches a single condition by ID from the API. + * @param id Condition ID + * @returns Condition or null on error + */ +const getCondition = async (id: number): Promise => { + try { + const response = await request(`/api/conditions/${id}`) + await ensureSuccess(response) + + const json = await response.json() + const condition = await parse_api_response(json) + + lastError.value = null + return condition + } + catch (error) { + handleError(error) + if (throwInstead.value) throw error + return null + } +} + +/** + * Creates a new condition via API. + * @param condition Condition to create + * @param callback Optional callback with APIResponse result + * @returns Created Condition or null on error + */ +const createCondition = async ( + condition: Omit, + callback?: (response: APIResponse) => void, +): Promise => { + addInProgress.value = true + try { + const response = await request('/api/conditions/', { + method: 'POST', + body: JSON.stringify(condition), + }) + + await ensureSuccess(response) + + const json = await response.json() + const created = await parse_api_response(json) + + updateConditions(created) + notify.success('Condition created.') + lastError.value = null + + if (callback) { + callback({ success: true, error: null, detail: null, data: created }) + } + + return created + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.' + handleError(error) + + if (callback) { + callback({ success: false, error: errorMessage, detail: error, data: undefined }) + } + + if (throwInstead.value) throw error + return null + } + finally { + addInProgress.value = false + } +} + +/** + * Updates an existing condition via API (PUT - full update). + * @param id Condition ID + * @param condition Updated Condition data + * @param callback Optional callback with APIResponse result + * @returns Updated Condition or null on error + */ +const updateCondition = async ( + id: number, + condition: Condition, + callback?: (response: APIResponse) => void, +): Promise => { + addInProgress.value = true + try { + if (condition.id) { + condition.id = undefined + } + const response = await request(`/api/conditions/${id}`, { + method: 'PUT', + body: JSON.stringify(condition), + }) + + await ensureSuccess(response) + + const json = await response.json() + const updated = await parse_api_response(json) + + updateConditions(updated) + notify.success(`Condition '${updated.name}' updated.`) + lastError.value = null + + if (callback) { + callback({ success: true, error: null, detail: null, data: updated }) + } + + return updated + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.' + handleError(error) + + if (callback) { + callback({ success: false, error: errorMessage, detail: error, data: undefined }) + } + + if (throwInstead.value) throw error + return null + } + finally { + addInProgress.value = false + } +} + +/** + * Partially updates an existing condition via API (PATCH). + * @param id Condition ID + * @param patch Partial Condition data to update + * @param callback Optional callback with APIResponse result + * @returns Updated Condition or null on error + */ +const patchCondition = async ( + id: number, + patch: Partial, + callback?: (response: APIResponse) => void, +): Promise => { + addInProgress.value = true + try { + if (patch.id) { + patch.id = undefined + } + const response = await request(`/api/conditions/${id}`, { + method: 'PATCH', + body: JSON.stringify(patch), + }) + + await ensureSuccess(response) + + const json = await response.json() + const updated = await parse_api_response(json) + + updateConditions(updated) + notify.success(`Condition '${updated.name}' updated.`) + lastError.value = null + + if (callback) { + callback({ success: true, error: null, detail: null, data: updated }) + } + + return updated + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.' + handleError(error) + + if (callback) { + callback({ success: false, error: errorMessage, detail: error, data: undefined }) + } + + if (throwInstead.value) throw error + return null + } + finally { + addInProgress.value = false + } +} + +/** + * Deletes a condition by ID via API. + * @param id Condition ID + * @param callback Optional callback with APIResponse result + * @returns true if deleted, false on error + */ +const deleteCondition = async ( + id: number, + callback?: (response: APIResponse) => void, +): Promise => { + try { + const response = await request(`/api/conditions/${id}`, { method: 'DELETE' }) + await ensureSuccess(response) + + removeCondition(id) + notify.success('Condition deleted.') + lastError.value = null + + if (callback) { + callback({ success: true, error: null, detail: null, data: true }) + } + + return true + } + catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.' + handleError(error) + + if (callback) { + callback({ success: false, error: errorMessage, detail: error, data: false }) + } + + if (throwInstead.value) throw error + return false + } +} + +/** + * Tests a condition against a URL. + * @param testRequest Test request parameters + * @returns Test result or null on error + */ +const testCondition = async (testRequest: ConditionTestRequest): Promise => { + try { + const response = await request('/api/conditions/test/', { + method: 'POST', + body: JSON.stringify(testRequest), + }) + + await ensureSuccess(response) + + const json = await response.json() + const result = await parse_api_response(json) + + lastError.value = null + return result + } + catch (error) { + handleError(error) + if (throwInstead.value) throw error + return null + } +} + +/** + * Clears the last error message. + */ +const clearError = () => lastError.value = null + +/** + * useConditions composable + * + * Returns reactive state and CRUD methods for conditions. + * @returns Object with state and API methods + */ +export const useConditions = () => ({ + conditions: readonly(conditions), + pagination: readonly(pagination), + isLoading: readonly(isLoading), + addInProgress: readonly(addInProgress), + lastError: readonly(lastError), + loadConditions, + getCondition, + createCondition, + updateCondition, + patchCondition, + deleteCondition, + testCondition, + clearError, + throwInstead, +}) diff --git a/ui/app/pages/conditions.vue b/ui/app/pages/conditions.vue index 24196cc3..e9d64e1c 100644 --- a/ui/app/pages/conditions.vue +++ b/ui/app/pages/conditions.vue @@ -47,8 +47,8 @@

- @@ -62,8 +62,13 @@ +

+ +
+
-
@@ -285,29 +290,33 @@ diff --git a/ui/app/types/conditions.d.ts b/ui/app/types/conditions.d.ts index fcab424c..ccca690a 100644 --- a/ui/app/types/conditions.d.ts +++ b/ui/app/types/conditions.d.ts @@ -1,15 +1,31 @@ -export type ConditionItem = { - id?: string +export interface Condition { + id?: number name: string filter: string cli: string - extras: Record + extras: Record enabled: boolean priority: number description: string } -export type ImportedConditionItem = ConditionItem & { - _type: string - _version: string +export interface Pagination { + page: number + per_page: number + total: number + total_pages: number + has_next: boolean + has_prev: boolean +} + +export interface ConditionTestRequest { + url: string + condition: string + preset?: string +} + +export interface ConditionTestResponse { + status: boolean + condition: string + data: Record } diff --git a/ui/app/types/index.d.ts b/ui/app/types/index.d.ts new file mode 100644 index 00000000..ce28ed7e --- /dev/null +++ b/ui/app/types/index.d.ts @@ -0,0 +1,6 @@ +type ImportedItem = { + _type: string, + _version: string +} + +export { ImportedItem } diff --git a/ui/app/types/responses.d.ts b/ui/app/types/responses.d.ts index 377ed414..af95453e 100644 --- a/ui/app/types/responses.d.ts +++ b/ui/app/types/responses.d.ts @@ -13,6 +13,26 @@ export type convert_args_response = { /** The download format if was provided */ format?: string, /** The removed options from the original CLI args if any. */ - removed_options?: string[], + removed_options?: Array, } +export type Pagination = { + page: number + per_page: number + total: number + total_pages: number + has_next: boolean + has_prev: boolean +} + +export type Paginated = { + items: Array + pagination: Pagination +} + +export interface APIResponse { + success: boolean + error: string | null + detail: unknown + data?: T +} diff --git a/ui/app/utils/index.ts b/ui/app/utils/index.ts index af7321f0..3e0c393d 100644 --- a/ui/app/utils/index.ts +++ b/ui/app/utils/index.ts @@ -1,5 +1,5 @@ import { useStorage } from '@vueuse/core' -import type { convert_args_response } from '~/types/responses' +import type { convert_args_response,Paginated } from '~/types/responses' import type { StoreItem } from '~/types/store' const AG_SEPARATOR = '.' @@ -815,10 +815,59 @@ const getImage = (basePath: string, item: StoreItem, fallback: boolean = true): return fallback ? uri('/images/placeholder.png') : '' } +const parse_list_response = async (json: unknown): Promise> => { + if ('function' === typeof (json as any).then) { + json = await (json as Promise) + } + + if (!json || 'object' !== typeof json) { + return { items: [], pagination: { page: 1, per_page: 20, total: 0, total_pages: 0, has_next: false, has_prev: false } } + } + + const payload = json as Paginated + const items = Array.isArray(payload.items) ? payload.items : [] + + const pagination = { + page: Number(payload.pagination?.page ?? 1), + per_page: Number(payload.pagination?.per_page ?? 20), + total: Number(payload.pagination?.total ?? 0), + total_pages: Number(payload.pagination?.total_pages ?? 0), + has_next: Boolean(payload.pagination?.has_next ?? false), + has_prev: Boolean(payload.pagination?.has_prev ?? false), + } + + return { items: items as T[], pagination } +} + +const parse_api_response = async (json: unknown): Promise => { + if ('function' === typeof (json as any).then) { + json = await (json as Promise) + } + return json as T +} + +const parse_api_error = async (json: unknown): Promise => { + if ('function' === typeof (json as any).then) { + json = await (json as Promise) + } + + if (!json || 'object' !== typeof json) { + return 'Unknown error occurred' + } + + const payload = json as { + error?: string; + message?: string; + detail?: string[]; + } + return String(payload.error ?? payload.message ?? payload.detail?.[0] ?? 'Unknown error occurred') +} + export { separators, convertCliOptions, getSeparatorsName, iTrim, eTrim, sTrim, ucFirst, getValue, ag, ag_set, awaitElement, r, copyText, dEvent, makePagination, encodePath, request, removeANSIColors, dec2hex, makeId, basename, dirname, getQueryParams, makeDownload, formatBytes, has_data, toggleClass, cleanObject, uri, formatTime, - sleep, awaiter, encode, decode, disableOpacity, enableOpacity, stripPath, shortPath, deepIncludes, getPath, getImage + sleep, awaiter, encode, decode, disableOpacity, enableOpacity, stripPath, shortPath, deepIncludes, getPath, getImage, + parse_list_response, parse_api_response, parse_api_error } diff --git a/ui/tests/composables/useConditions.test.ts b/ui/tests/composables/useConditions.test.ts new file mode 100644 index 00000000..957cb4b1 --- /dev/null +++ b/ui/tests/composables/useConditions.test.ts @@ -0,0 +1,553 @@ +import * as utils from '~/utils/index' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { useConditions } from '~/composables/useConditions' +import type { Condition, Pagination } from '~/types/conditions' + +vi.mock('~/composables/useNotification', () => { + const success = vi.fn() + const error = vi.fn() + return { + useNotification: () => ({ success, error }), + default: () => ({ success, error }), + } +}) + +// Sample data +const mockCondition: Condition = { + id: 1, + name: 'Test Condition', + filter: 'duration > 60', + cli: '--format best', + extras: { test: true }, + enabled: true, + priority: 10, + description: 'Test description', +} + +const mockPagination: Pagination = { + page: 1, + per_page: 50, + total: 1, + total_pages: 1, + has_next: false, + has_prev: false, +} + +// Helper to create a mock Response object +function createMockResponse({ ok, status, jsonData }: { ok: boolean; status: number; jsonData: any }) { + return { + ok, + status, + headers: new Headers(), + redirected: false, + statusText: '', + type: 'basic', + url: '', + body: null, + bodyUsed: false, + clone() { + return this + }, + async json() { + return jsonData + }, + text: async () => JSON.stringify(jsonData), + arrayBuffer: async () => new ArrayBuffer(0), + blob: async () => new Blob(), + formData: async () => new FormData(), + } as Response +} + +describe('useConditions', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('loadConditions', () => { + it('loads conditions with pagination successfully', async () => { + vi.spyOn(utils, 'request').mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + jsonData: { + items: [mockCondition], + pagination: mockPagination, + }, + }), + ) + + const conditions = useConditions() + await conditions.loadConditions() + + expect(conditions.conditions.value).toHaveLength(1) + expect(conditions.conditions.value[0]).toEqual(mockCondition) + expect(conditions.pagination.value).toEqual(mockPagination) + expect(conditions.lastError.value).toBeNull() + }) + + it('sorts conditions by priority then name', async () => { + const items = [ + { ...mockCondition, id: 1, name: 'B', priority: 2 }, + { ...mockCondition, id: 2, name: 'A', priority: 2 }, + { ...mockCondition, id: 3, name: 'C', priority: 1 }, + ] + + vi.spyOn(utils, 'request').mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + jsonData: { + items, + pagination: mockPagination, + }, + }), + ) + + const conditions = useConditions() + await conditions.loadConditions() + + const sorted = conditions.conditions.value + // Priority descending (2 before 1), then name ascending (A before B) + expect(sorted[0].priority).toBe(2) + expect(sorted[0].name).toBe('A') + expect(sorted[1].priority).toBe(2) + expect(sorted[1].name).toBe('B') + expect(sorted[2].priority).toBe(1) + expect(sorted[2].name).toBe('C') + }) + + it('handles empty conditions list', async () => { + vi.spyOn(utils, 'request').mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + jsonData: { + items: [], + pagination: { ...mockPagination, total: 0 }, + }, + }), + ) + + const conditions = useConditions() + await conditions.loadConditions() + + expect(conditions.conditions.value).toEqual([]) + expect(conditions.lastError.value).toBeNull() + }) + + it('handles errors during load', async () => { + vi.spyOn(utils, 'request').mockResolvedValueOnce( + createMockResponse({ + ok: false, + status: 500, + jsonData: { error: 'Server error' }, + }), + ) + + const conditions = useConditions() + await conditions.loadConditions() + + expect(conditions.lastError.value).toBeTruthy() + }) + }) + + describe('getCondition', () => { + it('fetches a single condition successfully', async () => { + vi.spyOn(utils, 'request').mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + jsonData: mockCondition, + }), + ) + + const conditions = useConditions() + const result = await conditions.getCondition(1) + + expect(result).toEqual(mockCondition) + expect(conditions.lastError.value).toBeNull() + }) + + it('handles 404 not found', async () => { + vi.spyOn(utils, 'request').mockResolvedValueOnce( + createMockResponse({ + ok: false, + status: 404, + jsonData: { error: 'Condition not found' }, + }), + ) + + const conditions = useConditions() + const result = await conditions.getCondition(999) + + expect(result).toBeNull() + expect(conditions.lastError.value).toBeTruthy() + }) + }) + + describe('createCondition', () => { + it('creates a condition successfully', async () => { + vi.spyOn(utils, 'request').mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + jsonData: mockCondition, + }), + ) + + const conditions = useConditions() + const initialTotal = conditions.pagination.value.total + const newCondition = { ...mockCondition } + delete newCondition.id + + const result = await conditions.createCondition(newCondition) + + expect(result).toEqual(mockCondition) + expect(conditions.conditions.value).toContainEqual(mockCondition) + expect(conditions.pagination.value.total).toBe(initialTotal + 1) + }) + + it('calls callback on success', async () => { + vi.spyOn(utils, 'request').mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + jsonData: mockCondition, + }), + ) + + const callback = vi.fn() + const conditions = useConditions() + const newCondition = { ...mockCondition } + delete newCondition.id + + await conditions.createCondition(newCondition, callback) + + expect(callback).toHaveBeenCalledWith({ + success: true, + error: null, + detail: null, + data: mockCondition, + }) + }) + + it('calls callback on error', async () => { + vi.spyOn(utils, 'request').mockResolvedValueOnce( + createMockResponse({ + ok: false, + status: 400, + jsonData: { detail: [{ loc: ['name'], msg: 'Field required', type: 'value_error' }] }, + }), + ) + + const callback = vi.fn() + const conditions = useConditions() + const newCondition = { ...mockCondition } + delete newCondition.id + + await conditions.createCondition(newCondition, callback) + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: expect.any(String), + }), + ) + }) + }) + + describe('updateCondition', () => { + it('updates a condition successfully', async () => { + vi.spyOn(utils, 'request').mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + jsonData: { ...mockCondition, name: 'Updated Name' }, + }), + ) + + const conditions = useConditions() + const updated = { ...mockCondition, name: 'Updated Name' } + + const result = await conditions.updateCondition(1, updated) + + expect(result?.name).toBe('Updated Name') + }) + + it('calls callback on success', async () => { + const updatedCondition = { ...mockCondition, name: 'Updated' } + vi.spyOn(utils, 'request').mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + jsonData: updatedCondition, + }), + ) + + const callback = vi.fn() + const conditions = useConditions() + + await conditions.updateCondition(1, updatedCondition, callback) + + expect(callback).toHaveBeenCalledWith({ + success: true, + error: null, + detail: null, + data: updatedCondition, + }) + }) + + it('removes id field from condition before sending', async () => { + const requestSpy = vi.spyOn(utils, 'request').mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + jsonData: mockCondition, + }), + ) + + const conditions = useConditions() + await conditions.updateCondition(1, mockCondition) + + const requestBody = JSON.parse((requestSpy.mock.calls[0][1] as any).body) + expect(requestBody.id).toBeUndefined() + }) + }) + + describe('patchCondition', () => { + it('patches a condition successfully', async () => { + vi.spyOn(utils, 'request').mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + jsonData: { ...mockCondition, enabled: false }, + }), + ) + + const conditions = useConditions() + const result = await conditions.patchCondition(1, { enabled: false }) + + expect(result?.enabled).toBe(false) + }) + + it('calls callback on patch success', async () => { + const patchedCondition = { ...mockCondition, enabled: false } + vi.spyOn(utils, 'request').mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + jsonData: patchedCondition, + }), + ) + + const callback = vi.fn() + const conditions = useConditions() + + await conditions.patchCondition(1, { enabled: false }, callback) + + expect(callback).toHaveBeenCalledWith({ + success: true, + error: null, + detail: null, + data: patchedCondition, + }) + }) + + it('handles validation errors with callback', async () => { + vi.spyOn(utils, 'request').mockResolvedValueOnce( + createMockResponse({ + ok: false, + status: 400, + jsonData: { detail: [{ loc: ['priority'], msg: 'Invalid priority', type: 'value_error' }] }, + }), + ) + + const callback = vi.fn() + const conditions = useConditions() + + await conditions.patchCondition(1, { priority: -1 }, callback) + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: expect.any(String), + }), + ) + }) + }) + + describe('deleteCondition', () => { + it('deletes a condition successfully', async () => { + vi.spyOn(utils, 'request').mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + jsonData: mockCondition, + }), + ) + + const conditions = useConditions() + const initialTotal = conditions.pagination.value.total + + const result = await conditions.deleteCondition(mockCondition.id!) + + expect(result).toBe(true) + expect(conditions.pagination.value.total).toBe(Math.max(0, initialTotal - 1)) + }) + + it('calls callback on delete success', async () => { + vi.spyOn(utils, 'request').mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + jsonData: mockCondition, + }), + ) + + const callback = vi.fn() + const conditions = useConditions() + + await conditions.deleteCondition(1, callback) + + expect(callback).toHaveBeenCalledWith({ + success: true, + error: null, + detail: null, + data: true, + }) + }) + + it('calls callback on delete error', async () => { + vi.spyOn(utils, 'request').mockResolvedValueOnce( + createMockResponse({ + ok: false, + status: 404, + jsonData: { error: 'Condition not found' }, + }), + ) + + const callback = vi.fn() + const conditions = useConditions() + + await conditions.deleteCondition(999, callback) + + expect(callback).toHaveBeenCalledWith( + expect.objectContaining({ + success: false, + error: expect.any(String), + data: false, + }), + ) + }) + }) + + describe('testCondition', () => { + it('tests a condition successfully', async () => { + const testResponse = { + status: true, + condition: 'duration > 60', + data: { duration: 120 }, + } + + vi.spyOn(utils, 'request').mockResolvedValueOnce( + createMockResponse({ + ok: true, + status: 200, + jsonData: testResponse, + }), + ) + + const conditions = useConditions() + const result = await conditions.testCondition({ + url: 'https://example.com/video', + condition: 'duration > 60', + }) + + expect(result).toEqual(testResponse) + expect(result?.status).toBe(true) + }) + + it('handles test errors', async () => { + vi.spyOn(utils, 'request').mockResolvedValueOnce( + createMockResponse({ + ok: false, + status: 400, + jsonData: { error: 'Invalid URL' }, + }), + ) + + const conditions = useConditions() + const result = await conditions.testCondition({ + url: 'invalid', + condition: 'test', + }) + + expect(result).toBeNull() + expect(conditions.lastError.value).toBeTruthy() + }) + }) + + describe('error handling', () => { + it('throws when throwInstead is true', async () => { + vi.spyOn(utils, 'request').mockResolvedValueOnce( + createMockResponse({ + ok: false, + status: 500, + jsonData: { error: 'Server error' }, + }), + ) + + const conditions = useConditions() + conditions.throwInstead.value = true + + await expect(conditions.loadConditions()).rejects.toThrow() + }) + + it('clears error on clearError call', async () => { + vi.spyOn(utils, 'request').mockResolvedValueOnce( + createMockResponse({ + ok: false, + status: 500, + jsonData: { error: 'Server error' }, + }), + ) + + const conditions = useConditions() + conditions.throwInstead.value = false + + // Don't throw, just capture the error + await conditions.loadConditions() + + expect(conditions.lastError.value).toBeTruthy() + + conditions.clearError() + + expect(conditions.lastError.value).toBeNull() + }) + }) + + describe('addInProgress state', () => { + it('sets addInProgress during create operation', async () => { + let inProgressDuringCall = false + + vi.spyOn(utils, 'request').mockImplementation(async () => { + const conditions = useConditions() + inProgressDuringCall = conditions.addInProgress.value + return createMockResponse({ + ok: true, + status: 200, + jsonData: mockCondition, + }) + }) + + const conditions = useConditions() + const newCondition = { ...mockCondition } + delete newCondition.id + + await conditions.createCondition(newCondition) + + expect(inProgressDuringCall).toBe(true) + expect(conditions.addInProgress.value).toBe(false) + }) + }) +}) diff --git a/ui/tests/composables/useTaskDefinitions.test.ts b/ui/tests/composables/useTaskDefinitions.test.ts index 6c43521c..881540f2 100644 --- a/ui/tests/composables/useTaskDefinitions.test.ts +++ b/ui/tests/composables/useTaskDefinitions.test.ts @@ -1,4 +1,4 @@ -import * as utils from '../../app/utils/index' +import * as utils from '~/utils/index' import { describe, it, expect, beforeEach, vi } from 'vitest' import { useTaskDefinitions } from '~/composables/useTaskDefinitions' import type { TaskDefinitionSummary } from '~/types/task_definitions' diff --git a/ui/tests/utils/index.test.ts b/ui/tests/utils/index.test.ts index a04563c3..9b4640f4 100644 --- a/ui/tests/utils/index.test.ts +++ b/ui/tests/utils/index.test.ts @@ -50,7 +50,7 @@ const originalFetch = globalThis.fetch const originalClipboard = globalThis.navigator?.clipboard const originalCrypto = globalThis.crypto -let utils: Awaited +let utils: Awaited let fetchSpy: MockInstance | undefined const resetStorage = () => { @@ -141,7 +141,7 @@ globalThis.atob = globalThis.atob ?? ((b64: string) => Buffer.from(b64, 'base64' beforeAll(async () => { // Import utils after all mocks are set up - utils = await import('../../app/utils/index') + utils = await import('~/utils/index') }) beforeEach(() => { diff --git a/ui/tests/utils/ytdlp.test.ts b/ui/tests/utils/ytdlp.test.ts index d64944bb..b436a83d 100644 --- a/ui/tests/utils/ytdlp.test.ts +++ b/ui/tests/utils/ytdlp.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { MatchFilterParser } from '../../app/utils/ytdlp' +import { MatchFilterParser } from '~/utils/ytdlp' function normalize(filters: string[]): Set { return new Set(filters.map(f => f.split("&").map(x => x.trim()).sort().join("&")));