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",
|
"noprogress",
|
||||||
"onefile",
|
"onefile",
|
||||||
"oneline",
|
"oneline",
|
||||||
|
"onupdate",
|
||||||
"parsel",
|
"parsel",
|
||||||
"pathex",
|
"pathex",
|
||||||
"pickleable",
|
"pickleable",
|
||||||
|
|
@ -164,11 +165,13 @@
|
||||||
"RPAREN",
|
"RPAREN",
|
||||||
"rtime",
|
"rtime",
|
||||||
"rvfc",
|
"rvfc",
|
||||||
|
"sessionmaker",
|
||||||
"SIGUSR",
|
"SIGUSR",
|
||||||
"smhd",
|
"smhd",
|
||||||
"socketio",
|
"socketio",
|
||||||
"softprops",
|
"softprops",
|
||||||
"solverr",
|
"solverr",
|
||||||
|
"SQLA",
|
||||||
"sstr",
|
"sstr",
|
||||||
"startswith",
|
"startswith",
|
||||||
"SUPPRESSHELP",
|
"SUPPRESSHELP",
|
||||||
|
|
@ -199,6 +202,7 @@
|
||||||
"vconvert",
|
"vconvert",
|
||||||
"Vitest",
|
"Vitest",
|
||||||
"writedescription",
|
"writedescription",
|
||||||
|
"WSEP",
|
||||||
"xerror",
|
"xerror",
|
||||||
"youtu",
|
"youtu",
|
||||||
"youtubepot",
|
"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?
|
# 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.
|
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).
|
1. Create definition via the WebUI > tasks > Definitions.
|
||||||
2. Keep your scheduled task in `tasks.json` pointing at the page you want to monitor and make sure it uses a preset that
|
2. Create task that reference same url click on inspect to see the results. Make sure it uses a preset that enables
|
||||||
enables a download archive (`--download-archive`).
|
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
|
3. When the task handler run, the handler scans the definitions, picks the first definition whose `match` rule covers
|
||||||
URL, fetches the page, extracts items, and queues the unseen ones.
|
the task URL, fetches the page, extracts items, and queues the unseen ones.
|
||||||
|
|
||||||
### Definition schema
|
### 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
|
```json5
|
||||||
{
|
{
|
||||||
"name": "example", // Friendly identifier shown in logs
|
"name": "example", // Friendly identifier shown in logs
|
||||||
"match": [
|
"match_url": [
|
||||||
"https://example.com/articles/*", // Glob strings, or objects with {"regex": "..."} or {"glob": "..."}
|
"https://example.com/articles/*", // Glob strings
|
||||||
{ "regex": "https://example.com/post/[0-9]+" }
|
"https://example.com/post/[0-9]+" // Regex strings
|
||||||
],
|
],
|
||||||
"engine": { // Optional, defaults to HTTPX
|
"engine": { // Optional, defaults to HTTPX
|
||||||
"type": "httpx", // "httpx" (default) or "selenium"
|
"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" },
|
"headers": { "User-Agent": "MyAgent/1.0" },
|
||||||
"params": { "page": 1 },
|
"params": { "page": 1 },
|
||||||
"data": null,
|
"data": null,
|
||||||
"json": null,
|
"json_data": null,
|
||||||
"timeout": 30
|
"timeout": 30
|
||||||
},
|
},
|
||||||
"response": { // Optional: how to interpret the body
|
"response": { // Optional: how to interpret the body
|
||||||
|
|
@ -247,6 +247,7 @@ For JSON endpoints, switch the response format and use `jsonpath` selectors:
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
|
...
|
||||||
"response": { "type": "json" },
|
"response": { "type": "json" },
|
||||||
"parse": {
|
"parse": {
|
||||||
"items": {
|
"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`,
|
the moment. Optional keys: `arguments` (list or string), `wait_for` (type `css`/`xpath` + `expression`), `wait_timeout`,
|
||||||
and `page_load_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]
|
> [!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.
|
> 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
|
import httpx
|
||||||
from yt_dlp.utils.networking import random_user_agent
|
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.config import Config
|
||||||
from app.library.httpx_client import async_client
|
from app.library.httpx_client import async_client
|
||||||
from app.library.Tasks import Task, TaskFailure, TaskResult
|
|
||||||
|
|
||||||
|
|
||||||
class BaseHandler:
|
class BaseHandler:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def can_handle(task: Task) -> bool:
|
async def can_handle(task: HandleTask) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@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
|
raise NotImplementedError
|
||||||
|
|
||||||
@classmethod
|
@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)
|
return await cls.extract(task=task, config=config)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -4,9 +4,9 @@ import re
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
from xml.etree.ElementTree import Element
|
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.cache import Cache
|
||||||
from app.library.Tasks import Task, TaskFailure, TaskItem, TaskResult
|
from app.library.Utils import fetch_info, get_archive_id
|
||||||
from app.library.Utils import extract_info, get_archive_id
|
|
||||||
|
|
||||||
from ._base_handler import BaseHandler
|
from ._base_handler import BaseHandler
|
||||||
|
|
||||||
|
|
@ -24,13 +24,13 @@ class RssGenericHandler(BaseHandler):
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@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}")
|
LOG.debug(f"'{task.name}': Checking if task URL is parsable RSS feed: {task.url}")
|
||||||
return RssGenericHandler.parse(task.url) is not None
|
return RssGenericHandler.parse(task.url) is not None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _get(
|
async def _get(
|
||||||
task: Task,
|
task: HandleTask,
|
||||||
params: dict,
|
params: dict,
|
||||||
parsed: dict[str, str],
|
parsed: dict[str, str],
|
||||||
) -> tuple[str, list[dict[str, str]], int]:
|
) -> tuple[str, list[dict[str, str]], int]:
|
||||||
|
|
@ -131,7 +131,7 @@ class RssGenericHandler(BaseHandler):
|
||||||
return feed_url, items, real_count
|
return feed_url, items, real_count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def extract(task: Task) -> TaskResult | TaskFailure:
|
async def extract(task: HandleTask) -> TaskResult | TaskFailure:
|
||||||
"""
|
"""
|
||||||
Extract items from an RSS/Atom feed.
|
Extract items from an RSS/Atom feed.
|
||||||
|
|
||||||
|
|
@ -179,7 +179,7 @@ class RssGenericHandler(BaseHandler):
|
||||||
"Doing real request to fetch yt-dlp archive ID."
|
"Doing real request to fetch yt-dlp archive ID."
|
||||||
)
|
)
|
||||||
|
|
||||||
info = extract_info(
|
info = await fetch_info(
|
||||||
config=params,
|
config=params,
|
||||||
url=url,
|
url=url,
|
||||||
no_archive=True,
|
no_archive=True,
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import logging
|
import logging
|
||||||
import re
|
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 app.library.Utils import get_archive_id
|
||||||
|
|
||||||
from ._base_handler import BaseHandler
|
from ._base_handler import BaseHandler
|
||||||
|
|
@ -10,9 +10,9 @@ LOG: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class TverHandler(BaseHandler):
|
class TverHandler(BaseHandler):
|
||||||
SERIES_API = "https://platform-api.tver.jp/service/api/v1/callSeriesEpisodes/{id}"
|
SERIES_API: str = "https://platform-api.tver.jp/service/api/v1/callSeriesEpisodes/{id}"
|
||||||
SESSION_API = "https://platform-api.tver.jp/v2/api/platform_users/browser/create"
|
SESSION_API: str = "https://platform-api.tver.jp/v2/api/platform_users/browser/create"
|
||||||
HEADERS = {
|
HEADERS: dict[str, str] = {
|
||||||
"x-tver-platform-type": "web",
|
"x-tver-platform-type": "web",
|
||||||
"Origin": "https://tver.jp",
|
"Origin": "https://tver.jp",
|
||||||
"Referer": "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_]+)$")
|
RX: re.Pattern[str] = re.compile(r"^https?:\/\/(?:www\.|m\.)?tver\.jp\/series\/(?P<id>sr[a-z0-9_]+)$")
|
||||||
|
|
||||||
@staticmethod
|
@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}")
|
LOG.debug(f"Checking if task '{task.name}' is using parsable Tver series URL: {task.url}")
|
||||||
return TverHandler.parse(task.url) is not None
|
return TverHandler.parse(task.url) is not None
|
||||||
|
|
||||||
|
|
@ -58,7 +58,7 @@ class TverHandler(BaseHandler):
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _collect_feed(
|
async def _collect_feed(
|
||||||
task: Task,
|
task: HandleTask,
|
||||||
params: dict,
|
params: dict,
|
||||||
series_id: str,
|
series_id: str,
|
||||||
) -> tuple[str, list[dict[str, str]], bool]:
|
) -> tuple[str, list[dict[str, str]], bool]:
|
||||||
|
|
@ -142,7 +142,7 @@ class TverHandler(BaseHandler):
|
||||||
return feed_url, items, has_items
|
return feed_url, items, has_items
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def extract(task: Task) -> TaskResult | TaskFailure:
|
async def extract(task: HandleTask) -> TaskResult | TaskFailure:
|
||||||
series_id: str | None = TverHandler.parse(task.url)
|
series_id: str | None = TverHandler.parse(task.url)
|
||||||
if not series_id:
|
if not series_id:
|
||||||
return TaskFailure(message="Unrecognized Tver series URL.")
|
return TaskFailure(message="Unrecognized Tver series URL.")
|
||||||
|
|
@ -3,7 +3,7 @@ import re
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from xml.etree.ElementTree import Element
|
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 app.library.Utils import get_archive_id
|
||||||
|
|
||||||
from ._base_handler import BaseHandler
|
from ._base_handler import BaseHandler
|
||||||
|
|
@ -15,18 +15,18 @@ LOG: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class TwitchHandler(BaseHandler):
|
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})(?:\/.*)?$")
|
RX: re.Pattern[str] = re.compile(r"^https?:\/\/(?:www\.|m\.)?twitch\.tv\/(?P<id>[a-z0-9_]{3,25})(?:\/.*)?$")
|
||||||
|
|
||||||
@staticmethod
|
@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}")
|
LOG.debug(f"Checking if task '{task.name}' is using parsable Twitch URL: {task.url}")
|
||||||
return TwitchHandler.parse(task.url) is not None
|
return TwitchHandler.parse(task.url) is not None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _collect_feed(
|
async def _collect_feed(
|
||||||
task: Task,
|
task: HandleTask,
|
||||||
params: dict,
|
params: dict,
|
||||||
handle_name: str,
|
handle_name: str,
|
||||||
) -> tuple[str, list[dict[str, str]], bool]:
|
) -> tuple[str, list[dict[str, str]], bool]:
|
||||||
|
|
@ -74,7 +74,7 @@ class TwitchHandler(BaseHandler):
|
||||||
return feed_url, items, has_items
|
return feed_url, items, has_items
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def extract(task: Task) -> TaskResult | TaskFailure:
|
async def extract(task: HandleTask) -> TaskResult | TaskFailure:
|
||||||
handle_name: str | None = TwitchHandler.parse(task.url)
|
handle_name: str | None = TwitchHandler.parse(task.url)
|
||||||
if not handle_name:
|
if not handle_name:
|
||||||
return TaskFailure(message="Unrecognized Twitch channel URL.")
|
return TaskFailure(message="Unrecognized Twitch channel URL.")
|
||||||
|
|
@ -3,7 +3,7 @@ import re
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
from xml.etree.ElementTree import Element
|
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 app.library.Utils import get_archive_id
|
||||||
|
|
||||||
from ._base_handler import BaseHandler
|
from ._base_handler import BaseHandler
|
||||||
|
|
@ -15,7 +15,7 @@ LOG: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class YoutubeHandler(BaseHandler):
|
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(
|
CHANNEL_REGEX: re.Pattern[str] = re.compile(
|
||||||
r"^https?://(?:www\.)?youtube\.com/(?:channel/(?P<id>UC[0-9A-Za-z_-]{22})|)/?$"
|
r"^https?://(?:www\.)?youtube\.com/(?:channel/(?P<id>UC[0-9A-Za-z_-]{22})|)/?$"
|
||||||
|
|
@ -26,12 +26,12 @@ class YoutubeHandler(BaseHandler):
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@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}")
|
LOG.debug(f"'{task.name}': Checking if task URL is parsable YouTube URL: {task.url}")
|
||||||
return YoutubeHandler.parse(task.url) is not None
|
return YoutubeHandler.parse(task.url) is not None
|
||||||
|
|
||||||
@staticmethod
|
@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.
|
Fetch the feed and return raw entries.
|
||||||
|
|
||||||
|
|
@ -89,7 +89,7 @@ class YoutubeHandler(BaseHandler):
|
||||||
return feed_url, items, real_count
|
return feed_url, items, real_count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def extract(task: Task) -> TaskResult | TaskFailure:
|
async def extract(task: HandleTask) -> TaskResult | TaskFailure:
|
||||||
parsed: dict[str, str] | None = YoutubeHandler.parse(task.url)
|
parsed: dict[str, str] | None = YoutubeHandler.parse(task.url)
|
||||||
if not parsed:
|
if not parsed:
|
||||||
return TaskFailure(message="Unrecognized YouTube channel or playlist URL.")
|
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
|
import pytest
|
||||||
|
|
||||||
from app.library.task_handlers.rss import RssGenericHandler
|
from app.features.tasks.definitions.handlers.rss import RssGenericHandler
|
||||||
from app.library.Tasks import Task, TaskResult
|
from app.features.tasks.definitions.results import TaskResult
|
||||||
|
from app.features.tasks.definitions.results import HandleTask
|
||||||
|
|
||||||
|
|
||||||
class DummyResponse:
|
class DummyResponse:
|
||||||
|
|
@ -68,10 +69,10 @@ class TestRssHandlerExtraction:
|
||||||
return DummyResponse(atom_feed)
|
return DummyResponse(atom_feed)
|
||||||
|
|
||||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
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(
|
task = HandleTask(
|
||||||
id="test_rss",
|
id=1,
|
||||||
name="Test Atom Feed",
|
name="Test Atom Feed",
|
||||||
url="https://example.com/feed.atom",
|
url="https://example.com/feed.atom",
|
||||||
preset="default",
|
preset="default",
|
||||||
|
|
@ -112,10 +113,10 @@ class TestRssHandlerExtraction:
|
||||||
return DummyResponse(rss_feed)
|
return DummyResponse(rss_feed)
|
||||||
|
|
||||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
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(
|
task = HandleTask(
|
||||||
id="test_rss",
|
id=1,
|
||||||
name="Test RSS Feed",
|
name="Test RSS Feed",
|
||||||
url="https://example.com/feed.rss",
|
url="https://example.com/feed.rss",
|
||||||
preset="default",
|
preset="default",
|
||||||
|
|
@ -146,25 +147,25 @@ class TestRssHandlerExtraction:
|
||||||
return DummyResponse(atom_feed)
|
return DummyResponse(atom_feed)
|
||||||
|
|
||||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
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(
|
task = HandleTask(
|
||||||
id="test_rss",
|
id=1,
|
||||||
name="Test Feed",
|
name="Test rss Feed",
|
||||||
url="https://example.com/feed.atom",
|
url="https://example.com/feed.atom",
|
||||||
preset="default",
|
preset="default",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert RssGenericHandler.can_handle(task) is True
|
assert await RssGenericHandler.can_handle(task) is True
|
||||||
|
|
||||||
non_feed_task = Task(
|
non_feed_task = HandleTask(
|
||||||
id="test_youtube",
|
id=1,
|
||||||
name="YouTube Video",
|
name="YouTube Video",
|
||||||
url="https://www.youtube.com/watch?v=abc123",
|
url="https://www.youtube.com/watch?v=abc123",
|
||||||
preset="default",
|
preset="default",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert RssGenericHandler.can_handle(non_feed_task) is False
|
assert await RssGenericHandler.can_handle(non_feed_task) is False
|
||||||
|
|
||||||
|
|
||||||
class TestRssHandlerEdgeCases:
|
class TestRssHandlerEdgeCases:
|
||||||
|
|
@ -185,10 +186,10 @@ class TestRssHandlerEdgeCases:
|
||||||
return DummyResponse(empty_feed)
|
return DummyResponse(empty_feed)
|
||||||
|
|
||||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
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(
|
task = HandleTask(
|
||||||
id="test_empty",
|
id=1,
|
||||||
name="Empty Feed",
|
name="Empty Feed",
|
||||||
url="https://example.com/feed.rss",
|
url="https://example.com/feed.rss",
|
||||||
preset="default",
|
preset="default",
|
||||||
|
|
@ -203,17 +204,17 @@ class TestRssHandlerEdgeCases:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_invalid_feed_url(self, monkeypatch):
|
async def test_invalid_feed_url(self, monkeypatch):
|
||||||
"""Test handling of invalid feed URL."""
|
"""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
|
async def fake_request(**kwargs): # noqa: ARG001
|
||||||
msg = "Network error"
|
msg = "Network error"
|
||||||
raise Exception(msg)
|
raise Exception(msg)
|
||||||
|
|
||||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
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(
|
task = HandleTask(
|
||||||
id="test_invalid",
|
id=1,
|
||||||
name="Invalid Feed",
|
name="Invalid Feed",
|
||||||
url="https://example.com/feed.rss",
|
url="https://example.com/feed.rss",
|
||||||
preset="default",
|
preset="default",
|
||||||
|
|
@ -244,10 +245,10 @@ class TestRssHandlerEdgeCases:
|
||||||
return DummyResponse(feed)
|
return DummyResponse(feed)
|
||||||
|
|
||||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
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(
|
task = HandleTask(
|
||||||
id="test_missing",
|
id=1,
|
||||||
name="Feed with Missing URLs",
|
name="Feed with Missing URLs",
|
||||||
url="https://example.com/feed.rss",
|
url="https://example.com/feed.rss",
|
||||||
preset="default",
|
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
|
import pytest
|
||||||
|
|
||||||
from app.library.task_handlers.tver import TverHandler
|
from app.features.tasks.definitions.handlers.tver import TverHandler
|
||||||
from app.library.Tasks import Task, TaskResult
|
from app.features.tasks.definitions.results import TaskResult
|
||||||
|
from app.features.tasks.definitions.results import HandleTask
|
||||||
|
|
||||||
|
|
||||||
class DummyResponse:
|
class DummyResponse:
|
||||||
|
|
@ -118,9 +119,9 @@ async def test_tver_handler_extract(monkeypatch):
|
||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
monkeypatch.setattr(TverHandler, "request", staticmethod(fake_request))
|
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)
|
result = await TverHandler.extract(task)
|
||||||
|
|
||||||
|
|
@ -151,9 +152,10 @@ def test_tver_handler_parse(url: str, should_match: bool):
|
||||||
assert result is None
|
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."""
|
"""Test tver handler can_handle method."""
|
||||||
task_valid = Task(id="test1", name="Test", url="https://tver.jp/series/sr8sb9pnhc", preset="default")
|
task_valid = HandleTask(id=1, 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")
|
task_invalid = HandleTask(id=2, name="Test", url="https://youtube.com/watch?v=123", preset="default")
|
||||||
assert TverHandler.can_handle(task_valid) is True
|
assert await TverHandler.can_handle(task_valid) is True
|
||||||
assert TverHandler.can_handle(task_invalid) is False
|
assert await TverHandler.can_handle(task_invalid) is False
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.library.task_handlers.twitch import TwitchHandler
|
from app.features.tasks.definitions.handlers.twitch import TwitchHandler
|
||||||
from app.library.Tasks import Task, TaskResult
|
from app.features.tasks.definitions.results import TaskResult
|
||||||
|
from app.features.tasks.definitions.results import HandleTask
|
||||||
|
|
||||||
|
|
||||||
class DummyResponse:
|
class DummyResponse:
|
||||||
|
|
@ -39,10 +40,10 @@ async def test_twitch_handler_inspect(monkeypatch):
|
||||||
return DummyResponse(feed)
|
return DummyResponse(feed)
|
||||||
|
|
||||||
monkeypatch.setattr(TwitchHandler, "request", staticmethod(fake_request))
|
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(
|
task = HandleTask(
|
||||||
id="inspect",
|
id=1,
|
||||||
name="Inspect",
|
name="Inspect",
|
||||||
url="https://www.twitch.tv/testchannel",
|
url="https://www.twitch.tv/testchannel",
|
||||||
preset="default",
|
preset="default",
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.library.task_handlers.youtube import YoutubeHandler
|
from app.features.tasks.definitions.handlers.youtube import YoutubeHandler
|
||||||
from app.library.Tasks import Task, TaskResult
|
from app.features.tasks.definitions.results import TaskResult
|
||||||
|
from app.features.tasks.definitions.results import HandleTask
|
||||||
|
|
||||||
|
|
||||||
class DummyResponse:
|
class DummyResponse:
|
||||||
|
|
@ -41,10 +42,10 @@ async def test_youtube_handler_inspect(monkeypatch):
|
||||||
return DummyResponse(feed)
|
return DummyResponse(feed)
|
||||||
|
|
||||||
monkeypatch.setattr(YoutubeHandler, "request", staticmethod(fake_request))
|
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(
|
task = HandleTask(
|
||||||
id="inspect",
|
id=1,
|
||||||
name="Inspect",
|
name="Inspect",
|
||||||
url="https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv",
|
url="https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv",
|
||||||
preset="default",
|
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 asyncio
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
from collections.abc import Callable
|
||||||
from collections.abc import Awaitable
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from app.features.core.utils import gen_random
|
||||||
|
|
||||||
from .BackgroundWorker import BackgroundWorker
|
from .BackgroundWorker import BackgroundWorker
|
||||||
from .Singleton import Singleton
|
from .Singleton import Singleton
|
||||||
|
|
||||||
LOG: logging.Logger = logging.getLogger("library.events")
|
LOG: logging.Logger = logging.getLogger("events")
|
||||||
|
|
||||||
|
|
||||||
class Events:
|
class Events:
|
||||||
|
|
@ -25,6 +26,7 @@ class Events:
|
||||||
CONNECTED: str = "connected"
|
CONNECTED: str = "connected"
|
||||||
|
|
||||||
CONFIGURATION: str = "configuration"
|
CONFIGURATION: str = "configuration"
|
||||||
|
CONFIG_UPDATE: str = "config_update"
|
||||||
ACTIVE_QUEUE: str = "active_queue"
|
ACTIVE_QUEUE: str = "active_queue"
|
||||||
|
|
||||||
LOG_INFO: str = "log_info"
|
LOG_INFO: str = "log_info"
|
||||||
|
|
@ -49,29 +51,13 @@ class Events:
|
||||||
PAUSED: str = "paused"
|
PAUSED: str = "paused"
|
||||||
RESUMED: str = "resumed"
|
RESUMED: str = "resumed"
|
||||||
|
|
||||||
CLI_POST: str = "cli_post"
|
|
||||||
CLI_CLOSE: str = "cli_close"
|
|
||||||
CLI_OUTPUT: str = "cli_output"
|
|
||||||
|
|
||||||
TASKS_ADD: str = "task_add"
|
TASKS_ADD: str = "task_add"
|
||||||
TASK_DISPATCHED: str = "task_dispatched"
|
TASK_DISPATCHED: str = "task_dispatched"
|
||||||
TASK_FINISHED: str = "task_finished"
|
TASK_FINISHED: str = "task_finished"
|
||||||
TASK_ERROR: str = "task_error"
|
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"
|
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:
|
def get_all() -> list:
|
||||||
"""
|
"""
|
||||||
Get all the events.
|
Get all the events.
|
||||||
|
|
@ -94,6 +80,7 @@ class Events:
|
||||||
"""
|
"""
|
||||||
return [
|
return [
|
||||||
Events.CONFIGURATION,
|
Events.CONFIGURATION,
|
||||||
|
Events.CONFIG_UPDATE,
|
||||||
Events.CONNECTED,
|
Events.CONNECTED,
|
||||||
Events.ACTIVE_QUEUE,
|
Events.ACTIVE_QUEUE,
|
||||||
Events.LOG_INFO,
|
Events.LOG_INFO,
|
||||||
|
|
@ -108,10 +95,6 @@ class Events:
|
||||||
Events.ITEM_STATUS,
|
Events.ITEM_STATUS,
|
||||||
Events.PAUSED,
|
Events.PAUSED,
|
||||||
Events.RESUMED,
|
Events.RESUMED,
|
||||||
Events.CLI_CLOSE,
|
|
||||||
Events.CLI_OUTPUT,
|
|
||||||
Events.PRESETS_UPDATE,
|
|
||||||
Events.DLFIELDS_UPDATE,
|
|
||||||
]
|
]
|
||||||
|
|
||||||
def only_debug() -> list:
|
def only_debug() -> list:
|
||||||
|
|
@ -122,7 +105,7 @@ class Events:
|
||||||
list: The list of debug events.
|
list: The list of debug events.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
return [Events.ITEM_UPDATED, Events.CLI_OUTPUT]
|
return [Events.ITEM_UPDATED]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(kw_only=True)
|
@dataclass(kw_only=True)
|
||||||
|
|
@ -131,7 +114,7 @@ class Event:
|
||||||
Event is a data transfer object that represents an event that was emitted.
|
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."""
|
"""The id of the event."""
|
||||||
|
|
||||||
created_at: str = field(default_factory=lambda: str(datetime.datetime.now(tz=datetime.UTC).isoformat()))
|
created_at: str = field(default_factory=lambda: str(datetime.datetime.now(tz=datetime.UTC).isoformat()))
|
||||||
|
|
@ -198,10 +181,10 @@ class Event:
|
||||||
|
|
||||||
|
|
||||||
class EventListener:
|
class EventListener:
|
||||||
def __init__(self, name: str, callback: callable):
|
def __init__(self, name: str, callback: Callable[..., Any]):
|
||||||
self.name: str = name
|
self.name: str = name
|
||||||
"The name of the listener."
|
"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."
|
"The callback function to call when the event is emitted."
|
||||||
self.is_coroutine: bool = asyncio.iscoroutinefunction(callback)
|
self.is_coroutine: bool = asyncio.iscoroutinefunction(callback)
|
||||||
"Whether the callback is a coroutine function or not."
|
"Whether the callback is a coroutine function or not."
|
||||||
|
|
@ -219,13 +202,13 @@ class EventBus(metaclass=Singleton):
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._listeners: dict[str, list[str, EventListener]] = {}
|
self._listeners: dict[str, list[tuple[str, EventListener]]] = {}
|
||||||
"The listeners for the events."
|
"The listeners for the events."
|
||||||
|
|
||||||
self.debug: bool = False
|
self.debug: bool = False
|
||||||
"Whether to log debug messages or not."
|
"Whether to log debug messages or not."
|
||||||
|
|
||||||
self._offload: BackgroundWorker = None
|
self._offload: BackgroundWorker | None = None
|
||||||
"The background worker to offload tasks to."
|
"The background worker to offload tasks to."
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -239,7 +222,7 @@ class EventBus(metaclass=Singleton):
|
||||||
"""
|
"""
|
||||||
return EventBus()
|
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.
|
Subscribe to an event.
|
||||||
|
|
||||||
|
|
@ -267,7 +250,7 @@ class EventBus(metaclass=Singleton):
|
||||||
event = [event]
|
event = [event]
|
||||||
|
|
||||||
if not name:
|
if not name:
|
||||||
name = str(uuid.uuid4())
|
name = gen_random(12)
|
||||||
|
|
||||||
for e in event:
|
for e in event:
|
||||||
if e not in all_events:
|
if e not in all_events:
|
||||||
|
|
@ -275,9 +258,10 @@ class EventBus(metaclass=Singleton):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if e not in self._listeners:
|
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}'.")
|
LOG.debug(f"'{name}' subscribed to '{event}'.")
|
||||||
|
|
||||||
|
|
@ -300,9 +284,11 @@ class EventBus(metaclass=Singleton):
|
||||||
|
|
||||||
events = []
|
events = []
|
||||||
for e in event:
|
for e in event:
|
||||||
if e in self._listeners and name in self._listeners[e]:
|
if e in self._listeners:
|
||||||
events.append(e)
|
original_len: int = len(self._listeners[e])
|
||||||
del self._listeners[e][name]
|
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:
|
if len(events) > 0:
|
||||||
LOG.debug(f"'{name}' unsubscribed from '{events}'.")
|
LOG.debug(f"'{name}' unsubscribed from '{events}'.")
|
||||||
|
|
@ -337,22 +323,25 @@ class EventBus(metaclass=Singleton):
|
||||||
try:
|
try:
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
|
|
||||||
for handler in self._listeners[event].values():
|
async def execute_handlers():
|
||||||
try:
|
for _, handler in self._listeners[event]:
|
||||||
if handler.is_coroutine:
|
try:
|
||||||
coro = handler.call_back(ev, handler.name, **kwargs)
|
if handler.is_coroutine:
|
||||||
if asyncio.iscoroutine(coro):
|
coro = handler.call_back(ev, handler.name, **kwargs)
|
||||||
loop.create_task(coro)
|
if asyncio.iscoroutine(coro):
|
||||||
|
await coro
|
||||||
|
else:
|
||||||
|
LOG.warning(f"Expected coroutine from async handler '{handler.name}', got {type(coro)}")
|
||||||
else:
|
else:
|
||||||
LOG.warning(f"Expected coroutine from async handler '{handler.name}', got {type(coro)}")
|
await self._call(handler, ev, kwargs)
|
||||||
else:
|
except Exception as e:
|
||||||
loop.create_task(self._call(handler, ev, kwargs), name=f"sync-handler-{handler.name}-{ev.id}")
|
LOG.exception(e)
|
||||||
except Exception as e:
|
LOG.error(f"Failed to emit event '{ev.event}' to '{handler.name}'. Error message '{e!s}'.")
|
||||||
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:
|
except RuntimeError:
|
||||||
LOG.debug(f"No event loop detected - using BackgroundWorker for {len(self._listeners[event])} handlers")
|
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:
|
try:
|
||||||
if not self._offload:
|
if not self._offload:
|
||||||
self._offload = BackgroundWorker.get_instance()
|
self._offload = BackgroundWorker.get_instance()
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ class HttpAPI:
|
||||||
self.config: Config = Config.get_instance()
|
self.config: Config = Config.get_instance()
|
||||||
self._notify: EventBus = EventBus.get_instance()
|
self._notify: EventBus = EventBus.get_instance()
|
||||||
self.rootPath: Path = root_path
|
self.rootPath: Path = root_path
|
||||||
self.cache = Cache()
|
self.cache: Cache = Cache.get_instance()
|
||||||
self.app: web.Application | None = None
|
self.app: web.Application | None = None
|
||||||
|
|
||||||
services: Services = Services.get_instance()
|
services: Services = Services.get_instance()
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import functools
|
import functools
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import socketio
|
|
||||||
from aiohttp import web
|
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.Services import Services
|
||||||
from app.library.Utils import load_modules
|
from app.library.Utils import load_modules
|
||||||
|
|
||||||
|
|
@ -18,13 +19,55 @@ from .ItemDTO import Item
|
||||||
LOG: logging.Logger = logging.getLogger("socket_api")
|
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:
|
class HttpSocket:
|
||||||
"""
|
"""
|
||||||
This class is used to handle WebSocket events.
|
This class is used to handle WebSocket events.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
config: Config
|
config: Config
|
||||||
sio: socketio.AsyncServer
|
sio: WebSocketHub
|
||||||
di_context: dict[str, Any] = {}
|
di_context: dict[str, Any] = {}
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|
@ -32,26 +75,18 @@ class HttpSocket:
|
||||||
root_path: Path,
|
root_path: Path,
|
||||||
encoder: Encoder | None = None,
|
encoder: Encoder | None = None,
|
||||||
config: Config | None = None,
|
config: Config | None = None,
|
||||||
sio: socketio.AsyncServer | None = None,
|
sio: WebSocketHub | None = None,
|
||||||
):
|
):
|
||||||
self.config = config or Config.get_instance()
|
self.config = config or Config.get_instance()
|
||||||
self._notify = EventBus.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()
|
encoder = encoder or Encoder()
|
||||||
|
self.sio = sio or WebSocketHub(encoder=encoder)
|
||||||
self.rootPath: Path = root_path
|
self.rootPath: Path = root_path
|
||||||
|
|
||||||
async def event_handler(e: Event, _, **kwargs):
|
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: Services = Services.get_instance()
|
||||||
services.add_all(
|
services.add_all(
|
||||||
|
|
@ -85,38 +120,86 @@ class HttpSocket:
|
||||||
|
|
||||||
async def on_shutdown(self, _: web.Application):
|
async def on_shutdown(self, _: web.Application):
|
||||||
LOG.debug("Shutting down socket server.")
|
LOG.debug("Shutting down socket server.")
|
||||||
|
await self.sio.disconnect_all()
|
||||||
for sid in self.sio.manager.get_participants("/", None):
|
|
||||||
LOG.debug(f"Disconnecting client '{sid}'.")
|
|
||||||
await self.sio.disconnect(sid[0], namespace="/")
|
|
||||||
|
|
||||||
LOG.debug("Socket server shutdown complete.")
|
LOG.debug("Socket server shutdown complete.")
|
||||||
|
|
||||||
def attach(self, app: web.Application):
|
def attach(self, app: web.Application):
|
||||||
app.on_shutdown.append(self.on_shutdown)
|
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, _):
|
async def event_handler(data: Event, _):
|
||||||
if data and data.data:
|
if not (data and data.data):
|
||||||
await Services.get_instance().get("queue").add(item=Item.format(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")
|
self._notify.subscribe(Events.ADD_URL, event_handler, f"{__class__.__name__}.add")
|
||||||
|
|
||||||
load_modules(self.rootPath, self.rootPath / "routes" / "socket")
|
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:
|
if self.config.debug:
|
||||||
LOG.debug(
|
LOG.debug(
|
||||||
f"Add ({route.name}) {route.method.value if isinstance(route.method, RouteType) else route.method}: {route.path}."
|
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
|
async def handle_message(sid: str, message: str) -> None:
|
||||||
def _injector(func, event: str):
|
try:
|
||||||
async def wrapper(sid, data=None, **kwargs):
|
payload = json.loads(message)
|
||||||
if not data:
|
except json.JSONDecodeError:
|
||||||
data = {}
|
LOG.debug("Invalid websocket payload received.")
|
||||||
return await Services.get_instance().handle_async(func, sid=sid, data=data, event=event, **kwargs)
|
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
|
from app.library.YTDLPOpts import YTDLPOpts
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.library.Presets import Preset
|
from app.features.presets.schemas import Preset
|
||||||
|
|
||||||
LOG: logging.Logger = logging.getLogger("ItemDTO")
|
LOG: logging.Logger = logging.getLogger("ItemDTO")
|
||||||
|
|
||||||
|
|
@ -184,7 +184,7 @@ class Item:
|
||||||
|
|
||||||
preset: str | None = item.get("preset")
|
preset: str | None = item.get("preset")
|
||||||
if preset and isinstance(preset, str) and preset != Item._default_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):
|
if not Presets.get_instance().has(preset):
|
||||||
msg: str = f"Preset '{preset}' does not exist."
|
msg: str = f"Preset '{preset}' does not exist."
|
||||||
|
|
@ -224,15 +224,15 @@ class Item:
|
||||||
|
|
||||||
return Item(**data)
|
return Item(**data)
|
||||||
|
|
||||||
def get_preset(self) -> "Preset":
|
def get_preset(self) -> "Preset | None":
|
||||||
"""
|
"""
|
||||||
Get the preset for the item.
|
Get the preset for the item.
|
||||||
|
|
||||||
Returns:
|
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())
|
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.
|
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")
|
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] = {}
|
self._jobs: dict[str, Cron] = {}
|
||||||
"The scheduled jobs."
|
"The scheduled jobs."
|
||||||
|
|
||||||
self._loop = loop or asyncio.get_event_loop()
|
self._loop: asyncio.AbstractEventLoop | None = loop
|
||||||
"The event loop to use."
|
"The event loop to use."
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
@ -114,6 +114,13 @@ class Scheduler(metaclass=Singleton):
|
||||||
if id and id in self._jobs:
|
if id and id in self._jobs:
|
||||||
self.remove(id)
|
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 = Cron(spec=timer, func=func, args=args, kwargs=kwargs, uuid=id, start=True, loop=self._loop)
|
||||||
|
|
||||||
job_id = str(job.uuid)
|
job_id = str(job.uuid)
|
||||||
|
|
@ -126,7 +133,7 @@ class Scheduler(metaclass=Singleton):
|
||||||
|
|
||||||
def remove(self, id: str | list[str]) -> bool:
|
def remove(self, id: str | list[str]) -> bool:
|
||||||
"""
|
"""
|
||||||
Remove a job from the schedule.
|
Remove a job from the scheduler.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
id (str|list[str]): The id of the job to remove.
|
id (str|list[str]): The id of the job to remove.
|
||||||
|
|
@ -149,7 +156,7 @@ class Scheduler(metaclass=Singleton):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
del self._jobs[id]
|
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 True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import inspect
|
import inspect
|
||||||
import logging
|
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
|
from app.library.Singleton import Singleton
|
||||||
|
|
||||||
|
|
@ -8,59 +9,170 @@ T = TypeVar("T")
|
||||||
LOG: logging.Logger = logging.getLogger(__name__)
|
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):
|
class Services(metaclass=Singleton):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._services: dict[str, T] = {}
|
self._services: list[ServiceEntry] = []
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_instance() -> "Services":
|
def get_instance() -> "Services":
|
||||||
return Services()
|
return Services()
|
||||||
|
|
||||||
def add(self, name: str, service: T):
|
def add(self, name: str, service: Any, declared_type: type | None = None) -> "Services":
|
||||||
self._services[name] = service
|
"""
|
||||||
|
Add a service by name.
|
||||||
|
|
||||||
def add_all(self, services: dict[str, T]):
|
Args:
|
||||||
for name, service in services.items():
|
name: The name of the service.
|
||||||
self.add(name, service)
|
service: The service instance.
|
||||||
|
declared_type: The declared type of the service (optional).
|
||||||
|
|
||||||
def get(self, name: str) -> T | None:
|
Returns:
|
||||||
return self._services.get(name)
|
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):
|
def remove(self, name: str):
|
||||||
if name not in self._services:
|
self._services = [e for e in self._services if e.name != name]
|
||||||
return
|
|
||||||
|
|
||||||
self._services.pop(name, None)
|
|
||||||
|
|
||||||
def clear(self):
|
def clear(self):
|
||||||
self._services.clear()
|
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()
|
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:
|
async def handle_async(self, handler: callable, **kwargs) -> Any:
|
||||||
context = {**self.get_all(), **kwargs}
|
resolved: dict[str, Any] = self._build_call_args(handler, kwargs)
|
||||||
|
return await handler(**resolved)
|
||||||
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)
|
|
||||||
|
|
||||||
def handle_sync(self, handler: callable, **kwargs) -> Any:
|
def handle_sync(self, handler: callable, **kwargs) -> Any:
|
||||||
context = {**self.get_all(), **kwargs}
|
resolved: dict[str, Any] = self._build_call_args(handler, kwargs)
|
||||||
|
return handler(**resolved)
|
||||||
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)
|
|
||||||
|
|
|
||||||
|
|
@ -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 asyncio
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import TYPE_CHECKING
|
from typing import Any
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
|
|
||||||
|
|
@ -13,9 +13,6 @@ from .Scheduler import Scheduler
|
||||||
from .Singleton import Singleton
|
from .Singleton import Singleton
|
||||||
from .version import APP_VERSION
|
from .version import APP_VERSION
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.library.dl_fields import Any
|
|
||||||
|
|
||||||
LOG: logging.Logger = logging.getLogger("update_checker")
|
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_URL: str = "https://api.github.com/repos/arabcoders/ytptube/releases/latest"
|
||||||
"GitHub API endpoint for latest release"
|
"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: int = 300
|
||||||
"Cache duration in seconds (5 minutes)"
|
"Cache duration in seconds (5 minutes)"
|
||||||
|
|
||||||
CACHE_KEY: str = "update_checker:result"
|
CACHE_KEY: str = "update_checker:result"
|
||||||
"Cache key for storing check results"
|
"Cache key for storing check results"
|
||||||
|
|
||||||
|
YTDLP_CACHE_KEY: str = "update_checker:ytdlp"
|
||||||
|
"Cache key for storing yt-dlp check results"
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self, config: Config | None = None, scheduler: Scheduler | None = None, notify: EventBus | None = None
|
self, config: Config | None = None, scheduler: Scheduler | None = None, notify: EventBus | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
@ -55,28 +58,9 @@ class UpdateChecker(metaclass=Singleton):
|
||||||
def get_instance(
|
def get_instance(
|
||||||
config: Config | None = None, scheduler: Scheduler | None = None, notify: EventBus | None = None
|
config: Config | None = None, scheduler: Scheduler | None = None, notify: EventBus | None = None
|
||||||
) -> "UpdateChecker":
|
) -> "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)
|
return UpdateChecker(config=config, scheduler=scheduler, notify=notify)
|
||||||
|
|
||||||
def attach(self, _: web.Application) -> None:
|
def attach(self, _: web.Application) -> None:
|
||||||
"""
|
|
||||||
Attach the UpdateChecker to the application.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
_ (web.Application): The aiohttp web application instance.
|
|
||||||
|
|
||||||
"""
|
|
||||||
from .Services import Services
|
from .Services import Services
|
||||||
|
|
||||||
Services.get_instance().add("update_checker", self)
|
Services.get_instance().add("update_checker", self)
|
||||||
|
|
@ -100,7 +84,7 @@ class UpdateChecker(metaclass=Singleton):
|
||||||
|
|
||||||
self._schedule_check()
|
self._schedule_check()
|
||||||
|
|
||||||
async def on_shutdown(self, _: web.Application) -> None:
|
async def on_shutdown(self, _: web.Application | None) -> None:
|
||||||
"""
|
"""
|
||||||
Handle application shutdown event.
|
Handle application shutdown event.
|
||||||
|
|
||||||
|
|
@ -113,89 +97,129 @@ class UpdateChecker(metaclass=Singleton):
|
||||||
|
|
||||||
self._scheduler.remove(self._job_id)
|
self._scheduler.remove(self._job_id)
|
||||||
self._job_id = None
|
self._job_id = None
|
||||||
LOG.debug("Stopped update check scheduled task.")
|
|
||||||
|
|
||||||
def _schedule_check(self) -> None:
|
def _schedule_check(self) -> None:
|
||||||
"""Schedule the update check task to run daily at 3 AM."""
|
|
||||||
if not self._config.check_for_updates:
|
if not self._config.check_for_updates:
|
||||||
LOG.debug("Update checking is disabled, skipping scheduling.")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Run daily at 3 AM
|
|
||||||
timer: str = "0 3 * * *"
|
timer: str = "0 3 * * *"
|
||||||
|
|
||||||
self._job_id = self._scheduler.add(
|
self._job_id = self._scheduler.add(
|
||||||
timer=timer,
|
timer=timer,
|
||||||
func=lambda: asyncio.create_task(self.check_for_updates()),
|
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[tuple[str, str | None], tuple[str, str | None]]:
|
||||||
|
|
||||||
async def check_for_updates(self) -> tuple[str, str | None]:
|
|
||||||
"""
|
"""
|
||||||
Check for updates from GitHub releases.
|
Check for updates from GitHub releases for both the app and yt-dlp.
|
||||||
Updates config.new_version if a newer version is available.
|
Updates config.new_version and config.yt_new_version if newer versions are available.
|
||||||
Stops the scheduled task if an update is found.
|
Stops the scheduled task if an app update is found.
|
||||||
|
|
||||||
Returns:
|
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"
|
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:
|
if not self._config.check_for_updates:
|
||||||
LOG.debug("Update checking is disabled, skipping check.")
|
return (("disabled", None), ("disabled", None))
|
||||||
return ("disabled", None)
|
|
||||||
|
|
||||||
# Check cache
|
app_cached = await self._cache.aget(self.CACHE_KEY)
|
||||||
cached = await self._cache.aget(self.CACHE_KEY)
|
ytdlp_cached = await self._cache.aget(self.YTDLP_CACHE_KEY)
|
||||||
if cached:
|
|
||||||
ttl = await self._cache.attl(self.CACHE_KEY)
|
|
||||||
LOG.debug(f"Returning cached result (TTL: {ttl:.0f}s)")
|
|
||||||
return cached
|
|
||||||
|
|
||||||
|
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:
|
try:
|
||||||
LOG.info("Checking for application updates...")
|
LOG.info(f"Checking for {name} updates...")
|
||||||
|
|
||||||
current_version: str = APP_VERSION.lstrip("v")
|
|
||||||
|
|
||||||
async with async_client(timeout=10.0) as client:
|
async with async_client(timeout=10.0) as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
self.GITHUB_API_URL,
|
api_url,
|
||||||
headers={"Accept": "application/vnd.github+json"},
|
headers={"Accept": "application/vnd.github+json"},
|
||||||
)
|
)
|
||||||
|
|
||||||
if 200 != response.status_code:
|
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)
|
return ("error", None)
|
||||||
|
|
||||||
data: dict[str, Any] = response.json()
|
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:
|
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)
|
return ("error", None)
|
||||||
|
|
||||||
if self._compare_versions(current_version, latest_tag):
|
compare_current: str = current_version.lstrip("v") if strip_v_prefix else current_version
|
||||||
LOG.warning(f"Update available: {current_version} → {latest_tag}")
|
compare_latest: str = latest_tag.lstrip("v") if strip_v_prefix else latest_tag
|
||||||
new_version_tag = data.get("tag_name", "")
|
|
||||||
self._config.new_version = new_version_tag
|
if self._compare_versions(compare_current, compare_latest):
|
||||||
await self.on_shutdown(None)
|
LOG.warning(f"{name} update available: {current_version} -> {latest_tag}")
|
||||||
result = ("update_available", new_version_tag)
|
result: tuple[str, str] = ("update_available", latest_tag)
|
||||||
await self._cache.aset(self.CACHE_KEY, result, self.CACHE_DURATION)
|
await self._cache.aset(cache_key, result, self.CACHE_DURATION)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
LOG.info("No updates available.")
|
LOG.info(f"No {name} updates available.")
|
||||||
self._config.new_version = ""
|
result: tuple[str, None] = ("up_to_date", None)
|
||||||
result = ("up_to_date", None)
|
await self._cache.aset(cache_key, result, self.CACHE_DURATION)
|
||||||
await self._cache.aset(self.CACHE_KEY, result, self.CACHE_DURATION)
|
|
||||||
return result
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
LOG.exception(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)
|
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:
|
def _compare_versions(self, current: str, latest: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Compare version strings to determine if an update is available.
|
Compare version strings to determine if an update is available.
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import copy
|
import copy
|
||||||
|
import functools
|
||||||
import glob
|
import glob
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import json
|
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?")
|
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."
|
"Regex to match ISO 8601 datetime strings."
|
||||||
|
|
||||||
|
EXTRACTORS_SEMAPHORE: asyncio.Semaphore | None = None
|
||||||
|
"Global semaphore for limiting concurrent extractor operations."
|
||||||
|
|
||||||
|
|
||||||
class StreamingError(Exception):
|
class StreamingError(Exception):
|
||||||
"""Raised when an error occurs during streaming."""
|
"""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
|
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:
|
def _is_safe_key(key: any) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if a dictionary key is safe for merging.
|
Check if a dictionary key is safe for merging.
|
||||||
|
|
@ -1590,6 +1647,9 @@ def load_modules(root_path: Path, directory: Path):
|
||||||
import pkgutil
|
import pkgutil
|
||||||
|
|
||||||
package_name: str = str(directory.relative_to(root_path).as_posix()).replace("/", ".")
|
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]):
|
for _, name, _ in pkgutil.iter_modules([directory]):
|
||||||
full_name: str = f"{package_name}.{name}"
|
full_name: str = f"{package_name}.{name}"
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ import shlex
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from app.features.presets.schemas import Preset
|
||||||
|
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .Presets import Preset, Presets
|
|
||||||
from .Utils import REMOVE_KEYS, arg_converter, calc_download_path, create_cookies_file, merge_dict
|
from .Utils import REMOVE_KEYS, arg_converter, calc_download_path, create_cookies_file, merge_dict
|
||||||
|
|
||||||
LOG: logging.Logger = logging.getLogger("YTDLPOpts")
|
LOG: logging.Logger = logging.getLogger("YTDLPOpts")
|
||||||
|
|
@ -129,9 +130,16 @@ class YTDLPCli:
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
|
|
||||||
self.item = item
|
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()
|
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]:
|
def build(self) -> tuple[str, dict]:
|
||||||
"""
|
"""
|
||||||
Build the CLI command following make_command logic.
|
Build the CLI command following make_command logic.
|
||||||
|
|
@ -306,7 +314,7 @@ class YTDLPOpts:
|
||||||
YTDLPOpts: The instance of the class
|
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:
|
if not preset:
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,18 @@
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
from app.library.Services import Services
|
||||||
|
|
||||||
|
from .Scheduler import Scheduler
|
||||||
from .Singleton import ThreadSafe
|
from .Singleton import ThreadSafe
|
||||||
|
|
||||||
|
LOG = logging.getLogger("cache")
|
||||||
|
|
||||||
|
|
||||||
class Cache(metaclass=ThreadSafe):
|
class Cache(metaclass=ThreadSafe):
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
|
|
@ -14,6 +22,25 @@ class Cache(metaclass=ThreadSafe):
|
||||||
self._cache: dict[str, tuple[Any, float | None]] = {}
|
self._cache: dict[str, tuple[Any, float | None]] = {}
|
||||||
self._lock = threading.Lock()
|
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:
|
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.
|
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.
|
Asynchronously generate a SHA-256 hash for the given input string.
|
||||||
"""
|
"""
|
||||||
return self.hash(key=key)
|
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 = ""
|
new_version: str = ""
|
||||||
"The new version available."
|
"The new version available."
|
||||||
|
|
||||||
|
yt_new_version: str = ""
|
||||||
|
"The new yt-dlp version available."
|
||||||
|
|
||||||
_manual_vars: tuple = (
|
_manual_vars: tuple = (
|
||||||
"temp_path",
|
"temp_path",
|
||||||
"config_path",
|
"config_path",
|
||||||
|
|
@ -255,6 +258,7 @@ class Config(metaclass=Singleton):
|
||||||
"app_build_date",
|
"app_build_date",
|
||||||
"app_branch",
|
"app_branch",
|
||||||
"new_version",
|
"new_version",
|
||||||
|
"yt_new_version",
|
||||||
)
|
)
|
||||||
"The variables that are immutable."
|
"The variables that are immutable."
|
||||||
|
|
||||||
|
|
@ -269,6 +273,7 @@ class Config(metaclass=Singleton):
|
||||||
"download_info_expires",
|
"download_info_expires",
|
||||||
"auto_clear_history_days",
|
"auto_clear_history_days",
|
||||||
"default_pagination",
|
"default_pagination",
|
||||||
|
"extract_info_concurrency",
|
||||||
"flaresolverr_max_timeout",
|
"flaresolverr_max_timeout",
|
||||||
"flaresolverr_client_timeout",
|
"flaresolverr_client_timeout",
|
||||||
"flaresolverr_cache_ttl",
|
"flaresolverr_cache_ttl",
|
||||||
|
|
@ -327,6 +332,7 @@ class Config(metaclass=Singleton):
|
||||||
"default_pagination",
|
"default_pagination",
|
||||||
"check_for_updates",
|
"check_for_updates",
|
||||||
"new_version",
|
"new_version",
|
||||||
|
"yt_new_version",
|
||||||
)
|
)
|
||||||
"The variables that are relevant to the frontend."
|
"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 asyncio
|
||||||
import functools
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
|
@ -20,16 +7,16 @@ from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import yt_dlp.utils
|
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.Events import Events
|
||||||
from app.library.ItemDTO import ItemDTO
|
from app.library.ItemDTO import ItemDTO
|
||||||
from app.library.Presets import Presets
|
|
||||||
from app.library.Utils import (
|
from app.library.Utils import (
|
||||||
archive_add,
|
archive_add,
|
||||||
archive_read,
|
archive_read,
|
||||||
arg_converter,
|
arg_converter,
|
||||||
create_cookies_file,
|
create_cookies_file,
|
||||||
extract_info,
|
fetch_info,
|
||||||
get_extras,
|
get_extras,
|
||||||
merge_dict,
|
merge_dict,
|
||||||
ytdlp_reject,
|
ytdlp_reject,
|
||||||
|
|
@ -40,8 +27,8 @@ from .playlist_processor import process_playlist
|
||||||
from .video_processor import add_video
|
from .video_processor import add_video
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from app.features.presets.schemas import Preset
|
||||||
from app.library.ItemDTO import Item
|
from app.library.ItemDTO import Item
|
||||||
from app.library.Presets import Preset
|
|
||||||
|
|
||||||
from .queue_manager import DownloadQueue
|
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 ''}.")
|
LOG.info(f"[P] Extracting '{item.url}'{' with cookies' if yt_conf.get('cookiefile') else ''}.")
|
||||||
|
|
||||||
if not entry:
|
if not entry:
|
||||||
async with queue.extractors:
|
LOG.info(f"Extracting '{item.url}'{' with cookies' if yt_conf.get('cookiefile') else ''}.")
|
||||||
LOG.info(f"Extracting '{item.url}'{' with cookies' if yt_conf.get('cookiefile') else ''}.")
|
entry: dict | None = await fetch_info(
|
||||||
entry: dict | None = await asyncio.wait_for(
|
config=yt_conf,
|
||||||
fut=asyncio.get_running_loop().run_in_executor(
|
url=item.url,
|
||||||
None,
|
debug=bool(queue.config.ytdlp_debug),
|
||||||
functools.partial(
|
no_archive=False,
|
||||||
extract_info,
|
follow_redirect=True,
|
||||||
config=yt_conf,
|
)
|
||||||
url=item.url,
|
|
||||||
debug=bool(queue.config.ytdlp_debug),
|
|
||||||
no_archive=False,
|
|
||||||
follow_redirect=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
timeout=queue.config.extract_info_timeout,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not entry:
|
if not entry:
|
||||||
LOG.error(f"Unable to extract info for '{item.url}'. Logs: {logs}")
|
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}
|
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()
|
already.pop()
|
||||||
|
|
||||||
message = f"Condition '{condition.name}' matched for '{item!r}'."
|
message = f"Condition '{condition.name}' matched for '{item!r}'."
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,6 @@ class DownloadQueue(metaclass=Singleton):
|
||||||
"DataStore for the download queue."
|
"DataStore for the download queue."
|
||||||
self.processors = asyncio.Semaphore(self.config.playlist_items_concurrency)
|
self.processors = asyncio.Semaphore(self.config.playlist_items_concurrency)
|
||||||
"Semaphore to limit the number of concurrent processors."
|
"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)
|
self.pool = PoolManager(queue=self, config=self.config)
|
||||||
"Pool manager for coordinating download execution."
|
"Pool manager for coordinating download execution."
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,16 +16,10 @@ class Encoder(json.JSONEncoder):
|
||||||
def default(self, o):
|
def default(self, o):
|
||||||
from .ItemDTO import ItemDTO
|
from .ItemDTO import ItemDTO
|
||||||
|
|
||||||
if isinstance(o, Path):
|
|
||||||
return str(o)
|
|
||||||
|
|
||||||
if isinstance(o, DateRange):
|
if isinstance(o, DateRange):
|
||||||
return {"start": str(o.start).replace("-", ""), "end": str(o.end).replace("-", "")}
|
return {"start": str(o.start).replace("-", ""), "end": str(o.end).replace("-", "")}
|
||||||
|
|
||||||
if isinstance(o, date):
|
if isinstance(o, (Path, date, ImpersonateTarget, ValueError)):
|
||||||
return str(o)
|
|
||||||
|
|
||||||
if isinstance(o, ImpersonateTarget):
|
|
||||||
return str(o)
|
return str(o)
|
||||||
|
|
||||||
if isinstance(o, ItemDTO):
|
if isinstance(o, ItemDTO):
|
||||||
|
|
@ -35,6 +29,9 @@ class Encoder(json.JSONEncoder):
|
||||||
if hasattr(o, "serialize"):
|
if hasattr(o, "serialize"):
|
||||||
return o.serialize()
|
return o.serialize()
|
||||||
|
|
||||||
|
if hasattr(o, "model_dump"):
|
||||||
|
return o.model_dump()
|
||||||
|
|
||||||
if hasattr(o, "__dict__"):
|
if hasattr(o, "__dict__"):
|
||||||
return 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