Merge pull request #549 from arabcoders/dev

Refactor: migrate from file-based config to db model
This commit is contained in:
Abdulmohsen 2026-01-25 21:42:20 +03:00 committed by GitHub
commit 412ad9565d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
202 changed files with 20052 additions and 14223 deletions

View file

@ -137,6 +137,7 @@
"noprogress",
"onefile",
"oneline",
"onupdate",
"parsel",
"pathex",
"pickleable",
@ -164,11 +165,13 @@
"RPAREN",
"rtime",
"rvfc",
"sessionmaker",
"SIGUSR",
"smhd",
"socketio",
"softprops",
"solverr",
"SQLA",
"sstr",
"startswith",
"SUPPRESSHELP",
@ -199,6 +202,7 @@
"vconvert",
"Vitest",
"writedescription",
"WSEP",
"xerror",
"youtu",
"youtubepot",

1996
API.md

File diff suppressed because it is too large Load diff

26
FAQ.md
View file

@ -171,25 +171,25 @@ Then restart the container to apply the changes.
# How can I monitor sites without RSS feeds?
YTPTube includes a **generic task handler** that turns JSON definition files into site-specific scrapers. You can use it
YTPTube includes a **generic task handler** that turns JSON definitions into site-specific scrapers. You can use it
to watch pages that do not expose RSS or public APIs and automatically enqueue new links into the download queue.
1. Create definition files under `/config/tasks/*.json` (for Docker this is the mounted `config/tasks/` folder).
2. Keep your scheduled task in `tasks.json` pointing at the page you want to monitor and make sure it uses a preset that
enables a download archive (`--download-archive`).
3. When the task runs, the handler scans the JSON files, picks the first definition whose `match` rule covers the task
URL, fetches the page, extracts items, and queues the unseen ones.
1. Create definition via the WebUI > tasks > Definitions.
2. Create task that reference same url click on inspect to see the results. Make sure it uses a preset that enables
a download archive (`--download-archive`).
3. When the task handler run, the handler scans the definitions, picks the first definition whose `match` rule covers
the task URL, fetches the page, extracts items, and queues the unseen ones.
### Definition schema
Each file must contain a single JSON object with the following keys:
Each definition must contain a single JSON object with the following keys:
```json5
{
"name": "example", // Friendly identifier shown in logs
"match": [
"https://example.com/articles/*", // Glob strings, or objects with {"regex": "..."} or {"glob": "..."}
{ "regex": "https://example.com/post/[0-9]+" }
"match_url": [
"https://example.com/articles/*", // Glob strings
"https://example.com/post/[0-9]+" // Regex strings
],
"engine": { // Optional, defaults to HTTPX
"type": "httpx", // "httpx" (default) or "selenium"
@ -207,7 +207,7 @@ Each file must contain a single JSON object with the following keys:
"headers": { "User-Agent": "MyAgent/1.0" },
"params": { "page": 1 },
"data": null,
"json": null,
"json_data": null,
"timeout": 30
},
"response": { // Optional: how to interpret the body
@ -247,6 +247,7 @@ For JSON endpoints, switch the response format and use `jsonpath` selectors:
```json5
{
...
"response": { "type": "json" },
"parse": {
"items": {
@ -282,9 +283,6 @@ For JSON endpoints, switch the response format and use `jsonpath` selectors:
the moment. Optional keys: `arguments` (list or string), `wait_for` (type `css`/`xpath` + `expression`), `wait_timeout`,
and `page_load_timeout`.
Definitions are reloaded automatically when files change, so you can tweak them without restarting YTPTube. Check
`var/config/tasks/01-*.json` for sample files.
> [!NOTE]
> A machine-readable schema is available at `app/schema/task_definition.json` if you want to validate your JSON with editors or CI tools.

View file

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

View file

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

View file

@ -0,0 +1,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,
)

View file

@ -0,0 +1,28 @@
from __future__ import annotations
from datetime import datetime # noqa: TC003
from sqlalchemy import JSON, Boolean, Index, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.features.core.models import Base, UTCDateTime, utcnow
class ConditionModel(Base):
__tablename__: str = "conditions"
__table_args__: tuple[Index, ...] = (
Index("ix_conditions_name", "name"),
Index("ix_conditions_enabled", "enabled"),
Index("ix_conditions_priority", "priority"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(255), nullable=False, 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)

View 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

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

View 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

View 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

View 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
View file

@ -0,0 +1,12 @@
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import AsyncSession
from app.library.sqlite_store import SqliteStore
@asynccontextmanager
async def get_session() -> AsyncGenerator[AsyncSession]:
async with SqliteStore.get_instance().sessionmaker()() as session:
yield session

View file

@ -0,0 +1,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

View file

@ -0,0 +1,39 @@
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import DateTime as SQLADateTime
from sqlalchemy import TypeDecorator
from sqlalchemy.orm import DeclarativeBase
def utcnow() -> datetime:
"""
Return current UTC time as timezone-aware datetime.
This is the canonical way to get current UTC time for database fields.
"""
return datetime.now(tz=UTC)
class UTCDateTime(TypeDecorator):
impl = SQLADateTime
cache_ok = True
def process_bind_param(self, value: datetime | None, _dialect) -> datetime | None:
"""Convert datetime to UTC before storing."""
if value is not None:
if value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value.astimezone(UTC).replace(tzinfo=None)
return value
def process_result_value(self, value: datetime | None, _dialect) -> datetime | None:
"""Ensure datetime is timezone-aware (UTC) when loading."""
if value is not None and value.tzinfo is None:
return value.replace(tzinfo=UTC)
return value
class Base(DeclarativeBase):
pass

View file

@ -0,0 +1,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)

View 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])

View file

@ -0,0 +1 @@
"""DL Fields Feature"""

View file

@ -0,0 +1,5 @@
from app.features.dl_fields.repository import DLFieldsRepository
def get_dl_fields_repo() -> DLFieldsRepository:
return DLFieldsRepository.get_instance()

View 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

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

View 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

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

View 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

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

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

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

View file

@ -0,0 +1,5 @@
from app.features.notifications.repository import NotificationsRepository
def get_notifications_repo() -> NotificationsRepository:
return NotificationsRepository.get_instance()

View 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

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

View 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

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

View 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

View 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)}

View file

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

View file

@ -0,0 +1 @@
"""Presets feature module."""

View 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,
},
]

View 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()

View 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

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

View 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

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

View 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

View 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

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

View 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())

View file

@ -0,0 +1 @@
"""Tasks Feature."""

View file

@ -0,0 +1 @@
"""Tasks Definitions Feature."""

View file

@ -0,0 +1,5 @@
from app.features.tasks.definitions.repository import TaskDefinitionsRepository
def get_task_definitions_repo() -> TaskDefinitionsRepository:
return TaskDefinitionsRepository.get_instance()

View file

@ -0,0 +1 @@
"""Handlers package for task definitions."""

View file

@ -4,22 +4,22 @@ from typing import Any
import httpx
from yt_dlp.utils.networking import random_user_agent
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskResult
from app.library.config import Config
from app.library.httpx_client import async_client
from app.library.Tasks import Task, TaskFailure, TaskResult
class BaseHandler:
@staticmethod
def can_handle(task: Task) -> bool:
async def can_handle(task: HandleTask) -> bool:
return False
@staticmethod
async def extract(task: Task, config: Config | None = None) -> TaskResult | TaskFailure:
async def extract(task: HandleTask, config: Config | None = None) -> TaskResult | TaskFailure:
raise NotImplementedError
@classmethod
async def inspect(cls, task: Task, config: Config | None = None) -> TaskResult | TaskFailure:
async def inspect(cls, task: HandleTask, config: Config | None = None) -> TaskResult | TaskFailure:
return await cls.extract(task=task, config=config)
@staticmethod

View file

@ -4,9 +4,9 @@ import re
from typing import TYPE_CHECKING, Any
from xml.etree.ElementTree import Element
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
from app.library.cache import Cache
from app.library.Tasks import Task, TaskFailure, TaskItem, TaskResult
from app.library.Utils import extract_info, get_archive_id
from app.library.Utils import fetch_info, get_archive_id
from ._base_handler import BaseHandler
@ -24,13 +24,13 @@ class RssGenericHandler(BaseHandler):
)
@staticmethod
def can_handle(task: Task) -> bool:
async def can_handle(task: HandleTask) -> bool:
LOG.debug(f"'{task.name}': Checking if task URL is parsable RSS feed: {task.url}")
return RssGenericHandler.parse(task.url) is not None
@staticmethod
async def _get(
task: Task,
task: HandleTask,
params: dict,
parsed: dict[str, str],
) -> tuple[str, list[dict[str, str]], int]:
@ -131,7 +131,7 @@ class RssGenericHandler(BaseHandler):
return feed_url, items, real_count
@staticmethod
async def extract(task: Task) -> TaskResult | TaskFailure:
async def extract(task: HandleTask) -> TaskResult | TaskFailure:
"""
Extract items from an RSS/Atom feed.
@ -179,7 +179,7 @@ class RssGenericHandler(BaseHandler):
"Doing real request to fetch yt-dlp archive ID."
)
info = extract_info(
info = await fetch_info(
config=params,
url=url,
no_archive=True,

View file

@ -1,7 +1,7 @@
import logging
import re
from app.library.Tasks import Task, TaskFailure, TaskItem, TaskResult
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
from app.library.Utils import get_archive_id
from ._base_handler import BaseHandler
@ -10,9 +10,9 @@ LOG: logging.Logger = logging.getLogger(__name__)
class TverHandler(BaseHandler):
SERIES_API = "https://platform-api.tver.jp/service/api/v1/callSeriesEpisodes/{id}"
SESSION_API = "https://platform-api.tver.jp/v2/api/platform_users/browser/create"
HEADERS = {
SERIES_API: str = "https://platform-api.tver.jp/service/api/v1/callSeriesEpisodes/{id}"
SESSION_API: str = "https://platform-api.tver.jp/v2/api/platform_users/browser/create"
HEADERS: dict[str, str] = {
"x-tver-platform-type": "web",
"Origin": "https://tver.jp",
"Referer": "https://tver.jp/",
@ -21,7 +21,7 @@ class TverHandler(BaseHandler):
RX: re.Pattern[str] = re.compile(r"^https?:\/\/(?:www\.|m\.)?tver\.jp\/series\/(?P<id>sr[a-z0-9_]+)$")
@staticmethod
def can_handle(task: Task) -> bool:
async def can_handle(task: HandleTask) -> bool:
LOG.debug(f"Checking if task '{task.name}' is using parsable Tver series URL: {task.url}")
return TverHandler.parse(task.url) is not None
@ -58,7 +58,7 @@ class TverHandler(BaseHandler):
@staticmethod
async def _collect_feed(
task: Task,
task: HandleTask,
params: dict,
series_id: str,
) -> tuple[str, list[dict[str, str]], bool]:
@ -142,7 +142,7 @@ class TverHandler(BaseHandler):
return feed_url, items, has_items
@staticmethod
async def extract(task: Task) -> TaskResult | TaskFailure:
async def extract(task: HandleTask) -> TaskResult | TaskFailure:
series_id: str | None = TverHandler.parse(task.url)
if not series_id:
return TaskFailure(message="Unrecognized Tver series URL.")

View file

@ -3,7 +3,7 @@ import re
from typing import TYPE_CHECKING
from xml.etree.ElementTree import Element
from app.library.Tasks import Task, TaskFailure, TaskItem, TaskResult
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
from app.library.Utils import get_archive_id
from ._base_handler import BaseHandler
@ -15,18 +15,18 @@ LOG: logging.Logger = logging.getLogger(__name__)
class TwitchHandler(BaseHandler):
FEED = "https://twitchrss.appspot.com/vodonly/{handle}"
FEED: str = "https://twitchrss.appspot.com/vodonly/{handle}"
RX: re.Pattern[str] = re.compile(r"^https?:\/\/(?:www\.|m\.)?twitch\.tv\/(?P<id>[a-z0-9_]{3,25})(?:\/.*)?$")
@staticmethod
def can_handle(task: Task) -> bool:
async def can_handle(task: HandleTask) -> bool:
LOG.debug(f"Checking if task '{task.name}' is using parsable Twitch URL: {task.url}")
return TwitchHandler.parse(task.url) is not None
@staticmethod
async def _collect_feed(
task: Task,
task: HandleTask,
params: dict,
handle_name: str,
) -> tuple[str, list[dict[str, str]], bool]:
@ -74,7 +74,7 @@ class TwitchHandler(BaseHandler):
return feed_url, items, has_items
@staticmethod
async def extract(task: Task) -> TaskResult | TaskFailure:
async def extract(task: HandleTask) -> TaskResult | TaskFailure:
handle_name: str | None = TwitchHandler.parse(task.url)
if not handle_name:
return TaskFailure(message="Unrecognized Twitch channel URL.")

View file

@ -3,7 +3,7 @@ import re
from typing import TYPE_CHECKING, Any
from xml.etree.ElementTree import Element
from app.library.Tasks import Task, TaskFailure, TaskItem, TaskResult
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
from app.library.Utils import get_archive_id
from ._base_handler import BaseHandler
@ -15,7 +15,7 @@ LOG: logging.Logger = logging.getLogger(__name__)
class YoutubeHandler(BaseHandler):
FEED = "https://www.youtube.com/feeds/videos.xml?{type}={id}"
FEED: str = "https://www.youtube.com/feeds/videos.xml?{type}={id}"
CHANNEL_REGEX: re.Pattern[str] = re.compile(
r"^https?://(?:www\.)?youtube\.com/(?:channel/(?P<id>UC[0-9A-Za-z_-]{22})|)/?$"
@ -26,12 +26,12 @@ class YoutubeHandler(BaseHandler):
)
@staticmethod
def can_handle(task: Task) -> bool:
async def can_handle(task: HandleTask) -> bool:
LOG.debug(f"'{task.name}': Checking if task URL is parsable YouTube URL: {task.url}")
return YoutubeHandler.parse(task.url) is not None
@staticmethod
async def _get(task: Task, params: dict, parsed: dict[str, str]) -> tuple[str, list[dict[str, str]], int]:
async def _get(task: HandleTask, params: dict, parsed: dict[str, str]) -> tuple[str, list[dict[str, str]], int]:
"""
Fetch the feed and return raw entries.
@ -89,7 +89,7 @@ class YoutubeHandler(BaseHandler):
return feed_url, items, real_count
@staticmethod
async def extract(task: Task) -> TaskResult | TaskFailure:
async def extract(task: HandleTask) -> TaskResult | TaskFailure:
parsed: dict[str, str] | None = YoutubeHandler.parse(task.url)
if not parsed:
return TaskFailure(message="Unrecognized YouTube channel or playlist URL.")

View 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

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

View 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

View 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

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

View 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

View 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

View file

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

View file

@ -1,7 +1,8 @@
import pytest
from app.library.task_handlers.rss import RssGenericHandler
from app.library.Tasks import Task, TaskResult
from app.features.tasks.definitions.handlers.rss import RssGenericHandler
from app.features.tasks.definitions.results import TaskResult
from app.features.tasks.definitions.results import HandleTask
class DummyResponse:
@ -68,10 +69,10 @@ class TestRssHandlerExtraction:
return DummyResponse(atom_feed)
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
task = Task(
id="test_rss",
task = HandleTask(
id=1,
name="Test Atom Feed",
url="https://example.com/feed.atom",
preset="default",
@ -112,10 +113,10 @@ class TestRssHandlerExtraction:
return DummyResponse(rss_feed)
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
task = Task(
id="test_rss",
task = HandleTask(
id=1,
name="Test RSS Feed",
url="https://example.com/feed.rss",
preset="default",
@ -146,25 +147,25 @@ class TestRssHandlerExtraction:
return DummyResponse(atom_feed)
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
task = Task(
id="test_rss",
name="Test Feed",
task = HandleTask(
id=1,
name="Test rss Feed",
url="https://example.com/feed.atom",
preset="default",
)
assert RssGenericHandler.can_handle(task) is True
assert await RssGenericHandler.can_handle(task) is True
non_feed_task = Task(
id="test_youtube",
non_feed_task = HandleTask(
id=1,
name="YouTube Video",
url="https://www.youtube.com/watch?v=abc123",
preset="default",
)
assert RssGenericHandler.can_handle(non_feed_task) is False
assert await RssGenericHandler.can_handle(non_feed_task) is False
class TestRssHandlerEdgeCases:
@ -185,10 +186,10 @@ class TestRssHandlerEdgeCases:
return DummyResponse(empty_feed)
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
task = Task(
id="test_empty",
task = HandleTask(
id=1,
name="Empty Feed",
url="https://example.com/feed.rss",
preset="default",
@ -203,17 +204,17 @@ class TestRssHandlerEdgeCases:
@pytest.mark.asyncio
async def test_invalid_feed_url(self, monkeypatch):
"""Test handling of invalid feed URL."""
from app.library.Tasks import TaskFailure
from app.features.tasks.definitions.results import TaskFailure
async def fake_request(**kwargs): # noqa: ARG001
msg = "Network error"
raise Exception(msg)
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
task = Task(
id="test_invalid",
task = HandleTask(
id=1,
name="Invalid Feed",
url="https://example.com/feed.rss",
preset="default",
@ -244,10 +245,10 @@ class TestRssHandlerEdgeCases:
return DummyResponse(feed)
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
task = Task(
id="test_missing",
task = HandleTask(
id=1,
name="Feed with Missing URLs",
url="https://example.com/feed.rss",
preset="default",

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

View file

@ -1,7 +1,8 @@
import pytest
from app.library.task_handlers.tver import TverHandler
from app.library.Tasks import Task, TaskResult
from app.features.tasks.definitions.handlers.tver import TverHandler
from app.features.tasks.definitions.results import TaskResult
from app.features.tasks.definitions.results import HandleTask
class DummyResponse:
@ -118,9 +119,9 @@ async def test_tver_handler_extract(monkeypatch):
raise RuntimeError(msg)
monkeypatch.setattr(TverHandler, "request", staticmethod(fake_request))
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda _: DummyOpts({"download_archive": "/tmp/archive"}))
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda _: DummyOpts({"download_archive": "/tmp/archive"}))
task = Task(id="test_tver", name="Test Tver Series", url="https://tver.jp/series/sr8sb9pnhc", preset="default")
task = HandleTask(id=1, name="Test Tver Series", url="https://tver.jp/series/sr8sb9pnhc", preset="default")
result = await TverHandler.extract(task)
@ -151,9 +152,10 @@ def test_tver_handler_parse(url: str, should_match: bool):
assert result is None
def test_tver_handler_can_handle():
@pytest.mark.asyncio
async def test_tver_handler_can_handle():
"""Test tver handler can_handle method."""
task_valid = Task(id="test1", name="Test", url="https://tver.jp/series/sr8sb9pnhc", preset="default")
task_invalid = Task(id="test2", name="Test", url="https://youtube.com/watch?v=123", preset="default")
assert TverHandler.can_handle(task_valid) is True
assert TverHandler.can_handle(task_invalid) is False
task_valid = HandleTask(id=1, name="Test", url="https://tver.jp/series/sr8sb9pnhc", preset="default")
task_invalid = HandleTask(id=2, name="Test", url="https://youtube.com/watch?v=123", preset="default")
assert await TverHandler.can_handle(task_valid) is True
assert await TverHandler.can_handle(task_invalid) is False

View file

@ -1,7 +1,8 @@
import pytest
from app.library.task_handlers.twitch import TwitchHandler
from app.library.Tasks import Task, TaskResult
from app.features.tasks.definitions.handlers.twitch import TwitchHandler
from app.features.tasks.definitions.results import TaskResult
from app.features.tasks.definitions.results import HandleTask
class DummyResponse:
@ -39,10 +40,10 @@ async def test_twitch_handler_inspect(monkeypatch):
return DummyResponse(feed)
monkeypatch.setattr(TwitchHandler, "request", staticmethod(fake_request))
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
task = Task(
id="inspect",
task = HandleTask(
id=1,
name="Inspect",
url="https://www.twitch.tv/testchannel",
preset="default",

View file

@ -1,7 +1,8 @@
import pytest
from app.library.task_handlers.youtube import YoutubeHandler
from app.library.Tasks import Task, TaskResult
from app.features.tasks.definitions.handlers.youtube import YoutubeHandler
from app.features.tasks.definitions.results import TaskResult
from app.features.tasks.definitions.results import HandleTask
class DummyResponse:
@ -41,10 +42,10 @@ async def test_youtube_handler_inspect(monkeypatch):
return DummyResponse(feed)
monkeypatch.setattr(YoutubeHandler, "request", staticmethod(fake_request))
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
task = Task(
id="inspect",
task = HandleTask(
id=1,
name="Inspect",
url="https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv",
preset="default",

View 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

View 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()

View 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

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

View 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

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

View 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

View 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

View file

@ -0,0 +1 @@
"""Tests for tasks feature."""

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

View 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

View file

@ -1,15 +1,16 @@
import asyncio
import datetime
import logging
import uuid
from collections.abc import Awaitable
from collections.abc import Callable
from dataclasses import dataclass, field
from typing import Any
from app.features.core.utils import gen_random
from .BackgroundWorker import BackgroundWorker
from .Singleton import Singleton
LOG: logging.Logger = logging.getLogger("library.events")
LOG: logging.Logger = logging.getLogger("events")
class Events:
@ -25,6 +26,7 @@ class Events:
CONNECTED: str = "connected"
CONFIGURATION: str = "configuration"
CONFIG_UPDATE: str = "config_update"
ACTIVE_QUEUE: str = "active_queue"
LOG_INFO: str = "log_info"
@ -49,29 +51,13 @@ class Events:
PAUSED: str = "paused"
RESUMED: str = "resumed"
CLI_POST: str = "cli_post"
CLI_CLOSE: str = "cli_close"
CLI_OUTPUT: str = "cli_output"
TASKS_ADD: str = "task_add"
TASK_DISPATCHED: str = "task_dispatched"
TASK_FINISHED: str = "task_finished"
TASK_ERROR: str = "task_error"
PRESETS_ADD: str = "presets_add"
PRESETS_UPDATE: str = "presets_update"
DLFIELDS_ADD: str = "dlfields_add"
DLFIELDS_UPDATE: str = "dlfields_update"
SCHEDULE_ADD: str = "schedule_add"
CONDITIONS_ADD: str = "conditions_add"
CONDITIONS_UPDATE: str = "conditions_update"
SUBSCRIBED: str = "subscribed"
UNSUBSCRIBED: str = "unsubscribed"
def get_all() -> list:
"""
Get all the events.
@ -94,6 +80,7 @@ class Events:
"""
return [
Events.CONFIGURATION,
Events.CONFIG_UPDATE,
Events.CONNECTED,
Events.ACTIVE_QUEUE,
Events.LOG_INFO,
@ -108,10 +95,6 @@ class Events:
Events.ITEM_STATUS,
Events.PAUSED,
Events.RESUMED,
Events.CLI_CLOSE,
Events.CLI_OUTPUT,
Events.PRESETS_UPDATE,
Events.DLFIELDS_UPDATE,
]
def only_debug() -> list:
@ -122,7 +105,7 @@ class Events:
list: The list of debug events.
"""
return [Events.ITEM_UPDATED, Events.CLI_OUTPUT]
return [Events.ITEM_UPDATED]
@dataclass(kw_only=True)
@ -131,7 +114,7 @@ class Event:
Event is a data transfer object that represents an event that was emitted.
"""
id: str = field(default_factory=lambda: str(uuid.uuid4()), init=False)
id: str = field(default_factory=lambda: str(gen_random(16)), init=False)
"""The id of the event."""
created_at: str = field(default_factory=lambda: str(datetime.datetime.now(tz=datetime.UTC).isoformat()))
@ -198,10 +181,10 @@ class Event:
class EventListener:
def __init__(self, name: str, callback: callable):
def __init__(self, name: str, callback: Callable[..., Any]):
self.name: str = name
"The name of the listener."
self.call_back: callable = callback
self.call_back: Callable[..., Any] = callback
"The callback function to call when the event is emitted."
self.is_coroutine: bool = asyncio.iscoroutinefunction(callback)
"Whether the callback is a coroutine function or not."
@ -219,13 +202,13 @@ class EventBus(metaclass=Singleton):
"""
def __init__(self):
self._listeners: dict[str, list[str, EventListener]] = {}
self._listeners: dict[str, list[tuple[str, EventListener]]] = {}
"The listeners for the events."
self.debug: bool = False
"Whether to log debug messages or not."
self._offload: BackgroundWorker = None
self._offload: BackgroundWorker | None = None
"The background worker to offload tasks to."
@staticmethod
@ -239,7 +222,7 @@ class EventBus(metaclass=Singleton):
"""
return EventBus()
def subscribe(self, event: str | list | tuple, callback: Awaitable, name: str | None = None) -> "EventBus":
def subscribe(self, event: str | list | tuple, callback: Callable[..., Any], name: str | None = None) -> "EventBus":
"""
Subscribe to an event.
@ -267,7 +250,7 @@ class EventBus(metaclass=Singleton):
event = [event]
if not name:
name = str(uuid.uuid4())
name = gen_random(12)
for e in event:
if e not in all_events:
@ -275,9 +258,10 @@ class EventBus(metaclass=Singleton):
continue
if e not in self._listeners:
self._listeners[e] = {}
self._listeners[e] = []
self._listeners[e][name] = EventListener(name, callback)
self._listeners[e] = [(n, listener) for n, listener in self._listeners[e] if n != name]
self._listeners[e].append((name, EventListener(name, callback)))
LOG.debug(f"'{name}' subscribed to '{event}'.")
@ -300,9 +284,11 @@ class EventBus(metaclass=Singleton):
events = []
for e in event:
if e in self._listeners and name in self._listeners[e]:
events.append(e)
del self._listeners[e][name]
if e in self._listeners:
original_len: int = len(self._listeners[e])
self._listeners[e] = [(n, listener) for n, listener in self._listeners[e] if n != name]
if len(self._listeners[e]) < original_len:
events.append(e)
if len(events) > 0:
LOG.debug(f"'{name}' unsubscribed from '{events}'.")
@ -337,22 +323,25 @@ class EventBus(metaclass=Singleton):
try:
loop = asyncio.get_running_loop()
for handler in self._listeners[event].values():
try:
if handler.is_coroutine:
coro = handler.call_back(ev, handler.name, **kwargs)
if asyncio.iscoroutine(coro):
loop.create_task(coro)
async def execute_handlers():
for _, handler in self._listeners[event]:
try:
if handler.is_coroutine:
coro = handler.call_back(ev, handler.name, **kwargs)
if asyncio.iscoroutine(coro):
await coro
else:
LOG.warning(f"Expected coroutine from async handler '{handler.name}', got {type(coro)}")
else:
LOG.warning(f"Expected coroutine from async handler '{handler.name}', got {type(coro)}")
else:
loop.create_task(self._call(handler, ev, kwargs), name=f"sync-handler-{handler.name}-{ev.id}")
except Exception as e:
LOG.exception(e)
LOG.error(f"Failed to emit event '{ev.event}' to '{handler.name}'. Error message '{e!s}'.")
await self._call(handler, ev, kwargs)
except Exception as e:
LOG.exception(e)
LOG.error(f"Failed to emit event '{ev.event}' to '{handler.name}'. Error message '{e!s}'.")
loop.create_task(execute_handlers())
except RuntimeError:
LOG.debug(f"No event loop detected - using BackgroundWorker for {len(self._listeners[event])} handlers")
for handler in self._listeners[event].values():
for _, handler in self._listeners[event]:
try:
if not self._offload:
self._offload = BackgroundWorker.get_instance()

View file

@ -28,7 +28,7 @@ class HttpAPI:
self.config: Config = Config.get_instance()
self._notify: EventBus = EventBus.get_instance()
self.rootPath: Path = root_path
self.cache = Cache()
self.cache: Cache = Cache.get_instance()
self.app: web.Application | None = None
services: Services = Services.get_instance()

View file

@ -1,12 +1,13 @@
import functools
import json
import logging
from pathlib import Path
from typing import Any
import socketio
from aiohttp import web
from app.library.router import RouteType, get_routes
from app.features.core.utils import gen_random
from app.library.router import Route, RouteType, get_routes
from app.library.Services import Services
from app.library.Utils import load_modules
@ -18,13 +19,55 @@ from .ItemDTO import Item
LOG: logging.Logger = logging.getLogger("socket_api")
class WebSocketHub:
def __init__(self, encoder: Encoder):
self._encoder: Encoder = encoder
self._clients: dict[str, web.WebSocketResponse] = {}
def add(self, sid: str, ws: web.WebSocketResponse) -> None:
self._clients[sid] = ws
def remove(self, sid: str) -> None:
self._clients.pop(sid, None)
async def emit(self, event: str, data: Any, to: str | None = None) -> None:
payload: str = self._encoder.encode({"event": event, "data": data})
if to:
await self._send(to, payload)
return
for sid in list(self._clients.keys()):
await self._send(sid, payload)
async def disconnect(self, sid: str) -> None:
ws: web.WebSocketResponse | None = self._clients.pop(sid, None)
if ws and not ws.closed:
await ws.close()
async def disconnect_all(self) -> None:
for sid in list(self._clients.keys()):
await self.disconnect(sid)
async def _send(self, sid: str, payload: str) -> None:
ws: web.WebSocketResponse | None = self._clients.get(sid)
if not ws or ws.closed:
self._clients.pop(sid, None)
return
try:
await ws.send_str(payload)
except ConnectionResetError:
self._clients.pop(sid, None)
class HttpSocket:
"""
This class is used to handle WebSocket events.
"""
config: Config
sio: socketio.AsyncServer
sio: WebSocketHub
di_context: dict[str, Any] = {}
def __init__(
@ -32,26 +75,18 @@ class HttpSocket:
root_path: Path,
encoder: Encoder | None = None,
config: Config | None = None,
sio: socketio.AsyncServer | None = None,
sio: WebSocketHub | None = None,
):
self.config = config or Config.get_instance()
self._notify = EventBus.get_instance()
self.sio = sio or socketio.AsyncServer(
async_handlers=True,
async_mode="aiohttp",
cors_allowed_origins="*",
transports=["websocket", "polling"],
logger=self.config.debug,
engineio_logger=self.config.debug,
ping_interval=10,
ping_timeout=5,
)
encoder = encoder or Encoder()
self.sio = sio or WebSocketHub(encoder=encoder)
self.rootPath: Path = root_path
async def event_handler(e: Event, _, **kwargs):
await self.sio.emit(event=e.event, data=encoder.encode(e), **kwargs)
payload = json.loads(encoder.encode(e))
await self.sio.emit(event=e.event, data=payload, **kwargs)
services: Services = Services.get_instance()
services.add_all(
@ -85,38 +120,86 @@ class HttpSocket:
async def on_shutdown(self, _: web.Application):
LOG.debug("Shutting down socket server.")
for sid in self.sio.manager.get_participants("/", None):
LOG.debug(f"Disconnecting client '{sid}'.")
await self.sio.disconnect(sid[0], namespace="/")
await self.sio.disconnect_all()
LOG.debug("Socket server shutdown complete.")
def attach(self, app: web.Application):
app.on_shutdown.append(self.on_shutdown)
self.sio.attach(app, socketio_path=f"{self.config.base_path.rstrip('/')}/socket.io")
base_path: str = self.config.base_path.rstrip("/")
ws_path: str = f"{base_path}/ws" if base_path else "/ws"
async def event_handler(data: Event, _):
if data and data.data:
await Services.get_instance().get("queue").add(item=Item.format(data.data))
if not (data and data.data):
return
await Services.get_instance().get("queue").add(item=Item.format(data.data))
self._notify.subscribe(Events.ADD_URL, event_handler, f"{__class__.__name__}.add")
load_modules(self.rootPath, self.rootPath / "routes" / "socket")
for route in get_routes(RouteType.SOCKET).values():
socket_routes: dict[str, Route] = {route.path: route for route in get_routes(RouteType.SOCKET).values()}
for route in socket_routes.values():
if self.config.debug:
LOG.debug(
f"Add ({route.name}) {route.method.value if isinstance(route.method, RouteType) else route.method}: {route.path}."
)
self.sio.on(route.path)(HttpSocket._injector(route.handler, route.path))
@staticmethod
def _injector(func, event: str):
async def wrapper(sid, data=None, **kwargs):
if not data:
data = {}
return await Services.get_instance().handle_async(func, sid=sid, data=data, event=event, **kwargs)
async def handle_message(sid: str, message: str) -> None:
try:
payload = json.loads(message)
except json.JSONDecodeError:
LOG.debug("Invalid websocket payload received.")
return
return wrapper
event = payload.get("event") if isinstance(payload, dict) else None
if not event or not isinstance(event, str):
LOG.debug("Missing websocket event name.")
return
if not (route := socket_routes.get(event)):
LOG.debug(f"Unknown websocket event '{event}'.")
return
data = payload.get("data") if isinstance(payload, dict) else None
await Services.get_instance().handle_async(route.handler, sid=sid, data=data, event=event)
async def handle_connect(sid: str) -> None:
if not (route := socket_routes.get("connect")):
return
await Services.get_instance().handle_async(route.handler, sid=sid, event="connect")
async def handle_disconnect(sid: str, reason: str | None) -> None:
if not (route := socket_routes.get("disconnect")):
return
await Services.get_instance().handle_async(route.handler, sid=sid, data=reason, event="disconnect")
async def ws_handler(request: web.Request) -> web.WebSocketResponse:
ws = web.WebSocketResponse(heartbeat=10)
await ws.prepare(request)
sid: str = gen_random(14)
self.sio.add(sid, ws)
LOG.debug(f"WebSocket client '{sid}' connected.")
await handle_connect(sid)
try:
async for msg in ws:
if msg.type == web.WSMsgType.TEXT:
await handle_message(sid, msg.data)
elif msg.type == web.WSMsgType.ERROR:
LOG.error(f"WebSocket connection closed with exception {ws.exception()}")
finally:
await handle_disconnect(sid, getattr(ws, "close_reason", None))
self.sio.remove(sid)
LOG.debug(f"WebSocket client '{sid}' disconnected.")
return ws
if self.config.debug:
LOG.debug(f"Add (ws) GET: {ws_path}.")
app.router.add_get(ws_path, ws_handler, name="ws")

View file

@ -20,7 +20,7 @@ from app.library.Utils import (
from app.library.YTDLPOpts import YTDLPOpts
if TYPE_CHECKING:
from app.library.Presets import Preset
from app.features.presets.schemas import Preset
LOG: logging.Logger = logging.getLogger("ItemDTO")
@ -184,7 +184,7 @@ class Item:
preset: str | None = item.get("preset")
if preset and isinstance(preset, str) and preset != Item._default_preset():
from .Presets import Presets
from app.features.presets.service import Presets
if not Presets.get_instance().has(preset):
msg: str = f"Preset '{preset}' does not exist."
@ -224,15 +224,15 @@ class Item:
return Item(**data)
def get_preset(self) -> "Preset":
def get_preset(self) -> "Preset | None":
"""
Get the preset for the item.
Returns:
Preset: The preset for the item. If not found, None.
Preset | None: The preset for the item. If not found, None.
"""
from .Presets import Presets
from app.features.presets.service import Presets
return Presets.get_instance().get(self.preset if self.preset else self._default_preset())
@ -558,7 +558,7 @@ class ItemDTO:
Preset | None: The preset for the item. If not found, None.
"""
from .Presets import Presets
from app.features.presets.service import Presets
return Presets.get_instance().get(self.preset if self.preset else "default")

View file

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

View file

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

View file

@ -19,7 +19,7 @@ class Scheduler(metaclass=Singleton):
self._jobs: dict[str, Cron] = {}
"The scheduled jobs."
self._loop = loop or asyncio.get_event_loop()
self._loop: asyncio.AbstractEventLoop | None = loop
"The event loop to use."
@staticmethod
@ -114,6 +114,13 @@ class Scheduler(metaclass=Singleton):
if id and id in self._jobs:
self.remove(id)
if not self._loop:
try:
self._loop = asyncio.get_running_loop()
except RuntimeError:
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
job = Cron(spec=timer, func=func, args=args, kwargs=kwargs, uuid=id, start=True, loop=self._loop)
job_id = str(job.uuid)
@ -126,7 +133,7 @@ class Scheduler(metaclass=Singleton):
def remove(self, id: str | list[str]) -> bool:
"""
Remove a job from the schedule.
Remove a job from the scheduler.
Args:
id (str|list[str]): The id of the job to remove.
@ -149,7 +156,7 @@ class Scheduler(metaclass=Singleton):
return False
del self._jobs[id]
LOG.debug(f"Removed job '{id}' from the schedule.")
LOG.debug(f"Removed job '{id}' from the scheduler.")
return True
return False

View file

@ -1,6 +1,7 @@
import inspect
import logging
from typing import Any, TypeVar
from dataclasses import dataclass
from typing import Annotated, Any, TypeVar, get_args, get_origin, get_type_hints
from app.library.Singleton import Singleton
@ -8,59 +9,170 @@ T = TypeVar("T")
LOG: logging.Logger = logging.getLogger(__name__)
def _unwrap_annotation(ann: Any) -> Any:
if ann is inspect._empty:
return inspect._empty
origin = get_origin(ann)
# Annotated[T, ...] -> T
if origin is Annotated:
args = get_args(ann)
return args[0] if args else ann
# Optional[T] / Union[T, None] / T | None -> T
if origin is None:
return ann
if str(origin) in ("typing.Union", "types.UnionType"):
args = [a for a in get_args(ann) if a is not type(None)]
if len(args) == 1:
return args[0]
return ann
return ann
@dataclass(frozen=True)
class ServiceEntry:
name: str
declared_type: type | None
instance: Any
class Services(metaclass=Singleton):
def __init__(self):
self._services: dict[str, T] = {}
self._services: list[ServiceEntry] = []
@staticmethod
def get_instance() -> "Services":
return Services()
def add(self, name: str, service: T):
self._services[name] = service
def add(self, name: str, service: Any, declared_type: type | None = None) -> "Services":
"""
Add a service by name.
def add_all(self, services: dict[str, T]):
for name, service in services.items():
self.add(name, service)
Args:
name: The name of the service.
service: The service instance.
declared_type: The declared type of the service (optional).
def get(self, name: str) -> T | None:
return self._services.get(name)
Returns:
Services: The Services instance (for chaining).
def has(self, name: str) -> bool:
return name in self._services
"""
if declared_type is None and service is not None:
declared_type = type(service)
self.remove(name)
self._services.append(ServiceEntry(name=name, declared_type=declared_type, instance=service))
return self
def add_all(self, services: dict[str, Any]):
for name, svc in services.items():
self.add(name, svc)
def remove(self, name: str):
if name not in self._services:
return
self._services.pop(name, None)
self._services = [e for e in self._services if e.name != name]
def clear(self):
self._services.clear()
def get_all(self) -> dict[str, T]:
def get(self, name: str) -> Any | None:
for e in reversed(self._services):
if e.name == name:
return e.instance
return None
def has(self, name: str) -> bool:
return any(e.name == name for e in self._services)
def get_all(self) -> list[ServiceEntry]:
return self._services.copy()
def get_by_type(self, expected_type: Any) -> Any | None:
expected_type = _unwrap_annotation(expected_type)
if expected_type is inspect._empty or not isinstance(expected_type, type):
return None
exact: list[Any] = [e.instance for e in self._services if e.declared_type is expected_type]
if len(exact) == 1:
return exact[0]
if len(exact) > 1:
msg: str = (
f"Ambiguous dependency for type {expected_type.__name__}: {len(exact)} exact matches. Resolve by name."
)
raise LookupError(msg)
candidates: list[Any] = [e.instance for e in self._services if isinstance(e.instance, expected_type)]
if len(candidates) == 0:
return None
if len(candidates) > 1:
msg: str = (
f"Ambiguous dependency for type {expected_type.__name__}: "
f"{len(candidates)} candidates. Resolve by name."
)
raise LookupError(msg)
return candidates[0]
def _build_call_args(self, handler: callable, overrides: dict[str, Any]) -> dict[str, Any]:
sig: inspect.Signature = inspect.signature(handler)
try:
type_hints: dict[str, Any] = get_type_hints(handler)
except Exception:
type_hints = {}
resolved: dict[str, Any] = {}
for name, param in sig.parameters.items():
if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
continue
if name in overrides:
resolved[name] = overrides[name]
continue
by_name: Any | None = self.get(name)
if by_name is not None:
resolved[name] = by_name
continue
ann: Any | None = type_hints.get(name, param.annotation)
by_type: Any | None = self.get_by_type(ann)
if by_type is not None:
resolved[name] = by_type
continue
if param.default is not inspect._empty:
continue
missing_required: list[str] = []
for name, param in sig.parameters.items():
if param.kind in (param.VAR_POSITIONAL, param.VAR_KEYWORD):
continue
if param.default is not inspect._empty:
continue
if name not in resolved:
missing_required.append(name)
if missing_required:
LOG.error(
"Missing arguments for handler '%s': %s",
getattr(handler, "__name__", str(handler)),
missing_required,
)
return resolved
async def handle_async(self, handler: callable, **kwargs) -> Any:
context = {**self.get_all(), **kwargs}
sig = inspect.signature(handler)
expected_args = sig.parameters.keys()
filtered = {k: v for k, v in context.items() if k in expected_args}
if missing_args := expected_args - filtered.keys():
LOG.error(f"Missing arguments for handler '{handler.__name__}': {missing_args}")
return await handler(**filtered)
resolved: dict[str, Any] = self._build_call_args(handler, kwargs)
return await handler(**resolved)
def handle_sync(self, handler: callable, **kwargs) -> Any:
context = {**self.get_all(), **kwargs}
sig = inspect.signature(handler)
expected_args = sig.parameters.keys()
filtered = {k: v for k, v in context.items() if k in expected_args}
if missing_args := expected_args - filtered.keys():
LOG.error(f"Missing arguments for handler '{handler.__name__}': {missing_args}")
return handler(**filtered)
resolved: dict[str, Any] = self._build_call_args(handler, kwargs)
return handler(**resolved)

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
import asyncio
import logging
import re
from typing import TYPE_CHECKING
from typing import Any
from aiohttp import web
@ -13,9 +13,6 @@ from .Scheduler import Scheduler
from .Singleton import Singleton
from .version import APP_VERSION
if TYPE_CHECKING:
from app.library.dl_fields import Any
LOG: logging.Logger = logging.getLogger("update_checker")
@ -27,12 +24,18 @@ class UpdateChecker(metaclass=Singleton):
GITHUB_API_URL: str = "https://api.github.com/repos/arabcoders/ytptube/releases/latest"
"GitHub API endpoint for latest release"
YTDLP_API_URL: str = "https://api.github.com/repos/yt-dlp/yt-dlp/releases/latest"
"GitHub API endpoint for yt-dlp latest release"
CACHE_DURATION: int = 300
"Cache duration in seconds (5 minutes)"
CACHE_KEY: str = "update_checker:result"
"Cache key for storing check results"
YTDLP_CACHE_KEY: str = "update_checker:ytdlp"
"Cache key for storing yt-dlp check results"
def __init__(
self, config: Config | None = None, scheduler: Scheduler | None = None, notify: EventBus | None = None
) -> None:
@ -55,28 +58,9 @@ class UpdateChecker(metaclass=Singleton):
def get_instance(
config: Config | None = None, scheduler: Scheduler | None = None, notify: EventBus | None = None
) -> "UpdateChecker":
"""
Get the singleton instance of UpdateChecker.
Args:
config (Config | None): Optional Config instance to use.
scheduler (Scheduler | None): Optional Scheduler instance to use.
notify (EventBus | None): Optional EventBus instance to use.
Returns:
UpdateChecker: The singleton instance of UpdateChecker.
"""
return UpdateChecker(config=config, scheduler=scheduler, notify=notify)
def attach(self, _: web.Application) -> None:
"""
Attach the UpdateChecker to the application.
Args:
_ (web.Application): The aiohttp web application instance.
"""
from .Services import Services
Services.get_instance().add("update_checker", self)
@ -100,7 +84,7 @@ class UpdateChecker(metaclass=Singleton):
self._schedule_check()
async def on_shutdown(self, _: web.Application) -> None:
async def on_shutdown(self, _: web.Application | None) -> None:
"""
Handle application shutdown event.
@ -113,89 +97,129 @@ class UpdateChecker(metaclass=Singleton):
self._scheduler.remove(self._job_id)
self._job_id = None
LOG.debug("Stopped update check scheduled task.")
def _schedule_check(self) -> None:
"""Schedule the update check task to run daily at 3 AM."""
if not self._config.check_for_updates:
LOG.debug("Update checking is disabled, skipping scheduling.")
return
# Run daily at 3 AM
timer: str = "0 3 * * *"
self._job_id = self._scheduler.add(
timer=timer,
func=lambda: asyncio.create_task(self.check_for_updates()),
id="update_checker",
id=f"{__class__.__name__}.{self.check_for_updates.__name__}",
)
LOG.info(f"Scheduled update check to run daily at 3 AM (cron: {timer}).")
async def check_for_updates(self) -> tuple[str, str | None]:
async def check_for_updates(self) -> tuple[tuple[str, str | None], tuple[str, str | None]]:
"""
Check for updates from GitHub releases.
Updates config.new_version if a newer version is available.
Stops the scheduled task if an update is found.
Check for updates from GitHub releases for both the app and yt-dlp.
Updates config.new_version and config.yt_new_version if newer versions are available.
Stops the scheduled task if an app update is found.
Returns:
tuple[str, str | None]: (status, new_version)
tuple[tuple[str, str | None], tuple[str, str | None]]: ((app_status, app_version), (ytdlp_status, ytdlp_version))
status: "disabled", "error", "up_to_date", or "update_available"
new_version: The new version tag if available, None otherwise
version: The new version tag if available, None otherwise
"""
if not self._config.check_for_updates:
LOG.debug("Update checking is disabled, skipping check.")
return ("disabled", None)
return (("disabled", None), ("disabled", None))
# Check cache
cached = await self._cache.aget(self.CACHE_KEY)
if cached:
ttl = await self._cache.attl(self.CACHE_KEY)
LOG.debug(f"Returning cached result (TTL: {ttl:.0f}s)")
return cached
app_cached = await self._cache.aget(self.CACHE_KEY)
ytdlp_cached = await self._cache.aget(self.YTDLP_CACHE_KEY)
if app_cached and ytdlp_cached:
return (app_cached, ytdlp_cached)
app_result = app_cached if app_cached else await self._check_app_version()
ytdlp_result = ytdlp_cached if ytdlp_cached else await self._check_ytdlp_version()
return (app_result, ytdlp_result)
async def _check_github_version(
self,
name: str,
api_url: str,
current_version: str,
cache_key: str,
strip_v_prefix: bool = False,
) -> tuple[str, str | None]:
try:
LOG.info("Checking for application updates...")
current_version: str = APP_VERSION.lstrip("v")
LOG.info(f"Checking for {name} updates...")
async with async_client(timeout=10.0) as client:
response = await client.get(
self.GITHUB_API_URL,
api_url,
headers={"Accept": "application/vnd.github+json"},
)
if 200 != response.status_code:
LOG.warning(f"Failed to check for updates: HTTP {response.status_code}")
LOG.warning(f"Failed to check for {name} updates: HTTP {response.status_code}")
return ("error", None)
data: dict[str, Any] = response.json()
latest_tag: str = data.get("tag_name", "").lstrip("v")
latest_tag: str = data.get("tag_name", "")
if not latest_tag:
LOG.warning("No tag_name found in GitHub release data.")
LOG.warning(f"No tag_name found in {name} GitHub release data.")
return ("error", None)
if self._compare_versions(current_version, latest_tag):
LOG.warning(f"Update available: {current_version}{latest_tag}")
new_version_tag = data.get("tag_name", "")
self._config.new_version = new_version_tag
await self.on_shutdown(None)
result = ("update_available", new_version_tag)
await self._cache.aset(self.CACHE_KEY, result, self.CACHE_DURATION)
compare_current: str = current_version.lstrip("v") if strip_v_prefix else current_version
compare_latest: str = latest_tag.lstrip("v") if strip_v_prefix else latest_tag
if self._compare_versions(compare_current, compare_latest):
LOG.warning(f"{name} update available: {current_version} -> {latest_tag}")
result: tuple[str, str] = ("update_available", latest_tag)
await self._cache.aset(cache_key, result, self.CACHE_DURATION)
return result
LOG.info("No updates available.")
self._config.new_version = ""
result = ("up_to_date", None)
await self._cache.aset(self.CACHE_KEY, result, self.CACHE_DURATION)
LOG.info(f"No {name} updates available.")
result: tuple[str, None] = ("up_to_date", None)
await self._cache.aset(cache_key, result, self.CACHE_DURATION)
return result
except Exception as e:
LOG.exception(e)
LOG.error(f"Error checking for updates: {e!s}")
LOG.error(f"Error checking for {name} updates: {e!s}")
return ("error", None)
async def _check_app_version(self) -> tuple[str, str | None]:
status, new_version = await self._check_github_version(
name="application",
api_url=self.GITHUB_API_URL,
current_version=APP_VERSION,
cache_key=self.CACHE_KEY,
strip_v_prefix=True,
)
if "update_available" == status:
self._config.new_version = new_version or ""
await self.on_shutdown(None)
elif "up_to_date" == status:
self._config.new_version = ""
return (status, new_version)
async def _check_ytdlp_version(self) -> tuple[str, str | None]:
current_version: str = self._config._ytdlp_version()
if not current_version or "0.0.0" == current_version:
LOG.warning("Could not determine yt-dlp version, skipping yt-dlp update check.")
return ("error", None)
status, new_version = await self._check_github_version(
name="yt-dlp",
api_url=self.YTDLP_API_URL,
current_version=current_version,
cache_key=self.YTDLP_CACHE_KEY,
strip_v_prefix=False,
)
if "update_available" == status:
self._config.yt_new_version = new_version or ""
elif "up_to_date" == status:
self._config.yt_new_version = ""
return (status, new_version)
def _compare_versions(self, current: str, latest: str) -> bool:
"""
Compare version strings to determine if an update is available.

View file

@ -1,5 +1,7 @@
import asyncio
import base64
import copy
import functools
import glob
import ipaddress
import json
@ -86,6 +88,9 @@ TAG_REGEX: re.Pattern[str] = re.compile(r"%{([^:}]+)(?::([^}]*))?}c")
DT_PATTERN: re.Pattern[str] = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?")
"Regex to match ISO 8601 datetime strings."
EXTRACTORS_SEMAPHORE: asyncio.Semaphore | None = None
"Global semaphore for limiting concurrent extractor operations."
class StreamingError(Exception):
"""Raised when an error occurs during streaming."""
@ -416,6 +421,58 @@ def extract_info(
return YTDLP.sanitize_info(data, remove_private_keys=True) if sanitize_info else data
async def fetch_info(
config: dict,
url: str,
debug: bool = False,
no_archive: bool = False,
follow_redirect: bool = False,
sanitize_info: bool = False,
**kwargs,
) -> dict:
"""
Extracts video information from the given URL.
Args:
config (dict): Configuration options.
url (str): URL to extract information from.
debug (bool): Enable debug logging.
no_archive (bool): Disable download archive.
follow_redirect (bool): Follow URL redirects.
sanitize_info (bool): Sanitize the extracted information
**kwargs: Additional arguments.
Returns:
dict: Video information.
"""
from .config import Config
global EXTRACTORS_SEMAPHORE # noqa: PLW0603
conf = Config.get_instance()
if EXTRACTORS_SEMAPHORE is None:
EXTRACTORS_SEMAPHORE = asyncio.Semaphore(conf.extract_info_concurrency)
async with EXTRACTORS_SEMAPHORE:
return await asyncio.wait_for(
fut=asyncio.get_running_loop().run_in_executor(
None,
functools.partial(
extract_info,
config=config,
url=url,
debug=debug,
no_archive=no_archive,
follow_redirect=follow_redirect,
sanitize_info=sanitize_info,
**kwargs,
),
),
timeout=conf.extract_info_timeout,
)
def _is_safe_key(key: any) -> bool:
"""
Check if a dictionary key is safe for merging.
@ -1590,6 +1647,9 @@ def load_modules(root_path: Path, directory: Path):
import pkgutil
package_name: str = str(directory.relative_to(root_path).as_posix()).replace("/", ".")
# Ensure package name starts with 'app.' for proper module resolution
if not package_name.startswith("app."):
package_name = f"app.{package_name}"
for _, name, _ in pkgutil.iter_modules([directory]):
full_name: str = f"{package_name}.{name}"

View file

@ -3,8 +3,9 @@ import shlex
from pathlib import Path
from typing import Any
from app.features.presets.schemas import Preset
from .config import Config
from .Presets import Preset, Presets
from .Utils import REMOVE_KEYS, arg_converter, calc_download_path, create_cookies_file, merge_dict
LOG: logging.Logger = logging.getLogger("YTDLPOpts")
@ -129,9 +130,16 @@ class YTDLPCli:
raise ValueError(msg)
self.item = item
self.preset: Preset = item.get_preset()
preset_name = item.preset if item.preset else self._config.default_preset
self.preset: Preset | None = YTDLPCli._get_presets().get(preset_name)
self._config: Config = config or Config.get_instance()
@staticmethod
def _get_presets():
from app.features.presets.service import Presets
return Presets.get_instance()
def build(self) -> tuple[str, dict]:
"""
Build the CLI command following make_command logic.
@ -306,7 +314,7 @@ class YTDLPOpts:
YTDLPOpts: The instance of the class
"""
preset: Preset | None = Presets.get_instance().get(name)
preset: Preset | None = YTDLPCli._get_presets().get(name)
if not preset:
return self

View file

@ -1,10 +1,18 @@
import hashlib
import logging
import threading
import time
from typing import Any
from aiohttp import web
from app.library.Services import Services
from .Scheduler import Scheduler
from .Singleton import ThreadSafe
LOG = logging.getLogger("cache")
class Cache(metaclass=ThreadSafe):
def __init__(self) -> None:
@ -14,6 +22,25 @@ class Cache(metaclass=ThreadSafe):
self._cache: dict[str, tuple[Any, float | None]] = {}
self._lock = threading.Lock()
@staticmethod
def get_instance() -> "Cache":
"""
Get the singleton instance of Cache.
Returns:
Cache: The singleton instance of Cache.
"""
return Cache()
def attach(self, _: web.Application) -> None:
Services.get_instance().add("cache", self)
Scheduler.get_instance().add(
timer="* * * * *",
func=self.cleanup,
id=f"{__class__.__name__}.{__class__.cleanup.__name__}",
)
def set(self, key: str, value: Any, ttl: float | None = None) -> None:
"""
Synchronously set a value in the cache with an optional time-to-live.
@ -132,3 +159,20 @@ class Cache(metaclass=ThreadSafe):
Asynchronously generate a SHA-256 hash for the given input string.
"""
return self.hash(key=key)
async def cleanup(self) -> None:
"""
Remove all expired entries from the cache.
Called periodically by the scheduler.
"""
with self._lock:
now = time.time()
expired_keys: list[str] = [
key for key, (_, expire_at) in self._cache.items() if expire_at is not None and now >= expire_at
]
for key in expired_keys:
del self._cache[key]
if expired_keys:
LOG.debug(f"Cleaned up {len(expired_keys)} expired cache entries.")

View file

@ -1,377 +0,0 @@
import json
import logging
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from aiohttp import web
from .config import Config
from .encoder import Encoder
from .Events import EventBus, Events
from .mini_filter import match_str
from .Singleton import Singleton
from .Utils import arg_converter, init_class
LOG: logging.Logger = logging.getLogger("conditions")
@dataclass(kw_only=True)
class Condition:
id: str = field(default_factory=lambda: str(uuid.uuid4()))
"""The id of the condition."""
name: str
"""The name of the condition."""
filter: str
"""The filter to run on info dict."""
cli: str = ""
"""If matched append this to the download request and retry."""
extras: dict[str, Any] = field(default_factory=dict)
"""Any extra data to store with the condition."""
enabled: bool = True
"""Whether the condition is enabled."""
priority: int = 0
"""Priority of the condition."""
description: str = ""
"""A description of what the condition does."""
def serialize(self) -> dict:
return self.__dict__
def json(self) -> str:
return Encoder().encode(self.serialize())
def get(self, key: str, default: Any = None) -> Any:
return self.serialize().get(key, default)
class Conditions(metaclass=Singleton):
"""
This class is used to manage the download conditions.
"""
def __init__(self, file: Path | str | None = None, config: Config | None = None):
self._items: list[Condition] = []
"The list of items."
config = config or Config.get_instance()
self._file: Path = Path(file) if file else Path(config.config_path) / "conditions.json"
"The path to the file where the items are stored."
if self._file.exists() and "600" != self._file.stat().st_mode:
try:
self._file.chmod(0o600)
except Exception:
pass
@staticmethod
def get_instance(file: Path | str | None = None, config: Config | None = None) -> "Conditions":
"""
Get the instance of the class.
Returns:
Conditions: The instance of the class
"""
return Conditions(file=file, config=config)
async def on_shutdown(self, _: web.Application):
pass
def attach(self, _: web.Application):
"""
Attach the work to the aiohttp application.
Args:
_ (web.Application): The aiohttp application.
Returns:
None
"""
self.load()
async def event_handler(_, __):
msg = "Not implemented"
raise Exception(msg)
EventBus.get_instance().subscribe(Events.CONDITIONS_ADD, event_handler, f"{__class__.__name__}.save")
def get_all(self) -> list[Condition]:
"""Return the items."""
return self._items
def load(self) -> "Conditions":
"""
Load the items.
Returns:
Conditions: The current instance.
"""
self.clear()
if not self._file.exists() or self._file.stat().st_size < 1:
return self
try:
LOG.info(f"Loading '{self._file}'.")
items = json.loads(self._file.read_text())
except Exception as e:
LOG.exception(e)
LOG.error(f"Error loading '{self._file}'. '{e}'.")
return self
if not items or len(items) < 1:
LOG.debug(f"No items were found in '{self._file}'.")
return self
need_save = False
for i, item in enumerate(items):
try:
if "id" not in item:
item["id"] = str(uuid.uuid4())
need_save = True
if "extras" not in item:
item["extras"] = {}
need_save = True
if "enabled" not in item:
item["enabled"] = True
need_save = True
if "priority" not in item:
item["priority"] = 0
need_save = True
if "description" not in item:
item["description"] = ""
need_save = True
item: Condition = init_class(Condition, item)
self._items.append(item)
except Exception as e:
LOG.error(f"Failed to parse condition at list position '{i}'. '{e!s}'.")
continue
if need_save:
LOG.warning("Saving conditions due to schema changes.")
self.save(self._items)
return self
def clear(self) -> "Conditions":
"""
Clear all items
Returns:
conditions: The current instance.
"""
if len(self._items) < 1:
return self
self._items.clear()
return self
def validate(self, item: Condition | dict) -> bool:
"""
Validate item.
Args:
item (Condition|dict): The item to validate.
Returns:
bool: True if valid, False otherwise.
"""
if not isinstance(item, dict):
if not isinstance(item, Condition):
msg = f"Unexpected '{type(item).__name__}' item type."
raise ValueError(msg)
item = item.serialize()
if not item.get("id"):
msg = "No id found."
raise ValueError(msg)
if not item.get("name"):
msg = "No name found."
raise ValueError(msg)
if not item.get("filter"):
msg = "No filter found."
raise ValueError(msg)
try:
match_str(item.get("filter"), {})
except Exception as e:
msg = f"Invalid filter. '{e!s}'."
raise ValueError(msg) from e
if item.get("cli"):
try:
arg_converter(args=item.get("cli"))
except Exception as e:
msg = f"Invalid command options for yt-dlp. '{e!s}'."
raise ValueError(msg) from e
if not isinstance(item.get("extras"), dict):
msg = "Extras must be a dictionary."
raise ValueError(msg)
if item.get("enabled") is not None and not isinstance(item.get("enabled"), bool):
msg = "Enabled must be a boolean."
raise ValueError(msg)
if item.get("priority") is not None:
priority = item.get("priority")
if not isinstance(priority, int):
msg = "Priority must be an integer."
raise ValueError(msg)
if priority < 0:
msg = "Priority must be >= 0."
raise ValueError(msg)
if item.get("description") and not isinstance(item.get("description"), str):
msg = "Description must be a string."
raise ValueError(msg)
return True
def save(self, items: list[Condition | dict]) -> "Conditions":
"""
Save the items.
Args:
items (list[Condition]): The items to save.
Returns:
Conditions: The current instance.
"""
for i, item in enumerate(items):
try:
if not isinstance(item, Condition):
item: Condition = init_class(Condition, item)
items[i] = item
except Exception as e:
LOG.error(f"Failed to save '{i}' due to parsing error. '{e!s}'.")
continue
try:
self.validate(item)
except ValueError as e:
LOG.error(f"Failed to validate '{i}: {item.name}'. '{e!s}'.")
continue
try:
self._file.write_text(json.dumps(obj=[item.serialize() for item in items], indent=4))
LOG.info(f"Updated '{self._file}'.")
except Exception as e:
LOG.error(f"Error saving '{self._file}'. '{e!s}'.")
return self
def has(self, id_or_name: str) -> bool:
"""
Check if the item exists by id or name.
Args:
id_or_name (str): The id or name of the preset.
Returns:
bool: True if the item exists, False otherwise.
"""
return self.get(id_or_name) is not None
def get(self, id_or_name: str) -> Condition | None:
"""
Get the item by id or name.
Args:
id_or_name (str): The id or name of the preset.
Returns:
Condition|None: The item if found, None otherwise.
"""
if not id_or_name:
return None
for condition in self.get_all():
if id_or_name not in (condition.id, condition.name):
continue
return condition
return None
def match(self, info: dict) -> Condition | None:
"""
Check if any condition matches the info dict.
Args:
info (dict): The info dict to check.
Returns:
Condition|None: The condition if found, None otherwise.
"""
if len(self._items) < 1 or not info or not isinstance(info, dict) or len(info) < 1:
return None
for item in sorted(self.get_all(), key=lambda x: x.priority, reverse=True):
if not item.enabled:
continue
if not item.filter:
LOG.error(f"Filter is empty for '{item.name}'.")
continue
try:
if match_str(item.filter, info):
LOG.debug(f"Matched '{item.name}' with filter '{item.filter}'.")
return item
except Exception as e:
LOG.error(f"Failed to evaluate '{item.name}'. '{e!s}'.")
continue
return None
def single_match(self, name: str, info: dict) -> Condition | None:
"""
Check if condition matches the info dict.
Args:
name (str): The condition name to check.
info (dict): The info dict to check.
Returns:
Condition|None: The condition if found, None otherwise.
"""
if len(self._items) < 1 or not info or not isinstance(info, dict) or len(info) < 1:
return None
item = self.get(name)
if not item or not item.enabled or not item.filter:
return None
return item if match_str(item.filter, info) else None

View file

@ -238,6 +238,9 @@ class Config(metaclass=Singleton):
new_version: str = ""
"The new version available."
yt_new_version: str = ""
"The new yt-dlp version available."
_manual_vars: tuple = (
"temp_path",
"config_path",
@ -255,6 +258,7 @@ class Config(metaclass=Singleton):
"app_build_date",
"app_branch",
"new_version",
"yt_new_version",
)
"The variables that are immutable."
@ -269,6 +273,7 @@ class Config(metaclass=Singleton):
"download_info_expires",
"auto_clear_history_days",
"default_pagination",
"extract_info_concurrency",
"flaresolverr_max_timeout",
"flaresolverr_client_timeout",
"flaresolverr_cache_ttl",
@ -327,6 +332,7 @@ class Config(metaclass=Singleton):
"default_pagination",
"check_for_updates",
"new_version",
"yt_new_version",
)
"The variables that are relevant to the frontend."

View file

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

View file

@ -1,17 +1,4 @@
"""
Item addition and entry routing.
This module handles the complete flow of adding items to the download queue:
- Preset and CLI validation
- Cookie file management
- Archive checking (early and post-extraction)
- Info extraction with yt-dlp
- Conditions matching and re-queuing
- Entry type routing (playlist, video, URL)
"""
import asyncio
import functools
import logging
import time
import uuid
@ -20,16 +7,16 @@ from typing import TYPE_CHECKING
import yt_dlp.utils
from app.library.conditions import Conditions
from app.features.conditions.service import Conditions
from app.features.presets.service import Presets
from app.library.Events import Events
from app.library.ItemDTO import ItemDTO
from app.library.Presets import Presets
from app.library.Utils import (
archive_add,
archive_read,
arg_converter,
create_cookies_file,
extract_info,
fetch_info,
get_extras,
merge_dict,
ytdlp_reject,
@ -40,8 +27,8 @@ from .playlist_processor import process_playlist
from .video_processor import add_video
if TYPE_CHECKING:
from app.features.presets.schemas import Preset
from app.library.ItemDTO import Item
from app.library.Presets import Preset
from .queue_manager import DownloadQueue
@ -205,22 +192,14 @@ async def add(
LOG.info(f"[P] Extracting '{item.url}'{' with cookies' if yt_conf.get('cookiefile') else ''}.")
if not entry:
async with queue.extractors:
LOG.info(f"Extracting '{item.url}'{' with cookies' if yt_conf.get('cookiefile') else ''}.")
entry: dict | None = await asyncio.wait_for(
fut=asyncio.get_running_loop().run_in_executor(
None,
functools.partial(
extract_info,
config=yt_conf,
url=item.url,
debug=bool(queue.config.ytdlp_debug),
no_archive=False,
follow_redirect=True,
),
),
timeout=queue.config.extract_info_timeout,
)
LOG.info(f"Extracting '{item.url}'{' with cookies' if yt_conf.get('cookiefile') else ''}.")
entry: dict | None = await fetch_info(
config=yt_conf,
url=item.url,
debug=bool(queue.config.ytdlp_debug),
no_archive=False,
follow_redirect=True,
)
if not entry:
LOG.error(f"Unable to extract info for '{item.url}'. Logs: {logs}")
@ -288,7 +267,7 @@ async def add(
)
return {"status": "error", "msg": message, "hidden": True}
if not item.requeued and (condition := Conditions.get_instance().match(info=entry)):
if not item.requeued and (condition := await Conditions.get_instance().match(info=entry)):
already.pop()
message = f"Condition '{condition.name}' matched for '{item!r}'."

View file

@ -43,8 +43,6 @@ class DownloadQueue(metaclass=Singleton):
"DataStore for the download queue."
self.processors = asyncio.Semaphore(self.config.playlist_items_concurrency)
"Semaphore to limit the number of concurrent processors."
self.extractors = asyncio.Semaphore(self.config.extract_info_concurrency)
"Semaphore to limit the number of concurrent extract_info calls."
self.pool = PoolManager(queue=self, config=self.config)
"Pool manager for coordinating download execution."

View file

@ -16,16 +16,10 @@ class Encoder(json.JSONEncoder):
def default(self, o):
from .ItemDTO import ItemDTO
if isinstance(o, Path):
return str(o)
if isinstance(o, DateRange):
return {"start": str(o.start).replace("-", ""), "end": str(o.end).replace("-", "")}
if isinstance(o, date):
return str(o)
if isinstance(o, ImpersonateTarget):
if isinstance(o, (Path, date, ImpersonateTarget, ValueError)):
return str(o)
if isinstance(o, ItemDTO):
@ -35,6 +29,9 @@ class Encoder(json.JSONEncoder):
if hasattr(o, "serialize"):
return o.serialize()
if hasattr(o, "model_dump"):
return o.model_dump()
if hasattr(o, "__dict__"):
return o.__dict__

Some files were not shown because too many files have changed in this diff Show more