refactor: conditions feature to db model
This commit is contained in:
parent
f98acbaa42
commit
a09343938d
33 changed files with 2575 additions and 1672 deletions
1
app/features/conditions/__init__.py
Normal file
1
app/features/conditions/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Conditions Feature"""
|
||||
5
app/features/conditions/deps.py
Normal file
5
app/features/conditions/deps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from app.features.conditions.repository import ConditionsRepository
|
||||
|
||||
|
||||
def get_conditions_repo() -> ConditionsRepository:
|
||||
return ConditionsRepository.get_instance()
|
||||
97
app/features/conditions/migration.py
Normal file
97
app/features/conditions/migration.py
Normal 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,
|
||||
)
|
||||
28
app/features/conditions/models.py
Normal file
28
app/features/conditions/models.py
Normal 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)
|
||||
178
app/features/conditions/repository.py
Normal file
178
app/features/conditions/repository.py
Normal 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
|
||||
329
app/features/conditions/router.py
Normal file
329
app/features/conditions/router.py
Normal 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,
|
||||
)
|
||||
93
app/features/conditions/schemas.py
Normal file
93
app/features/conditions/schemas.py
Normal 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
|
||||
204
app/features/conditions/service.py
Normal file
204
app/features/conditions/service.py
Normal 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
|
||||
230
app/features/conditions/tests/test_repository.py
Normal file
230
app/features/conditions/tests/test_repository.py
Normal 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
12
app/features/core/deps.py
Normal 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
|
||||
50
app/features/core/migration.py
Normal file
50
app/features/core/migration.py
Normal 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
|
||||
39
app/features/core/models.py
Normal file
39
app/features/core/models.py
Normal 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
|
||||
12
app/features/core/schemas.py
Normal file
12
app/features/core/schemas.py
Normal 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
|
||||
77
app/features/core/utils.py
Normal file
77
app/features/core/utils.py
Normal 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()
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
@ -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}'."
|
||||
|
|
|
|||
|
|
@ -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__
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
39
app/migrations/20260117184822_add_conditions_table.py
Normal file
39
app/migrations/20260117184822_add_conditions_table.py
Normal 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))
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -163,6 +163,7 @@ ignore = [
|
|||
"PT011",
|
||||
"RUF001",
|
||||
"S311",
|
||||
"TC001"
|
||||
]
|
||||
|
||||
fixable = ["ALL"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
437
ui/app/composables/useConditions.ts
Normal file
437
ui/app/composables/useConditions.ts
Normal 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,
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
28
ui/app/types/conditions.d.ts
vendored
28
ui/app/types/conditions.d.ts
vendored
|
|
@ -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
6
ui/app/types/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
type ImportedItem = {
|
||||
_type: string,
|
||||
_version: string
|
||||
}
|
||||
|
||||
export { ImportedItem }
|
||||
22
ui/app/types/responses.d.ts
vendored
22
ui/app/types/responses.d.ts
vendored
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
553
ui/tests/composables/useConditions.test.ts
Normal file
553
ui/tests/composables/useConditions.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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("&")));
|
||||
|
|
|
|||
Loading…
Reference in a new issue