diff --git a/app/features/conditions/tests/test_conditions_repository.py b/app/features/conditions/tests/test_conditions_repository.py index 1f44ecdb..0b030ab9 100644 --- a/app/features/conditions/tests/test_conditions_repository.py +++ b/app/features/conditions/tests/test_conditions_repository.py @@ -11,15 +11,11 @@ from app.library.sqlite_store import SqliteStore @pytest_asyncio.fixture -async def repo(tmp_path): - """Provide a fresh repository instance with initialized database for each test.""" - # Reset singletons +async def repo(): ConditionsRepository._reset_singleton() SqliteStore._reset_singleton() - # Initialize database with temp path - test_db_path = tmp_path / "test.db" - store = SqliteStore(db_path=str(test_db_path)) + store = SqliteStore(db_path=":memory:") await store.get_connection() # Create repository diff --git a/app/features/notifications/migration.py b/app/features/notifications/migration.py index 44e4b460..6262cdee 100644 --- a/app/features/notifications/migration.py +++ b/app/features/notifications/migration.py @@ -7,8 +7,8 @@ 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 -from app.library.Presets import Presets if TYPE_CHECKING: from app.features.notifications.repository import NotificationsRepository diff --git a/app/features/notifications/service.py b/app/features/notifications/service.py index f01948b3..bdef57b7 100644 --- a/app/features/notifications/service.py +++ b/app/features/notifications/service.py @@ -15,13 +15,14 @@ from app.features.notifications.schemas import ( 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.Presets import Preset, Presets from app.library.Singleton import Singleton if TYPE_CHECKING: diff --git a/app/features/presets/__init__.py b/app/features/presets/__init__.py new file mode 100644 index 00000000..33e0f1db --- /dev/null +++ b/app/features/presets/__init__.py @@ -0,0 +1 @@ +"""Presets feature module.""" diff --git a/app/features/presets/defaults.py b/app/features/presets/defaults.py new file mode 100644 index 00000000..850a73fc --- /dev/null +++ b/app/features/presets/defaults.py @@ -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, + }, +] diff --git a/app/features/presets/deps.py b/app/features/presets/deps.py new file mode 100644 index 00000000..88bfcf53 --- /dev/null +++ b/app/features/presets/deps.py @@ -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() diff --git a/app/features/presets/migration.py b/app/features/presets/migration.py new file mode 100644 index 00000000..f44c78e3 --- /dev/null +++ b/app/features/presets/migration.py @@ -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 diff --git a/app/features/presets/models.py b/app/features/presets/models.py new file mode 100644 index 00000000..2ec5b227 --- /dev/null +++ b/app/features/presets/models.py @@ -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) diff --git a/app/features/presets/repository.py b/app/features/presets/repository.py new file mode 100644 index 00000000..c8adf9b3 --- /dev/null +++ b/app/features/presets/repository.py @@ -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 diff --git a/app/features/presets/router.py b/app/features/presets/router.py new file mode 100644 index 00000000..afb9c786 --- /dev/null +++ b/app/features/presets/router.py @@ -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) diff --git a/app/features/presets/schemas.py b/app/features/presets/schemas.py new file mode 100644 index 00000000..fd80f639 --- /dev/null +++ b/app/features/presets/schemas.py @@ -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 diff --git a/app/features/presets/service.py b/app/features/presets/service.py new file mode 100644 index 00000000..9d483ba5 --- /dev/null +++ b/app/features/presets/service.py @@ -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 diff --git a/app/features/presets/tests/test_presets_repository.py b/app/features/presets/tests/test_presets_repository.py new file mode 100644 index 00000000..dbf13843 --- /dev/null +++ b/app/features/presets/tests/test_presets_repository.py @@ -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" diff --git a/app/features/presets/utils.py b/app/features/presets/utils.py new file mode 100644 index 00000000..0164da9a --- /dev/null +++ b/app/features/presets/utils.py @@ -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()) diff --git a/app/library/ItemDTO.py b/app/library/ItemDTO.py index d3faaf2f..d1439bbf 100644 --- a/app/library/ItemDTO.py +++ b/app/library/ItemDTO.py @@ -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") diff --git a/app/library/Presets.py b/app/library/Presets.py deleted file mode 100644 index 9d5e4252..00000000 --- a/app/library/Presets.py +++ /dev/null @@ -1,412 +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 .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" - - 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 diff --git a/app/library/YTDLPOpts.py b/app/library/YTDLPOpts.py index 8b678d12..d846e6a9 100644 --- a/app/library/YTDLPOpts.py +++ b/app/library/YTDLPOpts.py @@ -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 diff --git a/app/library/config.py b/app/library/config.py index a34a9ca5..c139ed42 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -273,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", diff --git a/app/library/downloads/item_adder.py b/app/library/downloads/item_adder.py index e0942232..af7aeaba 100644 --- a/app/library/downloads/item_adder.py +++ b/app/library/downloads/item_adder.py @@ -8,9 +8,9 @@ from typing import TYPE_CHECKING import yt_dlp.utils 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, @@ -27,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 diff --git a/app/main.py b/app/main.py index 78a49dff..6dfe8dde 100644 --- a/app/main.py +++ b/app/main.py @@ -17,6 +17,7 @@ from aiohttp import web from app.features.conditions.service import Conditions from app.features.dl_fields.service import DLFields from app.features.notifications.service import Notifications +from app.features.presets.deps import get_presets_repo from app.features.tasks.definitions.deps import get_task_definitions_repo from app.features.tasks.service import Tasks from app.library.BackgroundWorker import BackgroundWorker @@ -26,7 +27,6 @@ from app.library.downloads import DownloadQueue from app.library.Events import EventBus, Events from app.library.HttpAPI import HttpAPI from app.library.HttpSocket import HttpSocket -from app.library.Presets import Presets from app.library.Scheduler import Scheduler from app.library.Services import Services from app.library.sqlite_store import SqliteStore @@ -116,7 +116,7 @@ class Main: self._socket.attach(self._app) self._http.attach(self._app) - Presets.get_instance().attach(self._app) + get_presets_repo().attach(self._app) Tasks.get_instance().attach(self._app) Notifications.get_instance().attach(self._app) Conditions.get_instance().attach(self._app) diff --git a/app/migrations/20260125125900_add_presets_table.py b/app/migrations/20260125125900_add_presets_table.py new file mode 100644 index 00000000..e2b496d6 --- /dev/null +++ b/app/migrations/20260125125900_add_presets_table.py @@ -0,0 +1,44 @@ +""" +This module contains a db migration. + +Migration Name: add_presets_table +Migration Version: 20260125125900 +""" + +from sqlalchemy import text + + +async def upgrade(c): + sql: list[str] = [ + """ + CREATE TABLE IF NOT EXISTS presets ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', + folder TEXT NOT NULL DEFAULT '', + template TEXT NOT NULL DEFAULT '', + cookies TEXT NOT NULL DEFAULT '', + cli TEXT NOT NULL DEFAULT '', + is_default INTEGER NOT NULL DEFAULT 0, + priority INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """, + 'CREATE INDEX IF NOT EXISTS "ix_presets_name" ON "presets" ("name")', + 'CREATE INDEX IF NOT EXISTS "ix_presets_is_default" ON "presets" ("is_default")', + 'CREATE INDEX IF NOT EXISTS "ix_presets_priority" ON "presets" ("priority")', + ] + for sql_stmt in sql: + await c.execute(text(sql_stmt)) + + +async def downgrade(c): + sql: list[str] = [ + 'DROP INDEX IF EXISTS "ix_presets_name"', + 'DROP INDEX IF EXISTS "ix_presets_is_default"', + 'DROP INDEX IF EXISTS "ix_presets_priority"', + 'DROP TABLE IF EXISTS "presets"', + ] + for sql_stmt in sql: + await c.execute(text(sql_stmt)) diff --git a/app/routes/api/archiver.py b/app/routes/api/archiver.py index c732f5a9..4183deda 100644 --- a/app/routes/api/archiver.py +++ b/app/routes/api/archiver.py @@ -4,8 +4,8 @@ from collections.abc import Iterable from aiohttp import web from aiohttp.web import Request, Response +from app.features.presets.service import Presets from app.library.Archiver import Archiver -from app.library.Presets import Presets from app.library.router import route from app.library.YTDLPOpts import YTDLPOpts diff --git a/app/routes/api/history.py b/app/routes/api/history.py index 2ebdaaa8..32ef6a05 100644 --- a/app/routes/api/history.py +++ b/app/routes/api/history.py @@ -5,13 +5,14 @@ from typing import TYPE_CHECKING, Any from aiohttp import web from aiohttp.web import Request, Response +from app.features.presets.schemas import Preset +from app.features.presets.service import Presets from app.library.config import Config from app.library.DataStore import StoreType from app.library.downloads import Download, DownloadQueue from app.library.encoder import Encoder from app.library.Events import EventBus, Events from app.library.ItemDTO import Item -from app.library.Presets import Preset, Presets from app.library.router import route if TYPE_CHECKING: diff --git a/app/routes/api/presets.py b/app/routes/api/presets.py index e729cbe4..79d59507 100644 --- a/app/routes/api/presets.py +++ b/app/routes/api/presets.py @@ -1,108 +1 @@ -import logging -import uuid - -from aiohttp import web -from aiohttp.web import Request, Response - -from app.features.core.schemas import CEAction, CEFeature, ConfigEvent -from app.library.encoder import Encoder -from app.library.Events import EventBus, Events -from app.library.Presets import Preset, Presets -from app.library.router import route -from app.library.Utils import init_class, validate_uuid - -LOG: logging.Logger = logging.getLogger(__name__) - - -@route("GET", "api/presets/", "presets") -async def presets(request: Request, encoder: Encoder) -> Response: - """ - Get the presets. - - Args: - request (Request): The request object. - encoder (Encoder): The encoder instance. - - Returns: - Response: The response object. - - """ - data: list[Preset] = Presets.get_instance().get_all() - filter_fields: str | None = request.query.get("filter", None) - - if filter_fields: - fields: list[str] = [field.strip() for field in filter_fields.split(",")] - data = [{key: value for key, value in preset.serialize().items() if key in fields} for preset in data] - - return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=encoder.encode) - - -@route("PUT", "api/presets/", "presets_add") -async def presets_add(request: Request, encoder: Encoder, notify: EventBus) -> Response: - """ - Add presets. - - Args: - request (Request): The request object. - encoder (Encoder): The encoder instance. - notify (EventBus): The event bus instance. - - Returns: - Response: The response object - - """ - data = await request.json() - - if not isinstance(data, list): - return web.json_response( - {"error": "Invalid request body expecting list with dicts."}, - status=web.HTTPBadRequest.status_code, - ) - - presets: list = [] - - cls = Presets.get_instance() - - for item in data: - if not isinstance(item, dict): - return web.json_response( - {"error": "Invalid request body expecting list with dicts."}, - status=web.HTTPBadRequest.status_code, - ) - - if not item.get("name"): - return web.json_response( - {"error": "name is required.", "data": item}, status=web.HTTPBadRequest.status_code - ) - - if not item.get("id", None) or not validate_uuid(item.get("id"), version=4): - item["id"] = str(uuid.uuid4()) - - try: - cls.validate(item) - except ValueError as e: - return web.json_response( - {"error": f"Failed to validate preset '{item.get('name')}'. '{e!s}'"}, - status=web.HTTPBadRequest.status_code, - ) - - presets.append(init_class(Preset, item)) - - try: - presets = cls.save(items=presets).load().get_all() - except Exception as e: - LOG.exception(e) - return web.json_response( - {"error": "Failed to save presets.", "message": str(e)}, - status=web.HTTPInternalServerError.status_code, - ) - - notify.emit( - Events.CONFIG_UPDATE, - data=ConfigEvent( - feature=CEFeature.PRESETS, - action=CEAction.REPLACE, - data=[preset.serialize() for preset in presets], - ), - ) - return web.json_response(data=presets, status=web.HTTPOk.status_code, dumps=encoder.encode) +import app.features.presets.router # noqa: F401 diff --git a/app/routes/api/system.py b/app/routes/api/system.py index df83fcf5..2c4345f4 100644 --- a/app/routes/api/system.py +++ b/app/routes/api/system.py @@ -12,11 +12,11 @@ from aiohttp.web import Request, Response from aiohttp.web_runner import GracefulExit from app.features.dl_fields.service import DLFields +from app.features.presets.service import Presets from app.library.config import Config from app.library.downloads import DownloadQueue from app.library.encoder import Encoder from app.library.Events import EventBus, Events -from app.library.Presets import Presets from app.library.router import route from app.library.UpdateChecker import UpdateChecker from app.library.Utils import list_folders diff --git a/app/routes/api/yt_dlp.py b/app/routes/api/yt_dlp.py index de87e4cf..996e0248 100644 --- a/app/routes/api/yt_dlp.py +++ b/app/routes/api/yt_dlp.py @@ -7,11 +7,11 @@ from typing import Any from aiohttp import web from aiohttp.web import Request, Response +from app.features.presets.service import Presets from app.library.cache import Cache from app.library.config import Config from app.library.encoder import Encoder from app.library.ItemDTO import Item -from app.library.Presets import Presets from app.library.router import route from app.library.Utils import ( REMOVE_KEYS, diff --git a/app/tests/test_itemdto.py b/app/tests/test_itemdto.py index e6fa577b..305402bf 100644 --- a/app/tests/test_itemdto.py +++ b/app/tests/test_itemdto.py @@ -8,7 +8,7 @@ from app.library.ItemDTO import Item, ItemDTO class TestItemFormatAndBasics: - @patch("app.library.Presets.Presets.get_instance") + @patch("app.features.presets.service.Presets.get_instance") def test_format_validates_and_normalizes(self, mock_presets_get): # Preset exists and is not default => allowed mock_presets_get.return_value.has.return_value = True @@ -42,7 +42,7 @@ class TestItemFormatAndBasics: assert item.requeued is True assert item.cli == "--embed-metadata" - @patch("app.library.Presets.Presets.get_instance") + @patch("app.features.presets.service.Presets.get_instance") def test_format_raises_for_missing_url_and_invalid_preset(self, mock_presets_get): # Missing url with pytest.raises(ValueError, match="url param is required"): @@ -282,14 +282,14 @@ class TestItemDTO: def test_get_preset_returns_preset_instance(self): """Test ItemDTO.get_preset returns the Preset instance.""" - from app.library.Presets import Preset + from app.features.presets.schemas import Preset - mock_preset = Preset(id="test-id", name="test-preset", cli="--test") + mock_preset = Preset(id=1, name="test-preset", cli="--format best") with patch.object(ItemDTO, "__post_init__", lambda _: None): dto = ItemDTO(id="vid", title="t", url="u", folder="f", preset="test-preset") - with patch("app.library.Presets.Presets.get_instance") as mock_presets: + with patch("app.features.presets.service.Presets.get_instance") as mock_presets: mock_presets.return_value.get.return_value = mock_preset result = dto.get_preset() @@ -300,14 +300,14 @@ class TestItemDTO: def test_get_preset_uses_default_when_no_preset_set(self): """Test ItemDTO.get_preset uses 'default' when preset is empty.""" - from app.library.Presets import Preset + from app.features.presets.schemas import Preset - mock_preset = Preset(id="default-id", name="default", cli="--default") + mock_preset = Preset(id=2, name="default", cli="--format best") with patch.object(ItemDTO, "__post_init__", lambda _: None): dto = ItemDTO(id="vid", title="t", url="u", folder="f", preset="") - with patch("app.library.Presets.Presets.get_instance") as mock_presets: + with patch("app.features.presets.service.Presets.get_instance") as mock_presets: mock_presets.return_value.get.return_value = mock_preset result = dto.get_preset() @@ -320,7 +320,7 @@ class TestItemDTO: with patch.object(ItemDTO, "__post_init__", lambda _: None): dto = ItemDTO(id="vid", title="t", url="u", folder="f", preset="nonexistent") - with patch("app.library.Presets.Presets.get_instance") as mock_presets: + with patch("app.features.presets.service.Presets.get_instance") as mock_presets: mock_presets.return_value.get.return_value = None result = dto.get_preset() diff --git a/app/tests/test_presets.py b/app/tests/test_presets.py deleted file mode 100644 index 987c1750..00000000 --- a/app/tests/test_presets.py +++ /dev/null @@ -1,638 +0,0 @@ -import json -import tempfile -import uuid -from pathlib import Path -from unittest.mock import Mock, patch - -import pytest - -from app.library.Presets import DEFAULT_PRESETS, Preset, Presets - - -class TestPreset: - """Test the Preset dataclass.""" - - def test_preset_creation_with_defaults(self): - """Test creating a preset with default values.""" - preset = Preset(name="test_preset") - - assert preset.name == "test_preset" - assert preset.description == "" - assert preset.folder == "" - assert preset.template == "" - assert preset.cookies == "" - assert preset.cli == "" - assert preset.default is False - assert len(preset.id) == 36, "ID should be auto-generated UUID" # UUID4 string length - assert "-" in preset.id - - def test_preset_creation_with_all_fields(self): - """Test creating a preset with all fields specified.""" - preset_id = str(uuid.uuid4()) - preset = Preset( - id=preset_id, - name="test_preset", - description="Test description", - folder="test_folder", - template="test_template", - cookies="test_cookies", - cli="--test-option", - default=True, - priority=10, - ) - - assert preset.id == preset_id - assert preset.name == "test_preset" - assert preset.description == "Test description" - assert preset.folder == "test_folder" - assert preset.template == "test_template" - assert preset.cookies == "test_cookies" - assert preset.cli == "--test-option" - assert preset.priority == 10 - assert preset.default is True - - def test_preset_serialize(self): - """Test preset serialization to dictionary.""" - preset = Preset(name="test_preset", description="Test description", cli="--test-option") - - serialized = preset.serialize() - - assert isinstance(serialized, dict) - assert serialized["name"] == "test_preset" - assert serialized["description"] == "Test description" - assert serialized["cli"] == "--test-option" - assert "id" in serialized - - def test_preset_json(self): - """Test preset JSON serialization.""" - preset = Preset(name="test_preset", cli="--test-option") - - json_str = preset.json() - - assert isinstance(json_str, str) - # Should be valid JSON - parsed = json.loads(json_str) - assert parsed["name"] == "test_preset" - assert parsed["cli"] == "--test-option" - - def test_preset_get_method(self): - """Test preset get method for accessing fields.""" - preset = Preset(name="test_preset", description="Test description") - - assert preset.get("name") == "test_preset" - assert preset.get("description") == "Test description" - assert preset.get("nonexistent") is None - assert preset.get("nonexistent", "default_value") == "default_value" - - def test_preset_id_generation(self): - """Test that each preset gets a unique ID.""" - preset1 = Preset(name="preset1") - preset2 = Preset(name="preset2") - - assert preset1.id != preset2.id - assert len(preset1.id) == 36 - assert len(preset2.id) == 36 - - -class TestPresets: - """Test the Presets singleton manager.""" - - def setup_method(self): - """Set up test environment before each test.""" - # Reset singleton completely - Presets._reset_singleton() - - @patch("app.library.Presets.Config") - def test_presets_singleton(self, mock_config): - """Test that Presets follows singleton pattern.""" - mock_config.get_instance.return_value = Mock(config_path="/tmp", default_preset="default") - - instance1 = Presets.get_instance() - instance2 = Presets.get_instance() - - assert instance1 is instance2 - assert isinstance(instance1, Presets) - - @patch("app.library.Presets.Config") - def test_presets_initialization(self, mock_config): - """Test Presets initialization with default presets.""" - mock_config_instance = Mock(config_path="/tmp", default_preset="default") - mock_config.get_instance.return_value = mock_config_instance - - with tempfile.TemporaryDirectory() as temp_dir: - presets_file = Path(temp_dir) / "presets.json" - presets = Presets(file=presets_file, config=mock_config_instance) - - # Should have default presets loaded - default_presets = presets._default - assert len(default_presets) > 0 - - # Check that default presets are Preset instances - for preset in default_presets: - assert isinstance(preset, Preset) - assert preset.default is True - - @patch("app.library.Presets.Config") - def test_presets_load_empty_file(self, mock_config): - """Test loading presets from empty or non-existent file.""" - mock_config_instance = Mock(config_path="/tmp", default_preset="default") - mock_config.get_instance.return_value = mock_config_instance - - with tempfile.TemporaryDirectory() as temp_dir: - presets_file = Path(temp_dir) / "presets.json" - presets = Presets(file=presets_file, config=mock_config_instance) - presets.clear() # Ensure we start with empty items - - result = presets.load() - - assert result is presets - assert len(presets._items) == 0 - assert len(presets._default) > 0 # Should still have default presets - - @patch("app.library.Presets.Config") - @patch("app.library.Presets.arg_converter") - def test_presets_load_valid_file(self, mock_arg_converter, mock_config): - """Test loading presets from valid JSON file.""" - mock_config_instance = Mock(config_path="/tmp", default_preset="default") - mock_config.get_instance.return_value = mock_config_instance - mock_arg_converter.return_value = [] # Mock successful CLI parsing - - test_presets = [ - {"id": str(uuid.uuid4()), "name": "test_preset_1", "description": "Test preset 1", "cli": "--format best"}, - {"id": str(uuid.uuid4()), "name": "test_preset_2", "description": "Test preset 2", "cli": "--format worst"}, - ] - - with tempfile.TemporaryDirectory() as temp_dir: - presets_file = Path(temp_dir) / "presets.json" - presets_file.write_text(json.dumps(test_presets, indent=2)) - - presets = Presets(file=presets_file, config=mock_config_instance) - presets._items.clear() # Ensure we start with empty items - result = presets.load() - - assert result is presets - assert len(presets._items) == 2 - assert presets._items[0].name == "test_preset_1" - assert presets._items[1].name == "test_preset_2" - - @patch("app.library.Presets.Config") - def test_presets_load_invalid_json(self, mock_config): - """Test loading presets from invalid JSON file.""" - mock_config_instance = Mock(config_path="/tmp", default_preset="default") - mock_config.get_instance.return_value = mock_config_instance - - with tempfile.TemporaryDirectory() as temp_dir: - presets_file = Path(temp_dir) / "presets.json" - presets_file.write_text("invalid json content") - - presets = Presets(file=presets_file, config=mock_config_instance) - - # Should handle invalid JSON gracefully - result = presets.load() - assert result is presets - assert len(presets._items) == 0 - - @patch("app.library.Presets.Config") - @patch("app.library.Presets.arg_converter") - def test_presets_load_with_format_migration(self, mock_arg_converter, mock_config): - """Test loading presets with old format that needs migration.""" - mock_config_instance = Mock(config_path="/tmp", default_preset="default") - mock_config.get_instance.return_value = mock_config_instance - mock_arg_converter.return_value = [] # Mock successful CLI parsing - - # Old format preset with 'format' field instead of 'cli' - old_preset = {"name": "old_preset", "format": "best[height<=720]", "description": "Old format preset"} - - with tempfile.TemporaryDirectory() as temp_dir: - presets_file = Path(temp_dir) / "presets.json" - presets_file.write_text(json.dumps([old_preset])) - - presets = Presets(file=presets_file, config=mock_config_instance) - result = presets.load() - - assert result is presets - assert len(presets._items) == 1 - loaded_preset = presets._items[0] - assert loaded_preset.name == "old_preset" - assert "best[height<=720]" in loaded_preset.cli, "Should have migrated format to cli" - assert "--format" in loaded_preset.cli - # Should have generated ID - assert loaded_preset.id is not None - - @patch("app.library.Presets.Config") - def test_presets_validate_valid_preset(self, mock_config): - """Test validating a valid preset.""" - mock_config_instance = Mock(config_path="/tmp", default_preset="default") - mock_config.get_instance.return_value = mock_config_instance - - with tempfile.TemporaryDirectory() as temp_dir: - presets_file = Path(temp_dir) / "presets.json" - presets = Presets(file=presets_file, config=mock_config_instance) - - valid_preset = {"id": str(uuid.uuid4()), "name": "valid_preset", "cli": "--format best"} - - result = presets.validate(valid_preset) - assert result is True - - @patch("app.library.Presets.Config") - def test_presets_validate_invalid_preset(self, mock_config): - """Test validating invalid presets.""" - mock_config_instance = Mock(config_path="/tmp", default_preset="default") - mock_config.get_instance.return_value = mock_config_instance - - with tempfile.TemporaryDirectory() as temp_dir: - presets_file = Path(temp_dir) / "presets.json" - presets = Presets(file=presets_file, config=mock_config_instance) - - # Test missing ID - with pytest.raises(ValueError, match="No id found"): - presets.validate({"name": "test"}) - - # Test missing name - with pytest.raises(ValueError, match="No name found"): - presets.validate({"id": str(uuid.uuid4())}) - - # Test wrong type - with pytest.raises(ValueError, match="Unexpected"): - presets.validate("invalid_type") - - @patch("app.library.Presets.Config") - @patch("app.library.Presets.arg_converter") - def test_presets_save(self, mock_arg_converter, mock_config): - """Test saving presets to file.""" - mock_config_instance = Mock(config_path="/tmp", default_preset="default") - mock_config.get_instance.return_value = mock_config_instance - mock_arg_converter.return_value = [] # Mock successful CLI parsing - - test_presets = [ - Preset(name="test1", cli="--format best"), - Preset(name="test2", cli="--format worst", default=True), # This should be excluded from save - ] - - with tempfile.TemporaryDirectory() as temp_dir: - presets_file = Path(temp_dir) / "presets.json" - presets = Presets(file=presets_file, config=mock_config_instance) - - result = presets.save(test_presets) - - assert result is presets - assert presets_file.exists() - - # Should only save non-default presets - saved_data = json.loads(presets_file.read_text()) - assert len(saved_data) == 1 - assert saved_data[0]["name"] == "test1" - - @patch("app.library.Presets.Config") - def test_presets_get_all(self, mock_config): - """Test getting all presets (default + custom).""" - mock_config_instance = Mock(config_path="/tmp", default_preset="default") - mock_config.get_instance.return_value = mock_config_instance - - with tempfile.TemporaryDirectory() as temp_dir: - presets_file = Path(temp_dir) / "presets.json" - presets = Presets(file=presets_file, config=mock_config_instance) - - # Add a custom preset - custom_preset = Preset(name="custom", cli="--custom") - presets._items.append(custom_preset) - - all_presets = presets.get_all() - - assert len(all_presets) > 1, "Should include both default and custom presets" - assert any(p.name == "custom" for p in all_presets) - assert any(p.default is True for p in all_presets) - - @patch("app.library.Presets.Config") - def test_presets_get_by_id(self, mock_config): - """Test getting preset by ID.""" - mock_config_instance = Mock(config_path="/tmp", default_preset="default") - mock_config.get_instance.return_value = mock_config_instance - - with tempfile.TemporaryDirectory() as temp_dir: - presets_file = Path(temp_dir) / "presets.json" - presets = Presets(file=presets_file, config=mock_config_instance) - - test_id = str(uuid.uuid4()) - test_preset = Preset(id=test_id, name="test_preset") - presets._items.append(test_preset) - - found_preset = presets.get(test_id) - - assert found_preset is not None - assert found_preset.id == test_id - assert found_preset.name == "test_preset" - - @patch("app.library.Presets.Config") - def test_presets_get_by_name(self, mock_config): - """Test getting preset by name.""" - mock_config_instance = Mock(config_path="/tmp", default_preset="default") - mock_config.get_instance.return_value = mock_config_instance - - with tempfile.TemporaryDirectory() as temp_dir: - presets_file = Path(temp_dir) / "presets.json" - presets = Presets(file=presets_file, config=mock_config_instance) - - test_preset = Preset(name="test_preset", cli="--test") - presets._items.append(test_preset) - - found_preset = presets.get("test_preset") - - assert found_preset is not None - assert found_preset.name == "test_preset" - assert found_preset.cli == "--test" - - @patch("app.library.Presets.Config") - def test_presets_get_nonexistent(self, mock_config): - """Test getting non-existent preset returns None.""" - mock_config_instance = Mock(config_path="/tmp", default_preset="default") - mock_config.get_instance.return_value = mock_config_instance - - with tempfile.TemporaryDirectory() as temp_dir: - presets_file = Path(temp_dir) / "presets.json" - presets = Presets(file=presets_file, config=mock_config_instance) - - found_preset = presets.get("nonexistent") - - assert found_preset is None - - @patch("app.library.Presets.Config") - def test_presets_get_empty_parameter(self, mock_config): - """Test getting preset with empty string returns None.""" - mock_config_instance = Mock(config_path="/tmp", default_preset="default") - mock_config.get_instance.return_value = mock_config_instance - - with tempfile.TemporaryDirectory() as temp_dir: - presets_file = Path(temp_dir) / "presets.json" - presets = Presets(file=presets_file, config=mock_config_instance) - - assert presets.get("") is None - assert presets.get(None) is None - - @patch("app.library.Presets.Config") - def test_presets_has_method(self, mock_config): - """Test has method for checking preset existence.""" - mock_config_instance = Mock(config_path="/tmp", default_preset="default") - mock_config.get_instance.return_value = mock_config_instance - - with tempfile.TemporaryDirectory() as temp_dir: - presets_file = Path(temp_dir) / "presets.json" - presets = Presets(file=presets_file, config=mock_config_instance) - - test_preset = Preset(name="existing_preset") - presets._items.append(test_preset) - - assert presets.has("existing_preset") is True - assert presets.has(test_preset.id) is True - assert presets.has("nonexistent") is False - - @patch("app.library.Presets.Config") - def test_presets_clear(self, mock_config): - """Test clearing all custom presets.""" - mock_config_instance = Mock(config_path="/tmp", default_preset="default") - mock_config.get_instance.return_value = mock_config_instance - - with tempfile.TemporaryDirectory() as temp_dir: - presets_file = Path(temp_dir) / "presets.json" - presets = Presets(file=presets_file, config=mock_config_instance) - - # Add some custom presets - presets._items.append(Preset(name="custom1")) - presets._items.append(Preset(name="custom2")) - - assert len(presets._items) == 2 - - result = presets.clear() - - assert result is presets - assert len(presets._items) == 0 - - @patch("app.library.Presets.Config") - def test_presets_file_permissions(self, mock_config): - """Test that presets file gets correct permissions.""" - mock_config_instance = Mock(config_path="/tmp", default_preset="default") - mock_config.get_instance.return_value = mock_config_instance - - with tempfile.TemporaryDirectory() as temp_dir: - presets_file = Path(temp_dir) / "presets.json" - # Create file with different permissions - presets_file.write_text("{}") - presets_file.chmod(0o644) - - # Creating Presets instance should attempt to fix permissions - presets = Presets(file=presets_file, config=mock_config_instance) - - # Note: The actual chmod might fail in test environment, - # but we're testing that the code attempts it - assert presets is not None, "but we're testing that the code attempts it" - - def test_default_presets_validation(self): - """Test that all default presets are valid.""" - # This test verifies that the DEFAULT_PRESETS constant contains valid presets - for i, preset_data in enumerate(DEFAULT_PRESETS): - assert "id" in preset_data, f"Default preset {i} missing id" - assert "name" in preset_data, f"Default preset {i} missing name" - assert "default" in preset_data, f"Default preset {i} missing default flag" - assert preset_data["default"] is True, f"Default preset {i} should have default=True" - - # Should be able to create Preset instance - preset = Preset( - id=preset_data["id"], - name=preset_data["name"], - description=preset_data.get("description", ""), - folder=preset_data.get("folder", ""), - template=preset_data.get("template", ""), - cli=preset_data.get("cli", ""), - default=preset_data["default"], - ) - assert isinstance(preset, Preset) - - @patch("app.library.Presets.Config") - def test_presets_attach_method(self, mock_config): - """Test the attach method for aiohttp integration.""" - mock_config_instance = Mock(config_path="/tmp", default_preset="default") - mock_config.get_instance.return_value = mock_config_instance - mock_app = Mock() - - with tempfile.TemporaryDirectory() as temp_dir: - presets_file = Path(temp_dir) / "presets.json" - presets = Presets(file=presets_file, config=mock_config_instance) - - # Mock the get method to return a preset for default_preset check - with patch.object(presets, "get", return_value=Mock()) as mock_get: - presets.attach(mock_app) - mock_get.assert_called_once_with("default") - - @patch("app.library.Presets.Config") - def test_presets_attach_default_preset_not_found(self, mock_config): - """Test attach method when default preset is not found.""" - mock_config_instance = Mock(config_path="/tmp", default_preset="nonexistent") - mock_config.get_instance.return_value = mock_config_instance - mock_app = Mock() - - with tempfile.TemporaryDirectory() as temp_dir: - presets_file = Path(temp_dir) / "presets.json" - presets = Presets(file=presets_file, config=mock_config_instance) - - with patch.object(presets, "get", return_value=None): - # The actual config instance stored in presets should be checked - presets.attach(mock_app) - assert presets._config.default_preset == "default", "Should have reset default_preset to 'default'" - - @pytest.mark.asyncio - @patch("app.library.Presets.Config") - async def test_presets_on_shutdown(self, mock_config): - """Test the on_shutdown method.""" - mock_config_instance = Mock(config_path="/tmp", default_preset="default") - mock_config.get_instance.return_value = mock_config_instance - mock_app = Mock() - - with tempfile.TemporaryDirectory() as temp_dir: - presets_file = Path(temp_dir) / "presets.json" - presets = Presets(file=presets_file, config=mock_config_instance) - - # Should not raise an exception - await presets.on_shutdown(mock_app) - - @patch("app.library.Presets.Config") - @patch("app.library.Presets.LOG") - @patch("app.library.Presets.arg_converter") - def test_presets_load_invalid_preset_in_file(self, mock_arg_converter, mock_log, mock_config): - """Test loading file with some invalid presets.""" - mock_config_instance = Mock(config_path="/tmp", default_preset="default") - mock_config.get_instance.return_value = mock_config_instance - mock_arg_converter.return_value = [] # Mock successful CLI parsing - - # Mix of valid and invalid presets - test_presets = [ - {"id": str(uuid.uuid4()), "name": "valid_preset", "cli": "--format best"}, - { - # Missing required fields - "description": "Invalid preset" - }, - ] - - with tempfile.TemporaryDirectory() as temp_dir: - presets_file = Path(temp_dir) / "presets.json" - presets_file.write_text(json.dumps(test_presets)) - - # Clear any existing items first - presets = Presets(file=presets_file, config=mock_config_instance) - presets.load() - - assert len(presets._items) == 1, "Should only load the valid preset" - assert presets._items[0].name == "valid_preset", f"Expected 'valid_preset', got '{presets._items}'" - - # Should have logged an error for the invalid preset - mock_log.error.assert_called() - - @patch("app.library.Presets.Config") - @patch("app.library.Presets.LOG") - def test_presets_save_with_invalid_preset(self, mock_log, mock_config): - """Test saving presets with some invalid ones.""" - mock_config_instance = Mock(config_path="/tmp", default_preset="default") - mock_config.get_instance.return_value = mock_config_instance - - # Create preset with invalid CLI that will fail validation - valid_preset = Preset(name="valid", cli="--valid-option") - - # Create invalid preset data - invalid_preset_data = {"name": "invalid"} # Missing ID - - presets_to_save = [valid_preset, invalid_preset_data] - - with tempfile.TemporaryDirectory() as temp_dir: - presets_file = Path(temp_dir) / "presets.json" - presets = Presets(file=presets_file, config=mock_config_instance) - - presets.save(presets_to_save) - - # Should have logged errors for invalid presets - mock_log.error.assert_called() - - @patch("app.library.Presets.Config") - def test_preset_priority_validation_integer(self, mock_config): - """Test that priority must be an integer.""" - mock_config_instance = Mock(config_path="/tmp", default_preset="default") - mock_config.get_instance.return_value = mock_config_instance - - with tempfile.TemporaryDirectory() as temp_dir: - presets_file = Path(temp_dir) / "presets.json" - presets = Presets(file=presets_file, config=mock_config_instance) - - # Test with non-integer priority - invalid_preset = {"id": str(uuid.uuid4()), "name": "test", "priority": "not_an_int"} - - with pytest.raises(ValueError, match="Priority must be an integer"): - presets.validate(invalid_preset) - - @patch("app.library.Presets.Config") - def test_preset_priority_validation_negative(self, mock_config): - """Test that priority must be >= 0.""" - mock_config_instance = Mock(config_path="/tmp", default_preset="default") - mock_config.get_instance.return_value = mock_config_instance - - with tempfile.TemporaryDirectory() as temp_dir: - presets_file = Path(temp_dir) / "presets.json" - presets = Presets(file=presets_file, config=mock_config_instance) - - # Test with negative priority - invalid_preset = {"id": str(uuid.uuid4()), "name": "test", "priority": -5} - - with pytest.raises(ValueError, match="Priority must be >= 0"): - presets.validate(invalid_preset) - - @patch("app.library.Presets.Config") - def test_preset_priority_sorting(self, mock_config): - """Test that presets are sorted by priority in descending order.""" - mock_config_instance = Mock(config_path="/tmp", default_preset="default") - mock_config.get_instance.return_value = mock_config_instance - - with tempfile.TemporaryDirectory() as temp_dir: - presets_file = Path(temp_dir) / "presets.json" - presets = Presets(file=presets_file, config=mock_config_instance) - - # Create presets with different priorities - preset1 = Preset(name="low", priority=1) - preset2 = Preset(name="high", priority=10) - preset3 = Preset(name="medium", priority=5) - - presets.save([preset1, preset2, preset3]).load() - - all_presets = presets.get_all() - non_default = [p for p in all_presets if not p.default] - - assert non_default[0].name == "high", "Should be sorted by priority descending" - assert non_default[0].priority == 10 - assert non_default[1].name == "medium" - assert non_default[1].priority == 5 - assert non_default[2].name == "low" - assert non_default[2].priority == 1 - - @patch("app.library.Presets.Config") - def test_preset_priority_default_zero(self, mock_config): - """Test that priority defaults to 0 if not specified.""" - preset = Preset(name="test") - assert preset.priority == 0 - - @patch("app.library.Presets.Config") - def test_preset_priority_migration(self, mock_config): - """Test that old presets without priority get it added on load.""" - mock_config_instance = Mock(config_path="/tmp", default_preset="default") - mock_config.get_instance.return_value = mock_config_instance - - with tempfile.TemporaryDirectory() as temp_dir: - presets_file = Path(temp_dir) / "presets.json" - - # Create a preset file without priority field - old_preset_data = [{"id": str(uuid.uuid4()), "name": "old_preset", "cli": ""}] - presets_file.write_text(json.dumps(old_preset_data)) - - # Load presets - should migrate by adding priority - presets = Presets(file=presets_file, config=mock_config_instance) - presets.load() - - # Check that priority was added - loaded_data = json.loads(presets_file.read_text()) - assert "priority" in loaded_data[0] - assert loaded_data[0]["priority"] == 0 diff --git a/app/tests/test_ytdlpopts.py b/app/tests/test_ytdlpopts.py index 916aad42..107a078f 100644 --- a/app/tests/test_ytdlpopts.py +++ b/app/tests/test_ytdlpopts.py @@ -4,7 +4,7 @@ from unittest.mock import Mock, patch import pytest -from app.library.Presets import Preset +from app.features.presets.schemas import Preset from app.library.YTDLPOpts import YTDLPOpts @@ -116,7 +116,7 @@ class TestYTDLPOpts: """Test applying a valid preset.""" with ( patch("app.library.YTDLPOpts.Config") as mock_config, - patch("app.library.YTDLPOpts.Presets") as mock_presets, + patch("app.features.presets.service.Presets") as mock_presets, patch("app.library.YTDLPOpts.arg_converter") as mock_converter, ): # Mock config @@ -155,7 +155,7 @@ class TestYTDLPOpts: def test_preset_with_nonexistent_preset(self): """Test applying a nonexistent preset returns self.""" - with patch("app.library.YTDLPOpts.Config"), patch("app.library.YTDLPOpts.Presets") as mock_presets: + with patch("app.library.YTDLPOpts.Config"), patch("app.features.presets.service.Presets") as mock_presets: mock_presets_instance = Mock() mock_presets_instance.get.return_value = None mock_presets.get_instance.return_value = mock_presets_instance @@ -171,7 +171,7 @@ class TestYTDLPOpts: """Test that preset with cookies creates cookie file.""" with ( patch("app.library.YTDLPOpts.Config") as mock_config, - patch("app.library.YTDLPOpts.Presets") as mock_presets, + patch("app.features.presets.service.Presets") as mock_presets, ): # Mock config mock_config_instance = Mock() @@ -209,7 +209,7 @@ class TestYTDLPOpts: """Test that preset with invalid CLI raises ValueError.""" with ( patch("app.library.YTDLPOpts.Config"), - patch("app.library.YTDLPOpts.Presets") as mock_presets, + patch("app.features.presets.service.Presets") as mock_presets, patch("app.library.YTDLPOpts.arg_converter") as mock_converter, ): mock_preset = Mock(spec=Preset) @@ -401,7 +401,7 @@ class TestYTDLPOpts: with ( patch("app.library.YTDLPOpts.Config"), patch("app.library.YTDLPOpts.arg_converter"), - patch("app.library.YTDLPOpts.Presets") as mock_presets, + patch("app.features.presets.service.Presets") as mock_presets, ): mock_presets_instance = Mock() mock_presets_instance.get.return_value = None @@ -441,7 +441,7 @@ class TestYTDLPOpts: """Test error handling when cookie loading fails.""" with ( patch("app.library.YTDLPOpts.Config") as mock_config, - patch("app.library.YTDLPOpts.Presets") as mock_presets, + patch("app.features.presets.service.Presets") as mock_presets, patch("app.library.YTDLPOpts.LOG") as mock_log, ): # Mock config @@ -741,7 +741,7 @@ class TestARGSMerger: class TestYTDLPCli: """Test the YTDLPCli class.""" - @patch("app.library.Presets.Presets") + @patch("app.features.presets.service.Presets") @patch("app.library.YTDLPOpts.Config") def test_constructor_with_valid_item(self, mock_config, mock_presets): """Test YTDLPCli constructor with valid Item.""" @@ -770,7 +770,7 @@ class TestYTDLPCli: with pytest.raises(ValueError, match="Expected Item instance"): YTDLPCli(item="not an item") # type: ignore - @patch("app.library.Presets.Presets") + @patch("app.features.presets.service.Presets") @patch("app.library.YTDLPOpts.Config") def test_build_with_user_fields_only(self, mock_config, mock_presets): """Test build with only user-provided fields.""" @@ -805,12 +805,12 @@ class TestYTDLPCli: assert info["merged"]["save_path"] == "/downloads/myfolder" assert "--format best" in command - @patch("app.library.Presets.Presets") + @patch("app.features.presets.service.Presets") @patch("app.library.YTDLPOpts.Config") def test_build_with_preset_fields_fallback(self, mock_config, mock_presets): """Test build falls back to preset fields when user doesn't provide them.""" from app.library.ItemDTO import Item - from app.library.Presets import Preset + from app.features.presets.schemas import Preset from app.library.YTDLPOpts import YTDLPCli mock_config_instance = Mock() @@ -839,12 +839,12 @@ class TestYTDLPCli: assert info["merged"]["save_path"] == "/downloads/preset_folder" assert "--format 720p" in command - @patch("app.library.Presets.Presets") + @patch("app.features.presets.service.Presets") @patch("app.library.YTDLPOpts.Config") def test_build_user_fields_override_preset(self, mock_config, mock_presets): """Test that user fields override preset fields.""" from app.library.ItemDTO import Item - from app.library.Presets import Preset + from app.features.presets.schemas import Preset from app.library.YTDLPOpts import YTDLPCli mock_config_instance = Mock() @@ -878,7 +878,7 @@ class TestYTDLPCli: assert info["merged"]["save_path"] == "/downloads/user_folder" assert "--format best" in command, "User CLI should appear after preset CLI in command" - @patch("app.library.Presets.Presets") + @patch("app.features.presets.service.Presets") @patch("app.library.YTDLPOpts.Config") def test_build_with_default_fallback(self, mock_config, mock_presets): """Test build falls back to defaults when neither user nor preset provide fields.""" @@ -903,7 +903,7 @@ class TestYTDLPCli: assert info["merged"]["template"] == "%(title)s.%(ext)s" assert info["merged"]["save_path"] == "/default/downloads" - @patch("app.library.Presets.Presets") + @patch("app.features.presets.service.Presets") @patch("app.library.YTDLPOpts.create_cookies_file") @patch("app.library.YTDLPOpts.Config") def test_build_with_cookies_from_user(self, mock_config, mock_create_cookies, mock_presets): @@ -933,12 +933,12 @@ class TestYTDLPCli: assert "/tmp/cookies.txt" in command assert info["merged"]["cookie_file"] == "/tmp/cookies.txt" - @patch("app.library.Presets.Presets") + @patch("app.features.presets.service.Presets") @patch("app.library.YTDLPOpts.Config") def test_build_with_cookies_from_preset(self, mock_config, mock_presets): """Test build with cookies from preset when user doesn't provide.""" from app.library.ItemDTO import Item - from app.library.Presets import Preset + from app.features.presets.schemas import Preset from app.library.YTDLPOpts import YTDLPCli mock_config_instance = Mock() @@ -969,7 +969,7 @@ class TestYTDLPCli: assert "--cookies" in command assert "/preset/cookies.txt" in command - @patch("app.library.Presets.Presets") + @patch("app.features.presets.service.Presets") @patch("app.library.YTDLPOpts.Config") def test_build_with_absolute_folder_path(self, mock_config, mock_presets): """Test build with absolute folder path from user.""" @@ -996,7 +996,7 @@ class TestYTDLPCli: ) assert "--paths" in command - @patch("app.library.Presets.Presets") + @patch("app.features.presets.service.Presets") @patch("app.library.YTDLPOpts.Config") def test_build_includes_url_in_command(self, mock_config, mock_presets): """Test that build includes the URL in the final command.""" @@ -1018,12 +1018,12 @@ class TestYTDLPCli: assert "https://youtube.com/watch?v=test123" in command - @patch("app.library.Presets.Presets") + @patch("app.features.presets.service.Presets") @patch("app.library.YTDLPOpts.Config") def test_build_cli_args_priority_order(self, mock_config, mock_presets): """Test that CLI args are added in correct priority order (preset first, user last).""" from app.library.ItemDTO import Item - from app.library.Presets import Preset + from app.features.presets.schemas import Preset from app.library.YTDLPOpts import YTDLPCli mock_config_instance = Mock() diff --git a/ui/app/components/NewDownload.vue b/ui/app/components/NewDownload.vue index b6f41029..b3f25082 100644 --- a/ui/app/components/NewDownload.vue +++ b/ui/app/components/NewDownload.vue @@ -38,7 +38,7 @@