Merge pull request #549 from arabcoders/dev
Refactor: migrate from file-based config to db model
This commit is contained in:
commit
412ad9565d
202 changed files with 20052 additions and 14223 deletions
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
|
|
@ -137,6 +137,7 @@
|
|||
"noprogress",
|
||||
"onefile",
|
||||
"oneline",
|
||||
"onupdate",
|
||||
"parsel",
|
||||
"pathex",
|
||||
"pickleable",
|
||||
|
|
@ -164,11 +165,13 @@
|
|||
"RPAREN",
|
||||
"rtime",
|
||||
"rvfc",
|
||||
"sessionmaker",
|
||||
"SIGUSR",
|
||||
"smhd",
|
||||
"socketio",
|
||||
"softprops",
|
||||
"solverr",
|
||||
"SQLA",
|
||||
"sstr",
|
||||
"startswith",
|
||||
"SUPPRESSHELP",
|
||||
|
|
@ -199,6 +202,7 @@
|
|||
"vconvert",
|
||||
"Vitest",
|
||||
"writedescription",
|
||||
"WSEP",
|
||||
"xerror",
|
||||
"youtu",
|
||||
"youtubepot",
|
||||
|
|
|
|||
26
FAQ.md
26
FAQ.md
|
|
@ -171,25 +171,25 @@ Then restart the container to apply the changes.
|
|||
|
||||
# How can I monitor sites without RSS feeds?
|
||||
|
||||
YTPTube includes a **generic task handler** that turns JSON definition files into site-specific scrapers. You can use it
|
||||
YTPTube includes a **generic task handler** that turns JSON definitions into site-specific scrapers. You can use it
|
||||
to watch pages that do not expose RSS or public APIs and automatically enqueue new links into the download queue.
|
||||
|
||||
1. Create definition files under `/config/tasks/*.json` (for Docker this is the mounted `config/tasks/` folder).
|
||||
2. Keep your scheduled task in `tasks.json` pointing at the page you want to monitor and make sure it uses a preset that
|
||||
enables a download archive (`--download-archive`).
|
||||
3. When the task runs, the handler scans the JSON files, picks the first definition whose `match` rule covers the task
|
||||
URL, fetches the page, extracts items, and queues the unseen ones.
|
||||
1. Create definition via the WebUI > tasks > Definitions.
|
||||
2. Create task that reference same url click on inspect to see the results. Make sure it uses a preset that enables
|
||||
a download archive (`--download-archive`).
|
||||
3. When the task handler run, the handler scans the definitions, picks the first definition whose `match` rule covers
|
||||
the task URL, fetches the page, extracts items, and queues the unseen ones.
|
||||
|
||||
### Definition schema
|
||||
|
||||
Each file must contain a single JSON object with the following keys:
|
||||
Each definition must contain a single JSON object with the following keys:
|
||||
|
||||
```json5
|
||||
{
|
||||
"name": "example", // Friendly identifier shown in logs
|
||||
"match": [
|
||||
"https://example.com/articles/*", // Glob strings, or objects with {"regex": "..."} or {"glob": "..."}
|
||||
{ "regex": "https://example.com/post/[0-9]+" }
|
||||
"match_url": [
|
||||
"https://example.com/articles/*", // Glob strings
|
||||
"https://example.com/post/[0-9]+" // Regex strings
|
||||
],
|
||||
"engine": { // Optional, defaults to HTTPX
|
||||
"type": "httpx", // "httpx" (default) or "selenium"
|
||||
|
|
@ -207,7 +207,7 @@ Each file must contain a single JSON object with the following keys:
|
|||
"headers": { "User-Agent": "MyAgent/1.0" },
|
||||
"params": { "page": 1 },
|
||||
"data": null,
|
||||
"json": null,
|
||||
"json_data": null,
|
||||
"timeout": 30
|
||||
},
|
||||
"response": { // Optional: how to interpret the body
|
||||
|
|
@ -247,6 +247,7 @@ For JSON endpoints, switch the response format and use `jsonpath` selectors:
|
|||
|
||||
```json5
|
||||
{
|
||||
...
|
||||
"response": { "type": "json" },
|
||||
"parse": {
|
||||
"items": {
|
||||
|
|
@ -282,9 +283,6 @@ For JSON endpoints, switch the response format and use `jsonpath` selectors:
|
|||
the moment. Optional keys: `arguments` (list or string), `wait_for` (type `css`/`xpath` + `expression`), `wait_timeout`,
|
||||
and `page_load_timeout`.
|
||||
|
||||
Definitions are reloaded automatically when files change, so you can tweak them without restarting YTPTube. Check
|
||||
`var/config/tasks/01-*.json` for sample files.
|
||||
|
||||
> [!NOTE]
|
||||
> A machine-readable schema is available at `app/schema/task_definition.json` if you want to validate your JSON with editors or CI tools.
|
||||
|
||||
|
|
|
|||
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()
|
||||
110
app/features/conditions/migration.py
Normal file
110
app/features/conditions/migration.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
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
|
||||
seen_names: dict[str, int] = {}
|
||||
for index, item in enumerate(items):
|
||||
if not (normalized := await self._normalize(item, index, seen_names)):
|
||||
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, seen_names: dict[str, int]) -> ConditionModel | None:
|
||||
if not isinstance(item, dict):
|
||||
LOG.warning("Skipping condition at index %s due to invalid type.", index)
|
||||
return None
|
||||
|
||||
assert isinstance(item, dict)
|
||||
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
|
||||
|
||||
normalized_name = name.strip()
|
||||
if not normalized_name:
|
||||
LOG.warning("Skipping condition at index %s due to empty name.", index)
|
||||
return None
|
||||
|
||||
name = self._unique_name(normalized_name, seen_names)
|
||||
|
||||
extras = item.get("extras") if isinstance(item.get("extras"), dict) else {}
|
||||
extras = cast("dict[str, Any]", extras)
|
||||
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_value = item.get("enabled")
|
||||
enabled: bool = enabled_value if isinstance(enabled_value, bool) else True
|
||||
priority_value = item.get("priority")
|
||||
priority: int = priority_value if isinstance(priority_value, int) else 0
|
||||
if isinstance(priority, bool) or 0 > priority:
|
||||
priority = 0
|
||||
|
||||
description_value = item.get("description")
|
||||
description: str = description_value if isinstance(description_value, 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, unique=True)
|
||||
filter: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
cli: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
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)
|
||||
175
app/features/conditions/repository.py
Normal file
175
app/features/conditions/repository.py
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
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(__name__)
|
||||
|
||||
|
||||
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))
|
||||
if not (model := result.scalar_one_or_none()):
|
||||
msg: str = f"Condition '{identifier}' not found."
|
||||
raise KeyError(msg)
|
||||
|
||||
payload.pop("id", None)
|
||||
payload.pop("created_at", None)
|
||||
payload.pop("updated_at", 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:
|
||||
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))
|
||||
if not (model := result.scalar_one_or_none()):
|
||||
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
|
||||
328
app/features/conditions/router.py
Normal file
328
app/features/conditions/router.py
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
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 CEAction, CEFeature, ConfigEvent, 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.Events import EventBus, Events
|
||||
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, notify: EventBus) -> 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:
|
||||
saved = _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)
|
||||
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.CONDITIONS, action=CEAction.CREATE, data=saved)
|
||||
)
|
||||
return web.json_response(data=saved, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("GET", r"api/conditions/{id:\d+}", 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", r"api/conditions/{id:\d+}", name="condition_delete")
|
||||
async def conditions_delete(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
"""
|
||||
Delete Condition.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
encoder (Encoder): The encoder instance.
|
||||
notify (EventBus): The event bus 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:
|
||||
deleted = _serialize(await Conditions.get_instance()._repo.delete(id))
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.CONDITIONS, action=CEAction.DELETE, data=deleted)
|
||||
)
|
||||
return web.json_response(data=deleted, 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", r"api/conditions/{id:\d+}", name="condition_patch")
|
||||
async def conditions_patch(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
"""
|
||||
Patch Condition.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
encoder (Encoder): The encoder instance.
|
||||
notify (EventBus): The event bus 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,
|
||||
)
|
||||
|
||||
updated = _serialize(await service._repo.update(model.id, validated.model_dump(exclude_unset=True)))
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.CONDITIONS, action=CEAction.UPDATE, data=updated)
|
||||
)
|
||||
return web.json_response(data=updated, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("PUT", r"api/conditions/{id:\d+}", name="condition_update")
|
||||
async def conditions_update(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
"""
|
||||
Update Condition.
|
||||
|
||||
Args:
|
||||
request (Request): The request object.
|
||||
encoder (Encoder): The encoder instance.
|
||||
notify (EventBus): The event bus 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,
|
||||
)
|
||||
|
||||
updated = _serialize(await service._repo.update(model.id, validated.model_dump(exclude_unset=True)))
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.CONDITIONS, action=CEAction.UPDATE, data=updated)
|
||||
)
|
||||
return web.json_response(data=updated, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
92
app/features/conditions/schemas.py
Normal file
92
app/features/conditions/schemas.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
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_cli_or_extras(self) -> Condition:
|
||||
if not self.cli and not self.extras:
|
||||
msg: str = "Either cli 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_cli_or_extras(self) -> ConditionPatch:
|
||||
return self
|
||||
|
||||
|
||||
class ConditionList(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
items: list[Condition] = Field(default_factory=list)
|
||||
pagination: Pagination
|
||||
147
app/features/conditions/service.py
Normal file
147
app/features/conditions/service.py
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
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
|
||||
|
||||
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()
|
||||
|
||||
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
|
||||
226
app/features/conditions/tests/test_conditions_repository.py
Normal file
226
app/features/conditions/tests/test_conditions_repository.py
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
"""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():
|
||||
ConditionsRepository._reset_singleton()
|
||||
SqliteStore._reset_singleton()
|
||||
|
||||
store = SqliteStore(db_path=":memory:")
|
||||
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
|
||||
66
app/features/core/migration.py
Normal file
66
app/features/core/migration.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import logging
|
||||
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}"
|
||||
|
||||
source.rename(destination)
|
||||
return destination
|
||||
|
||||
def _unique_name(self, name: str, seen_names: dict[str, int]) -> str:
|
||||
base = name
|
||||
count = seen_names.get(base, 0)
|
||||
if count == 0:
|
||||
seen_names[base] = 1
|
||||
return base
|
||||
|
||||
suffix = count + 1
|
||||
new_name = f"{base} ({suffix})"
|
||||
while new_name in seen_names:
|
||||
suffix += 1
|
||||
new_name = f"{base} ({suffix})"
|
||||
|
||||
seen_names[base] = suffix
|
||||
seen_names[new_name] = 1
|
||||
return new_name
|
||||
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
|
||||
44
app/features/core/schemas.py
Normal file
44
app/features/core/schemas.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Pagination(BaseModel):
|
||||
page: int
|
||||
per_page: int
|
||||
total: int
|
||||
total_pages: int
|
||||
has_next: bool
|
||||
has_prev: bool
|
||||
|
||||
|
||||
class CEFeature(str, Enum):
|
||||
PRESETS = "presets"
|
||||
DL_FIELDS = "dl_fields"
|
||||
CONDITIONS = "conditions"
|
||||
NOTIFICATIONS = "notifications"
|
||||
TASKS = "tasks"
|
||||
TASKS_DEFINITIONS = "tasks_definitions"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class CEAction(str, Enum):
|
||||
CREATE = "create"
|
||||
UPDATE = "update"
|
||||
DELETE = "delete"
|
||||
REPLACE = "replace"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class ConfigEvent(BaseModel):
|
||||
feature: CEFeature
|
||||
action: CEAction
|
||||
data: dict[str, Any] | list[dict[str, Any]]
|
||||
extras: dict[str, Any] = Field(default_factory=dict)
|
||||
83
app/features/core/utils.py
Normal file
83
app/features/core/utils.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
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()
|
||||
]
|
||||
|
||||
|
||||
def gen_random(length: int = 16) -> str:
|
||||
import secrets
|
||||
|
||||
return "".join(secrets.token_urlsafe(length)[:length])
|
||||
1
app/features/dl_fields/__init__.py
Normal file
1
app/features/dl_fields/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""DL Fields Feature"""
|
||||
5
app/features/dl_fields/deps.py
Normal file
5
app/features/dl_fields/deps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from app.features.dl_fields.repository import DLFieldsRepository
|
||||
|
||||
|
||||
def get_dl_fields_repo() -> DLFieldsRepository:
|
||||
return DLFieldsRepository.get_instance()
|
||||
113
app/features/dl_fields/migration.py
Normal file
113
app/features/dl_fields/migration.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from app.features.core.migration import Migration as FeatureMigration
|
||||
from app.features.dl_fields.schemas import DLField
|
||||
from app.library.config import Config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.features.dl_fields.repository import DLFieldsRepository
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Migration(FeatureMigration):
|
||||
name: str = "dl_fields"
|
||||
|
||||
def __init__(self, repo: DLFieldsRepository, config: Config | None = None):
|
||||
self._config: Config = config or Config.get_instance()
|
||||
super().__init__(config=self._config)
|
||||
self._repo: DLFieldsRepository = repo
|
||||
self._source_file: Path = Path(self._config.config_path) / "dl_fields.json"
|
||||
|
||||
async def should_run(self) -> bool:
|
||||
return self._source_file.exists()
|
||||
|
||||
async def migrate(self) -> None:
|
||||
if await self._repo.count() > 0:
|
||||
LOG.warning("DL fields already exist in the database; skipping migration.")
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
try:
|
||||
items: list[dict[str, Any]] | None = json.loads(self._source_file.read_text())
|
||||
except Exception as exc:
|
||||
LOG.exception("Failed to read %s: %s. Ignoring", self._source_file, exc)
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
if items is None:
|
||||
LOG.warning("No dl fields found in %s; skipping migration.", self._source_file)
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
inserted = 0
|
||||
seen_names: dict[str, int] = {}
|
||||
for index, item in enumerate(items):
|
||||
if not (normalized := self._normalize(item, index, seen_names)):
|
||||
continue
|
||||
try:
|
||||
await self._repo.create(normalized)
|
||||
inserted += 1
|
||||
except Exception as exc:
|
||||
LOG.exception("Failed to insert dl field '%s': %s", normalized["name"], exc)
|
||||
|
||||
LOG.info("Migrated %s dl field(s) from %s.", inserted, self._source_file)
|
||||
await self._move_file(self._source_file)
|
||||
|
||||
def _normalize(self, item: Any, index: int, seen_names: dict[str, int]) -> dict[str, Any] | None:
|
||||
if not isinstance(item, dict):
|
||||
LOG.warning("Skipping dl field at index %s due to invalid type.", index)
|
||||
return None
|
||||
|
||||
assert isinstance(item, dict)
|
||||
|
||||
name: str | None = item.get("name")
|
||||
if not name or not isinstance(name, str):
|
||||
LOG.warning("Skipping dl field at index %s due to missing name.", index)
|
||||
return None
|
||||
|
||||
normalized_name = name.strip()
|
||||
if not normalized_name:
|
||||
LOG.warning("Skipping dl field at index %s due to empty name.", index)
|
||||
return None
|
||||
|
||||
name = self._unique_name(normalized_name, seen_names)
|
||||
description: str = item.get("description") if isinstance(item.get("description"), str) else ""
|
||||
field_value: str | None = item.get("field")
|
||||
if not field_value or not isinstance(field_value, str):
|
||||
LOG.warning("Skipping dl field '%s' due to missing field value.", name)
|
||||
return None
|
||||
|
||||
kind: str = item.get("kind") if isinstance(item.get("kind"), str) else "text"
|
||||
icon: str = item.get("icon") if isinstance(item.get("icon"), str) else ""
|
||||
value: str = item.get("value") if isinstance(item.get("value"), str) else ""
|
||||
extras = item.get("extras") if isinstance(item.get("extras"), dict) else {}
|
||||
extras = cast("dict[str, Any]", extras)
|
||||
|
||||
order_value: int = 0
|
||||
if isinstance(item.get("order"), int) and not isinstance(item.get("order"), bool):
|
||||
order_value = item.get("order", 0)
|
||||
|
||||
payload = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"field": field_value,
|
||||
"kind": kind,
|
||||
"icon": icon,
|
||||
"order": order_value,
|
||||
"value": value,
|
||||
"extras": extras,
|
||||
}
|
||||
|
||||
try:
|
||||
DLField.model_validate(payload)
|
||||
except Exception as exc:
|
||||
LOG.warning("Skipping dl field '%s' due to validation error: %s", name, exc)
|
||||
return None
|
||||
|
||||
return payload
|
||||
29
app/features/dl_fields/models.py
Normal file
29
app/features/dl_fields/models.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime # noqa: TC003
|
||||
|
||||
from sqlalchemy import JSON, Index, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.features.core.models import Base, UTCDateTime, utcnow
|
||||
|
||||
|
||||
class DLFieldModel(Base):
|
||||
__tablename__: str = "dl_fields"
|
||||
__table_args__: tuple[Index, ...] = (
|
||||
Index("ix_dl_fields_name", "name"),
|
||||
Index("ix_dl_fields_order", "order"),
|
||||
Index("ix_dl_fields_kind", "kind"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
field: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(50), nullable=False, default="text")
|
||||
icon: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
value: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
extras: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, onupdate=utcnow, nullable=False)
|
||||
168
app/features/dl_fields/repository.py
Normal file
168
app/features/dl_fields/repository.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import delete, func, or_, select
|
||||
|
||||
from app.features.core.deps import get_session
|
||||
from app.features.dl_fields.migration import Migration
|
||||
from app.features.dl_fields.models import DLFieldModel
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncGenerator, Iterable
|
||||
|
||||
from sqlalchemy.engine.result import Result
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
from sqlalchemy.sql.selectable import Select
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DLFieldsRepository(metaclass=Singleton):
|
||||
def __init__(self, session: AsyncGenerator[AsyncSession] | None = None) -> None:
|
||||
self._migrated = False
|
||||
self.session: AsyncGenerator[AsyncSession] = session or get_session
|
||||
|
||||
async def run_migrations(self) -> None:
|
||||
if self._migrated:
|
||||
return
|
||||
|
||||
self._migrated = True
|
||||
await Migration(repo=self).run()
|
||||
|
||||
@staticmethod
|
||||
def get_instance() -> DLFieldsRepository:
|
||||
return DLFieldsRepository()
|
||||
|
||||
async def list(self) -> list[DLFieldModel]:
|
||||
async with self.session() as session:
|
||||
result: Result[tuple[DLFieldModel]] = await session.execute(
|
||||
select(DLFieldModel).order_by(DLFieldModel.order.asc(), DLFieldModel.name.asc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_paginated(self, page: int, per_page: int) -> tuple[list[DLFieldModel], int, int, int]:
|
||||
async with self.session() as session:
|
||||
total: int = await self.count()
|
||||
total_pages: int = (total + per_page - 1) // per_page if total > 0 else 1
|
||||
|
||||
if page > total_pages and total > 0:
|
||||
page = total_pages
|
||||
|
||||
query: Select[tuple[DLFieldModel]] = (
|
||||
select(DLFieldModel)
|
||||
.order_by(DLFieldModel.order.asc(), DLFieldModel.name.asc())
|
||||
.limit(per_page)
|
||||
.offset((page - 1) * per_page)
|
||||
)
|
||||
result: Result[tuple[DLFieldModel]] = await session.execute(query)
|
||||
return list(result.scalars().all()), total, page, total_pages
|
||||
|
||||
async def count(self) -> int:
|
||||
async with self.session() as session:
|
||||
result: Result[tuple[int]] = await session.execute(select(func.count()).select_from(DLFieldModel))
|
||||
return int(result.scalar_one())
|
||||
|
||||
async def get(self, identifier: int | str) -> DLFieldModel | None:
|
||||
async with self.session() as session:
|
||||
if not identifier:
|
||||
return None
|
||||
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = DLFieldModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause = or_(DLFieldModel.id == int(identifier), DLFieldModel.name == identifier)
|
||||
else:
|
||||
clause = DLFieldModel.name == identifier
|
||||
|
||||
result: Result[tuple[DLFieldModel]] = await session.execute(select(DLFieldModel).where(clause).limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_name(self, name: str, exclude_id: int | None = None) -> DLFieldModel | None:
|
||||
async with self.session() as session:
|
||||
query: Select[tuple[DLFieldModel]] = select(DLFieldModel).where(DLFieldModel.name == name)
|
||||
if exclude_id is not None:
|
||||
query = query.where(DLFieldModel.id != exclude_id)
|
||||
|
||||
result: Result[tuple[DLFieldModel]] = await session.execute(query.limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(self, payload: DLFieldModel | dict) -> DLFieldModel:
|
||||
async with self.session() as session:
|
||||
model: DLFieldModel = DLFieldModel(**payload) if isinstance(payload, dict) else payload
|
||||
|
||||
if await self.get_by_name(name=model.name) is not None:
|
||||
msg: str = f"DL field with name '{model.name}' already exists."
|
||||
raise ValueError(msg)
|
||||
|
||||
session.add(model)
|
||||
await session.commit()
|
||||
await session.refresh(model)
|
||||
return model
|
||||
|
||||
async def update(self, identifier: int | str, payload: dict[str, Any]) -> DLFieldModel:
|
||||
async with self.session() as session:
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = DLFieldModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause = or_(DLFieldModel.id == int(identifier), DLFieldModel.name == identifier)
|
||||
else:
|
||||
clause = DLFieldModel.name == identifier
|
||||
|
||||
result: Result[tuple[DLFieldModel]] = await session.execute(select(DLFieldModel).where(clause).limit(1))
|
||||
model: DLFieldModel | None = result.scalar_one_or_none()
|
||||
|
||||
if not model:
|
||||
msg: str = f"DL field '{identifier}' not found."
|
||||
raise KeyError(msg)
|
||||
|
||||
payload.pop("id", None)
|
||||
|
||||
if "name" in payload and await self.get_by_name(name=payload["name"], exclude_id=model.id) is not None:
|
||||
msg = f"DL field with name '{payload['name']}' already exists."
|
||||
raise ValueError(msg)
|
||||
|
||||
for key, value in payload.items():
|
||||
if hasattr(model, key):
|
||||
setattr(model, key, value)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(model)
|
||||
return model
|
||||
|
||||
async def delete(self, identifier: int | str) -> DLFieldModel:
|
||||
async with self.session() as session:
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = DLFieldModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause = or_(DLFieldModel.id == int(identifier), DLFieldModel.name == identifier)
|
||||
else:
|
||||
clause = DLFieldModel.name == identifier
|
||||
|
||||
result: Result[tuple[DLFieldModel]] = await session.execute(select(DLFieldModel).where(clause).limit(1))
|
||||
model = result.scalar_one_or_none()
|
||||
|
||||
if not model:
|
||||
msg: str = f"DL field '{identifier}' not found."
|
||||
raise KeyError(msg)
|
||||
|
||||
await session.delete(model)
|
||||
await session.commit()
|
||||
return model
|
||||
|
||||
async def replace_all(self, items: Iterable[dict | DLFieldModel]) -> list[DLFieldModel]:
|
||||
async with self.session() as session:
|
||||
try:
|
||||
await session.execute(delete(DLFieldModel))
|
||||
models: list[DLFieldModel] = [
|
||||
DLFieldModel(**item) if isinstance(item, dict) else item for item in items
|
||||
]
|
||||
session.add_all(models)
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
return models
|
||||
176
app/features/dl_fields/router.py
Normal file
176
app/features/dl_fields/router.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.features.core.schemas import CEAction, CEFeature, ConfigEvent, Pagination
|
||||
from app.features.core.utils import build_pagination, format_validation_errors, normalize_pagination
|
||||
from app.features.dl_fields.schemas import DLField, DLFieldList, DLFieldPatch
|
||||
from app.features.dl_fields.service import DLFields
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.router import route
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _model(model: Any) -> DLField:
|
||||
return DLField.model_validate(model)
|
||||
|
||||
|
||||
def _serialize(model: Any) -> dict:
|
||||
return _model(model).model_dump()
|
||||
|
||||
|
||||
@route("GET", "api/dl_fields/", "dl_fields")
|
||||
async def dl_fields_list(request: Request, encoder: Encoder) -> Response:
|
||||
repo = DLFields.get_instance()._repo
|
||||
page, per_page = normalize_pagination(request)
|
||||
items, total, current_page, total_pages = await repo.list_paginated(page, per_page)
|
||||
return web.json_response(
|
||||
data=DLFieldList(
|
||||
items=[_model(model) for model in items],
|
||||
pagination=Pagination.model_validate(build_pagination(total, current_page, per_page, total_pages)),
|
||||
),
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("POST", "api/dl_fields/", "dl_fields_add")
|
||||
async def dl_fields_add(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
data = await request.json()
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting dict."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
item: DLField = DLField.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate dl field.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
saved = _serialize(await DLFields.get_instance().save(item=item.model_dump()))
|
||||
except ValueError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
notify.emit(Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.DL_FIELDS, action=CEAction.CREATE, data=saved))
|
||||
|
||||
return web.json_response(data=saved, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("GET", r"api/dl_fields/{id:\d+}", "dl_fields_get")
|
||||
async def dl_fields_get(request: Request, encoder: Encoder) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not (model := await DLFields.get_instance().get(identifier)):
|
||||
return web.json_response({"error": "DL field not found"}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
return web.json_response(data=_serialize(model), status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("DELETE", r"api/dl_fields/{id:\d+}", "dl_fields_delete")
|
||||
async def dl_fields_delete(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
deleted = _serialize(await DLFields.get_instance()._repo.delete(identifier))
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.DL_FIELDS, action=CEAction.DELETE, data=deleted)
|
||||
)
|
||||
return web.json_response(
|
||||
data=deleted,
|
||||
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", r"api/dl_fields/{id:\d+}", "dl_fields_patch")
|
||||
async def dl_fields_patch(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not (model := await DLFields.get_instance().get(identifier)):
|
||||
return web.json_response({"error": "DL field not found"}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
data = await request.json()
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting dict."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
validated = DLFieldPatch.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate dl field.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if validated.name and await DLFields.get_instance()._repo.get_by_name(validated.name, exclude_id=model.id):
|
||||
return web.json_response(
|
||||
data={"error": f"DL field with name '{validated.name}' already exists."},
|
||||
status=web.HTTPConflict.status_code,
|
||||
)
|
||||
|
||||
updated = _serialize(await DLFields.get_instance()._repo.update(model.id, validated.model_dump(exclude_unset=True)))
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.DL_FIELDS, action=CEAction.UPDATE, data=updated)
|
||||
)
|
||||
return web.json_response(
|
||||
data=updated,
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("PUT", r"api/dl_fields/{id:\d+}", "dl_fields_update")
|
||||
async def dl_fields_update(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not (model := await DLFields.get_instance().get(identifier)):
|
||||
return web.json_response({"error": "DL field not found"}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
data = await request.json()
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting dict."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
validated = DLField.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate dl field.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if validated.name and await DLFields.get_instance()._repo.get_by_name(validated.name, exclude_id=model.id):
|
||||
return web.json_response(
|
||||
data={"error": f"DL field with name '{validated.name}' already exists."},
|
||||
status=web.HTTPConflict.status_code,
|
||||
)
|
||||
|
||||
updated = _serialize(await DLFields.get_instance()._repo.update(model.id, validated.model_dump(exclude_unset=True)))
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.DL_FIELDS, action=CEAction.UPDATE, data=updated)
|
||||
)
|
||||
|
||||
return web.json_response(data=updated, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
101
app/features/dl_fields/schemas.py
Normal file
101
app/features/dl_fields/schemas.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from app.features.core.schemas import Pagination
|
||||
from app.features.core.utils import parse_int
|
||||
|
||||
|
||||
class FieldType(str, Enum):
|
||||
STRING = "string"
|
||||
TEXT = "text"
|
||||
BOOL = "bool"
|
||||
|
||||
|
||||
class DLField(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, from_attributes=True)
|
||||
|
||||
id: int | None = None
|
||||
name: str = Field(min_length=1)
|
||||
description: str = ""
|
||||
field: str = Field(min_length=1)
|
||||
kind: FieldType = FieldType.TEXT
|
||||
icon: str = ""
|
||||
order: int = 0
|
||||
value: str = ""
|
||||
extras: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@field_validator("field")
|
||||
@classmethod
|
||||
def _validate_field(cls, value: str) -> str:
|
||||
if not value.startswith("--"):
|
||||
msg: str = "Field must start with '--'."
|
||||
raise ValueError(msg)
|
||||
if " " in value:
|
||||
msg = "Field must not contain spaces."
|
||||
raise ValueError(msg)
|
||||
return value
|
||||
|
||||
@field_validator("order", mode="before")
|
||||
@classmethod
|
||||
def _normalize_order(cls, value: Any) -> int:
|
||||
return parse_int(value, field="Order", minimum=0)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _normalize_extras(self) -> DLField:
|
||||
if not isinstance(self.extras, dict):
|
||||
msg: str = "Extras must be a dictionary."
|
||||
raise ValueError(msg)
|
||||
return self
|
||||
|
||||
|
||||
class DLFieldPatch(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, from_attributes=True)
|
||||
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
field: str | None = None
|
||||
kind: FieldType | None = None
|
||||
icon: str | None = None
|
||||
order: int | None = None
|
||||
value: str | None = None
|
||||
extras: dict[str, Any] | None = None
|
||||
|
||||
@field_validator("field")
|
||||
@classmethod
|
||||
def _validate_field(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return value
|
||||
if not value.startswith("--"):
|
||||
msg: str = "Field must start with '--'."
|
||||
raise ValueError(msg)
|
||||
if " " in value:
|
||||
msg = "Field must not contain spaces."
|
||||
raise ValueError(msg)
|
||||
return value
|
||||
|
||||
@field_validator("order", mode="before")
|
||||
@classmethod
|
||||
def _normalize_order(cls, value: Any) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
return parse_int(value, field="Order", minimum=0)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _normalize_extras(self) -> DLFieldPatch:
|
||||
if self.extras is None:
|
||||
return self
|
||||
if not isinstance(self.extras, dict):
|
||||
msg: str = "Extras must be a dictionary."
|
||||
raise ValueError(msg)
|
||||
return self
|
||||
|
||||
|
||||
class DLFieldList(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
items: list[DLField] = Field(default_factory=list)
|
||||
pagination: Pagination
|
||||
62
app/features/dl_fields/service.py
Normal file
62
app/features/dl_fields/service.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.features.dl_fields.models import DLFieldModel
|
||||
from app.features.dl_fields.repository import DLFieldsRepository
|
||||
from app.features.dl_fields.schemas import DLField
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from aiohttp import web
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("feature.dl_fields")
|
||||
|
||||
|
||||
class DLFields(metaclass=Singleton):
|
||||
def __init__(self):
|
||||
self._repo: DLFieldsRepository = DLFieldsRepository.get_instance()
|
||||
|
||||
@staticmethod
|
||||
def get_instance() -> DLFields:
|
||||
return DLFields()
|
||||
|
||||
async def on_shutdown(self, _: web.Application) -> None:
|
||||
pass
|
||||
|
||||
def attach(self, _: web.Application) -> None:
|
||||
async def handle_event(_, __):
|
||||
await self._repo.run_migrations()
|
||||
|
||||
EventBus.get_instance().subscribe(Events.STARTED, handle_event, "DLFieldsRepository.run_migrations")
|
||||
|
||||
async def get_all(self) -> list[DLFieldModel]:
|
||||
return await self._repo.list()
|
||||
|
||||
async def get_all_serialized(self) -> list[dict[str, Any]]:
|
||||
items = await self._repo.list()
|
||||
return [DLField.model_validate(item).model_dump() for item in items]
|
||||
|
||||
async def save(self, item: DLField | dict) -> DLFieldModel:
|
||||
try:
|
||||
if not isinstance(item, DLField):
|
||||
item = DLField.model_validate(item)
|
||||
except Exception as exc:
|
||||
msg: str = f"Failed to parse item. '{exc!s}'"
|
||||
raise ValueError(msg) from exc
|
||||
|
||||
try:
|
||||
repo = self._repo
|
||||
if item.id is None or 0 == item.id:
|
||||
model = await repo.create(item.model_dump(exclude_unset=True))
|
||||
else:
|
||||
model = await repo.update(item.id, item.model_dump(exclude_unset=True))
|
||||
except KeyError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
|
||||
return model
|
||||
|
||||
async def get(self, identifier: int | str) -> DLFieldModel | None:
|
||||
return await self._repo.get(identifier)
|
||||
76
app/features/dl_fields/tests/test_dl_fields.py
Normal file
76
app/features/dl_fields/tests/test_dl_fields.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"""Tests for dl_fields feature service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from app.features.dl_fields.repository import DLFieldsRepository
|
||||
from app.features.dl_fields.service import DLFields
|
||||
from app.library.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def repo(tmp_path):
|
||||
"""Provide a fresh repository instance with initialized database for each test."""
|
||||
DLFieldsRepository._reset_singleton()
|
||||
DLFields._reset_singleton()
|
||||
SqliteStore._reset_singleton()
|
||||
|
||||
store = SqliteStore(db_path=":memory:")
|
||||
await store.get_connection()
|
||||
|
||||
repository = DLFieldsRepository.get_instance()
|
||||
|
||||
yield repository
|
||||
|
||||
if store._conn:
|
||||
await store._conn.close()
|
||||
if store._engine:
|
||||
await store._engine.dispose()
|
||||
|
||||
DLFieldsRepository._reset_singleton()
|
||||
DLFields._reset_singleton()
|
||||
SqliteStore._reset_singleton()
|
||||
|
||||
|
||||
class TestDLFieldsService:
|
||||
"""Test suite for DLFields service methods."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_save_creates_field(self, repo):
|
||||
"""Save should create a new dl field when ID is missing."""
|
||||
service = DLFields.get_instance()
|
||||
payload = {
|
||||
"name": "quality",
|
||||
"description": "Video quality",
|
||||
"field": "--format",
|
||||
"kind": "string",
|
||||
"order": 1,
|
||||
"extras": {"options": ["best", "worst"]},
|
||||
}
|
||||
|
||||
model = await service.save(payload)
|
||||
|
||||
assert model.id is not None, "Should create new dl field"
|
||||
assert model.name == "quality", "Should store name correctly"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_serialized(self, repo):
|
||||
"""Get all serialized returns list of dictionaries."""
|
||||
service = DLFields.get_instance()
|
||||
await service.save(
|
||||
{
|
||||
"name": "audio_only",
|
||||
"description": "Audio",
|
||||
"field": "--extract-audio",
|
||||
"kind": "bool",
|
||||
"order": 2,
|
||||
}
|
||||
)
|
||||
|
||||
items = await service.get_all_serialized()
|
||||
|
||||
assert len(items) == 1, "Should return one dl field"
|
||||
assert items[0]["name"] == "audio_only", "Should serialize name"
|
||||
assert isinstance(items[0]["id"], int), "Should serialize integer ID"
|
||||
270
app/features/dl_fields/tests/test_dl_fields_repository.py
Normal file
270
app/features/dl_fields/tests/test_dl_fields_repository.py
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
"""Tests for DLFieldsRepository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from app.features.dl_fields.models import DLFieldModel
|
||||
from app.features.dl_fields.repository import DLFieldsRepository
|
||||
from app.library.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def repo(tmp_path):
|
||||
"""Provide a fresh repository instance with initialized database for each test."""
|
||||
DLFieldsRepository._reset_singleton()
|
||||
SqliteStore._reset_singleton()
|
||||
|
||||
store = SqliteStore(db_path=str(":memory:"))
|
||||
await store.get_connection()
|
||||
|
||||
repository = DLFieldsRepository.get_instance()
|
||||
|
||||
yield repository
|
||||
|
||||
if store._conn:
|
||||
await store._conn.close()
|
||||
if store._engine:
|
||||
await store._engine.dispose()
|
||||
|
||||
DLFieldsRepository._reset_singleton()
|
||||
SqliteStore._reset_singleton()
|
||||
|
||||
|
||||
class TestDLFieldsRepository:
|
||||
"""Test suite for DLFieldsRepository database operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repository_singleton(self, repo):
|
||||
"""Verify repository follows singleton pattern."""
|
||||
instance1 = DLFieldsRepository.get_instance()
|
||||
instance2 = DLFieldsRepository.get_instance()
|
||||
assert instance1 is instance2, "Should return same singleton instance"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_empty(self, repo):
|
||||
"""List returns empty when no fields exist."""
|
||||
fields = await repo.list()
|
||||
assert fields == [], "Should return empty list when no fields"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_empty(self, repo):
|
||||
"""Count returns 0 when no fields exist."""
|
||||
count = await repo.count()
|
||||
assert count == 0, "Should return 0 when no fields exist"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_field(self, repo):
|
||||
"""Create field with valid data."""
|
||||
data = {
|
||||
"name": "quality",
|
||||
"description": "Video quality setting",
|
||||
"field": "--format",
|
||||
"kind": "string",
|
||||
"icon": "fa-video",
|
||||
"order": 1,
|
||||
"value": "best",
|
||||
"extras": {"options": ["best", "worst"]},
|
||||
}
|
||||
|
||||
model = await repo.create(data)
|
||||
|
||||
assert model.id is not None, "Should generate ID for new field"
|
||||
assert model.name == "quality", "Should store name correctly"
|
||||
assert model.description == "Video quality setting", "Should store description correctly"
|
||||
assert model.field == "--format", "Should store field correctly"
|
||||
assert model.kind == "string", "Should store kind correctly"
|
||||
assert model.icon == "fa-video", "Should store icon correctly"
|
||||
assert model.order == 1, "Should store order correctly"
|
||||
assert model.value == "best", "Should store value correctly"
|
||||
assert model.extras == {"options": ["best", "worst"]}, "Should store extras as dict"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_with_defaults(self, repo):
|
||||
"""Create field with minimal data uses defaults."""
|
||||
data = {
|
||||
"name": "minimal",
|
||||
"description": "Minimal",
|
||||
"field": "--minimal",
|
||||
"kind": "text",
|
||||
}
|
||||
|
||||
model = await repo.create(data)
|
||||
|
||||
assert model.icon == "", "Should default icon to empty string"
|
||||
assert model.order == 0, "Should default order to 0"
|
||||
assert model.value == "", "Should default value to empty string"
|
||||
assert model.extras == {}, "Should default extras to empty dict"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_id(self, repo):
|
||||
"""Get field by integer ID."""
|
||||
created = await repo.create(
|
||||
{
|
||||
"name": "get_test",
|
||||
"description": "Get test",
|
||||
"field": "--get-test",
|
||||
"kind": "text",
|
||||
}
|
||||
)
|
||||
|
||||
retrieved = await repo.get(created.id)
|
||||
|
||||
assert retrieved is not None, "Should retrieve created field"
|
||||
assert retrieved.id == created.id, "Should retrieve correct field by ID"
|
||||
assert retrieved.name == "get_test", "Should match created field name"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_name(self, repo):
|
||||
"""Get field by string name."""
|
||||
await repo.create(
|
||||
{
|
||||
"name": "named_test",
|
||||
"description": "Named test",
|
||||
"field": "--named-test",
|
||||
"kind": "text",
|
||||
}
|
||||
)
|
||||
|
||||
retrieved = await repo.get("named_test")
|
||||
|
||||
assert retrieved is not None, "Should retrieve by name"
|
||||
assert retrieved.name == "named_test", "Should match field name"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nonexistent(self, repo):
|
||||
"""Get nonexistent field returns None."""
|
||||
result = await repo.get(99999)
|
||||
assert result is None, "Should return None for nonexistent ID"
|
||||
|
||||
result = await repo.get("nonexistent")
|
||||
assert result is None, "Should return None for nonexistent name"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_field(self, repo):
|
||||
"""Update existing field."""
|
||||
created = await repo.create(
|
||||
{
|
||||
"name": "update_test",
|
||||
"description": "Update test",
|
||||
"field": "--update-test",
|
||||
"kind": "text",
|
||||
}
|
||||
)
|
||||
|
||||
updated = await repo.update(
|
||||
created.id,
|
||||
{
|
||||
"name": "updated_name",
|
||||
"order": 5,
|
||||
"extras": {"updated": True},
|
||||
},
|
||||
)
|
||||
|
||||
assert updated.name == "updated_name", "Should update name"
|
||||
assert updated.order == 5, "Should update order"
|
||||
assert updated.extras == {"updated": True}, "Should update extras"
|
||||
assert updated.field == "--update-test", "Should preserve unchanged field"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_nonexistent_raises(self, repo):
|
||||
"""Update nonexistent field raises KeyError."""
|
||||
with pytest.raises(KeyError, match="not found"):
|
||||
await repo.update(99999, {"name": "should_fail"})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_field(self, repo):
|
||||
"""Delete existing field."""
|
||||
created = await repo.create(
|
||||
{
|
||||
"name": "delete_test",
|
||||
"description": "Delete test",
|
||||
"field": "--delete-test",
|
||||
"kind": "text",
|
||||
}
|
||||
)
|
||||
|
||||
deleted = await repo.delete(created.id)
|
||||
|
||||
assert deleted.id == created.id, "Should return deleted field"
|
||||
|
||||
result = await repo.get(created.id)
|
||||
assert result is None, "Deleted field should not be retrievable"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_nonexistent_raises(self, repo):
|
||||
"""Delete nonexistent field raises KeyError."""
|
||||
with pytest.raises(KeyError, match="not found"):
|
||||
await repo.delete(99999)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_paginated(self, repo):
|
||||
"""List paginated returns correct subset."""
|
||||
for i in range(5):
|
||||
await repo.create(
|
||||
{
|
||||
"name": f"item_{i}",
|
||||
"description": "desc",
|
||||
"field": f"--item-{i}",
|
||||
"kind": "text",
|
||||
"order": i,
|
||||
}
|
||||
)
|
||||
|
||||
items, total, page, total_pages = await repo.list_paginated(page=1, per_page=2)
|
||||
|
||||
assert len(items) == 2, "Should return 2 items per page"
|
||||
assert total == 5, "Should report total count of 5"
|
||||
assert page == 1, "Should be on page 1"
|
||||
assert total_pages == 3, "Should have 3 pages total"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_ordering(self, repo):
|
||||
"""List orders by order asc then name asc."""
|
||||
await repo.create({"name": "b", "description": "b", "field": "--b", "kind": "text", "order": 1})
|
||||
await repo.create({"name": "a", "description": "a", "field": "--a", "kind": "text", "order": 1})
|
||||
await repo.create({"name": "c", "description": "c", "field": "--c", "kind": "text", "order": 0})
|
||||
|
||||
items = await repo.list()
|
||||
|
||||
assert items[0].name == "c", "Lowest order should be first"
|
||||
assert items[1].name == "a", "Same order sorted alphabetically"
|
||||
assert items[2].name == "b", "Same order sorted alphabetically"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_name_excludes_id(self, repo):
|
||||
"""Get by name can exclude specific ID."""
|
||||
first = await repo.create(
|
||||
{
|
||||
"name": "duplicate",
|
||||
"description": "duplicate",
|
||||
"field": "--duplicate",
|
||||
"kind": "text",
|
||||
}
|
||||
)
|
||||
|
||||
result = await repo.get_by_name("duplicate", exclude_id=first.id)
|
||||
assert result is None, "Should not find when excluding only match"
|
||||
|
||||
result = await repo.get_by_name("duplicate", exclude_id=None)
|
||||
assert result is not None, "Should find without exclusion"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replace_all(self, repo):
|
||||
"""Replace all fields atomically."""
|
||||
await repo.create({"name": "old_1", "description": "old", "field": "--old-1", "kind": "text"})
|
||||
await repo.create({"name": "old_2", "description": "old", "field": "--old-2", "kind": "text"})
|
||||
|
||||
new_items = [
|
||||
{"name": "new_1", "description": "new", "field": "--new-1", "kind": "text"},
|
||||
{"name": "new_2", "description": "new", "field": "--new-2", "kind": "text"},
|
||||
]
|
||||
|
||||
result = await repo.replace_all(new_items)
|
||||
|
||||
assert len(result) == 2, "Should create 2 new fields"
|
||||
|
||||
all_items = await repo.list()
|
||||
assert len(all_items) == 2, "Should only have new fields"
|
||||
assert {item.name for item in all_items} == {"new_1", "new_2"}, "Should only have new items"
|
||||
5
app/features/notifications/deps.py
Normal file
5
app/features/notifications/deps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from app.features.notifications.repository import NotificationsRepository
|
||||
|
||||
|
||||
def get_notifications_repo() -> NotificationsRepository:
|
||||
return NotificationsRepository.get_instance()
|
||||
179
app/features/notifications/migration.py
Normal file
179
app/features/notifications/migration.py
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from app.features.core.migration import Migration as FeatureMigration
|
||||
from app.features.notifications.schemas import NotificationEvents
|
||||
from app.features.presets.service import Presets
|
||||
from app.library.config import Config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.features.notifications.repository import NotificationsRepository
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Migration(FeatureMigration):
|
||||
name: str = "notifications"
|
||||
|
||||
def __init__(self, repo: NotificationsRepository, config: Config | None = None):
|
||||
self._config: Config = config or Config.get_instance()
|
||||
super().__init__(config=self._config)
|
||||
self._repo: NotificationsRepository = repo
|
||||
self._source_file: Path = Path(self._config.config_path) / "notifications.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("Notification targets 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 notification targets found in %s; skipping migration.", self._source_file)
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
inserted = 0
|
||||
seen_names: dict[str, int] = {}
|
||||
for index, item in enumerate(items):
|
||||
normalized = self._normalize(item, index, seen_names)
|
||||
if not normalized:
|
||||
continue
|
||||
try:
|
||||
await self._repo.create(normalized)
|
||||
inserted += 1
|
||||
except Exception as exc:
|
||||
LOG.exception("Failed to insert notification '%s': %s", normalized.get("name"), exc)
|
||||
|
||||
LOG.info("Migrated %s notification target(s) from %s.", inserted, self._source_file)
|
||||
await self._move_file(self._source_file)
|
||||
|
||||
def _normalize(self, item: Any, index: int, seen_names: dict[str, int]) -> dict[str, Any] | None:
|
||||
if not isinstance(item, dict):
|
||||
LOG.warning("Skipping notification at index %s due to invalid type.", index)
|
||||
return None
|
||||
|
||||
assert isinstance(item, dict)
|
||||
|
||||
name: str | None = item.get("name")
|
||||
if not name or not isinstance(name, str):
|
||||
LOG.warning("Skipping notification at index %s due to missing name.", index)
|
||||
return None
|
||||
|
||||
normalized_name = name.strip()
|
||||
if not normalized_name:
|
||||
LOG.warning("Skipping notification at index %s due to empty name.", index)
|
||||
return None
|
||||
|
||||
name = self._unique_name(normalized_name, seen_names)
|
||||
request = item.get("request") if isinstance(item.get("request"), dict) else {}
|
||||
request = cast("dict[str, Any]", request)
|
||||
url: str | None = request.get("url")
|
||||
if not url or not isinstance(url, str):
|
||||
LOG.warning("Skipping notification '%s' due to missing request url.", name)
|
||||
return None
|
||||
|
||||
raw_method = request.get("method")
|
||||
method = raw_method.upper() if isinstance(raw_method, str) else "POST"
|
||||
if method not in {"POST", "PUT"}:
|
||||
LOG.warning("Skipping notification '%s' due to invalid method '%s'.", name, method)
|
||||
return None
|
||||
|
||||
raw_type = request.get("type")
|
||||
req_type = raw_type.lower() if isinstance(raw_type, str) else "json"
|
||||
if req_type not in {"json", "form"}:
|
||||
LOG.warning("Skipping notification '%s' due to invalid type '%s'.", name, req_type)
|
||||
return None
|
||||
|
||||
data_key = request.get("data_key") if isinstance(request.get("data_key"), str) else "data"
|
||||
if not data_key:
|
||||
data_key = "data"
|
||||
|
||||
headers = self._normalize_headers(request.get("headers"))
|
||||
|
||||
enabled_value = item.get("enabled")
|
||||
enabled: bool = enabled_value if isinstance(enabled_value, bool) else True
|
||||
events = self._normalize_events(item.get("on"))
|
||||
if events is None:
|
||||
LOG.warning("Skipping notification '%s' due to invalid events.", name)
|
||||
return None
|
||||
|
||||
presets = self._normalize_presets(item.get("presets"))
|
||||
if presets is None:
|
||||
LOG.warning("Skipping notification '%s' due to invalid presets.", name)
|
||||
return None
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"on": events,
|
||||
"presets": presets,
|
||||
"enabled": enabled,
|
||||
"request_url": url,
|
||||
"request_method": method,
|
||||
"request_type": req_type,
|
||||
"request_data_key": data_key,
|
||||
"request_headers": headers,
|
||||
}
|
||||
|
||||
def _normalize_events(self, events: Any) -> list[str] | None:
|
||||
if events is None:
|
||||
return []
|
||||
if not isinstance(events, list):
|
||||
return []
|
||||
|
||||
allowed = set(NotificationEvents.get_events().values())
|
||||
valid = [event for event in events if event in allowed]
|
||||
invalid = [event for event in events if event not in allowed]
|
||||
|
||||
if len(invalid) > 0 and len(valid) < 1:
|
||||
return None
|
||||
|
||||
if len(invalid) > 0:
|
||||
LOG.warning("Dropping invalid notification events: %s", ", ".join(invalid))
|
||||
|
||||
return valid
|
||||
|
||||
def _normalize_presets(self, presets: Any) -> list[str] | None:
|
||||
if presets is None:
|
||||
return []
|
||||
if not isinstance(presets, list):
|
||||
return []
|
||||
|
||||
allowed = {preset.name for preset in Presets.get_instance().get_all()}
|
||||
valid = [preset for preset in presets if preset in allowed]
|
||||
invalid = [preset for preset in presets if preset not in allowed]
|
||||
|
||||
if len(invalid) > 0 and len(valid) < 1:
|
||||
return None
|
||||
|
||||
if len(invalid) > 0:
|
||||
LOG.warning("Dropping invalid notification presets: %s", ", ".join(invalid))
|
||||
|
||||
return valid
|
||||
|
||||
def _normalize_headers(self, headers: Any) -> list[dict[str, str]]:
|
||||
if not isinstance(headers, list):
|
||||
return []
|
||||
|
||||
normalized: list[dict[str, str]] = []
|
||||
for header in headers:
|
||||
if not isinstance(header, dict):
|
||||
continue
|
||||
key = str(header.get("key", "")).strip()
|
||||
value = str(header.get("value", "")).strip()
|
||||
if key and value:
|
||||
normalized.append({"key": key, "value": value})
|
||||
return normalized
|
||||
39
app/features/notifications/models.py
Normal file
39
app/features/notifications/models.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime # noqa: TC003
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Index, Integer, String, Text
|
||||
from sqlalchemy import Enum as SQLAEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.features.core.models import Base, UTCDateTime, utcnow
|
||||
from app.features.notifications.schemas import NotificationRequestMethod, NotificationRequestType
|
||||
|
||||
|
||||
class NotificationModel(Base):
|
||||
__tablename__: str = "notifications"
|
||||
__table_args__: tuple[Index, ...] = (
|
||||
Index("ix_notifications_name", "name"),
|
||||
Index("ix_notifications_enabled", "enabled"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
on: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
|
||||
presets: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
request_url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
request_method: Mapped[NotificationRequestMethod] = mapped_column(
|
||||
SQLAEnum(NotificationRequestMethod, name="notification_request_method", native_enum=False),
|
||||
nullable=False,
|
||||
default=NotificationRequestMethod.POST,
|
||||
)
|
||||
request_type: Mapped[NotificationRequestType] = mapped_column(
|
||||
SQLAEnum(NotificationRequestType, name="notification_request_type", native_enum=False),
|
||||
nullable=False,
|
||||
default=NotificationRequestType.JSON,
|
||||
)
|
||||
request_data_key: Mapped[str] = mapped_column(String(255), nullable=False, default="data")
|
||||
request_headers: Mapped[list[dict]] = mapped_column(JSON, nullable=False, default=list)
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, onupdate=utcnow, nullable=False)
|
||||
162
app/features/notifications/repository.py
Normal file
162
app/features/notifications/repository.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
|
||||
from app.features.core.deps import get_session
|
||||
from app.features.notifications.migration import Migration
|
||||
from app.features.notifications.models import NotificationModel
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.engine.result import Result
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
from sqlalchemy.sql.selectable import Select
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationsRepository(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() -> NotificationsRepository:
|
||||
return NotificationsRepository()
|
||||
|
||||
async def list(self) -> list[NotificationModel]:
|
||||
async with self.session() as session:
|
||||
result: Result[tuple[NotificationModel]] = await session.execute(
|
||||
select(NotificationModel).order_by(NotificationModel.name.asc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_paginated(self, page: int, per_page: int) -> tuple[list[NotificationModel], 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[NotificationModel]] = (
|
||||
select(NotificationModel)
|
||||
.order_by(NotificationModel.name.asc())
|
||||
.limit(per_page)
|
||||
.offset((page - 1) * per_page)
|
||||
)
|
||||
result: Result[tuple[NotificationModel]] = 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(NotificationModel))
|
||||
return int(result.scalar_one())
|
||||
|
||||
async def get(self, identifier: int | str) -> NotificationModel | None:
|
||||
async with self.session() as session:
|
||||
if not identifier:
|
||||
return None
|
||||
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = NotificationModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause = or_(NotificationModel.id == int(identifier), NotificationModel.name == identifier)
|
||||
else:
|
||||
clause = NotificationModel.name == identifier
|
||||
|
||||
result: Result[tuple[NotificationModel]] = await session.execute(
|
||||
select(NotificationModel).where(clause).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_name(self, name: str, exclude_id: int | None = None) -> NotificationModel | None:
|
||||
async with self.session() as session:
|
||||
query: Select[tuple[NotificationModel]] = select(NotificationModel).where(NotificationModel.name == name)
|
||||
if exclude_id is not None:
|
||||
query = query.where(NotificationModel.id != exclude_id)
|
||||
|
||||
result: Result[tuple[NotificationModel]] = await session.execute(query.limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(self, payload: NotificationModel | dict) -> NotificationModel:
|
||||
async with self.session() as session:
|
||||
model: NotificationModel = NotificationModel(**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"Notification target 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]) -> NotificationModel:
|
||||
async with self.session() as session:
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = NotificationModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause = or_(NotificationModel.id == int(identifier), NotificationModel.name == identifier)
|
||||
else:
|
||||
clause = NotificationModel.name == identifier
|
||||
|
||||
result: Result[tuple[NotificationModel]] = await session.execute(
|
||||
select(NotificationModel).where(clause).limit(1)
|
||||
)
|
||||
model: NotificationModel | None = result.scalar_one_or_none()
|
||||
|
||||
if not model:
|
||||
msg: str = f"Notification target '{identifier}' not found."
|
||||
raise KeyError(msg)
|
||||
|
||||
payload.pop("id", None)
|
||||
|
||||
if "name" in payload and await self.get_by_name(name=payload["name"], exclude_id=model.id) is not None:
|
||||
msg = f"Notification target 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) -> NotificationModel:
|
||||
async with self.session() as session:
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = NotificationModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause = or_(NotificationModel.id == int(identifier), NotificationModel.name == identifier)
|
||||
else:
|
||||
clause = NotificationModel.name == identifier
|
||||
|
||||
result: Result[tuple[NotificationModel]] = await session.execute(
|
||||
select(NotificationModel).where(clause).limit(1)
|
||||
)
|
||||
model = result.scalar_one_or_none()
|
||||
|
||||
if not model:
|
||||
msg: str = f"Notification target '{identifier}' not found."
|
||||
raise KeyError(msg)
|
||||
|
||||
await session.delete(model)
|
||||
await session.commit()
|
||||
return model
|
||||
218
app/features/notifications/router.py
Normal file
218
app/features/notifications/router.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.features.core.schemas import CEAction, CEFeature, ConfigEvent, Pagination
|
||||
from app.features.core.utils import build_pagination, format_validation_errors, normalize_pagination
|
||||
from app.features.notifications.schemas import Notification, NotificationEvents, NotificationList, NotificationPatch
|
||||
from app.features.notifications.service import Notifications
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.router import route
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _model(model: Any) -> Notification:
|
||||
return Notifications.get_instance().model_to_schema(model)
|
||||
|
||||
|
||||
def _serialize(model: Any) -> dict:
|
||||
return _model(model).model_dump()
|
||||
|
||||
|
||||
@route("GET", "api/notifications/", "notifications_list")
|
||||
async def notifications_list(request: Request, encoder: Encoder) -> Response:
|
||||
page, per_page = normalize_pagination(request)
|
||||
items, total, current_page, total_pages = await Notifications.get_instance().list_paginated(page, per_page)
|
||||
return web.json_response(
|
||||
data=NotificationList(
|
||||
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("GET", "api/notifications/events/", "notifications_events")
|
||||
async def notifications_events(encoder: Encoder) -> Response:
|
||||
return web.json_response(
|
||||
data={"events": list(NotificationEvents.get_events().values())},
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("POST", "api/notifications/test/", "notification_test")
|
||||
async def notification_test(encoder: Encoder, notify: EventBus) -> Response:
|
||||
notify.emit(Events.TEST, title="Test Notification", message="This is a test notification.")
|
||||
return web.json_response(data={}, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("POST", "api/notifications/", "notification_add")
|
||||
async def notifications_add(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
data = await request.json()
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting dict."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
item: Notification = Notification.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate notification.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
saved = _serialize(await Notifications.get_instance().create(item))
|
||||
except ValueError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE,
|
||||
data=ConfigEvent(feature=CEFeature.NOTIFICATIONS, action=CEAction.CREATE, data=saved),
|
||||
)
|
||||
return web.json_response(data=saved, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("GET", r"api/notifications/{id:\d+}", "notification_get")
|
||||
async def notifications_get(request: Request, encoder: Encoder) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not (model := await Notifications.get_instance().get(identifier)):
|
||||
return web.json_response({"error": "Notification not found"}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
return web.json_response(data=_serialize(model), status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("DELETE", r"api/notifications/{id:\d+}", "notification_delete")
|
||||
async def notifications_delete(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
deleted = _serialize(await Notifications.get_instance().delete(identifier))
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE,
|
||||
data=ConfigEvent(feature=CEFeature.NOTIFICATIONS, action=CEAction.DELETE, data=deleted),
|
||||
)
|
||||
return web.json_response(
|
||||
data=deleted,
|
||||
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", r"api/notifications/{id:\d+}", "notification_patch")
|
||||
async def notifications_patch(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not (model := await Notifications.get_instance().get(identifier)):
|
||||
return web.json_response({"error": "Notification not found"}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
data = await request.json()
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting dict."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
validated = NotificationPatch.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate notification.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
current = _model(model).model_dump()
|
||||
if validated.name is not None:
|
||||
current["name"] = validated.name
|
||||
if validated.on is not None:
|
||||
current["on"] = validated.on
|
||||
if validated.presets is not None:
|
||||
current["presets"] = validated.presets
|
||||
if validated.enabled is not None:
|
||||
current["enabled"] = validated.enabled
|
||||
|
||||
if validated.request is not None:
|
||||
request_payload = current.get("request", {})
|
||||
if validated.request.url is not None:
|
||||
request_payload["url"] = validated.request.url
|
||||
if validated.request.method is not None:
|
||||
request_payload["method"] = validated.request.method
|
||||
if validated.request.type is not None:
|
||||
request_payload["type"] = validated.request.type
|
||||
if validated.request.headers is not None:
|
||||
request_payload["headers"] = [header.model_dump() for header in validated.request.headers]
|
||||
if validated.request.data_key is not None:
|
||||
request_payload["data_key"] = validated.request.data_key
|
||||
current["request"] = request_payload
|
||||
|
||||
try:
|
||||
updated = _serialize(await Notifications.get_instance().update(model.id, current))
|
||||
except ValueError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=web.HTTPBadRequest.status_code)
|
||||
except KeyError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE,
|
||||
data=ConfigEvent(feature=CEFeature.NOTIFICATIONS, action=CEAction.UPDATE, data=updated),
|
||||
)
|
||||
return web.json_response(
|
||||
data=updated,
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("PUT", r"api/notifications/{id:\d+}", "notification_update")
|
||||
async def notifications_update(request: Request, encoder: Encoder, notify: EventBus) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not (model := await Notifications.get_instance().get(identifier)):
|
||||
return web.json_response({"error": "Notification not found"}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
data = await request.json()
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting dict."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
validated = Notification.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate notification.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
updated = _serialize(await Notifications.get_instance().update(model.id, validated))
|
||||
except ValueError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=web.HTTPBadRequest.status_code)
|
||||
except KeyError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE,
|
||||
data=ConfigEvent(feature=CEFeature.NOTIFICATIONS, action=CEAction.UPDATE, data=updated),
|
||||
)
|
||||
return web.json_response(data=updated, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
145
app/features/notifications/schemas.py
Normal file
145
app/features/notifications/schemas.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.features.core.schemas import Pagination
|
||||
from app.library.Events import Events
|
||||
|
||||
|
||||
class NotificationEvents:
|
||||
TEST: str = Events.TEST
|
||||
|
||||
ITEM_ADDED: str = Events.ITEM_ADDED
|
||||
ITEM_COMPLETED: str = Events.ITEM_COMPLETED
|
||||
ITEM_CANCELLED: str = Events.ITEM_CANCELLED
|
||||
ITEM_DELETED: str = Events.ITEM_DELETED
|
||||
ITEM_PAUSED: str = Events.ITEM_PAUSED
|
||||
ITEM_RESUMED: str = Events.ITEM_RESUMED
|
||||
ITEM_MOVED: str = Events.ITEM_MOVED
|
||||
|
||||
PAUSED: str = Events.PAUSED
|
||||
RESUMED: str = Events.RESUMED
|
||||
|
||||
LOG_INFO: str = Events.LOG_INFO
|
||||
LOG_SUCCESS: str = Events.LOG_SUCCESS
|
||||
LOG_WARNING: str = Events.LOG_WARNING
|
||||
LOG_ERROR: str = Events.LOG_ERROR
|
||||
|
||||
TASK_DISPATCHED: str = Events.TASK_DISPATCHED
|
||||
|
||||
@staticmethod
|
||||
def get_events() -> dict[str, str]:
|
||||
return {k: v for k, v in vars(NotificationEvents).items() if not k.startswith("__") and not callable(v)}
|
||||
|
||||
@staticmethod
|
||||
def events() -> list[str]:
|
||||
return [
|
||||
getattr(NotificationEvents, ev)
|
||||
for ev in dir(NotificationEvents)
|
||||
if not ev.startswith("_") and not callable(getattr(NotificationEvents, ev))
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def is_valid(event: str) -> bool:
|
||||
return event in NotificationEvents.get_events().values()
|
||||
|
||||
|
||||
class NotificationRequestType(str, Enum):
|
||||
JSON = "json"
|
||||
FORM = "form"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class NotificationRequestMethod(str, Enum):
|
||||
POST = "POST"
|
||||
PUT = "PUT"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
class NotificationRequestHeader(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, from_attributes=True)
|
||||
|
||||
key: str = Field(min_length=1)
|
||||
value: str = Field(min_length=1)
|
||||
|
||||
|
||||
class NotificationRequest(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, from_attributes=True)
|
||||
|
||||
type: NotificationRequestType = NotificationRequestType.JSON
|
||||
method: NotificationRequestMethod = NotificationRequestMethod.POST
|
||||
url: str = Field(min_length=1)
|
||||
headers: list[NotificationRequestHeader] = Field(default_factory=list)
|
||||
data_key: str = Field(default="data", min_length=1)
|
||||
|
||||
@field_validator("method", mode="before")
|
||||
@classmethod
|
||||
def _normalize_method(cls, value: Any) -> Any:
|
||||
if value is None:
|
||||
return value
|
||||
if isinstance(value, NotificationRequestMethod):
|
||||
return value
|
||||
return str(value).upper()
|
||||
|
||||
@field_validator("type", mode="before")
|
||||
@classmethod
|
||||
def _normalize_type(cls, value: Any) -> Any:
|
||||
if value is None:
|
||||
return value
|
||||
if isinstance(value, NotificationRequestType):
|
||||
return value
|
||||
return str(value).lower()
|
||||
|
||||
|
||||
class NotificationRequestPatch(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, from_attributes=True)
|
||||
|
||||
type: NotificationRequestType | None = None
|
||||
method: NotificationRequestMethod | None = None
|
||||
url: str | None = None
|
||||
headers: list[NotificationRequestHeader] | None = None
|
||||
data_key: str | None = None
|
||||
|
||||
|
||||
class Notification(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, from_attributes=True)
|
||||
|
||||
id: int | None = None
|
||||
name: str = Field(min_length=1)
|
||||
on: list[str] = Field(default_factory=list)
|
||||
presets: list[str] = Field(default_factory=list)
|
||||
enabled: bool = True
|
||||
request: NotificationRequest
|
||||
|
||||
@field_validator("on", "presets", mode="before")
|
||||
@classmethod
|
||||
def _normalize_list(cls, value: Any) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
return [str(value).strip()]
|
||||
|
||||
|
||||
class NotificationPatch(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, from_attributes=True)
|
||||
|
||||
name: str | None = None
|
||||
on: list[str] | None = None
|
||||
presets: list[str] | None = None
|
||||
enabled: bool | None = None
|
||||
request: NotificationRequestPatch | None = None
|
||||
|
||||
|
||||
class NotificationList(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
items: list[Notification] = Field(default_factory=list)
|
||||
pagination: Pagination
|
||||
348
app/features/notifications/service.py
Normal file
348
app/features/notifications/service.py
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.features.notifications.models import NotificationModel
|
||||
from app.features.notifications.repository import NotificationsRepository
|
||||
from app.features.notifications.schemas import (
|
||||
Notification,
|
||||
NotificationEvents,
|
||||
NotificationRequest,
|
||||
NotificationRequestHeader,
|
||||
NotificationRequestType,
|
||||
)
|
||||
from app.features.presets.schemas import Preset
|
||||
from app.features.presets.service import Presets
|
||||
from app.library.BackgroundWorker import BackgroundWorker
|
||||
from app.library.config import Config
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import Event, EventBus, Events
|
||||
from app.library.httpx_client import async_client
|
||||
from app.library.ItemDTO import Item, ItemDTO
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Awaitable
|
||||
|
||||
import httpx
|
||||
from aiohttp import web
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("feature.notifications")
|
||||
|
||||
|
||||
class Notifications(metaclass=Singleton):
|
||||
def __init__(
|
||||
self,
|
||||
repo: NotificationsRepository | None = None,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
encoder: Encoder | None = None,
|
||||
config: Config | None = None,
|
||||
background_worker: BackgroundWorker | None = None,
|
||||
) -> None:
|
||||
self._repo: NotificationsRepository = repo or NotificationsRepository.get_instance()
|
||||
|
||||
config = config or Config.get_instance()
|
||||
|
||||
self._debug: bool = config.debug
|
||||
self._version: str = config.app_version
|
||||
self._client: httpx.AsyncClient = client or async_client()
|
||||
self._encoder: Encoder = encoder or Encoder()
|
||||
self._offload: BackgroundWorker = background_worker or BackgroundWorker.get_instance()
|
||||
|
||||
@staticmethod
|
||||
def get_instance() -> Notifications:
|
||||
return Notifications()
|
||||
|
||||
async def on_shutdown(self, _: web.Application) -> None:
|
||||
pass
|
||||
|
||||
def attach(self, _: web.Application) -> None:
|
||||
async def handle_event(_, __):
|
||||
await self._repo.run_migrations()
|
||||
|
||||
EventBus.get_instance().subscribe(Events.STARTED, handle_event, "NotificationsRepository.run_migrations")
|
||||
EventBus.get_instance().subscribe(NotificationEvents.events(), self.emit, f"{__class__.__name__}.emit")
|
||||
|
||||
async def list(self) -> list[NotificationModel]:
|
||||
return await self._repo.list()
|
||||
|
||||
async def list_paginated(self, page: int, per_page: int) -> tuple[list[NotificationModel], int, int, int]:
|
||||
return await self._repo.list_paginated(page, per_page)
|
||||
|
||||
async def get(self, identifier: int | str) -> NotificationModel | None:
|
||||
return await self._repo.get(identifier)
|
||||
|
||||
async def create(self, item: Notification | dict) -> NotificationModel:
|
||||
if not isinstance(item, Notification):
|
||||
item = Notification.model_validate(item)
|
||||
normalized = self._normalize(item)
|
||||
payload = self._payload_from_schema(normalized)
|
||||
return await self._repo.create(payload)
|
||||
|
||||
async def update(self, identifier: int | str, payload: Notification | dict) -> NotificationModel:
|
||||
if not isinstance(payload, Notification):
|
||||
payload = Notification.model_validate(payload)
|
||||
normalized = self._normalize(payload)
|
||||
update_payload = self._payload_from_schema(normalized)
|
||||
return await self._repo.update(identifier, update_payload)
|
||||
|
||||
async def delete(self, identifier: int | str) -> NotificationModel:
|
||||
return await self._repo.delete(identifier)
|
||||
|
||||
async def send(self, ev: Event, wait: bool = True) -> list[dict] | list[Awaitable[dict]]:
|
||||
targets = await self._repo.list()
|
||||
if len(targets) < 1:
|
||||
return []
|
||||
|
||||
if not isinstance(ev.data, (ItemDTO, Item, dict)):
|
||||
LOG.debug("Received invalid item type '%s' with event '%s'.", type(ev.data), ev.event)
|
||||
return []
|
||||
|
||||
tasks: list[Awaitable[dict]] = []
|
||||
apprise_targets: list[Notification] = []
|
||||
|
||||
for target_model in targets:
|
||||
target = self.model_to_schema(target_model)
|
||||
if not target.enabled:
|
||||
continue
|
||||
|
||||
if len(target.on) > 0 and ev.event not in target.on and NotificationEvents.TEST != ev.event:
|
||||
continue
|
||||
|
||||
if NotificationEvents.TEST != ev.event and not self._check_preset(target, ev):
|
||||
continue
|
||||
|
||||
if not target.request.url.startswith("http"):
|
||||
apprise_targets.append(target)
|
||||
else:
|
||||
tasks.append(self._send(target, ev))
|
||||
|
||||
if len(apprise_targets) > 0:
|
||||
tasks.append(self._apprise(apprise_targets, ev))
|
||||
|
||||
if wait:
|
||||
return await asyncio.gather(*tasks)
|
||||
|
||||
return tasks
|
||||
|
||||
def emit(self, e: Event, _, **__) -> None:
|
||||
if not NotificationEvents.is_valid(e.event):
|
||||
return
|
||||
|
||||
self._offload.submit(self.send, e)
|
||||
|
||||
def _normalize(self, item: Notification) -> Notification:
|
||||
if item.enabled is not None and not isinstance(item.enabled, bool):
|
||||
msg: str = "Enabled must be a boolean."
|
||||
raise ValueError(msg)
|
||||
|
||||
item.on = self._filter_events(item.on)
|
||||
item.presets = self._filter_presets(item.presets)
|
||||
item.request.headers = [
|
||||
NotificationRequestHeader(key=header.key, value=header.value)
|
||||
for header in item.request.headers
|
||||
if header.key and header.value
|
||||
]
|
||||
return item
|
||||
|
||||
def _filter_events(self, events: list[str]) -> list[str]:
|
||||
if not events:
|
||||
return []
|
||||
|
||||
allowed = set(NotificationEvents.get_events().values())
|
||||
valid = [event for event in events if event in allowed]
|
||||
invalid = [event for event in events if event not in allowed]
|
||||
|
||||
if len(invalid) > 0 and len(valid) < 1:
|
||||
msg: str = f"Invalid notification target. Invalid events '{', '.join(invalid)}' found."
|
||||
raise ValueError(msg)
|
||||
|
||||
if len(invalid) > 0:
|
||||
LOG.warning("Dropping invalid notification events: %s", ", ".join(invalid))
|
||||
|
||||
return valid
|
||||
|
||||
def _filter_presets(self, presets: list[str]) -> list[str]:
|
||||
if not presets:
|
||||
return []
|
||||
|
||||
all_presets: list[Preset] = Presets.get_instance().get_all()
|
||||
allowed = {preset.name for preset in all_presets}
|
||||
valid = [preset for preset in presets if preset in allowed]
|
||||
invalid = [preset for preset in presets if preset not in allowed]
|
||||
|
||||
if len(invalid) > 0 and len(valid) < 1:
|
||||
msg: str = f"Invalid notification target. Invalid presets '{', '.join(invalid)}' found."
|
||||
raise ValueError(msg)
|
||||
|
||||
if len(invalid) > 0:
|
||||
LOG.warning("Dropping invalid notification presets: %s", ", ".join(invalid))
|
||||
|
||||
return valid
|
||||
|
||||
def model_to_schema(self, model: NotificationModel) -> Notification:
|
||||
headers: list[NotificationRequestHeader] = []
|
||||
if isinstance(model.request_headers, list):
|
||||
for header in model.request_headers:
|
||||
if not isinstance(header, dict):
|
||||
continue
|
||||
key = str(header.get("key", "")).strip()
|
||||
value = str(header.get("value", "")).strip()
|
||||
if key and value:
|
||||
headers.append(NotificationRequestHeader(key=key, value=value))
|
||||
|
||||
return Notification(
|
||||
id=model.id,
|
||||
name=model.name,
|
||||
on=list(model.on or []),
|
||||
presets=list(model.presets or []),
|
||||
enabled=model.enabled,
|
||||
request=NotificationRequest.model_validate(
|
||||
{
|
||||
"type": model.request_type,
|
||||
"method": model.request_method,
|
||||
"url": model.request_url,
|
||||
"headers": headers,
|
||||
"data_key": model.request_data_key,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
def _payload_from_schema(self, item: Notification) -> dict[str, Any]:
|
||||
return {
|
||||
"name": item.name,
|
||||
"on": list(item.on),
|
||||
"presets": list(item.presets),
|
||||
"enabled": item.enabled,
|
||||
"request_url": item.request.url,
|
||||
"request_method": str(item.request.method),
|
||||
"request_type": str(item.request.type),
|
||||
"request_data_key": item.request.data_key,
|
||||
"request_headers": [header.model_dump() for header in item.request.headers],
|
||||
}
|
||||
|
||||
def _check_preset(self, target: Notification, ev: Event) -> bool:
|
||||
if len(target.presets) < 1:
|
||||
return True
|
||||
|
||||
if not isinstance(ev.data, (Item, ItemDTO, dict)):
|
||||
return False
|
||||
|
||||
preset_name: str | None = None
|
||||
|
||||
if isinstance(ev.data, Item):
|
||||
preset_name = ev.data.preset
|
||||
|
||||
if isinstance(ev.data, ItemDTO):
|
||||
preset_name = ev.data.preset
|
||||
|
||||
if isinstance(ev.data, dict):
|
||||
preset_name = ev.data.get("preset", None)
|
||||
|
||||
if not preset_name:
|
||||
return False
|
||||
|
||||
return preset_name in target.presets
|
||||
|
||||
async def _apprise(self, targets: list[Notification], ev: Event) -> dict:
|
||||
if not targets:
|
||||
return {}
|
||||
|
||||
import apprise
|
||||
|
||||
try:
|
||||
notify = apprise.Apprise(debug=self._debug)
|
||||
apr_config = Path(Config.get_instance().apprise_config)
|
||||
if apr_config.exists():
|
||||
apprise_file = apprise.AppriseConfig()
|
||||
apprise_file.add(str(apr_config))
|
||||
notify.add(apprise_file)
|
||||
|
||||
for target in targets:
|
||||
notify.add(target.request.url)
|
||||
|
||||
status = await notify.async_notify(
|
||||
body=ev.message or self._encoder.encode(ev.serialize()),
|
||||
title=ev.title or f"YTPTube Event: {ev.event}",
|
||||
)
|
||||
|
||||
if not status:
|
||||
msg = "Apprise failed to send notification."
|
||||
raise RuntimeError(msg) # noqa: TRY301
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
LOG.error("Error sending Apprise notification: %s", exc)
|
||||
return {"error": str(exc), "event": ev.event, "id": ev.id, "targets": [t.name for t in targets]}
|
||||
|
||||
return {}
|
||||
|
||||
async def _send(self, target: Notification, ev: Event) -> dict:
|
||||
try:
|
||||
LOG.info("Sending notification event '%s: %s' to '%s'.", ev.event, ev.id, target.name)
|
||||
|
||||
headers: dict[str, str] = {
|
||||
"User-Agent": f"YTPTube/{self._version}",
|
||||
"X-Event-Id": ev.id,
|
||||
"X-Event": ev.event,
|
||||
"Content-Type": "application/json"
|
||||
if NotificationRequestType.JSON == target.request.type
|
||||
else "application/x-www-form-urlencoded",
|
||||
}
|
||||
|
||||
if len(target.request.headers) > 0:
|
||||
headers.update({h.key: h.value for h in target.request.headers if h.key and h.value})
|
||||
|
||||
payload_data: dict[str, Any] = ev.serialize()
|
||||
|
||||
if "data" != target.request.data_key:
|
||||
payload_data[target.request.data_key] = payload_data["data"]
|
||||
payload_data.pop("data", None)
|
||||
|
||||
if NotificationRequestType.FORM == target.request.type:
|
||||
payload_data[target.request.data_key] = self._encoder.encode(payload_data[target.request.data_key])
|
||||
data_payload: dict[str, Any] | None = payload_data
|
||||
content_payload: str | None = None
|
||||
else:
|
||||
data_payload = None
|
||||
content_payload = self._encoder.encode(payload_data)
|
||||
|
||||
response = await self._client.request(
|
||||
method=str(target.request.method).upper(),
|
||||
url=target.request.url,
|
||||
headers=headers,
|
||||
data=data_payload,
|
||||
content=content_payload,
|
||||
)
|
||||
|
||||
resp_data: dict[str, Any] = {
|
||||
"url": target.request.url,
|
||||
"status": response.status_code,
|
||||
"text": response.text,
|
||||
}
|
||||
|
||||
msg: str = (
|
||||
f"Notification target '{target.name}' Responded to event '{ev.event}: {ev.id}' "
|
||||
f"with status '{response.status_code}'."
|
||||
)
|
||||
if self._debug and resp_data.get("text"):
|
||||
msg += f" body '{resp_data.get('text', '??')}'."
|
||||
|
||||
LOG.info(msg)
|
||||
|
||||
return resp_data
|
||||
except Exception as exc:
|
||||
err_msg = str(exc) or type(exc).__name__
|
||||
tb = "".join(traceback.format_exception(type(exc), exc, exc.__traceback__))
|
||||
LOG.error(
|
||||
"Error sending notification event '%s: %s' to '%s'. '%s'. %s",
|
||||
ev.event,
|
||||
ev.id,
|
||||
target.name,
|
||||
err_msg,
|
||||
tb,
|
||||
)
|
||||
return {"url": target.request.url, "status": 500, "text": str(ev)}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
"""Tests for NotificationsRepository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from app.features.notifications.repository import NotificationsRepository
|
||||
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."""
|
||||
NotificationsRepository._reset_singleton()
|
||||
SqliteStore._reset_singleton()
|
||||
|
||||
store = SqliteStore(db_path=":memory:")
|
||||
await store.get_connection()
|
||||
|
||||
repository = NotificationsRepository.get_instance()
|
||||
|
||||
yield repository
|
||||
|
||||
if store._conn:
|
||||
await store._conn.close()
|
||||
if store._engine:
|
||||
await store._engine.dispose()
|
||||
|
||||
NotificationsRepository._reset_singleton()
|
||||
SqliteStore._reset_singleton()
|
||||
|
||||
|
||||
class TestNotificationsRepository:
|
||||
"""Test suite for NotificationsRepository database operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_empty(self, repo):
|
||||
"""List returns empty when no notifications exist."""
|
||||
notifications = await repo.list()
|
||||
assert notifications == [], "Should return empty list when no notifications exist"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_notification(self, repo):
|
||||
"""Create notification with valid data."""
|
||||
payload = {
|
||||
"name": "Webhook",
|
||||
"on": ["item_completed"],
|
||||
"presets": [],
|
||||
"enabled": True,
|
||||
"request_url": "https://example.com/webhook",
|
||||
"request_method": "POST",
|
||||
"request_type": "json",
|
||||
"request_data_key": "data",
|
||||
"request_headers": [{"key": "Authorization", "value": "Bearer token"}],
|
||||
}
|
||||
|
||||
model = await repo.create(payload)
|
||||
|
||||
assert model.id is not None, "Should generate ID for new notification"
|
||||
assert model.name == "Webhook", "Should store name correctly"
|
||||
assert model.request_url == "https://example.com/webhook", "Should store request url"
|
||||
assert model.request_method == "POST", "Should store request method"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_id(self, repo):
|
||||
"""Get notification by integer ID."""
|
||||
created = await repo.create(
|
||||
{
|
||||
"name": "Get Test",
|
||||
"on": [],
|
||||
"presets": [],
|
||||
"enabled": True,
|
||||
"request_url": "https://example.com",
|
||||
"request_method": "POST",
|
||||
"request_type": "json",
|
||||
"request_data_key": "data",
|
||||
"request_headers": [],
|
||||
}
|
||||
)
|
||||
|
||||
retrieved = await repo.get(created.id)
|
||||
|
||||
assert retrieved is not None, "Should retrieve created notification"
|
||||
assert retrieved.id == created.id, "Should match created notification id"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_notification(self, repo):
|
||||
"""Update existing notification."""
|
||||
created = await repo.create(
|
||||
{
|
||||
"name": "Update Test",
|
||||
"on": [],
|
||||
"presets": [],
|
||||
"enabled": True,
|
||||
"request_url": "https://example.com",
|
||||
"request_method": "POST",
|
||||
"request_type": "json",
|
||||
"request_data_key": "data",
|
||||
"request_headers": [],
|
||||
}
|
||||
)
|
||||
|
||||
updated = await repo.update(created.id, {"name": "Updated Name", "enabled": False})
|
||||
|
||||
assert updated.name == "Updated Name", "Should update name"
|
||||
assert updated.enabled is False, "Should update enabled flag"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_notification(self, repo):
|
||||
"""Delete existing notification."""
|
||||
created = await repo.create(
|
||||
{
|
||||
"name": "Delete Test",
|
||||
"on": [],
|
||||
"presets": [],
|
||||
"enabled": True,
|
||||
"request_url": "https://example.com",
|
||||
"request_method": "POST",
|
||||
"request_type": "json",
|
||||
"request_data_key": "data",
|
||||
"request_headers": [],
|
||||
}
|
||||
)
|
||||
|
||||
deleted = await repo.delete(created.id)
|
||||
|
||||
assert deleted.id == created.id, "Should return deleted notification"
|
||||
assert await repo.get(created.id) is None, "Deleted notification should not be retrievable"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_paginated(self, repo):
|
||||
"""List paginated returns correct subset."""
|
||||
for i in range(4):
|
||||
await repo.create(
|
||||
{
|
||||
"name": f"Item {i}",
|
||||
"on": [],
|
||||
"presets": [],
|
||||
"enabled": True,
|
||||
"request_url": "https://example.com",
|
||||
"request_method": "POST",
|
||||
"request_type": "json",
|
||||
"request_data_key": "data",
|
||||
"request_headers": [],
|
||||
}
|
||||
)
|
||||
|
||||
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 == 4, "Should report total count of 4"
|
||||
assert page == 1, "Should be on page 1"
|
||||
assert total_pages == 2, "Should have 2 pages total"
|
||||
1
app/features/presets/__init__.py
Normal file
1
app/features/presets/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Presets feature module."""
|
||||
94
app/features/presets/defaults.py
Normal file
94
app/features/presets/defaults.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
from datetime import UTC, datetime
|
||||
|
||||
DEFAULT_PRESET_UPDATED_AT: datetime = datetime(2026, 1, 25, tzinfo=UTC)
|
||||
|
||||
DEFAULT_PRESETS: list[dict[str, object]] = [
|
||||
{
|
||||
"name": "default",
|
||||
"default": True,
|
||||
"cli": "--socket-timeout 30 --download-archive %(archive_file)s",
|
||||
"description": "Default preset for yt-dlp. It will download whatever yt-dlp decides is the best quality for the video and audio.",
|
||||
"folder": "",
|
||||
"template": "",
|
||||
"cookies": "",
|
||||
"priority": 0,
|
||||
"updated_at": DEFAULT_PRESET_UPDATED_AT,
|
||||
},
|
||||
{
|
||||
"name": "mobile",
|
||||
"cli": '--socket-timeout 30 --download-archive %(archive_file)s\n-t mp4 --merge-output-format mp4 --add-chapters --remux-video mp4 \n--embed-metadata --embed-thumbnail \n--postprocessor-args "-movflags +faststart"',
|
||||
"default": True,
|
||||
"description": "This preset is designed for mobile devices. It will download the best quality video and audio in mp4 format, merge them, and add chapters, metadata, and thumbnail.",
|
||||
"folder": "",
|
||||
"template": "",
|
||||
"cookies": "",
|
||||
"priority": 0,
|
||||
"updated_at": DEFAULT_PRESET_UPDATED_AT,
|
||||
},
|
||||
{
|
||||
"name": "1080p",
|
||||
"cli": "--socket-timeout 30 --download-archive %(archive_file)s\n-S vcodec:h264 --format 'bv[height<=1080][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]'",
|
||||
"default": True,
|
||||
"description": "Download the best quality video and audio in mp4 format for 1080p resolution.",
|
||||
"folder": "",
|
||||
"template": "",
|
||||
"cookies": "",
|
||||
"priority": 0,
|
||||
"updated_at": DEFAULT_PRESET_UPDATED_AT,
|
||||
},
|
||||
{
|
||||
"name": "720p",
|
||||
"cli": "--socket-timeout 30 --download-archive %(archive_file)s\n-S vcodec:h264 --format 'bv[height<=720][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]'",
|
||||
"default": True,
|
||||
"description": "Download the best quality video and audio in mp4 format for 720p resolution.",
|
||||
"folder": "",
|
||||
"template": "",
|
||||
"cookies": "",
|
||||
"priority": 0,
|
||||
"updated_at": DEFAULT_PRESET_UPDATED_AT,
|
||||
},
|
||||
{
|
||||
"name": "audio_only",
|
||||
"cli": "--socket-timeout 30 --download-archive %(archive_file)s\n--extract-audio --add-chapters --embed-metadata --embed-thumbnail --format 'bestaudio/best'",
|
||||
"default": True,
|
||||
"description": "This preset is designed to download only the audio of the video. It will extract the audio, add chapters, metadata, and thumbnail.",
|
||||
"folder": "",
|
||||
"template": "",
|
||||
"cookies": "",
|
||||
"priority": 0,
|
||||
"updated_at": DEFAULT_PRESET_UPDATED_AT,
|
||||
},
|
||||
{
|
||||
"name": "info_reader_plugin",
|
||||
"description": 'This preset generate specific filename format and metadata to work with yt-dlp info reader plugins for jellyfin/emby and plex and to make play state sync work for WatchState.\n\nThere is one more step you need to do via Other > Terminal if you have it enabled or directly from container shell\n\nyt-dlp -I0 --write-info-json --write-thumbnail --convert-thumbnails jpg --paths /downloads/youtube -o "%(channel|Unknown_title)s [%(channel_id|Unknown_id)s]/%(title).180B [%(channel_id|Unknown_id)s].%(ext)s" -- https://www.youtube.com/channel/UCClfFsWcT3N2I7VTXXyt84A\n\nChange the url to the channel you want to download.\n\nFor more information please visit \nhttps://github.com/arabcoders/watchstate/blob/master/FAQ.md#how-to-get-watchstate-working-with-youtube-contentlibrary',
|
||||
"folder": "",
|
||||
"template": "%(channel)s [%(channel_id|Unknown_id)s]/Season %(release_date>%Y,upload_date>%Y|Unknown)s/%(release_date>%Y%m%d,upload_date>%Y%m%d)s - %(title).180B [%(id)s].%(ext)s",
|
||||
"cli": "--socket-timeout 30 --download-archive %(archive_file)s\n--windows-filenames --write-info-json --embed-info-json \n--convert-thumbnails jpg --write-thumbnail --embed-metadata",
|
||||
"cookies": "",
|
||||
"default": True,
|
||||
"priority": 0,
|
||||
"updated_at": DEFAULT_PRESET_UPDATED_AT,
|
||||
},
|
||||
{
|
||||
"name": "nfo_maker_tv",
|
||||
"description": "This preset generate specific filename format and metadata to work with media servers like jellyfin/emby and plex for episodes.",
|
||||
"folder": "",
|
||||
"template": "%(channel)s [%(channel_id|Unknown_id)s]/Season %(release_date>%Y,upload_date>%Y|Unknown)s/S%(release_date>%Y,upload_date>%Y)sE%(release_date>%m%d,upload_date>%m%d)s - %(title).100B [%(id)s].%(ext)s",
|
||||
"cli": '--socket-timeout 30 --download-archive %(archive_file)s\n--use-postprocessor "NFOMakerPP:when=after_move" \n--windows-filenames --convert-thumbnails jpg --write-thumbnail',
|
||||
"cookies": "",
|
||||
"default": True,
|
||||
"priority": 0,
|
||||
"updated_at": DEFAULT_PRESET_UPDATED_AT,
|
||||
},
|
||||
{
|
||||
"name": "nfo_maker_movie",
|
||||
"description": "This preset generate specific filename format and metadata to work with media servers like jellyfin/emby and plex for movies.",
|
||||
"folder": "",
|
||||
"template": "%(channel)s [%(channel_id|Unknown_id)s]/Season %(release_date>%Y,upload_date>%Y|Unknown)s/S%(release_date>%Y,upload_date>%Y)sE%(release_date>%m%d,upload_date>%m%d)s - %(title).100B [%(id)s].%(ext)s",
|
||||
"cli": '--socket-timeout 30 --download-archive %(archive_file)s\n--use-postprocessor "NFOMakerPP:when=after_move;mode=movie" \n--windows-filenames --convert-thumbnails jpg --write-thumbnail',
|
||||
"cookies": "",
|
||||
"default": True,
|
||||
"priority": 0,
|
||||
"updated_at": DEFAULT_PRESET_UPDATED_AT,
|
||||
},
|
||||
]
|
||||
10
app/features/presets/deps.py
Normal file
10
app/features/presets/deps.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from app.features.presets.repository import PresetsRepository
|
||||
from app.features.presets.service import Presets
|
||||
|
||||
|
||||
def get_presets_repo() -> PresetsRepository:
|
||||
return PresetsRepository.get_instance()
|
||||
|
||||
|
||||
def get_presets_service() -> Presets:
|
||||
return Presets.get_instance()
|
||||
112
app/features/presets/migration.py
Normal file
112
app/features/presets/migration.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.features.core.migration import Migration as FeatureMigration
|
||||
from app.features.presets.schemas import Preset
|
||||
from app.features.presets.utils import preset_name
|
||||
from app.library.config import Config
|
||||
from app.library.Utils import arg_converter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.features.presets.repository import PresetsRepository
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Migration(FeatureMigration):
|
||||
name: str = "presets"
|
||||
|
||||
def __init__(self, repo: PresetsRepository, config: Config | None = None) -> None:
|
||||
self._config: Config = config or Config.get_instance()
|
||||
super().__init__(config=self._config)
|
||||
self._repo: PresetsRepository = repo
|
||||
self._source_file: Path = Path(self._config.config_path) / "presets.json"
|
||||
|
||||
async def should_run(self) -> bool:
|
||||
return self._source_file.exists()
|
||||
|
||||
async def migrate(self) -> None:
|
||||
try:
|
||||
items: list[dict[str, Any]] | None = json.loads(self._source_file.read_text())
|
||||
except Exception as exc:
|
||||
LOG.exception("Failed to read %s: %s. Ignoring", self._source_file, exc)
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
if not items:
|
||||
LOG.warning("No presets found in %s; skipping migration.", self._source_file)
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
inserted = 0
|
||||
seen_names: dict[str, int] = {preset_name(preset.name): 1 for preset in await self._repo.list()}
|
||||
|
||||
for index, item in enumerate(items):
|
||||
if not (normalized := self._normalize(item, index, seen_names)):
|
||||
continue
|
||||
try:
|
||||
await self._repo.create(normalized)
|
||||
inserted += 1
|
||||
except Exception as exc:
|
||||
LOG.exception("Failed to insert preset '%s': %s", normalized["name"], exc)
|
||||
|
||||
LOG.info("Migrated %s preset(s) from %s.", inserted, self._source_file)
|
||||
await self._move_file(self._source_file)
|
||||
|
||||
def _normalize(self, item: Any, index: int, seen_names: dict[str, int]) -> dict[str, Any] | None:
|
||||
if not isinstance(item, dict):
|
||||
LOG.warning("Skipping preset 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 preset at index %s due to missing name.", index)
|
||||
return None
|
||||
|
||||
if not (name := preset_name(name)):
|
||||
LOG.warning("Skipping preset at index %s due to empty name.", index)
|
||||
return None
|
||||
|
||||
name = self._unique_name(name, seen_names)
|
||||
|
||||
description: str = item.get("description") if isinstance(item.get("description"), str) else ""
|
||||
folder: str = item.get("folder") if isinstance(item.get("folder"), str) else ""
|
||||
template: str = item.get("template") if isinstance(item.get("template"), str) else ""
|
||||
cookies: str = item.get("cookies") if isinstance(item.get("cookies"), str) else ""
|
||||
cli: str = item.get("cli") if isinstance(item.get("cli"), str) else ""
|
||||
priority = 0
|
||||
if isinstance(item.get("priority"), int) and not isinstance(item.get("priority"), bool):
|
||||
priority = int(item.get("priority"))
|
||||
|
||||
if not cli and isinstance(item.get("format"), str):
|
||||
cli = f"--format {item.get('format')}"
|
||||
|
||||
if cli:
|
||||
try:
|
||||
arg_converter(args=cli, level=True)
|
||||
except Exception as exc:
|
||||
LOG.warning("Skipping preset '%s' due to invalid CLI: %s", name, exc)
|
||||
return None
|
||||
|
||||
payload = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"folder": folder,
|
||||
"template": template,
|
||||
"cookies": cookies,
|
||||
"cli": cli,
|
||||
"default": False,
|
||||
"priority": priority,
|
||||
}
|
||||
|
||||
try:
|
||||
Preset.model_validate(payload)
|
||||
except Exception as exc:
|
||||
LOG.warning("Skipping preset '%s' due to validation error: %s", name, exc)
|
||||
return None
|
||||
|
||||
return payload
|
||||
27
app/features/presets/models.py
Normal file
27
app/features/presets/models.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, Index, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.features.core.models import Base, UTCDateTime, utcnow
|
||||
|
||||
|
||||
class PresetModel(Base):
|
||||
__tablename__: str = "presets"
|
||||
__table_args__: tuple[Index, ...] = (
|
||||
Index("ix_presets_name", "name"),
|
||||
Index("ix_presets_is_default", "is_default"),
|
||||
Index("ix_presets_priority", "priority"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
folder: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
template: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
cookies: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
cli: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
default: Mapped[bool] = mapped_column("is_default", Boolean, nullable=False, default=False)
|
||||
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, onupdate=utcnow, nullable=False)
|
||||
207
app/features/presets/repository.py
Normal file
207
app/features/presets/repository.py
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
|
||||
from app.features.core.deps import get_session
|
||||
from app.features.core.schemas import CEFeature, ConfigEvent
|
||||
from app.features.presets.migration import Migration
|
||||
from app.features.presets.models import PresetModel
|
||||
from app.features.presets.utils import preset_name, seed_defaults
|
||||
from app.library.config import Config
|
||||
from app.library.Events import Event, EventBus, Events
|
||||
from app.library.Services import Services
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from aiohttp import web
|
||||
from sqlalchemy.engine.result import Result
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
from sqlalchemy.sql.selectable import Select
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PresetsRepository(metaclass=Singleton):
|
||||
def __init__(self, session: AsyncGenerator[AsyncSession] | None = None) -> None:
|
||||
self._migrated = False
|
||||
self.session: AsyncGenerator[AsyncSession] = session or get_session
|
||||
|
||||
async def run_migrations(self) -> None:
|
||||
if self._migrated:
|
||||
return
|
||||
|
||||
self._migrated = True
|
||||
await Migration(repo=self).run()
|
||||
|
||||
def attach(self, _: web.Application) -> None:
|
||||
async def handle_event(_, __):
|
||||
await seed_defaults(self)
|
||||
await self.run_migrations()
|
||||
await self._update_cache()
|
||||
await self._ensure_default_preset()
|
||||
|
||||
async def handler(e: Event, __):
|
||||
if isinstance(e.data, ConfigEvent) and CEFeature.PRESETS == e.data.feature:
|
||||
LOG.debug("Refreshing presets cache due to configuration update.")
|
||||
await self._update_cache()
|
||||
|
||||
Services.get_instance().add(__class__.__name__, self)
|
||||
EventBus.get_instance().subscribe(
|
||||
Events.STARTED, handle_event, f"{__class__.__name__}.run_migrations"
|
||||
).subscribe(Events.CONFIG_UPDATE, handler, "Presets.refresh_cache")
|
||||
|
||||
async def _update_cache(self) -> None:
|
||||
from app.features.presets.service import Presets
|
||||
|
||||
await Presets.get_instance().refresh_cache(await self.list())
|
||||
|
||||
@staticmethod
|
||||
def get_instance() -> PresetsRepository:
|
||||
return PresetsRepository()
|
||||
|
||||
async def _ensure_default_preset(self) -> None:
|
||||
config = Config.get_instance()
|
||||
if not (default_name := preset_name(config.default_preset)):
|
||||
config.default_preset = "default"
|
||||
default_name = config.default_preset
|
||||
|
||||
if await self.get_by_name(default_name) is None:
|
||||
LOG.error("Default preset '%s' not found, using 'default' preset.", default_name)
|
||||
config.default_preset = "default"
|
||||
|
||||
async def list(self) -> list[PresetModel]:
|
||||
async with self.session() as session:
|
||||
result: Result[tuple[PresetModel]] = await session.execute(
|
||||
select(PresetModel).order_by(PresetModel.priority.desc(), PresetModel.name.asc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_paginated(self, page: int, per_page: int) -> tuple[list[PresetModel], 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[PresetModel]] = (
|
||||
select(PresetModel)
|
||||
.order_by(PresetModel.priority.desc(), PresetModel.name.asc())
|
||||
.limit(per_page)
|
||||
.offset((page - 1) * per_page)
|
||||
)
|
||||
result: Result[tuple[PresetModel]] = 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(PresetModel))
|
||||
return int(result.scalar_one())
|
||||
|
||||
async def get(self, identifier: int | str) -> PresetModel | None:
|
||||
async with self.session() as session:
|
||||
if not identifier:
|
||||
return None
|
||||
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = PresetModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause = or_(PresetModel.id == int(identifier), PresetModel.name == identifier)
|
||||
else:
|
||||
name = preset_name(identifier) if isinstance(identifier, str) else identifier
|
||||
if not name:
|
||||
return None
|
||||
clause = PresetModel.name == name
|
||||
|
||||
result: Result[tuple[PresetModel]] = await session.execute(select(PresetModel).where(clause).limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_name(self, name: str, exclude_id: int | None = None) -> PresetModel | None:
|
||||
async with self.session() as session:
|
||||
if not (name := preset_name(name)):
|
||||
return None
|
||||
|
||||
query: Select[tuple[PresetModel]] = select(PresetModel).where(PresetModel.name == name)
|
||||
if None is not exclude_id:
|
||||
query = query.where(PresetModel.id != exclude_id)
|
||||
|
||||
result: Result[tuple[PresetModel]] = await session.execute(query.limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(self, payload: PresetModel | dict) -> PresetModel:
|
||||
async with self.session() as session:
|
||||
model: PresetModel = PresetModel(**payload) if isinstance(payload, dict) else payload
|
||||
if model.id is not None:
|
||||
model.id = None
|
||||
|
||||
model.name = preset_name(model.name)
|
||||
|
||||
if await self.get_by_name(name=model.name) is not None:
|
||||
msg: str = f"Preset 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]) -> PresetModel:
|
||||
async with self.session() as session:
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = PresetModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause = or_(PresetModel.id == int(identifier), PresetModel.name == identifier)
|
||||
else:
|
||||
clause = PresetModel.name == identifier
|
||||
|
||||
result: Result[tuple[PresetModel]] = await session.execute(select(PresetModel).where(clause).limit(1))
|
||||
model: PresetModel | None = result.scalar_one_or_none()
|
||||
|
||||
if None is model:
|
||||
msg: str = f"Preset '{identifier}' not found."
|
||||
raise KeyError(msg)
|
||||
|
||||
assert None is not model
|
||||
|
||||
payload.pop("id", None)
|
||||
payload.pop("created_at", None)
|
||||
payload.pop("updated_at", None)
|
||||
|
||||
for key, value in payload.items():
|
||||
if hasattr(model, key):
|
||||
setattr(model, key, value)
|
||||
|
||||
model.name = preset_name(model.name)
|
||||
if await self.get_by_name(name=model.name, exclude_id=model.id) is not None:
|
||||
msg = f"Preset with name '{model.name}' already exists."
|
||||
raise ValueError(msg)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(model)
|
||||
return model
|
||||
|
||||
async def delete(self, identifier: int | str) -> PresetModel:
|
||||
async with self.session() as session:
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = PresetModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause = or_(PresetModel.id == int(identifier), PresetModel.name == identifier)
|
||||
else:
|
||||
clause = PresetModel.name == preset_name(identifier)
|
||||
|
||||
result: Result[tuple[PresetModel]] = await session.execute(select(PresetModel).where(clause).limit(1))
|
||||
if not (model := result.scalar_one_or_none()):
|
||||
msg: str = f"Preset '{identifier}' not found."
|
||||
raise KeyError(msg)
|
||||
|
||||
assert None is not model
|
||||
|
||||
await session.delete(model)
|
||||
await session.commit()
|
||||
return model
|
||||
190
app/features/presets/router.py
Normal file
190
app/features/presets/router.py
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.features.core.schemas import CEAction, CEFeature, ConfigEvent, Pagination
|
||||
from app.features.core.utils import build_pagination, format_validation_errors, normalize_pagination
|
||||
from app.features.presets.repository import PresetsRepository
|
||||
from app.features.presets.schemas import Preset, PresetList, PresetPatch
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.router import route
|
||||
|
||||
|
||||
def _model(model: Any) -> Preset:
|
||||
return Preset.model_validate(model)
|
||||
|
||||
|
||||
def _serialize(model: Any) -> dict:
|
||||
return _model(model).model_dump()
|
||||
|
||||
|
||||
@route("GET", "api/presets/", "presets")
|
||||
async def presets_list(request: Request, encoder: Encoder, repo: PresetsRepository) -> Response:
|
||||
page, per_page = normalize_pagination(request)
|
||||
items, total, current_page, total_pages = await repo.list_paginated(page, per_page)
|
||||
return web.json_response(
|
||||
data=PresetList(
|
||||
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("GET", r"api/presets/{id:\d+}", "presets_get")
|
||||
async def presets_get(request: Request, encoder: Encoder, repo: PresetsRepository) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not (model := await repo.get(identifier)):
|
||||
return web.json_response({"error": "Preset not found"}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
return web.json_response(data=_serialize(model), status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("POST", "api/presets/", "presets_add")
|
||||
async def presets_add(request: Request, encoder: Encoder, notify: EventBus, repo: PresetsRepository) -> Response:
|
||||
data = await request.json()
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting dict."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
item: Preset = Preset.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate preset.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
payload = item.model_dump(exclude_unset=True)
|
||||
payload.pop("id", None)
|
||||
payload["default"] = False
|
||||
|
||||
try:
|
||||
saved = _serialize(await repo.create(payload))
|
||||
except ValueError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE,
|
||||
data=ConfigEvent(feature=CEFeature.PRESETS, action=CEAction.CREATE, data=saved),
|
||||
)
|
||||
return web.json_response(data=saved, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("PATCH", r"api/presets/{id:\d+}", "presets_patch")
|
||||
async def presets_patch(request: Request, encoder: Encoder, notify: EventBus, repo: PresetsRepository) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not (model := await repo.get(identifier)):
|
||||
return web.json_response({"error": "Preset not found"}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
if model.default:
|
||||
return web.json_response(
|
||||
{"error": "Default presets cannot be modified."}, status=web.HTTPBadRequest.status_code
|
||||
)
|
||||
|
||||
data = await request.json()
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting dict."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
validated = PresetPatch.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate preset.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if validated.name and await repo.get_by_name(validated.name, exclude_id=model.id):
|
||||
return web.json_response(
|
||||
data={"error": f"Preset with name '{validated.name}' already exists."},
|
||||
status=web.HTTPConflict.status_code,
|
||||
)
|
||||
|
||||
payload = validated.model_dump(exclude_unset=True)
|
||||
payload.pop("default", None)
|
||||
updated = _serialize(await repo.update(model.id, payload))
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE,
|
||||
data=ConfigEvent(feature=CEFeature.PRESETS, action=CEAction.UPDATE, data=updated),
|
||||
)
|
||||
return web.json_response(data=updated, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("PUT", r"api/presets/{id:\d+}", "presets_update")
|
||||
async def presets_update(request: Request, encoder: Encoder, notify: EventBus, repo: PresetsRepository) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not (model := await repo.get(identifier)):
|
||||
return web.json_response({"error": "Preset not found"}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
if model.default:
|
||||
return web.json_response(
|
||||
{"error": "Default presets cannot be modified."}, status=web.HTTPBadRequest.status_code
|
||||
)
|
||||
|
||||
data = await request.json()
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting dict."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
validated = Preset.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate preset.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if validated.name and await repo.get_by_name(validated.name, exclude_id=model.id):
|
||||
return web.json_response(
|
||||
data={"error": f"Preset with name '{validated.name}' already exists."},
|
||||
status=web.HTTPConflict.status_code,
|
||||
)
|
||||
|
||||
payload = validated.model_dump(exclude_unset=True)
|
||||
payload.pop("default", None)
|
||||
payload.pop("id", None)
|
||||
updated = _serialize(await repo.update(model.id, payload))
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE,
|
||||
data=ConfigEvent(feature=CEFeature.PRESETS, action=CEAction.UPDATE, data=updated),
|
||||
)
|
||||
return web.json_response(data=updated, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("DELETE", r"api/presets/{id:\d+}", "presets_delete")
|
||||
async def presets_delete(request: Request, encoder: Encoder, notify: EventBus, repo: PresetsRepository) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not (model := await repo.get(identifier)):
|
||||
return web.json_response({"error": "Preset not found"}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
if model.default:
|
||||
return web.json_response({"error": "Default presets cannot be deleted."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
deleted = _serialize(await repo.delete(model.id))
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE,
|
||||
data=ConfigEvent(feature=CEFeature.PRESETS, action=CEAction.DELETE, data=deleted),
|
||||
)
|
||||
return web.json_response(data=deleted, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
95
app/features/presets/schemas.py
Normal file
95
app/features/presets/schemas.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator
|
||||
|
||||
from app.features.core.schemas import Pagination
|
||||
from app.features.core.utils import parse_int
|
||||
from app.features.presets.utils import preset_name
|
||||
from app.library.config import Config
|
||||
from app.library.Utils import arg_converter, create_cookies_file
|
||||
|
||||
|
||||
class Preset(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, from_attributes=True)
|
||||
|
||||
id: int | None = None
|
||||
name: str = Field(min_length=1)
|
||||
description: str = ""
|
||||
folder: str = ""
|
||||
template: str = ""
|
||||
cookies: str = ""
|
||||
cli: str = ""
|
||||
default: bool = False
|
||||
priority: int = 0
|
||||
|
||||
_cookies_file: Path | None = PrivateAttr(default=None)
|
||||
|
||||
@field_validator("name", mode="before")
|
||||
@classmethod
|
||||
def _normalize_name(cls, value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
msg: str = "Name must be a string."
|
||||
raise ValueError(msg)
|
||||
|
||||
if not (value := preset_name(value)):
|
||||
msg = "Name cannot be empty."
|
||||
raise ValueError(msg)
|
||||
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("cli", mode="before")
|
||||
@classmethod
|
||||
def _validate_cli(cls, value: Any) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
if not isinstance(value, str):
|
||||
msg: str = "CLI must be a string."
|
||||
raise ValueError(msg)
|
||||
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
try:
|
||||
arg_converter(args=value, level=True)
|
||||
except Exception as e:
|
||||
msg = f"Invalid command options for yt-dlp: {e!s}"
|
||||
raise ValueError(msg) from e
|
||||
return value
|
||||
|
||||
def get_cookies_file(self, config: Config | None = None) -> Path | None:
|
||||
if None is not self._cookies_file:
|
||||
return self._cookies_file
|
||||
|
||||
if not self.cookies or self.id is None:
|
||||
return None
|
||||
|
||||
config = config or Config.get_instance()
|
||||
self._cookies_file = create_cookies_file(self.cookies, Path(config.config_path) / "cookies" / f"{self.id}.txt")
|
||||
return self._cookies_file
|
||||
|
||||
|
||||
class PresetPatch(Preset):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, from_attributes=True)
|
||||
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
folder: str | None = None
|
||||
template: str | None = None
|
||||
cookies: str | None = None
|
||||
cli: str | None = None
|
||||
priority: int | None = None
|
||||
|
||||
|
||||
class PresetList(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
items: list[Preset] = Field(default_factory=list)
|
||||
pagination: Pagination
|
||||
44
app/features/presets/service.py
Normal file
44
app/features/presets/service.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from app.features.presets.models import PresetModel
|
||||
from app.features.presets.repository import PresetsRepository
|
||||
from app.features.presets.schemas import Preset
|
||||
from app.features.presets.utils import preset_name
|
||||
from app.library.Services import Services
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
|
||||
class Presets(metaclass=Singleton):
|
||||
def __init__(self, repo: PresetsRepository | None = None) -> None:
|
||||
self._repo: PresetsRepository = repo or PresetsRepository.get_instance()
|
||||
self._cache: list[tuple[int, str, Preset]] = []
|
||||
Services.get_instance().add(__class__.__name__, self)
|
||||
|
||||
@staticmethod
|
||||
def get_instance() -> Presets:
|
||||
return Presets()
|
||||
|
||||
async def refresh_cache(self, items: list[PresetModel]) -> None:
|
||||
presets = [Preset.model_validate(item) for item in items]
|
||||
self._cache = [(preset.id if preset.id is not None else -1, preset.name, preset) for preset in presets]
|
||||
|
||||
def get_all(self) -> list[Preset]:
|
||||
return [preset for _, _, preset in self._cache]
|
||||
|
||||
def get(self, identifier: int | str) -> Preset | None:
|
||||
if not identifier:
|
||||
return None
|
||||
|
||||
if isinstance(identifier, str) and not (identifier := preset_name(identifier)):
|
||||
return None
|
||||
|
||||
for id, name, preset in self._cache:
|
||||
if isinstance(identifier, int) and id == identifier:
|
||||
return preset
|
||||
if isinstance(identifier, str) and name == identifier:
|
||||
return preset
|
||||
|
||||
return None
|
||||
|
||||
def has(self, identifier: int | str) -> bool:
|
||||
return self.get(identifier) is not None
|
||||
71
app/features/presets/tests/test_presets_repository.py
Normal file
71
app/features/presets/tests/test_presets_repository.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from app.features.presets.repository import PresetsRepository
|
||||
from app.library.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def repo():
|
||||
PresetsRepository._reset_singleton()
|
||||
SqliteStore._reset_singleton()
|
||||
|
||||
store = SqliteStore(db_path=":memory:")
|
||||
await store.get_connection()
|
||||
|
||||
repository = PresetsRepository.get_instance()
|
||||
yield repository
|
||||
|
||||
if store._conn:
|
||||
await store._conn.close()
|
||||
if store._engine:
|
||||
await store._engine.dispose()
|
||||
|
||||
PresetsRepository._reset_singleton()
|
||||
SqliteStore._reset_singleton()
|
||||
|
||||
|
||||
class TestPresetsRepository:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_and_get(self, repo):
|
||||
preset = await repo.create({"name": "Custom", "cli": "--format best"})
|
||||
|
||||
assert preset.id is not None, "Should generate ID for new preset"
|
||||
assert preset.name == "custom", "Should normalize preset name"
|
||||
assert preset.cli == "--format best", "Should store preset cli"
|
||||
|
||||
fetched = await repo.get(preset.id)
|
||||
assert fetched is not None, "Should fetch preset by id"
|
||||
assert fetched.name == "custom", "Should return matching preset"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_normalizes_spaces(self, repo):
|
||||
preset = await repo.create({"name": "My Preset"})
|
||||
|
||||
assert preset.name == "my_preset", "Should normalize spaces to underscores"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_orders_by_priority_then_name(self, repo):
|
||||
await repo.create({"name": "B", "priority": 1})
|
||||
await repo.create({"name": "A", "priority": 1})
|
||||
await repo.create({"name": "C", "priority": 2})
|
||||
|
||||
items = await repo.list()
|
||||
|
||||
assert items[0].name == "c", "Highest priority should be first"
|
||||
assert items[1].name == "a", "Same priority should sort by name"
|
||||
assert items[2].name == "b", "Same priority should sort by name"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_paginated(self, repo):
|
||||
for i in range(5):
|
||||
await repo.create({"name": f"Item {i}", "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"
|
||||
assert page == 1, "Should be on page 1"
|
||||
assert total_pages == 3, "Should have 3 pages total"
|
||||
55
app/features/presets/utils.py
Normal file
55
app/features/presets/utils.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
|
||||
NAME_WHITESPACE_PATTERN = re.compile(r"\s+")
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def seed_defaults(repo) -> None:
|
||||
from .defaults import DEFAULT_PRESETS
|
||||
|
||||
for preset in DEFAULT_PRESETS:
|
||||
try:
|
||||
name = preset.get("name")
|
||||
if not name or not isinstance(name, str):
|
||||
LOG.warning("Skipping default preset with invalid name: %s", preset)
|
||||
continue
|
||||
|
||||
if not (name := preset_name(name)):
|
||||
LOG.warning("Skipping default preset with empty normalized name: %s", name)
|
||||
continue
|
||||
|
||||
payload = {**preset}
|
||||
payload.pop("id", None)
|
||||
payload["default"] = True
|
||||
payload["name"] = name
|
||||
|
||||
updated_at_value: datetime | None = None
|
||||
updated_at_raw = payload.get("updated_at")
|
||||
if isinstance(updated_at_raw, str):
|
||||
updated_at_value = datetime.fromisoformat(updated_at_raw)
|
||||
elif isinstance(updated_at_raw, datetime):
|
||||
updated_at_value = updated_at_raw
|
||||
if isinstance(updated_at_value, datetime) and updated_at_value.tzinfo is None:
|
||||
updated_at_value = updated_at_value.replace(tzinfo=UTC)
|
||||
payload["updated_at"] = updated_at_value
|
||||
|
||||
existing = await repo.get_by_name(name)
|
||||
if None is existing:
|
||||
await repo.create(payload)
|
||||
continue
|
||||
|
||||
if existing.updated_at and updated_at_value and existing.updated_at >= updated_at_value:
|
||||
if False is existing.default:
|
||||
await repo.update(existing.id, {"default": True})
|
||||
continue
|
||||
|
||||
await repo.update(existing.id, payload)
|
||||
except Exception as exc:
|
||||
LOG.exception("Failed to seed default preset '%s': %s", preset.get("name"), exc)
|
||||
|
||||
|
||||
def preset_name(value: str) -> str:
|
||||
return NAME_WHITESPACE_PATTERN.sub("_", value.strip().lower())
|
||||
1
app/features/tasks/__init__.py
Normal file
1
app/features/tasks/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Tasks Feature."""
|
||||
1
app/features/tasks/definitions/__init__.py
Normal file
1
app/features/tasks/definitions/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Tasks Definitions Feature."""
|
||||
5
app/features/tasks/definitions/deps.py
Normal file
5
app/features/tasks/definitions/deps.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from app.features.tasks.definitions.repository import TaskDefinitionsRepository
|
||||
|
||||
|
||||
def get_task_definitions_repo() -> TaskDefinitionsRepository:
|
||||
return TaskDefinitionsRepository.get_instance()
|
||||
1
app/features/tasks/definitions/handlers/__init__.py
Normal file
1
app/features/tasks/definitions/handlers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Handlers package for task definitions."""
|
||||
|
|
@ -4,22 +4,22 @@ from typing import Any
|
|||
import httpx
|
||||
from yt_dlp.utils.networking import random_user_agent
|
||||
|
||||
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskResult
|
||||
from app.library.config import Config
|
||||
from app.library.httpx_client import async_client
|
||||
from app.library.Tasks import Task, TaskFailure, TaskResult
|
||||
|
||||
|
||||
class BaseHandler:
|
||||
@staticmethod
|
||||
def can_handle(task: Task) -> bool:
|
||||
async def can_handle(task: HandleTask) -> bool:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def extract(task: Task, config: Config | None = None) -> TaskResult | TaskFailure:
|
||||
async def extract(task: HandleTask, config: Config | None = None) -> TaskResult | TaskFailure:
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
async def inspect(cls, task: Task, config: Config | None = None) -> TaskResult | TaskFailure:
|
||||
async def inspect(cls, task: HandleTask, config: Config | None = None) -> TaskResult | TaskFailure:
|
||||
return await cls.extract(task=task, config=config)
|
||||
|
||||
@staticmethod
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -4,9 +4,9 @@ import re
|
|||
from typing import TYPE_CHECKING, Any
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||
from app.library.cache import Cache
|
||||
from app.library.Tasks import Task, TaskFailure, TaskItem, TaskResult
|
||||
from app.library.Utils import extract_info, get_archive_id
|
||||
from app.library.Utils import fetch_info, get_archive_id
|
||||
|
||||
from ._base_handler import BaseHandler
|
||||
|
||||
|
|
@ -24,13 +24,13 @@ class RssGenericHandler(BaseHandler):
|
|||
)
|
||||
|
||||
@staticmethod
|
||||
def can_handle(task: Task) -> bool:
|
||||
async def can_handle(task: HandleTask) -> bool:
|
||||
LOG.debug(f"'{task.name}': Checking if task URL is parsable RSS feed: {task.url}")
|
||||
return RssGenericHandler.parse(task.url) is not None
|
||||
|
||||
@staticmethod
|
||||
async def _get(
|
||||
task: Task,
|
||||
task: HandleTask,
|
||||
params: dict,
|
||||
parsed: dict[str, str],
|
||||
) -> tuple[str, list[dict[str, str]], int]:
|
||||
|
|
@ -131,7 +131,7 @@ class RssGenericHandler(BaseHandler):
|
|||
return feed_url, items, real_count
|
||||
|
||||
@staticmethod
|
||||
async def extract(task: Task) -> TaskResult | TaskFailure:
|
||||
async def extract(task: HandleTask) -> TaskResult | TaskFailure:
|
||||
"""
|
||||
Extract items from an RSS/Atom feed.
|
||||
|
||||
|
|
@ -179,7 +179,7 @@ class RssGenericHandler(BaseHandler):
|
|||
"Doing real request to fetch yt-dlp archive ID."
|
||||
)
|
||||
|
||||
info = extract_info(
|
||||
info = await fetch_info(
|
||||
config=params,
|
||||
url=url,
|
||||
no_archive=True,
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import logging
|
||||
import re
|
||||
|
||||
from app.library.Tasks import Task, TaskFailure, TaskItem, TaskResult
|
||||
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||
from app.library.Utils import get_archive_id
|
||||
|
||||
from ._base_handler import BaseHandler
|
||||
|
|
@ -10,9 +10,9 @@ LOG: logging.Logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
class TverHandler(BaseHandler):
|
||||
SERIES_API = "https://platform-api.tver.jp/service/api/v1/callSeriesEpisodes/{id}"
|
||||
SESSION_API = "https://platform-api.tver.jp/v2/api/platform_users/browser/create"
|
||||
HEADERS = {
|
||||
SERIES_API: str = "https://platform-api.tver.jp/service/api/v1/callSeriesEpisodes/{id}"
|
||||
SESSION_API: str = "https://platform-api.tver.jp/v2/api/platform_users/browser/create"
|
||||
HEADERS: dict[str, str] = {
|
||||
"x-tver-platform-type": "web",
|
||||
"Origin": "https://tver.jp",
|
||||
"Referer": "https://tver.jp/",
|
||||
|
|
@ -21,7 +21,7 @@ class TverHandler(BaseHandler):
|
|||
RX: re.Pattern[str] = re.compile(r"^https?:\/\/(?:www\.|m\.)?tver\.jp\/series\/(?P<id>sr[a-z0-9_]+)$")
|
||||
|
||||
@staticmethod
|
||||
def can_handle(task: Task) -> bool:
|
||||
async def can_handle(task: HandleTask) -> bool:
|
||||
LOG.debug(f"Checking if task '{task.name}' is using parsable Tver series URL: {task.url}")
|
||||
return TverHandler.parse(task.url) is not None
|
||||
|
||||
|
|
@ -58,7 +58,7 @@ class TverHandler(BaseHandler):
|
|||
|
||||
@staticmethod
|
||||
async def _collect_feed(
|
||||
task: Task,
|
||||
task: HandleTask,
|
||||
params: dict,
|
||||
series_id: str,
|
||||
) -> tuple[str, list[dict[str, str]], bool]:
|
||||
|
|
@ -142,7 +142,7 @@ class TverHandler(BaseHandler):
|
|||
return feed_url, items, has_items
|
||||
|
||||
@staticmethod
|
||||
async def extract(task: Task) -> TaskResult | TaskFailure:
|
||||
async def extract(task: HandleTask) -> TaskResult | TaskFailure:
|
||||
series_id: str | None = TverHandler.parse(task.url)
|
||||
if not series_id:
|
||||
return TaskFailure(message="Unrecognized Tver series URL.")
|
||||
|
|
@ -3,7 +3,7 @@ import re
|
|||
from typing import TYPE_CHECKING
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
from app.library.Tasks import Task, TaskFailure, TaskItem, TaskResult
|
||||
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||
from app.library.Utils import get_archive_id
|
||||
|
||||
from ._base_handler import BaseHandler
|
||||
|
|
@ -15,18 +15,18 @@ LOG: logging.Logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
class TwitchHandler(BaseHandler):
|
||||
FEED = "https://twitchrss.appspot.com/vodonly/{handle}"
|
||||
FEED: str = "https://twitchrss.appspot.com/vodonly/{handle}"
|
||||
|
||||
RX: re.Pattern[str] = re.compile(r"^https?:\/\/(?:www\.|m\.)?twitch\.tv\/(?P<id>[a-z0-9_]{3,25})(?:\/.*)?$")
|
||||
|
||||
@staticmethod
|
||||
def can_handle(task: Task) -> bool:
|
||||
async def can_handle(task: HandleTask) -> bool:
|
||||
LOG.debug(f"Checking if task '{task.name}' is using parsable Twitch URL: {task.url}")
|
||||
return TwitchHandler.parse(task.url) is not None
|
||||
|
||||
@staticmethod
|
||||
async def _collect_feed(
|
||||
task: Task,
|
||||
task: HandleTask,
|
||||
params: dict,
|
||||
handle_name: str,
|
||||
) -> tuple[str, list[dict[str, str]], bool]:
|
||||
|
|
@ -74,7 +74,7 @@ class TwitchHandler(BaseHandler):
|
|||
return feed_url, items, has_items
|
||||
|
||||
@staticmethod
|
||||
async def extract(task: Task) -> TaskResult | TaskFailure:
|
||||
async def extract(task: HandleTask) -> TaskResult | TaskFailure:
|
||||
handle_name: str | None = TwitchHandler.parse(task.url)
|
||||
if not handle_name:
|
||||
return TaskFailure(message="Unrecognized Twitch channel URL.")
|
||||
|
|
@ -3,7 +3,7 @@ import re
|
|||
from typing import TYPE_CHECKING, Any
|
||||
from xml.etree.ElementTree import Element
|
||||
|
||||
from app.library.Tasks import Task, TaskFailure, TaskItem, TaskResult
|
||||
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||
from app.library.Utils import get_archive_id
|
||||
|
||||
from ._base_handler import BaseHandler
|
||||
|
|
@ -15,7 +15,7 @@ LOG: logging.Logger = logging.getLogger(__name__)
|
|||
|
||||
|
||||
class YoutubeHandler(BaseHandler):
|
||||
FEED = "https://www.youtube.com/feeds/videos.xml?{type}={id}"
|
||||
FEED: str = "https://www.youtube.com/feeds/videos.xml?{type}={id}"
|
||||
|
||||
CHANNEL_REGEX: re.Pattern[str] = re.compile(
|
||||
r"^https?://(?:www\.)?youtube\.com/(?:channel/(?P<id>UC[0-9A-Za-z_-]{22})|)/?$"
|
||||
|
|
@ -26,12 +26,12 @@ class YoutubeHandler(BaseHandler):
|
|||
)
|
||||
|
||||
@staticmethod
|
||||
def can_handle(task: Task) -> bool:
|
||||
async def can_handle(task: HandleTask) -> bool:
|
||||
LOG.debug(f"'{task.name}': Checking if task URL is parsable YouTube URL: {task.url}")
|
||||
return YoutubeHandler.parse(task.url) is not None
|
||||
|
||||
@staticmethod
|
||||
async def _get(task: Task, params: dict, parsed: dict[str, str]) -> tuple[str, list[dict[str, str]], int]:
|
||||
async def _get(task: HandleTask, params: dict, parsed: dict[str, str]) -> tuple[str, list[dict[str, str]], int]:
|
||||
"""
|
||||
Fetch the feed and return raw entries.
|
||||
|
||||
|
|
@ -89,7 +89,7 @@ class YoutubeHandler(BaseHandler):
|
|||
return feed_url, items, real_count
|
||||
|
||||
@staticmethod
|
||||
async def extract(task: Task) -> TaskResult | TaskFailure:
|
||||
async def extract(task: HandleTask) -> TaskResult | TaskFailure:
|
||||
parsed: dict[str, str] | None = YoutubeHandler.parse(task.url)
|
||||
if not parsed:
|
||||
return TaskFailure(message="Unrecognized YouTube channel or playlist URL.")
|
||||
126
app/features/tasks/definitions/migration.py
Normal file
126
app/features/tasks/definitions/migration.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.features.core.migration import Migration as FeatureMigration
|
||||
from app.library.config import Config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.features.tasks.definitions.repository import TaskDefinitionsRepository
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Migration(FeatureMigration):
|
||||
name: str = "task_definitions"
|
||||
|
||||
def __init__(self, repo: TaskDefinitionsRepository, config: Config | None = None) -> None:
|
||||
self._config: Config = config or Config.get_instance()
|
||||
super().__init__(config=self._config)
|
||||
self._repo: TaskDefinitionsRepository = repo
|
||||
self._source_dir: Path = Path(self._config.config_path) / "tasks"
|
||||
|
||||
async def should_run(self) -> bool:
|
||||
if not self._source_dir.exists():
|
||||
return False
|
||||
|
||||
return any(self._source_dir.glob("*.json"))
|
||||
|
||||
async def migrate(self) -> None:
|
||||
if await self._repo.count() > 0:
|
||||
LOG.warning("Task definitions already exist in the database; skipping migration.")
|
||||
await self._archive_sources()
|
||||
return
|
||||
|
||||
inserted = 0
|
||||
seen_names: dict[str, int] = {}
|
||||
|
||||
for path in sorted(self._source_dir.glob("*.json")):
|
||||
normalized = await self._normalize(path, seen_names)
|
||||
if not normalized:
|
||||
await self._move_file(path)
|
||||
continue
|
||||
|
||||
try:
|
||||
await self._repo.create(normalized)
|
||||
inserted += 1
|
||||
except Exception as exc:
|
||||
LOG.exception("Failed to insert task definition '%s': %s", normalized.get("name"), exc)
|
||||
finally:
|
||||
await self._move_file(path)
|
||||
|
||||
LOG.info("Migrated %s task definition(s) from %s.", inserted, self._source_dir)
|
||||
|
||||
async def _archive_sources(self) -> None:
|
||||
for path in self._source_dir.glob("*.json"):
|
||||
await self._move_file(path)
|
||||
|
||||
async def _normalize(self, path: Path, seen_names: dict[str, int]) -> dict[str, Any] | None:
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except Exception as exc:
|
||||
LOG.error("Failed to read task definition '%s': %s", path, exc)
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = json.loads(content)
|
||||
except Exception as exc:
|
||||
LOG.error("Failed to parse JSON for '%s': %s", path, exc)
|
||||
return None
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
LOG.error("Task definition in '%s' must be a JSON object.", path)
|
||||
return None
|
||||
|
||||
if "match" in payload and "match_url" not in payload:
|
||||
payload["match_url"] = payload.pop("match")
|
||||
|
||||
# Normalize match_url from old object format to new string format
|
||||
if "match_url" in payload and isinstance(payload["match_url"], list):
|
||||
normalized_match: list[str] = []
|
||||
for item in payload["match_url"]:
|
||||
if isinstance(item, str):
|
||||
normalized_match.append(item)
|
||||
elif isinstance(item, dict):
|
||||
if "regex" in item and isinstance(item["regex"], str):
|
||||
# Convert {regex: "pattern"} to /pattern/
|
||||
normalized_match.append(f"/{item['regex']}/")
|
||||
elif "glob" in item and isinstance(item["glob"], str):
|
||||
# Convert {glob: "pattern"} to pattern
|
||||
normalized_match.append(item["glob"])
|
||||
payload["match_url"] = normalized_match
|
||||
|
||||
# Rename request.json to request.json_data
|
||||
if "request" in payload and isinstance(payload["request"], dict) and "json" in payload["request"]:
|
||||
payload["request"]["json_data"] = payload["request"].pop("json")
|
||||
|
||||
if "definition" not in payload:
|
||||
definition_fields = {}
|
||||
for field in ["parse", "engine", "request", "response"]:
|
||||
if field in payload:
|
||||
definition_fields[field] = payload.pop(field)
|
||||
|
||||
if definition_fields:
|
||||
payload["definition"] = definition_fields
|
||||
# Also handle nested definition.request.json
|
||||
elif isinstance(payload["definition"], dict):
|
||||
if (
|
||||
"request" in payload["definition"]
|
||||
and isinstance(payload["definition"]["request"], dict)
|
||||
and "json" in payload["definition"]["request"]
|
||||
):
|
||||
payload["definition"]["request"]["json_data"] = payload["definition"]["request"].pop("json")
|
||||
|
||||
name_value = payload.get("name")
|
||||
if not isinstance(name_value, str) or not name_value.strip():
|
||||
LOG.error("Task definition in '%s' missing a valid name.", path)
|
||||
return None
|
||||
|
||||
name = self._unique_name(name_value.strip(), seen_names)
|
||||
payload["name"] = name
|
||||
|
||||
# Repository will handle validation and field extraction
|
||||
return payload
|
||||
27
app/features/tasks/definitions/models.py
Normal file
27
app/features/tasks/definitions/models.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime # noqa: TC003
|
||||
|
||||
from sqlalchemy import JSON, Boolean, Index, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.features.core.models import Base, UTCDateTime, utcnow
|
||||
|
||||
|
||||
class TaskDefinitionModel(Base):
|
||||
__tablename__: str = "task_definitions"
|
||||
__table_args__: tuple[Index, ...] = (
|
||||
Index("ix_task_definitions_name", "name"),
|
||||
Index("ix_task_definitions_priority", "priority"),
|
||||
Index("ix_task_definitions_match_url", "match_url"),
|
||||
Index("ix_task_definitions_enabled", "enabled"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
priority: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
match_url: Mapped[list] = mapped_column(JSON, nullable=False)
|
||||
definition: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, onupdate=utcnow, nullable=False)
|
||||
184
app/features/tasks/definitions/repository.py
Normal file
184
app/features/tasks/definitions/repository.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
|
||||
from app.features.core.deps import get_session
|
||||
from app.features.core.schemas import CEFeature, ConfigEvent
|
||||
from app.features.tasks.definitions.migration import Migration
|
||||
from app.features.tasks.definitions.models import TaskDefinitionModel
|
||||
from app.library.Events import Event, EventBus, Events
|
||||
from app.library.Services import Services
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.engine.result import Result
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
from sqlalchemy.sql.selectable import Select
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TaskDefinitionsRepository(metaclass=Singleton):
|
||||
def __init__(self, session: AsyncGenerator[AsyncSession] | None = None) -> None:
|
||||
self._migrated = False
|
||||
self.session: AsyncGenerator[AsyncSession] = session or get_session
|
||||
|
||||
async def run_migrations(self) -> None:
|
||||
if self._migrated:
|
||||
return
|
||||
|
||||
self._migrated = True
|
||||
await Migration(repo=self).run()
|
||||
|
||||
@staticmethod
|
||||
def get_instance() -> TaskDefinitionsRepository:
|
||||
return TaskDefinitionsRepository()
|
||||
|
||||
def attach(self, _: Any) -> None:
|
||||
async def handle_event(_, __):
|
||||
await self.run_migrations()
|
||||
|
||||
async def handler(e: Event, __):
|
||||
from app.features.tasks.definitions.handlers.generic import GenericTaskHandler
|
||||
|
||||
if isinstance(e.data, ConfigEvent) and CEFeature.TASKS_DEFINITIONS == e.data.feature:
|
||||
LOG.debug("Refreshing task definitions due to configuration update.")
|
||||
await GenericTaskHandler.refresh_definitions(force=True)
|
||||
|
||||
Services.get_instance().add(__class__.__name__, self)
|
||||
EventBus.get_instance().subscribe(
|
||||
Events.STARTED, handle_event, f"{__class__.__name__}.run_migrations"
|
||||
).subscribe(Events.CONFIG_UPDATE, handler, "GenericTaskHandler.refresh_definitions")
|
||||
|
||||
async def list(self) -> list[TaskDefinitionModel]:
|
||||
async with self.session() as session:
|
||||
result: Result[tuple[TaskDefinitionModel]] = await session.execute(
|
||||
select(TaskDefinitionModel).order_by(TaskDefinitionModel.priority.asc(), TaskDefinitionModel.name.asc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_paginated(self, page: int, per_page: int) -> tuple[list[TaskDefinitionModel], 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[TaskDefinitionModel]] = (
|
||||
select(TaskDefinitionModel)
|
||||
.order_by(TaskDefinitionModel.priority.asc(), TaskDefinitionModel.name.asc())
|
||||
.limit(per_page)
|
||||
.offset((page - 1) * per_page)
|
||||
)
|
||||
result: Result[tuple[TaskDefinitionModel]] = 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(TaskDefinitionModel))
|
||||
return int(result.scalar_one())
|
||||
|
||||
async def get(self, identifier: int | str) -> TaskDefinitionModel | None:
|
||||
async with self.session() as session:
|
||||
if not identifier:
|
||||
return None
|
||||
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = TaskDefinitionModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause = or_(TaskDefinitionModel.id == int(identifier), TaskDefinitionModel.name == identifier)
|
||||
else:
|
||||
clause = TaskDefinitionModel.name == identifier
|
||||
|
||||
result: Result[tuple[TaskDefinitionModel]] = await session.execute(
|
||||
select(TaskDefinitionModel).where(clause).limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_name(self, name: str, exclude_id: int | None = None) -> TaskDefinitionModel | None:
|
||||
async with self.session() as session:
|
||||
query: Select[tuple[TaskDefinitionModel]] = select(TaskDefinitionModel).where(
|
||||
TaskDefinitionModel.name == name
|
||||
)
|
||||
if exclude_id is not None:
|
||||
query = query.where(TaskDefinitionModel.id != exclude_id)
|
||||
|
||||
result: Result[tuple[TaskDefinitionModel]] = await session.execute(query.limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(self, payload: dict[str, Any]) -> TaskDefinitionModel:
|
||||
async with self.session() as session:
|
||||
model: TaskDefinitionModel = TaskDefinitionModel(**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"Task definition 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]) -> TaskDefinitionModel:
|
||||
async with self.session() as session:
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = TaskDefinitionModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause = or_(TaskDefinitionModel.id == int(identifier), TaskDefinitionModel.name == identifier)
|
||||
else:
|
||||
clause = TaskDefinitionModel.name == identifier
|
||||
|
||||
result: Result[tuple[TaskDefinitionModel]] = await session.execute(
|
||||
select(TaskDefinitionModel).where(clause).limit(1)
|
||||
)
|
||||
model: TaskDefinitionModel | None = result.scalar_one_or_none()
|
||||
|
||||
if not model:
|
||||
msg: str = f"Task definition '{identifier}' not found."
|
||||
raise KeyError(msg)
|
||||
|
||||
payload.pop("id", None)
|
||||
payload.pop("created_at", None)
|
||||
payload.pop("updated_at", None)
|
||||
|
||||
if "name" in payload and await self.get_by_name(name=payload["name"], exclude_id=model.id) is not None:
|
||||
msg = f"Task definition 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) -> TaskDefinitionModel:
|
||||
async with self.session() as session:
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = TaskDefinitionModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause = or_(TaskDefinitionModel.id == int(identifier), TaskDefinitionModel.name == identifier)
|
||||
else:
|
||||
clause = TaskDefinitionModel.name == identifier
|
||||
|
||||
result: Result[tuple[TaskDefinitionModel]] = await session.execute(
|
||||
select(TaskDefinitionModel).where(clause).limit(1)
|
||||
)
|
||||
|
||||
if not (model := result.scalar_one_or_none()):
|
||||
msg: str = f"Task definition '{identifier}' not found."
|
||||
raise KeyError(msg)
|
||||
|
||||
await session.delete(model)
|
||||
await session.commit()
|
||||
return model
|
||||
226
app/features/tasks/definitions/results.py
Normal file
226
app/features/tasks/definitions/results.py
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.features.tasks.schemas import Task as TaskSchema
|
||||
from app.library.YTDLPOpts import YTDLPOpts
|
||||
|
||||
from .utils import split_inspect_metadata
|
||||
|
||||
|
||||
class HandleTask(TaskSchema):
|
||||
def get_ytdlp_opts(self) -> YTDLPOpts:
|
||||
"""
|
||||
Get the yt-dlp options for the task.
|
||||
|
||||
Returns:
|
||||
YTDLPOpts: The yt-dlp options.
|
||||
|
||||
"""
|
||||
from app.library.YTDLPOpts import YTDLPOpts
|
||||
|
||||
params: YTDLPOpts = YTDLPOpts.get_instance()
|
||||
|
||||
if self.preset:
|
||||
params = params.preset(name=self.preset)
|
||||
|
||||
if self.cli:
|
||||
params = params.add_cli(self.cli, from_user=True)
|
||||
|
||||
if self.template:
|
||||
params = params.add({"outtmpl": {"default": self.template}}, from_user=False)
|
||||
|
||||
return params
|
||||
|
||||
async def mark(self) -> tuple[bool, str]:
|
||||
"""
|
||||
Mark the task's items as downloaded in the archive file.
|
||||
|
||||
Returns:
|
||||
tuple[bool, str]: A tuple indicating success and a message.
|
||||
|
||||
"""
|
||||
from app.library.Utils import archive_add
|
||||
|
||||
ret: tuple[bool, str] | dict[str, Any] = await self._mark_logic()
|
||||
if isinstance(ret, tuple):
|
||||
return ret
|
||||
|
||||
archive_file: Path = ret.get("file")
|
||||
items: set[str] = ret.get("items", set())
|
||||
|
||||
if len(items) < 1 or not archive_add(archive_file, list(items)):
|
||||
return (True, "No new items to mark as downloaded.")
|
||||
|
||||
return (True, f"Task '{self.name}' items marked as downloaded.")
|
||||
|
||||
async def unmark(self) -> tuple[bool, str]:
|
||||
"""
|
||||
Unmark the task's items from the archive file.
|
||||
|
||||
Returns:
|
||||
tuple[bool, str]: A tuple indicating success and a message.
|
||||
|
||||
"""
|
||||
from app.library.Utils import archive_delete
|
||||
|
||||
ret: tuple[bool, str] | dict[str, Any] = await self._mark_logic()
|
||||
if isinstance(ret, tuple):
|
||||
return ret
|
||||
|
||||
archive_file: Path = ret.get("file")
|
||||
items: set[str] = ret.get("items", set())
|
||||
|
||||
if len(items) < 1 or not archive_delete(archive_file, list(items)):
|
||||
return (True, "No items to remove from archive file.")
|
||||
|
||||
return (True, f"Removed '{self.name}' items from archive file.")
|
||||
|
||||
async def fetch_metadata(self, full: bool = False) -> tuple[dict[str, Any] | None, bool, str]:
|
||||
"""
|
||||
Fetch metadata for the task's URL.
|
||||
|
||||
Args:
|
||||
full (bool): Whether to fetch full metadata including all entries for playlists.
|
||||
|
||||
Returns:
|
||||
tuple[dict[str, Any]|None, bool, str]: A tuple containing the metadata (or None on failure), a boolean
|
||||
indicating if the operation was successful, and a message.
|
||||
|
||||
"""
|
||||
from app.library.ytdlp import fetch_info
|
||||
|
||||
if not self.url:
|
||||
return ({}, False, "No URL found in task parameters.")
|
||||
|
||||
params = self.get_ytdlp_opts()
|
||||
if not full:
|
||||
params.add_cli("-I0", from_user=False)
|
||||
|
||||
params_dict = params.get_all()
|
||||
|
||||
ie_info: dict | None = await fetch_info(
|
||||
params_dict,
|
||||
self.url,
|
||||
no_archive=True,
|
||||
follow_redirect=False,
|
||||
sanitize_info=True,
|
||||
)
|
||||
|
||||
if not ie_info or not isinstance(ie_info, dict):
|
||||
return ({}, False, "Failed to extract information from URL.")
|
||||
|
||||
return (ie_info, True, "")
|
||||
|
||||
async def _mark_logic(self) -> tuple[bool, str] | dict[str, Any]:
|
||||
"""
|
||||
Internal logic for marking/un-marking items.
|
||||
|
||||
Returns:
|
||||
tuple[bool, str] | dict[str, Any]: Either an error tuple or a dict with 'file' and 'items' keys.
|
||||
|
||||
"""
|
||||
from app.library.ytdlp import fetch_info
|
||||
|
||||
if not self.url:
|
||||
return (False, "No URL found in task parameters.")
|
||||
|
||||
params: dict = self.get_ytdlp_opts().get_all()
|
||||
if not (archive_file := params.get("download_archive")):
|
||||
return (False, "No archive file found.")
|
||||
|
||||
archive_file: Path = Path(archive_file)
|
||||
|
||||
ie_info: dict | None = await fetch_info(params, self.url, no_archive=True, follow_redirect=True)
|
||||
if not ie_info or not isinstance(ie_info, dict):
|
||||
return (False, "Failed to extract information from URL.")
|
||||
|
||||
if "playlist" != ie_info.get("_type"):
|
||||
return (False, "Expected a playlist type from extract_info.")
|
||||
|
||||
items: set[str] = set()
|
||||
|
||||
def _process(item: dict):
|
||||
for entry in item.get("entries", []):
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
|
||||
if "playlist" == entry.get("_type"):
|
||||
_process(entry)
|
||||
continue
|
||||
|
||||
if entry.get("_type") not in ("video", "url"):
|
||||
continue
|
||||
|
||||
if not entry.get("id") or not entry.get("ie_key"):
|
||||
continue
|
||||
|
||||
archive_id: str = f"{entry.get('ie_key', '').lower()} {entry.get('id')}"
|
||||
|
||||
items.add(archive_id)
|
||||
|
||||
_process(ie_info)
|
||||
|
||||
return {"file": archive_file, "items": items}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TaskItem:
|
||||
"""Represents a single item in a task result."""
|
||||
|
||||
url: str
|
||||
"The URL of the item."
|
||||
title: str | None = None
|
||||
"The title of the item."
|
||||
archive_id: str | None = None
|
||||
"The archive ID of the item."
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
"Additional metadata related to the item."
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TaskResult:
|
||||
"""Represents a successful task handler execution result."""
|
||||
|
||||
items: list[TaskItem] = field(default_factory=list)
|
||||
"The list of items."
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
"Additional metadata related to the result."
|
||||
|
||||
def serialize(self) -> dict[str, Any]:
|
||||
primary, extra = split_inspect_metadata(self.metadata)
|
||||
payload: dict[str, Any] = {**primary, "items": [asdict(item) for item in self.items]}
|
||||
|
||||
if extra:
|
||||
payload["metadata"] = extra
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TaskFailure:
|
||||
"""Represents a failed task handler execution result."""
|
||||
|
||||
message: str
|
||||
"A human-readable message describing the failure."
|
||||
error: str | None = None
|
||||
"An optional error code or string."
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
"Additional metadata related to the failure."
|
||||
|
||||
def serialize(self) -> dict[str, Any]:
|
||||
primary, extra = split_inspect_metadata(self.metadata)
|
||||
payload: dict[str, Any] = dict(primary)
|
||||
|
||||
if self.error:
|
||||
payload["error"] = self.error
|
||||
|
||||
if self.message and (not self.error or self.message != self.error):
|
||||
payload["message"] = self.message
|
||||
|
||||
if extra:
|
||||
payload["metadata"] = extra
|
||||
|
||||
return payload
|
||||
235
app/features/tasks/definitions/router.py
Normal file
235
app/features/tasks/definitions/router.py
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.features.core.schemas import CEAction, CEFeature, ConfigEvent, Pagination
|
||||
from app.features.core.utils import build_pagination, format_validation_errors, normalize_pagination
|
||||
from app.features.tasks.definitions.repository import TaskDefinitionsRepository as Repo
|
||||
from app.features.tasks.definitions.schemas import (
|
||||
TaskDefinition,
|
||||
TaskDefinitionList,
|
||||
TaskDefinitionPatch,
|
||||
)
|
||||
from app.features.tasks.definitions.utils import model_to_schema, schema_to_payload
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.router import route
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@route("GET", "api/tasks/definitions/", "task_definitions")
|
||||
async def task_definitions_list(request: Request, encoder: Encoder, repo: Repo) -> Response:
|
||||
page, per_page = normalize_pagination(request)
|
||||
models, total, current_page, total_pages = await repo.list_paginated(page, per_page)
|
||||
|
||||
include: str | None = request.query.get("include")
|
||||
summary: bool = "definition" != include
|
||||
|
||||
return web.json_response(
|
||||
data=TaskDefinitionList(
|
||||
items=[model_to_schema(model, summary=summary) for model in models],
|
||||
pagination=Pagination.model_validate(build_pagination(total, current_page, per_page, total_pages)),
|
||||
),
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("GET", r"api/tasks/definitions/{id:\d+}", "task_definitions_get")
|
||||
async def task_definitions_get(request: Request, encoder: Encoder, repo: Repo) -> Response:
|
||||
identifier: str = request.match_info.get("id", "").strip()
|
||||
if not identifier:
|
||||
return web.json_response(
|
||||
data={"error": "Missing task definition identifier."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if not (model := await repo.get(identifier)):
|
||||
return web.json_response(
|
||||
data={"error": f"Task definition '{identifier}' not found."},
|
||||
status=web.HTTPNotFound.status_code,
|
||||
)
|
||||
|
||||
definition = model_to_schema(model)
|
||||
return web.json_response(data=definition, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("POST", "api/tasks/definitions/", "task_definitions_create")
|
||||
async def task_definitions_create(request: Request, encoder: Encoder, notify: EventBus, repo: Repo) -> Response:
|
||||
try:
|
||||
payload: Any = await request.json()
|
||||
except Exception:
|
||||
return web.json_response(
|
||||
data={"error": "Invalid JSON in request body."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return web.json_response(
|
||||
data={"error": "Invalid request body; expected JSON object."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
definition_input = TaskDefinition.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate task definition.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
repo_payload = schema_to_payload(definition_input)
|
||||
model = await repo.create(repo_payload)
|
||||
definition = model_to_schema(model)
|
||||
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE,
|
||||
data=ConfigEvent(feature=CEFeature.TASKS_DEFINITIONS, action=CEAction.CREATE, data=definition.model_dump()),
|
||||
)
|
||||
return web.json_response(data=definition, status=web.HTTPCreated.status_code, dumps=encoder.encode)
|
||||
except ValueError as exc:
|
||||
return web.json_response(data={"error": str(exc)}, status=web.HTTPBadRequest.status_code)
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
return web.json_response(
|
||||
data={"error": "Failed to create task definition."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
|
||||
@route("PUT", r"api/tasks/definitions/{id:\d+}", "task_definitions_update")
|
||||
async def task_definitions_update(request: Request, encoder: Encoder, notify: EventBus, repo: Repo) -> Response:
|
||||
if not (identifier := request.match_info.get("id", "").strip()):
|
||||
return web.json_response(
|
||||
data={"error": "Missing task definition identifier."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
payload: dict | None = await request.json()
|
||||
except Exception:
|
||||
return web.json_response(
|
||||
data={"error": "Invalid JSON in request body."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return web.json_response(
|
||||
data={"error": "Invalid request body; expected JSON object."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
definition_input: TaskDefinition = TaskDefinition.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate task definition.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
definition: TaskDefinition = model_to_schema(await repo.update(identifier, schema_to_payload(definition_input)))
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE,
|
||||
data=ConfigEvent(feature=CEFeature.TASKS_DEFINITIONS, action=CEAction.UPDATE, data=definition.model_dump()),
|
||||
)
|
||||
return web.json_response(data=definition, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
except KeyError as exc:
|
||||
return web.json_response(data={"error": str(exc)}, status=web.HTTPNotFound.status_code)
|
||||
except ValueError as exc:
|
||||
return web.json_response(data={"error": str(exc)}, status=web.HTTPBadRequest.status_code)
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
return web.json_response(
|
||||
data={"error": "Failed to update task definition."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
|
||||
@route("PATCH", r"api/tasks/definitions/{id:\d+}", "task_definitions_patch")
|
||||
async def task_definitions_patch(request: Request, encoder: Encoder, notify: EventBus, repo: Repo) -> Response:
|
||||
if not (identifier := request.match_info.get("id", "").strip()):
|
||||
return web.json_response(
|
||||
data={"error": "Missing task definition identifier."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if not await repo.get(identifier):
|
||||
return web.json_response(
|
||||
data={"error": f"Task definition '{identifier}' not found."},
|
||||
status=web.HTTPNotFound.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
payload: dict | None = await request.json()
|
||||
except Exception:
|
||||
return web.json_response(
|
||||
data={"error": "Invalid JSON in request body."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return web.json_response(
|
||||
data={"error": "Invalid request body; expected JSON object."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
patch_input: TaskDefinitionPatch = TaskDefinitionPatch.model_validate(payload)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate task definition patch.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
definition: TaskDefinition = model_to_schema(
|
||||
await repo.update(identifier, patch_input.model_dump(exclude_unset=True))
|
||||
)
|
||||
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE,
|
||||
data=ConfigEvent(feature=CEFeature.TASKS_DEFINITIONS, action=CEAction.UPDATE, data=definition.model_dump()),
|
||||
)
|
||||
return web.json_response(data=definition, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
except KeyError as exc:
|
||||
return web.json_response(data={"error": str(exc)}, status=web.HTTPNotFound.status_code)
|
||||
except ValueError as exc:
|
||||
return web.json_response(data={"error": str(exc)}, status=web.HTTPBadRequest.status_code)
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
return web.json_response(
|
||||
data={"error": "Failed to patch task definition."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
|
||||
@route("DELETE", r"api/tasks/definitions/{id:\d+}", "task_definitions_delete")
|
||||
async def task_definitions_delete(request: Request, encoder: Encoder, notify: EventBus, repo: Repo) -> Response:
|
||||
if not (identifier := request.match_info.get("id", "").strip()):
|
||||
return web.json_response(
|
||||
data={"error": "Missing task definition identifier."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
definition: TaskDefinition = model_to_schema(await repo.delete(identifier))
|
||||
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE,
|
||||
data=ConfigEvent(feature=CEFeature.TASKS_DEFINITIONS, action=CEAction.DELETE, data=definition.model_dump()),
|
||||
)
|
||||
return web.json_response(data=definition, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
except KeyError as exc:
|
||||
return web.json_response(data={"error": str(exc)}, status=web.HTTPNotFound.status_code)
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
return web.json_response(
|
||||
data={"error": "Failed to delete task definition."},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
222
app/features/tasks/definitions/schemas.py
Normal file
222
app/features/tasks/definitions/schemas.py
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime # noqa: TC003
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from app.features.core.schemas import Pagination
|
||||
from app.features.core.utils import parse_int
|
||||
|
||||
|
||||
class PostFilter(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
filter: str = Field(min_length=1)
|
||||
value: str | None = None
|
||||
|
||||
@field_validator("filter")
|
||||
@classmethod
|
||||
def _validate_filter(cls, value: str) -> str:
|
||||
try:
|
||||
re.compile(value)
|
||||
except re.error as exc:
|
||||
msg: str = f"Invalid post_filter regex pattern: {exc}"
|
||||
raise ValueError(msg) from exc
|
||||
return value
|
||||
|
||||
|
||||
class ExtractionRule(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
type: Literal["css", "xpath", "regex", "jsonpath"]
|
||||
expression: str = Field(min_length=1)
|
||||
attribute: str | None = None
|
||||
post_filter: PostFilter | None = None
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
"""Support bracket notation access."""
|
||||
if not hasattr(self, key):
|
||||
raise KeyError(key)
|
||||
return getattr(self, key)
|
||||
|
||||
|
||||
class ParseItems(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
type: Literal["css", "xpath", "jsonpath"] = "css"
|
||||
selector: str | None = Field(None, min_length=1)
|
||||
expression: str | None = Field(None, min_length=1)
|
||||
fields: dict[str, ExtractionRule]
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a field value by key, supporting dict-like access."""
|
||||
return getattr(self, key, default)
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
"""Support bracket notation access."""
|
||||
if not hasattr(self, key):
|
||||
raise KeyError(key)
|
||||
return getattr(self, key)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_items(self) -> ParseItems:
|
||||
if not self.selector and not self.expression:
|
||||
msg = "Either 'selector' or 'expression' must be provided."
|
||||
raise ValueError(msg)
|
||||
if not self.selector:
|
||||
self.selector = self.expression
|
||||
if "link" not in self.fields:
|
||||
msg = "Container 'fields' must include a 'link' field."
|
||||
raise ValueError(msg)
|
||||
return self
|
||||
|
||||
|
||||
class Parse(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, extra="allow")
|
||||
items: ParseItems | None = None
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get a field value by key, supporting dict-like access."""
|
||||
return getattr(self, key, default)
|
||||
|
||||
def field_items(self) -> list[tuple[str, Any]]:
|
||||
"""Return field items like a dict, excluding private fields and 'items'."""
|
||||
data: dict[str, Any] = self.model_dump()
|
||||
return [(k, v) for k, v in data.items() if k not in ("items",)]
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
"""Support bracket notation access."""
|
||||
if not hasattr(self, key):
|
||||
raise KeyError(key)
|
||||
return getattr(self, key)
|
||||
|
||||
def __contains__(self, key: str) -> bool:
|
||||
"""Support 'in' operator."""
|
||||
return hasattr(self, key) and not key.startswith("_")
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _validate_parse(cls, value: Any) -> Any:
|
||||
"""Validate that we have either items or direct parsers with link."""
|
||||
if not isinstance(value, dict):
|
||||
msg: str = "Parse must be a dict"
|
||||
raise ValueError(msg)
|
||||
|
||||
has_items: bool = "items" in value and value["items"] is not None
|
||||
direct_parsers: dict[str, Any] = {
|
||||
k: v for k, v in value.items() if k not in ("items",) and not k.startswith("_")
|
||||
}
|
||||
has_direct_parsers: bool = len(direct_parsers) > 0
|
||||
has_link_parser: bool = "link" in direct_parsers
|
||||
|
||||
if not has_items and not has_direct_parsers:
|
||||
msg: str = "Field 'parse' must contain either 'items' or direct parsers."
|
||||
raise ValueError(msg)
|
||||
|
||||
if not has_items and not has_link_parser:
|
||||
msg: str = "Missing required 'link' parser definition."
|
||||
raise ValueError(msg)
|
||||
|
||||
for field_name, field_value in direct_parsers.items():
|
||||
if not isinstance(field_value, dict):
|
||||
msg: str = f"Parse field '{field_name}' must be an object."
|
||||
raise ValueError(msg)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class EngineConfig(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
type: Literal["httpx", "selenium"] = "httpx"
|
||||
options: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class RequestConfig(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True, protected_namespaces=())
|
||||
method: str = "GET"
|
||||
headers: dict[str, str] = Field(default_factory=dict)
|
||||
params: dict[str, Any] = Field(default_factory=dict)
|
||||
data: dict[str, Any] | None = None
|
||||
json_data: dict[str, Any] | None = None
|
||||
timeout: float | None = None
|
||||
url: str | None = None
|
||||
|
||||
|
||||
class ResponseConfig(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
type: Literal["html", "json"] = "html"
|
||||
|
||||
|
||||
class Definition(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
parse: Parse
|
||||
engine: EngineConfig = Field(default_factory=EngineConfig)
|
||||
request: RequestConfig = Field(default_factory=RequestConfig)
|
||||
response: ResponseConfig = Field(default_factory=ResponseConfig)
|
||||
|
||||
|
||||
class TaskDefinitionSummary(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True, str_strip_whitespace=True)
|
||||
id: int | None = None
|
||||
name: str = Field(min_length=1)
|
||||
priority: int = Field(default=0, ge=0)
|
||||
match_url: list[str] = Field(min_length=1)
|
||||
enabled: bool = Field(default=True)
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class TaskDefinition(TaskDefinitionSummary):
|
||||
definition: Definition
|
||||
|
||||
@field_validator("priority", mode="before")
|
||||
@classmethod
|
||||
def _normalize_priority(cls, value: Any) -> int:
|
||||
if value is None:
|
||||
return 0
|
||||
return parse_int(value, field="Priority", minimum=0)
|
||||
|
||||
@field_validator("match_url", mode="before")
|
||||
@classmethod
|
||||
def _validate_match_url(cls, value: Any) -> list[str]:
|
||||
"""Validate that match_url is a list of strings and validate regex patterns."""
|
||||
if not isinstance(value, list):
|
||||
msg = "match_url must be a list"
|
||||
raise ValueError(msg)
|
||||
|
||||
validated: list[str] = []
|
||||
for item in value:
|
||||
if not isinstance(item, str):
|
||||
msg: str = f"match_url items must be strings, got {type(item).__name__}"
|
||||
raise ValueError(msg)
|
||||
|
||||
item: str = item.strip()
|
||||
if not item:
|
||||
msg = "match_url items cannot be empty"
|
||||
raise ValueError(msg)
|
||||
|
||||
if item.startswith("/") and item.endswith("/") and len(item) > 2:
|
||||
pattern = item[1:-1]
|
||||
try:
|
||||
re.compile(pattern)
|
||||
except re.error as exc:
|
||||
msg = f"Invalid regex pattern '{pattern}': {exc}"
|
||||
raise ValueError(msg) from exc
|
||||
|
||||
validated.append(item)
|
||||
|
||||
return validated
|
||||
|
||||
|
||||
class TaskDefinitionPatch(TaskDefinition):
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
name: str | None = None
|
||||
priority: int | None = None
|
||||
match_url: list[str] | None = None
|
||||
definition: Definition | None = None
|
||||
enabled: bool | None = None
|
||||
|
||||
|
||||
class TaskDefinitionList(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
items: list[TaskDefinitionSummary | TaskDefinition] = Field(default_factory=list)
|
||||
pagination: Pagination
|
||||
456
app/features/tasks/definitions/service.py
Normal file
456
app/features/tasks/definitions/service.py
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import inspect
|
||||
import logging
|
||||
import pkgutil
|
||||
import random
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||
from app.features.tasks.models import TaskModel
|
||||
from app.library.downloads.queue_manager import DownloadQueue
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.ItemDTO import Item, ItemDTO
|
||||
from app.library.Services import Services
|
||||
from app.library.Utils import archive_read
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.features.tasks.repository import TasksRepository
|
||||
from app.library.config import Config
|
||||
from app.library.Scheduler import Scheduler
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("tasks.definitions.service")
|
||||
|
||||
|
||||
class TaskHandle:
|
||||
def __init__(self, scheduler: Scheduler, tasks: TasksRepository, config: Config) -> None:
|
||||
self._handlers: list[type] = []
|
||||
"The available handlers."
|
||||
self._repo: TasksRepository = tasks
|
||||
"The tasks manager."
|
||||
self._scheduler: Scheduler = scheduler
|
||||
"The scheduler."
|
||||
self._config: Config = config
|
||||
"The configuration."
|
||||
self._task_name: str = f"{__class__.__name__}._dispatcher"
|
||||
"The task name for the scheduler."
|
||||
self._queued: dict[str, set[str]] = {}
|
||||
"Queued archive IDs per handler."
|
||||
self._failure_count: dict[str, dict[str, int]] = {}
|
||||
"Failure counts per handler and archive ID."
|
||||
|
||||
EventBus.get_instance().subscribe(
|
||||
Events.ITEM_ERROR,
|
||||
self._handle_item_error,
|
||||
f"{__class__.__name__}.item_error",
|
||||
)
|
||||
|
||||
def load(self) -> None:
|
||||
self._handlers: list[type] = self._discover()
|
||||
|
||||
timer: str = self._config.tasks_handler_timer
|
||||
try:
|
||||
from cronsim import CronSim
|
||||
|
||||
CronSim(timer, datetime.now(UTC))
|
||||
except Exception as e:
|
||||
timer = "15 */1 * * *"
|
||||
LOG.error(f"Invalid timer format. '{e!s}'. Defaulting to '{timer}'.")
|
||||
|
||||
self._scheduler.add(
|
||||
timer=timer,
|
||||
func=lambda: asyncio.create_task(self._dispatcher(), name="task-handler-dispatcher"),
|
||||
id=f"{__class__.__name__}._dispatcher",
|
||||
)
|
||||
|
||||
async def _dispatcher(self):
|
||||
s: dict[str, list[str]] = {"h": [], "d": [], "u": [], "f": []}
|
||||
|
||||
handler_groups: dict[str, list[tuple[HandleTask, type]]] = {}
|
||||
|
||||
tasks: list[TaskModel] = await self._repo.list()
|
||||
|
||||
for task_model in tasks:
|
||||
task: HandleTask = HandleTask.model_validate(task_model)
|
||||
|
||||
if not task.enabled or not task.handler_enabled:
|
||||
s["d"].append(task.name)
|
||||
continue
|
||||
|
||||
if not task.get_ytdlp_opts().get_all().get("download_archive"):
|
||||
LOG.debug(f"Task '{task.name}' does not have an archive file configured.")
|
||||
s["f"].append(task.name)
|
||||
continue
|
||||
|
||||
try:
|
||||
handler: type | None = await self._find_handler(task)
|
||||
if handler is None:
|
||||
s["u"].append(task.name)
|
||||
continue
|
||||
|
||||
handler_name: str = handler.__name__
|
||||
if handler_name not in handler_groups:
|
||||
handler_groups[handler_name] = []
|
||||
handler_groups[handler_name].append((task, handler))
|
||||
s["h"].append(task.name)
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to handle task '{task.name}'. '{e!s}'.")
|
||||
s["f"].append(task.name)
|
||||
|
||||
for tasks_with_handlers in handler_groups.values():
|
||||
for idx, (task, handler) in enumerate(tasks_with_handlers):
|
||||
try:
|
||||
t: asyncio.Task[TaskResult | TaskFailure | None] = asyncio.create_task(
|
||||
coro=self._dispatch(
|
||||
task,
|
||||
handler,
|
||||
delay=0.0 if 0 == idx else random.uniform(1.0, self._config.task_handler_random_delay),
|
||||
),
|
||||
name=f"taskHandler-{task.id}",
|
||||
)
|
||||
t.add_done_callback(lambda fut, t=task: self._handle_exception(fut, t))
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to dispatch task '{task.name}'. '{e!s}'.")
|
||||
|
||||
if len(tasks) > 0:
|
||||
LOG.info(
|
||||
f"Tasks handler summary: Handled: {len(s['h'])}, Unhandled: {len(s['u'])}, Disabled: {len(s['d'])}, Failed: {len(s['f'])}."
|
||||
)
|
||||
|
||||
async def _dispatch(self, task: HandleTask, handler: type, delay: float) -> TaskResult | TaskFailure | None:
|
||||
"""
|
||||
Dispatch a task after a random delay to avoid rate limiting.
|
||||
|
||||
Args:
|
||||
task: The task to dispatch.
|
||||
handler: The handler to use.
|
||||
delay: The delay in seconds before dispatching.
|
||||
|
||||
Returns:
|
||||
The dispatch result.
|
||||
|
||||
"""
|
||||
if delay > 0:
|
||||
LOG.debug(f"Delaying dispatch of task '{task.name}' by {delay:.1f} seconds.")
|
||||
await asyncio.sleep(delay)
|
||||
return await self.dispatch(task, handler=handler)
|
||||
|
||||
def _handle_exception(self, fut: asyncio.Task, task: HandleTask) -> None:
|
||||
if fut.cancelled():
|
||||
return
|
||||
|
||||
if exc := fut.exception():
|
||||
LOG.error(f"Exception while handling task '{task.name}': {exc}")
|
||||
|
||||
async def _find_handler(self, task: HandleTask) -> type | None:
|
||||
for cls in self._handlers:
|
||||
try:
|
||||
if await Services.get_instance().handle_async(handler=cls.can_handle, task=task):
|
||||
return cls
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
async def dispatch(
|
||||
self,
|
||||
task: HandleTask,
|
||||
handler: type | None = None,
|
||||
**kwargs, # noqa: ARG002
|
||||
) -> TaskResult | TaskFailure | None:
|
||||
"""
|
||||
Dispatch a task to the appropriate handler.
|
||||
|
||||
Args:
|
||||
task: The task to dispatch.
|
||||
handler: Optional specific handler to use instead of finding one.
|
||||
**kwargs: Additional context to pass to the handler.
|
||||
|
||||
Returns:
|
||||
The extraction outcome, or None if no handler matched.
|
||||
|
||||
"""
|
||||
if not handler:
|
||||
handler = await self._find_handler(task)
|
||||
if handler is None:
|
||||
return None
|
||||
|
||||
services: Services = Services.get_instance()
|
||||
|
||||
try:
|
||||
extraction: TaskResult | TaskFailure = await services.handle_async(
|
||||
handler=handler.extract, task=task, config=self._config
|
||||
)
|
||||
except NotImplementedError:
|
||||
LOG.error(f"Handler '{handler.__name__}' does not implement extract().")
|
||||
return TaskFailure(message="Handler does not support extraction.")
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
raise
|
||||
|
||||
if isinstance(extraction, TaskFailure):
|
||||
LOG.error(f"Handler '{handler.__name__}' failed to extract items: {extraction.message}")
|
||||
return extraction
|
||||
|
||||
if not isinstance(extraction, TaskResult):
|
||||
LOG.error(
|
||||
f"Handler '{handler.__name__}' returned unexpected result type '{type(extraction).__name__}'.",
|
||||
)
|
||||
return TaskFailure(
|
||||
message="Handler returned invalid result type.", metadata={"type": type(extraction).__name__}
|
||||
)
|
||||
|
||||
raw_items: list[TaskItem] = extraction.items or []
|
||||
metadata: dict[str, Any] = extraction.metadata or {}
|
||||
|
||||
handler_name: str = handler.__name__
|
||||
queued: set[str] = self._queued.setdefault(handler_name, set())
|
||||
failures: dict[str, int] = self._failure_count.setdefault(handler_name, {})
|
||||
|
||||
params: dict = task.get_ytdlp_opts().get_all()
|
||||
archive_file: str | None = params.get("download_archive")
|
||||
|
||||
download_queue: DownloadQueue = services.get("queue") or DownloadQueue.get_instance()
|
||||
notify: EventBus = services.get("notify") or EventBus.get_instance()
|
||||
|
||||
archive_ids: list[str] = [
|
||||
item.archive_id for item in raw_items if isinstance(item, TaskItem) and item.archive_id
|
||||
]
|
||||
downloaded: list[str] = archive_read(archive_file, archive_ids) if archive_file else []
|
||||
|
||||
filtered: list[TaskItem] = []
|
||||
|
||||
for item in raw_items:
|
||||
if not isinstance(item, TaskItem):
|
||||
LOG.warning("Handler '{handler.__name__}' produced non-TaskItem entry: {item!r}")
|
||||
continue
|
||||
|
||||
url: str = item.url
|
||||
if not url:
|
||||
continue
|
||||
|
||||
archive_id: str | None = item.archive_id
|
||||
if not archive_id:
|
||||
LOG.warning(f"'{task.name}': Item with URL '{url}' is missing an archive ID. Skipping.")
|
||||
continue
|
||||
|
||||
if archive_id in queued:
|
||||
continue
|
||||
|
||||
queued.add(archive_id)
|
||||
|
||||
if archive_file and archive_id in downloaded:
|
||||
continue
|
||||
|
||||
if await download_queue.queue.exists(url=url):
|
||||
continue
|
||||
|
||||
try:
|
||||
done = await download_queue.done.get(url=url)
|
||||
if "error" != done.info.status:
|
||||
continue
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
if archive_id not in failures:
|
||||
failures[archive_id] = 0
|
||||
|
||||
filtered.append(item)
|
||||
|
||||
if not filtered:
|
||||
if raw_items:
|
||||
LOG.debug(
|
||||
f"Handler '{handler.__name__}' produced '{len(raw_items)}' for '{task.name}' items, none queued after filtering."
|
||||
)
|
||||
return TaskResult(items=[], metadata=metadata)
|
||||
|
||||
LOG.info(
|
||||
f"Handler '{handler.__name__}' Found '{len(filtered)}' new items for '{task.name}' (raw={len(raw_items)})."
|
||||
)
|
||||
|
||||
base_item = Item.format(
|
||||
{
|
||||
"url": task.url,
|
||||
"preset": task.preset or self._config.default_preset,
|
||||
"folder": task.folder or "",
|
||||
"template": task.template or "",
|
||||
"cli": task.cli or "",
|
||||
"auto_start": task.auto_start,
|
||||
"extras": {"source_name": task.name, "source_id": task.id, "source_handler": handler.__name__},
|
||||
}
|
||||
)
|
||||
|
||||
for item in filtered:
|
||||
metadata_entry: dict[str, Any] = item.metadata if isinstance(item.metadata, dict) else {}
|
||||
extras: dict[str, Any] = base_item.extras.copy()
|
||||
if metadata_entry:
|
||||
extras["metadata"] = metadata_entry
|
||||
|
||||
notify.emit(
|
||||
Events.ADD_URL,
|
||||
data=base_item.new_with(url=item.url, extras=extras).serialize(),
|
||||
)
|
||||
|
||||
return TaskResult(items=filtered, metadata=metadata)
|
||||
|
||||
async def inspect(
|
||||
self,
|
||||
url: str,
|
||||
preset: str | None = None,
|
||||
handler_name: str | None = None,
|
||||
static_only: bool = False,
|
||||
) -> TaskResult | TaskFailure:
|
||||
"""
|
||||
Inspect a URL to find a matching handler and optionally extract items.
|
||||
|
||||
Args:
|
||||
url: The URL to inspect.
|
||||
preset: Optional preset name to use.
|
||||
handler_name: Optional specific handler name to use.
|
||||
static_only: If True, only check if a handler matches without extraction.
|
||||
|
||||
Returns:
|
||||
TaskResult or TaskFailure with inspection results.
|
||||
|
||||
"""
|
||||
if not self._handlers:
|
||||
self._handlers = self._discover()
|
||||
|
||||
task = HandleTask(
|
||||
id=None,
|
||||
name="Inspector",
|
||||
url=url,
|
||||
preset=preset or self._config.default_preset,
|
||||
auto_start=False,
|
||||
)
|
||||
|
||||
services = Services.get_instance()
|
||||
|
||||
handler_cls: type | None
|
||||
if handler_name:
|
||||
handler_cls = next((cls for cls in self._handlers if cls.__name__.lower() == handler_name.lower()), None)
|
||||
if handler_cls is None:
|
||||
message: str = f"Handler '{handler_name}' not found."
|
||||
return TaskFailure(
|
||||
message=message,
|
||||
error=message,
|
||||
metadata={"matched": False, "handler": handler_name},
|
||||
)
|
||||
|
||||
try:
|
||||
matched = await services.handle_async(handler=handler_cls.can_handle, task=task)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
LOG.exception(exc)
|
||||
message = str(exc)
|
||||
return TaskFailure(
|
||||
message=message,
|
||||
error=message,
|
||||
metadata={"matched": False, "handler": handler_cls.__name__},
|
||||
)
|
||||
|
||||
if not matched:
|
||||
return TaskFailure(
|
||||
message="Handler cannot process the supplied URL.",
|
||||
metadata={"matched": False, "handler": handler_cls.__name__},
|
||||
)
|
||||
else:
|
||||
handler_cls = await self._find_handler(task)
|
||||
if handler_cls is None:
|
||||
message = "No handler matched the supplied URL."
|
||||
return TaskFailure(
|
||||
message=message,
|
||||
error=message,
|
||||
metadata={"matched": False, "handler": None},
|
||||
)
|
||||
|
||||
base_metadata: dict[str, Any] = {"matched": True, "handler": handler_cls.__name__}
|
||||
|
||||
if static_only:
|
||||
return TaskResult(items=[], metadata=base_metadata)
|
||||
|
||||
try:
|
||||
extraction: TaskResult | TaskFailure = await services.handle_async(
|
||||
handler=handler_cls.extract, task=task, config=self._config
|
||||
)
|
||||
except NotImplementedError:
|
||||
return TaskFailure(
|
||||
message="Handler does not support manual inspection.",
|
||||
metadata={**base_metadata, "supported": False},
|
||||
)
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
message = str(exc)
|
||||
return TaskFailure(
|
||||
message=message,
|
||||
error=message,
|
||||
metadata={**base_metadata, "supported": True},
|
||||
)
|
||||
|
||||
if isinstance(extraction, TaskFailure):
|
||||
combined_failure_metadata: dict[str, Any] = {**base_metadata, "supported": True}
|
||||
if extraction.metadata:
|
||||
combined_failure_metadata.update(extraction.metadata)
|
||||
|
||||
return TaskFailure(
|
||||
message=extraction.message,
|
||||
error=extraction.error if extraction.error else extraction.message,
|
||||
metadata=combined_failure_metadata,
|
||||
)
|
||||
|
||||
if not isinstance(extraction, TaskResult):
|
||||
LOG.error(
|
||||
f"Handler '{handler_cls.__name__}' returned unexpected result type '{type(extraction).__name__}' during inspection.",
|
||||
)
|
||||
extraction = TaskResult()
|
||||
|
||||
combined_metadata: dict[str, Any] = {**base_metadata, "supported": True}
|
||||
if extraction.metadata:
|
||||
combined_metadata.update(extraction.metadata)
|
||||
|
||||
return TaskResult(items=list(extraction.items), metadata=combined_metadata)
|
||||
|
||||
def _discover(self) -> list[type]:
|
||||
"""Discover all available task handlers."""
|
||||
import app.features.tasks.definitions.handlers as handlers_pkg
|
||||
|
||||
handlers: list[type] = []
|
||||
|
||||
for _, module_name, _ in pkgutil.iter_modules(handlers_pkg.__path__):
|
||||
if module_name.startswith("_"):
|
||||
continue
|
||||
|
||||
module = importlib.import_module(f"{handlers_pkg.__name__}.{module_name}")
|
||||
for _, cls in inspect.getmembers(module, inspect.isclass):
|
||||
if cls.__module__ != module.__name__:
|
||||
continue
|
||||
|
||||
if callable(getattr(cls, "can_handle", None)) and callable(getattr(cls, "extract", None)):
|
||||
handlers.append(cls)
|
||||
|
||||
return handlers
|
||||
|
||||
async def _handle_item_error(self, event, _name, **_kwargs):
|
||||
"""Handle item error events to clean up queued items and track failures."""
|
||||
item: ItemDTO | None = getattr(event, "data", None)
|
||||
if not isinstance(item, ItemDTO):
|
||||
return
|
||||
|
||||
extras: dict[Any, Any] = getattr(item, "extras", {}) or {}
|
||||
handler_name: Any | None = extras.get("source_handler")
|
||||
if not handler_name:
|
||||
return
|
||||
|
||||
archive_id: str | None = item.archive_id
|
||||
if not archive_id:
|
||||
return
|
||||
|
||||
queued: set[str] | None = self._queued.get(handler_name)
|
||||
if queued:
|
||||
queued.discard(archive_id)
|
||||
|
||||
failures: dict[str, int] = self._failure_count.setdefault(handler_name, {})
|
||||
failures[archive_id] = failures.get(archive_id, 0) + 1
|
||||
|
|
@ -0,0 +1,396 @@
|
|||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.features.tasks.definitions.handlers.generic import GenericTaskHandler
|
||||
from app.features.tasks.definitions.results import TaskFailure, TaskResult
|
||||
from app.features.tasks.definitions.schemas import (
|
||||
Definition,
|
||||
EngineConfig,
|
||||
RequestConfig,
|
||||
ResponseConfig,
|
||||
TaskDefinition,
|
||||
)
|
||||
from app.features.tasks.definitions.results import HandleTask
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_generic_handler(monkeypatch):
|
||||
monkeypatch.setattr(GenericTaskHandler, "_definitions", [])
|
||||
monkeypatch.setattr(GenericTaskHandler, "_sources_mtime", {})
|
||||
|
||||
|
||||
def test_build_task_definition_parses_valid_payload():
|
||||
definition = TaskDefinition(
|
||||
id=1,
|
||||
name="example",
|
||||
priority=0,
|
||||
match_url=["https://example.com/articles/*"],
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
definition=Definition(
|
||||
parse={
|
||||
"link": {"type": "css", "expression": ".article a.link::attr(href)"},
|
||||
"title": {"type": "css", "expression": ".article .title", "attribute": "text"},
|
||||
},
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
response=ResponseConfig(),
|
||||
),
|
||||
)
|
||||
|
||||
assert definition is not None, "TaskDefinition should be created"
|
||||
assert "example" == definition.name, "Name should match"
|
||||
assert "https://example.com/articles/*" in definition.match_url, "Match URL should be in list"
|
||||
assert "link" in definition.definition.parse, "Parse should contain link field"
|
||||
assert ".article a.link::attr(href)" == definition.definition.parse["link"]["expression"], (
|
||||
"Link expression should match"
|
||||
)
|
||||
|
||||
|
||||
def test_build_task_definition_handles_container():
|
||||
definition = TaskDefinition(
|
||||
id=2,
|
||||
name="container",
|
||||
priority=0,
|
||||
match_url=["https://example.com/cards"],
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
definition=Definition(
|
||||
parse={
|
||||
"items": {
|
||||
"selector": ".cards .card",
|
||||
"fields": {
|
||||
"link": {"type": "css", "expression": ".card-header a", "attribute": "href"},
|
||||
"title": {"type": "css", "expression": ".card-header a", "attribute": "text"},
|
||||
},
|
||||
}
|
||||
},
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
response=ResponseConfig(),
|
||||
),
|
||||
)
|
||||
|
||||
assert definition is not None, "TaskDefinition should be created"
|
||||
assert "items" in definition.definition.parse, "Parse should contain items container"
|
||||
assert ".cards .card" == definition.definition.parse["items"]["selector"], "Items selector should match"
|
||||
assert "link" in definition.definition.parse["items"]["fields"], "Items fields should contain link"
|
||||
|
||||
|
||||
def test_build_task_definition_handles_json():
|
||||
definition = TaskDefinition(
|
||||
id=3,
|
||||
name="json-def",
|
||||
priority=0,
|
||||
match_url=["https://example.com/api"],
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
definition=Definition(
|
||||
parse={
|
||||
"items": {
|
||||
"type": "jsonpath",
|
||||
"selector": "items",
|
||||
"fields": {
|
||||
"link": {"type": "jsonpath", "expression": "url"},
|
||||
"title": {"type": "jsonpath", "expression": "title"},
|
||||
},
|
||||
}
|
||||
},
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
response=ResponseConfig(type="json"),
|
||||
),
|
||||
)
|
||||
|
||||
assert definition is not None, "TaskDefinition should be created"
|
||||
assert "json" == definition.definition.response.type, "Response type should be json"
|
||||
assert "items" in definition.definition.parse, "Parse should contain items container"
|
||||
assert "jsonpath" == definition.definition.parse["items"]["type"], "Items type should be jsonpath"
|
||||
assert "jsonpath" == definition.definition.parse["items"]["fields"]["link"]["type"], (
|
||||
"Link field type should be jsonpath"
|
||||
)
|
||||
|
||||
|
||||
def test_parse_items_extracts_values():
|
||||
definition = TaskDefinition(
|
||||
id=4,
|
||||
name="example",
|
||||
priority=0,
|
||||
match_url=["https://example.com/*"],
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
definition=Definition(
|
||||
parse={
|
||||
"link": {"type": "css", "expression": ".article a.link::attr(href)", "attribute": None},
|
||||
"title": {"type": "css", "expression": ".article .title", "attribute": "text"},
|
||||
"id": {"type": "css", "expression": ".article", "attribute": "data-id"},
|
||||
},
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
response=ResponseConfig(),
|
||||
),
|
||||
)
|
||||
|
||||
html = """
|
||||
<div class="article" data-id="101">
|
||||
<a class="link" href="/article-101">First</a>
|
||||
<span class="title">First Title</span>
|
||||
</div>
|
||||
<div class="article" data-id="102">
|
||||
<a class="link" href="https://example.com/article-102">Second</a>
|
||||
<span class="title">Second Title</span>
|
||||
</div>
|
||||
"""
|
||||
|
||||
items = GenericTaskHandler._parse_items(definition, html, "https://example.com/base/")
|
||||
|
||||
assert 2 == len(items), "Should extract 2 items"
|
||||
assert "https://example.com/article-101" == items[0]["link"], "First item link should be absolute URL"
|
||||
assert "First Title" == items[0]["title"], "First item title should match"
|
||||
assert "101" == items[0]["id"], "First item id should match"
|
||||
assert "https://example.com/article-102" == items[1]["link"], "Second item link should match"
|
||||
|
||||
|
||||
def test_parse_items_handles_nested_card_layout():
|
||||
definition = TaskDefinition(
|
||||
id=5,
|
||||
name="nested",
|
||||
priority=0,
|
||||
match_url=["https://example.com/*"],
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
definition=Definition(
|
||||
parse={
|
||||
"items": {
|
||||
"type": "css",
|
||||
"selector": ".columns .card",
|
||||
"fields": {
|
||||
"link": {
|
||||
"type": "css",
|
||||
"expression": ".card-header a[href]",
|
||||
"attribute": "href",
|
||||
},
|
||||
"title": {
|
||||
"type": "css",
|
||||
"expression": ".card-header a[href]",
|
||||
"attribute": "text",
|
||||
},
|
||||
"poet": {
|
||||
"type": "css",
|
||||
"expression": "footer .card-footer-item:first-child a",
|
||||
"attribute": "text",
|
||||
},
|
||||
"category": {
|
||||
"type": "css",
|
||||
"expression": "footer .card-footer-item:nth-child(2) a",
|
||||
"attribute": "text",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
response=ResponseConfig(),
|
||||
),
|
||||
)
|
||||
|
||||
html = """
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<p class="card-header-title is-4 has-text-centered is-block text-truncate">
|
||||
<a href="/poems/view/111" title="First Poem">First Poem</a>
|
||||
</p>
|
||||
</div>
|
||||
<footer class="card-footer has-text-centered">
|
||||
<p class="card-footer-item text-truncate">
|
||||
<span class="text-truncate"> By <a href="/poet/alpha">Poet Alpha</a></span>
|
||||
</p>
|
||||
<p class="card-footer-item text-truncate">
|
||||
<span class="text-truncate"> In <a href="/category/one">Category One</a></span>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<p class="card-header-title is-4 has-text-centered is-block text-truncate">
|
||||
<a href="/poems/view/222" title="Second Poem">Second Poem</a>
|
||||
</p>
|
||||
</div>
|
||||
<footer class="card-footer has-text-centered">
|
||||
<p class="card-footer-item text-truncate">
|
||||
<span class="text-truncate"> By <a href="/poet/beta">Poet Beta</a></span>
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
items = GenericTaskHandler._parse_items(definition, html, "https://example.com")
|
||||
|
||||
assert 2 == len(items), "Should extract 2 items"
|
||||
assert "https://example.com/poems/view/111" == items[0]["link"], "First item link should match"
|
||||
assert "First Poem" == items[0]["title"], "First item title should match"
|
||||
assert "Poet Alpha" == items[0]["poet"], "First item poet should match"
|
||||
assert "Category One" == items[0]["category"], "First item category should match"
|
||||
|
||||
assert "https://example.com/poems/view/222" == items[1]["link"], "Second item link should match"
|
||||
assert "Second Poem" == items[1]["title"], "Second item title should match"
|
||||
assert "Poet Beta" == items[1]["poet"], "Second item poet should match"
|
||||
assert "category" not in items[1], "Second item should not have category"
|
||||
|
||||
|
||||
def test_parse_items_handles_json_container():
|
||||
definition = TaskDefinition(
|
||||
id=6,
|
||||
name="json",
|
||||
priority=0,
|
||||
match_url=["https://example.com/*"],
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
definition=Definition(
|
||||
parse={
|
||||
"items": {
|
||||
"type": "jsonpath",
|
||||
"selector": "entries",
|
||||
"fields": {
|
||||
"link": {"type": "jsonpath", "expression": "url"},
|
||||
"title": {"type": "jsonpath", "expression": "title"},
|
||||
"id": {"type": "jsonpath", "expression": "id"},
|
||||
},
|
||||
}
|
||||
},
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
response=ResponseConfig(type="json"),
|
||||
),
|
||||
)
|
||||
|
||||
payload = {
|
||||
"entries": [
|
||||
{"url": "/video/1", "title": "First", "id": 1},
|
||||
{"url": "https://example.com/video/2", "title": "Second", "id": 2},
|
||||
{"title": "Missing Link", "id": 3},
|
||||
]
|
||||
}
|
||||
|
||||
items = GenericTaskHandler._parse_items(
|
||||
definition=definition,
|
||||
html="",
|
||||
base_url="https://example.com",
|
||||
json_data=payload,
|
||||
)
|
||||
|
||||
assert 2 == len(items), "Should extract 2 items (third missing link)"
|
||||
assert "https://example.com/video/1" == items[0]["link"], "First item link should be absolute"
|
||||
assert "First" == items[0]["title"], "First item title should match"
|
||||
assert "1" == items[0]["id"], "First item id should match"
|
||||
|
||||
assert "https://example.com/video/2" == items[1]["link"], "Second item link should match"
|
||||
assert "Second" == items[1]["title"], "Second item title should match"
|
||||
assert "2" == items[1]["id"], "Second item id should match"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generic_task_handler_inspect(monkeypatch):
|
||||
definition = TaskDefinition(
|
||||
id=7,
|
||||
name="json-inspect",
|
||||
priority=0,
|
||||
match_url=["https://example.com/*"],
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
definition=Definition(
|
||||
parse={
|
||||
"items": {
|
||||
"type": "jsonpath",
|
||||
"selector": "items",
|
||||
"fields": {
|
||||
"link": {"type": "jsonpath", "expression": "url"},
|
||||
"title": {"type": "jsonpath", "expression": "title"},
|
||||
},
|
||||
}
|
||||
},
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
response=ResponseConfig(type="json"),
|
||||
),
|
||||
)
|
||||
|
||||
async def fake_find_definition(cls, url): # noqa: ARG001
|
||||
return definition
|
||||
|
||||
monkeypatch.setattr(
|
||||
GenericTaskHandler,
|
||||
"_find_definition",
|
||||
classmethod(fake_find_definition),
|
||||
)
|
||||
|
||||
async def fake_fetch_content(url, definition, ytdlp_opts): # noqa: ARG001
|
||||
return "", {"items": [{"url": "/video/1", "title": "First"}]}
|
||||
|
||||
monkeypatch.setattr(GenericTaskHandler, "_fetch_content", staticmethod(fake_fetch_content))
|
||||
|
||||
# Mock fetch_info to return valid info with required fields for archive ID generation
|
||||
async def fake_fetch_info(config, url, **kwargs): # noqa: ARG001
|
||||
return {"id": "test_video_1", "extractor_key": "Example"}
|
||||
|
||||
with patch("app.features.tasks.definitions.handlers.generic.fetch_info", side_effect=fake_fetch_info):
|
||||
task = HandleTask(id=1, name="Inspect", url="https://example.com/api")
|
||||
result: TaskResult | TaskFailure = await GenericTaskHandler.extract(task)
|
||||
|
||||
assert isinstance(result, TaskResult), "Result should be TaskResult"
|
||||
assert 1 == len(result.items), "Should have 1 item"
|
||||
item = result.items[0]
|
||||
assert "https://example.com/video/1" == item.url, "Item URL should match"
|
||||
assert "First" == item.title, "Item title should match"
|
||||
|
||||
|
||||
def test_parse_items_handles_json_top_level_list():
|
||||
definition = TaskDefinition(
|
||||
id=8,
|
||||
name="json-list",
|
||||
priority=0,
|
||||
match_url=["https://example.com/*"],
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
definition=Definition(
|
||||
parse={
|
||||
"items": {
|
||||
"type": "jsonpath",
|
||||
"selector": "[]",
|
||||
"fields": {
|
||||
"link": {"type": "jsonpath", "expression": "url"},
|
||||
"title": {"type": "jsonpath", "expression": "title"},
|
||||
},
|
||||
}
|
||||
},
|
||||
engine=EngineConfig(),
|
||||
request=RequestConfig(),
|
||||
response=ResponseConfig(type="json"),
|
||||
),
|
||||
)
|
||||
|
||||
payload = [
|
||||
{"url": "/video/1", "title": "First"},
|
||||
{"url": "/video/2", "title": "Second"},
|
||||
]
|
||||
|
||||
items = GenericTaskHandler._parse_items(
|
||||
definition=definition,
|
||||
html="",
|
||||
base_url="https://example.com",
|
||||
json_data=payload,
|
||||
)
|
||||
|
||||
assert 2 == len(items), "Should extract 2 items"
|
||||
assert "https://example.com/video/1" == items[0]["link"], "First item link should match"
|
||||
assert "First" == items[0]["title"], "First item title should match"
|
||||
assert "https://example.com/video/2" == items[1]["link"], "Second item link should match"
|
||||
assert "Second" == items[1]["title"], "Second item title should match"
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import pytest
|
||||
|
||||
from app.library.task_handlers.rss import RssGenericHandler
|
||||
from app.library.Tasks import Task, TaskResult
|
||||
from app.features.tasks.definitions.handlers.rss import RssGenericHandler
|
||||
from app.features.tasks.definitions.results import TaskResult
|
||||
from app.features.tasks.definitions.results import HandleTask
|
||||
|
||||
|
||||
class DummyResponse:
|
||||
|
|
@ -68,10 +69,10 @@ class TestRssHandlerExtraction:
|
|||
return DummyResponse(atom_feed)
|
||||
|
||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
|
||||
task = Task(
|
||||
id="test_rss",
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
name="Test Atom Feed",
|
||||
url="https://example.com/feed.atom",
|
||||
preset="default",
|
||||
|
|
@ -112,10 +113,10 @@ class TestRssHandlerExtraction:
|
|||
return DummyResponse(rss_feed)
|
||||
|
||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
|
||||
task = Task(
|
||||
id="test_rss",
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
name="Test RSS Feed",
|
||||
url="https://example.com/feed.rss",
|
||||
preset="default",
|
||||
|
|
@ -146,25 +147,25 @@ class TestRssHandlerExtraction:
|
|||
return DummyResponse(atom_feed)
|
||||
|
||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
|
||||
task = Task(
|
||||
id="test_rss",
|
||||
name="Test Feed",
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
name="Test rss Feed",
|
||||
url="https://example.com/feed.atom",
|
||||
preset="default",
|
||||
)
|
||||
|
||||
assert RssGenericHandler.can_handle(task) is True
|
||||
assert await RssGenericHandler.can_handle(task) is True
|
||||
|
||||
non_feed_task = Task(
|
||||
id="test_youtube",
|
||||
non_feed_task = HandleTask(
|
||||
id=1,
|
||||
name="YouTube Video",
|
||||
url="https://www.youtube.com/watch?v=abc123",
|
||||
preset="default",
|
||||
)
|
||||
|
||||
assert RssGenericHandler.can_handle(non_feed_task) is False
|
||||
assert await RssGenericHandler.can_handle(non_feed_task) is False
|
||||
|
||||
|
||||
class TestRssHandlerEdgeCases:
|
||||
|
|
@ -185,10 +186,10 @@ class TestRssHandlerEdgeCases:
|
|||
return DummyResponse(empty_feed)
|
||||
|
||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
|
||||
task = Task(
|
||||
id="test_empty",
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
name="Empty Feed",
|
||||
url="https://example.com/feed.rss",
|
||||
preset="default",
|
||||
|
|
@ -203,17 +204,17 @@ class TestRssHandlerEdgeCases:
|
|||
@pytest.mark.asyncio
|
||||
async def test_invalid_feed_url(self, monkeypatch):
|
||||
"""Test handling of invalid feed URL."""
|
||||
from app.library.Tasks import TaskFailure
|
||||
from app.features.tasks.definitions.results import TaskFailure
|
||||
|
||||
async def fake_request(**kwargs): # noqa: ARG001
|
||||
msg = "Network error"
|
||||
raise Exception(msg)
|
||||
|
||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
|
||||
task = Task(
|
||||
id="test_invalid",
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
name="Invalid Feed",
|
||||
url="https://example.com/feed.rss",
|
||||
preset="default",
|
||||
|
|
@ -244,10 +245,10 @@ class TestRssHandlerEdgeCases:
|
|||
return DummyResponse(feed)
|
||||
|
||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
|
||||
task = Task(
|
||||
id="test_missing",
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
name="Feed with Missing URLs",
|
||||
url="https://example.com/feed.rss",
|
||||
preset="default",
|
||||
228
app/features/tasks/definitions/tests/test_task_definitions.py
Normal file
228
app/features/tasks/definitions/tests/test_task_definitions.py
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request
|
||||
|
||||
from app.features.tasks.definitions.repository import TaskDefinitionsRepository
|
||||
from app.features.tasks.definitions.router import (
|
||||
task_definitions_create,
|
||||
task_definitions_delete,
|
||||
task_definitions_get,
|
||||
task_definitions_list,
|
||||
task_definitions_patch,
|
||||
task_definitions_update,
|
||||
)
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.sqlite_store import SqliteStore
|
||||
from app.main import EventBus
|
||||
|
||||
|
||||
def _sample_definition(name: str = "example", *, priority: int = 0) -> dict:
|
||||
"""Returns a properly structured task definition payload for the repository."""
|
||||
return {
|
||||
"name": name,
|
||||
"match_url": ["https://example.com/*"],
|
||||
"priority": priority,
|
||||
"definition": {
|
||||
"parse": {
|
||||
"link": {
|
||||
"type": "css",
|
||||
"expression": "a",
|
||||
"attribute": "href",
|
||||
}
|
||||
},
|
||||
"engine": {"type": "httpx"},
|
||||
"request": {},
|
||||
"response": {"type": "html"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def repo() -> AsyncGenerator[TaskDefinitionsRepository, None]:
|
||||
TaskDefinitionsRepository._reset_singleton()
|
||||
SqliteStore._reset_singleton()
|
||||
|
||||
store = SqliteStore(db_path=":memory:")
|
||||
await store.get_connection()
|
||||
|
||||
repository = TaskDefinitionsRepository.get_instance()
|
||||
|
||||
yield repository
|
||||
|
||||
if store._conn:
|
||||
await store._conn.close()
|
||||
if store._engine:
|
||||
await store._engine.dispose()
|
||||
|
||||
TaskDefinitionsRepository._reset_singleton()
|
||||
SqliteStore._reset_singleton()
|
||||
|
||||
|
||||
class TestTaskDefinitionsRepository:
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_and_list(self, repo: TaskDefinitionsRepository) -> None:
|
||||
await repo.create(_sample_definition("Alpha", priority=2))
|
||||
await repo.create(_sample_definition("Beta", priority=1))
|
||||
|
||||
items = await repo.list()
|
||||
assert len(items) == 2, "Should return two task definitions"
|
||||
assert [item.name for item in items] == ["Beta", "Alpha"], "Should sort by priority then name"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_duplicate_name_raises(self, repo: TaskDefinitionsRepository) -> None:
|
||||
payload = _sample_definition("Dup")
|
||||
await repo.create(payload)
|
||||
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
await repo.create(payload)
|
||||
|
||||
with pytest.raises(ValueError, match="already exists"):
|
||||
await repo.create(payload)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_missing_raises(self, repo: TaskDefinitionsRepository) -> None:
|
||||
with pytest.raises(KeyError, match="not found"):
|
||||
await repo.update(999, {"name": "Missing"})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_paginated(self, repo: TaskDefinitionsRepository) -> None:
|
||||
for idx in range(5):
|
||||
await repo.create(_sample_definition(f"Item {idx}", priority=idx))
|
||||
|
||||
items, total, page, total_pages = await repo.list_paginated(page=1, per_page=2)
|
||||
assert len(items) == 2, "Should return two items per page"
|
||||
assert total == 5, "Should report total count of 5"
|
||||
assert page == 1, "Should return requested page"
|
||||
assert total_pages == 3, "Should compute total pages"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestTaskDefinitionRoutes:
|
||||
async def test_list_definitions(self, repo: TaskDefinitionsRepository) -> None:
|
||||
await repo.create(_sample_definition("Sample"))
|
||||
request = MagicMock(spec=Request)
|
||||
request.query = {}
|
||||
|
||||
response = await task_definitions_list(request, Encoder(), repo)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPOk.status_code, "Should return 200 for list"
|
||||
assert payload["items"][0]["name"] == "Sample", "Should include created definition"
|
||||
|
||||
async def test_get_definition_not_found(self, repo: TaskDefinitionsRepository) -> None:
|
||||
request = MagicMock(spec=Request)
|
||||
request.match_info = {"id": "999"}
|
||||
|
||||
response = await task_definitions_get(request, Encoder(), repo)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPNotFound.status_code, "Should return 404 for missing definition"
|
||||
assert "error" in payload, "Should include error payload"
|
||||
|
||||
async def test_create_definition_success(self, repo: TaskDefinitionsRepository) -> None:
|
||||
request = MagicMock(spec=Request)
|
||||
request.json = AsyncMock(return_value=_sample_definition("New", priority=3))
|
||||
|
||||
response = await task_definitions_create(request, Encoder(), MagicMock(spec=EventBus), repo)
|
||||
body = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPCreated.status_code, "Should create task definition"
|
||||
assert body["name"] == "New", "Should return created name"
|
||||
assert body["priority"] == 3, "Should return created priority"
|
||||
|
||||
async def test_update_definition_success(self, repo: TaskDefinitionsRepository) -> None:
|
||||
created = await repo.create(_sample_definition("Original", priority=0))
|
||||
|
||||
request = MagicMock(spec=Request)
|
||||
request.match_info = {"id": str(created.id)}
|
||||
request.json = AsyncMock(return_value=_sample_definition("Updated", priority=4))
|
||||
|
||||
response = await task_definitions_update(request, Encoder(), MagicMock(spec=EventBus), repo)
|
||||
body = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPOk.status_code, "Should update task definition"
|
||||
assert body["name"] == "Updated", "Should return updated name"
|
||||
assert body["priority"] == 4, "Should return updated priority"
|
||||
|
||||
async def test_delete_definition_success(self, repo: TaskDefinitionsRepository) -> None:
|
||||
created = await repo.create(_sample_definition("Delete"))
|
||||
|
||||
request = MagicMock(spec=Request)
|
||||
request.match_info = {"id": str(created.id)}
|
||||
|
||||
response = await task_definitions_delete(request, Encoder(), MagicMock(spec=EventBus), repo)
|
||||
assert response.status == web.HTTPOk.status_code, "Should delete task definition"
|
||||
|
||||
async def test_patch_definition_enabled(self, repo: TaskDefinitionsRepository) -> None:
|
||||
created = await repo.create(_sample_definition("PatchTest", priority=5))
|
||||
assert created.enabled is True, "Should be enabled by default"
|
||||
|
||||
request = MagicMock(spec=Request)
|
||||
request.match_info = {"id": str(created.id)}
|
||||
request.json = AsyncMock(return_value={"enabled": False})
|
||||
|
||||
response = await task_definitions_patch(request, Encoder(), MagicMock(spec=EventBus), repo)
|
||||
body = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPOk.status_code, "Should patch task definition"
|
||||
assert body["name"] == "PatchTest", "Should keep original name"
|
||||
assert body["priority"] == 5, "Should keep original priority"
|
||||
assert body["enabled"] is False, "Should update enabled status"
|
||||
|
||||
async def test_patch_definition_priority(self, repo: TaskDefinitionsRepository) -> None:
|
||||
created = await repo.create(_sample_definition("PatchPriority", priority=1))
|
||||
|
||||
request = MagicMock(spec=Request)
|
||||
request.match_info = {"id": str(created.id)}
|
||||
request.json = AsyncMock(return_value={"priority": 10})
|
||||
|
||||
response = await task_definitions_patch(request, Encoder(), MagicMock(spec=EventBus), repo)
|
||||
body = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPOk.status_code, "Should patch task definition"
|
||||
assert body["priority"] == 10, "Should update priority"
|
||||
assert body["enabled"] is True, "Should keep original enabled status"
|
||||
|
||||
async def test_patch_definition_not_found(self, repo: TaskDefinitionsRepository) -> None:
|
||||
request = MagicMock(spec=Request)
|
||||
request.match_info = {"id": "999"}
|
||||
request.json = AsyncMock(return_value={"enabled": False})
|
||||
|
||||
response = await task_definitions_patch(request, Encoder(), MagicMock(spec=EventBus), repo)
|
||||
payload = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPNotFound.status_code, "Should return 404 for missing definition"
|
||||
assert "error" in payload, "Should include error payload"
|
||||
|
||||
async def test_create_with_regex_pattern(self, repo: TaskDefinitionsRepository) -> None:
|
||||
payload = _sample_definition("RegexTest", priority=0)
|
||||
payload["match_url"] = ["/https://example\\.com/post/[0-9]+/"]
|
||||
|
||||
request = MagicMock(spec=Request)
|
||||
request.json = AsyncMock(return_value=payload)
|
||||
|
||||
response = await task_definitions_create(request, Encoder(), MagicMock(spec=EventBus), repo)
|
||||
body = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPCreated.status_code, "Should create task definition with regex pattern"
|
||||
assert body["match_url"][0] == "/https://example\\.com/post/[0-9]+/", "Should preserve regex pattern format"
|
||||
|
||||
async def test_create_with_invalid_regex_pattern(self, repo: TaskDefinitionsRepository) -> None:
|
||||
payload = _sample_definition("BadRegex", priority=0)
|
||||
payload["match_url"] = ["/[invalid(/"]
|
||||
|
||||
request = MagicMock(spec=Request)
|
||||
request.json = AsyncMock(return_value=payload)
|
||||
|
||||
response = await task_definitions_create(request, Encoder(), MagicMock(spec=EventBus), repo)
|
||||
payload_response = json.loads(response.text)
|
||||
|
||||
assert response.status == web.HTTPBadRequest.status_code, "Should reject invalid regex pattern"
|
||||
assert "error" in payload_response, "Should include error payload"
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import pytest
|
||||
|
||||
from app.library.task_handlers.tver import TverHandler
|
||||
from app.library.Tasks import Task, TaskResult
|
||||
from app.features.tasks.definitions.handlers.tver import TverHandler
|
||||
from app.features.tasks.definitions.results import TaskResult
|
||||
from app.features.tasks.definitions.results import HandleTask
|
||||
|
||||
|
||||
class DummyResponse:
|
||||
|
|
@ -118,9 +119,9 @@ async def test_tver_handler_extract(monkeypatch):
|
|||
raise RuntimeError(msg)
|
||||
|
||||
monkeypatch.setattr(TverHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda _: DummyOpts({"download_archive": "/tmp/archive"}))
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda _: DummyOpts({"download_archive": "/tmp/archive"}))
|
||||
|
||||
task = Task(id="test_tver", name="Test Tver Series", url="https://tver.jp/series/sr8sb9pnhc", preset="default")
|
||||
task = HandleTask(id=1, name="Test Tver Series", url="https://tver.jp/series/sr8sb9pnhc", preset="default")
|
||||
|
||||
result = await TverHandler.extract(task)
|
||||
|
||||
|
|
@ -151,9 +152,10 @@ def test_tver_handler_parse(url: str, should_match: bool):
|
|||
assert result is None
|
||||
|
||||
|
||||
def test_tver_handler_can_handle():
|
||||
@pytest.mark.asyncio
|
||||
async def test_tver_handler_can_handle():
|
||||
"""Test tver handler can_handle method."""
|
||||
task_valid = Task(id="test1", name="Test", url="https://tver.jp/series/sr8sb9pnhc", preset="default")
|
||||
task_invalid = Task(id="test2", name="Test", url="https://youtube.com/watch?v=123", preset="default")
|
||||
assert TverHandler.can_handle(task_valid) is True
|
||||
assert TverHandler.can_handle(task_invalid) is False
|
||||
task_valid = HandleTask(id=1, name="Test", url="https://tver.jp/series/sr8sb9pnhc", preset="default")
|
||||
task_invalid = HandleTask(id=2, name="Test", url="https://youtube.com/watch?v=123", preset="default")
|
||||
assert await TverHandler.can_handle(task_valid) is True
|
||||
assert await TverHandler.can_handle(task_invalid) is False
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import pytest
|
||||
|
||||
from app.library.task_handlers.twitch import TwitchHandler
|
||||
from app.library.Tasks import Task, TaskResult
|
||||
from app.features.tasks.definitions.handlers.twitch import TwitchHandler
|
||||
from app.features.tasks.definitions.results import TaskResult
|
||||
from app.features.tasks.definitions.results import HandleTask
|
||||
|
||||
|
||||
class DummyResponse:
|
||||
|
|
@ -39,10 +40,10 @@ async def test_twitch_handler_inspect(monkeypatch):
|
|||
return DummyResponse(feed)
|
||||
|
||||
monkeypatch.setattr(TwitchHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
|
||||
task = Task(
|
||||
id="inspect",
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
name="Inspect",
|
||||
url="https://www.twitch.tv/testchannel",
|
||||
preset="default",
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import pytest
|
||||
|
||||
from app.library.task_handlers.youtube import YoutubeHandler
|
||||
from app.library.Tasks import Task, TaskResult
|
||||
from app.features.tasks.definitions.handlers.youtube import YoutubeHandler
|
||||
from app.features.tasks.definitions.results import TaskResult
|
||||
from app.features.tasks.definitions.results import HandleTask
|
||||
|
||||
|
||||
class DummyResponse:
|
||||
|
|
@ -41,10 +42,10 @@ async def test_youtube_handler_inspect(monkeypatch):
|
|||
return DummyResponse(feed)
|
||||
|
||||
monkeypatch.setattr(YoutubeHandler, "request", staticmethod(fake_request))
|
||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||
|
||||
task = Task(
|
||||
id="inspect",
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
name="Inspect",
|
||||
url="https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv",
|
||||
preset="default",
|
||||
69
app/features/tasks/definitions/utils.py
Normal file
69
app/features/tasks/definitions/utils.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
from typing import Any
|
||||
|
||||
from app.features.tasks.definitions.models import TaskDefinitionModel
|
||||
from app.features.tasks.definitions.schemas import Definition, TaskDefinition, TaskDefinitionSummary
|
||||
|
||||
|
||||
def model_to_schema(model: TaskDefinitionModel, summary: bool = False) -> TaskDefinition | TaskDefinitionSummary:
|
||||
"""
|
||||
Convert a TaskDefinitionModel to a TaskDefinition or TaskDefinitionSummary schema.
|
||||
|
||||
Args:
|
||||
model (TaskDefinitionModel): The model instance to convert.
|
||||
summary (bool): Whether to return a summary schema.
|
||||
|
||||
Returns:
|
||||
TaskDefinition | TaskDefinitionSummary: The corresponding schema instance.
|
||||
|
||||
"""
|
||||
dct = {
|
||||
"id": model.id,
|
||||
"name": model.name,
|
||||
"priority": model.priority,
|
||||
"match_url": model.match_url,
|
||||
"enabled": model.enabled,
|
||||
"created_at": model.created_at,
|
||||
"updated_at": model.updated_at,
|
||||
}
|
||||
return TaskDefinitionSummary(**dct) if summary else TaskDefinition(**dct, definition=Definition(**model.definition))
|
||||
|
||||
|
||||
def schema_to_payload(item: TaskDefinition) -> dict[str, Any]:
|
||||
"""
|
||||
Convert a TaskDefinition schema to a dictionary payload for database operations.
|
||||
|
||||
Args:
|
||||
item (TaskDefinition): The schema instance to convert.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: The corresponding dictionary payload.
|
||||
|
||||
"""
|
||||
return {
|
||||
"name": item.name,
|
||||
"priority": item.priority,
|
||||
"match_url": item.match_url,
|
||||
"enabled": item.enabled,
|
||||
"definition": item.definition.model_dump(exclude_unset=True, exclude_none=True),
|
||||
}
|
||||
|
||||
|
||||
def split_inspect_metadata(metadata: dict[str, Any] | None) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""
|
||||
Split commonly consumed metadata keys from the rest.
|
||||
|
||||
Args:
|
||||
metadata (dict[str, Any]|None): The metadata to split.
|
||||
|
||||
Returns:
|
||||
tuple[dict[str, Any], dict[str, Any]]: The primary and extra metadata.
|
||||
|
||||
"""
|
||||
metadata = dict(metadata or {})
|
||||
primary: dict[str, Any] = {}
|
||||
|
||||
for key in ("matched", "handler", "supported"):
|
||||
if key in metadata:
|
||||
primary[key] = metadata.pop(key)
|
||||
|
||||
return primary, metadata
|
||||
14
app/features/tasks/deps.py
Normal file
14
app/features/tasks/deps.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from app.features.tasks.repository import TasksRepository
|
||||
from app.features.tasks.service import Tasks
|
||||
|
||||
|
||||
def get_tasks_repo() -> TasksRepository:
|
||||
"""Get tasks repository instance."""
|
||||
return TasksRepository.get_instance()
|
||||
|
||||
|
||||
def get_tasks_service() -> Tasks:
|
||||
"""Get tasks service instance."""
|
||||
return Tasks.get_instance()
|
||||
116
app/features/tasks/migration.py
Normal file
116
app/features/tasks/migration.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.features.core.migration import Migration as FeatureMigration
|
||||
from app.features.tasks.schemas import Task
|
||||
from app.library.config import Config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.features.tasks.repository import TasksRepository
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Migration(FeatureMigration):
|
||||
name: str = "tasks"
|
||||
|
||||
def __init__(self, repo: TasksRepository, config: Config | None = None):
|
||||
self._config: Config = config or Config.get_instance()
|
||||
super().__init__(config=self._config)
|
||||
self._repo: TasksRepository = repo
|
||||
self._source_file: Path = Path(self._config.config_path) / "tasks.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("Tasks already exist in the database; skipping migration.")
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
try:
|
||||
items: list[dict[str, Any]] | None = json.loads(self._source_file.read_text())
|
||||
except Exception as exc:
|
||||
LOG.exception("Failed to read %s: %s. Ignoring", self._source_file, exc)
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
if items is None:
|
||||
LOG.warning("No tasks found in %s; skipping migration.", self._source_file)
|
||||
await self._move_file(self._source_file)
|
||||
return
|
||||
|
||||
inserted = 0
|
||||
seen_names: dict[str, int] = {}
|
||||
for index, item in enumerate(items):
|
||||
if not (normalized := self._normalize(item, index, seen_names)):
|
||||
continue
|
||||
try:
|
||||
await self._repo.create(normalized)
|
||||
inserted += 1
|
||||
except Exception as exc:
|
||||
LOG.exception("Failed to insert task '%s': %s", normalized["name"], exc)
|
||||
|
||||
LOG.info("Migrated %s task(s) from %s.", inserted, self._source_file)
|
||||
await self._move_file(self._source_file)
|
||||
|
||||
def _normalize(self, item: Any, index: int, seen_names: dict[str, int]) -> dict[str, Any] | None:
|
||||
if not isinstance(item, dict):
|
||||
LOG.warning("Skipping task at index %s due to invalid type.", index)
|
||||
return None
|
||||
|
||||
assert isinstance(item, dict)
|
||||
|
||||
name: str | None = item.get("name")
|
||||
if not name or not isinstance(name, str):
|
||||
LOG.warning("Skipping task at index %s due to missing name.", index)
|
||||
return None
|
||||
|
||||
normalized_name = name.strip()
|
||||
if not normalized_name:
|
||||
LOG.warning("Skipping task at index %s due to empty name.", index)
|
||||
return None
|
||||
|
||||
name = self._unique_name(normalized_name, seen_names)
|
||||
|
||||
url: str | None = item.get("url")
|
||||
if not url or not isinstance(url, str):
|
||||
LOG.warning("Skipping task '%s' at index %s due to missing URL.", name, index)
|
||||
return None
|
||||
|
||||
url = url.strip()
|
||||
if not url:
|
||||
LOG.warning("Skipping task '%s' at index %s due to empty URL.", name, index)
|
||||
return None
|
||||
|
||||
folder: str = item.get("folder") if isinstance(item.get("folder"), str) else ""
|
||||
preset: str = item.get("preset") if isinstance(item.get("preset"), str) else ""
|
||||
timer: str = item.get("timer") if isinstance(item.get("timer"), str) else ""
|
||||
template: str = item.get("template") if isinstance(item.get("template"), str) else ""
|
||||
cli: str = item.get("cli") if isinstance(item.get("cli"), str) else ""
|
||||
auto_start: bool = item.get("auto_start") if isinstance(item.get("auto_start"), bool) else True
|
||||
handler_enabled: bool = item.get("handler_enabled") if isinstance(item.get("handler_enabled"), bool) else True
|
||||
enabled: bool = item.get("enabled") if isinstance(item.get("enabled"), bool) else True
|
||||
|
||||
try:
|
||||
validated = Task(
|
||||
name=name,
|
||||
url=url,
|
||||
folder=folder,
|
||||
preset=preset,
|
||||
timer=timer,
|
||||
template=template,
|
||||
cli=cli,
|
||||
auto_start=auto_start,
|
||||
handler_enabled=handler_enabled,
|
||||
enabled=enabled,
|
||||
)
|
||||
return validated.model_dump()
|
||||
except Exception as e:
|
||||
LOG.warning("Skipping task '%s' at index %s due to validation error: %s", name, index, e)
|
||||
return None
|
||||
31
app/features/tasks/models.py
Normal file
31
app/features/tasks/models.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime # noqa: TC003
|
||||
|
||||
from sqlalchemy import Boolean, Index, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.features.core.models import Base, UTCDateTime, utcnow
|
||||
|
||||
|
||||
class TaskModel(Base):
|
||||
__tablename__: str = "tasks"
|
||||
__table_args__: tuple[Index, ...] = (
|
||||
Index("ix_tasks_name", "name"),
|
||||
Index("ix_tasks_enabled", "enabled"),
|
||||
Index("ix_tasks_timer", "timer"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
url: Mapped[str] = mapped_column(String(2048), nullable=False)
|
||||
folder: Mapped[str] = mapped_column(String(512), nullable=False, default="")
|
||||
preset: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
timer: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||
template: Mapped[str] = mapped_column(String(1024), nullable=False, default="")
|
||||
cli: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
auto_start: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
handler_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, onupdate=utcnow, nullable=False)
|
||||
169
app/features/tasks/repository.py
Normal file
169
app/features/tasks/repository.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import func, or_, select
|
||||
|
||||
from app.features.core.deps import get_session
|
||||
from app.features.tasks.models import TaskModel
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.engine.result import Result
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
from sqlalchemy.sql.selectable import Select
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TasksRepository(metaclass=Singleton):
|
||||
def __init__(self, session: AsyncGenerator[AsyncSession] | None = None) -> None:
|
||||
self._migrated = False
|
||||
self.session = session or get_session
|
||||
|
||||
async def run_migrations(self) -> None:
|
||||
if self._migrated:
|
||||
return
|
||||
|
||||
self._migrated = True
|
||||
from app.features.tasks.migration import Migration
|
||||
|
||||
await Migration(repo=self).run()
|
||||
|
||||
@staticmethod
|
||||
def get_instance() -> TasksRepository:
|
||||
return TasksRepository()
|
||||
|
||||
async def list(self) -> list[TaskModel]:
|
||||
async with self.session() as session:
|
||||
result: Result[tuple[TaskModel]] = await session.execute(select(TaskModel).order_by(TaskModel.name.asc()))
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_paginated(self, page: int, per_page: int) -> tuple[list[TaskModel], 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[TaskModel]] = (
|
||||
select(TaskModel).order_by(TaskModel.name.asc()).limit(per_page).offset((page - 1) * per_page)
|
||||
)
|
||||
result: Result[tuple[TaskModel]] = 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(TaskModel))
|
||||
return int(result.scalar_one())
|
||||
|
||||
async def get(self, identifier: int | str) -> TaskModel | None:
|
||||
async with self.session() as session:
|
||||
if not identifier:
|
||||
return None
|
||||
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = TaskModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause = or_(TaskModel.id == int(identifier), TaskModel.name == identifier)
|
||||
else:
|
||||
clause = TaskModel.name == identifier
|
||||
|
||||
result: Result[tuple[TaskModel]] = await session.execute(select(TaskModel).where(clause).limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_name(self, name: str, exclude_id: int | None = None) -> TaskModel | None:
|
||||
async with self.session() as session:
|
||||
query: Select[tuple[TaskModel]] = select(TaskModel).where(TaskModel.name == name)
|
||||
if exclude_id is not None:
|
||||
query = query.where(TaskModel.id != exclude_id)
|
||||
|
||||
result: Result[tuple[TaskModel]] = await session.execute(query.limit(1))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_all_enabled(self) -> list[TaskModel]:
|
||||
"""Get all enabled tasks."""
|
||||
async with self.session() as session:
|
||||
result: Result[tuple[TaskModel]] = await session.execute(
|
||||
select(TaskModel).where(TaskModel.enabled == True).order_by(TaskModel.name.asc()) # noqa: E712
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_all_with_timer(self) -> list[TaskModel]:
|
||||
"""Get all tasks that have a timer configured."""
|
||||
async with self.session() as session:
|
||||
result: Result[tuple[TaskModel]] = await session.execute(
|
||||
select(TaskModel).where(TaskModel.timer != "").order_by(TaskModel.name.asc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create(self, payload: TaskModel | dict) -> TaskModel:
|
||||
async with self.session() as session:
|
||||
model: TaskModel = TaskModel(**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"Task 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]) -> TaskModel:
|
||||
"""Update an existing task."""
|
||||
async with self.session() as session:
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = TaskModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause = or_(TaskModel.id == int(identifier), TaskModel.name == identifier)
|
||||
else:
|
||||
clause = TaskModel.name == identifier
|
||||
|
||||
result: Result[tuple[TaskModel]] = await session.execute(select(TaskModel).where(clause).limit(1))
|
||||
model: TaskModel | None = result.scalar_one_or_none()
|
||||
|
||||
if model is None:
|
||||
msg: str = f"Task '{identifier}' not found."
|
||||
raise KeyError(msg)
|
||||
|
||||
if "name" in payload:
|
||||
existing: TaskModel | None = await self.get_by_name(name=payload["name"], exclude_id=model.id)
|
||||
if existing is not None:
|
||||
msg = f"Task 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) -> TaskModel:
|
||||
async with self.session() as session:
|
||||
if isinstance(identifier, int):
|
||||
clause: ColumnElement[bool] = TaskModel.id == identifier
|
||||
elif isinstance(identifier, str) and identifier.isdigit():
|
||||
clause = or_(TaskModel.id == int(identifier), TaskModel.name == identifier)
|
||||
else:
|
||||
clause = TaskModel.name == identifier
|
||||
|
||||
result: Result[tuple[TaskModel]] = await session.execute(select(TaskModel).where(clause).limit(1))
|
||||
model: TaskModel | None = result.scalar_one_or_none()
|
||||
|
||||
if model is None:
|
||||
msg: str = f"Task '{identifier}' not found."
|
||||
raise KeyError(msg)
|
||||
|
||||
await session.delete(model)
|
||||
await session.commit()
|
||||
return model
|
||||
480
app/features/tasks/router.py
Normal file
480
app/features/tasks/router.py
Normal file
|
|
@ -0,0 +1,480 @@
|
|||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web import Request, Response
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.features.core.schemas import CEAction, CEFeature, ConfigEvent, Pagination
|
||||
from app.features.core.utils import build_pagination, format_validation_errors, normalize_pagination
|
||||
from app.features.tasks.definitions.results import HandleTask as ExtendedTask
|
||||
from app.features.tasks.definitions.results import TaskFailure, TaskResult
|
||||
from app.features.tasks.definitions.service import TaskHandle
|
||||
from app.features.tasks.repository import TasksRepository
|
||||
from app.features.tasks.schemas import Task, TaskList, TaskPatch
|
||||
from app.library.ag_utils import ag
|
||||
from app.library.config import Config
|
||||
from app.library.encoder import Encoder
|
||||
from app.library.Events import EventBus, Events
|
||||
from app.library.router import route
|
||||
from app.library.Utils import get_channel_images, get_file, parse_outtmpl, validate_url
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _model(model: Any) -> Task:
|
||||
return Task.model_validate(model)
|
||||
|
||||
|
||||
def _serialize(model: Any) -> dict:
|
||||
return _model(model).model_dump()
|
||||
|
||||
|
||||
@route("GET", "api/tasks/", "tasks_list")
|
||||
async def tasks_list(request: Request, repo: TasksRepository, encoder: Encoder) -> Response:
|
||||
page, per_page = normalize_pagination(request)
|
||||
items, total, current_page, total_pages = await repo.list_paginated(page, per_page)
|
||||
return web.json_response(
|
||||
data=TaskList(
|
||||
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/tasks/", "tasks_add")
|
||||
async def tasks_add(request: Request, repo: TasksRepository, encoder: Encoder, notify: EventBus) -> Response:
|
||||
data = await request.json()
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting dict."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
item: Task = Task.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate task.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if await repo.get_by_name(item.name):
|
||||
return web.json_response(
|
||||
data={"error": f"Task with name '{item.name}' already exists."},
|
||||
status=web.HTTPConflict.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
created = await repo.create(item.model_dump())
|
||||
saved = _serialize(created)
|
||||
except ValueError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
notify.emit(Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.TASKS, action=CEAction.CREATE, data=saved))
|
||||
|
||||
return web.json_response(data=saved, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("GET", r"api/tasks/{id:\d+}", "tasks_get")
|
||||
async def tasks_get(request: Request, repo: TasksRepository, encoder: Encoder) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
model = await repo.get(identifier)
|
||||
if not model:
|
||||
return web.json_response({"error": "Task not found"}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
return web.json_response(data=_serialize(model), status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("DELETE", r"api/tasks/{id:\d+}", "tasks_delete")
|
||||
async def tasks_delete(request: Request, repo: TasksRepository, encoder: Encoder, notify: EventBus) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
deleted = _serialize(await repo.delete(identifier))
|
||||
notify.emit(
|
||||
Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.TASKS, action=CEAction.DELETE, data=deleted)
|
||||
)
|
||||
return web.json_response(
|
||||
data=deleted,
|
||||
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", r"api/tasks/{id:\d+}", "tasks_patch")
|
||||
async def tasks_patch(request: Request, repo: TasksRepository, encoder: Encoder, notify: EventBus) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
model = await repo.get(identifier)
|
||||
if not model:
|
||||
return web.json_response({"error": "Task not found"}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
data = await request.json()
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting dict."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
validated = TaskPatch.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate task.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if validated.name and await repo.get_by_name(validated.name, exclude_id=model.id):
|
||||
return web.json_response(
|
||||
data={"error": f"Task with name '{validated.name}' already exists."},
|
||||
status=web.HTTPConflict.status_code,
|
||||
)
|
||||
|
||||
updated = _serialize(await repo.update(model.id, validated.model_dump(exclude_unset=True)))
|
||||
notify.emit(Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.TASKS, action=CEAction.UPDATE, data=updated))
|
||||
return web.json_response(
|
||||
data=updated,
|
||||
status=web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("PUT", r"api/tasks/{id:\d+}", "tasks_update")
|
||||
async def tasks_update(request: Request, repo: TasksRepository, encoder: Encoder, notify: EventBus) -> Response:
|
||||
if not (identifier := request.match_info.get("id")):
|
||||
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
model = await repo.get(identifier)
|
||||
if not model:
|
||||
return web.json_response({"error": "Task not found"}, status=web.HTTPNotFound.status_code)
|
||||
|
||||
data = await request.json()
|
||||
|
||||
if not isinstance(data, dict):
|
||||
return web.json_response(
|
||||
{"error": "Invalid request body expecting dict."},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
try:
|
||||
validated = Task.model_validate(data)
|
||||
except ValidationError as exc:
|
||||
return web.json_response(
|
||||
data={"error": "Failed to validate task.", "detail": format_validation_errors(exc)},
|
||||
status=web.HTTPBadRequest.status_code,
|
||||
)
|
||||
|
||||
if validated.name and await repo.get_by_name(validated.name, exclude_id=model.id):
|
||||
return web.json_response(
|
||||
data={"error": f"Task with name '{validated.name}' already exists."},
|
||||
status=web.HTTPConflict.status_code,
|
||||
)
|
||||
|
||||
updated = _serialize(await repo.update(model.id, validated.model_dump(exclude_unset=True)))
|
||||
notify.emit(Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.TASKS, action=CEAction.UPDATE, data=updated))
|
||||
|
||||
return web.json_response(data=updated, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
|
||||
|
||||
@route("POST", "api/tasks/inspect", "task_handler_inspect")
|
||||
async def task_handler_inspect(request: Request, handler: TaskHandle, encoder: Encoder, config: Config) -> Response:
|
||||
"""
|
||||
Check if handler can process the given URL.
|
||||
|
||||
Args:
|
||||
request: The request object.
|
||||
handler: The handler service instance.
|
||||
encoder: The encoder instance.
|
||||
config: The config instance.
|
||||
|
||||
Returns:
|
||||
The response object.
|
||||
|
||||
"""
|
||||
data = await request.json()
|
||||
|
||||
url: str | None = data.get("url") if isinstance(data, dict) else None
|
||||
if not url:
|
||||
return web.json_response({"error": "url is required."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
static_only: bool = data.get("static_only", False) if isinstance(data, dict) else False
|
||||
if not static_only:
|
||||
try:
|
||||
validate_url(url, allow_internal=config.allow_internal_urls)
|
||||
except ValueError as e:
|
||||
return web.json_response({"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
preset: str = data.get("preset", "") if isinstance(data, dict) else ""
|
||||
handler_name: str | None = data.get("handler") if isinstance(data, dict) else None
|
||||
|
||||
try:
|
||||
result: TaskResult | TaskFailure = await handler.inspect(
|
||||
url=url, preset=preset, handler_name=handler_name, static_only=static_only
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
return web.json_response(
|
||||
{"error": "Failed to inspect handler.", "message": str(e)},
|
||||
status=web.HTTPInternalServerError.status_code,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
data=result,
|
||||
status=web.HTTPBadRequest.status_code if isinstance(result, TaskFailure) else web.HTTPOk.status_code,
|
||||
dumps=encoder.encode,
|
||||
)
|
||||
|
||||
|
||||
@route("POST", r"api/tasks/{id:\d+}/mark", "tasks_mark")
|
||||
async def task_mark(request: Request, repo: TasksRepository, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Mark all items from task as downloaded.
|
||||
|
||||
Args:
|
||||
request: The request object.
|
||||
repo: The tasks repository instance.
|
||||
encoder: The encoder instance.
|
||||
|
||||
Returns:
|
||||
The response object.
|
||||
|
||||
"""
|
||||
if not (task_id := request.match_info.get("id")):
|
||||
return web.json_response(data={"error": "No task id."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
model = await repo.get(int(task_id))
|
||||
if not model:
|
||||
return web.json_response(
|
||||
data={"error": f"Task '{task_id}' does not exist."}, status=web.HTTPNotFound.status_code
|
||||
)
|
||||
|
||||
# Convert to extended Task with handler methods
|
||||
task = ExtendedTask.model_validate(model)
|
||||
_status, _message = await task.mark()
|
||||
|
||||
if not _status:
|
||||
return web.json_response(data={"error": _message}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
return web.json_response(data={"message": _message}, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
except ValueError as e:
|
||||
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
|
||||
@route("DELETE", r"api/tasks/{id:\d+}/mark", "tasks_unmark")
|
||||
async def task_unmark(request: Request, repo: TasksRepository, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Remove all task items from download archive.
|
||||
|
||||
Args:
|
||||
request: The request object.
|
||||
repo: The tasks repository instance.
|
||||
encoder: The encoder instance.
|
||||
|
||||
Returns:
|
||||
The response object.
|
||||
|
||||
"""
|
||||
if not (task_id := request.match_info.get("id")):
|
||||
return web.json_response(data={"error": "No task id."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
try:
|
||||
model = await repo.get(int(task_id))
|
||||
if not model:
|
||||
return web.json_response(
|
||||
data={"error": f"Task '{task_id}' does not exist."}, status=web.HTTPNotFound.status_code
|
||||
)
|
||||
|
||||
# Convert to extended Task with handler methods
|
||||
task = ExtendedTask.model_validate(model)
|
||||
_status, _message = await task.unmark()
|
||||
|
||||
if not _status:
|
||||
return web.json_response(data={"error": _message}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
return web.json_response(data={"message": _message}, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
except ValueError as e:
|
||||
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
|
||||
@route("POST", r"api/tasks/{id:\d+}/metadata", "tasks_metadata")
|
||||
async def task_metadata(request: Request, repo: TasksRepository, config: Config, encoder: Encoder) -> Response:
|
||||
"""
|
||||
Generate metadata for the task.
|
||||
|
||||
Args:
|
||||
request: The request object.
|
||||
repo: The tasks repository instance.
|
||||
config: The config instance.
|
||||
encoder: The encoder instance.
|
||||
|
||||
Returns:
|
||||
The response object.
|
||||
|
||||
"""
|
||||
task_id = request.match_info.get("id")
|
||||
|
||||
try:
|
||||
if not (model := await repo.get(int(task_id))):
|
||||
return web.json_response(
|
||||
data={"error": f"Task '{task_id}' does not exist."}, status=web.HTTPNotFound.status_code
|
||||
)
|
||||
|
||||
# Convert to extended Task with handler methods
|
||||
task = ExtendedTask.model_validate(model)
|
||||
|
||||
(save_path, _) = get_file(config.download_path, task.folder)
|
||||
if not str(save_path or "").startswith(str(config.download_path)):
|
||||
return web.json_response(data={"error": "Invalid task folder."}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not save_path.exists():
|
||||
save_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
metadata, status, message = await task.fetch_metadata()
|
||||
if not status:
|
||||
return web.json_response(data={"error": message}, status=web.HTTPBadRequest.status_code)
|
||||
|
||||
if not task.folder:
|
||||
try:
|
||||
ytdlp_opts: dict = task.get_ytdlp_opts().get_all()
|
||||
outtmpl: str = parse_outtmpl(
|
||||
output_template=ytdlp_opts.get("outtmpl", {}).get("default", "{title} [{id}]"),
|
||||
info_dict=metadata,
|
||||
params=ytdlp_opts,
|
||||
)
|
||||
if outtmpl:
|
||||
_path: Path = save_path / outtmpl
|
||||
if not _path.is_dir():
|
||||
_path: Path = _path.parent
|
||||
|
||||
(save_path, _) = get_file(config.download_path, _path.relative_to(config.download_path))
|
||||
if not str(save_path or "").startswith(str(config.download_path)):
|
||||
return web.json_response(
|
||||
data={"error": "Invalid final path folder."}, status=web.HTTPBadRequest.status_code
|
||||
)
|
||||
|
||||
if not save_path.exists():
|
||||
save_path.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
LOG.warning(f"Failed to resolve final path from outtmpl. '{e!s}'")
|
||||
|
||||
info = {
|
||||
"id": ag(metadata, ["id", "channel_id"]),
|
||||
"id_type": metadata.get("extractor", "").split(":")[0].lower() if metadata.get("extractor") else None,
|
||||
"title": ag(metadata, ["title", "fulltitle"]) or None,
|
||||
"description": metadata.get("description", ""),
|
||||
"uploader": metadata.get("uploader", ""),
|
||||
"tags": metadata.get("tags", []),
|
||||
"year": metadata.get("release_year"),
|
||||
"thumbnails": get_channel_images(metadata.get("thumbnails", {})),
|
||||
}
|
||||
|
||||
if not info.get("title"):
|
||||
return web.json_response(
|
||||
data={"error": "Failed to get title from metadata."}, status=web.HTTPBadRequest.status_code
|
||||
)
|
||||
|
||||
LOG.info(f"Generating metadata for task '{task.name}' in '{save_path!s}'")
|
||||
|
||||
from yt_dlp.utils import sanitize_filename
|
||||
|
||||
from app.yt_dlp_plugins.postprocessor.nfo_maker import NFOMakerPP
|
||||
|
||||
title: str = sanitize_filename(info.get("title"))
|
||||
info_file: Path = save_path / f"{title} [{info.get('id')}].info.json"
|
||||
info_file.write_text(encoder.encode(metadata), encoding="utf-8")
|
||||
info["json_file"] = str(info_file.relative_to(config.download_path))
|
||||
|
||||
xml_file: Path = save_path / "tvshow.nfo"
|
||||
info["nfo_file"] = str(xml_file.relative_to(config.download_path))
|
||||
|
||||
xml_content = "<tvshow>\n"
|
||||
xml_content += f" <title>{NFOMakerPP._escape_text(info.get('title'))}</title>\n"
|
||||
if info.get("description"):
|
||||
xml_content += (
|
||||
f" <plot>{NFOMakerPP._escape_text(NFOMakerPP._clean_description(info.get('description')))}</plot>\n"
|
||||
)
|
||||
if info.get("id"):
|
||||
xml_content += f" <id>{NFOMakerPP._escape_text(info.get('id'))}</id>\n"
|
||||
if info.get("id_type") and info.get("id"):
|
||||
xml_content += f' <uniqueid type="{NFOMakerPP._escape_text(info.get("id_type"))}" default="true">{NFOMakerPP._escape_text(info.get("id"))}</uniqueid>\n'
|
||||
if info.get("uploader"):
|
||||
xml_content += f" <studio>{NFOMakerPP._escape_text(info.get('uploader'))}</studio>\n"
|
||||
if info.get("tags", []):
|
||||
for tag in info.get("tags", []):
|
||||
xml_content += f" <tag>{NFOMakerPP._escape_text(tag)}</tag>\n"
|
||||
if info.get("year"):
|
||||
xml_content += f" <year>{info.get('year')}</year>\n"
|
||||
xml_content += " <status>Continuing</status>\n"
|
||||
xml_content += "</tvshow>\n"
|
||||
xml_file.write_text(xml_content, encoding="utf-8")
|
||||
|
||||
try:
|
||||
from yt_dlp.utils.networking import random_user_agent
|
||||
|
||||
from app.library.httpx_client import async_client
|
||||
|
||||
ytdlp_args: dict = task.get_ytdlp_opts().get_all()
|
||||
opts: dict[str, Any] = {
|
||||
"headers": {
|
||||
"User-Agent": request.headers.get("User-Agent", ytdlp_args.get("user_agent", random_user_agent())),
|
||||
},
|
||||
}
|
||||
if proxy := ytdlp_args.get("proxy"):
|
||||
opts["proxy"] = proxy
|
||||
|
||||
try:
|
||||
from httpx_curl_cffi import AsyncCurlTransport, CurlOpt
|
||||
|
||||
opts["transport"] = AsyncCurlTransport(
|
||||
impersonate="chrome",
|
||||
default_headers=True,
|
||||
curl_options={CurlOpt.FRESH_CONNECT: True},
|
||||
)
|
||||
opts.pop("headers", None)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async with async_client(**opts) as client:
|
||||
for key in info.get("thumbnails", {}):
|
||||
try:
|
||||
url = info["thumbnails"][key]
|
||||
LOG.info(f"Fetching thumbnail '{key}' from '{url}'")
|
||||
if not url:
|
||||
continue
|
||||
|
||||
try:
|
||||
validate_url(url, allow_internal=config.allow_internal_urls)
|
||||
except ValueError:
|
||||
LOG.warning(f"Invalid thumbnail url '{url}'")
|
||||
continue
|
||||
|
||||
resp = await client.request(method="GET", url=url, follow_redirects=True)
|
||||
|
||||
img_file = save_path / f"{key}.jpg"
|
||||
img_file.write_bytes(resp.content)
|
||||
info["thumbnails"][key] = str(img_file.relative_to(config.download_path))
|
||||
except Exception as e:
|
||||
LOG.warning(f"Failed to fetch thumbnail '{key}' from '{url}'. '{e!s}'")
|
||||
continue
|
||||
except Exception as e:
|
||||
LOG.warning(f"Failed to fetch thumbnails. '{e!s}'")
|
||||
|
||||
return web.json_response(data=info, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||
except ValueError as e:
|
||||
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||
131
app/features/tasks/schemas.py
Normal file
131
app/features/tasks/schemas.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime # noqa: TC003
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.features.core.schemas import Pagination
|
||||
|
||||
|
||||
class Task(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True, str_strip_whitespace=True)
|
||||
|
||||
id: int | None = None
|
||||
name: str = Field(min_length=1)
|
||||
url: str = Field(min_length=1)
|
||||
folder: str = ""
|
||||
preset: str = ""
|
||||
timer: str = ""
|
||||
template: str = ""
|
||||
cli: str = ""
|
||||
auto_start: bool = True
|
||||
handler_enabled: bool = True
|
||||
enabled: bool = True
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
|
||||
@field_validator("name", mode="before")
|
||||
@classmethod
|
||||
def _normalize_name(cls, value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
msg: str = "Name must be a string."
|
||||
raise ValueError(msg)
|
||||
value = value.strip()
|
||||
if not value:
|
||||
msg = "Name cannot be empty."
|
||||
raise ValueError(msg)
|
||||
return value
|
||||
|
||||
@field_validator("url", mode="before")
|
||||
@classmethod
|
||||
def _normalize_url(cls, value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
msg: str = "URL must be a string."
|
||||
raise ValueError(msg)
|
||||
value = value.strip()
|
||||
if not value:
|
||||
msg = "URL cannot be empty."
|
||||
raise ValueError(msg)
|
||||
|
||||
from app.library.Utils import validate_url
|
||||
|
||||
try:
|
||||
validate_url(value, allow_internal=True)
|
||||
except ValueError as e:
|
||||
msg = f"Invalid URL format: {e!s}"
|
||||
raise ValueError(msg) from e
|
||||
|
||||
return value
|
||||
|
||||
@field_validator("timer", mode="before")
|
||||
@classmethod
|
||||
def _validate_timer(cls, value: Any) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
if not isinstance(value, str):
|
||||
msg: str = "Timer must be a string."
|
||||
raise ValueError(msg)
|
||||
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
try:
|
||||
from cronsim import CronSim
|
||||
|
||||
CronSim(value, datetime.now(UTC))
|
||||
except Exception as e:
|
||||
msg = f"Invalid timer format: {e!s}"
|
||||
raise ValueError(msg) from e
|
||||
|
||||
return value
|
||||
|
||||
@field_validator("cli", mode="before")
|
||||
@classmethod
|
||||
def _validate_cli(cls, value: Any) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
if not isinstance(value, str):
|
||||
msg: str = "CLI must be a string."
|
||||
raise ValueError(msg)
|
||||
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return ""
|
||||
|
||||
from app.library.Utils import arg_converter
|
||||
|
||||
try:
|
||||
arg_converter(args=value)
|
||||
except Exception as e:
|
||||
msg = f"Invalid command options for yt-dlp: {e!s}"
|
||||
raise ValueError(msg) from e
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class TaskPatch(BaseModel):
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
name: str | None = None
|
||||
url: str | None = None
|
||||
folder: str | None = None
|
||||
preset: str | None = None
|
||||
timer: str | None = None
|
||||
template: str | None = None
|
||||
cli: str | None = None
|
||||
auto_start: bool | None = None
|
||||
handler_enabled: bool | None = None
|
||||
enabled: bool | None = None
|
||||
|
||||
|
||||
class TaskList(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
items: list[Task] = Field(default_factory=list)
|
||||
pagination: Pagination
|
||||
185
app/features/tasks/service.py
Normal file
185
app/features/tasks/service.py
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from app.features.core.schemas import CEAction, CEFeature, ConfigEvent
|
||||
from app.features.tasks.models import TaskModel
|
||||
from app.features.tasks.utils import cron_time
|
||||
from app.library.Events import Event, EventBus, Events
|
||||
from app.library.Scheduler import Scheduler
|
||||
from app.library.Services import Services
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from aiohttp import web
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Tasks(metaclass=Singleton):
|
||||
def __init__(self):
|
||||
from app.features.tasks.deps import get_tasks_repo
|
||||
|
||||
self._repo = get_tasks_repo()
|
||||
self._loaded: bool = False
|
||||
self._handlers_service = None
|
||||
self._scheduler = Scheduler.get_instance()
|
||||
|
||||
@staticmethod
|
||||
def get_instance() -> Tasks:
|
||||
"""Get the singleton instance of Tasks."""
|
||||
return Tasks()
|
||||
|
||||
def attach(self, _: web.Application) -> None:
|
||||
async def handle_started(_, __):
|
||||
await self._repo.run_migrations()
|
||||
await self._load_tasks()
|
||||
await self._init_handlers_service(self._scheduler)
|
||||
|
||||
Services.get_instance().add("tasks_service", self).add("tasks_repository", self._repo)
|
||||
|
||||
async def handle_config_update(e: Event, _):
|
||||
if isinstance(e.data, ConfigEvent) and CEFeature.TASKS == e.data.feature:
|
||||
await self._handle_task_change(e.data)
|
||||
|
||||
EventBus.get_instance().subscribe(
|
||||
Events.CONFIG_UPDATE, handle_config_update, "Tasks.config_update_scheduler"
|
||||
).subscribe(Events.STARTED, handle_started, "TasksRepository.run_migrations")
|
||||
|
||||
async def on_shutdown(self, _: web.Application) -> None:
|
||||
pass
|
||||
|
||||
async def _load_tasks(self) -> None:
|
||||
tasks = await self._repo.list()
|
||||
|
||||
for task in tasks:
|
||||
if not task.timer or not task.enabled:
|
||||
continue
|
||||
|
||||
try:
|
||||
self._scheduler.add(timer=task.timer, func=self._runner, args=(task,), id=f"task-cronjob-{task.id}")
|
||||
LOG.info(f"Task '{task.id}: {task.name}' queued to be executed '{cron_time(task.timer)}'.")
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Failed to queue task '{task.name}'. '{e!s}'.")
|
||||
|
||||
async def _init_handlers_service(self, scheduler) -> None:
|
||||
"""Initialize the handlers service after migrations."""
|
||||
if self._handlers_service is not None:
|
||||
return
|
||||
|
||||
from app.features.tasks.definitions.service import TaskHandle
|
||||
from app.library.config import Config
|
||||
|
||||
config = Config.get_instance()
|
||||
self._handlers_service = TaskHandle(scheduler, self._repo, config)
|
||||
self._handlers_service.load()
|
||||
LOG.debug("Task handlers service initialized.")
|
||||
Services.get_instance().add("task_handle_service", self._handlers_service)
|
||||
|
||||
async def _handle_task_change(self, event_data) -> None:
|
||||
task_data: dict = event_data.data
|
||||
task_id: str = f"task-cronjob-{task_data['id']}"
|
||||
|
||||
if CEAction.DELETE == event_data.action:
|
||||
if self._scheduler.has(task_id):
|
||||
self._scheduler.remove(task_id)
|
||||
|
||||
elif event_data.action in (CEAction.CREATE, CEAction.UPDATE):
|
||||
if not (task := await self._repo.get(int(task_data["id"]))):
|
||||
return
|
||||
|
||||
if self._scheduler.has(task_id):
|
||||
self._scheduler.remove(task_id)
|
||||
|
||||
if task.timer and task.enabled:
|
||||
self._scheduler.add(timer=task.timer, func=self._runner, args=(task,), id=task_id)
|
||||
LOG.info(f"Task '{task.id}: {task.name}' queued to be executed '{cron_time(task.timer)}'.")
|
||||
|
||||
async def _runner(self, task: TaskModel) -> None:
|
||||
"""
|
||||
Execute a scheduled task.
|
||||
|
||||
Args:
|
||||
task: The TaskModel to execute.
|
||||
|
||||
"""
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from app.library.config import Config
|
||||
from app.library.downloads import DownloadQueue
|
||||
from app.library.ItemDTO import Item
|
||||
|
||||
timeNow: str = datetime.now(UTC).isoformat()
|
||||
try:
|
||||
if not (task := await self._repo.get(task.id)):
|
||||
LOG.info(f"Task '{task.name}' no longer exists.")
|
||||
return
|
||||
|
||||
if not task.enabled:
|
||||
LOG.debug(f"Task '{task.name}' is disabled. Skipping execution.")
|
||||
return
|
||||
|
||||
if not task.url:
|
||||
LOG.error(f"Failed to dispatch '{task.name}'. No URL found.")
|
||||
return
|
||||
|
||||
started: float = time.time()
|
||||
|
||||
config = Config.get_instance()
|
||||
preset: str = task.preset or config.default_preset
|
||||
folder: str = task.folder or ""
|
||||
template: str = task.template or ""
|
||||
cli: str = task.cli or ""
|
||||
|
||||
notify: EventBus = EventBus.get_instance()
|
||||
|
||||
status = await DownloadQueue.get_instance().add(
|
||||
item=Item.format(
|
||||
{
|
||||
"url": task.url,
|
||||
"preset": preset,
|
||||
"folder": folder,
|
||||
"template": template,
|
||||
"cli": cli,
|
||||
"auto_start": task.auto_start,
|
||||
"extras": {
|
||||
"source_name": task.name,
|
||||
"source_id": str(task.id),
|
||||
"source_handler": "Tasks",
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
timeNow = datetime.now(UTC).isoformat()
|
||||
ended: float = time.time()
|
||||
LOG.info(f"Task '{task.name}' completed at '{timeNow}' took '{ended - started:.2f}' seconds.")
|
||||
|
||||
notify.emit(
|
||||
Events.TASK_DISPATCHED,
|
||||
data={**status, "preset": task.preset} if status else {"preset": task.preset},
|
||||
title=f"Task '{task.name}' dispatched",
|
||||
message=f"Task '{task.name}' dispatched at '{timeNow}'.",
|
||||
)
|
||||
notify.emit(
|
||||
Events.LOG_SUCCESS,
|
||||
data={"preset": task.preset, "lowPriority": True},
|
||||
title="Task completed",
|
||||
message=f"Task '{task.name}' completed in '{ended - started:.2f}'.",
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to execute '{task.name}' at '{timeNow}'. '{e!s}'.")
|
||||
EventBus.get_instance().emit(
|
||||
Events.LOG_ERROR,
|
||||
data={"preset": task.preset},
|
||||
title="Task failed",
|
||||
message=f"Failed to execute '{task.name}'. '{e!s}'",
|
||||
)
|
||||
|
||||
@property
|
||||
def handlers(self):
|
||||
"""Get the handlers service instance."""
|
||||
return self._handlers_service
|
||||
1
app/features/tasks/tests/__init__.py
Normal file
1
app/features/tasks/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Tests for tasks feature."""
|
||||
289
app/features/tasks/tests/test_tasks_repository.py
Normal file
289
app/features/tasks/tests/test_tasks_repository.py
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
"""Tests for TasksRepository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from app.features.tasks.models import TaskModel
|
||||
from app.features.tasks.repository import TasksRepository
|
||||
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."""
|
||||
TasksRepository._reset_singleton()
|
||||
SqliteStore._reset_singleton()
|
||||
|
||||
store = SqliteStore(db_path=str(":memory:"))
|
||||
await store.get_connection()
|
||||
|
||||
repository = TasksRepository.get_instance()
|
||||
|
||||
yield repository
|
||||
|
||||
if store._conn:
|
||||
await store._conn.close()
|
||||
if store._engine:
|
||||
await store._engine.dispose()
|
||||
|
||||
TasksRepository._reset_singleton()
|
||||
SqliteStore._reset_singleton()
|
||||
|
||||
|
||||
class TestTasksRepository:
|
||||
"""Test suite for TasksRepository database operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repository_singleton(self, repo):
|
||||
"""Verify repository follows singleton pattern."""
|
||||
instance1 = TasksRepository.get_instance()
|
||||
instance2 = TasksRepository.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 tasks exist."""
|
||||
tasks = await repo.list()
|
||||
assert tasks == [], "Should return empty list when no tasks"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_count_empty(self, repo):
|
||||
"""Count returns 0 when no tasks exist."""
|
||||
count = await repo.count()
|
||||
assert count == 0, "Should return 0 when no tasks exist"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_task(self, repo):
|
||||
"""Create task with valid data."""
|
||||
data = {
|
||||
"name": "Daily Download",
|
||||
"url": "https://example.com/video",
|
||||
"folder": "/downloads",
|
||||
"preset": "audio",
|
||||
"timer": "0 0 * * *",
|
||||
"template": "%(title)s.%(ext)s",
|
||||
"cli": "--format best",
|
||||
"auto_start": True,
|
||||
"handler_enabled": True,
|
||||
"enabled": True,
|
||||
}
|
||||
|
||||
model = await repo.create(data)
|
||||
|
||||
assert model.id is not None, "Should generate ID for new task"
|
||||
assert model.name == "Daily Download", "Should store name correctly"
|
||||
assert model.url == "https://example.com/video", "Should store URL correctly"
|
||||
assert model.folder == "/downloads", "Should store folder correctly"
|
||||
assert model.preset == "audio", "Should store preset correctly"
|
||||
assert model.timer == "0 0 * * *", "Should store timer correctly"
|
||||
assert model.template == "%(title)s.%(ext)s", "Should store template correctly"
|
||||
assert model.cli == "--format best", "Should store CLI correctly"
|
||||
assert model.auto_start is True, "Should store auto_start correctly"
|
||||
assert model.handler_enabled is True, "Should store handler_enabled correctly"
|
||||
assert model.enabled is True, "Should store enabled correctly"
|
||||
assert model.created_at is not None, "Should have created_at timestamp"
|
||||
assert model.updated_at is not None, "Should have updated_at timestamp"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_with_minimal_data(self, repo):
|
||||
"""Create task with minimal required data."""
|
||||
data = {
|
||||
"name": "Simple Task",
|
||||
"url": "https://example.com",
|
||||
}
|
||||
|
||||
model = await repo.create(data)
|
||||
|
||||
assert model.name == "Simple Task", "Should store name"
|
||||
assert model.url == "https://example.com", "Should store URL"
|
||||
assert model.folder == "", "Should default folder to empty string"
|
||||
assert model.preset == "", "Should default preset to empty string"
|
||||
assert model.timer == "", "Should default timer to empty string"
|
||||
assert model.template == "", "Should default template to empty string"
|
||||
assert model.cli == "", "Should default CLI to empty string"
|
||||
assert model.auto_start is True, "Should default auto_start to True"
|
||||
assert model.handler_enabled is True, "Should default handler_enabled to True"
|
||||
assert model.enabled is True, "Should default enabled to True"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_id(self, repo):
|
||||
"""Get task by integer ID."""
|
||||
created = await repo.create(
|
||||
{
|
||||
"name": "Get Test",
|
||||
"url": "https://example.com",
|
||||
}
|
||||
)
|
||||
|
||||
retrieved = await repo.get(created.id)
|
||||
|
||||
assert retrieved is not None, "Should retrieve created task"
|
||||
assert retrieved.id == created.id, "Should retrieve correct task by ID"
|
||||
assert retrieved.name == "Get Test", "Should match created task name"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_string_id(self, repo):
|
||||
"""Get task by string ID."""
|
||||
created = await repo.create(
|
||||
{
|
||||
"name": "String ID Test",
|
||||
"url": "https://example.com",
|
||||
}
|
||||
)
|
||||
|
||||
retrieved = await repo.get(str(created.id))
|
||||
|
||||
assert retrieved is not None, "Should retrieve by string ID"
|
||||
assert retrieved.id == created.id, "Should match created task ID"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nonexistent(self, repo):
|
||||
"""Get nonexistent task returns None."""
|
||||
result = await repo.get(99999)
|
||||
assert result is None, "Should return None for nonexistent ID"
|
||||
|
||||
result = await repo.get("99999")
|
||||
assert result is None, "Should return None for nonexistent string ID"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_task(self, repo):
|
||||
"""Update existing task."""
|
||||
created = await repo.create(
|
||||
{
|
||||
"name": "Update Test",
|
||||
"url": "https://example.com",
|
||||
"preset": "video",
|
||||
"enabled": True,
|
||||
}
|
||||
)
|
||||
|
||||
updated = await repo.update(
|
||||
created.id,
|
||||
{
|
||||
"name": "Updated Name",
|
||||
"preset": "audio",
|
||||
"enabled": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert updated.name == "Updated Name", "Should update name"
|
||||
assert updated.preset == "audio", "Should update preset"
|
||||
assert updated.enabled is False, "Should update enabled"
|
||||
assert updated.url == "https://example.com", "Should preserve unchanged URL"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_nonexistent_raises(self, repo):
|
||||
"""Update nonexistent task raises KeyError."""
|
||||
with pytest.raises(KeyError, match="not found"):
|
||||
await repo.update(99999, {"name": "should_fail"})
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_task(self, repo):
|
||||
"""Delete existing task."""
|
||||
created = await repo.create(
|
||||
{
|
||||
"name": "Delete Test",
|
||||
"url": "https://example.com",
|
||||
}
|
||||
)
|
||||
|
||||
deleted = await repo.delete(created.id)
|
||||
|
||||
assert deleted.id == created.id, "Should return deleted task"
|
||||
|
||||
result = await repo.get(created.id)
|
||||
assert result is None, "Deleted task should not be retrievable"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_nonexistent_raises(self, repo):
|
||||
"""Delete nonexistent task raises KeyError."""
|
||||
with pytest.raises(KeyError, match="not found"):
|
||||
await repo.delete(99999)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_name(self, repo):
|
||||
"""Get task by name."""
|
||||
await repo.create(
|
||||
{
|
||||
"name": "Named Task",
|
||||
"url": "https://example.com",
|
||||
}
|
||||
)
|
||||
|
||||
retrieved = await repo.get_by_name("Named Task")
|
||||
|
||||
assert retrieved is not None, "Should retrieve by name"
|
||||
assert retrieved.name == "Named Task", "Should match task name"
|
||||
|
||||
@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",
|
||||
"url": "https://example.com",
|
||||
}
|
||||
)
|
||||
|
||||
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_get_all_enabled(self, repo):
|
||||
"""Get all enabled tasks."""
|
||||
await repo.create({"name": "Enabled 1", "url": "https://example.com", "enabled": True})
|
||||
await repo.create({"name": "Disabled", "url": "https://example.com", "enabled": False})
|
||||
await repo.create({"name": "Enabled 2", "url": "https://example.com", "enabled": True})
|
||||
|
||||
enabled = await repo.get_all_enabled()
|
||||
|
||||
assert len(enabled) == 2, "Should return only enabled tasks"
|
||||
assert all(task.enabled for task in enabled), "All tasks should be enabled"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_with_timer(self, repo):
|
||||
"""Get all tasks with timer configured."""
|
||||
await repo.create({"name": "With Timer", "url": "https://example.com", "timer": "0 0 * * *"})
|
||||
await repo.create({"name": "No Timer", "url": "https://example.com", "timer": ""})
|
||||
await repo.create({"name": "Another Timer", "url": "https://example.com", "timer": "0 12 * * *"})
|
||||
|
||||
with_timer = await repo.get_all_with_timer()
|
||||
|
||||
assert len(with_timer) == 2, "Should return only tasks with timer"
|
||||
assert all(task.timer for task in with_timer), "All tasks should have timer"
|
||||
|
||||
@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"Task {i}",
|
||||
"url": "https://example.com",
|
||||
}
|
||||
)
|
||||
|
||||
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 name ascending."""
|
||||
await repo.create({"name": "Charlie", "url": "https://example.com"})
|
||||
await repo.create({"name": "Alice", "url": "https://example.com"})
|
||||
await repo.create({"name": "Bob", "url": "https://example.com"})
|
||||
|
||||
items = await repo.list()
|
||||
|
||||
assert items[0].name == "Alice", "Should be alphabetically first"
|
||||
assert items[1].name == "Bob", "Should be alphabetically second"
|
||||
assert items[2].name == "Charlie", "Should be alphabetically third"
|
||||
16
app/features/tasks/utils.py
Normal file
16
app/features/tasks/utils.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import logging
|
||||
|
||||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def cron_time(timer: str) -> str:
|
||||
try:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from cronsim import CronSim
|
||||
|
||||
cs = CronSim(timer, datetime.now(UTC))
|
||||
return cs.explain()
|
||||
except Exception as exc:
|
||||
LOG.exception(exc)
|
||||
return timer
|
||||
|
|
@ -1,15 +1,16 @@
|
|||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Awaitable
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.features.core.utils import gen_random
|
||||
|
||||
from .BackgroundWorker import BackgroundWorker
|
||||
from .Singleton import Singleton
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("library.events")
|
||||
LOG: logging.Logger = logging.getLogger("events")
|
||||
|
||||
|
||||
class Events:
|
||||
|
|
@ -25,6 +26,7 @@ class Events:
|
|||
CONNECTED: str = "connected"
|
||||
|
||||
CONFIGURATION: str = "configuration"
|
||||
CONFIG_UPDATE: str = "config_update"
|
||||
ACTIVE_QUEUE: str = "active_queue"
|
||||
|
||||
LOG_INFO: str = "log_info"
|
||||
|
|
@ -49,29 +51,13 @@ class Events:
|
|||
PAUSED: str = "paused"
|
||||
RESUMED: str = "resumed"
|
||||
|
||||
CLI_POST: str = "cli_post"
|
||||
CLI_CLOSE: str = "cli_close"
|
||||
CLI_OUTPUT: str = "cli_output"
|
||||
|
||||
TASKS_ADD: str = "task_add"
|
||||
TASK_DISPATCHED: str = "task_dispatched"
|
||||
TASK_FINISHED: str = "task_finished"
|
||||
TASK_ERROR: str = "task_error"
|
||||
|
||||
PRESETS_ADD: str = "presets_add"
|
||||
PRESETS_UPDATE: str = "presets_update"
|
||||
|
||||
DLFIELDS_ADD: str = "dlfields_add"
|
||||
DLFIELDS_UPDATE: str = "dlfields_update"
|
||||
|
||||
SCHEDULE_ADD: str = "schedule_add"
|
||||
|
||||
CONDITIONS_ADD: str = "conditions_add"
|
||||
CONDITIONS_UPDATE: str = "conditions_update"
|
||||
|
||||
SUBSCRIBED: str = "subscribed"
|
||||
UNSUBSCRIBED: str = "unsubscribed"
|
||||
|
||||
def get_all() -> list:
|
||||
"""
|
||||
Get all the events.
|
||||
|
|
@ -94,6 +80,7 @@ class Events:
|
|||
"""
|
||||
return [
|
||||
Events.CONFIGURATION,
|
||||
Events.CONFIG_UPDATE,
|
||||
Events.CONNECTED,
|
||||
Events.ACTIVE_QUEUE,
|
||||
Events.LOG_INFO,
|
||||
|
|
@ -108,10 +95,6 @@ class Events:
|
|||
Events.ITEM_STATUS,
|
||||
Events.PAUSED,
|
||||
Events.RESUMED,
|
||||
Events.CLI_CLOSE,
|
||||
Events.CLI_OUTPUT,
|
||||
Events.PRESETS_UPDATE,
|
||||
Events.DLFIELDS_UPDATE,
|
||||
]
|
||||
|
||||
def only_debug() -> list:
|
||||
|
|
@ -122,7 +105,7 @@ class Events:
|
|||
list: The list of debug events.
|
||||
|
||||
"""
|
||||
return [Events.ITEM_UPDATED, Events.CLI_OUTPUT]
|
||||
return [Events.ITEM_UPDATED]
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
|
|
@ -131,7 +114,7 @@ class Event:
|
|||
Event is a data transfer object that represents an event that was emitted.
|
||||
"""
|
||||
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()), init=False)
|
||||
id: str = field(default_factory=lambda: str(gen_random(16)), init=False)
|
||||
"""The id of the event."""
|
||||
|
||||
created_at: str = field(default_factory=lambda: str(datetime.datetime.now(tz=datetime.UTC).isoformat()))
|
||||
|
|
@ -198,10 +181,10 @@ class Event:
|
|||
|
||||
|
||||
class EventListener:
|
||||
def __init__(self, name: str, callback: callable):
|
||||
def __init__(self, name: str, callback: Callable[..., Any]):
|
||||
self.name: str = name
|
||||
"The name of the listener."
|
||||
self.call_back: callable = callback
|
||||
self.call_back: Callable[..., Any] = callback
|
||||
"The callback function to call when the event is emitted."
|
||||
self.is_coroutine: bool = asyncio.iscoroutinefunction(callback)
|
||||
"Whether the callback is a coroutine function or not."
|
||||
|
|
@ -219,13 +202,13 @@ class EventBus(metaclass=Singleton):
|
|||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._listeners: dict[str, list[str, EventListener]] = {}
|
||||
self._listeners: dict[str, list[tuple[str, EventListener]]] = {}
|
||||
"The listeners for the events."
|
||||
|
||||
self.debug: bool = False
|
||||
"Whether to log debug messages or not."
|
||||
|
||||
self._offload: BackgroundWorker = None
|
||||
self._offload: BackgroundWorker | None = None
|
||||
"The background worker to offload tasks to."
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -239,7 +222,7 @@ class EventBus(metaclass=Singleton):
|
|||
"""
|
||||
return EventBus()
|
||||
|
||||
def subscribe(self, event: str | list | tuple, callback: Awaitable, name: str | None = None) -> "EventBus":
|
||||
def subscribe(self, event: str | list | tuple, callback: Callable[..., Any], name: str | None = None) -> "EventBus":
|
||||
"""
|
||||
Subscribe to an event.
|
||||
|
||||
|
|
@ -267,7 +250,7 @@ class EventBus(metaclass=Singleton):
|
|||
event = [event]
|
||||
|
||||
if not name:
|
||||
name = str(uuid.uuid4())
|
||||
name = gen_random(12)
|
||||
|
||||
for e in event:
|
||||
if e not in all_events:
|
||||
|
|
@ -275,9 +258,10 @@ class EventBus(metaclass=Singleton):
|
|||
continue
|
||||
|
||||
if e not in self._listeners:
|
||||
self._listeners[e] = {}
|
||||
self._listeners[e] = []
|
||||
|
||||
self._listeners[e][name] = EventListener(name, callback)
|
||||
self._listeners[e] = [(n, listener) for n, listener in self._listeners[e] if n != name]
|
||||
self._listeners[e].append((name, EventListener(name, callback)))
|
||||
|
||||
LOG.debug(f"'{name}' subscribed to '{event}'.")
|
||||
|
||||
|
|
@ -300,9 +284,11 @@ class EventBus(metaclass=Singleton):
|
|||
|
||||
events = []
|
||||
for e in event:
|
||||
if e in self._listeners and name in self._listeners[e]:
|
||||
events.append(e)
|
||||
del self._listeners[e][name]
|
||||
if e in self._listeners:
|
||||
original_len: int = len(self._listeners[e])
|
||||
self._listeners[e] = [(n, listener) for n, listener in self._listeners[e] if n != name]
|
||||
if len(self._listeners[e]) < original_len:
|
||||
events.append(e)
|
||||
|
||||
if len(events) > 0:
|
||||
LOG.debug(f"'{name}' unsubscribed from '{events}'.")
|
||||
|
|
@ -337,22 +323,25 @@ class EventBus(metaclass=Singleton):
|
|||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
for handler in self._listeners[event].values():
|
||||
try:
|
||||
if handler.is_coroutine:
|
||||
coro = handler.call_back(ev, handler.name, **kwargs)
|
||||
if asyncio.iscoroutine(coro):
|
||||
loop.create_task(coro)
|
||||
async def execute_handlers():
|
||||
for _, handler in self._listeners[event]:
|
||||
try:
|
||||
if handler.is_coroutine:
|
||||
coro = handler.call_back(ev, handler.name, **kwargs)
|
||||
if asyncio.iscoroutine(coro):
|
||||
await coro
|
||||
else:
|
||||
LOG.warning(f"Expected coroutine from async handler '{handler.name}', got {type(coro)}")
|
||||
else:
|
||||
LOG.warning(f"Expected coroutine from async handler '{handler.name}', got {type(coro)}")
|
||||
else:
|
||||
loop.create_task(self._call(handler, ev, kwargs), name=f"sync-handler-{handler.name}-{ev.id}")
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Failed to emit event '{ev.event}' to '{handler.name}'. Error message '{e!s}'.")
|
||||
await self._call(handler, ev, kwargs)
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Failed to emit event '{ev.event}' to '{handler.name}'. Error message '{e!s}'.")
|
||||
|
||||
loop.create_task(execute_handlers())
|
||||
except RuntimeError:
|
||||
LOG.debug(f"No event loop detected - using BackgroundWorker for {len(self._listeners[event])} handlers")
|
||||
for handler in self._listeners[event].values():
|
||||
for _, handler in self._listeners[event]:
|
||||
try:
|
||||
if not self._offload:
|
||||
self._offload = BackgroundWorker.get_instance()
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ class HttpAPI:
|
|||
self.config: Config = Config.get_instance()
|
||||
self._notify: EventBus = EventBus.get_instance()
|
||||
self.rootPath: Path = root_path
|
||||
self.cache = Cache()
|
||||
self.cache: Cache = Cache.get_instance()
|
||||
self.app: web.Application | None = None
|
||||
|
||||
services: Services = Services.get_instance()
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import functools
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import socketio
|
||||
from aiohttp import web
|
||||
|
||||
from app.library.router import RouteType, get_routes
|
||||
from app.features.core.utils import gen_random
|
||||
from app.library.router import Route, RouteType, get_routes
|
||||
from app.library.Services import Services
|
||||
from app.library.Utils import load_modules
|
||||
|
||||
|
|
@ -18,13 +19,55 @@ from .ItemDTO import Item
|
|||
LOG: logging.Logger = logging.getLogger("socket_api")
|
||||
|
||||
|
||||
class WebSocketHub:
|
||||
def __init__(self, encoder: Encoder):
|
||||
self._encoder: Encoder = encoder
|
||||
self._clients: dict[str, web.WebSocketResponse] = {}
|
||||
|
||||
def add(self, sid: str, ws: web.WebSocketResponse) -> None:
|
||||
self._clients[sid] = ws
|
||||
|
||||
def remove(self, sid: str) -> None:
|
||||
self._clients.pop(sid, None)
|
||||
|
||||
async def emit(self, event: str, data: Any, to: str | None = None) -> None:
|
||||
payload: str = self._encoder.encode({"event": event, "data": data})
|
||||
|
||||
if to:
|
||||
await self._send(to, payload)
|
||||
return
|
||||
|
||||
for sid in list(self._clients.keys()):
|
||||
await self._send(sid, payload)
|
||||
|
||||
async def disconnect(self, sid: str) -> None:
|
||||
ws: web.WebSocketResponse | None = self._clients.pop(sid, None)
|
||||
if ws and not ws.closed:
|
||||
await ws.close()
|
||||
|
||||
async def disconnect_all(self) -> None:
|
||||
for sid in list(self._clients.keys()):
|
||||
await self.disconnect(sid)
|
||||
|
||||
async def _send(self, sid: str, payload: str) -> None:
|
||||
ws: web.WebSocketResponse | None = self._clients.get(sid)
|
||||
if not ws or ws.closed:
|
||||
self._clients.pop(sid, None)
|
||||
return
|
||||
|
||||
try:
|
||||
await ws.send_str(payload)
|
||||
except ConnectionResetError:
|
||||
self._clients.pop(sid, None)
|
||||
|
||||
|
||||
class HttpSocket:
|
||||
"""
|
||||
This class is used to handle WebSocket events.
|
||||
"""
|
||||
|
||||
config: Config
|
||||
sio: socketio.AsyncServer
|
||||
sio: WebSocketHub
|
||||
di_context: dict[str, Any] = {}
|
||||
|
||||
def __init__(
|
||||
|
|
@ -32,26 +75,18 @@ class HttpSocket:
|
|||
root_path: Path,
|
||||
encoder: Encoder | None = None,
|
||||
config: Config | None = None,
|
||||
sio: socketio.AsyncServer | None = None,
|
||||
sio: WebSocketHub | None = None,
|
||||
):
|
||||
self.config = config or Config.get_instance()
|
||||
self._notify = EventBus.get_instance()
|
||||
|
||||
self.sio = sio or socketio.AsyncServer(
|
||||
async_handlers=True,
|
||||
async_mode="aiohttp",
|
||||
cors_allowed_origins="*",
|
||||
transports=["websocket", "polling"],
|
||||
logger=self.config.debug,
|
||||
engineio_logger=self.config.debug,
|
||||
ping_interval=10,
|
||||
ping_timeout=5,
|
||||
)
|
||||
encoder = encoder or Encoder()
|
||||
self.sio = sio or WebSocketHub(encoder=encoder)
|
||||
self.rootPath: Path = root_path
|
||||
|
||||
async def event_handler(e: Event, _, **kwargs):
|
||||
await self.sio.emit(event=e.event, data=encoder.encode(e), **kwargs)
|
||||
payload = json.loads(encoder.encode(e))
|
||||
await self.sio.emit(event=e.event, data=payload, **kwargs)
|
||||
|
||||
services: Services = Services.get_instance()
|
||||
services.add_all(
|
||||
|
|
@ -85,38 +120,86 @@ class HttpSocket:
|
|||
|
||||
async def on_shutdown(self, _: web.Application):
|
||||
LOG.debug("Shutting down socket server.")
|
||||
|
||||
for sid in self.sio.manager.get_participants("/", None):
|
||||
LOG.debug(f"Disconnecting client '{sid}'.")
|
||||
await self.sio.disconnect(sid[0], namespace="/")
|
||||
|
||||
await self.sio.disconnect_all()
|
||||
LOG.debug("Socket server shutdown complete.")
|
||||
|
||||
def attach(self, app: web.Application):
|
||||
app.on_shutdown.append(self.on_shutdown)
|
||||
|
||||
self.sio.attach(app, socketio_path=f"{self.config.base_path.rstrip('/')}/socket.io")
|
||||
base_path: str = self.config.base_path.rstrip("/")
|
||||
ws_path: str = f"{base_path}/ws" if base_path else "/ws"
|
||||
|
||||
async def event_handler(data: Event, _):
|
||||
if data and data.data:
|
||||
await Services.get_instance().get("queue").add(item=Item.format(data.data))
|
||||
if not (data and data.data):
|
||||
return
|
||||
|
||||
await Services.get_instance().get("queue").add(item=Item.format(data.data))
|
||||
|
||||
self._notify.subscribe(Events.ADD_URL, event_handler, f"{__class__.__name__}.add")
|
||||
|
||||
load_modules(self.rootPath, self.rootPath / "routes" / "socket")
|
||||
|
||||
for route in get_routes(RouteType.SOCKET).values():
|
||||
socket_routes: dict[str, Route] = {route.path: route for route in get_routes(RouteType.SOCKET).values()}
|
||||
|
||||
for route in socket_routes.values():
|
||||
if self.config.debug:
|
||||
LOG.debug(
|
||||
f"Add ({route.name}) {route.method.value if isinstance(route.method, RouteType) else route.method}: {route.path}."
|
||||
)
|
||||
self.sio.on(route.path)(HttpSocket._injector(route.handler, route.path))
|
||||
|
||||
@staticmethod
|
||||
def _injector(func, event: str):
|
||||
async def wrapper(sid, data=None, **kwargs):
|
||||
if not data:
|
||||
data = {}
|
||||
return await Services.get_instance().handle_async(func, sid=sid, data=data, event=event, **kwargs)
|
||||
async def handle_message(sid: str, message: str) -> None:
|
||||
try:
|
||||
payload = json.loads(message)
|
||||
except json.JSONDecodeError:
|
||||
LOG.debug("Invalid websocket payload received.")
|
||||
return
|
||||
|
||||
return wrapper
|
||||
event = payload.get("event") if isinstance(payload, dict) else None
|
||||
if not event or not isinstance(event, str):
|
||||
LOG.debug("Missing websocket event name.")
|
||||
return
|
||||
|
||||
if not (route := socket_routes.get(event)):
|
||||
LOG.debug(f"Unknown websocket event '{event}'.")
|
||||
return
|
||||
|
||||
data = payload.get("data") if isinstance(payload, dict) else None
|
||||
await Services.get_instance().handle_async(route.handler, sid=sid, data=data, event=event)
|
||||
|
||||
async def handle_connect(sid: str) -> None:
|
||||
if not (route := socket_routes.get("connect")):
|
||||
return
|
||||
await Services.get_instance().handle_async(route.handler, sid=sid, event="connect")
|
||||
|
||||
async def handle_disconnect(sid: str, reason: str | None) -> None:
|
||||
if not (route := socket_routes.get("disconnect")):
|
||||
return
|
||||
await Services.get_instance().handle_async(route.handler, sid=sid, data=reason, event="disconnect")
|
||||
|
||||
async def ws_handler(request: web.Request) -> web.WebSocketResponse:
|
||||
ws = web.WebSocketResponse(heartbeat=10)
|
||||
await ws.prepare(request)
|
||||
|
||||
sid: str = gen_random(14)
|
||||
self.sio.add(sid, ws)
|
||||
LOG.debug(f"WebSocket client '{sid}' connected.")
|
||||
|
||||
await handle_connect(sid)
|
||||
|
||||
try:
|
||||
async for msg in ws:
|
||||
if msg.type == web.WSMsgType.TEXT:
|
||||
await handle_message(sid, msg.data)
|
||||
elif msg.type == web.WSMsgType.ERROR:
|
||||
LOG.error(f"WebSocket connection closed with exception {ws.exception()}")
|
||||
finally:
|
||||
await handle_disconnect(sid, getattr(ws, "close_reason", None))
|
||||
self.sio.remove(sid)
|
||||
LOG.debug(f"WebSocket client '{sid}' disconnected.")
|
||||
|
||||
return ws
|
||||
|
||||
if self.config.debug:
|
||||
LOG.debug(f"Add (ws) GET: {ws_path}.")
|
||||
|
||||
app.router.add_get(ws_path, ws_handler, name="ws")
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ from app.library.Utils import (
|
|||
from app.library.YTDLPOpts import YTDLPOpts
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.library.Presets import Preset
|
||||
from app.features.presets.schemas import Preset
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("ItemDTO")
|
||||
|
||||
|
|
@ -184,7 +184,7 @@ class Item:
|
|||
|
||||
preset: str | None = item.get("preset")
|
||||
if preset and isinstance(preset, str) and preset != Item._default_preset():
|
||||
from .Presets import Presets
|
||||
from app.features.presets.service import Presets
|
||||
|
||||
if not Presets.get_instance().has(preset):
|
||||
msg: str = f"Preset '{preset}' does not exist."
|
||||
|
|
@ -224,15 +224,15 @@ class Item:
|
|||
|
||||
return Item(**data)
|
||||
|
||||
def get_preset(self) -> "Preset":
|
||||
def get_preset(self) -> "Preset | None":
|
||||
"""
|
||||
Get the preset for the item.
|
||||
|
||||
Returns:
|
||||
Preset: The preset for the item. If not found, None.
|
||||
Preset | None: The preset for the item. If not found, None.
|
||||
|
||||
"""
|
||||
from .Presets import Presets
|
||||
from app.features.presets.service import Presets
|
||||
|
||||
return Presets.get_instance().get(self.preset if self.preset else self._default_preset())
|
||||
|
||||
|
|
@ -558,7 +558,7 @@ class ItemDTO:
|
|||
Preset | None: The preset for the item. If not found, None.
|
||||
|
||||
"""
|
||||
from .Presets import Presets
|
||||
from app.features.presets.service import Presets
|
||||
|
||||
return Presets.get_instance().get(self.preset if self.preset else "default")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,559 +0,0 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import traceback
|
||||
from collections.abc import Awaitable
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from aiohttp import web
|
||||
|
||||
from .ag_utils import ag
|
||||
from .BackgroundWorker import BackgroundWorker
|
||||
from .config import Config
|
||||
from .encoder import Encoder
|
||||
from .Events import Event, EventBus, Events
|
||||
from .httpx_client import async_client
|
||||
from .ItemDTO import Item, ItemDTO
|
||||
from .Presets import Preset, Presets
|
||||
from .Singleton import Singleton
|
||||
from .Utils import validate_uuid
|
||||
|
||||
LOG = logging.getLogger("notifications")
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class TargetRequestHeader:
|
||||
"""Request header details for a notification target."""
|
||||
|
||||
key: str
|
||||
value: str
|
||||
|
||||
def serialize(self) -> dict:
|
||||
return {"key": self.key, "value": self.value}
|
||||
|
||||
def json(self) -> str:
|
||||
return Encoder().encode(self.serialize())
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
return self.serialize().get(key, default)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class TargetRequest:
|
||||
"""Request details for a notification target."""
|
||||
|
||||
type: str
|
||||
method: str
|
||||
url: str
|
||||
headers: list[TargetRequestHeader] = field(default_factory=list)
|
||||
data_key: str = "data"
|
||||
|
||||
def serialize(self) -> dict:
|
||||
return {
|
||||
"type": self.type,
|
||||
"method": self.method,
|
||||
"url": self.url,
|
||||
"data_key": self.data_key,
|
||||
"headers": [h.serialize() for h in self.headers],
|
||||
}
|
||||
|
||||
def json(self) -> str:
|
||||
return Encoder().encode(self.serialize())
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
return ag(self.serialize(), key, default)
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class Target:
|
||||
"""Notification target details."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
on: list[str] = field(default_factory=list)
|
||||
presets: list[str] = field(default_factory=list)
|
||||
request: TargetRequest
|
||||
enabled: bool = True
|
||||
|
||||
def serialize(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"on": self.on,
|
||||
"presets": self.presets,
|
||||
"request": self.request.serialize(),
|
||||
"enabled": self.enabled,
|
||||
}
|
||||
|
||||
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 NotificationEvents:
|
||||
TEST: str = Events.TEST
|
||||
|
||||
ITEM_ADDED: str = Events.ITEM_ADDED
|
||||
ITEM_COMPLETED: str = Events.ITEM_COMPLETED
|
||||
ITEM_CANCELLED: str = Events.ITEM_CANCELLED
|
||||
ITEM_DELETED: str = Events.ITEM_DELETED
|
||||
ITEM_PAUSED: str = Events.ITEM_PAUSED
|
||||
ITEM_RESUMED: str = Events.ITEM_RESUMED
|
||||
ITEM_MOVED: str = Events.ITEM_MOVED
|
||||
|
||||
PAUSED: str = Events.PAUSED
|
||||
RESUMED: str = Events.RESUMED
|
||||
|
||||
LOG_INFO: str = Events.LOG_INFO
|
||||
LOG_SUCCESS: str = Events.LOG_SUCCESS
|
||||
LOG_WARNING: str = Events.LOG_WARNING
|
||||
LOG_ERROR: str = Events.LOG_ERROR
|
||||
|
||||
TASK_DISPATCHED: str = Events.TASK_DISPATCHED
|
||||
|
||||
@staticmethod
|
||||
def get_events() -> dict[str, str]:
|
||||
return {k: v for k, v in vars(NotificationEvents).items() if not k.startswith("__") and not callable(v)}
|
||||
|
||||
def events() -> list:
|
||||
return [
|
||||
getattr(NotificationEvents, ev)
|
||||
for ev in dir(NotificationEvents)
|
||||
if not ev.startswith("_") and not callable(getattr(NotificationEvents, ev))
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def is_valid(event: str) -> bool:
|
||||
return event in NotificationEvents.get_events().values()
|
||||
|
||||
|
||||
class Notification(metaclass=Singleton):
|
||||
def __init__(
|
||||
self,
|
||||
file: str | None = None,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
encoder: Encoder | None = None,
|
||||
config: Config | None = None,
|
||||
background_worker: BackgroundWorker | None = None,
|
||||
):
|
||||
self._targets: list[Target] = []
|
||||
"Notification targets to send events to."
|
||||
|
||||
config: Config = config or Config.get_instance()
|
||||
|
||||
self._debug: bool = config.debug
|
||||
"Debug mode."
|
||||
self._file: Path = Path(file) if file else Path(config.config_path).joinpath("notifications.json")
|
||||
"File to store notification targets."
|
||||
self._client: httpx.AsyncClient = client or async_client()
|
||||
"HTTP client to send requests."
|
||||
self._encoder: Encoder = encoder or Encoder()
|
||||
"Encoder to encode data."
|
||||
self._version: str = config.app_version
|
||||
"Application version."
|
||||
self._offload: BackgroundWorker = background_worker or BackgroundWorker.get_instance()
|
||||
"Background worker to offload tasks to."
|
||||
|
||||
if self._file.exists() and "600" != self._file.stat().st_mode:
|
||||
try:
|
||||
self._file.chmod(0o600)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def get_instance(
|
||||
file: str | None = None,
|
||||
client: httpx.AsyncClient | None = None,
|
||||
encoder: Encoder | None = None,
|
||||
config: Config | None = None,
|
||||
background_worker: BackgroundWorker | None = None,
|
||||
) -> "Notification":
|
||||
"""
|
||||
Get the instance of the class.
|
||||
"""
|
||||
return Notification(
|
||||
file=file, client=client, encoder=encoder, config=config, background_worker=background_worker
|
||||
)
|
||||
|
||||
def attach(self, _: web.Application):
|
||||
"""
|
||||
Attach the class to the application.
|
||||
|
||||
Args:
|
||||
_ (web.Application): The aiohttp application.
|
||||
|
||||
"""
|
||||
self.load()
|
||||
EventBus.get_instance().subscribe(NotificationEvents.events(), self.emit, f"{__class__.__name__}.emit")
|
||||
|
||||
def get_targets(self) -> list[Target]:
|
||||
"""Get the list of notification targets."""
|
||||
return self._targets
|
||||
|
||||
def clear(self) -> "Notification":
|
||||
"""Clear the list of notification targets."""
|
||||
self._targets.clear()
|
||||
return self
|
||||
|
||||
def save(self, targets: list[Target]) -> "Notification":
|
||||
"""
|
||||
Save notification targets to the file.
|
||||
|
||||
Args:
|
||||
targets (list[Target]|None): The list of targets to save.
|
||||
|
||||
Returns:
|
||||
Notification: The Notification instance.
|
||||
|
||||
"""
|
||||
try:
|
||||
self._file.write_text(json.dumps([t.serialize() for t in targets], indent=4))
|
||||
LOG.info(f"Updated '{self._file}'.")
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Error saving '{self._file}'. '{e!s}'")
|
||||
|
||||
return self
|
||||
|
||||
def load(self) -> "Notification":
|
||||
"""Load or reload notification targets from the file."""
|
||||
if len(self._targets) > 0:
|
||||
self.clear()
|
||||
|
||||
if not self._file.exists() or self._file.stat().st_size < 1:
|
||||
return self
|
||||
|
||||
targets: list = []
|
||||
|
||||
try:
|
||||
LOG.info(f"Loading '{self._file}'.")
|
||||
targets = json.loads(self._file.read_text())
|
||||
except Exception as e:
|
||||
LOG.error(f"Error loading '{self._file}'. '{e!s}'")
|
||||
|
||||
if not targets or len(targets) < 1:
|
||||
LOG.debug(f"No targets were found in '{self._file}'.")
|
||||
return self
|
||||
|
||||
need_save = False
|
||||
|
||||
for target in targets:
|
||||
try:
|
||||
if "enabled" not in target:
|
||||
target["enabled"] = True
|
||||
need_save = True
|
||||
|
||||
try:
|
||||
Notification.validate(target)
|
||||
target: Target = self.make_target(target)
|
||||
except ValueError as e:
|
||||
name = target.get("name") or target.get("id") or target.get("request", {}).get("url") or "unknown"
|
||||
LOG.error(f"Invalid notification target '{name}'. '{e!s}'")
|
||||
continue
|
||||
|
||||
self._targets.append(target)
|
||||
|
||||
LOG.info(
|
||||
f"Send '{target.request.type}' request on '{', '.join(target.on) if len(target.on) > 0 else 'all events'}' to '{target.name}'."
|
||||
)
|
||||
except Exception as e:
|
||||
LOG.error(f"Error loading notification target '{target}'. '{e!s}'")
|
||||
|
||||
if need_save:
|
||||
LOG.warning("Saving notifications due to schema changes.")
|
||||
self.save(self._targets)
|
||||
|
||||
return self
|
||||
|
||||
def make_target(self, target: dict) -> Target:
|
||||
"""
|
||||
Make a notification target from a dictionary.
|
||||
|
||||
Args:
|
||||
target (dict): The target details.
|
||||
|
||||
Returns:
|
||||
Target: The notification target.
|
||||
|
||||
"""
|
||||
return Target(
|
||||
id=target.get("id"),
|
||||
name=target.get("name"),
|
||||
on=target.get("on", []),
|
||||
presets=target.get("presets", []),
|
||||
enabled=target.get("enabled", True),
|
||||
request=TargetRequest(
|
||||
type=target.get("request", {}).get("type", "json"),
|
||||
method=target.get("request", {}).get("method", "POST"),
|
||||
url=target.get("request", {}).get("url"),
|
||||
data_key=target.get("request", {}).get("data_key", "data"),
|
||||
headers=[
|
||||
TargetRequestHeader(
|
||||
key=str(h.get("key", "")).strip(),
|
||||
value=str(h.get("value", "")).strip(),
|
||||
)
|
||||
for h in target.get("request", {}).get("headers", [])
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def validate(target: Target | dict) -> bool:
|
||||
"""
|
||||
Validate a notification target.
|
||||
|
||||
Args:
|
||||
target (Target|dict): The target to validate.
|
||||
|
||||
Returns:
|
||||
bool: True if the target is valid, False otherwise.
|
||||
|
||||
"""
|
||||
if not isinstance(target, dict):
|
||||
target = target.serialize()
|
||||
|
||||
if "id" not in target or validate_uuid(target["id"], version=4) is False:
|
||||
msg = "Invalid notification target. No ID found."
|
||||
raise ValueError(msg)
|
||||
|
||||
if "name" not in target:
|
||||
msg = "Invalid notification target. No name found."
|
||||
raise ValueError(msg)
|
||||
|
||||
if "request" not in target:
|
||||
msg = "Invalid notification target. No request details found."
|
||||
raise ValueError(msg)
|
||||
|
||||
if "url" not in target["request"]:
|
||||
msg = "Invalid notification target. No URL found."
|
||||
raise ValueError(msg)
|
||||
|
||||
if "data_key" not in target["request"]:
|
||||
target["request"]["data_key"] = "data"
|
||||
|
||||
if "enabled" not in target:
|
||||
target["enabled"] = True
|
||||
|
||||
if target.get("enabled") is not None and not isinstance(target.get("enabled"), bool):
|
||||
msg = "Enabled must be a boolean."
|
||||
raise ValueError(msg)
|
||||
|
||||
if "method" in target["request"] and target["request"]["method"].upper() not in ["POST", "PUT"]:
|
||||
msg = "Invalid notification target. Invalid method found."
|
||||
raise ValueError(msg)
|
||||
|
||||
if "type" in target["request"] and target["request"]["type"].lower() not in ["json", "form"]:
|
||||
msg = "Invalid notification target. Invalid type found."
|
||||
raise ValueError(msg)
|
||||
|
||||
if "on" in target:
|
||||
if not isinstance(target["on"], list):
|
||||
msg = "Invalid notification target. Invalid 'on' event list found."
|
||||
raise ValueError(msg)
|
||||
|
||||
removed_events: list = []
|
||||
all_events: dict[str, str] = NotificationEvents.get_events().values()
|
||||
for e in target["on"]:
|
||||
if e not in all_events:
|
||||
removed_events.append(e)
|
||||
target["on"].remove(e)
|
||||
continue
|
||||
|
||||
if len(removed_events) > 0 and len(target["on"]) < 1:
|
||||
msg: str = f"Invalid notification target. Invalid events '{', '.join(removed_events)}' found."
|
||||
raise ValueError(msg)
|
||||
|
||||
if "presets" in target:
|
||||
if not isinstance(target["presets"], list):
|
||||
msg = "Invalid notification target. Invalid 'presets' list found."
|
||||
raise ValueError(msg)
|
||||
|
||||
removed_presets: list = []
|
||||
all_presets: list[Preset] = Presets.get_instance().get_all()
|
||||
|
||||
for p in target["presets"]:
|
||||
if p not in [ap.name for ap in all_presets]:
|
||||
removed_presets.append(p)
|
||||
target["presets"].remove(p)
|
||||
continue
|
||||
|
||||
if len(removed_presets) > 0 and len(target["presets"]) < 1:
|
||||
msg: str = f"Invalid notification target. Invalid presets '{', '.join(removed_presets)}' found."
|
||||
raise ValueError(msg)
|
||||
|
||||
if "headers" in target["request"]:
|
||||
if not isinstance(target["request"]["headers"], list):
|
||||
msg = "Invalid notification target. Invalid headers list found."
|
||||
raise ValueError(msg)
|
||||
|
||||
for h in target["request"]["headers"]:
|
||||
if "key" not in h:
|
||||
msg = "Invalid notification target. No header key found."
|
||||
raise ValueError(msg)
|
||||
if "value" not in h:
|
||||
msg = "Invalid notification target. No header value found."
|
||||
raise ValueError(msg)
|
||||
|
||||
return True
|
||||
|
||||
async def send(self, ev: Event, wait: bool = True) -> list[dict] | Awaitable[list[dict]]:
|
||||
if len(self._targets) < 1:
|
||||
return []
|
||||
|
||||
if not isinstance(ev.data, ItemDTO) and not isinstance(ev.data, dict):
|
||||
LOG.debug(f"Received invalid item type '{type(ev.data)}' with event '{ev.event}'.")
|
||||
return []
|
||||
|
||||
tasks = []
|
||||
|
||||
apprise_targets: list[Target] = []
|
||||
|
||||
for target in self._targets:
|
||||
if not target.enabled:
|
||||
continue
|
||||
|
||||
if len(target.on) > 0 and ev.event not in target.on and "test" != ev.event:
|
||||
continue
|
||||
|
||||
if "test" != ev.event and not self._check_preset(target, ev):
|
||||
continue
|
||||
|
||||
if not target.request.url.startswith("http"):
|
||||
apprise_targets.append(target)
|
||||
else:
|
||||
tasks.append(self._send(target, ev))
|
||||
|
||||
if len(apprise_targets) > 0:
|
||||
tasks.append(self._apprise(apprise_targets, ev))
|
||||
|
||||
if wait:
|
||||
return await asyncio.gather(*tasks)
|
||||
|
||||
return tasks
|
||||
|
||||
def _check_preset(self, target: Target, ev: Event) -> bool:
|
||||
if len(target.presets) < 1:
|
||||
return True
|
||||
|
||||
if not isinstance(ev.data, (Item, ItemDTO, dict)):
|
||||
return False
|
||||
|
||||
preset_name: str | None = None
|
||||
|
||||
if isinstance(ev.data, Item):
|
||||
preset_name = ev.data.preset
|
||||
|
||||
if isinstance(ev.data, ItemDTO):
|
||||
preset_name = ev.data.preset
|
||||
|
||||
if isinstance(ev.data, dict):
|
||||
preset_name = ev.data.get("preset", None)
|
||||
|
||||
if not preset_name:
|
||||
return False
|
||||
|
||||
return preset_name in target.presets
|
||||
|
||||
async def _apprise(self, target: list[Target], ev: Event) -> dict:
|
||||
if not target or not isinstance(target, list):
|
||||
return {}
|
||||
|
||||
import apprise
|
||||
|
||||
try:
|
||||
notify = apprise.Apprise(debug=self._debug)
|
||||
apr_config = Path(Config.get_instance().apprise_config)
|
||||
if apr_config.exists():
|
||||
apprise_config = notify.AppriseConfig()
|
||||
apprise_config.add(apr_config)
|
||||
notify.add(apprise_config)
|
||||
|
||||
for t in target:
|
||||
notify.add(t.request.url)
|
||||
|
||||
status = await notify.async_notify(
|
||||
body=ev.message or self._encoder.encode(ev.serialize()),
|
||||
title=ev.title or f"YTPTube Event: {ev.event}",
|
||||
)
|
||||
|
||||
if not status:
|
||||
msg = "Apprise failed to send notification."
|
||||
raise RuntimeError(msg) # noqa: TRY301
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Error sending Apprise notification: {e!s}")
|
||||
return {"error": str(e), "event": ev.event, "id": ev.id, "targets": [t.name for t in target]}
|
||||
|
||||
return {}
|
||||
|
||||
async def _send(self, target: Target, ev: Event) -> dict:
|
||||
try:
|
||||
LOG.info(f"Sending Notification event '{ev.event}: {ev.id}' to '{target.name}'.")
|
||||
|
||||
headers: dict[str, str] = {
|
||||
"User-Agent": f"YTPTube/{self._version}",
|
||||
"X-Event-Id": ev.id,
|
||||
"X-Event": ev.event,
|
||||
"Content-Type": "application/json"
|
||||
if "json" == target.request.type.lower()
|
||||
else "application/x-www-form-urlencoded",
|
||||
}
|
||||
|
||||
if len(target.request.headers) > 0:
|
||||
headers.update({h.key: h.value for h in target.request.headers if h.key and h.value})
|
||||
|
||||
payload: dict = ev.serialize()
|
||||
|
||||
if "data" != target.request.data_key:
|
||||
payload[target.request.data_key] = payload["data"]
|
||||
payload.pop("data", None)
|
||||
|
||||
if "form" == target.request.type.lower():
|
||||
payload[target.request.data_key] = self._encoder.encode(payload[target.request.data_key])
|
||||
else:
|
||||
payload = self._encoder.encode(payload)
|
||||
|
||||
response = await self._client.request(
|
||||
method=target.request.method.upper(),
|
||||
url=target.request.url,
|
||||
headers=headers,
|
||||
data=payload if "form" == target.request.type.lower() else None,
|
||||
content=payload if "json" == target.request.type.lower() else None,
|
||||
)
|
||||
|
||||
respData: dict[str, Any] = {
|
||||
"url": target.request.url,
|
||||
"status": response.status_code,
|
||||
"text": response.text,
|
||||
}
|
||||
|
||||
msg: str = f"Notification target '{target.name}' Responded to event '{ev.event}: {ev.id}' with status '{response.status_code}'."
|
||||
if self._debug and respData.get("text"):
|
||||
msg += f" body '{respData.get('text', '??')}'."
|
||||
|
||||
LOG.info(msg)
|
||||
|
||||
return respData
|
||||
except Exception as e:
|
||||
err_msg = str(e)
|
||||
if not err_msg:
|
||||
err_msg: str = type(e).__name__
|
||||
|
||||
tb = "".join(traceback.format_exception(type(e), e, e.__traceback__))
|
||||
LOG.error(f"Error sending Notification event '{ev.event}: {ev.id}' to '{target.name}'. '{err_msg!s}'. {tb}")
|
||||
return {"url": target.request.url, "status": 500, "text": str(ev)}
|
||||
|
||||
def emit(self, e: Event, _, **__) -> None:
|
||||
if len(self._targets) < 1 or not NotificationEvents.is_valid(e.event):
|
||||
return
|
||||
|
||||
self._offload.submit(self.send, e)
|
||||
return
|
||||
|
||||
async def noop(self) -> None:
|
||||
return None
|
||||
|
|
@ -1,419 +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 .Events import EventBus, Events
|
||||
from .Singleton import Singleton
|
||||
from .Utils import arg_converter, create_cookies_file, init_class
|
||||
|
||||
LOG = logging.getLogger("presets")
|
||||
|
||||
DEFAULT_PRESETS: list[dict[int, dict[str, str | bool]]] = [
|
||||
{
|
||||
"id": "3e163c6c-64eb-4448-924f-814b629b3810",
|
||||
"name": "default",
|
||||
"default": True,
|
||||
"cli": "--socket-timeout 30 --download-archive %(archive_file)s",
|
||||
"description": "Default preset for yt-dlp. It will download whatever yt-dlp decides is the best quality for the video and audio.",
|
||||
},
|
||||
{
|
||||
"id": "441675ed-b739-40f0-a0b0-1ecfcb9dc48d",
|
||||
"name": "Mobile",
|
||||
"cli": '--socket-timeout 30 --download-archive %(archive_file)s\n-t mp4 --merge-output-format mp4 --add-chapters --remux-video mp4 \n--embed-metadata --embed-thumbnail \n--postprocessor-args "-movflags +faststart"',
|
||||
"default": True,
|
||||
"description": "This preset is designed for mobile devices. It will download the best quality video and audio in mp4 format, merge them, and add chapters, metadata, and thumbnail.",
|
||||
},
|
||||
{
|
||||
"id": "441675ed-b739-40f0-a0b0-1ecfcb9dc48b",
|
||||
"name": "1080p",
|
||||
"cli": "--socket-timeout 30 --download-archive %(archive_file)s\n-S vcodec:h264 --format 'bv[height<=1080][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]'",
|
||||
"default": True,
|
||||
"description": "Download the best quality video and audio in mp4 format for 1080p resolution.",
|
||||
},
|
||||
{
|
||||
"id": "9719fcc3-4cf2-4d88-b1e4-74dff3dba00e",
|
||||
"name": "720p",
|
||||
"cli": "--socket-timeout 30 --download-archive %(archive_file)s\n-S vcodec:h264 --format 'bv[height<=720][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]'",
|
||||
"default": True,
|
||||
"description": "Download the best quality video and audio in mp4 format for 720p resolution.",
|
||||
},
|
||||
{
|
||||
"id": "a6fd4b25-2b3e-458d-bb57-b75e41cc4330",
|
||||
"name": "Audio Only",
|
||||
"cli": "--socket-timeout 30 --download-archive %(archive_file)s\n--extract-audio --add-chapters --embed-metadata --embed-thumbnail --format 'bestaudio/best'",
|
||||
"default": True,
|
||||
"description": "This preset is designed to download only the audio of the video. It will extract the audio, add chapters, metadata, and thumbnail.",
|
||||
},
|
||||
{
|
||||
"id": "2ade2c28-cad4-4a06-b7eb-2439fdf46f60",
|
||||
"name": "Info Reader Plugin",
|
||||
"description": 'This preset generate specific filename format and metadata to work with yt-dlp info reader plugins for jellyfin/emby and plex and to make play state sync work for WatchState.\n\nThere is one more step you need to do via Other > Terminal if you have it enabled or directly from container shell\n\nyt-dlp -I0 --write-info-json --write-thumbnail --convert-thumbnails jpg --paths /downloads/youtube -o "%(channel|Unknown_title)s [%(channel_id|Unknown_id)s]/%(title).180B [%(channel_id|Unknown_id)s].%(ext)s" -- https://www.youtube.com/channel/UCClfFsWcT3N2I7VTXXyt84A\n\nChange the url to the channel you want to download.\n\nFor more information please visit \nhttps://github.com/arabcoders/watchstate/blob/master/FAQ.md#how-to-get-watchstate-working-with-youtube-contentlibrary',
|
||||
"template": "%(channel)s [%(channel_id|Unknown_id)s]/Season %(release_date>%Y,upload_date>%Y|Unknown)s/%(release_date>%Y%m%d,upload_date>%Y%m%d)s - %(title).180B [%(id)s].%(ext)s",
|
||||
"cli": "--socket-timeout 30 --download-archive %(archive_file)s\n--windows-filenames --write-info-json --embed-info-json \n--convert-thumbnails jpg --write-thumbnail --embed-metadata",
|
||||
"default": True,
|
||||
},
|
||||
{
|
||||
"id": "2ade2c28-cad4-4a06-b7eb-2439fdf46f61",
|
||||
"name": "NFO Maker TV",
|
||||
"description": "This preset generate specific filename format and metadata to work with media servers like jellyfin/emby and plex for episodes.",
|
||||
"template": "%(channel)s [%(channel_id|Unknown_id)s]/Season %(release_date>%Y,upload_date>%Y|Unknown)s/S%(release_date>%Y,upload_date>%Y)sE%(release_date>%m%d,upload_date>%m%d)s - %(title).100B [%(id)s].%(ext)s",
|
||||
"cli": '--socket-timeout 30 --download-archive %(archive_file)s\n--use-postprocessor "NFOMakerPP:when=after_move" \n--windows-filenames --convert-thumbnails jpg --write-thumbnail',
|
||||
"default": True,
|
||||
},
|
||||
{
|
||||
"id": "2ade2c28-cad4-4a06-b7eb-2439fdf46f61",
|
||||
"name": "NFO Maker Movie",
|
||||
"description": "This preset generate specific filename format and metadata to work with media servers like jellyfin/emby and plex for movies.",
|
||||
"template": "%(channel)s [%(channel_id|Unknown_id)s]/Season %(release_date>%Y,upload_date>%Y|Unknown)s/S%(release_date>%Y,upload_date>%Y)sE%(release_date>%m%d,upload_date>%m%d)s - %(title).100B [%(id)s].%(ext)s",
|
||||
"cli": '--socket-timeout 30 --download-archive %(archive_file)s\n--use-postprocessor "NFOMakerPP:when=after_move;mode=movie" \n--windows-filenames --convert-thumbnails jpg --write-thumbnail',
|
||||
"default": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class Preset:
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
"""The id of the preset."""
|
||||
|
||||
name: str
|
||||
"""The name of the preset."""
|
||||
|
||||
description: str = ""
|
||||
"""The description of the preset."""
|
||||
|
||||
folder: str = ""
|
||||
"""The default download folder to use if non is given."""
|
||||
|
||||
template: str = ""
|
||||
"""The default template to use if non is given."""
|
||||
|
||||
cookies: str = ""
|
||||
"""The default cookies to use if non is given."""
|
||||
|
||||
cli: str = ""
|
||||
"""command options for yt-dlp."""
|
||||
|
||||
default: bool = False
|
||||
"""If True, the preset is a default preset."""
|
||||
|
||||
priority: int = 0
|
||||
"""Priority of the preset."""
|
||||
|
||||
_cookies_file: Path | None = field(init=False, default=None)
|
||||
"""The path to the cookies file."""
|
||||
|
||||
def serialize(self) -> dict:
|
||||
return {k: v for k, v in self.__dict__.items() if not k.startswith("_")}
|
||||
|
||||
def json(self) -> str:
|
||||
from .encoder import Encoder
|
||||
|
||||
return Encoder().encode(self.serialize())
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
return self.__dict__.get(key, default)
|
||||
|
||||
def get_cookies_file(self, config: Config | None = None) -> Path | None:
|
||||
"""
|
||||
Get the path to the cookies file.
|
||||
|
||||
Args:
|
||||
config (Config|None): The config instance.
|
||||
|
||||
Returns:
|
||||
Path|None: The path to the cookies file.
|
||||
|
||||
"""
|
||||
if self._cookies_file:
|
||||
return self._cookies_file
|
||||
|
||||
if not self.cookies or not self.id:
|
||||
return None
|
||||
|
||||
if not config:
|
||||
config = Config.get_instance()
|
||||
|
||||
self._cookies_file = create_cookies_file(self.cookies, Path(config.config_path) / "cookies" / f"{self.id}.txt")
|
||||
|
||||
return self._cookies_file
|
||||
|
||||
|
||||
class Presets(metaclass=Singleton):
|
||||
"""
|
||||
This class is used to manage the presets.
|
||||
"""
|
||||
|
||||
def __init__(self, file: str | Path | None = None, config: Config | None = None):
|
||||
self._items: list[Preset] = []
|
||||
"The list of presets."
|
||||
|
||||
self._config: Config = None
|
||||
"The config instance."
|
||||
|
||||
self._default: list[Preset] = []
|
||||
"The list of default presets."
|
||||
|
||||
self._config = config or Config.get_instance()
|
||||
|
||||
self._file: Path = Path(file) if file else Path(self._config.config_path).joinpath("presets.json")
|
||||
"The path to the presets file."
|
||||
|
||||
if self._file.exists() and "600" != self._file.stat().st_mode:
|
||||
try:
|
||||
self._file.chmod(0o600)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for i, preset in enumerate(DEFAULT_PRESETS):
|
||||
try:
|
||||
self.validate(preset)
|
||||
self._default.append(init_class(Preset, preset))
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to parse default preset ':{i}'. '{e!s}'.")
|
||||
continue
|
||||
|
||||
@staticmethod
|
||||
def get_instance(file: str | Path | None = None, config: Config | None = None) -> "Presets":
|
||||
"""
|
||||
Get the instance of the class.
|
||||
|
||||
Args:
|
||||
file (str|Path|None): The path to the presets file.
|
||||
config (Config|None): The config instance.
|
||||
|
||||
Returns:
|
||||
Presets: The instance of the class
|
||||
|
||||
"""
|
||||
return Presets(file=file, config=config)
|
||||
|
||||
async def on_shutdown(self, _: web.Application):
|
||||
pass
|
||||
|
||||
def attach(self, _: web.Application):
|
||||
"""
|
||||
Attach the class to the aiohttp application.
|
||||
|
||||
Args:
|
||||
_ (web.Application): The aiohttp application.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
self.load()
|
||||
|
||||
if not self.get(self._config.default_preset):
|
||||
LOG.error(f"Default preset '{self._config.default_preset}' not found, using 'default' preset.")
|
||||
self._config.default_preset = "default"
|
||||
|
||||
async def event_handler(_, __):
|
||||
msg = "Not implemented"
|
||||
raise Exception(msg)
|
||||
|
||||
EventBus.get_instance().subscribe(Events.PRESETS_ADD, event_handler, f"{__class__.__name__}.add")
|
||||
|
||||
def get_all(self) -> list[Preset]:
|
||||
"""Return the items."""
|
||||
return sorted(self._default + self._items, key=lambda x: x.priority, reverse=True)
|
||||
|
||||
def load(self) -> "Presets":
|
||||
"""
|
||||
Load the items.
|
||||
|
||||
Returns:
|
||||
Presets: The current instance.
|
||||
|
||||
"""
|
||||
has: int = len(self._items)
|
||||
self.clear()
|
||||
|
||||
if not self._file.exists() or self._file.stat().st_size < 10:
|
||||
return self
|
||||
|
||||
try:
|
||||
LOG.info(f"{'Reloading' if has else 'Loading'} '{self._file}'.")
|
||||
presets: dict = json.loads(self._file.read_text())
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to parse '{self._file}'. '{e}'.")
|
||||
return self
|
||||
|
||||
if not presets or len(presets) < 1:
|
||||
return self
|
||||
|
||||
need_save = False
|
||||
|
||||
for i, preset in enumerate(presets):
|
||||
try:
|
||||
if "priority" not in preset:
|
||||
preset["priority"] = 0
|
||||
need_save = True
|
||||
|
||||
if "id" not in preset:
|
||||
preset["id"] = str(uuid.uuid4())
|
||||
need_save = True
|
||||
|
||||
if preset.get("format"):
|
||||
if not preset.get("cli"):
|
||||
preset.update({"cli": f"--format {preset['format']}"})
|
||||
else:
|
||||
preset["cli"] = f"--format '{preset['format']}'\n" + preset["cli"]
|
||||
|
||||
preset["cli"] = str(preset["cli"]).strip()
|
||||
|
||||
preset.pop("format")
|
||||
need_save = True
|
||||
|
||||
preset: Preset = init_class(Preset, preset)
|
||||
|
||||
self._items.append(preset)
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to parse '{self._file}:{i}'. '{e!s}'.")
|
||||
continue
|
||||
|
||||
if need_save:
|
||||
LOG.warning("Saving presets due to schema changes.")
|
||||
self.save(self._items)
|
||||
|
||||
return self
|
||||
|
||||
def clear(self) -> "Presets":
|
||||
"""
|
||||
Clear all items.
|
||||
|
||||
Returns:
|
||||
Presets: The current instance.
|
||||
|
||||
"""
|
||||
if len(self._items) < 1:
|
||||
return self
|
||||
|
||||
self._items.clear()
|
||||
|
||||
return self
|
||||
|
||||
def validate(self, item: Preset | dict) -> bool:
|
||||
"""
|
||||
Validate the item.
|
||||
|
||||
Args:
|
||||
item (Preset|dict): The item to validate.
|
||||
|
||||
Returns:
|
||||
bool: True if valid
|
||||
|
||||
Raises:
|
||||
ValueError: If the item is not valid.
|
||||
|
||||
"""
|
||||
if not isinstance(item, dict):
|
||||
if not isinstance(item, Preset):
|
||||
msg = f"Unexpected '{type(item).__name__}' type was given."
|
||||
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 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 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)
|
||||
|
||||
return True
|
||||
|
||||
def save(self, items: list[Preset | dict]) -> "Presets":
|
||||
"""
|
||||
Save the items.
|
||||
|
||||
Args:
|
||||
items (list[Preset]): The items to save.
|
||||
|
||||
Returns:
|
||||
Presets: The current instance.
|
||||
|
||||
"""
|
||||
for i, preset in enumerate(items):
|
||||
try:
|
||||
if not isinstance(preset, Preset):
|
||||
preset: Preset = init_class(Preset, preset)
|
||||
items[i] = preset
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to save item '{i}' due to parsing error. '{e!s}'.")
|
||||
continue
|
||||
|
||||
try:
|
||||
self.validate(preset)
|
||||
except ValueError as e:
|
||||
LOG.error(f"Failed to validate item '{i}: {preset.name}'. '{e}'.")
|
||||
continue
|
||||
|
||||
try:
|
||||
self._file.write_text(
|
||||
json.dumps(obj=[preset.serialize() for preset in items if preset.default is False], indent=4)
|
||||
)
|
||||
|
||||
LOG.info(f"Saved '{self._file}'.")
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to save '{self._file}'. '{e!s}'.")
|
||||
|
||||
return self
|
||||
|
||||
def has(self, id_or_name: str) -> bool:
|
||||
"""
|
||||
Check if the item exists by id or name.
|
||||
|
||||
Args:
|
||||
id_or_name (str): The id or name of the item.
|
||||
|
||||
Returns:
|
||||
bool: True if exists, False otherwise.
|
||||
|
||||
"""
|
||||
return self.get(id_or_name) is not None
|
||||
|
||||
def get(self, id_or_name: str) -> Preset | None:
|
||||
"""
|
||||
Get the item by id or name.
|
||||
|
||||
Args:
|
||||
id_or_name (str): The id or name of the item.
|
||||
|
||||
Returns:
|
||||
Preset|None: The item if found, None otherwise.
|
||||
|
||||
"""
|
||||
if not id_or_name:
|
||||
return None
|
||||
|
||||
for preset in self.get_all():
|
||||
if id_or_name not in (preset.id, preset.name):
|
||||
continue
|
||||
|
||||
return preset
|
||||
|
||||
return None
|
||||
|
|
@ -19,7 +19,7 @@ class Scheduler(metaclass=Singleton):
|
|||
self._jobs: dict[str, Cron] = {}
|
||||
"The scheduled jobs."
|
||||
|
||||
self._loop = loop or asyncio.get_event_loop()
|
||||
self._loop: asyncio.AbstractEventLoop | None = loop
|
||||
"The event loop to use."
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -114,6 +114,13 @@ class Scheduler(metaclass=Singleton):
|
|||
if id and id in self._jobs:
|
||||
self.remove(id)
|
||||
|
||||
if not self._loop:
|
||||
try:
|
||||
self._loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
self._loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self._loop)
|
||||
|
||||
job = Cron(spec=timer, func=func, args=args, kwargs=kwargs, uuid=id, start=True, loop=self._loop)
|
||||
|
||||
job_id = str(job.uuid)
|
||||
|
|
@ -126,7 +133,7 @@ class Scheduler(metaclass=Singleton):
|
|||
|
||||
def remove(self, id: str | list[str]) -> bool:
|
||||
"""
|
||||
Remove a job from the schedule.
|
||||
Remove a job from the scheduler.
|
||||
|
||||
Args:
|
||||
id (str|list[str]): The id of the job to remove.
|
||||
|
|
@ -149,7 +156,7 @@ class Scheduler(metaclass=Singleton):
|
|||
return False
|
||||
|
||||
del self._jobs[id]
|
||||
LOG.debug(f"Removed job '{id}' from the schedule.")
|
||||
LOG.debug(f"Removed job '{id}' from the scheduler.")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import inspect
|
||||
import logging
|
||||
from typing import Any, TypeVar
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, Any, TypeVar, get_args, get_origin, get_type_hints
|
||||
|
||||
from app.library.Singleton import Singleton
|
||||
|
||||
|
|
@ -8,59 +9,170 @@ T = TypeVar("T")
|
|||
LOG: logging.Logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _unwrap_annotation(ann: Any) -> Any:
|
||||
if ann is inspect._empty:
|
||||
return inspect._empty
|
||||
|
||||
origin = get_origin(ann)
|
||||
|
||||
# Annotated[T, ...] -> T
|
||||
if origin is Annotated:
|
||||
args = get_args(ann)
|
||||
return args[0] if args else ann
|
||||
|
||||
# Optional[T] / Union[T, None] / T | None -> T
|
||||
if origin is None:
|
||||
return ann
|
||||
|
||||
if str(origin) in ("typing.Union", "types.UnionType"):
|
||||
args = [a for a in get_args(ann) if a is not type(None)]
|
||||
if len(args) == 1:
|
||||
return args[0]
|
||||
return ann
|
||||
|
||||
return ann
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServiceEntry:
|
||||
name: str
|
||||
declared_type: type | None
|
||||
instance: Any
|
||||
|
||||
|
||||
class Services(metaclass=Singleton):
|
||||
def __init__(self):
|
||||
self._services: dict[str, T] = {}
|
||||
self._services: list[ServiceEntry] = []
|
||||
|
||||
@staticmethod
|
||||
def get_instance() -> "Services":
|
||||
return Services()
|
||||
|
||||
def add(self, name: str, service: T):
|
||||
self._services[name] = service
|
||||
def add(self, name: str, service: Any, declared_type: type | None = None) -> "Services":
|
||||
"""
|
||||
Add a service by name.
|
||||
|
||||
def add_all(self, services: dict[str, T]):
|
||||
for name, service in services.items():
|
||||
self.add(name, service)
|
||||
Args:
|
||||
name: The name of the service.
|
||||
service: The service instance.
|
||||
declared_type: The declared type of the service (optional).
|
||||
|
||||
def get(self, name: str) -> T | None:
|
||||
return self._services.get(name)
|
||||
Returns:
|
||||
Services: The Services instance (for chaining).
|
||||
|
||||
def has(self, name: str) -> bool:
|
||||
return name in self._services
|
||||
"""
|
||||
if declared_type is None and service is not None:
|
||||
declared_type = type(service)
|
||||
|
||||
self.remove(name)
|
||||
self._services.append(ServiceEntry(name=name, declared_type=declared_type, instance=service))
|
||||
return self
|
||||
|
||||
def add_all(self, services: dict[str, Any]):
|
||||
for name, svc in services.items():
|
||||
self.add(name, svc)
|
||||
|
||||
def remove(self, name: str):
|
||||
if name not in self._services:
|
||||
return
|
||||
|
||||
self._services.pop(name, None)
|
||||
self._services = [e for e in self._services if e.name != name]
|
||||
|
||||
def clear(self):
|
||||
self._services.clear()
|
||||
|
||||
def get_all(self) -> dict[str, T]:
|
||||
def get(self, name: str) -> Any | None:
|
||||
for e in reversed(self._services):
|
||||
if e.name == name:
|
||||
return e.instance
|
||||
return None
|
||||
|
||||
def has(self, name: str) -> bool:
|
||||
return any(e.name == name for e in self._services)
|
||||
|
||||
def get_all(self) -> list[ServiceEntry]:
|
||||
return self._services.copy()
|
||||
|
||||
def get_by_type(self, expected_type: Any) -> Any | None:
|
||||
expected_type = _unwrap_annotation(expected_type)
|
||||
if expected_type is inspect._empty or not isinstance(expected_type, type):
|
||||
return None
|
||||
|
||||
exact: list[Any] = [e.instance for e in self._services if e.declared_type is expected_type]
|
||||
if len(exact) == 1:
|
||||
return exact[0]
|
||||
if len(exact) > 1:
|
||||
msg: str = (
|
||||
f"Ambiguous dependency for type {expected_type.__name__}: {len(exact)} exact matches. Resolve by name."
|
||||
)
|
||||
raise LookupError(msg)
|
||||
|
||||
candidates: list[Any] = [e.instance for e in self._services if isinstance(e.instance, expected_type)]
|
||||
if len(candidates) == 0:
|
||||
return None
|
||||
|
||||
if len(candidates) > 1:
|
||||
msg: str = (
|
||||
f"Ambiguous dependency for type {expected_type.__name__}: "
|
||||
f"{len(candidates)} candidates. Resolve by name."
|
||||
)
|
||||
raise LookupError(msg)
|
||||
|
||||
return candidates[0]
|
||||
|
||||
def _build_call_args(self, handler: callable, overrides: dict[str, Any]) -> dict[str, Any]:
|
||||
sig: inspect.Signature = inspect.signature(handler)
|
||||
|
||||
try:
|
||||
type_hints: dict[str, Any] = get_type_hints(handler)
|
||||
except Exception:
|
||||
type_hints = {}
|
||||
|
||||
resolved: dict[str, Any] = {}
|
||||
|
||||
for name, param in sig.parameters.items():
|
||||
if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
|
||||
continue
|
||||
|
||||
if name in overrides:
|
||||
resolved[name] = overrides[name]
|
||||
continue
|
||||
|
||||
by_name: Any | None = self.get(name)
|
||||
if by_name is not None:
|
||||
resolved[name] = by_name
|
||||
continue
|
||||
|
||||
ann: Any | None = type_hints.get(name, param.annotation)
|
||||
by_type: Any | None = self.get_by_type(ann)
|
||||
if by_type is not None:
|
||||
resolved[name] = by_type
|
||||
continue
|
||||
|
||||
if param.default is not inspect._empty:
|
||||
continue
|
||||
|
||||
missing_required: list[str] = []
|
||||
for name, param in sig.parameters.items():
|
||||
if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
|
||||
continue
|
||||
|
||||
if param.default is not inspect._empty:
|
||||
continue
|
||||
|
||||
if name not in resolved:
|
||||
missing_required.append(name)
|
||||
|
||||
if missing_required:
|
||||
LOG.error(
|
||||
"Missing arguments for handler '%s': %s",
|
||||
getattr(handler, "__name__", str(handler)),
|
||||
missing_required,
|
||||
)
|
||||
|
||||
return resolved
|
||||
|
||||
async def handle_async(self, handler: callable, **kwargs) -> Any:
|
||||
context = {**self.get_all(), **kwargs}
|
||||
|
||||
sig = inspect.signature(handler)
|
||||
expected_args = sig.parameters.keys()
|
||||
filtered = {k: v for k, v in context.items() if k in expected_args}
|
||||
|
||||
if missing_args := expected_args - filtered.keys():
|
||||
LOG.error(f"Missing arguments for handler '{handler.__name__}': {missing_args}")
|
||||
|
||||
return await handler(**filtered)
|
||||
resolved: dict[str, Any] = self._build_call_args(handler, kwargs)
|
||||
return await handler(**resolved)
|
||||
|
||||
def handle_sync(self, handler: callable, **kwargs) -> Any:
|
||||
context = {**self.get_all(), **kwargs}
|
||||
|
||||
sig = inspect.signature(handler)
|
||||
expected_args = sig.parameters.keys()
|
||||
filtered = {k: v for k, v in context.items() if k in expected_args}
|
||||
|
||||
if missing_args := expected_args - filtered.keys():
|
||||
LOG.error(f"Missing arguments for handler '{handler.__name__}': {missing_args}")
|
||||
|
||||
return handler(**filtered)
|
||||
resolved: dict[str, Any] = self._build_call_args(handler, kwargs)
|
||||
return handler(**resolved)
|
||||
|
|
|
|||
|
|
@ -1,435 +0,0 @@
|
|||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
from jsonschema import Draft7Validator, SchemaError, ValidationError
|
||||
|
||||
from .config import Config
|
||||
from .encoder import Encoder
|
||||
from .Services import Services
|
||||
from .Singleton import Singleton
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("task_definitions")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TaskDefinitionRecord:
|
||||
identifier: str
|
||||
"""UUID identifier of the task definition."""
|
||||
filename: str
|
||||
"""Filename of the task definition JSON file."""
|
||||
name: str
|
||||
"""Human-readable name of the task definition."""
|
||||
priority: int
|
||||
"""Priority of the task definition."""
|
||||
path: Path
|
||||
"""Path to the task definition JSON file."""
|
||||
data: dict[str, Any]
|
||||
"""The task definition data."""
|
||||
updated_at: float
|
||||
"""Last modified timestamp of the task definition file."""
|
||||
|
||||
def serialize(self, *, include_definition: bool = False) -> dict[str, Any]:
|
||||
"""
|
||||
Serialize the task definition record to a dictionary.
|
||||
|
||||
Args:
|
||||
include_definition (bool): Whether to include the full task definition data.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: The serialized task definition record.
|
||||
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
"id": self.identifier,
|
||||
"name": self.name,
|
||||
"priority": self.priority,
|
||||
"updated_at": self.updated_at,
|
||||
}
|
||||
|
||||
if include_definition:
|
||||
payload["definition"] = self.data
|
||||
|
||||
return payload
|
||||
|
||||
def json(self, *, include_definition: bool = False) -> str:
|
||||
"""
|
||||
Serialize the task definition record to a JSON string.
|
||||
|
||||
Args:
|
||||
include_definition (bool): Whether to include the full task definition data.
|
||||
|
||||
Returns:
|
||||
str: The JSON string representation of the task definition record.
|
||||
|
||||
"""
|
||||
return Encoder().encode(self.serialize(include_definition=include_definition))
|
||||
|
||||
|
||||
class TaskDefinitions(metaclass=Singleton):
|
||||
def __init__(
|
||||
self,
|
||||
directory: str | Path | None = None,
|
||||
config: Config | None = None,
|
||||
validator: Draft7Validator | None = None,
|
||||
):
|
||||
self._config: Config = config or Config.get_instance()
|
||||
"Instance of Config to use."
|
||||
self._directory: Path = Path(directory) if directory else Path(self._config.config_path) / "tasks"
|
||||
"Directory where task definition files are stored."
|
||||
self._validator: Draft7Validator | None = validator
|
||||
"JSON schema validator instance."
|
||||
self._items: dict[str, TaskDefinitionRecord] = {}
|
||||
"Mapping of task definition ID to TaskDefinitionRecord."
|
||||
self._schema: Path = Path(self._config.app_path) / "schema" / "task_definition.json"
|
||||
"Path to the JSON schema file for task definitions."
|
||||
|
||||
try:
|
||||
self._directory.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to create tasks directory '{self._directory}': {e}")
|
||||
|
||||
self.load()
|
||||
|
||||
@staticmethod
|
||||
def get_instance(
|
||||
directory: str | Path | None = None,
|
||||
config: Config | None = None,
|
||||
validator: Draft7Validator | None = None,
|
||||
) -> "TaskDefinitions":
|
||||
"""
|
||||
Get the singleton instance of TaskDefinitions.
|
||||
|
||||
Args:
|
||||
directory (str | Path | None): Optional directory to store task definitions.
|
||||
config (Config | None): Optional Config instance to use.
|
||||
validator (Draft7Validator | None): Optional JSON schema validator to use.
|
||||
|
||||
Returns:
|
||||
TaskDefinitions: The singleton instance of TaskDefinitions.
|
||||
|
||||
"""
|
||||
return TaskDefinitions(directory=directory, config=config, validator=validator)
|
||||
|
||||
def attach(self, _: web.Application) -> None:
|
||||
"""
|
||||
Attach the TaskDefinitions service to the application.
|
||||
|
||||
Args:
|
||||
_ (web.Application): The aiohttp web application instance.
|
||||
|
||||
"""
|
||||
Services.get_instance().add("task_definitions", self)
|
||||
|
||||
async def on_shutdown(self, _: web.Application) -> None:
|
||||
"""
|
||||
Handle application shutdown event.
|
||||
|
||||
Args:
|
||||
_ (web.Application): The aiohttp web application instance.
|
||||
|
||||
"""
|
||||
return
|
||||
|
||||
def _get_validator(self) -> Draft7Validator:
|
||||
"""
|
||||
Get or create the JSON schema validator for task definitions.
|
||||
|
||||
Returns:
|
||||
Draft7Validator: The JSON schema validator instance.
|
||||
|
||||
"""
|
||||
if self._validator:
|
||||
return self._validator
|
||||
|
||||
try:
|
||||
contents: str = self._schema.read_text(encoding="utf-8")
|
||||
schema = json.loads(contents)
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to read task definition schema '{self._schema}': {e}")
|
||||
raise
|
||||
|
||||
try:
|
||||
self._validator = Draft7Validator(schema)
|
||||
except SchemaError as e:
|
||||
LOG.error(f"Invalid task definition schema '{self._schema}': {e}")
|
||||
raise
|
||||
|
||||
return self._validator
|
||||
|
||||
def validate(self, definition: dict[str, Any]) -> None:
|
||||
"""
|
||||
Validate a task definition against the JSON schema.
|
||||
|
||||
Args:
|
||||
definition (dict[str, Any]): The task definition to validate.
|
||||
|
||||
Raises:
|
||||
ValueError: If the task definition is invalid.
|
||||
|
||||
"""
|
||||
try:
|
||||
self._get_validator().validate(definition)
|
||||
except ValidationError as e:
|
||||
path: str = " ".join(str(part) for part in e.path)
|
||||
error_path: str = f" ({path})" if path else ""
|
||||
message: str = f"Task definition validation failed{error_path}: {e.message}"
|
||||
raise ValueError(message) from e
|
||||
|
||||
def load(self) -> "TaskDefinitions":
|
||||
"""
|
||||
Load all task definitions from the directory.
|
||||
|
||||
Returns:
|
||||
TaskDefinitions: The current instance for chaining.
|
||||
|
||||
"""
|
||||
self._items.clear()
|
||||
|
||||
if not self._directory.exists():
|
||||
return self
|
||||
|
||||
for file_path in sorted(self._directory.glob("*.json")):
|
||||
stem: str = file_path.stem
|
||||
|
||||
try:
|
||||
identifier_uuid = uuid.UUID(stem)
|
||||
except ValueError:
|
||||
LOG.warning(f"Skipping task definition with invalid UUID filename '{file_path.name}'.")
|
||||
continue
|
||||
|
||||
if 4 != identifier_uuid.version:
|
||||
LOG.warning(f"Skipping task definition '{file_path.name}', Name is not UUIDv4.")
|
||||
continue
|
||||
|
||||
try:
|
||||
contents: str = file_path.read_text(encoding="utf-8")
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to load task definition '{file_path}': {e!s}")
|
||||
continue
|
||||
|
||||
try:
|
||||
parsed = json.loads(contents)
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to parse task definition '{file_path}': {e!s}")
|
||||
continue
|
||||
|
||||
if not isinstance(parsed, dict):
|
||||
LOG.error(f"Invalid task definition file '{file_path}': must be a JSON object.")
|
||||
continue
|
||||
|
||||
data: dict[str, Any] = parsed
|
||||
|
||||
identifier: str = str(identifier_uuid)
|
||||
|
||||
name_value: str = str(data.get("name") or identifier)
|
||||
priority: int = self._normalize_priority(data.get("priority", 0))
|
||||
data["priority"] = priority
|
||||
|
||||
record = TaskDefinitionRecord(
|
||||
identifier=identifier,
|
||||
filename=file_path.name,
|
||||
name=name_value,
|
||||
priority=priority,
|
||||
path=file_path,
|
||||
data=data,
|
||||
updated_at=file_path.stat().st_mtime,
|
||||
)
|
||||
self._items[record.identifier] = record
|
||||
|
||||
return self
|
||||
|
||||
def list(self) -> list[TaskDefinitionRecord]:
|
||||
"""
|
||||
List all task definitions, sorted by priority and name.
|
||||
|
||||
Returns:
|
||||
list[TaskDefinitionRecord]: List of task definitions sorted by priority and name.
|
||||
|
||||
"""
|
||||
return sorted(
|
||||
self._items.values(),
|
||||
key=lambda record: (record.priority, record.name.lower()),
|
||||
)
|
||||
|
||||
def get(self, identifier: str) -> TaskDefinitionRecord | None:
|
||||
"""
|
||||
Get a task definition by its identifier.
|
||||
|
||||
Args:
|
||||
identifier (str): The UUID identifier of the task definition.
|
||||
|
||||
Returns:
|
||||
TaskDefinitionRecord | None: The task definition record, or None if not found.
|
||||
|
||||
"""
|
||||
return self._items.get(identifier)
|
||||
|
||||
def _path_for(self, identifier: str) -> Path:
|
||||
"""
|
||||
Get the file path for a given task definition identifier.
|
||||
|
||||
Args:
|
||||
identifier (str): The UUID identifier of the task definition.
|
||||
|
||||
Returns:
|
||||
Path: The file path for the task definition JSON file.
|
||||
|
||||
"""
|
||||
return self._directory / f"{identifier}.json"
|
||||
|
||||
def _write_file(self, path: Path, payload: dict[str, Any]) -> None:
|
||||
"""
|
||||
Write a task definition to a file.
|
||||
|
||||
Args:
|
||||
path (Path): The file path to write to.
|
||||
payload (dict[str, Any]): The task definition data to write.
|
||||
|
||||
Raises:
|
||||
Exception: If writing to the file fails.
|
||||
|
||||
"""
|
||||
path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
||||
try:
|
||||
path.chmod(0o600)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _normalize_priority(self, value: Any) -> int:
|
||||
"""
|
||||
Normalize the priority value to an integer.
|
||||
|
||||
Args:
|
||||
value (Any): The priority value to normalize.
|
||||
|
||||
Returns:
|
||||
int: The normalized priority value.
|
||||
|
||||
"""
|
||||
try:
|
||||
priority = int(value)
|
||||
except Exception:
|
||||
priority = 0
|
||||
|
||||
return priority
|
||||
|
||||
def _refresh_generic_handler(self) -> None:
|
||||
"""
|
||||
Refresh the generic task handler definitions.
|
||||
"""
|
||||
try:
|
||||
from .task_handlers.generic import GenericTaskHandler
|
||||
|
||||
GenericTaskHandler.refresh_definitions(force=True)
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to refresh generic task handler: {e}")
|
||||
|
||||
def create(self, definition: dict[str, Any]) -> TaskDefinitionRecord:
|
||||
"""
|
||||
Create a new task definition.
|
||||
|
||||
Args:
|
||||
definition (dict[str, Any]): The task definition data.
|
||||
|
||||
Returns:
|
||||
TaskDefinitionRecord: The created task definition record.
|
||||
|
||||
"""
|
||||
self.validate(definition)
|
||||
|
||||
identifier: str = str(uuid.uuid4())
|
||||
path: Path = self._path_for(identifier)
|
||||
|
||||
while path.exists():
|
||||
identifier = str(uuid.uuid4())
|
||||
path = self._path_for(identifier)
|
||||
|
||||
priority: int = self._normalize_priority(definition.get("priority", 0))
|
||||
definition["priority"] = priority
|
||||
|
||||
self._write_file(path, definition)
|
||||
|
||||
record = TaskDefinitionRecord(
|
||||
identifier=identifier,
|
||||
filename=path.name,
|
||||
name=str(definition.get("name", identifier)),
|
||||
priority=priority,
|
||||
path=path,
|
||||
data=definition,
|
||||
updated_at=path.stat().st_mtime,
|
||||
)
|
||||
|
||||
self._items[identifier] = record
|
||||
self._refresh_generic_handler()
|
||||
return record
|
||||
|
||||
def update(self, identifier: str, definition: dict[str, Any]) -> TaskDefinitionRecord:
|
||||
"""
|
||||
Update an existing task definition.
|
||||
|
||||
Args:
|
||||
identifier (str): The UUID identifier of the task definition to update.
|
||||
definition (dict[str, Any]): The updated task definition data.
|
||||
|
||||
Returns:
|
||||
TaskDefinitionRecord: The updated task definition record.
|
||||
|
||||
Raises:
|
||||
ValueError: If the task definition does not exist or is invalid.
|
||||
|
||||
"""
|
||||
record: TaskDefinitionRecord | None = self.get(identifier)
|
||||
if not record:
|
||||
message: str = f"Task definition '{identifier}' does not exist."
|
||||
raise ValueError(message)
|
||||
|
||||
self.validate(definition)
|
||||
priority: int = self._normalize_priority(definition.get("priority", record.priority))
|
||||
definition["priority"] = priority
|
||||
self._write_file(record.path, definition)
|
||||
|
||||
updated_record = TaskDefinitionRecord(
|
||||
identifier=identifier,
|
||||
filename=record.filename,
|
||||
name=str(definition.get("name", identifier)),
|
||||
priority=priority,
|
||||
path=record.path,
|
||||
data=definition,
|
||||
updated_at=record.path.stat().st_mtime,
|
||||
)
|
||||
|
||||
self._items[identifier] = updated_record
|
||||
self._refresh_generic_handler()
|
||||
return updated_record
|
||||
|
||||
def delete(self, identifier: str) -> None:
|
||||
"""
|
||||
Delete a task definition.
|
||||
|
||||
Args:
|
||||
identifier (str): The UUID identifier of the task definition to delete.
|
||||
|
||||
Raises:
|
||||
ValueError: If the task definition does not exist.
|
||||
|
||||
"""
|
||||
record: TaskDefinitionRecord | None = self.get(identifier)
|
||||
if not record:
|
||||
message: str = f"Task definition '{identifier}' does not exist."
|
||||
raise ValueError(message)
|
||||
|
||||
try:
|
||||
record.path.unlink(missing_ok=False)
|
||||
except FileNotFoundError:
|
||||
LOG.warning(f"Task definition file '{record.path}' already removed.")
|
||||
except Exception as exc:
|
||||
LOG.error(f"Failed to delete task definition '{identifier}': {exc}")
|
||||
raise
|
||||
|
||||
self._items.pop(identifier, None)
|
||||
self._refresh_generic_handler()
|
||||
1067
app/library/Tasks.py
1067
app/library/Tasks.py
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,7 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
|
|
@ -13,9 +13,6 @@ from .Scheduler import Scheduler
|
|||
from .Singleton import Singleton
|
||||
from .version import APP_VERSION
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.library.dl_fields import Any
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("update_checker")
|
||||
|
||||
|
||||
|
|
@ -27,12 +24,18 @@ class UpdateChecker(metaclass=Singleton):
|
|||
GITHUB_API_URL: str = "https://api.github.com/repos/arabcoders/ytptube/releases/latest"
|
||||
"GitHub API endpoint for latest release"
|
||||
|
||||
YTDLP_API_URL: str = "https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest"
|
||||
"GitHub API endpoint for yt-dlp latest release"
|
||||
|
||||
CACHE_DURATION: int = 300
|
||||
"Cache duration in seconds (5 minutes)"
|
||||
|
||||
CACHE_KEY: str = "update_checker:result"
|
||||
"Cache key for storing check results"
|
||||
|
||||
YTDLP_CACHE_KEY: str = "update_checker:ytdlp"
|
||||
"Cache key for storing yt-dlp check results"
|
||||
|
||||
def __init__(
|
||||
self, config: Config | None = None, scheduler: Scheduler | None = None, notify: EventBus | None = None
|
||||
) -> None:
|
||||
|
|
@ -55,28 +58,9 @@ class UpdateChecker(metaclass=Singleton):
|
|||
def get_instance(
|
||||
config: Config | None = None, scheduler: Scheduler | None = None, notify: EventBus | None = None
|
||||
) -> "UpdateChecker":
|
||||
"""
|
||||
Get the singleton instance of UpdateChecker.
|
||||
|
||||
Args:
|
||||
config (Config | None): Optional Config instance to use.
|
||||
scheduler (Scheduler | None): Optional Scheduler instance to use.
|
||||
notify (EventBus | None): Optional EventBus instance to use.
|
||||
|
||||
Returns:
|
||||
UpdateChecker: The singleton instance of UpdateChecker.
|
||||
|
||||
"""
|
||||
return UpdateChecker(config=config, scheduler=scheduler, notify=notify)
|
||||
|
||||
def attach(self, _: web.Application) -> None:
|
||||
"""
|
||||
Attach the UpdateChecker to the application.
|
||||
|
||||
Args:
|
||||
_ (web.Application): The aiohttp web application instance.
|
||||
|
||||
"""
|
||||
from .Services import Services
|
||||
|
||||
Services.get_instance().add("update_checker", self)
|
||||
|
|
@ -100,7 +84,7 @@ class UpdateChecker(metaclass=Singleton):
|
|||
|
||||
self._schedule_check()
|
||||
|
||||
async def on_shutdown(self, _: web.Application) -> None:
|
||||
async def on_shutdown(self, _: web.Application | None) -> None:
|
||||
"""
|
||||
Handle application shutdown event.
|
||||
|
||||
|
|
@ -113,89 +97,129 @@ class UpdateChecker(metaclass=Singleton):
|
|||
|
||||
self._scheduler.remove(self._job_id)
|
||||
self._job_id = None
|
||||
LOG.debug("Stopped update check scheduled task.")
|
||||
|
||||
def _schedule_check(self) -> None:
|
||||
"""Schedule the update check task to run daily at 3 AM."""
|
||||
if not self._config.check_for_updates:
|
||||
LOG.debug("Update checking is disabled, skipping scheduling.")
|
||||
return
|
||||
|
||||
# Run daily at 3 AM
|
||||
timer: str = "0 3 * * *"
|
||||
|
||||
self._job_id = self._scheduler.add(
|
||||
timer=timer,
|
||||
func=lambda: asyncio.create_task(self.check_for_updates()),
|
||||
id="update_checker",
|
||||
id=f"{__class__.__name__}.{self.check_for_updates.__name__}",
|
||||
)
|
||||
|
||||
LOG.info(f"Scheduled update check to run daily at 3 AM (cron: {timer}).")
|
||||
|
||||
async def check_for_updates(self) -> tuple[str, str | None]:
|
||||
async def check_for_updates(self) -> tuple[tuple[str, str | None], tuple[str, str | None]]:
|
||||
"""
|
||||
Check for updates from GitHub releases.
|
||||
Updates config.new_version if a newer version is available.
|
||||
Stops the scheduled task if an update is found.
|
||||
Check for updates from GitHub releases for both the app and yt-dlp.
|
||||
Updates config.new_version and config.yt_new_version if newer versions are available.
|
||||
Stops the scheduled task if an app update is found.
|
||||
|
||||
Returns:
|
||||
tuple[str, str | None]: (status, new_version)
|
||||
tuple[tuple[str, str | None], tuple[str, str | None]]: ((app_status, app_version), (ytdlp_status, ytdlp_version))
|
||||
status: "disabled", "error", "up_to_date", or "update_available"
|
||||
new_version: The new version tag if available, None otherwise
|
||||
version: The new version tag if available, None otherwise
|
||||
|
||||
"""
|
||||
if not self._config.check_for_updates:
|
||||
LOG.debug("Update checking is disabled, skipping check.")
|
||||
return ("disabled", None)
|
||||
return (("disabled", None), ("disabled", None))
|
||||
|
||||
# Check cache
|
||||
cached = await self._cache.aget(self.CACHE_KEY)
|
||||
if cached:
|
||||
ttl = await self._cache.attl(self.CACHE_KEY)
|
||||
LOG.debug(f"Returning cached result (TTL: {ttl:.0f}s)")
|
||||
return cached
|
||||
app_cached = await self._cache.aget(self.CACHE_KEY)
|
||||
ytdlp_cached = await self._cache.aget(self.YTDLP_CACHE_KEY)
|
||||
|
||||
if app_cached and ytdlp_cached:
|
||||
return (app_cached, ytdlp_cached)
|
||||
|
||||
app_result = app_cached if app_cached else await self._check_app_version()
|
||||
ytdlp_result = ytdlp_cached if ytdlp_cached else await self._check_ytdlp_version()
|
||||
|
||||
return (app_result, ytdlp_result)
|
||||
|
||||
async def _check_github_version(
|
||||
self,
|
||||
name: str,
|
||||
api_url: str,
|
||||
current_version: str,
|
||||
cache_key: str,
|
||||
strip_v_prefix: bool = False,
|
||||
) -> tuple[str, str | None]:
|
||||
try:
|
||||
LOG.info("Checking for application updates...")
|
||||
|
||||
current_version: str = APP_VERSION.lstrip("v")
|
||||
LOG.info(f"Checking for {name} updates...")
|
||||
|
||||
async with async_client(timeout=10.0) as client:
|
||||
response = await client.get(
|
||||
self.GITHUB_API_URL,
|
||||
api_url,
|
||||
headers={"Accept": "application/vnd.github+json"},
|
||||
)
|
||||
|
||||
if 200 != response.status_code:
|
||||
LOG.warning(f"Failed to check for updates: HTTP {response.status_code}")
|
||||
LOG.warning(f"Failed to check for {name} updates: HTTP {response.status_code}")
|
||||
return ("error", None)
|
||||
|
||||
data: dict[str, Any] = response.json()
|
||||
|
||||
latest_tag: str = data.get("tag_name", "").lstrip("v")
|
||||
latest_tag: str = data.get("tag_name", "")
|
||||
if not latest_tag:
|
||||
LOG.warning("No tag_name found in GitHub release data.")
|
||||
LOG.warning(f"No tag_name found in {name} GitHub release data.")
|
||||
return ("error", None)
|
||||
|
||||
if self._compare_versions(current_version, latest_tag):
|
||||
LOG.warning(f"Update available: {current_version} → {latest_tag}")
|
||||
new_version_tag = data.get("tag_name", "")
|
||||
self._config.new_version = new_version_tag
|
||||
await self.on_shutdown(None)
|
||||
result = ("update_available", new_version_tag)
|
||||
await self._cache.aset(self.CACHE_KEY, result, self.CACHE_DURATION)
|
||||
compare_current: str = current_version.lstrip("v") if strip_v_prefix else current_version
|
||||
compare_latest: str = latest_tag.lstrip("v") if strip_v_prefix else latest_tag
|
||||
|
||||
if self._compare_versions(compare_current, compare_latest):
|
||||
LOG.warning(f"{name} update available: {current_version} -> {latest_tag}")
|
||||
result: tuple[str, str] = ("update_available", latest_tag)
|
||||
await self._cache.aset(cache_key, result, self.CACHE_DURATION)
|
||||
return result
|
||||
|
||||
LOG.info("No updates available.")
|
||||
self._config.new_version = ""
|
||||
result = ("up_to_date", None)
|
||||
await self._cache.aset(self.CACHE_KEY, result, self.CACHE_DURATION)
|
||||
LOG.info(f"No {name} updates available.")
|
||||
result: tuple[str, None] = ("up_to_date", None)
|
||||
await self._cache.aset(cache_key, result, self.CACHE_DURATION)
|
||||
return result
|
||||
except Exception as e:
|
||||
LOG.exception(e)
|
||||
LOG.error(f"Error checking for updates: {e!s}")
|
||||
LOG.error(f"Error checking for {name} updates: {e!s}")
|
||||
return ("error", None)
|
||||
|
||||
async def _check_app_version(self) -> tuple[str, str | None]:
|
||||
status, new_version = await self._check_github_version(
|
||||
name="application",
|
||||
api_url=self.GITHUB_API_URL,
|
||||
current_version=APP_VERSION,
|
||||
cache_key=self.CACHE_KEY,
|
||||
strip_v_prefix=True,
|
||||
)
|
||||
|
||||
if "update_available" == status:
|
||||
self._config.new_version = new_version or ""
|
||||
await self.on_shutdown(None)
|
||||
elif "up_to_date" == status:
|
||||
self._config.new_version = ""
|
||||
|
||||
return (status, new_version)
|
||||
|
||||
async def _check_ytdlp_version(self) -> tuple[str, str | None]:
|
||||
current_version: str = self._config._ytdlp_version()
|
||||
if not current_version or "0.0.0" == current_version:
|
||||
LOG.warning("Could not determine yt-dlp version, skipping yt-dlp update check.")
|
||||
return ("error", None)
|
||||
|
||||
status, new_version = await self._check_github_version(
|
||||
name="yt-dlp",
|
||||
api_url=self.YTDLP_API_URL,
|
||||
current_version=current_version,
|
||||
cache_key=self.YTDLP_CACHE_KEY,
|
||||
strip_v_prefix=False,
|
||||
)
|
||||
|
||||
if "update_available" == status:
|
||||
self._config.yt_new_version = new_version or ""
|
||||
elif "up_to_date" == status:
|
||||
self._config.yt_new_version = ""
|
||||
|
||||
return (status, new_version)
|
||||
|
||||
def _compare_versions(self, current: str, latest: str) -> bool:
|
||||
"""
|
||||
Compare version strings to determine if an update is available.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import asyncio
|
||||
import base64
|
||||
import copy
|
||||
import functools
|
||||
import glob
|
||||
import ipaddress
|
||||
import json
|
||||
|
|
@ -86,6 +88,9 @@ TAG_REGEX: re.Pattern[str] = re.compile(r"%{([^:}]+)(?::([^}]*))?}c")
|
|||
DT_PATTERN: re.Pattern[str] = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?")
|
||||
"Regex to match ISO 8601 datetime strings."
|
||||
|
||||
EXTRACTORS_SEMAPHORE: asyncio.Semaphore | None = None
|
||||
"Global semaphore for limiting concurrent extractor operations."
|
||||
|
||||
|
||||
class StreamingError(Exception):
|
||||
"""Raised when an error occurs during streaming."""
|
||||
|
|
@ -416,6 +421,58 @@ def extract_info(
|
|||
return YTDLP.sanitize_info(data, remove_private_keys=True) if sanitize_info else data
|
||||
|
||||
|
||||
async def fetch_info(
|
||||
config: dict,
|
||||
url: str,
|
||||
debug: bool = False,
|
||||
no_archive: bool = False,
|
||||
follow_redirect: bool = False,
|
||||
sanitize_info: bool = False,
|
||||
**kwargs,
|
||||
) -> dict:
|
||||
"""
|
||||
Extracts video information from the given URL.
|
||||
|
||||
Args:
|
||||
config (dict): Configuration options.
|
||||
url (str): URL to extract information from.
|
||||
debug (bool): Enable debug logging.
|
||||
no_archive (bool): Disable download archive.
|
||||
follow_redirect (bool): Follow URL redirects.
|
||||
sanitize_info (bool): Sanitize the extracted information
|
||||
**kwargs: Additional arguments.
|
||||
|
||||
Returns:
|
||||
dict: Video information.
|
||||
|
||||
"""
|
||||
from .config import Config
|
||||
|
||||
global EXTRACTORS_SEMAPHORE # noqa: PLW0603
|
||||
|
||||
conf = Config.get_instance()
|
||||
if EXTRACTORS_SEMAPHORE is None:
|
||||
EXTRACTORS_SEMAPHORE = asyncio.Semaphore(conf.extract_info_concurrency)
|
||||
|
||||
async with EXTRACTORS_SEMAPHORE:
|
||||
return await asyncio.wait_for(
|
||||
fut=asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
functools.partial(
|
||||
extract_info,
|
||||
config=config,
|
||||
url=url,
|
||||
debug=debug,
|
||||
no_archive=no_archive,
|
||||
follow_redirect=follow_redirect,
|
||||
sanitize_info=sanitize_info,
|
||||
**kwargs,
|
||||
),
|
||||
),
|
||||
timeout=conf.extract_info_timeout,
|
||||
)
|
||||
|
||||
|
||||
def _is_safe_key(key: any) -> bool:
|
||||
"""
|
||||
Check if a dictionary key is safe for merging.
|
||||
|
|
@ -1590,6 +1647,9 @@ def load_modules(root_path: Path, directory: Path):
|
|||
import pkgutil
|
||||
|
||||
package_name: str = str(directory.relative_to(root_path).as_posix()).replace("/", ".")
|
||||
# Ensure package name starts with 'app.' for proper module resolution
|
||||
if not package_name.startswith("app."):
|
||||
package_name = f"app.{package_name}"
|
||||
|
||||
for _, name, _ in pkgutil.iter_modules([directory]):
|
||||
full_name: str = f"{package_name}.{name}"
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ import shlex
|
|||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.features.presets.schemas import Preset
|
||||
|
||||
from .config import Config
|
||||
from .Presets import Preset, Presets
|
||||
from .Utils import REMOVE_KEYS, arg_converter, calc_download_path, create_cookies_file, merge_dict
|
||||
|
||||
LOG: logging.Logger = logging.getLogger("YTDLPOpts")
|
||||
|
|
@ -129,9 +130,16 @@ class YTDLPCli:
|
|||
raise ValueError(msg)
|
||||
|
||||
self.item = item
|
||||
self.preset: Preset = item.get_preset()
|
||||
preset_name = item.preset if item.preset else self._config.default_preset
|
||||
self.preset: Preset | None = YTDLPCli._get_presets().get(preset_name)
|
||||
self._config: Config = config or Config.get_instance()
|
||||
|
||||
@staticmethod
|
||||
def _get_presets():
|
||||
from app.features.presets.service import Presets
|
||||
|
||||
return Presets.get_instance()
|
||||
|
||||
def build(self) -> tuple[str, dict]:
|
||||
"""
|
||||
Build the CLI command following make_command logic.
|
||||
|
|
@ -306,7 +314,7 @@ class YTDLPOpts:
|
|||
YTDLPOpts: The instance of the class
|
||||
|
||||
"""
|
||||
preset: Preset | None = Presets.get_instance().get(name)
|
||||
preset: Preset | None = YTDLPCli._get_presets().get(name)
|
||||
if not preset:
|
||||
return self
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,18 @@
|
|||
import hashlib
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from app.library.Services import Services
|
||||
|
||||
from .Scheduler import Scheduler
|
||||
from .Singleton import ThreadSafe
|
||||
|
||||
LOG = logging.getLogger("cache")
|
||||
|
||||
|
||||
class Cache(metaclass=ThreadSafe):
|
||||
def __init__(self) -> None:
|
||||
|
|
@ -14,6 +22,25 @@ class Cache(metaclass=ThreadSafe):
|
|||
self._cache: dict[str, tuple[Any, float | None]] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@staticmethod
|
||||
def get_instance() -> "Cache":
|
||||
"""
|
||||
Get the singleton instance of Cache.
|
||||
|
||||
Returns:
|
||||
Cache: The singleton instance of Cache.
|
||||
|
||||
"""
|
||||
return Cache()
|
||||
|
||||
def attach(self, _: web.Application) -> None:
|
||||
Services.get_instance().add("cache", self)
|
||||
Scheduler.get_instance().add(
|
||||
timer="* * * * *",
|
||||
func=self.cleanup,
|
||||
id=f"{__class__.__name__}.{__class__.cleanup.__name__}",
|
||||
)
|
||||
|
||||
def set(self, key: str, value: Any, ttl: float | None = None) -> None:
|
||||
"""
|
||||
Synchronously set a value in the cache with an optional time-to-live.
|
||||
|
|
@ -132,3 +159,20 @@ class Cache(metaclass=ThreadSafe):
|
|||
Asynchronously generate a SHA-256 hash for the given input string.
|
||||
"""
|
||||
return self.hash(key=key)
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""
|
||||
Remove all expired entries from the cache.
|
||||
Called periodically by the scheduler.
|
||||
"""
|
||||
with self._lock:
|
||||
now = time.time()
|
||||
expired_keys: list[str] = [
|
||||
key for key, (_, expire_at) in self._cache.items() if expire_at is not None and now >= expire_at
|
||||
]
|
||||
|
||||
for key in expired_keys:
|
||||
del self._cache[key]
|
||||
|
||||
if expired_keys:
|
||||
LOG.debug(f"Cleaned up {len(expired_keys)} expired cache entries.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -238,6 +238,9 @@ class Config(metaclass=Singleton):
|
|||
new_version: str = ""
|
||||
"The new version available."
|
||||
|
||||
yt_new_version: str = ""
|
||||
"The new yt-dlp version available."
|
||||
|
||||
_manual_vars: tuple = (
|
||||
"temp_path",
|
||||
"config_path",
|
||||
|
|
@ -255,6 +258,7 @@ class Config(metaclass=Singleton):
|
|||
"app_build_date",
|
||||
"app_branch",
|
||||
"new_version",
|
||||
"yt_new_version",
|
||||
)
|
||||
"The variables that are immutable."
|
||||
|
||||
|
|
@ -269,6 +273,7 @@ class Config(metaclass=Singleton):
|
|||
"download_info_expires",
|
||||
"auto_clear_history_days",
|
||||
"default_pagination",
|
||||
"extract_info_concurrency",
|
||||
"flaresolverr_max_timeout",
|
||||
"flaresolverr_client_timeout",
|
||||
"flaresolverr_cache_ttl",
|
||||
|
|
@ -327,6 +332,7 @@ class Config(metaclass=Singleton):
|
|||
"default_pagination",
|
||||
"check_for_updates",
|
||||
"new_version",
|
||||
"yt_new_version",
|
||||
)
|
||||
"The variables that are relevant to the frontend."
|
||||
|
||||
|
|
|
|||
|
|
@ -1,333 +0,0 @@
|
|||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .config import Config
|
||||
from .encoder import Encoder
|
||||
from .Events import EventBus, Events
|
||||
from .Singleton import Singleton
|
||||
from .Utils import init_class
|
||||
|
||||
LOG = logging.getLogger("DLFields")
|
||||
|
||||
|
||||
class FieldType(str, Enum):
|
||||
STRING = "string"
|
||||
TEXT = "text"
|
||||
BOOL = "bool"
|
||||
|
||||
@classmethod
|
||||
def all(cls) -> list[str]:
|
||||
return [member.value for member in cls]
|
||||
|
||||
@classmethod
|
||||
def from_value(cls, value: str) -> "FieldType":
|
||||
"""
|
||||
Returns the FieldType enum member corresponding to the given value.
|
||||
|
||||
Args:
|
||||
value (str): The value to match against the enum members.
|
||||
|
||||
Returns:
|
||||
StoreType: The enum member that matches the value.
|
||||
|
||||
Raises:
|
||||
ValueError: If the value does not match any member.
|
||||
|
||||
"""
|
||||
for member in cls:
|
||||
if member.value == value:
|
||||
return member
|
||||
|
||||
msg = f"Invalid StoreType value: {value}"
|
||||
raise ValueError(msg)
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.value
|
||||
|
||||
|
||||
@dataclass(kw_only=True)
|
||||
class DLField:
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
"""The id of the field."""
|
||||
|
||||
name: str
|
||||
"""The name of the preset."""
|
||||
|
||||
description: str
|
||||
"""The description of the preset."""
|
||||
|
||||
field: str
|
||||
"""The yt-dlp field to use in long format."""
|
||||
|
||||
kind: FieldType = FieldType.TEXT
|
||||
"""The kind of the field. i.e. string, bool"""
|
||||
|
||||
icon: str = ""
|
||||
"""The icon of the field, it can be a font-awesome icon"""
|
||||
|
||||
order: int = 0
|
||||
"""The order of the field, used to sort the fields in the UI."""
|
||||
|
||||
value: str = ""
|
||||
"""The default value of the field, It's currently unused."""
|
||||
|
||||
extras: dict = field(default_factory=dict)
|
||||
"""Additional options for the field."""
|
||||
|
||||
def serialize(self) -> dict:
|
||||
dct = self.__dict__
|
||||
dct["kind"] = str(self.kind)
|
||||
dct["extras"] = {k: v for k, v in self.extras.items() if v is not None}
|
||||
return dct
|
||||
|
||||
def json(self) -> str:
|
||||
return Encoder().encode(self.serialize())
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
return self.serialize().get(key, default)
|
||||
|
||||
|
||||
class DLFields(metaclass=Singleton):
|
||||
"""
|
||||
This class is used to manage the DLFields.
|
||||
"""
|
||||
|
||||
def __init__(self, file: str | Path | None = None, config: Config | None = None):
|
||||
self._items: list[DLField] = []
|
||||
"The list of items."
|
||||
config: Config = config or Config.get_instance()
|
||||
"The configuration instance."
|
||||
self._file: Path = Path(file) if file else Path(config.config_path).joinpath("dl_fields.json")
|
||||
"The path to the file where the items are stored."
|
||||
|
||||
if self._file.exists() and "600" != self._file.stat().st_mode:
|
||||
try:
|
||||
self._file.chmod(0o600)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def get_instance(file: str | Path | None = None, config: Config | None = None) -> "DLFields":
|
||||
"""
|
||||
Get the instance of the class.
|
||||
|
||||
Returns:
|
||||
DLFields: The instance of the class
|
||||
|
||||
"""
|
||||
return DLFields(file=file, config=config)
|
||||
|
||||
async def on_shutdown(self, _: web.Application):
|
||||
pass
|
||||
|
||||
def attach(self, _: web.Application):
|
||||
"""
|
||||
Attach the class to the aiohttp application.
|
||||
|
||||
Args:
|
||||
_ (web.Application): The aiohttp application.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
self.load()
|
||||
|
||||
async def event_handler(_, __):
|
||||
msg = "Not implemented"
|
||||
raise Exception(msg)
|
||||
|
||||
EventBus.get_instance().subscribe(Events.DLFIELDS_ADD, event_handler, f"{__class__.__name__}.add")
|
||||
|
||||
def get_all(self) -> list[DLField]:
|
||||
"""Return the items."""
|
||||
return self._items
|
||||
|
||||
def load(self) -> "DLFields":
|
||||
"""
|
||||
Load the items.
|
||||
|
||||
Returns:
|
||||
Presets: The current instance.
|
||||
|
||||
"""
|
||||
has: int = len(self._items)
|
||||
self.clear()
|
||||
|
||||
if not self._file.exists() or self._file.stat().st_size < 10:
|
||||
return self
|
||||
|
||||
try:
|
||||
LOG.info(f"{'Reloading' if has else 'Loading'} '{self._file}'.")
|
||||
items: dict = json.loads(self._file.read_text())
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to parse '{self._file}'. '{e}'.")
|
||||
return self
|
||||
|
||||
if not items or len(items) < 1:
|
||||
return self
|
||||
|
||||
need_save = False
|
||||
|
||||
for i, item in enumerate(items):
|
||||
try:
|
||||
if "id" not in item:
|
||||
item["id"] = str(uuid.uuid4())
|
||||
need_save = True
|
||||
|
||||
item: DLField = init_class(DLField, item)
|
||||
|
||||
self._items.append(item)
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to parse '{self._file}:{i}'. '{e!s}'.")
|
||||
continue
|
||||
|
||||
if need_save:
|
||||
LOG.info(f"Saving '{self._file}'.")
|
||||
self.save(self._items)
|
||||
|
||||
return self
|
||||
|
||||
def clear(self) -> "DLFields":
|
||||
"""
|
||||
Clear all items.
|
||||
|
||||
Returns:
|
||||
DLFields: The current instance.
|
||||
|
||||
"""
|
||||
if len(self._items) < 1:
|
||||
return self
|
||||
|
||||
self._items.clear()
|
||||
|
||||
return self
|
||||
|
||||
def validate(self, item: DLField | dict) -> bool:
|
||||
"""
|
||||
Validate the item.
|
||||
|
||||
Args:
|
||||
item (DLField|dict): The item to validate.
|
||||
|
||||
Returns:
|
||||
bool: True if valid
|
||||
|
||||
Raises:
|
||||
ValueError: If the item is not valid.
|
||||
|
||||
"""
|
||||
if not isinstance(item, dict):
|
||||
if not isinstance(item, DLField):
|
||||
msg = f"Unexpected '{type(item).__name__}' type was given."
|
||||
raise ValueError(msg)
|
||||
|
||||
item = item.serialize()
|
||||
|
||||
for key in ["id", "name", "description", "field", "kind"]:
|
||||
if key not in item:
|
||||
msg = f"Missing required key '{key}'."
|
||||
raise ValueError(msg)
|
||||
|
||||
if item.get("kind") not in FieldType.all():
|
||||
msg = f"Invalid field type '{item.get('kind')}'."
|
||||
raise ValueError(msg)
|
||||
|
||||
if item.get("extras") and not isinstance(item.get("extras"), dict):
|
||||
msg = "Extras must be a dictionary."
|
||||
raise ValueError(msg)
|
||||
|
||||
if item.get("value") and not isinstance(item.get("value"), str):
|
||||
msg = "Value must be a string."
|
||||
raise ValueError(msg)
|
||||
|
||||
if item.get("order") is not None and not isinstance(item.get("order"), int):
|
||||
msg = "Order must be an integer."
|
||||
raise ValueError(msg)
|
||||
|
||||
if not isinstance(item.get("extras", {}), dict):
|
||||
msg = "Extras must be a dictionary."
|
||||
raise ValueError(msg)
|
||||
|
||||
if re.match(r"^--[a-zA-Z0-9\-]+$", item.get("field", "").strip()) is None:
|
||||
msg = "Invalid yt-dlp option field it must starts with '--' and contain only alphanumeric characters."
|
||||
raise ValueError(msg)
|
||||
|
||||
return True
|
||||
|
||||
def save(self, items: list[DLField | dict]) -> "DLFields":
|
||||
"""
|
||||
Save the items.
|
||||
|
||||
Args:
|
||||
items (list[DLField]): The items to save.
|
||||
|
||||
Returns:
|
||||
Presets: The current instance.
|
||||
|
||||
"""
|
||||
for i, item in enumerate(items):
|
||||
try:
|
||||
if not isinstance(item, DLField):
|
||||
item: DLField = init_class(DLField, item)
|
||||
items[i] = item
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to save item '{i}' due to parsing error. '{e!s}'.")
|
||||
continue
|
||||
|
||||
try:
|
||||
self.validate(item)
|
||||
except ValueError as e:
|
||||
LOG.error(f"Failed to validate item '{i}: {item.name}'. '{e}'.")
|
||||
continue
|
||||
|
||||
try:
|
||||
self._file.write_text(json.dumps(obj=[item.serialize() for item in items], indent=4))
|
||||
LOG.info(f"Saved '{self._file}'.")
|
||||
except Exception as e:
|
||||
LOG.error(f"Failed to save '{self._file}'. '{e!s}'.")
|
||||
|
||||
return self
|
||||
|
||||
def has(self, id_or_name: str) -> bool:
|
||||
"""
|
||||
Check if the item exists by id or name.
|
||||
|
||||
Args:
|
||||
id_or_name (str): The id or name of the item.
|
||||
|
||||
Returns:
|
||||
bool: True if exists, False otherwise.
|
||||
|
||||
"""
|
||||
return self.get(id_or_name) is not None
|
||||
|
||||
def get(self, id_or_name: str) -> DLField | None:
|
||||
"""
|
||||
Get the item by id or name.
|
||||
|
||||
Args:
|
||||
id_or_name (str): The id or name of the item.
|
||||
|
||||
Returns:
|
||||
Preset|None: The item if found, None otherwise.
|
||||
|
||||
"""
|
||||
if not id_or_name:
|
||||
return None
|
||||
|
||||
for item in self.get_all():
|
||||
if id_or_name not in (item.id, item.name):
|
||||
continue
|
||||
|
||||
return item
|
||||
|
||||
return None
|
||||
|
|
@ -1,17 +1,4 @@
|
|||
"""
|
||||
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 functools
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
|
@ -20,16 +7,16 @@ from typing import TYPE_CHECKING
|
|||
|
||||
import yt_dlp.utils
|
||||
|
||||
from app.library.conditions import Conditions
|
||||
from app.features.conditions.service import Conditions
|
||||
from app.features.presets.service import Presets
|
||||
from app.library.Events import Events
|
||||
from app.library.ItemDTO import ItemDTO
|
||||
from app.library.Presets import Presets
|
||||
from app.library.Utils import (
|
||||
archive_add,
|
||||
archive_read,
|
||||
arg_converter,
|
||||
create_cookies_file,
|
||||
extract_info,
|
||||
fetch_info,
|
||||
get_extras,
|
||||
merge_dict,
|
||||
ytdlp_reject,
|
||||
|
|
@ -40,8 +27,8 @@ from .playlist_processor import process_playlist
|
|||
from .video_processor import add_video
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.features.presets.schemas import Preset
|
||||
from app.library.ItemDTO import Item
|
||||
from app.library.Presets import Preset
|
||||
|
||||
from .queue_manager import DownloadQueue
|
||||
|
||||
|
|
@ -205,22 +192,14 @@ async def add(
|
|||
LOG.info(f"[P] Extracting '{item.url}'{' with cookies' if yt_conf.get('cookiefile') else ''}.")
|
||||
|
||||
if not entry:
|
||||
async with queue.extractors:
|
||||
LOG.info(f"Extracting '{item.url}'{' with cookies' if yt_conf.get('cookiefile') else ''}.")
|
||||
entry: dict | None = await asyncio.wait_for(
|
||||
fut=asyncio.get_running_loop().run_in_executor(
|
||||
None,
|
||||
functools.partial(
|
||||
extract_info,
|
||||
config=yt_conf,
|
||||
url=item.url,
|
||||
debug=bool(queue.config.ytdlp_debug),
|
||||
no_archive=False,
|
||||
follow_redirect=True,
|
||||
),
|
||||
),
|
||||
timeout=queue.config.extract_info_timeout,
|
||||
)
|
||||
LOG.info(f"Extracting '{item.url}'{' with cookies' if yt_conf.get('cookiefile') else ''}.")
|
||||
entry: dict | None = await fetch_info(
|
||||
config=yt_conf,
|
||||
url=item.url,
|
||||
debug=bool(queue.config.ytdlp_debug),
|
||||
no_archive=False,
|
||||
follow_redirect=True,
|
||||
)
|
||||
|
||||
if not entry:
|
||||
LOG.error(f"Unable to extract info for '{item.url}'. Logs: {logs}")
|
||||
|
|
@ -288,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}'."
|
||||
|
|
|
|||
|
|
@ -43,8 +43,6 @@ class DownloadQueue(metaclass=Singleton):
|
|||
"DataStore for the download queue."
|
||||
self.processors = asyncio.Semaphore(self.config.playlist_items_concurrency)
|
||||
"Semaphore to limit the number of concurrent processors."
|
||||
self.extractors = asyncio.Semaphore(self.config.extract_info_concurrency)
|
||||
"Semaphore to limit the number of concurrent extract_info calls."
|
||||
self.pool = PoolManager(queue=self, config=self.config)
|
||||
"Pool manager for coordinating download execution."
|
||||
|
||||
|
|
|
|||
|
|
@ -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__
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue