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
-