refactor: conditions feature to db model

This commit is contained in:
arabcoders 2026-01-18 22:17:07 +03:00
parent f98acbaa42
commit a09343938d
33 changed files with 2575 additions and 1672 deletions

View file

@ -0,0 +1 @@
"""Conditions Feature"""

View file

@ -0,0 +1,5 @@
from app.features.conditions.repository import ConditionsRepository
def get_conditions_repo() -> ConditionsRepository:
return ConditionsRepository.get_instance()

View file

@ -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,
)

View file

@ -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)

View file

@ -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

View file

@ -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,
)

View file

@ -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

View file

@ -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

View file

@ -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"

12
app/features/core/deps.py Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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()
]

View file

@ -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

View file

@ -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}'."

View file

@ -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__

View file

@ -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

View file

@ -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))

View file

@ -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

View file

@ -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"

View file

@ -163,6 +163,7 @@ ignore = [
"PT011",
"RUF001",
"S311",
"TC001"
]
fixable = ["ALL"]

View file

@ -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<ConditionItem>(JSON.parse(JSON.stringify(props.item)))
const form = reactive<Condition>(JSON.parse(JSON.stringify(props.item)))
const import_string = ref('')
const newExtraKey = ref('')
const newExtraValue = ref('')
@ -394,7 +395,7 @@ watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions
watch(() => form.filter, () => test_data.value.changed = true)
const checkInfo = async (): Promise<void> => {
const required: (keyof ConditionItem)[] = ['name', 'filter']
const required: (keyof Condition)[] = ['name', 'filter']
for (const key of required) {
if (!form[key]) {
@ -416,7 +417,7 @@ const checkInfo = async (): Promise<void> => {
form.cli = form.cli.trim()
}
const copy: ConditionItem = JSON.parse(JSON.stringify(form))
const copy: Condition = JSON.parse(JSON.stringify(form))
for (const key in copy) {
if (typeof (copy as any)[key] !== 'string') {
@ -483,9 +484,9 @@ const importItem = async (): Promise<void> => {
}
try {
const item = decode(val) as ImportedConditionItem
const item = decode(val) as Condition & ImportedItem
if (!item?._type || item._type !== 'condition') {
if (!item._type || item._type !== 'condition') {
toast.error(`Invalid import string. Expected type 'condition', got '${item._type ?? 'unknown'}'.`)
return
}

View file

@ -0,0 +1,437 @@
import { ref, readonly } from 'vue'
import { useNotification } from '~/composables/useNotification'
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils'
import type {
Condition,
ConditionTestRequest,
ConditionTestResponse,
Pagination,
} from '~/types/conditions'
import type { APIResponse } from '~/types/responses'
/**
* List of all conditions in memory.
*/
const conditions = ref<Array<Condition>>([])
/**
* Pagination state for conditions list.
*/
const pagination = ref<Pagination>({
page: 1,
per_page: 50,
total: 0,
total_pages: 0,
has_next: false,
has_prev: false,
})
/**
* Indicates if a request is in progress.
*/
const isLoading = ref<boolean>(false)
/**
* Indicates if an add/update operation is in progress.
* Used separately from isLoading for finer control.
*/
const addInProgress = ref<boolean>(false)
/**
* Stores the last error message, if any.
*/
const lastError = ref<string | null>(null)
/**
* If true, methods will throw errors instead of returning null/false (for testing)
*/
const throwInstead = ref(false)
/**
* Notification composable for showing success/error messages.
*/
const notify = useNotification()
/**
* Sorts conditions by priority (descending - higher number first), then name (A-Z).
* @param items Array of Condition
* @returns Sorted array of Condition
*/
const sortConditions = (items: Array<Condition>): Array<Condition> => {
return [...items].sort((a, b) => {
if (a.priority === b.priority) {
return a.name.localeCompare(b.name)
}
return b.priority - a.priority
})
}
/**
* Safely reads JSON from a Response, returns null on error.
* @param response Fetch Response object
* @returns Parsed JSON or null
*/
const readJson = async (response: Response): Promise<unknown> => {
try {
const clone = response.clone()
return await clone.json()
}
catch {
return null
}
}
/**
* Throws an error if the response is not OK, using API error message if available.
* @param response Fetch Response object
* @throws Error with message from API or status code
*/
const ensureSuccess = async (response: Response): Promise<void> => {
if (response.ok) {
return
}
const payload = await readJson(response)
const message = await parse_api_error(payload)
throw new Error(message)
}
/**
* Handles errors by updating lastError and showing a notification.
* @param error Error object or unknown
*/
const handleError = (error: unknown): void => {
const message = error instanceof Error ? error.message : 'Unexpected error occurred.'
lastError.value = message
notify.error(message)
}
/**
* Updates or adds a condition in the conditions list, keeping sort order.
* Also increments pagination.total if it's a new condition.
* @param condition Condition to update/add
*/
const updateConditions = (condition: Condition): void => {
const isNew = !conditions.value.some(item => item.id === condition.id)
conditions.value = sortConditions([
...conditions.value.filter(item => item.id !== condition.id),
condition,
])
if (isNew) {
pagination.value.total++
}
}
/**
* Removes a condition from the conditions list by ID.
* Also decrements pagination.total.
* @param id Condition ID
*/
const removeCondition = (id: number) => {
const initialLength = conditions.value.length
conditions.value = conditions.value.filter(item => item.id !== id)
if (conditions.value.length < initialLength) {
pagination.value.total = Math.max(0, pagination.value.total - 1)
}
}
/**
* Loads all conditions from the API with pagination support.
* Updates conditions, pagination, and lastError.
* @param page Page number
* @param perPage Items per page
*/
const loadConditions = async (page: number = 1, perPage: number | undefined = undefined): Promise<void> => {
isLoading.value = true
try {
let url = `/api/conditions/?page=${page}`
if (perPage !== undefined) {
url += `&per_page=${perPage}`
}
const response = await request(url)
await ensureSuccess(response)
const json = await response.json()
const { items, pagination: paginationData } = await parse_list_response<Condition>(json)
conditions.value = sortConditions(items)
pagination.value = paginationData
lastError.value = null
}
catch (error) {
handleError(error)
if (throwInstead.value) throw error
}
finally {
isLoading.value = false
}
}
/**
* Fetches a single condition by ID from the API.
* @param id Condition ID
* @returns Condition or null on error
*/
const getCondition = async (id: number): Promise<Condition | null> => {
try {
const response = await request(`/api/conditions/${id}`)
await ensureSuccess(response)
const json = await response.json()
const condition = await parse_api_response<Condition>(json)
lastError.value = null
return condition
}
catch (error) {
handleError(error)
if (throwInstead.value) throw error
return null
}
}
/**
* Creates a new condition via API.
* @param condition Condition to create
* @param callback Optional callback with APIResponse result
* @returns Created Condition or null on error
*/
const createCondition = async (
condition: Omit<Condition, 'id'>,
callback?: (response: APIResponse<Condition>) => void,
): Promise<Condition | null> => {
addInProgress.value = true
try {
const response = await request('/api/conditions/', {
method: 'POST',
body: JSON.stringify(condition),
})
await ensureSuccess(response)
const json = await response.json()
const created = await parse_api_response<Condition>(json)
updateConditions(created)
notify.success('Condition created.')
lastError.value = null
if (callback) {
callback({ success: true, error: null, detail: null, data: created })
}
return created
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
handleError(error)
if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: undefined })
}
if (throwInstead.value) throw error
return null
}
finally {
addInProgress.value = false
}
}
/**
* Updates an existing condition via API (PUT - full update).
* @param id Condition ID
* @param condition Updated Condition data
* @param callback Optional callback with APIResponse result
* @returns Updated Condition or null on error
*/
const updateCondition = async (
id: number,
condition: Condition,
callback?: (response: APIResponse<Condition>) => void,
): Promise<Condition | null> => {
addInProgress.value = true
try {
if (condition.id) {
condition.id = undefined
}
const response = await request(`/api/conditions/${id}`, {
method: 'PUT',
body: JSON.stringify(condition),
})
await ensureSuccess(response)
const json = await response.json()
const updated = await parse_api_response<Condition>(json)
updateConditions(updated)
notify.success(`Condition '${updated.name}' updated.`)
lastError.value = null
if (callback) {
callback({ success: true, error: null, detail: null, data: updated })
}
return updated
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
handleError(error)
if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: undefined })
}
if (throwInstead.value) throw error
return null
}
finally {
addInProgress.value = false
}
}
/**
* Partially updates an existing condition via API (PATCH).
* @param id Condition ID
* @param patch Partial Condition data to update
* @param callback Optional callback with APIResponse result
* @returns Updated Condition or null on error
*/
const patchCondition = async (
id: number,
patch: Partial<Condition>,
callback?: (response: APIResponse<Condition>) => void,
): Promise<Condition | null> => {
addInProgress.value = true
try {
if (patch.id) {
patch.id = undefined
}
const response = await request(`/api/conditions/${id}`, {
method: 'PATCH',
body: JSON.stringify(patch),
})
await ensureSuccess(response)
const json = await response.json()
const updated = await parse_api_response<Condition>(json)
updateConditions(updated)
notify.success(`Condition '${updated.name}' updated.`)
lastError.value = null
if (callback) {
callback({ success: true, error: null, detail: null, data: updated })
}
return updated
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
handleError(error)
if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: undefined })
}
if (throwInstead.value) throw error
return null
}
finally {
addInProgress.value = false
}
}
/**
* Deletes a condition by ID via API.
* @param id Condition ID
* @param callback Optional callback with APIResponse result
* @returns true if deleted, false on error
*/
const deleteCondition = async (
id: number,
callback?: (response: APIResponse<boolean>) => void,
): Promise<boolean> => {
try {
const response = await request(`/api/conditions/${id}`, { method: 'DELETE' })
await ensureSuccess(response)
removeCondition(id)
notify.success('Condition deleted.')
lastError.value = null
if (callback) {
callback({ success: true, error: null, detail: null, data: true })
}
return true
}
catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
handleError(error)
if (callback) {
callback({ success: false, error: errorMessage, detail: error, data: false })
}
if (throwInstead.value) throw error
return false
}
}
/**
* Tests a condition against a URL.
* @param testRequest Test request parameters
* @returns Test result or null on error
*/
const testCondition = async (testRequest: ConditionTestRequest): Promise<ConditionTestResponse | null> => {
try {
const response = await request('/api/conditions/test/', {
method: 'POST',
body: JSON.stringify(testRequest),
})
await ensureSuccess(response)
const json = await response.json()
const result = await parse_api_response<ConditionTestResponse>(json)
lastError.value = null
return result
}
catch (error) {
handleError(error)
if (throwInstead.value) throw error
return null
}
}
/**
* Clears the last error message.
*/
const clearError = () => lastError.value = null
/**
* useConditions composable
*
* Returns reactive state and CRUD methods for conditions.
* @returns Object with state and API methods
*/
export const useConditions = () => ({
conditions: readonly(conditions),
pagination: readonly(pagination),
isLoading: readonly(isLoading),
addInProgress: readonly(addInProgress),
lastError: readonly(lastError),
loadConditions,
getCondition,
createCondition,
updateCondition,
patchCondition,
deleteCondition,
testCondition,
clearError,
throwInstead,
})

View file

@ -47,8 +47,8 @@
</button>
</p>
<p class="control">
<button class="button is-info" @click="reloadContent(false)" :class="{ 'is-loading': isLoading }"
:disabled="isLoading" v-if="items && items.length > 0">
<button class="button is-info" @click="async () => await loadContent(page)"
:class="{ 'is-loading': isLoading }" :disabled="isLoading" v-if="items && items.length > 0">
<span class="icon"><i class="fas fa-refresh" /></span>
<span v-if="!isMobile">Reload</span>
</button>
@ -62,8 +62,13 @@
</div>
</div>
<div class="column is-12" v-if="!toggleForm && paging?.total_pages > 1">
<Pager :page="paging.page" :last_page="paging.total_pages" :isLoading="isLoading"
@navigate="async (newPage) => { page = newPage; await loadContent(newPage); }" />
</div>
<div class="column is-12" v-if="toggleForm">
<ConditionForm :addInProgress="addInProgress" :reference="itemRef" :item="item as ConditionItem"
<ConditionForm :addInProgress="conditions.addInProgress.value" :reference="itemRef" :item="item as Condition"
@cancel="resetForm(true)" @submit="updateItem" />
</div>
</div>
@ -285,29 +290,33 @@
<script setup lang="ts">
import { useStorage } from '@vueuse/core'
import type { ConditionItem, ImportedConditionItem } from '~/types/conditions'
import type { Condition } from '~/types/conditions'
import { useConfirm } from '~/composables/useConfirm'
import { useConditions } from '~/composables/useConditions'
import type { APIResponse } from '~/types/responses'
type ConditionItemWithUI = ConditionItem & { raw?: boolean }
type ConditionItemWithUI = Condition & { raw?: boolean }
const toast = useNotification()
const socket = useSocketStore()
const box = useConfirm()
const isMobile = useMediaQuery({ maxWidth: 1024 })
const display_style = useStorage<'list' | 'grid'>('conditions_display_style', 'grid')
const conditions = useConditions()
const route = useRoute()
const items = ref<ConditionItemWithUI[]>([])
const item = ref<Partial<ConditionItem>>({})
const itemRef = ref<string | null | undefined>("")
const items = conditions.conditions as Ref<ConditionItemWithUI[]>
const paging = conditions.pagination
const isLoading = conditions.isLoading
const page = ref<number>(route.query.page ? parseInt(route.query.page as string, 10) : 1)
const item = ref<Partial<Condition>>({})
const itemRef = ref<number | null | undefined>(null)
const toggleForm = ref(false)
const isLoading = ref(false)
const initialLoad = ref(true)
const addInProgress = ref(false)
const query = ref<string>('')
const toggleFilter = ref(false)
const remove_keys = ['raw', 'toggle_description']
const expandedItems = ref<Record<string, Set<string>>>({})
const expandedItems = reactive<Record<number, Set<string>>>({})
const filteredItems = computed<ConditionItemWithUI[]>(() => {
const q = query.value?.toLowerCase();
@ -315,194 +324,99 @@ const filteredItems = computed<ConditionItemWithUI[]>(() => {
return items.value.filter((item: ConditionItemWithUI) => deepIncludes(item, q, new WeakSet()));
});
const toggleExpand = (itemId: string | undefined, field: string) => {
if (!itemId) return
if (!expandedItems.value[itemId]) {
expandedItems.value[itemId] = new Set()
}
if (expandedItems.value[itemId].has(field)) {
expandedItems.value[itemId].delete(field)
} else {
expandedItems.value[itemId].add(field)
const loadContent = async (page: number = 1): Promise<void> => {
await conditions.loadConditions(page)
await nextTick()
if (conditions.pagination.value.total_pages > 1) {
useRouter().replace({ query: { ...route.query, page: page.toString() } })
}
}
const isExpanded = (itemId: string | undefined, field: string): boolean => {
const toggleExpand = (itemId: number | undefined, field: string) => {
if (!itemId) return
if (!expandedItems[itemId]) {
expandedItems[itemId] = new Set()
}
if (expandedItems[itemId].has(field)) {
expandedItems[itemId].delete(field)
} else {
expandedItems[itemId].add(field)
}
}
const isExpanded = (itemId: number | undefined, field: string): boolean => {
if (!itemId) return false
return expandedItems.value[itemId]?.has(field) ?? false
return expandedItems[itemId]?.has(field) ?? false
}
watch(() => socket.isConnected, async () => {
if (socket.isConnected && initialLoad.value) {
await reloadContent(true)
await loadContent(page.value)
initialLoad.value = false
}
})
watch(toggleFilter, (val) => {
watch(toggleFilter, val => {
if (!val) {
query.value = ''
}
})
const reloadContent = async (fromMounted = false): Promise<void> => {
try {
isLoading.value = true
const response = await request('/api/conditions')
if (fromMounted && !response.ok) {
return
}
const data = await response.json()
if (data.length < 1) {
return
}
items.value = data
} catch (e) {
if (!fromMounted) {
console.error(e)
toast.error('Failed to fetch page content.')
}
} finally {
isLoading.value = false
}
}
const resetForm = (closeForm = false): void => {
item.value = {}
itemRef.value = null
addInProgress.value = false
if (closeForm) {
toggleForm.value = false
}
}
const updateItems = async (newItems: ConditionItem[]): Promise<boolean> => {
let data: any
try {
addInProgress.value = true
const validItems = newItems.map(({ id, name, filter, cli, extras, enabled, priority, description }) => {
if (!name || !filter) {
throw new Error('Name and filter are required.')
}
return { id, name, filter, cli, extras, enabled, priority, description }
})
const response = await request('/api/conditions', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(validItems),
})
data = await response.json() as ConditionItem[]
if (response.status !== 200) {
toast.error(`Failed to update items. ${data.error}`)
return false
}
items.value = data
resetForm(true)
return true
} catch (e: any) {
toast.error(`Failed to update items. ${data?.error ?? ''} ${e.message}`)
} finally {
addInProgress.value = false
}
return false
}
const deleteItem = async (cond: ConditionItem): Promise<void> => {
const deleteItem = async (cond: Condition): Promise<void> => {
if (true !== (await box.confirm(`Delete '${cond.name}'?`))) {
return
}
const index = items.value.findIndex(t => t?.id === cond.id)
if (-1 === index) {
toast.error('Item not found.')
return
}
items.value.splice(index, 1)
const status = await updateItems(items.value)
if (status) {
toast.success('Item deleted.')
}
await conditions.deleteCondition(cond.id!)
}
const updateItem = async ({ reference, item: updatedItem, }: {
reference: string | null | undefined,
item: ConditionItem
const updateItem = async ({ reference, item: updatedItem }: {
reference: number | null | undefined,
item: Condition
}): Promise<void> => {
updatedItem = cleanObject(updatedItem, remove_keys) as ConditionItem
updatedItem = cleanObject(updatedItem, remove_keys) as Condition
const cb = (resp: APIResponse) => {
if (resp.success){
resetForm(true)
}
}
if (reference) {
const index = items.value.findIndex(t => t?.id === reference)
if (index !== -1) {
items.value[index] = updatedItem
}
await conditions.patchCondition(reference, updatedItem, cb)
} else {
items.value.push(updatedItem)
await conditions.createCondition(updatedItem, cb)
}
const status = await updateItems(items.value)
if (!status) {
return
}
toast.success(`Item ${reference ? 'updated' : 'added'}.`)
resetForm(true)
}
const editItem = (_item: ConditionItem): void => {
const editItem = (_item: Condition): void => {
item.value = { ..._item }
itemRef.value = _item.id
toggleForm.value = true
}
const toggleEnabled = async (cond: ConditionItem): Promise<void> => {
const index = items.value.findIndex(t => t?.id === cond.id)
if (-1 === index) {
toast.error('Item not found.')
return
}
const item = items.value[index]
if (!item) {
toast.error('Item not found.')
return
}
item.enabled = !item.enabled
const status = await updateItems(items.value)
if (status) {
toast[item.enabled ? 'success' : 'warning'](`Condition is ${item.enabled ? 'enabled' : 'disabled'}.`)
}
const toggleEnabled = async (cond: Condition): Promise<void> => {
const new_state = !cond.enabled
await conditions.patchCondition(cond.id!, { enabled: new_state })
}
const exportItem = (cond: ConditionItem): void => {
const clone: Partial<ImportedConditionItem> = JSON.parse(JSON.stringify(cond))
delete clone.id
const userData: ImportedConditionItem = {
...Object.fromEntries(Object.entries(clone).filter(([_, v]) => !!v)),
_type: 'condition',
_version: '1.1',
} as ImportedConditionItem
copyText(encode(userData))
}
const exportItem = (cond: Condition): void => copyText(encode({
...Object.fromEntries(Object.entries(cond).filter(([k, v]) => !!v && !['id', ...remove_keys].includes(k))),
_type: 'condition',
_version: '1.2',
}))
onMounted(async () => {
if (socket.isConnected) {
await reloadContent(true)
await loadContent(page.value)
}
})
</script>

View file

@ -1,15 +1,31 @@
export type ConditionItem = {
id?: string
export interface Condition {
id?: number
name: string
filter: string
cli: string
extras: Record<string, any>
extras: Record<string, unknown>
enabled: boolean
priority: number
description: string
}
export type ImportedConditionItem = ConditionItem & {
_type: string
_version: string
export interface Pagination {
page: number
per_page: number
total: number
total_pages: number
has_next: boolean
has_prev: boolean
}
export interface ConditionTestRequest {
url: string
condition: string
preset?: string
}
export interface ConditionTestResponse {
status: boolean
condition: string
data: Record<string, unknown>
}

6
ui/app/types/index.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
type ImportedItem = {
_type: string,
_version: string
}
export { ImportedItem }

View file

@ -13,6 +13,26 @@ export type convert_args_response = {
/** The download format if was provided */
format?: string,
/** The removed options from the original CLI args if any. */
removed_options?: string[],
removed_options?: Array<string>,
}
export type Pagination = {
page: number
per_page: number
total: number
total_pages: number
has_next: boolean
has_prev: boolean
}
export type Paginated<T> = {
items: Array<T>
pagination: Pagination
}
export interface APIResponse<T = unknown> {
success: boolean
error: string | null
detail: unknown
data?: T
}

View file

@ -1,5 +1,5 @@
import { useStorage } from '@vueuse/core'
import type { convert_args_response } from '~/types/responses'
import type { convert_args_response,Paginated } from '~/types/responses'
import type { StoreItem } from '~/types/store'
const AG_SEPARATOR = '.'
@ -815,10 +815,59 @@ const getImage = (basePath: string, item: StoreItem, fallback: boolean = true):
return fallback ? uri('/images/placeholder.png') : ''
}
const parse_list_response = async <T>(json: unknown): Promise<Paginated<T>> => {
if ('function' === typeof (json as any).then) {
json = await (json as Promise<unknown>)
}
if (!json || 'object' !== typeof json) {
return { items: [], pagination: { page: 1, per_page: 20, total: 0, total_pages: 0, has_next: false, has_prev: false } }
}
const payload = json as Paginated<T>
const items = Array.isArray(payload.items) ? payload.items : []
const pagination = {
page: Number(payload.pagination?.page ?? 1),
per_page: Number(payload.pagination?.per_page ?? 20),
total: Number(payload.pagination?.total ?? 0),
total_pages: Number(payload.pagination?.total_pages ?? 0),
has_next: Boolean(payload.pagination?.has_next ?? false),
has_prev: Boolean(payload.pagination?.has_prev ?? false),
}
return { items: items as T[], pagination }
}
const parse_api_response = async <T>(json: unknown): Promise<T> => {
if ('function' === typeof (json as any).then) {
json = await (json as Promise<unknown>)
}
return json as T
}
const parse_api_error = async (json: unknown): Promise<string> => {
if ('function' === typeof (json as any).then) {
json = await (json as Promise<unknown>)
}
if (!json || 'object' !== typeof json) {
return 'Unknown error occurred'
}
const payload = json as {
error?: string;
message?: string;
detail?: string[];
}
return String(payload.error ?? payload.message ?? payload.detail?.[0] ?? 'Unknown error occurred')
}
export {
separators, convertCliOptions, getSeparatorsName, iTrim, eTrim, sTrim, ucFirst,
getValue, ag, ag_set, awaitElement, r, copyText, dEvent, makePagination, encodePath,
request, removeANSIColors, dec2hex, makeId, basename, dirname, getQueryParams,
makeDownload, formatBytes, has_data, toggleClass, cleanObject, uri, formatTime,
sleep, awaiter, encode, decode, disableOpacity, enableOpacity, stripPath, shortPath, deepIncludes, getPath, getImage
sleep, awaiter, encode, decode, disableOpacity, enableOpacity, stripPath, shortPath, deepIncludes, getPath, getImage,
parse_list_response, parse_api_response, parse_api_error
}

View file

@ -0,0 +1,553 @@
import * as utils from '~/utils/index'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useConditions } from '~/composables/useConditions'
import type { Condition, Pagination } from '~/types/conditions'
vi.mock('~/composables/useNotification', () => {
const success = vi.fn()
const error = vi.fn()
return {
useNotification: () => ({ success, error }),
default: () => ({ success, error }),
}
})
// Sample data
const mockCondition: Condition = {
id: 1,
name: 'Test Condition',
filter: 'duration > 60',
cli: '--format best',
extras: { test: true },
enabled: true,
priority: 10,
description: 'Test description',
}
const mockPagination: Pagination = {
page: 1,
per_page: 50,
total: 1,
total_pages: 1,
has_next: false,
has_prev: false,
}
// Helper to create a mock Response object
function createMockResponse({ ok, status, jsonData }: { ok: boolean; status: number; jsonData: any }) {
return {
ok,
status,
headers: new Headers(),
redirected: false,
statusText: '',
type: 'basic',
url: '',
body: null,
bodyUsed: false,
clone() {
return this
},
async json() {
return jsonData
},
text: async () => JSON.stringify(jsonData),
arrayBuffer: async () => new ArrayBuffer(0),
blob: async () => new Blob(),
formData: async () => new FormData(),
} as Response
}
describe('useConditions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('loadConditions', () => {
it('loads conditions with pagination successfully', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: {
items: [mockCondition],
pagination: mockPagination,
},
}),
)
const conditions = useConditions()
await conditions.loadConditions()
expect(conditions.conditions.value).toHaveLength(1)
expect(conditions.conditions.value[0]).toEqual(mockCondition)
expect(conditions.pagination.value).toEqual(mockPagination)
expect(conditions.lastError.value).toBeNull()
})
it('sorts conditions by priority then name', async () => {
const items = [
{ ...mockCondition, id: 1, name: 'B', priority: 2 },
{ ...mockCondition, id: 2, name: 'A', priority: 2 },
{ ...mockCondition, id: 3, name: 'C', priority: 1 },
]
vi.spyOn(utils, 'request').mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: {
items,
pagination: mockPagination,
},
}),
)
const conditions = useConditions()
await conditions.loadConditions()
const sorted = conditions.conditions.value
// Priority descending (2 before 1), then name ascending (A before B)
expect(sorted[0].priority).toBe(2)
expect(sorted[0].name).toBe('A')
expect(sorted[1].priority).toBe(2)
expect(sorted[1].name).toBe('B')
expect(sorted[2].priority).toBe(1)
expect(sorted[2].name).toBe('C')
})
it('handles empty conditions list', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: {
items: [],
pagination: { ...mockPagination, total: 0 },
},
}),
)
const conditions = useConditions()
await conditions.loadConditions()
expect(conditions.conditions.value).toEqual([])
expect(conditions.lastError.value).toBeNull()
})
it('handles errors during load', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 500,
jsonData: { error: 'Server error' },
}),
)
const conditions = useConditions()
await conditions.loadConditions()
expect(conditions.lastError.value).toBeTruthy()
})
})
describe('getCondition', () => {
it('fetches a single condition successfully', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: mockCondition,
}),
)
const conditions = useConditions()
const result = await conditions.getCondition(1)
expect(result).toEqual(mockCondition)
expect(conditions.lastError.value).toBeNull()
})
it('handles 404 not found', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 404,
jsonData: { error: 'Condition not found' },
}),
)
const conditions = useConditions()
const result = await conditions.getCondition(999)
expect(result).toBeNull()
expect(conditions.lastError.value).toBeTruthy()
})
})
describe('createCondition', () => {
it('creates a condition successfully', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: mockCondition,
}),
)
const conditions = useConditions()
const initialTotal = conditions.pagination.value.total
const newCondition = { ...mockCondition }
delete newCondition.id
const result = await conditions.createCondition(newCondition)
expect(result).toEqual(mockCondition)
expect(conditions.conditions.value).toContainEqual(mockCondition)
expect(conditions.pagination.value.total).toBe(initialTotal + 1)
})
it('calls callback on success', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: mockCondition,
}),
)
const callback = vi.fn()
const conditions = useConditions()
const newCondition = { ...mockCondition }
delete newCondition.id
await conditions.createCondition(newCondition, callback)
expect(callback).toHaveBeenCalledWith({
success: true,
error: null,
detail: null,
data: mockCondition,
})
})
it('calls callback on error', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 400,
jsonData: { detail: [{ loc: ['name'], msg: 'Field required', type: 'value_error' }] },
}),
)
const callback = vi.fn()
const conditions = useConditions()
const newCondition = { ...mockCondition }
delete newCondition.id
await conditions.createCondition(newCondition, callback)
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: expect.any(String),
}),
)
})
})
describe('updateCondition', () => {
it('updates a condition successfully', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: { ...mockCondition, name: 'Updated Name' },
}),
)
const conditions = useConditions()
const updated = { ...mockCondition, name: 'Updated Name' }
const result = await conditions.updateCondition(1, updated)
expect(result?.name).toBe('Updated Name')
})
it('calls callback on success', async () => {
const updatedCondition = { ...mockCondition, name: 'Updated' }
vi.spyOn(utils, 'request').mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: updatedCondition,
}),
)
const callback = vi.fn()
const conditions = useConditions()
await conditions.updateCondition(1, updatedCondition, callback)
expect(callback).toHaveBeenCalledWith({
success: true,
error: null,
detail: null,
data: updatedCondition,
})
})
it('removes id field from condition before sending', async () => {
const requestSpy = vi.spyOn(utils, 'request').mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: mockCondition,
}),
)
const conditions = useConditions()
await conditions.updateCondition(1, mockCondition)
const requestBody = JSON.parse((requestSpy.mock.calls[0][1] as any).body)
expect(requestBody.id).toBeUndefined()
})
})
describe('patchCondition', () => {
it('patches a condition successfully', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: { ...mockCondition, enabled: false },
}),
)
const conditions = useConditions()
const result = await conditions.patchCondition(1, { enabled: false })
expect(result?.enabled).toBe(false)
})
it('calls callback on patch success', async () => {
const patchedCondition = { ...mockCondition, enabled: false }
vi.spyOn(utils, 'request').mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: patchedCondition,
}),
)
const callback = vi.fn()
const conditions = useConditions()
await conditions.patchCondition(1, { enabled: false }, callback)
expect(callback).toHaveBeenCalledWith({
success: true,
error: null,
detail: null,
data: patchedCondition,
})
})
it('handles validation errors with callback', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 400,
jsonData: { detail: [{ loc: ['priority'], msg: 'Invalid priority', type: 'value_error' }] },
}),
)
const callback = vi.fn()
const conditions = useConditions()
await conditions.patchCondition(1, { priority: -1 }, callback)
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: expect.any(String),
}),
)
})
})
describe('deleteCondition', () => {
it('deletes a condition successfully', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: mockCondition,
}),
)
const conditions = useConditions()
const initialTotal = conditions.pagination.value.total
const result = await conditions.deleteCondition(mockCondition.id!)
expect(result).toBe(true)
expect(conditions.pagination.value.total).toBe(Math.max(0, initialTotal - 1))
})
it('calls callback on delete success', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: mockCondition,
}),
)
const callback = vi.fn()
const conditions = useConditions()
await conditions.deleteCondition(1, callback)
expect(callback).toHaveBeenCalledWith({
success: true,
error: null,
detail: null,
data: true,
})
})
it('calls callback on delete error', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 404,
jsonData: { error: 'Condition not found' },
}),
)
const callback = vi.fn()
const conditions = useConditions()
await conditions.deleteCondition(999, callback)
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: expect.any(String),
data: false,
}),
)
})
})
describe('testCondition', () => {
it('tests a condition successfully', async () => {
const testResponse = {
status: true,
condition: 'duration > 60',
data: { duration: 120 },
}
vi.spyOn(utils, 'request').mockResolvedValueOnce(
createMockResponse({
ok: true,
status: 200,
jsonData: testResponse,
}),
)
const conditions = useConditions()
const result = await conditions.testCondition({
url: 'https://example.com/video',
condition: 'duration > 60',
})
expect(result).toEqual(testResponse)
expect(result?.status).toBe(true)
})
it('handles test errors', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 400,
jsonData: { error: 'Invalid URL' },
}),
)
const conditions = useConditions()
const result = await conditions.testCondition({
url: 'invalid',
condition: 'test',
})
expect(result).toBeNull()
expect(conditions.lastError.value).toBeTruthy()
})
})
describe('error handling', () => {
it('throws when throwInstead is true', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 500,
jsonData: { error: 'Server error' },
}),
)
const conditions = useConditions()
conditions.throwInstead.value = true
await expect(conditions.loadConditions()).rejects.toThrow()
})
it('clears error on clearError call', async () => {
vi.spyOn(utils, 'request').mockResolvedValueOnce(
createMockResponse({
ok: false,
status: 500,
jsonData: { error: 'Server error' },
}),
)
const conditions = useConditions()
conditions.throwInstead.value = false
// Don't throw, just capture the error
await conditions.loadConditions()
expect(conditions.lastError.value).toBeTruthy()
conditions.clearError()
expect(conditions.lastError.value).toBeNull()
})
})
describe('addInProgress state', () => {
it('sets addInProgress during create operation', async () => {
let inProgressDuringCall = false
vi.spyOn(utils, 'request').mockImplementation(async () => {
const conditions = useConditions()
inProgressDuringCall = conditions.addInProgress.value
return createMockResponse({
ok: true,
status: 200,
jsonData: mockCondition,
})
})
const conditions = useConditions()
const newCondition = { ...mockCondition }
delete newCondition.id
await conditions.createCondition(newCondition)
expect(inProgressDuringCall).toBe(true)
expect(conditions.addInProgress.value).toBe(false)
})
})
})

View file

@ -1,4 +1,4 @@
import * as utils from '../../app/utils/index'
import * as utils from '~/utils/index'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { useTaskDefinitions } from '~/composables/useTaskDefinitions'
import type { TaskDefinitionSummary } from '~/types/task_definitions'

View file

@ -50,7 +50,7 @@ const originalFetch = globalThis.fetch
const originalClipboard = globalThis.navigator?.clipboard
const originalCrypto = globalThis.crypto
let utils: Awaited<typeof import('../../app/utils/index')>
let utils: Awaited<typeof import('~/utils/index')>
let fetchSpy: MockInstance | undefined
const resetStorage = () => {
@ -141,7 +141,7 @@ globalThis.atob = globalThis.atob ?? ((b64: string) => Buffer.from(b64, 'base64'
beforeAll(async () => {
// Import utils after all mocks are set up
utils = await import('../../app/utils/index')
utils = await import('~/utils/index')
})
beforeEach(() => {

View file

@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { MatchFilterParser } from '../../app/utils/ytdlp'
import { MatchFilterParser } from '~/utils/ytdlp'
function normalize(filters: string[]): Set<string> {
return new Set(filters.map(f => f.split("&").map(x => x.trim()).sort().join("&")));