From 5c913ba88bc4a5cadba6ff8be75c3278d238e607 Mon Sep 17 00:00:00 2001 From: Thomas Willems Date: Sat, 17 Jan 2026 12:37:46 +0100 Subject: [PATCH 1/7] Stremio plan --- docs/stremio-catalogs-plan.md | 163 ++++++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 docs/stremio-catalogs-plan.md diff --git a/docs/stremio-catalogs-plan.md b/docs/stremio-catalogs-plan.md new file mode 100644 index 0000000..22be8e5 --- /dev/null +++ b/docs/stremio-catalogs-plan.md @@ -0,0 +1,163 @@ +# Stremio Catalogs Plan + +## Goals +- Provide a Stremio addon that exposes each user's watchlist as catalogs. +- Support user-defined filters (unwatched, released, combinations) and ordering (date added, release date, random, etc.). +- Add a TV-only "In progress" catalog for shows with unwatched released episodes. +- Enable custom catalogs with fixed, user-curated media items. +- Offer install options: direct install link and copyable manifest URL. +- Ship a responsive frontend for configuration + management. + +## Non-goals (for this phase) +- Streaming sources, meta enrichers, or providers beyond the local catalog. +- Replacing existing watchlist UI or watchlist data model. + +## Reference/Research +- Review Stremio addon manifest/catalog spec and best practices. +- Inspect any local Stremio-related code and the aiometadata/aiostreams addon patterns for response structure and pagination. + +## High-level UX +- Add a new burger-menu entry below Settings: "Stremio Addon". +- New page `/stremio-addon` with: + - Install section (manifest URL + stremio:// link). + - Addon key management (show + rotate). + - Built-in catalogs (Watchlist, In progress) with filter + order controls. + - Custom catalogs (CRUD + add/remove items). + +## Backend Design + +### Data Model +Add new tables (or extend existing ones) to capture per-user addon config and custom catalogs. + +Suggested tables: +- `stremio_addon_configs` + - `user_id` (FK) + - `is_enabled` (bool) + - `addon_key_hash` (string) + `addon_key_last_rotated_at` + - `default_catalogs` (JSON): list of catalog definitions (id, name, media_type, filters, ordering, enabled) + - `created_at`, `updated_at` + +- `stremio_custom_catalogs` + - `id`, `user_id` (FK) + - `name`, `slug` + - `media_type` (movie/tv/anime/all) + - `order_by`, `order_dir` + - `created_at`, `updated_at` + +- `stremio_custom_catalog_items` + - `catalog_id` (FK) + - `media_item_id` (FK) + - `position` (int) for manual ordering + - `created_at` + +Notes: +- Addon access is key-based (no JWT). Store addon keys hashed; keep plaintext only at creation/rotation time. +- If you prefer fewer tables, we can encode the built-in catalogs in JSON on the config table and only create a table for custom catalogs/items. + +### Internal API (auth-required) +Create endpoints under `/api/stremio-addon`: +- `GET /api/stremio-addon/config` + - Returns manifest URL, install link, enabled state, catalogs config, and custom catalogs. +- `POST /api/stremio-addon/config` + - Updates addon enablement and built-in catalog filters/order. +- `POST /api/stremio-addon/token/rotate` + - Rotates addon key and returns new manifest URL. +- Custom catalogs CRUD: + - `POST /api/stremio-addon/custom-catalogs` + - `PATCH /api/stremio-addon/custom-catalogs/{catalog_id}` + - `DELETE /api/stremio-addon/custom-catalogs/{catalog_id}` + - `POST /api/stremio-addon/custom-catalogs/{catalog_id}/items` + - `DELETE /api/stremio-addon/custom-catalogs/{catalog_id}/items/{media_item_id}` + - Optional: `POST /api/stremio-addon/custom-catalogs/{catalog_id}/reorder` + +### Addon (public) Routes +Add a new router with public endpoints; all access is gated by the addon key in the URL. + +Suggested URL scheme: +- `GET /stremio-addon/{addon_key}/manifest.json` +- `GET /stremio-addon/{addon_key}/catalog/{type}/{id}.json` +- (Optional) `GET /stremio-addon/{addon_key}/meta/{type}/{id}.json` + +Notes: +- Use Stremio types: `movie` and `series`. Map `tv` and `anime` to `series`. +- Manifest should include multiple catalogs (one addon, many catalogs). +- Catalog endpoint should support `extra` params (`skip`, `limit`, `search`) per Stremio spec. +- Random ordering should be fully random per request (no caching for now; add later if needed). +- Support Stremio pagination via `skip` + `limit` on every catalog response. + +### Catalog Definitions +Built-in catalogs (user-configurable): +- `watchlist_movies` +- `watchlist_shows` +- `watchlist_anime` (optional, map to series) +- `in_progress_shows` + +Custom catalogs: +- Each catalog has a stable `id` (slug or UUID) and `name`. +- Items are explicit `media_item_id` entries. +- Custom catalogs are separate from the watchlist. + +### Filters and Ordering +Filters should align with existing watchlist status logic: +- Unwatched: statuses in `added`, `in_progress`, `not_released` (exclude `watched`, `waiting`, `removed`). +- Released: user-selectable basis for shows: + - by show air date (`first_air_date`), or + - by per-episode air dates (released episodes only). +- Status combinations: map to existing watchlist status rules in `core/watchlist.py`. + +Ordering: +- Reuse `core/catalog_ordering.apply_catalog_ordering` for date added, release date, last watched, progress. +- Extend ordering to include `random` and optional `title` or `manual` (custom catalogs). + +### Query Strategy +- Base query: join `watchlist_items` -> `media_items` for watchlist-based catalogs. +- In-progress TV query: + - Use existing progress subqueries to detect shows with released episodes left. + - Filter where `watched_count > 0` and `watched_count < total_released`. +- Custom catalogs: + - Join `stremio_custom_catalog_items` -> `media_items`. + - Support manual ordering by `position`, fallback to created_at. + +## Frontend Plan +- Add nav entry in `backend/src/librarysync/templates/base.html`. +- Create `backend/src/librarysync/templates/stremio-addon.html`. +- Create `backend/src/librarysync/static/page-stremio-addon.js`. +- Update `frontend/input.css` (do not edit `backend/src/librarysync/static/styles.css` directly). + +UI sections: +1. Install + - Manifest URL (copy button). + - Direct install link (stremio://... per spec). + - Rotate key button. +2. Built-in catalogs + - Toggle enable/disable. + - Filters: unwatched, released, include watched, media type. + - Ordering: date added, release date, last watched, progress, random. +3. In progress (TV) + - Dedicated block with ordering controls and enable toggle. +4. Custom catalogs + - Create/edit/delete catalogs. + - Add items via search (reuse blacklist-style search UX). + - Reorder items (drag/drop or up/down). + +## Security + Access +- Addon access uses a per-user addon key (random, rotatable); no auth cookies. +- Store addon key hashed; verify using constant-time compare. +- Enforce `is_enabled` flag; return 404/401 for disabled or invalid keys. + +## Testing +- Unit tests for: + - Manifest generation includes expected catalogs. + - Catalog filters (unwatched, released, in-progress) and ordering. + - Custom catalog CRUD and item ordering. + - Addon key rotation and access control. +- Integration tests for Stremio endpoints (FastAPI test client). + +## Rollout Steps +- Add migrations for new tables/columns. +- Deploy backend endpoints and UI. +- Verify with a test Stremio client install link. +- Monitor catalog performance and adjust cache TTL or indexes. + +## Open Questions +None. From d270be2f839884061a25bd113e629f644d9fb64c Mon Sep 17 00:00:00 2001 From: Thomas Willems Date: Sat, 17 Jan 2026 15:06:36 +0100 Subject: [PATCH 2/7] Add Stremio addon scaffolding --- .../librarysync/api/routes_stremio_addon.py | 183 ++++++++ .../api/routes_stremio_addon_public.py | 443 ++++++++++++++++++ backend/src/librarysync/core/stremio_addon.py | 128 +++++ .../c9a6b5d7e8f1_add_stremio_addon_tables.py | 153 ++++++ backend/src/librarysync/db/models.py | 88 ++++ backend/src/librarysync/main.py | 5 + docs/stremio-catalogs-plan.md | 35 ++ 7 files changed, 1035 insertions(+) create mode 100644 backend/src/librarysync/api/routes_stremio_addon.py create mode 100644 backend/src/librarysync/api/routes_stremio_addon_public.py create mode 100644 backend/src/librarysync/core/stremio_addon.py create mode 100644 backend/src/librarysync/db/migrations/versions/c9a6b5d7e8f1_add_stremio_addon_tables.py diff --git a/backend/src/librarysync/api/routes_stremio_addon.py b/backend/src/librarysync/api/routes_stremio_addon.py new file mode 100644 index 0000000..ab5b2c2 --- /dev/null +++ b/backend/src/librarysync/api/routes_stremio_addon.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Literal + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from librarysync.api.deps import get_current_user, get_db +from librarysync.config import settings +from librarysync.core.stremio_addon import ( + build_default_catalogs, + ensure_addon_config, + rotate_addon_key, +) +from librarysync.db.models import StremioAddonConfig, StremioCustomCatalog, User + +router = APIRouter(prefix="/api/stremio-addon", tags=["stremio-addon"]) + + +class StremioCatalogFilters(BaseModel): + statuses: list[str] | None = None + + +class StremioCatalogOrdering(BaseModel): + order_by: str | None = None + order_dir: Literal["asc", "desc"] | None = None + + +class StremioCatalogUpdate(BaseModel): + id: str + enabled: bool | None = None + filters: StremioCatalogFilters | None = None + ordering: StremioCatalogOrdering | None = None + + +class StremioAddonConfigUpdate(BaseModel): + is_enabled: bool | None = None + catalogs: list[StremioCatalogUpdate] | None = None + + +def _resolve_base_url(request: Request) -> str: + if settings.base_url: + return settings.base_url.rstrip("/") + return str(request.base_url).rstrip("/") + + +def _build_manifest_links(base_url: str, addon_key: str) -> dict[str, str]: + manifest_url = f"{base_url}/stremio-addon/{addon_key}/manifest.json" + install_url = f"stremio://{manifest_url}" + return {"manifest_url": manifest_url, "install_url": install_url} + + +def _merge_catalog_updates( + existing: list[dict], + updates: list[StremioCatalogUpdate], +) -> list[dict]: + by_id = {catalog.get("id"): catalog for catalog in existing if catalog.get("id")} + for update in updates: + catalog = by_id.get(update.id) + if not catalog: + continue + if update.enabled is not None: + catalog["enabled"] = bool(update.enabled) + if update.filters is not None: + catalog["filters"] = update.filters.model_dump(exclude_none=True) + if update.ordering is not None: + catalog["ordering"] = update.ordering.model_dump(exclude_none=True) + return list(by_id.values()) + + +async def _ensure_default_catalogs( + db: AsyncSession, + config: StremioAddonConfig, +) -> list[dict]: + catalogs = config.default_catalogs + if not catalogs: + catalogs = build_default_catalogs() + config.default_catalogs = catalogs + db.add(config) + await db.commit() + await db.refresh(config) + return catalogs + + +@router.get( + "/config", + summary="Get Stremio addon config", + description="Return the Stremio addon config and install links.", +) +async def get_stremio_addon_config( + request: Request, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict: + config, addon_key = await ensure_addon_config(db, current_user.id) + catalogs = await _ensure_default_catalogs(db, config) + custom_result = await db.execute( + select(StremioCustomCatalog).where(StremioCustomCatalog.user_id == current_user.id) + ) + custom_catalogs = [ + { + "id": catalog.id, + "name": catalog.name, + "slug": catalog.slug, + "media_type": catalog.media_type, + "order_by": catalog.order_by, + "order_dir": catalog.order_dir, + "created_at": catalog.created_at, + "updated_at": catalog.updated_at, + } + for catalog in custom_result.scalars().all() + ] + payload: dict[str, object] = { + "is_enabled": bool(config.is_enabled), + "addon_key_last_rotated_at": config.addon_key_last_rotated_at, + "catalogs": catalogs, + "custom_catalogs": custom_catalogs, + } + if addon_key: + payload["addon_key"] = addon_key + payload.update(_build_manifest_links(_resolve_base_url(request), addon_key)) + return payload + + +@router.post( + "/config", + summary="Update Stremio addon config", + description="Update Stremio addon settings and catalog filters/order.", +) +async def update_stremio_addon_config( + payload: StremioAddonConfigUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict: + result = await db.execute( + select(StremioAddonConfig).where(StremioAddonConfig.user_id == current_user.id) + ) + config = result.scalars().first() + if not config: + config, _ = await ensure_addon_config(db, current_user.id) + catalogs = await _ensure_default_catalogs(db, config) + + fields = payload.model_fields_set + if "is_enabled" in fields: + config.is_enabled = bool(payload.is_enabled) + if payload.catalogs: + catalogs = _merge_catalog_updates(catalogs, payload.catalogs) + config.default_catalogs = catalogs + config.updated_at = datetime.now(timezone.utc) + db.add(config) + await db.commit() + await db.refresh(config) + return { + "is_enabled": config.is_enabled, + "catalogs": config.default_catalogs, + } + + +@router.post( + "/token/rotate", + summary="Rotate Stremio addon key", + description="Rotate the per-user addon key and return install links.", +) +async def rotate_stremio_addon_token( + request: Request, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict: + result = await db.execute( + select(StremioAddonConfig).where(StremioAddonConfig.user_id == current_user.id) + ) + config = result.scalars().first() + if not config: + config, _ = await ensure_addon_config(db, current_user.id) + addon_key = await rotate_addon_key(db, config) + return { + "addon_key": addon_key, + "addon_key_last_rotated_at": config.addon_key_last_rotated_at, + **_build_manifest_links(_resolve_base_url(request), addon_key), + } diff --git a/backend/src/librarysync/api/routes_stremio_addon_public.py b/backend/src/librarysync/api/routes_stremio_addon_public.py new file mode 100644 index 0000000..afc8da4 --- /dev/null +++ b/backend/src/librarysync/api/routes_stremio_addon_public.py @@ -0,0 +1,443 @@ +from __future__ import annotations + +from datetime import date, datetime, timezone +from importlib import metadata +from typing import Any, Literal + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy import func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from librarysync.api.deps import get_db +from librarysync.core.catalog_ordering import apply_catalog_ordering +from librarysync.core.stremio_addon import build_default_catalogs, get_addon_config_by_key +from librarysync.db.models import ( + EpisodeItem, + MediaItem, + StremioCustomCatalog, + StremioCustomCatalogItem, + WatchedItem, + WatchlistItem, +) + +router = APIRouter(prefix="/stremio-addon", tags=["stremio-addon"]) + +STREMIO_EXTRA = ["search", "skip", "limit"] +MAX_LIMIT = 100 + + +def _get_app_version() -> str: + try: + return metadata.version("librarysync") + except metadata.PackageNotFoundError: + return "unknown" + + +def _resolve_meta_id(media_item: MediaItem) -> str | None: + raw = media_item.raw if isinstance(media_item.raw, dict) else {} + stremio_id = raw.get("stremio_id") + if not stremio_id: + stremio_payload = raw.get("stremio") + if isinstance(stremio_payload, dict): + stremio_id = stremio_payload.get("id") or stremio_payload.get("_id") + if stremio_id: + return str(stremio_id) + if media_item.imdb_id: + return media_item.imdb_id + return None + + +def _resolve_stremio_type(media_type: str) -> Literal["movie", "series"] | None: + if media_type == "movie": + return "movie" + if media_type in {"tv", "anime", "series"}: + return "series" + return None + + +def _build_meta(media_item: MediaItem, catalog_type: str) -> dict[str, Any] | None: + stremio_id = _resolve_meta_id(media_item) + stremio_type = _resolve_stremio_type(media_item.media_type) + if not stremio_id or not stremio_type or stremio_type != catalog_type: + return None + meta = { + "id": stremio_id, + "type": stremio_type, + "name": media_item.title, + } + if media_item.poster_url: + meta["poster"] = media_item.poster_url + if media_item.year: + meta["year"] = media_item.year + return meta + + +def _extract_extra_param(request: Request, name: str) -> str | None: + return request.query_params.get(name) or request.query_params.get(f"extra[{name}]") + + +def _parse_int_param(value: str | None, default: int) -> int: + if not value: + return default + try: + parsed = int(value) + except ValueError: + return default + return parsed + + +def _apply_search_filter(query, search: str): + normalized_search = search.strip() + if not normalized_search: + return query + like_value = f"%{normalized_search}%" + search_clauses = [ + MediaItem.title.ilike(like_value), + MediaItem.imdb_id.ilike(like_value), + MediaItem.tmdb_id.ilike(like_value), + MediaItem.tvdb_id.ilike(like_value), + MediaItem.tvmaze_id.ilike(like_value), + MediaItem.kitsu_id.ilike(like_value), + MediaItem.myanimelist_id.ilike(like_value), + MediaItem.anilist_id.ilike(like_value), + ] + if normalized_search.isdigit() and len(normalized_search) == 4: + search_clauses.append(MediaItem.year == int(normalized_search)) + return query.where(or_(*search_clauses)) + + +def _resolve_pagination(request: Request) -> tuple[int, int, str | None]: + search = _extract_extra_param(request, "search") + skip = _parse_int_param(_extract_extra_param(request, "skip"), 0) + limit = _parse_int_param(_extract_extra_param(request, "limit"), 50) + if skip < 0: + skip = 0 + if limit <= 0: + limit = 50 + if limit > MAX_LIMIT: + limit = MAX_LIMIT + return skip, limit, search + + +def _normalize_catalogs(config_catalogs: list[dict] | None) -> list[dict]: + if not config_catalogs: + return build_default_catalogs() + return config_catalogs + + +def _catalogs_by_id(catalogs: list[dict]) -> dict[str, dict]: + return {catalog.get("id"): catalog for catalog in catalogs if catalog.get("id")} + + +def _build_manifest( + base_url: str, + addon_key: str, + catalogs: list[dict], + custom_catalogs: list[StremioCustomCatalog], +) -> dict[str, Any]: + manifest_catalogs: list[dict[str, Any]] = [] + seen_types: set[str] = set() + + for catalog in catalogs: + if not catalog.get("enabled", True): + continue + catalog_id = catalog.get("id") + if not catalog_id: + continue + media_type = catalog.get("media_type") + stremio_type = _resolve_stremio_type(str(media_type)) + if not stremio_type: + continue + manifest_catalogs.append( + { + "type": stremio_type, + "id": catalog_id, + "name": catalog.get("name") or catalog_id, + "extraSupported": STREMIO_EXTRA, + } + ) + seen_types.add(stremio_type) + + for custom in custom_catalogs: + stremio_type = _resolve_stremio_type(custom.media_type) + if not stremio_type: + continue + manifest_catalogs.append( + { + "type": stremio_type, + "id": custom.slug, + "name": custom.name, + "extraSupported": STREMIO_EXTRA, + } + ) + seen_types.add(stremio_type) + + return { + "id": "org.librarysync.catalogs", + "version": _get_app_version(), + "name": "librarySync Watchlists", + "description": "Personal watchlist catalogs from librarySync.", + "resources": ["catalog"], + "types": sorted(seen_types) if seen_types else ["movie", "series"], + "catalogs": manifest_catalogs, + } + + +async def _build_watchlist_query( + user_id: str, + catalog: dict, + search: str | None, +): + query = ( + select(MediaItem) + .join(WatchlistItem, WatchlistItem.media_item_id == MediaItem.id) + .where( + WatchlistItem.user_id == user_id, + WatchlistItem.status != "removed", + ) + ) + media_type = catalog.get("media_type") + if media_type: + normalized = str(media_type) + if normalized == "series": + query = query.where(WatchlistItem.type.in_(["tv", "anime"])) + else: + query = query.where(WatchlistItem.type == normalized) + filters = catalog.get("filters") if isinstance(catalog.get("filters"), dict) else {} + statuses = filters.get("statuses") if isinstance(filters, dict) else None + if statuses: + query = query.where(WatchlistItem.status.in_(statuses)) + if search: + query = _apply_search_filter(query, search) + return query + + +def _build_progress_subquery(user_id: str, now_date: date): + base = ( + select(EpisodeItem.show_media_item_id.label("media_item_id")) + .where(EpisodeItem.show_media_item_id.is_not(None)) + .group_by(EpisodeItem.show_media_item_id) + .subquery() + ) + released_subq = ( + select( + EpisodeItem.show_media_item_id.label("media_item_id"), + func.count(EpisodeItem.id).label("total_released"), + ) + .where( + EpisodeItem.air_date.is_not(None), + EpisodeItem.air_date <= now_date, + EpisodeItem.season_number > 0, + ) + .group_by(EpisodeItem.show_media_item_id) + .subquery() + ) + watched_subq = ( + select( + EpisodeItem.show_media_item_id.label("media_item_id"), + func.count(func.distinct(WatchedItem.episode_item_id)).label("watched_count"), + ) + .join(WatchedItem, WatchedItem.episode_item_id == EpisodeItem.id) + .where( + WatchedItem.user_id == user_id, + WatchedItem.media_item_id.is_(None), + EpisodeItem.air_date.is_not(None), + EpisodeItem.air_date <= now_date, + EpisodeItem.season_number > 0, + ) + .group_by(EpisodeItem.show_media_item_id) + .subquery() + ) + return ( + select( + base.c.media_item_id, + func.coalesce(released_subq.c.total_released, 0).label("total_released"), + func.coalesce(watched_subq.c.watched_count, 0).label("watched_count"), + ) + .select_from(base) + .outerjoin(released_subq, released_subq.c.media_item_id == base.c.media_item_id) + .outerjoin(watched_subq, watched_subq.c.media_item_id == base.c.media_item_id) + .subquery() + ) + + +async def _build_in_progress_query( + user_id: str, + search: str | None, +): + now_date = datetime.now(timezone.utc).date() + progress_subq = _build_progress_subquery(user_id, now_date) + query = ( + select(MediaItem) + .join(WatchlistItem, WatchlistItem.media_item_id == MediaItem.id) + .outerjoin(progress_subq, progress_subq.c.media_item_id == MediaItem.id) + .where( + WatchlistItem.user_id == user_id, + WatchlistItem.status != "removed", + WatchlistItem.type.in_(["tv", "anime"]), + MediaItem.media_type.in_(["tv", "anime"]), + progress_subq.c.total_released > 0, + progress_subq.c.watched_count > 0, + progress_subq.c.watched_count < progress_subq.c.total_released, + ) + ) + if search: + query = _apply_search_filter(query, search) + return query + + +async def _build_custom_catalog_query( + catalog: StremioCustomCatalog, + search: str | None, +): + query = ( + select(MediaItem) + .join( + StremioCustomCatalogItem, + StremioCustomCatalogItem.media_item_id == MediaItem.id, + ) + .where(StremioCustomCatalogItem.catalog_id == catalog.id) + ) + if search: + query = _apply_search_filter(query, search) + return query + + +def _apply_ordering( + query, + *, + order_by: str, + order_dir: str, + user_id: str, + date_added_col, + release_date_col, + base_media_id_col, + tie_breaker_col=None, +): + if order_by == "random": + return query.order_by(func.random()) + return apply_catalog_ordering( + query, + order_by=order_by, + order_dir=order_dir, + user_id=user_id, + date_added_col=date_added_col, + release_date_col=release_date_col, + base_media_id_col=base_media_id_col, + tie_breaker_col=tie_breaker_col, + ) + + +@router.get("/{addon_key}/manifest.json", include_in_schema=False) +async def stremio_addon_manifest( + addon_key: str, + request: Request, + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + config = await get_addon_config_by_key(db, addon_key) + if not config or not config.is_enabled: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") + catalogs = _normalize_catalogs(config.default_catalogs) + custom_result = await db.execute( + select(StremioCustomCatalog).where(StremioCustomCatalog.user_id == config.user_id) + ) + custom_catalogs = custom_result.scalars().all() + base_url = str(request.base_url).rstrip("/") + return _build_manifest(base_url, addon_key, catalogs, custom_catalogs) + + +@router.get("/{addon_key}/catalog/{catalog_type}/{catalog_id}.json", include_in_schema=False) +async def stremio_addon_catalog( + addon_key: str, + catalog_type: Literal["movie", "series"], + catalog_id: str, + request: Request, + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + config = await get_addon_config_by_key(db, addon_key) + if not config or not config.is_enabled: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") + + catalogs = _normalize_catalogs(config.default_catalogs) + catalogs_by_id = _catalogs_by_id(catalogs) + catalog = catalogs_by_id.get(catalog_id) + custom_catalog = None + if not catalog: + custom_result = await db.execute( + select(StremioCustomCatalog).where( + StremioCustomCatalog.user_id == config.user_id, + StremioCustomCatalog.slug == catalog_id, + ) + ) + custom_catalog = custom_result.scalars().first() + if not custom_catalog: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Catalog not found") + expected_type = _resolve_stremio_type(custom_catalog.media_type) + if expected_type != catalog_type: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Catalog not found") + else: + if not catalog.get("enabled", True): + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Catalog not found") + expected_type = _resolve_stremio_type(str(catalog.get("media_type"))) + if expected_type != catalog_type: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Catalog not found") + + skip, limit, search = _resolve_pagination(request) + + if custom_catalog: + query = await _build_custom_catalog_query(custom_catalog, search) + order_by = custom_catalog.order_by or "manual" + order_dir = custom_catalog.order_dir or "asc" + if order_by == "manual": + query = query.order_by( + StremioCustomCatalogItem.position.asc(), StremioCustomCatalogItem.created_at.asc() + ) + else: + release_date_expr = func.coalesce(MediaItem.release_date, MediaItem.first_air_date) + query = _apply_ordering( + query, + order_by=order_by, + order_dir=order_dir, + user_id=config.user_id, + date_added_col=StremioCustomCatalogItem.created_at, + release_date_col=release_date_expr, + base_media_id_col=MediaItem.id, + tie_breaker_col=MediaItem.id, + ) + else: + if catalog_id == "in_progress_shows": + query = await _build_in_progress_query(config.user_id, search) + else: + query = await _build_watchlist_query(config.user_id, catalog, search) + ordering = catalog.get("ordering") if isinstance(catalog.get("ordering"), dict) else {} + order_by = str(ordering.get("order_by") or "date_added") + order_dir = str(ordering.get("order_dir") or "desc") + release_date_expr = func.coalesce(MediaItem.release_date, MediaItem.first_air_date) + query = _apply_ordering( + query, + order_by=order_by, + order_dir=order_dir, + user_id=config.user_id, + date_added_col=WatchlistItem.created_at, + release_date_col=release_date_expr, + base_media_id_col=MediaItem.id, + tie_breaker_col=MediaItem.id, + ) + + query = query.offset(skip).limit(limit + 1) + result = await db.execute(query) + media_items = result.scalars().all() + has_more = len(media_items) > limit + if has_more: + media_items = media_items[:limit] + + metas: list[dict[str, Any]] = [] + for media in media_items: + meta = _build_meta(media, catalog_type) + if meta: + metas.append(meta) + + payload: dict[str, Any] = {"metas": metas} + if has_more: + payload["hasMore"] = True + return payload diff --git a/backend/src/librarysync/core/stremio_addon.py b/backend/src/librarysync/core/stremio_addon.py new file mode 100644 index 0000000..347ed10 --- /dev/null +++ b/backend/src/librarysync/core/stremio_addon.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import copy +import hashlib +import hmac +import secrets +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from librarysync.config import settings +from librarysync.db.models import StremioAddonConfig + +DEFAULT_CATALOGS: list[dict[str, Any]] = [ + { + "id": "watchlist_movies", + "name": "Watchlist Movies", + "media_type": "movie", + "enabled": True, + "filters": {"statuses": ["added", "in_progress", "not_released"]}, + "ordering": {"order_by": "date_added", "order_dir": "desc"}, + }, + { + "id": "watchlist_shows", + "name": "Watchlist Shows", + "media_type": "tv", + "enabled": True, + "filters": {"statuses": ["added", "in_progress", "not_released"]}, + "ordering": {"order_by": "date_added", "order_dir": "desc"}, + }, + { + "id": "watchlist_anime", + "name": "Watchlist Anime", + "media_type": "anime", + "enabled": False, + "filters": {"statuses": ["added", "in_progress", "not_released"]}, + "ordering": {"order_by": "date_added", "order_dir": "desc"}, + }, + { + "id": "in_progress_shows", + "name": "In Progress", + "media_type": "tv", + "enabled": True, + "filters": {"statuses": ["added", "in_progress", "not_released"]}, + "ordering": {"order_by": "episodes_left", "order_dir": "asc"}, + }, +] + + +def build_default_catalogs() -> list[dict[str, Any]]: + return copy.deepcopy(DEFAULT_CATALOGS) + + +def generate_addon_key() -> str: + return secrets.token_urlsafe(32) + + +def hash_addon_key(addon_key: str) -> str: + if not settings.secret_key: + raise RuntimeError("LIBRARYSYNC_SECRET_KEY is not set") + return hmac.new( + settings.secret_key.encode("utf-8"), + addon_key.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + +async def get_addon_config_by_user( + db: AsyncSession, + user_id: str, +) -> StremioAddonConfig | None: + result = await db.execute( + select(StremioAddonConfig).where(StremioAddonConfig.user_id == user_id) + ) + return result.scalars().first() + + +async def get_addon_config_by_key( + db: AsyncSession, + addon_key: str, +) -> StremioAddonConfig | None: + key_hash = hash_addon_key(addon_key) + result = await db.execute( + select(StremioAddonConfig).where(StremioAddonConfig.addon_key_hash == key_hash) + ) + config = result.scalars().first() + if not config: + return None + if not hmac.compare_digest(config.addon_key_hash, key_hash): + return None + return config + + +async def ensure_addon_config( + db: AsyncSession, + user_id: str, +) -> tuple[StremioAddonConfig, str | None]: + config = await get_addon_config_by_user(db, user_id) + if config: + return config, None + addon_key = generate_addon_key() + now = datetime.now(timezone.utc) + config = StremioAddonConfig( + user_id=user_id, + is_enabled=True, + addon_key_hash=hash_addon_key(addon_key), + addon_key_last_rotated_at=now, + default_catalogs=build_default_catalogs(), + ) + db.add(config) + await db.commit() + await db.refresh(config) + return config, addon_key + + +async def rotate_addon_key( + db: AsyncSession, + config: StremioAddonConfig, +) -> str: + addon_key = generate_addon_key() + config.addon_key_hash = hash_addon_key(addon_key) + config.addon_key_last_rotated_at = datetime.now(timezone.utc) + db.add(config) + await db.commit() + await db.refresh(config) + return addon_key diff --git a/backend/src/librarysync/db/migrations/versions/c9a6b5d7e8f1_add_stremio_addon_tables.py b/backend/src/librarysync/db/migrations/versions/c9a6b5d7e8f1_add_stremio_addon_tables.py new file mode 100644 index 0000000..54480c8 --- /dev/null +++ b/backend/src/librarysync/db/migrations/versions/c9a6b5d7e8f1_add_stremio_addon_tables.py @@ -0,0 +1,153 @@ +"""add stremio addon tables + +Revision ID: c9a6b5d7e8f1 +Revises: 34ca9376da38 +Create Date: 2026-02-01 00:00:00.000000 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "c9a6b5d7e8f1" +down_revision = "34ca9376da38" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "stremio_addon_configs", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("user_id", sa.String(length=36), nullable=False), + sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("addon_key_hash", sa.String(length=64), nullable=False), + sa.Column( + "addon_key_last_rotated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.Column("default_catalogs", sa.JSON(), nullable=True), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", name="uq_stremio_addon_configs_user_id"), + sa.UniqueConstraint( + "addon_key_hash", name="uq_stremio_addon_configs_addon_key_hash" + ), + ) + op.create_index( + "ix_stremio_addon_configs_addon_key_hash", + "stremio_addon_configs", + ["addon_key_hash"], + ) + op.create_index( + op.f("ix_stremio_addon_configs_user_id"), + "stremio_addon_configs", + ["user_id"], + ) + + op.create_table( + "stremio_custom_catalogs", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("user_id", sa.String(length=36), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("slug", sa.String(length=64), nullable=False), + sa.Column("media_type", sa.String(length=32), nullable=False, server_default="movie"), + sa.Column("order_by", sa.String(length=32), nullable=False, server_default="manual"), + sa.Column("order_dir", sa.String(length=8), nullable=False, server_default="asc"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "slug", name="uq_stremio_custom_catalogs_user_slug"), + ) + op.create_index( + op.f("ix_stremio_custom_catalogs_user_id"), + "stremio_custom_catalogs", + ["user_id"], + ) + + op.create_table( + "stremio_custom_catalog_items", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("catalog_id", sa.String(length=36), nullable=False), + sa.Column("media_item_id", sa.String(length=36), nullable=False), + sa.Column("position", sa.Integer(), nullable=False, server_default="0"), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + sa.ForeignKeyConstraint( + ["catalog_id"], ["stremio_custom_catalogs.id"], ondelete="CASCADE" + ), + sa.ForeignKeyConstraint( + ["media_item_id"], ["media_items.id"], ondelete="CASCADE" + ), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint( + "catalog_id", + "media_item_id", + name="uq_stremio_custom_catalog_items_catalog_media", + ), + ) + op.create_index( + "ix_stremio_custom_catalog_items_catalog_position", + "stremio_custom_catalog_items", + ["catalog_id", "position"], + ) + op.create_index( + op.f("ix_stremio_custom_catalog_items_catalog_id"), + "stremio_custom_catalog_items", + ["catalog_id"], + ) + op.create_index( + op.f("ix_stremio_custom_catalog_items_media_item_id"), + "stremio_custom_catalog_items", + ["media_item_id"], + ) + + +def downgrade() -> None: + op.drop_index(op.f("ix_stremio_custom_catalog_items_media_item_id"), table_name="stremio_custom_catalog_items") + op.drop_index(op.f("ix_stremio_custom_catalog_items_catalog_id"), table_name="stremio_custom_catalog_items") + op.drop_index( + "ix_stremio_custom_catalog_items_catalog_position", + table_name="stremio_custom_catalog_items", + ) + op.drop_table("stremio_custom_catalog_items") + op.drop_index( + op.f("ix_stremio_custom_catalogs_user_id"), table_name="stremio_custom_catalogs" + ) + op.drop_table("stremio_custom_catalogs") + op.drop_index( + op.f("ix_stremio_addon_configs_user_id"), table_name="stremio_addon_configs" + ) + op.drop_index( + "ix_stremio_addon_configs_addon_key_hash", table_name="stremio_addon_configs" + ) + op.drop_table("stremio_addon_configs") diff --git a/backend/src/librarysync/db/models.py b/backend/src/librarysync/db/models.py index ebf8c22..140c094 100644 --- a/backend/src/librarysync/db/models.py +++ b/backend/src/librarysync/db/models.py @@ -385,6 +385,94 @@ class WatchSync(Base): ) +class StremioAddonConfig(Base): + __tablename__ = "stremio_addon_configs" + __table_args__ = ( + UniqueConstraint("user_id", name="uq_stremio_addon_configs_user_id"), + UniqueConstraint("addon_key_hash", name="uq_stremio_addon_configs_addon_key_hash"), + Index("ix_stremio_addon_configs_addon_key_hash", "addon_key_hash"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + user_id: Mapped[str] = mapped_column( + String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + addon_key_hash: Mapped[str] = mapped_column(String(64), nullable=False) + addon_key_last_rotated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + default_catalogs: Mapped[list[dict] | None] = mapped_column(JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + +class StremioCustomCatalog(Base): + __tablename__ = "stremio_custom_catalogs" + __table_args__ = ( + UniqueConstraint( + "user_id", + "slug", + name="uq_stremio_custom_catalogs_user_slug", + ), + Index("ix_stremio_custom_catalogs_user_id", "user_id"), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + user_id: Mapped[str] = mapped_column( + String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True + ) + name: Mapped[str] = mapped_column(String(255)) + slug: Mapped[str] = mapped_column(String(64)) + media_type: Mapped[str] = mapped_column(String(32), default="movie") + order_by: Mapped[str] = mapped_column(String(32), default="manual") + order_dir: Mapped[str] = mapped_column(String(8), default="asc") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + + +class StremioCustomCatalogItem(Base): + __tablename__ = "stremio_custom_catalog_items" + __table_args__ = ( + UniqueConstraint( + "catalog_id", + "media_item_id", + name="uq_stremio_custom_catalog_items_catalog_media", + ), + Index( + "ix_stremio_custom_catalog_items_catalog_position", + "catalog_id", + "position", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + catalog_id: Mapped[str] = mapped_column( + String(36), + ForeignKey("stremio_custom_catalogs.id", ondelete="CASCADE"), + index=True, + ) + media_item_id: Mapped[str] = mapped_column( + String(36), ForeignKey("media_items.id", ondelete="CASCADE"), index=True + ) + position: Mapped[int] = mapped_column(Integer, default=0) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) + ) + + class WatchlistItem(Base): __tablename__ = "watchlist_items" __table_args__ = ( diff --git a/backend/src/librarysync/main.py b/backend/src/librarysync/main.py index 8a12e6f..b9c9fdf 100644 --- a/backend/src/librarysync/main.py +++ b/backend/src/librarysync/main.py @@ -19,6 +19,8 @@ routes_integrations, routes_metadata, routes_settings, + routes_stremio_addon, + routes_stremio_addon_public, routes_watchlist, ) from librarysync.api.deps import get_db, get_optional_user @@ -45,6 +47,7 @@ {"name": "dashboard", "description": "Dashboard statistics and analytics."}, {"name": "settings", "description": "Per-user search settings."}, {"name": "blacklist", "description": "Per-user blacklist entries."}, + {"name": "stremio-addon", "description": "Stremio addon configuration and catalogs."}, {"name": "admin", "description": "Administrative operations."}, {"name": "health", "description": "Service health check."}, ] @@ -89,6 +92,8 @@ def create_app() -> FastAPI: app.include_router(routes_settings.router) app.include_router(routes_blacklist.router) app.include_router(routes_watchlist.router) + app.include_router(routes_stremio_addon.router) + app.include_router(routes_stremio_addon_public.router) app.include_router(routes_admin.router) app_version = get_app_version() diff --git a/docs/stremio-catalogs-plan.md b/docs/stremio-catalogs-plan.md index 22be8e5..3457120 100644 --- a/docs/stremio-catalogs-plan.md +++ b/docs/stremio-catalogs-plan.md @@ -5,6 +5,8 @@ - Support user-defined filters (unwatched, released, combinations) and ordering (date added, release date, random, etc.). - Add a TV-only "In progress" catalog for shows with unwatched released episodes. - Enable custom catalogs with fixed, user-curated media items. +- V2: Support catalog-only external watchlists (provider watchlists or list URLs), imported + separately and refreshed regularly, with the same filters applied before ordering. - Offer install options: direct install link and copyable manifest URL. - Ship a responsive frontend for configuration + management. @@ -53,6 +55,13 @@ Suggested tables: Notes: - Addon access is key-based (no JWT). Store addon keys hashed; keep plaintext only at creation/rotation time. - If you prefer fewer tables, we can encode the built-in catalogs in JSON on the config table and only create a table for custom catalogs/items. +- V2 catalog-only watchlists: + - Add tables mirroring watchlist sources/items but scoped to the addon: + - `stremio_watchlist_sources` (user_id, provider, source_type, external_id, url, name, enabled) + - `stremio_watchlist_items` (user_id, media_item_id, status, dates, source metadata) + - `stremio_watchlist_source_items` (source_id, watchlist_item_id, last_seen_at) + - Keep these separate from `watchlist_items` so catalog-only imports do not modify the main + watchlist UI. ### Internal API (auth-required) Create endpoints under `/api/stremio-addon`: @@ -69,6 +78,12 @@ Create endpoints under `/api/stremio-addon`: - `POST /api/stremio-addon/custom-catalogs/{catalog_id}/items` - `DELETE /api/stremio-addon/custom-catalogs/{catalog_id}/items/{media_item_id}` - Optional: `POST /api/stremio-addon/custom-catalogs/{catalog_id}/reorder` +- V2 catalog-only watchlists: + - `GET /api/stremio-addon/watchlists` + - `POST /api/stremio-addon/watchlists` + - `PATCH /api/stremio-addon/watchlists/{watchlist_id}` + - `DELETE /api/stremio-addon/watchlists/{watchlist_id}` + - Optional: `POST /api/stremio-addon/watchlists/{watchlist_id}/refresh` ### Addon (public) Routes Add a new router with public endpoints; all access is gated by the addon key in the URL. @@ -97,6 +112,11 @@ Custom catalogs: - Items are explicit `media_item_id` entries. - Custom catalogs are separate from the watchlist. +V2: Catalog-only watchlist catalogs: +- Each catalog maps to a `stremio_watchlist_source` (provider + source_type + external_id). +- Treated like watchlist-backed catalogs but scoped to the catalog-only dataset. +- Apply the same filters (e.g. exclude `watched` statuses) before ordering. + ### Filters and Ordering Filters should align with existing watchlist status logic: - Unwatched: statuses in `added`, `in_progress`, `not_released` (exclude `watched`, `waiting`, `removed`). @@ -117,6 +137,9 @@ Ordering: - Custom catalogs: - Join `stremio_custom_catalog_items` -> `media_items`. - Support manual ordering by `position`, fallback to created_at. +- V2 catalog-only watchlists: + - Join `stremio_watchlist_source_items` -> `stremio_watchlist_items` -> `media_items`. + - Filter by source scope, then apply the same watchlist filters. ## Frontend Plan - Add nav entry in `backend/src/librarysync/templates/base.html`. @@ -139,6 +162,9 @@ UI sections: - Create/edit/delete catalogs. - Add items via search (reuse blacklist-style search UX). - Reorder items (drag/drop or up/down). +5. V2: Catalog-only watchlists + - Add/manage external watchlist sources for catalog-only use. + - Apply the same filters/order options as built-in watchlist catalogs. ## Security + Access - Addon access uses a per-user addon key (random, rotatable); no auth cookies. @@ -161,3 +187,12 @@ UI sections: ## Open Questions None. + +## Known Bugs (needs fixing) + - [P2] Persist default catalog updates when editing JSON config — /Users/twillems/Development/stremio/librarySync/backend/src/librarysync/api/routes_stremio_addon.py:60-70 + The catalog update path mutates the existing default_catalogs list/dicts in place and then reassigns the list. Because default_catalogs is a plain JSON column (not a MutableList), SQLAlchemy doesn’t track in-place JSON mutations; if the new list + compares equal to the old one, the UPDATE won’t include default_catalogs. This means toggling catalog enabled state or changing filters/order can appear to succeed in the response but revert on the next load. Consider deep-copying before mutation or + explicitly flagging the JSON column as modified. + - [P2] Apply catalog status filters to in-progress catalog — /Users/twillems/Development/stremio/librarySync/backend/src/librarysync/api/routes_stremio_addon_public.py:270-277 + The in-progress catalog query ignores the configured filters.statuses and only excludes removed. If a user disables certain statuses in the addon config (e.g., wants to exclude watched or not_released items), those settings are silently ignored for + the in_progress_shows catalog. This causes the in-progress catalog to return items the user explicitly filtered out. Consider applying the same status filter logic as _build_watchlist_query or pass the catalog’s filters into this helper. From 163a41830da39ef115e80b08a49e4d3969ac2f9f Mon Sep 17 00:00:00 2001 From: Thomas Willems Date: Sat, 17 Jan 2026 17:34:17 +0100 Subject: [PATCH 3/7] stremio catalogs --- .../librarysync/api/routes_stremio_addon.py | 547 +++++++- .../api/routes_stremio_addon_public.py | 27 +- backend/src/librarysync/core/stremio_addon.py | 58 +- ...a1b7c3e9_drop_stremio_addon_key_columns.py | 56 + backend/src/librarysync/db/models.py | 6 - backend/src/librarysync/main.py | 14 + .../src/librarysync/static/icons/stremio.svg | 1 + .../librarysync/static/page-stremio-addon.js | 1096 +++++++++++++++++ backend/src/librarysync/static/styles.css | 51 + backend/src/librarysync/templates/base.html | 4 + .../librarysync/templates/stremio-addon.html | 173 +++ backend/tests/test_stremio_addon_catalogs.py | 84 ++ backend/tests/test_watchlist_filters.py | 61 + docs/stremio-catalogs-plan.md | 35 +- frontend/input.css | 9 + 15 files changed, 2092 insertions(+), 130 deletions(-) create mode 100644 backend/src/librarysync/db/migrations/versions/d2f6a1b7c3e9_drop_stremio_addon_key_columns.py create mode 100644 backend/src/librarysync/static/icons/stremio.svg create mode 100644 backend/src/librarysync/static/page-stremio-addon.js create mode 100644 backend/src/librarysync/templates/stremio-addon.html create mode 100644 backend/tests/test_stremio_addon_catalogs.py create mode 100644 backend/tests/test_watchlist_filters.py diff --git a/backend/src/librarysync/api/routes_stremio_addon.py b/backend/src/librarysync/api/routes_stremio_addon.py index ab5b2c2..944a48e 100644 --- a/backend/src/librarysync/api/routes_stremio_addon.py +++ b/backend/src/librarysync/api/routes_stremio_addon.py @@ -1,24 +1,48 @@ from __future__ import annotations +import copy +import re +import secrets from datetime import datetime, timezone from typing import Literal -from fastapi import APIRouter, Depends, Request +from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm.attributes import flag_modified from librarysync.api.deps import get_current_user, get_db from librarysync.config import settings +from librarysync.core.catalog_ordering import CatalogOrderBy from librarysync.core.stremio_addon import ( build_default_catalogs, ensure_addon_config, - rotate_addon_key, ) -from librarysync.db.models import StremioAddonConfig, StremioCustomCatalog, User +from librarysync.core.watchlist import ( + apply_media_id_update, + fallback_title, + find_media_item_by_ids, + normalize_media_ids, +) +from librarysync.db.models import ( + MediaItem, + StremioAddonConfig, + StremioCustomCatalog, + StremioCustomCatalogItem, + User, +) router = APIRouter(prefix="/api/stremio-addon", tags=["stremio-addon"]) +CUSTOM_MEDIA_TYPES = {"movie", "tv", "anime"} +CUSTOM_ORDER_BY = { + "manual", + "random", + *CatalogOrderBy.__args__, +} +CUSTOM_ORDER_DIR = {"asc", "desc"} + class StremioCatalogFilters(BaseModel): statuses: list[str] | None = None @@ -41,23 +65,178 @@ class StremioAddonConfigUpdate(BaseModel): catalogs: list[StremioCatalogUpdate] | None = None +class StremioCustomCatalogCreate(BaseModel): + name: str + media_type: Literal["movie", "tv", "anime"] = "movie" + order_by: str | None = None + order_dir: Literal["asc", "desc"] | None = None + + +class StremioCustomCatalogUpdate(BaseModel): + name: str | None = None + media_type: Literal["movie", "tv", "anime"] | None = None + order_by: str | None = None + order_dir: Literal["asc", "desc"] | None = None + + +class StremioCustomCatalogItemCreate(BaseModel): + media_item_id: str | None = None + media_type: Literal["movie", "tv", "anime"] | None = None + title: str | None = None + year: int | None = None + poster_url: str | None = None + imdb_id: str | None = None + tmdb_id: str | None = None + tvdb_id: str | None = None + tvmaze_id: str | None = None + kitsu_id: str | None = None + myanimelist_id: str | None = None + anilist_id: str | None = None + stremio_id: str | None = None + + +class StremioCustomCatalogReorder(BaseModel): + media_item_ids: list[str] + + def _resolve_base_url(request: Request) -> str: if settings.base_url: return settings.base_url.rstrip("/") return str(request.base_url).rstrip("/") -def _build_manifest_links(base_url: str, addon_key: str) -> dict[str, str]: - manifest_url = f"{base_url}/stremio-addon/{addon_key}/manifest.json" +def _build_manifest_links(base_url: str, addon_id: str) -> dict[str, str]: + manifest_url = f"{base_url}/stremio-addon/{addon_id}/manifest.json" install_url = f"stremio://{manifest_url}" return {"manifest_url": manifest_url, "install_url": install_url} +def _slugify(value: str) -> str: + normalized = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") + return normalized or "catalog" + + +def _truncate_slug(value: str, suffix: str | None = None) -> str: + limit = 64 - (len(suffix) if suffix else 0) + trimmed = value[:limit].rstrip("-") + if suffix: + return f"{trimmed}{suffix}" + return trimmed + + +async def _build_unique_slug( + db: AsyncSession, + user_id: str, + value: str, + *, + exclude_catalog_id: str | None = None, +) -> str: + base_slug = _truncate_slug(_slugify(value)) + result = await db.execute( + select(StremioCustomCatalog.id, StremioCustomCatalog.slug).where( + StremioCustomCatalog.user_id == user_id + ) + ) + existing = { + slug + for catalog_id, slug in result.all() + if slug and (not exclude_catalog_id or catalog_id != exclude_catalog_id) + } + if base_slug not in existing: + return base_slug + for index in range(2, 100): + candidate = _truncate_slug(base_slug, f"-{index}") + if candidate not in existing: + return candidate + return _truncate_slug(base_slug, f"-{secrets.token_hex(3)}") + + +def _normalize_custom_order_by(value: str | None) -> str: + if not value: + return "manual" + normalized = value.strip().lower() + if normalized in CUSTOM_ORDER_BY: + return normalized + return "manual" + + +def _normalize_custom_order_dir(value: str | None) -> str: + if value and value.strip().lower() in CUSTOM_ORDER_DIR: + return value.strip().lower() + return "asc" + + +def _custom_catalog_out(catalog: StremioCustomCatalog) -> dict: + return { + "id": catalog.id, + "name": catalog.name, + "slug": catalog.slug, + "media_type": catalog.media_type, + "order_by": catalog.order_by, + "order_dir": catalog.order_dir, + "created_at": catalog.created_at, + "updated_at": catalog.updated_at, + } + + +def _custom_catalog_item_out( + item: StremioCustomCatalogItem, media_item: MediaItem +) -> dict[str, object]: + return { + "media_item_id": media_item.id, + "position": item.position, + "created_at": item.created_at, + "media_type": media_item.media_type, + "title": media_item.title, + "year": media_item.year, + "poster_url": media_item.poster_url, + "imdb_id": media_item.imdb_id, + "tmdb_id": media_item.tmdb_id, + "tvdb_id": media_item.tvdb_id, + "tvmaze_id": media_item.tvmaze_id, + "kitsu_id": media_item.kitsu_id, + "myanimelist_id": media_item.myanimelist_id, + "anilist_id": media_item.anilist_id, + } + + +def _build_reorder_map( + existing_ids: list[str], + requested_ids: list[str], +) -> dict[str, int]: + if not existing_ids: + if requested_ids: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Catalog has no items to reorder", + ) + return {} + if not requested_ids: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Provide media_item_ids to reorder", + ) + if len(set(requested_ids)) != len(requested_ids): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Duplicate media_item_ids provided", + ) + existing_set = set(existing_ids) + requested_set = set(requested_ids) + if existing_set != requested_set: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Provide all catalog items to reorder", + ) + return {media_item_id: index for index, media_item_id in enumerate(requested_ids)} + + def _merge_catalog_updates( existing: list[dict], updates: list[StremioCatalogUpdate], ) -> list[dict]: - by_id = {catalog.get("id"): catalog for catalog in existing if catalog.get("id")} + catalog_copy = [copy.deepcopy(catalog) for catalog in existing] + by_id = {catalog.get("id"): catalog for catalog in catalog_copy if catalog.get("id")} for update in updates: catalog = by_id.get(update.id) if not catalog: @@ -85,6 +264,116 @@ async def _ensure_default_catalogs( return catalogs +async def _load_custom_catalog( + db: AsyncSession, + user_id: str, + catalog_id: str, +) -> StremioCustomCatalog: + result = await db.execute( + select(StremioCustomCatalog).where( + StremioCustomCatalog.user_id == user_id, + StremioCustomCatalog.id == catalog_id, + ) + ) + catalog = result.scalars().first() + if not catalog: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Catalog not found") + return catalog + + +async def _resolve_custom_media_item( + db: AsyncSession, + payload: StremioCustomCatalogItemCreate, + catalog: StremioCustomCatalog, +) -> MediaItem: + if payload.media_item_id: + media_item = await db.get(MediaItem, payload.media_item_id) + if not media_item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Media item not found") + if media_item.media_type != catalog.media_type: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Media type does not match catalog", + ) + return media_item + + media_type = payload.media_type or catalog.media_type + if media_type not in CUSTOM_MEDIA_TYPES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid media type", + ) + if media_type != catalog.media_type: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Media type does not match catalog", + ) + ids = normalize_media_ids( + { + "imdb_id": payload.imdb_id, + "tmdb_id": payload.tmdb_id, + "tvdb_id": payload.tvdb_id, + "tvmaze_id": payload.tvmaze_id, + "kitsu_id": payload.kitsu_id, + "myanimelist_id": payload.myanimelist_id, + "anilist_id": payload.anilist_id, + } + ) + if not ids: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Provide a media_item_id or at least one external ID", + ) + + media_item = await find_media_item_by_ids(db, media_type, ids) + if media_item and media_item.media_type != media_type: + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Media type does not match existing item", + ) + + if not media_item: + resolved_title = payload.title or fallback_title(ids) + raw = {"source": "stremio_custom_catalog", "ids": ids} + if payload.stremio_id: + raw["stremio_id"] = payload.stremio_id + media_item = MediaItem( + media_type=media_type, + title=resolved_title, + year=payload.year, + poster_url=payload.poster_url, + imdb_id=ids.get("imdb_id"), + tmdb_id=ids.get("tmdb_id"), + tvdb_id=ids.get("tvdb_id"), + tvmaze_id=ids.get("tvmaze_id"), + kitsu_id=ids.get("kitsu_id"), + myanimelist_id=ids.get("myanimelist_id"), + anilist_id=ids.get("anilist_id"), + raw=raw, + ) + db.add(media_item) + await db.flush() + return media_item + + apply_media_id_update(media_item, "imdb_id", ids.get("imdb_id")) + apply_media_id_update(media_item, "tmdb_id", ids.get("tmdb_id")) + apply_media_id_update(media_item, "tvdb_id", ids.get("tvdb_id")) + apply_media_id_update(media_item, "tvmaze_id", ids.get("tvmaze_id")) + apply_media_id_update(media_item, "kitsu_id", ids.get("kitsu_id")) + apply_media_id_update(media_item, "myanimelist_id", ids.get("myanimelist_id")) + apply_media_id_update(media_item, "anilist_id", ids.get("anilist_id")) + if payload.year is not None and media_item.year is None: + media_item.year = payload.year + if payload.poster_url and not media_item.poster_url: + media_item.poster_url = payload.poster_url + if payload.stremio_id: + raw = media_item.raw if isinstance(media_item.raw, dict) else {} + if not raw.get("stremio_id"): + raw["stremio_id"] = payload.stremio_id + media_item.raw = raw + return media_item + + @router.get( "/config", summary="Get Stremio addon config", @@ -95,33 +384,20 @@ async def get_stremio_addon_config( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> dict: - config, addon_key = await ensure_addon_config(db, current_user.id) + config = await ensure_addon_config(db, current_user.id) catalogs = await _ensure_default_catalogs(db, config) custom_result = await db.execute( select(StremioCustomCatalog).where(StremioCustomCatalog.user_id == current_user.id) ) - custom_catalogs = [ - { - "id": catalog.id, - "name": catalog.name, - "slug": catalog.slug, - "media_type": catalog.media_type, - "order_by": catalog.order_by, - "order_dir": catalog.order_dir, - "created_at": catalog.created_at, - "updated_at": catalog.updated_at, - } - for catalog in custom_result.scalars().all() - ] + custom_catalogs = [_custom_catalog_out(catalog) for catalog in custom_result.scalars().all()] + manifest_links = _build_manifest_links(_resolve_base_url(request), config.id) payload: dict[str, object] = { + "addon_id": config.id, "is_enabled": bool(config.is_enabled), - "addon_key_last_rotated_at": config.addon_key_last_rotated_at, "catalogs": catalogs, "custom_catalogs": custom_catalogs, + **manifest_links, } - if addon_key: - payload["addon_key"] = addon_key - payload.update(_build_manifest_links(_resolve_base_url(request), addon_key)) return payload @@ -140,7 +416,7 @@ async def update_stremio_addon_config( ) config = result.scalars().first() if not config: - config, _ = await ensure_addon_config(db, current_user.id) + config = await ensure_addon_config(db, current_user.id) catalogs = await _ensure_default_catalogs(db, config) fields = payload.model_fields_set @@ -149,6 +425,7 @@ async def update_stremio_addon_config( if payload.catalogs: catalogs = _merge_catalog_updates(catalogs, payload.catalogs) config.default_catalogs = catalogs + flag_modified(config, "default_catalogs") config.updated_at = datetime.now(timezone.utc) db.add(config) await db.commit() @@ -160,24 +437,212 @@ async def update_stremio_addon_config( @router.post( - "/token/rotate", - summary="Rotate Stremio addon key", - description="Rotate the per-user addon key and return install links.", + "/custom-catalogs", + status_code=status.HTTP_201_CREATED, + summary="Create custom catalog", + description="Create a new custom Stremio catalog.", ) -async def rotate_stremio_addon_token( - request: Request, +async def create_custom_catalog( + payload: StremioCustomCatalogCreate, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> dict: + name = payload.name.strip() + if not name: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Name is required") + slug = await _build_unique_slug(db, current_user.id, name) + order_by = _normalize_custom_order_by(payload.order_by) + order_dir = _normalize_custom_order_dir(payload.order_dir) + catalog = StremioCustomCatalog( + user_id=current_user.id, + name=name, + slug=slug, + media_type=payload.media_type, + order_by=order_by, + order_dir=order_dir, + ) + db.add(catalog) + await db.commit() + await db.refresh(catalog) + return _custom_catalog_out(catalog) + + +@router.patch( + "/custom-catalogs/{catalog_id}", + summary="Update custom catalog", + description="Update a custom Stremio catalog.", +) +async def update_custom_catalog( + catalog_id: str, + payload: StremioCustomCatalogUpdate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict: + catalog = await _load_custom_catalog(db, current_user.id, catalog_id) + fields = payload.model_fields_set + if "name" in fields: + name = (payload.name or "").strip() + if not name: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Name is required") + if name != catalog.name: + catalog.slug = await _build_unique_slug( + db, + current_user.id, + name, + exclude_catalog_id=catalog.id, + ) + catalog.name = name + if "media_type" in fields and payload.media_type: + if payload.media_type not in CUSTOM_MEDIA_TYPES: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid media type", + ) + catalog.media_type = payload.media_type + if "order_by" in fields: + catalog.order_by = _normalize_custom_order_by(payload.order_by) + if "order_dir" in fields: + catalog.order_dir = _normalize_custom_order_dir(payload.order_dir) + catalog.updated_at = datetime.now(timezone.utc) + db.add(catalog) + await db.commit() + await db.refresh(catalog) + return _custom_catalog_out(catalog) + + +@router.delete( + "/custom-catalogs/{catalog_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete custom catalog", + description="Delete a custom Stremio catalog.", +) +async def delete_custom_catalog( + catalog_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> None: + catalog = await _load_custom_catalog(db, current_user.id, catalog_id) + await db.delete(catalog) + await db.commit() + return None + + +@router.get( + "/custom-catalogs/{catalog_id}/items", + summary="List custom catalog items", + description="List items in a custom Stremio catalog.", +) +async def list_custom_catalog_items( + catalog_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict: + catalog = await _load_custom_catalog(db, current_user.id, catalog_id) result = await db.execute( - select(StremioAddonConfig).where(StremioAddonConfig.user_id == current_user.id) + select(StremioCustomCatalogItem, MediaItem) + .join(MediaItem, MediaItem.id == StremioCustomCatalogItem.media_item_id) + .where(StremioCustomCatalogItem.catalog_id == catalog.id) + .order_by( + StremioCustomCatalogItem.position.asc(), + StremioCustomCatalogItem.created_at.asc(), + ) ) - config = result.scalars().first() - if not config: - config, _ = await ensure_addon_config(db, current_user.id) - addon_key = await rotate_addon_key(db, config) - return { - "addon_key": addon_key, - "addon_key_last_rotated_at": config.addon_key_last_rotated_at, - **_build_manifest_links(_resolve_base_url(request), addon_key), - } + items = [_custom_catalog_item_out(item, media) for item, media in result.all()] + return {"items": items} + + +@router.post( + "/custom-catalogs/{catalog_id}/items", + status_code=status.HTTP_201_CREATED, + summary="Add custom catalog item", + description="Add a media item to a custom Stremio catalog.", +) +async def add_custom_catalog_item( + catalog_id: str, + payload: StremioCustomCatalogItemCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict: + catalog = await _load_custom_catalog(db, current_user.id, catalog_id) + media_item = await _resolve_custom_media_item(db, payload, catalog) + existing_result = await db.execute( + select(StremioCustomCatalogItem).where( + StremioCustomCatalogItem.catalog_id == catalog.id, + StremioCustomCatalogItem.media_item_id == media_item.id, + ) + ) + if existing_result.scalars().first(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="Media item already in catalog", + ) + position_result = await db.execute( + select(func.coalesce(func.max(StremioCustomCatalogItem.position), 0)).where( + StremioCustomCatalogItem.catalog_id == catalog.id + ) + ) + next_position = int(position_result.scalar_one() or 0) + 1 + item = StremioCustomCatalogItem( + catalog_id=catalog.id, + media_item_id=media_item.id, + position=next_position, + ) + db.add(item) + await db.commit() + await db.refresh(item) + return {"item": _custom_catalog_item_out(item, media_item)} + + +@router.delete( + "/custom-catalogs/{catalog_id}/items/{media_item_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Remove custom catalog item", + description="Remove a media item from a custom Stremio catalog.", +) +async def remove_custom_catalog_item( + catalog_id: str, + media_item_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> None: + catalog = await _load_custom_catalog(db, current_user.id, catalog_id) + result = await db.execute( + select(StremioCustomCatalogItem).where( + StremioCustomCatalogItem.catalog_id == catalog.id, + StremioCustomCatalogItem.media_item_id == media_item_id, + ) + ) + item = result.scalars().first() + if not item: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Item not found") + await db.delete(item) + await db.commit() + return None + + +@router.post( + "/custom-catalogs/{catalog_id}/reorder", + summary="Reorder custom catalog items", + description="Reorder items in a custom Stremio catalog.", +) +async def reorder_custom_catalog( + catalog_id: str, + payload: StremioCustomCatalogReorder, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> dict: + catalog = await _load_custom_catalog(db, current_user.id, catalog_id) + result = await db.execute( + select(StremioCustomCatalogItem).where( + StremioCustomCatalogItem.catalog_id == catalog.id + ) + ) + items = result.scalars().all() + existing_ids = [item.media_item_id for item in items] + reorder_map = _build_reorder_map(existing_ids, payload.media_item_ids) + if not reorder_map: + return {"status": "ok"} + for item in items: + item.position = reorder_map.get(item.media_item_id, item.position) + await db.commit() + return {"status": "ok"} diff --git a/backend/src/librarysync/api/routes_stremio_addon_public.py b/backend/src/librarysync/api/routes_stremio_addon_public.py index afc8da4..6b96247 100644 --- a/backend/src/librarysync/api/routes_stremio_addon_public.py +++ b/backend/src/librarysync/api/routes_stremio_addon_public.py @@ -10,7 +10,7 @@ from librarysync.api.deps import get_db from librarysync.core.catalog_ordering import apply_catalog_ordering -from librarysync.core.stremio_addon import build_default_catalogs, get_addon_config_by_key +from librarysync.core.stremio_addon import build_default_catalogs, get_addon_config_by_id from librarysync.db.models import ( EpisodeItem, MediaItem, @@ -130,8 +130,6 @@ def _catalogs_by_id(catalogs: list[dict]) -> dict[str, dict]: def _build_manifest( - base_url: str, - addon_key: str, catalogs: list[dict], custom_catalogs: list[StremioCustomCatalog], ) -> dict[str, Any]: @@ -263,6 +261,7 @@ def _build_progress_subquery(user_id: str, now_date: date): async def _build_in_progress_query( user_id: str, + catalog: dict | None, search: str | None, ): now_date = datetime.now(timezone.utc).date() @@ -281,6 +280,10 @@ async def _build_in_progress_query( progress_subq.c.watched_count < progress_subq.c.total_released, ) ) + filters = catalog.get("filters") if isinstance(catalog, dict) else {} + statuses = filters.get("statuses") if isinstance(filters, dict) else None + if statuses: + query = query.where(WatchlistItem.status.in_(statuses)) if search: query = _apply_search_filter(query, search) return query @@ -328,13 +331,12 @@ def _apply_ordering( ) -@router.get("/{addon_key}/manifest.json", include_in_schema=False) +@router.get("/{addon_id}/manifest.json", include_in_schema=False) async def stremio_addon_manifest( - addon_key: str, - request: Request, + addon_id: str, db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: - config = await get_addon_config_by_key(db, addon_key) + config = await get_addon_config_by_id(db, addon_id) if not config or not config.is_enabled: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") catalogs = _normalize_catalogs(config.default_catalogs) @@ -342,19 +344,18 @@ async def stremio_addon_manifest( select(StremioCustomCatalog).where(StremioCustomCatalog.user_id == config.user_id) ) custom_catalogs = custom_result.scalars().all() - base_url = str(request.base_url).rstrip("/") - return _build_manifest(base_url, addon_key, catalogs, custom_catalogs) + return _build_manifest(catalogs, custom_catalogs) -@router.get("/{addon_key}/catalog/{catalog_type}/{catalog_id}.json", include_in_schema=False) +@router.get("/{addon_id}/catalog/{catalog_type}/{catalog_id}.json", include_in_schema=False) async def stremio_addon_catalog( - addon_key: str, + addon_id: str, catalog_type: Literal["movie", "series"], catalog_id: str, request: Request, db: AsyncSession = Depends(get_db), ) -> dict[str, Any]: - config = await get_addon_config_by_key(db, addon_key) + config = await get_addon_config_by_id(db, addon_id) if not config or not config.is_enabled: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") @@ -406,7 +407,7 @@ async def stremio_addon_catalog( ) else: if catalog_id == "in_progress_shows": - query = await _build_in_progress_query(config.user_id, search) + query = await _build_in_progress_query(config.user_id, catalog, search) else: query = await _build_watchlist_query(config.user_id, catalog, search) ordering = catalog.get("ordering") if isinstance(catalog.get("ordering"), dict) else {} diff --git a/backend/src/librarysync/core/stremio_addon.py b/backend/src/librarysync/core/stremio_addon.py index 347ed10..4841132 100644 --- a/backend/src/librarysync/core/stremio_addon.py +++ b/backend/src/librarysync/core/stremio_addon.py @@ -1,16 +1,12 @@ from __future__ import annotations import copy -import hashlib -import hmac -import secrets -from datetime import datetime, timezone +import uuid from typing import Any from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from librarysync.config import settings from librarysync.db.models import StremioAddonConfig DEFAULT_CATALOGS: list[dict[str, Any]] = [ @@ -53,20 +49,6 @@ def build_default_catalogs() -> list[dict[str, Any]]: return copy.deepcopy(DEFAULT_CATALOGS) -def generate_addon_key() -> str: - return secrets.token_urlsafe(32) - - -def hash_addon_key(addon_key: str) -> str: - if not settings.secret_key: - raise RuntimeError("LIBRARYSYNC_SECRET_KEY is not set") - return hmac.new( - settings.secret_key.encode("utf-8"), - addon_key.encode("utf-8"), - hashlib.sha256, - ).hexdigest() - - async def get_addon_config_by_user( db: AsyncSession, user_id: str, @@ -77,52 +59,30 @@ async def get_addon_config_by_user( return result.scalars().first() -async def get_addon_config_by_key( +async def get_addon_config_by_id( db: AsyncSession, - addon_key: str, + addon_id: str, ) -> StremioAddonConfig | None: - key_hash = hash_addon_key(addon_key) result = await db.execute( - select(StremioAddonConfig).where(StremioAddonConfig.addon_key_hash == key_hash) + select(StremioAddonConfig).where(StremioAddonConfig.id == addon_id) ) - config = result.scalars().first() - if not config: - return None - if not hmac.compare_digest(config.addon_key_hash, key_hash): - return None - return config + return result.scalars().first() async def ensure_addon_config( db: AsyncSession, user_id: str, -) -> tuple[StremioAddonConfig, str | None]: +) -> StremioAddonConfig: config = await get_addon_config_by_user(db, user_id) if config: - return config, None - addon_key = generate_addon_key() - now = datetime.now(timezone.utc) + return config config = StremioAddonConfig( + id=str(uuid.uuid4()), user_id=user_id, is_enabled=True, - addon_key_hash=hash_addon_key(addon_key), - addon_key_last_rotated_at=now, default_catalogs=build_default_catalogs(), ) db.add(config) await db.commit() await db.refresh(config) - return config, addon_key - - -async def rotate_addon_key( - db: AsyncSession, - config: StremioAddonConfig, -) -> str: - addon_key = generate_addon_key() - config.addon_key_hash = hash_addon_key(addon_key) - config.addon_key_last_rotated_at = datetime.now(timezone.utc) - db.add(config) - await db.commit() - await db.refresh(config) - return addon_key + return config diff --git a/backend/src/librarysync/db/migrations/versions/d2f6a1b7c3e9_drop_stremio_addon_key_columns.py b/backend/src/librarysync/db/migrations/versions/d2f6a1b7c3e9_drop_stremio_addon_key_columns.py new file mode 100644 index 0000000..da02c5e --- /dev/null +++ b/backend/src/librarysync/db/migrations/versions/d2f6a1b7c3e9_drop_stremio_addon_key_columns.py @@ -0,0 +1,56 @@ +"""drop stremio addon key columns + +Revision ID: d2f6a1b7c3e9 +Revises: c9a6b5d7e8f1 +Create Date: 2026-02-01 00:00:00.000000 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "d2f6a1b7c3e9" +down_revision = "c9a6b5d7e8f1" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_index( + "ix_stremio_addon_configs_addon_key_hash", + table_name="stremio_addon_configs", + ) + op.drop_constraint( + "uq_stremio_addon_configs_addon_key_hash", + "stremio_addon_configs", + type_="unique", + ) + op.drop_column("stremio_addon_configs", "addon_key_last_rotated_at") + op.drop_column("stremio_addon_configs", "addon_key_hash") + + +def downgrade() -> None: + op.add_column( + "stremio_addon_configs", + sa.Column("addon_key_hash", sa.String(length=64), nullable=False, server_default=""), + ) + op.add_column( + "stremio_addon_configs", + sa.Column( + "addon_key_last_rotated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("CURRENT_TIMESTAMP"), + ), + ) + op.alter_column("stremio_addon_configs", "addon_key_hash", server_default=None) + op.create_unique_constraint( + "uq_stremio_addon_configs_addon_key_hash", + "stremio_addon_configs", + ["addon_key_hash"], + ) + op.create_index( + "ix_stremio_addon_configs_addon_key_hash", + "stremio_addon_configs", + ["addon_key_hash"], + ) diff --git a/backend/src/librarysync/db/models.py b/backend/src/librarysync/db/models.py index 140c094..b9eb393 100644 --- a/backend/src/librarysync/db/models.py +++ b/backend/src/librarysync/db/models.py @@ -389,8 +389,6 @@ class StremioAddonConfig(Base): __tablename__ = "stremio_addon_configs" __table_args__ = ( UniqueConstraint("user_id", name="uq_stremio_addon_configs_user_id"), - UniqueConstraint("addon_key_hash", name="uq_stremio_addon_configs_addon_key_hash"), - Index("ix_stremio_addon_configs_addon_key_hash", "addon_key_hash"), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) @@ -398,10 +396,6 @@ class StremioAddonConfig(Base): String(36), ForeignKey("users.id", ondelete="CASCADE"), index=True ) is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) - addon_key_hash: Mapped[str] = mapped_column(String(64), nullable=False) - addon_key_last_rotated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) - ) default_catalogs: Mapped[list[dict] | None] = mapped_column(JSON, nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) diff --git a/backend/src/librarysync/main.py b/backend/src/librarysync/main.py index b9c9fdf..a9d3690 100644 --- a/backend/src/librarysync/main.py +++ b/backend/src/librarysync/main.py @@ -298,6 +298,20 @@ async def settings_page( current_user=current_user, ) + @app.get("/stremio-addon", include_in_schema=False) + async def stremio_addon_page( + request: Request, + current_user: User | None = Depends(get_optional_user), + ): + return _render_page( + request, + "stremio-addon.html", + page_title="Stremio Addon", + active_page="stremio-addon", + requires_auth=True, + current_user=current_user, + ) + @app.get("/offline", include_in_schema=False) async def offline( request: Request, diff --git a/backend/src/librarysync/static/icons/stremio.svg b/backend/src/librarysync/static/icons/stremio.svg new file mode 100644 index 0000000..12d3ede --- /dev/null +++ b/backend/src/librarysync/static/icons/stremio.svg @@ -0,0 +1 @@ +Stremio \ No newline at end of file diff --git a/backend/src/librarysync/static/page-stremio-addon.js b/backend/src/librarysync/static/page-stremio-addon.js new file mode 100644 index 0000000..9499bb9 --- /dev/null +++ b/backend/src/librarysync/static/page-stremio-addon.js @@ -0,0 +1,1096 @@ +const addonState = { + config: null, + catalogs: [], + customCatalogs: [], + selectedCatalogId: null, + items: [], + candidates: [], + lookupId: null, + lookupTimer: null, + installLinks: { + manifestUrl: "", + installUrl: "", + }, +}; + +const BUILTIN_CATALOG_DETAILS = { + watchlist_movies: { + title: "Watchlist Movies", + description: "Movies queued in your watchlist.", + }, + watchlist_shows: { + title: "Watchlist Shows", + description: "TV and anime watchlist entries.", + }, + watchlist_anime: { + title: "Watchlist Anime", + description: "Anime-only watchlist slice.", + }, + in_progress_shows: { + title: "In Progress", + description: "Shows with released episodes left to watch.", + }, +}; + +const STATUS_OPTIONS = [ + { value: "added", label: "Added" }, + { value: "in_progress", label: "In progress" }, + { value: "not_released", label: "Not released" }, + { value: "watched", label: "Watched" }, +]; + +const ORDER_OPTIONS = [ + { value: "date_added", label: "Date added" }, + { value: "release_date", label: "Release date" }, + { value: "last_watched", label: "Last watched" }, + { value: "episodes_left", label: "Episodes left" }, + { value: "progress", label: "Progress" }, + { value: "last_episode_air_date", label: "Last episode air date" }, + { value: "next_episode_air_date", label: "Next episode air date" }, + { value: "random", label: "Random" }, +]; + +const CUSTOM_ORDER_OPTIONS = [ + { value: "manual", label: "Manual" }, + ...ORDER_OPTIONS, +]; + +function setAddonMessage(message, isError = false) { + setMessage("stremio-addon-message", message, isError); +} + +function setControlsMessage(message, isError = false) { + setMessage("stremio-addon-controls-message", message, isError); +} + +function updateInstallLinks(payload) { + if (!payload) { + return; + } + if (payload.manifest_url) { + addonState.installLinks.manifestUrl = payload.manifest_url; + } + if (payload.install_url) { + addonState.installLinks.installUrl = payload.install_url; + } +} + +function renderInstallSection() { + const manifestInput = document.getElementById("stremio-manifest-url"); + const installLink = document.getElementById("stremio-install-link"); + const manifestCopy = document.getElementById("stremio-manifest-copy"); + const installCopy = document.getElementById("stremio-install-copy"); + const hint = document.getElementById("stremio-install-hint"); + + const manifestUrl = addonState.installLinks.manifestUrl || ""; + const installUrl = addonState.installLinks.installUrl || ""; + if (manifestInput) { + manifestInput.value = manifestUrl; + } + if (manifestCopy) { + manifestCopy.disabled = !manifestUrl; + } + if (installLink) { + installLink.href = installUrl || "#"; + installLink.dataset.disabled = installUrl ? "false" : "true"; + } + if (installCopy) { + installCopy.disabled = !installUrl; + } + if (hint) { + hint.textContent = manifestUrl + ? "Keep this URL somewhere safe for reinstalling." + : "Save your settings to generate the install link."; + } +} + +function renderControlSection() { + const enabledToggle = document.getElementById("stremio-addon-enabled"); + if (enabledToggle) { + enabledToggle.checked = !!(addonState.config && addonState.config.is_enabled); + } +} + +function buildSelect(options, selectedValue) { + const select = document.createElement("select"); + select.className = "select"; + options.forEach((option) => { + const opt = document.createElement("option"); + opt.value = option.value; + opt.textContent = option.label; + if (option.value === selectedValue) { + opt.selected = true; + } + select.appendChild(opt); + }); + return select; +} + +function normalizeStatuses(catalog) { + const filters = catalog && catalog.filters ? catalog.filters : {}; + const statuses = Array.isArray(filters.statuses) ? filters.statuses : null; + if (Array.isArray(statuses)) { + return statuses; + } + return ["added", "in_progress", "not_released"]; +} + +function normalizeOrdering(catalog) { + const ordering = catalog && catalog.ordering ? catalog.ordering : {}; + const orderBy = ordering.order_by || "date_added"; + const orderDir = ordering.order_dir || "desc"; + return { orderBy, orderDir }; +} + +function renderBuiltInCatalogs() { + const container = document.getElementById("stremio-addon-catalogs"); + if (!container) { + return; + } + container.innerHTML = ""; + if (!addonState.catalogs.length) { + container.textContent = "No catalogs configured yet."; + return; + } + + const fragment = document.createDocumentFragment(); + addonState.catalogs.forEach((catalog) => { + const details = BUILTIN_CATALOG_DETAILS[catalog.id] || {}; + const titleText = catalog.name || details.title || catalog.id; + const descriptionText = details.description || "Catalog settings."; + const statuses = normalizeStatuses(catalog); + const ordering = normalizeOrdering(catalog); + + const card = document.createElement("div"); + card.className = "rounded-2xl border border-line/60 bg-surface/80 p-5"; + card.dataset.catalogId = catalog.id; + + const header = document.createElement("div"); + header.className = "flex flex-wrap items-center justify-between gap-3"; + const info = document.createElement("div"); + const title = document.createElement("h3"); + title.className = "font-display text-base font-semibold text-ink"; + title.textContent = titleText; + const desc = document.createElement("p"); + desc.className = "text-xs text-muted"; + desc.textContent = descriptionText; + info.appendChild(title); + info.appendChild(desc); + + const toggle = document.createElement("label"); + toggle.className = "inline-control"; + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = catalog.enabled !== false; + checkbox.dataset.catalogEnabled = "true"; + const toggleLabel = document.createElement("span"); + toggleLabel.textContent = "Enabled"; + toggle.appendChild(checkbox); + toggle.appendChild(toggleLabel); + + header.appendChild(info); + header.appendChild(toggle); + + const body = document.createElement("div"); + body.className = "mt-4 grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]"; + + const statusBlock = document.createElement("div"); + const statusLabel = document.createElement("p"); + statusLabel.className = "text-xs font-semibold uppercase tracking-[0.2em] text-muted"; + statusLabel.textContent = "Statuses"; + const statusGroup = document.createElement("div"); + statusGroup.className = "mt-2 flex flex-wrap gap-3"; + STATUS_OPTIONS.forEach((option) => { + const label = document.createElement("label"); + label.className = "inline-control"; + const input = document.createElement("input"); + input.type = "checkbox"; + input.value = option.value; + input.dataset.catalogStatus = "true"; + input.checked = statuses.includes(option.value); + const span = document.createElement("span"); + span.textContent = option.label; + label.appendChild(input); + label.appendChild(span); + statusGroup.appendChild(label); + }); + statusBlock.appendChild(statusLabel); + statusBlock.appendChild(statusGroup); + + const orderingBlock = document.createElement("div"); + orderingBlock.className = "space-y-3"; + const orderByField = document.createElement("label"); + orderByField.className = "field"; + const orderByLabel = document.createElement("span"); + orderByLabel.textContent = "Order by"; + const orderBySelect = buildSelect(ORDER_OPTIONS, ordering.orderBy); + orderBySelect.dataset.catalogOrderBy = "true"; + orderByField.appendChild(orderByLabel); + orderByField.appendChild(orderBySelect); + + const orderDirField = document.createElement("label"); + orderDirField.className = "field"; + const orderDirLabel = document.createElement("span"); + orderDirLabel.textContent = "Direction"; + const orderDirSelect = buildSelect( + [ + { value: "desc", label: "Descending" }, + { value: "asc", label: "Ascending" }, + ], + ordering.orderDir + ); + orderDirSelect.dataset.catalogOrderDir = "true"; + orderDirField.appendChild(orderDirLabel); + orderDirField.appendChild(orderDirSelect); + + orderingBlock.appendChild(orderByField); + orderingBlock.appendChild(orderDirField); + + body.appendChild(statusBlock); + body.appendChild(orderingBlock); + + const footer = document.createElement("div"); + footer.className = "mt-4 flex flex-wrap items-center justify-between gap-3"; + const idLabel = document.createElement("span"); + idLabel.className = "text-xs text-muted"; + idLabel.textContent = `Catalog ID: ${catalog.id}`; + const saveButton = document.createElement("button"); + saveButton.type = "button"; + saveButton.className = "btn btn-secondary btn-sm"; + saveButton.dataset.catalogSave = catalog.id; + saveButton.textContent = "Save changes"; + footer.appendChild(idLabel); + footer.appendChild(saveButton); + + card.appendChild(header); + card.appendChild(body); + card.appendChild(footer); + fragment.appendChild(card); + }); + container.appendChild(fragment); +} + +function renderCustomCatalogs() { + const container = document.getElementById("custom-catalog-list"); + if (!container) { + return; + } + container.innerHTML = ""; + if (!addonState.customCatalogs.length) { + const empty = document.createElement("p"); + empty.className = "empty-state"; + empty.textContent = "No custom catalogs yet. Create one above."; + container.appendChild(empty); + return; + } + + const fragment = document.createDocumentFragment(); + addonState.customCatalogs.forEach((catalog) => { + const card = document.createElement("div"); + card.className = "rounded-2xl border border-line/60 bg-surface/80 p-5"; + if (catalog.id === addonState.selectedCatalogId) { + card.classList.add("border-primary/50", "shadow-glow"); + } + card.dataset.customCatalogId = catalog.id; + + const header = document.createElement("div"); + header.className = "flex flex-wrap items-start justify-between gap-3"; + const info = document.createElement("div"); + const title = document.createElement("h3"); + title.className = "font-display text-base font-semibold text-ink"; + title.textContent = catalog.name; + const slug = document.createElement("p"); + slug.className = "text-xs text-muted"; + slug.textContent = `Slug: ${catalog.slug}`; + info.appendChild(title); + info.appendChild(slug); + + const actions = document.createElement("div"); + actions.className = "flex flex-wrap items-center gap-2"; + const manageButton = document.createElement("button"); + manageButton.type = "button"; + manageButton.className = "btn btn-secondary btn-sm"; + manageButton.dataset.customCatalogManage = catalog.id; + manageButton.textContent = + catalog.id === addonState.selectedCatalogId ? "Managing" : "Manage items"; + const deleteButton = document.createElement("button"); + deleteButton.type = "button"; + deleteButton.className = "btn btn-ghost btn-sm"; + deleteButton.dataset.customCatalogDelete = catalog.id; + deleteButton.textContent = "Delete"; + actions.appendChild(manageButton); + actions.appendChild(deleteButton); + + header.appendChild(info); + header.appendChild(actions); + + const body = document.createElement("div"); + body.className = "mt-4 grid gap-4 md:grid-cols-3"; + + const nameField = document.createElement("label"); + nameField.className = "field md:col-span-3"; + const nameLabel = document.createElement("span"); + nameLabel.textContent = "Name"; + const nameInput = document.createElement("input"); + nameInput.className = "input"; + nameInput.type = "text"; + nameInput.value = catalog.name; + nameInput.dataset.customCatalogName = "true"; + nameField.appendChild(nameLabel); + nameField.appendChild(nameInput); + + const typeField = document.createElement("label"); + typeField.className = "field"; + const typeLabel = document.createElement("span"); + typeLabel.textContent = "Type"; + const typeSelect = buildSelect( + [ + { value: "movie", label: "Movie" }, + { value: "tv", label: "TV" }, + { value: "anime", label: "Anime" }, + ], + catalog.media_type + ); + typeSelect.dataset.customCatalogType = "true"; + typeField.appendChild(typeLabel); + typeField.appendChild(typeSelect); + + const orderField = document.createElement("label"); + orderField.className = "field"; + const orderLabel = document.createElement("span"); + orderLabel.textContent = "Order"; + const orderSelect = buildSelect(CUSTOM_ORDER_OPTIONS, catalog.order_by || "manual"); + orderSelect.dataset.customCatalogOrderBy = "true"; + orderField.appendChild(orderLabel); + orderField.appendChild(orderSelect); + + const dirField = document.createElement("label"); + dirField.className = "field"; + const dirLabel = document.createElement("span"); + dirLabel.textContent = "Direction"; + const dirSelect = buildSelect( + [ + { value: "asc", label: "Ascending" }, + { value: "desc", label: "Descending" }, + ], + catalog.order_dir || "asc" + ); + dirSelect.dataset.customCatalogOrderDir = "true"; + dirField.appendChild(dirLabel); + dirField.appendChild(dirSelect); + + body.appendChild(nameField); + body.appendChild(typeField); + body.appendChild(orderField); + body.appendChild(dirField); + + const footer = document.createElement("div"); + footer.className = "mt-4 flex flex-wrap items-center justify-between gap-3"; + const updated = document.createElement("span"); + updated.className = "text-xs text-muted"; + const updatedAt = catalog.updated_at + ? `Updated ${formatMetadataDate(catalog.updated_at)}` + : "Updated —"; + updated.textContent = updatedAt; + const saveButton = document.createElement("button"); + saveButton.type = "button"; + saveButton.className = "btn btn-secondary btn-sm"; + saveButton.dataset.customCatalogUpdate = catalog.id; + saveButton.textContent = "Save changes"; + + footer.appendChild(updated); + footer.appendChild(saveButton); + + card.appendChild(header); + card.appendChild(body); + card.appendChild(footer); + + fragment.appendChild(card); + }); + container.appendChild(fragment); + + if (addonState.selectedCatalogId) { + renderCustomCatalogItemsPanel(); + } +} + +function renderCustomCatalogItemsPanel() { + const panel = document.getElementById("custom-catalog-items-panel"); + if (!panel) { + return; + } + const catalog = addonState.customCatalogs.find( + (entry) => entry.id === addonState.selectedCatalogId + ); + if (!catalog) { + panel.hidden = true; + return; + } + panel.hidden = false; + const subtitle = document.getElementById("custom-catalog-items-subtitle"); + if (subtitle) { + subtitle.textContent = `${catalog.name} · ${catalog.media_type.toUpperCase()} · ${catalog.slug}`; + } + const orderHint = document.getElementById("custom-catalog-items-order-hint"); + if (orderHint) { + orderHint.textContent = + catalog.order_by === "manual" + ? "Manual ordering active." + : "Manual ordering stored for later."; + } +} + +function resetCustomLookupUI() { + const results = document.getElementById("custom-catalog-items-results"); + const candidates = document.getElementById("custom-catalog-items-candidates"); + if (results) { + results.hidden = true; + } + if (candidates) { + candidates.innerHTML = ""; + } + addonState.candidates = []; + addonState.lookupId = null; + setMessage("custom-catalog-items-message", ""); +} + +function clearCustomLookupTimer() { + if (addonState.lookupTimer) { + window.clearTimeout(addonState.lookupTimer); + addonState.lookupTimer = null; + } +} + +function catalogSearchScope(catalog) { + if (!catalog) { + return "all"; + } + if (catalog.media_type === "anime") { + return "anime"; + } + if (catalog.media_type === "tv") { + return "tv"; + } + return "movie"; +} + +function candidateMatchesItem(candidate, item) { + if (!candidate || !item) { + return false; + } + const pairs = [ + ["imdb_id"], + ["tmdb_id"], + ["tvdb_id"], + ["tvmaze_id"], + ["kitsu_id"], + ["myanimelist_id"], + ["anilist_id"], + ]; + return pairs.some(([key]) => { + const left = candidate[key]; + const right = item[key]; + return left && right && String(left) === String(right); + }); +} + +function renderCustomCandidates(candidates) { + const results = document.getElementById("custom-catalog-items-results"); + const container = document.getElementById("custom-catalog-items-candidates"); + const catalog = addonState.customCatalogs.find( + (entry) => entry.id === addonState.selectedCatalogId + ); + if (!results || !container || !catalog) { + return; + } + container.innerHTML = ""; + const matching = (candidates || []).filter( + (candidate) => candidate.media_type === catalog.media_type + ); + addonState.candidates = matching; + + if (!matching.length) { + container.textContent = "No matches found for this catalog."; + } else { + matching.forEach((candidate) => { + const row = document.createElement("div"); + row.className = "candidate-option"; + + const action = document.createElement("button"); + action.type = "button"; + action.className = "btn btn-secondary btn-sm"; + action.dataset.customItemAdd = candidate.id; + const alreadyAdded = addonState.items.some((item) => candidateMatchesItem(candidate, item)); + action.textContent = alreadyAdded ? "Added" : "Add"; + action.disabled = alreadyAdded; + + const poster = document.createElement("img"); + poster.className = "candidate-poster"; + if (candidate.poster_url) { + poster.src = candidate.poster_url; + poster.alt = `${candidate.title} poster`; + poster.loading = "lazy"; + } else { + poster.alt = ""; + } + + const meta = document.createElement("div"); + meta.className = "candidate-meta"; + const title = document.createElement("h3"); + title.textContent = candidate.title || "Unknown title"; + const detail = document.createElement("p"); + const year = candidate.year ? candidate.year : "Year unknown"; + detail.textContent = `${year} · ${candidate.provider.toUpperCase()}`; + meta.appendChild(title); + meta.appendChild(detail); + + row.appendChild(action); + row.appendChild(poster); + row.appendChild(meta); + container.appendChild(row); + }); + } + results.hidden = false; + setMessage("custom-catalog-items-message", ""); +} + +async function pollCustomLookupStatus(lookupId) { + try { + const data = await requestJSON(`/api/metadata/lookup/${lookupId}`); + if (data.status === "completed") { + renderCustomCandidates(data.candidates || []); + return; + } + if (data.status === "failed") { + setMessage("custom-catalog-items-message", data.error || "Lookup failed.", true); + return; + } + addonState.lookupTimer = window.setTimeout(() => pollCustomLookupStatus(lookupId), 1500); + } catch (error) { + setMessage("custom-catalog-items-message", error.message, true); + } +} + +async function handleCustomLookupSubmit(data) { + resetCustomLookupUI(); + clearCustomLookupTimer(); + const query = (data.get("query") || "").trim(); + if (!query) { + setMessage("custom-catalog-items-message", "Enter a title or ID to search.", true); + return; + } + const catalog = addonState.customCatalogs.find( + (entry) => entry.id === addonState.selectedCatalogId + ); + try { + setMessage("custom-catalog-items-message", "Searching..."); + const response = await requestJSON("/api/metadata/lookup", { + method: "POST", + body: JSON.stringify({ query, search_scope: catalogSearchScope(catalog) }), + }); + addonState.lookupId = response.lookup_id; + await pollCustomLookupStatus(response.lookup_id); + } catch (error) { + setMessage("custom-catalog-items-message", error.message, true); + } +} + +function getCustomCandidate(candidateId) { + return addonState.candidates.find((candidate) => candidate.id === candidateId) || null; +} + +function buildCustomItemPayload(candidate) { + return { + media_type: candidate.media_type, + title: candidate.title, + year: candidate.year || null, + poster_url: candidate.poster_url || null, + imdb_id: candidate.imdb_id || null, + tmdb_id: candidate.tmdb_id || null, + tvdb_id: candidate.tvdb_id || null, + tvmaze_id: candidate.tvmaze_id || null, + kitsu_id: candidate.kitsu_id || null, + myanimelist_id: candidate.myanimelist_id || null, + anilist_id: candidate.anilist_id || null, + }; +} + +async function handleCustomItemAdd(candidateId) { + const catalogId = addonState.selectedCatalogId; + if (!catalogId) { + return; + } + const candidate = getCustomCandidate(candidateId); + if (!candidate) { + setMessage("custom-catalog-items-message", "Select an item to add.", true); + return; + } + try { + await requestJSON(`/api/stremio-addon/custom-catalogs/${catalogId}/items`, { + method: "POST", + body: JSON.stringify(buildCustomItemPayload(candidate)), + }); + setMessage( + "custom-catalog-items-message", + `${candidate.title || "Item"} added to catalog.` + ); + await loadCustomCatalogItems(); + renderCustomCandidates(addonState.candidates); + } catch (error) { + setMessage("custom-catalog-items-message", error.message, true); + } +} + +function renderCustomItems() { + const container = document.getElementById("custom-catalog-items-list"); + if (!container) { + return; + } + container.innerHTML = ""; + if (!addonState.items.length) { + const empty = document.createElement("p"); + empty.className = "empty-state"; + empty.textContent = "No items yet. Add a few above."; + container.appendChild(empty); + return; + } + + const catalog = addonState.customCatalogs.find( + (entry) => entry.id === addonState.selectedCatalogId + ); + const allowManual = catalog ? catalog.order_by === "manual" : false; + + addonState.items.forEach((item, index) => { + const row = document.createElement("div"); + row.className = "candidate-option"; + + const actions = document.createElement("div"); + actions.className = "flex flex-col gap-2"; + const upButton = document.createElement("button"); + upButton.type = "button"; + upButton.className = "btn btn-ghost btn-xs"; + upButton.dataset.customItemMove = "up"; + upButton.dataset.mediaItemId = item.media_item_id; + upButton.textContent = "Up"; + upButton.disabled = index === 0 || !allowManual; + + const downButton = document.createElement("button"); + downButton.type = "button"; + downButton.className = "btn btn-ghost btn-xs"; + downButton.dataset.customItemMove = "down"; + downButton.dataset.mediaItemId = item.media_item_id; + downButton.textContent = "Down"; + downButton.disabled = index === addonState.items.length - 1 || !allowManual; + + const removeButton = document.createElement("button"); + removeButton.type = "button"; + removeButton.className = "btn btn-ghost btn-xs"; + removeButton.dataset.customItemRemove = item.media_item_id; + removeButton.textContent = "Remove"; + + actions.appendChild(upButton); + actions.appendChild(downButton); + actions.appendChild(removeButton); + + const poster = document.createElement("img"); + poster.className = "candidate-poster"; + if (item.poster_url) { + poster.src = item.poster_url; + poster.alt = `${item.title} poster`; + poster.loading = "lazy"; + } else { + poster.alt = ""; + } + + const meta = document.createElement("div"); + meta.className = "candidate-meta"; + const title = document.createElement("h3"); + title.textContent = item.title || "Unknown title"; + const detail = document.createElement("p"); + const year = item.year ? ` (${item.year})` : ""; + detail.textContent = `${item.media_type.toUpperCase()}${year}`; + meta.appendChild(title); + meta.appendChild(detail); + + row.appendChild(actions); + row.appendChild(poster); + row.appendChild(meta); + container.appendChild(row); + }); +} + +async function loadCustomCatalogItems() { + const catalogId = addonState.selectedCatalogId; + const container = document.getElementById("custom-catalog-items-list"); + if (!catalogId || !container) { + return; + } + container.textContent = "Loading..."; + try { + const data = await requestJSON( + `/api/stremio-addon/custom-catalogs/${catalogId}/items` + ); + addonState.items = data && Array.isArray(data.items) ? data.items : []; + renderCustomItems(); + if (addonState.candidates.length) { + renderCustomCandidates(addonState.candidates); + } + } catch (error) { + container.innerHTML = ""; + setMessage("custom-catalog-items-message", error.message, true); + } +} + +async function handleCustomItemRemove(mediaItemId) { + const catalogId = addonState.selectedCatalogId; + if (!catalogId) { + return; + } + setMessage("custom-catalog-items-message", ""); + try { + await requestJSON( + `/api/stremio-addon/custom-catalogs/${catalogId}/items/${mediaItemId}`, + { method: "DELETE" } + ); + setMessage("custom-catalog-items-message", "Item removed."); + await loadCustomCatalogItems(); + } catch (error) { + setMessage("custom-catalog-items-message", error.message, true); + } +} + +async function handleCustomItemMove(mediaItemId, direction) { + const catalogId = addonState.selectedCatalogId; + if (!catalogId) { + return; + } + const index = addonState.items.findIndex((item) => item.media_item_id === mediaItemId); + if (index < 0) { + return; + } + const targetIndex = direction === "up" ? index - 1 : index + 1; + if (targetIndex < 0 || targetIndex >= addonState.items.length) { + return; + } + const reordered = addonState.items.slice(); + const [moved] = reordered.splice(index, 1); + reordered.splice(targetIndex, 0, moved); + try { + await requestJSON(`/api/stremio-addon/custom-catalogs/${catalogId}/reorder`, { + method: "POST", + body: JSON.stringify({ + media_item_ids: reordered.map((item) => item.media_item_id), + }), + }); + addonState.items = reordered; + renderCustomItems(); + } catch (error) { + setMessage("custom-catalog-items-message", error.message, true); + await loadCustomCatalogItems(); + } +} + +async function handleCustomCatalogCreate(data, form) { + setMessage("custom-catalog-message", ""); + const payload = { + name: (data.get("name") || "").trim(), + media_type: data.get("media_type") || "movie", + order_by: data.get("order_by") || "manual", + order_dir: data.get("order_dir") || "asc", + }; + if (!payload.name) { + setMessage("custom-catalog-message", "Name is required.", true); + return; + } + try { + const response = await requestJSON("/api/stremio-addon/custom-catalogs", { + method: "POST", + body: JSON.stringify(payload), + }); + addonState.customCatalogs = [...addonState.customCatalogs, response]; + renderCustomCatalogs(); + if (form) { + form.reset(); + } + setMessage("custom-catalog-message", "Catalog created."); + } catch (error) { + setMessage("custom-catalog-message", error.message, true); + } +} + +async function handleCustomCatalogUpdate(catalogId, card) { + setMessage("custom-catalog-message", ""); + const nameInput = card.querySelector("[data-custom-catalog-name]"); + const typeSelect = card.querySelector("[data-custom-catalog-type]"); + const orderSelect = card.querySelector("[data-custom-catalog-order-by]"); + const dirSelect = card.querySelector("[data-custom-catalog-order-dir]"); + const payload = { + name: nameInput ? nameInput.value.trim() : "", + media_type: typeSelect ? typeSelect.value : null, + order_by: orderSelect ? orderSelect.value : null, + order_dir: dirSelect ? dirSelect.value : null, + }; + if (!payload.name) { + setMessage("custom-catalog-message", "Name is required.", true); + return; + } + try { + const response = await requestJSON( + `/api/stremio-addon/custom-catalogs/${catalogId}`, + { + method: "PATCH", + body: JSON.stringify(payload), + } + ); + addonState.customCatalogs = addonState.customCatalogs.map((entry) => + entry.id === catalogId ? response : entry + ); + renderCustomCatalogs(); + setMessage("custom-catalog-message", "Catalog updated."); + } catch (error) { + setMessage("custom-catalog-message", error.message, true); + } +} + +async function handleCustomCatalogDelete(catalogId) { + setMessage("custom-catalog-message", ""); + const confirmDelete = window.confirm( + "Delete this catalog? The items will be removed from Stremio." + ); + if (!confirmDelete) { + return; + } + try { + await requestJSON(`/api/stremio-addon/custom-catalogs/${catalogId}`, { + method: "DELETE", + }); + addonState.customCatalogs = addonState.customCatalogs.filter( + (entry) => entry.id !== catalogId + ); + if (addonState.selectedCatalogId === catalogId) { + addonState.selectedCatalogId = null; + addonState.items = []; + resetCustomLookupUI(); + const panel = document.getElementById("custom-catalog-items-panel"); + if (panel) { + panel.hidden = true; + } + } + renderCustomCatalogs(); + setMessage("custom-catalog-message", "Catalog deleted."); + } catch (error) { + setMessage("custom-catalog-message", error.message, true); + } +} + +async function handleEnableSave() { + setControlsMessage(""); + const enabledToggle = document.getElementById("stremio-addon-enabled"); + if (!enabledToggle) { + return; + } + try { + const response = await requestJSON("/api/stremio-addon/config", { + method: "POST", + body: JSON.stringify({ is_enabled: enabledToggle.checked }), + }); + if (!addonState.config) { + addonState.config = {}; + } + addonState.config.is_enabled = response.is_enabled; + setControlsMessage("Status saved."); + } catch (error) { + setControlsMessage(error.message, true); + } +} + +async function handleCatalogSave(button) { + const catalogId = button.dataset.catalogSave; + const card = button.closest("[data-catalog-id]"); + if (!catalogId || !card) { + return; + } + setAddonMessage(""); + const enabledToggle = card.querySelector("[data-catalog-enabled]"); + const statusChecks = Array.from(card.querySelectorAll("[data-catalog-status]")); + const orderBySelect = card.querySelector("[data-catalog-order-by]"); + const orderDirSelect = card.querySelector("[data-catalog-order-dir]"); + const statuses = statusChecks + .filter((input) => input.checked) + .map((input) => input.value); + const payload = { + catalogs: [ + { + id: catalogId, + enabled: enabledToggle ? enabledToggle.checked : true, + filters: { statuses }, + ordering: { + order_by: orderBySelect ? orderBySelect.value : "date_added", + order_dir: orderDirSelect ? orderDirSelect.value : "desc", + }, + }, + ], + }; + try { + const response = await requestJSON("/api/stremio-addon/config", { + method: "POST", + body: JSON.stringify(payload), + }); + addonState.catalogs = response && Array.isArray(response.catalogs) ? response.catalogs : []; + renderBuiltInCatalogs(); + setAddonMessage("Catalog updated."); + } catch (error) { + setAddonMessage(error.message, true); + } +} + +async function loadAddonConfig() { + try { + const data = await requestJSON("/api/stremio-addon/config"); + addonState.config = data; + addonState.catalogs = Array.isArray(data.catalogs) ? data.catalogs : []; + addonState.customCatalogs = Array.isArray(data.custom_catalogs) ? data.custom_catalogs : []; + updateInstallLinks(data); + renderInstallSection(); + renderControlSection(); + renderBuiltInCatalogs(); + renderCustomCatalogs(); + } catch (error) { + setAddonMessage(error.message, true); + } +} + +async function copyValue(value, messageId) { + if (!value) { + setMessage(messageId, "Nothing to copy.", true); + return; + } + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(value); + } else { + const temp = document.createElement("textarea"); + temp.value = value; + temp.setAttribute("readonly", ""); + temp.style.position = "absolute"; + temp.style.left = "-9999px"; + document.body.appendChild(temp); + temp.select(); + document.execCommand("copy"); + document.body.removeChild(temp); + } + setMessage(messageId, "Copied."); + } catch (error) { + setMessage(messageId, "Copy failed.", true); + } +} + +function bindAddonActions() { + const enableSave = document.getElementById("stremio-addon-enable-save"); + if (enableSave) { + enableSave.addEventListener("click", handleEnableSave); + } + + const manifestCopy = document.getElementById("stremio-manifest-copy"); + if (manifestCopy) { + manifestCopy.addEventListener("click", () => + copyValue(addonState.installLinks.manifestUrl, "stremio-install-message") + ); + } + const installCopy = document.getElementById("stremio-install-copy"); + if (installCopy) { + installCopy.addEventListener("click", () => + copyValue(addonState.installLinks.installUrl, "stremio-install-message") + ); + } + + const catalogs = document.getElementById("stremio-addon-catalogs"); + if (catalogs) { + catalogs.addEventListener("click", (event) => { + const button = event.target.closest("[data-catalog-save]"); + if (!button) { + return; + } + handleCatalogSave(button); + }); + } + + const customList = document.getElementById("custom-catalog-list"); + if (customList) { + customList.addEventListener("click", (event) => { + const manage = event.target.closest("[data-custom-catalog-manage]"); + if (manage) { + addonState.selectedCatalogId = manage.dataset.customCatalogManage; + renderCustomCatalogs(); + resetCustomLookupUI(); + loadCustomCatalogItems(); + return; + } + const update = event.target.closest("[data-custom-catalog-update]"); + if (update) { + const card = update.closest("[data-custom-catalog-id]"); + if (card) { + handleCustomCatalogUpdate(update.dataset.customCatalogUpdate, card); + } + return; + } + const remove = event.target.closest("[data-custom-catalog-delete]"); + if (remove) { + handleCustomCatalogDelete(remove.dataset.customCatalogDelete); + } + }); + } + + const closeItems = document.getElementById("custom-catalog-items-close"); + if (closeItems) { + closeItems.addEventListener("click", () => { + addonState.selectedCatalogId = null; + addonState.items = []; + resetCustomLookupUI(); + const panel = document.getElementById("custom-catalog-items-panel"); + if (panel) { + panel.hidden = true; + } + renderCustomCatalogs(); + }); + } + + const candidates = document.getElementById("custom-catalog-items-candidates"); + if (candidates) { + candidates.addEventListener("click", (event) => { + const button = event.target.closest("[data-custom-item-add]"); + if (!button) { + return; + } + handleCustomItemAdd(button.dataset.customItemAdd); + }); + } + + const itemsList = document.getElementById("custom-catalog-items-list"); + if (itemsList) { + itemsList.addEventListener("click", (event) => { + const moveButton = event.target.closest("[data-custom-item-move]"); + if (moveButton) { + handleCustomItemMove( + moveButton.dataset.mediaItemId, + moveButton.dataset.customItemMove + ); + return; + } + const removeButton = event.target.closest("[data-custom-item-remove]"); + if (removeButton) { + handleCustomItemRemove(removeButton.dataset.customItemRemove); + } + }); + } +} + +window.librarysyncPageInit = async ({ user }) => { + if (!user) { + return; + } + bindForm("custom-catalog-form", handleCustomCatalogCreate); + bindForm("custom-catalog-items-lookup-form", handleCustomLookupSubmit); + bindAddonActions(); + await loadAddonConfig(); +}; diff --git a/backend/src/librarysync/static/styles.css b/backend/src/librarysync/static/styles.css index 7cbe2d6..b11852d 100644 --- a/backend/src/librarysync/static/styles.css +++ b/backend/src/librarysync/static/styles.css @@ -712,6 +712,18 @@ h1, } } +.\!card { + border-radius: 1.5rem; + border-width: 1px; + border-color: rgb(var(--color-line) / 0.6); + --tw-bg-opacity: 1; + background-color: rgb(var(--color-surface) / var(--tw-bg-opacity, 1)); + padding: 1.5rem; + --tw-shadow: 0 18px 40px -24px rgba(15, 27, 36, 0.45); + --tw-shadow-colored: 0 18px 40px -24px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + .card { border-radius: 1.5rem; border-width: 1px; @@ -1480,6 +1492,15 @@ h1, color: rgb(var(--color-muted) / var(--tw-text-opacity, 1)); } +.icon-stremio { + display: inline-block; + width: 18px; + height: 18px; + background-color: currentColor; + mask: url("/static/icons/stremio.svg") no-repeat center / contain; + -webkit-mask: url("/static/icons/stremio.svg") no-repeat center / contain; +} + .episode-picker { border-radius: 1rem; border-width: 1px; @@ -3002,6 +3023,12 @@ h1, margin-bottom: calc(1rem * var(--tw-space-y-reverse)); } +.space-y-5 > :not([hidden]) ~ :not([hidden]) { + --tw-space-y-reverse: 0; + margin-top: calc(1.25rem * calc(1 - var(--tw-space-y-reverse))); + margin-bottom: calc(1.25rem * var(--tw-space-y-reverse)); +} + .space-y-6 > :not([hidden]) ~ :not([hidden]) { --tw-space-y-reverse: 0; margin-top: calc(1.5rem * calc(1 - var(--tw-space-y-reverse))); @@ -3080,6 +3107,10 @@ h1, border-color: rgb(var(--color-primary) / 0.2); } +.border-primary\/50 { + border-color: rgb(var(--color-primary) / 0.5); +} + .bg-amber-500\/10 { background-color: rgb(245 158 11 / 0.1); } @@ -3632,6 +3663,14 @@ h1, grid-template-columns: repeat(3, minmax(0, 1fr)); } + .sm\:flex-row { + flex-direction: row; + } + + .sm\:items-center { + align-items: center; + } + .sm\:justify-start { justify-content: flex-start; } @@ -3680,6 +3719,10 @@ h1, grid-column: span 3 / span 3; } + .md\:col-span-4 { + grid-column: span 4 / span 4; + } + .md\:flex { display: flex; } @@ -3700,6 +3743,10 @@ h1, grid-template-columns: repeat(3, minmax(0, 1fr)); } + .md\:grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + .md\:flex-row { flex-direction: row; } @@ -3743,6 +3790,10 @@ h1, grid-template-columns: repeat(4, minmax(0, 1fr)); } + .lg\:grid-cols-\[minmax\(0\2c 2fr\)_minmax\(0\2c 1fr\)\] { + grid-template-columns: minmax(0,2fr) minmax(0,1fr); + } + .lg\:px-8 { padding-left: 2rem; padding-right: 2rem; diff --git a/backend/src/librarysync/templates/base.html b/backend/src/librarysync/templates/base.html index dfa3cd2..f19900a 100644 --- a/backend/src/librarysync/templates/base.html +++ b/backend/src/librarysync/templates/base.html @@ -230,6 +230,10 @@ Settings + + + Stremio Addon + + + + +

+ Keep this URL somewhere safe for reinstalling. +

+ + + +
+
+

Addon controls

+

Enable or disable the addon.

+
+ +
+ +
+ +
+ + +
+
+
+

Built-in catalogs

+

Filter and order the default watchlist feeds.

+
+
+
Loading...
+
+ +
+
+
+

Custom catalogs

+

Curate hand-picked lists for Stremio.

+
+
+
+ + + + +
+ + +
+
+
Loading...
+
+ + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/backend/tests/test_stremio_addon_catalogs.py b/backend/tests/test_stremio_addon_catalogs.py new file mode 100644 index 0000000..dc0048c --- /dev/null +++ b/backend/tests/test_stremio_addon_catalogs.py @@ -0,0 +1,84 @@ +import asyncio +import copy +import sys +import unittest +from pathlib import Path +from types import SimpleNamespace + +from fastapi import HTTPException + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +sys.path.append(str(PROJECT_ROOT / "src")) + +from librarysync.api import routes_stremio_addon # noqa: E402 +from librarysync.api import routes_stremio_addon_public # noqa: E402 +from librarysync.core.stremio_addon import build_default_catalogs # noqa: E402 +from librarysync.db.models import MediaItem # noqa: E402 + + +class TestStremioAddonCatalogs(unittest.TestCase): + def test_merge_catalog_updates_does_not_mutate_existing(self) -> None: + existing = build_default_catalogs() + original = copy.deepcopy(existing) + update = routes_stremio_addon.StremioCatalogUpdate( + id="watchlist_movies", + enabled=False, + ordering=routes_stremio_addon.StremioCatalogOrdering(order_by="title"), + ) + merged = routes_stremio_addon._merge_catalog_updates(existing, [update]) + + self.assertEqual(existing, original) + movie_catalog = next(catalog for catalog in merged if catalog["id"] == "watchlist_movies") + self.assertFalse(movie_catalog["enabled"]) + self.assertEqual(movie_catalog["ordering"]["order_by"], "title") + + def test_build_manifest_includes_custom_and_enabled_catalogs(self) -> None: + catalogs = build_default_catalogs() + custom_catalog = SimpleNamespace(name="Curated Picks", slug="curated_picks", media_type="movie") + + manifest = routes_stremio_addon_public._build_manifest(catalogs, [custom_catalog]) + + catalog_ids = {catalog["id"] for catalog in manifest["catalogs"]} + self.assertIn("watchlist_movies", catalog_ids) + self.assertIn("watchlist_shows", catalog_ids) + self.assertIn("in_progress_shows", catalog_ids) + self.assertIn("curated_picks", catalog_ids) + self.assertNotIn("watchlist_anime", catalog_ids) + for catalog in manifest["catalogs"]: + self.assertIn("extraSupported", catalog) + + def test_build_meta_prefers_stremio_id(self) -> None: + media_item = MediaItem( + title="Example Movie", + media_type="movie", + imdb_id="tt1234567", + raw={"stremio_id": "stremio:movie:123"}, + ) + meta = routes_stremio_addon_public._build_meta(media_item, "movie") + self.assertIsNotNone(meta) + assert meta is not None + self.assertEqual(meta["id"], "stremio:movie:123") + self.assertEqual(meta["type"], "movie") + + def test_in_progress_query_applies_status_filter(self) -> None: + catalog = {"filters": {"statuses": ["added"]}} + + query = asyncio.run( + routes_stremio_addon_public._build_in_progress_query("user-id", catalog, None) + ) + compiled = str(query.compile(compile_kwargs={"literal_binds": True})).lower() + self.assertIn("watchlist_items.status in", compiled) + + def test_slugify_normalizes(self) -> None: + self.assertEqual(routes_stremio_addon._slugify("Curated Picks!"), "curated-picks") + + def test_slugify_falls_back_for_empty(self) -> None: + self.assertEqual(routes_stremio_addon._slugify(" "), "catalog") + + def test_reorder_map_requires_all_items(self) -> None: + with self.assertRaises(HTTPException): + routes_stremio_addon._build_reorder_map(["a", "b"], ["a"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_watchlist_filters.py b/backend/tests/test_watchlist_filters.py new file mode 100644 index 0000000..36316f3 --- /dev/null +++ b/backend/tests/test_watchlist_filters.py @@ -0,0 +1,61 @@ +import sys +from dataclasses import dataclass +from datetime import date +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +sys.path.append(str(PROJECT_ROOT / "src")) + +from librarysync.core.watchlist import determine_show_watchlist_status # noqa: E402 + + +@dataclass(frozen=True) +class FakeWatchlistItem: + item_id: str + status: str + + +def _build_tv_item( + item_id: str, + *, + total_released: int, + watched_count: int, + now_date: date, +) -> FakeWatchlistItem: + status = determine_show_watchlist_status( + total_released=total_released, + watched_count=watched_count, + first_air_date=now_date, + earliest_air_date=now_date, + now_date=now_date, + ) + return FakeWatchlistItem(item_id=item_id, status=status) + + +def _apply_status_filter( + items: list[FakeWatchlistItem], + statuses: list[str] | None, +) -> list[FakeWatchlistItem]: + if statuses: + return [item for item in items if item.status in statuses] + return list(items) + + +def test_tv_watchlist_filters_select_expected_statuses() -> None: + now = date(2024, 1, 1) + items = [ + _build_tv_item("added", total_released=10, watched_count=0, now_date=now), + _build_tv_item("in_progress", total_released=10, watched_count=3, now_date=now), + _build_tv_item("watched", total_released=10, watched_count=10, now_date=now), + ] + + assert [item.status for item in items] == ["added", "in_progress", "watched"] + assert [item.item_id for item in _apply_status_filter(items, ["added"])] == ["added"] + assert [item.item_id for item in _apply_status_filter(items, ["in_progress"])] == [ + "in_progress" + ] + assert [item.item_id for item in _apply_status_filter(items, ["watched"])] == ["watched"] + assert [item.item_id for item in _apply_status_filter(items, ["added", "in_progress"])] == [ + "added", + "in_progress", + ] diff --git a/docs/stremio-catalogs-plan.md b/docs/stremio-catalogs-plan.md index 3457120..3885c02 100644 --- a/docs/stremio-catalogs-plan.md +++ b/docs/stremio-catalogs-plan.md @@ -14,6 +14,10 @@ - Streaming sources, meta enrichers, or providers beyond the local catalog. - Replacing existing watchlist UI or watchlist data model. +## Status (current) +- Done: backend data model + migration, addon config helpers, public Stremio endpoints keyed by addon config id, auth-required config endpoints, basic manifest + catalog responses, custom catalog CRUD + items/reorder endpoints, frontend page/JS/Tailwind build, tests for helpers/queries. +- Missing: none. + ## Reference/Research - Review Stremio addon manifest/catalog spec and best practices. - Inspect any local Stremio-related code and the aiometadata/aiostreams addon patterns for response structure and pagination. @@ -22,7 +26,7 @@ - Add a new burger-menu entry below Settings: "Stremio Addon". - New page `/stremio-addon` with: - Install section (manifest URL + stremio:// link). - - Addon key management (show + rotate). + - Addon controls (enable/disable). - Built-in catalogs (Watchlist, In progress) with filter + order controls. - Custom catalogs (CRUD + add/remove items). @@ -35,7 +39,6 @@ Suggested tables: - `stremio_addon_configs` - `user_id` (FK) - `is_enabled` (bool) - - `addon_key_hash` (string) + `addon_key_last_rotated_at` - `default_catalogs` (JSON): list of catalog definitions (id, name, media_type, filters, ordering, enabled) - `created_at`, `updated_at` @@ -53,7 +56,7 @@ Suggested tables: - `created_at` Notes: -- Addon access is key-based (no JWT). Store addon keys hashed; keep plaintext only at creation/rotation time. +- Addon access uses the addon config id in the URL (no JWT or separate key rotation). - If you prefer fewer tables, we can encode the built-in catalogs in JSON on the config table and only create a table for custom catalogs/items. - V2 catalog-only watchlists: - Add tables mirroring watchlist sources/items but scoped to the addon: @@ -69,8 +72,6 @@ Create endpoints under `/api/stremio-addon`: - Returns manifest URL, install link, enabled state, catalogs config, and custom catalogs. - `POST /api/stremio-addon/config` - Updates addon enablement and built-in catalog filters/order. -- `POST /api/stremio-addon/token/rotate` - - Rotates addon key and returns new manifest URL. - Custom catalogs CRUD: - `POST /api/stremio-addon/custom-catalogs` - `PATCH /api/stremio-addon/custom-catalogs/{catalog_id}` @@ -86,12 +87,12 @@ Create endpoints under `/api/stremio-addon`: - Optional: `POST /api/stremio-addon/watchlists/{watchlist_id}/refresh` ### Addon (public) Routes -Add a new router with public endpoints; all access is gated by the addon key in the URL. +Add a new router with public endpoints; all access is via the addon config id in the URL. Suggested URL scheme: -- `GET /stremio-addon/{addon_key}/manifest.json` -- `GET /stremio-addon/{addon_key}/catalog/{type}/{id}.json` -- (Optional) `GET /stremio-addon/{addon_key}/meta/{type}/{id}.json` +- `GET /stremio-addon/{addon_id}/manifest.json` +- `GET /stremio-addon/{addon_id}/catalog/{type}/{id}.json` +- (Optional) `GET /stremio-addon/{addon_id}/meta/{type}/{id}.json` Notes: - Use Stremio types: `movie` and `series`. Map `tv` and `anime` to `series`. @@ -151,7 +152,6 @@ UI sections: 1. Install - Manifest URL (copy button). - Direct install link (stremio://... per spec). - - Rotate key button. 2. Built-in catalogs - Toggle enable/disable. - Filters: unwatched, released, include watched, media type. @@ -167,16 +167,15 @@ UI sections: - Apply the same filters/order options as built-in watchlist catalogs. ## Security + Access -- Addon access uses a per-user addon key (random, rotatable); no auth cookies. -- Store addon key hashed; verify using constant-time compare. -- Enforce `is_enabled` flag; return 404/401 for disabled or invalid keys. +- Addon access uses the addon config id in the URL; no auth cookies. +- Enforce `is_enabled` flag; return 404 for disabled configs. ## Testing - Unit tests for: - Manifest generation includes expected catalogs. - Catalog filters (unwatched, released, in-progress) and ordering. - Custom catalog CRUD and item ordering. - - Addon key rotation and access control. + - Access control for disabled configs. - Integration tests for Stremio endpoints (FastAPI test client). ## Rollout Steps @@ -189,10 +188,4 @@ UI sections: None. ## Known Bugs (needs fixing) - - [P2] Persist default catalog updates when editing JSON config — /Users/twillems/Development/stremio/librarySync/backend/src/librarysync/api/routes_stremio_addon.py:60-70 - The catalog update path mutates the existing default_catalogs list/dicts in place and then reassigns the list. Because default_catalogs is a plain JSON column (not a MutableList), SQLAlchemy doesn’t track in-place JSON mutations; if the new list - compares equal to the old one, the UPDATE won’t include default_catalogs. This means toggling catalog enabled state or changing filters/order can appear to succeed in the response but revert on the next load. Consider deep-copying before mutation or - explicitly flagging the JSON column as modified. - - [P2] Apply catalog status filters to in-progress catalog — /Users/twillems/Development/stremio/librarySync/backend/src/librarysync/api/routes_stremio_addon_public.py:270-277 - The in-progress catalog query ignores the configured filters.statuses and only excludes removed. If a user disables certain statuses in the addon config (e.g., wants to exclude watched or not_released items), those settings are silently ignored for - the in_progress_shows catalog. This causes the in-progress catalog to return items the user explicitly filtered out. Consider applying the same status filter logic as _build_watchlist_query or pass the catalog’s filters into this helper. + - None (last two P2s fixed: JSON mutation persistence + in-progress status filters). diff --git a/frontend/input.css b/frontend/input.css index 13bc60b..aa96da6 100644 --- a/frontend/input.css +++ b/frontend/input.css @@ -403,6 +403,15 @@ @apply text-xs text-muted; } + .icon-stremio { + display: inline-block; + width: 18px; + height: 18px; + background-color: currentColor; + mask: url("/static/icons/stremio.svg") no-repeat center / contain; + -webkit-mask: url("/static/icons/stremio.svg") no-repeat center / contain; + } + .episode-picker { @apply rounded-xl border border-line/70 bg-elevated/70 p-4; } From dd1eeac020b751cf908d07c36b70d4836655bc37 Mon Sep 17 00:00:00 2001 From: Thomas Willems Date: Sat, 17 Jan 2026 20:05:29 +0100 Subject: [PATCH 4/7] catalogs working --- backend/pyproject.toml | 2 +- .../librarysync/api/routes_stremio_addon.py | 10 +- .../api/routes_stremio_addon_public.py | 58 ++++++++-- backend/src/librarysync/core/stremio_addon.py | 34 +++++- .../c9a6b5d7e8f1_add_stremio_addon_tables.py | 18 ---- ...a1b7c3e9_drop_stremio_addon_key_columns.py | 56 ---------- .../librarysync/static/page-stremio-addon.js | 101 +++++++++++++----- uv.lock | 2 +- 8 files changed, 160 insertions(+), 121 deletions(-) delete mode 100644 backend/src/librarysync/db/migrations/versions/d2f6a1b7c3e9_drop_stremio_addon_key_columns.py diff --git a/backend/pyproject.toml b/backend/pyproject.toml index ee09e70..77ccdb5 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "librarysync" -version = "0.11.0" +version = "0.11.2" description = "librarySync backend" requires-python = ">=3.11" dependencies = [ diff --git a/backend/src/librarysync/api/routes_stremio_addon.py b/backend/src/librarysync/api/routes_stremio_addon.py index 944a48e..647f596 100644 --- a/backend/src/librarysync/api/routes_stremio_addon.py +++ b/backend/src/librarysync/api/routes_stremio_addon.py @@ -16,8 +16,8 @@ from librarysync.config import settings from librarysync.core.catalog_ordering import CatalogOrderBy from librarysync.core.stremio_addon import ( - build_default_catalogs, ensure_addon_config, + normalize_default_catalogs, ) from librarysync.core.watchlist import ( apply_media_id_update, @@ -58,6 +58,7 @@ class StremioCatalogUpdate(BaseModel): enabled: bool | None = None filters: StremioCatalogFilters | None = None ordering: StremioCatalogOrdering | None = None + showInHome: bool | None = None class StremioAddonConfigUpdate(BaseModel): @@ -247,6 +248,8 @@ def _merge_catalog_updates( catalog["filters"] = update.filters.model_dump(exclude_none=True) if update.ordering is not None: catalog["ordering"] = update.ordering.model_dump(exclude_none=True) + if update.showInHome is not None: + catalog["showInHome"] = bool(update.showInHome) return list(by_id.values()) @@ -254,9 +257,8 @@ async def _ensure_default_catalogs( db: AsyncSession, config: StremioAddonConfig, ) -> list[dict]: - catalogs = config.default_catalogs - if not catalogs: - catalogs = build_default_catalogs() + catalogs = normalize_default_catalogs(config.default_catalogs) + if config.default_catalogs != catalogs: config.default_catalogs = catalogs db.add(config) await db.commit() diff --git a/backend/src/librarysync/api/routes_stremio_addon_public.py b/backend/src/librarysync/api/routes_stremio_addon_public.py index 6b96247..a6c113a 100644 --- a/backend/src/librarysync/api/routes_stremio_addon_public.py +++ b/backend/src/librarysync/api/routes_stremio_addon_public.py @@ -10,7 +10,12 @@ from librarysync.api.deps import get_db from librarysync.core.catalog_ordering import apply_catalog_ordering -from librarysync.core.stremio_addon import build_default_catalogs, get_addon_config_by_id +from librarysync.core.stremio_addon import ( + DEFAULT_PAGE_SIZE, + DEFAULT_SHOW_IN_HOME, + get_addon_config_by_id, + normalize_default_catalogs, +) from librarysync.db.models import ( EpisodeItem, MediaItem, @@ -120,15 +125,48 @@ def _resolve_pagination(request: Request) -> tuple[int, int, str | None]: def _normalize_catalogs(config_catalogs: list[dict] | None) -> list[dict]: - if not config_catalogs: - return build_default_catalogs() - return config_catalogs + return normalize_default_catalogs(config_catalogs) def _catalogs_by_id(catalogs: list[dict]) -> dict[str, dict]: return {catalog.get("id"): catalog for catalog in catalogs if catalog.get("id")} +def _resolve_status_filter(catalog: dict | None, base_statuses: list[str]) -> list[str]: + filters = catalog.get("filters") if isinstance(catalog, dict) else {} + statuses = filters.get("statuses") if isinstance(filters, dict) else None + extras: list[str] = [] + if isinstance(statuses, list): + for status_value in statuses: + if not status_value: + continue + status = str(status_value) + if status == "added": + continue + if status in base_statuses: + continue + extras.append(status) + return list(dict.fromkeys([*base_statuses, *extras])) + + +def _coerce_page_size(catalog: dict | None) -> int: + if not isinstance(catalog, dict): + return DEFAULT_PAGE_SIZE + value = catalog.get("pageSize") + if isinstance(value, int) and value > 0: + return value + return DEFAULT_PAGE_SIZE + + +def _coerce_show_in_home(catalog: dict | None) -> bool: + if not isinstance(catalog, dict): + return DEFAULT_SHOW_IN_HOME + value = catalog.get("showInHome") + if isinstance(value, bool): + return value + return DEFAULT_SHOW_IN_HOME + + def _build_manifest( catalogs: list[dict], custom_catalogs: list[StremioCustomCatalog], @@ -146,12 +184,16 @@ def _build_manifest( stremio_type = _resolve_stremio_type(str(media_type)) if not stremio_type: continue + page_size = _coerce_page_size(catalog) + show_in_home = _coerce_show_in_home(catalog) manifest_catalogs.append( { "type": stremio_type, "id": catalog_id, "name": catalog.get("name") or catalog_id, "extraSupported": STREMIO_EXTRA, + "pageSize": page_size, + "showInHome": show_in_home, } ) seen_types.add(stremio_type) @@ -166,6 +208,8 @@ def _build_manifest( "id": custom.slug, "name": custom.name, "extraSupported": STREMIO_EXTRA, + "pageSize": DEFAULT_PAGE_SIZE, + "showInHome": DEFAULT_SHOW_IN_HOME, } ) seen_types.add(stremio_type) @@ -201,8 +245,7 @@ async def _build_watchlist_query( query = query.where(WatchlistItem.type.in_(["tv", "anime"])) else: query = query.where(WatchlistItem.type == normalized) - filters = catalog.get("filters") if isinstance(catalog.get("filters"), dict) else {} - statuses = filters.get("statuses") if isinstance(filters, dict) else None + statuses = _resolve_status_filter(catalog, ["added"]) if statuses: query = query.where(WatchlistItem.status.in_(statuses)) if search: @@ -280,8 +323,7 @@ async def _build_in_progress_query( progress_subq.c.watched_count < progress_subq.c.total_released, ) ) - filters = catalog.get("filters") if isinstance(catalog, dict) else {} - statuses = filters.get("statuses") if isinstance(filters, dict) else None + statuses = _resolve_status_filter(catalog, ["in_progress"]) if statuses: query = query.where(WatchlistItem.status.in_(statuses)) if search: diff --git a/backend/src/librarysync/core/stremio_addon.py b/backend/src/librarysync/core/stremio_addon.py index 4841132..1aaf54a 100644 --- a/backend/src/librarysync/core/stremio_addon.py +++ b/backend/src/librarysync/core/stremio_addon.py @@ -9,38 +9,49 @@ from librarysync.db.models import StremioAddonConfig +DEFAULT_PAGE_SIZE = 30 +DEFAULT_SHOW_IN_HOME = True + DEFAULT_CATALOGS: list[dict[str, Any]] = [ { "id": "watchlist_movies", "name": "Watchlist Movies", "media_type": "movie", "enabled": True, - "filters": {"statuses": ["added", "in_progress", "not_released"]}, + "filters": {"statuses": []}, "ordering": {"order_by": "date_added", "order_dir": "desc"}, + "pageSize": DEFAULT_PAGE_SIZE, + "showInHome": DEFAULT_SHOW_IN_HOME, }, { "id": "watchlist_shows", "name": "Watchlist Shows", "media_type": "tv", "enabled": True, - "filters": {"statuses": ["added", "in_progress", "not_released"]}, + "filters": {"statuses": []}, "ordering": {"order_by": "date_added", "order_dir": "desc"}, + "pageSize": DEFAULT_PAGE_SIZE, + "showInHome": DEFAULT_SHOW_IN_HOME, }, { "id": "watchlist_anime", "name": "Watchlist Anime", "media_type": "anime", "enabled": False, - "filters": {"statuses": ["added", "in_progress", "not_released"]}, + "filters": {"statuses": []}, "ordering": {"order_by": "date_added", "order_dir": "desc"}, + "pageSize": DEFAULT_PAGE_SIZE, + "showInHome": DEFAULT_SHOW_IN_HOME, }, { "id": "in_progress_shows", "name": "In Progress", "media_type": "tv", "enabled": True, - "filters": {"statuses": ["added", "in_progress", "not_released"]}, + "filters": {"statuses": []}, "ordering": {"order_by": "episodes_left", "order_dir": "asc"}, + "pageSize": DEFAULT_PAGE_SIZE, + "showInHome": DEFAULT_SHOW_IN_HOME, }, ] @@ -49,6 +60,21 @@ def build_default_catalogs() -> list[dict[str, Any]]: return copy.deepcopy(DEFAULT_CATALOGS) +def normalize_default_catalogs(catalogs: list[dict[str, Any]] | None) -> list[dict[str, Any]]: + base = catalogs or build_default_catalogs() + normalized: list[dict[str, Any]] = [] + for catalog in base: + clone = copy.deepcopy(catalog) + page_size = clone.get("pageSize") + if not isinstance(page_size, int) or page_size <= 0: + clone["pageSize"] = DEFAULT_PAGE_SIZE + show_in_home = clone.get("showInHome") + if not isinstance(show_in_home, bool): + clone["showInHome"] = DEFAULT_SHOW_IN_HOME + normalized.append(clone) + return normalized + + async def get_addon_config_by_user( db: AsyncSession, user_id: str, diff --git a/backend/src/librarysync/db/migrations/versions/c9a6b5d7e8f1_add_stremio_addon_tables.py b/backend/src/librarysync/db/migrations/versions/c9a6b5d7e8f1_add_stremio_addon_tables.py index 54480c8..6a80187 100644 --- a/backend/src/librarysync/db/migrations/versions/c9a6b5d7e8f1_add_stremio_addon_tables.py +++ b/backend/src/librarysync/db/migrations/versions/c9a6b5d7e8f1_add_stremio_addon_tables.py @@ -21,13 +21,6 @@ def upgrade() -> None: sa.Column("id", sa.String(length=36), nullable=False), sa.Column("user_id", sa.String(length=36), nullable=False), sa.Column("is_enabled", sa.Boolean(), nullable=False, server_default=sa.true()), - sa.Column("addon_key_hash", sa.String(length=64), nullable=False), - sa.Column( - "addon_key_last_rotated_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.text("CURRENT_TIMESTAMP"), - ), sa.Column("default_catalogs", sa.JSON(), nullable=True), sa.Column( "created_at", @@ -44,14 +37,6 @@ def upgrade() -> None: sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), sa.PrimaryKeyConstraint("id"), sa.UniqueConstraint("user_id", name="uq_stremio_addon_configs_user_id"), - sa.UniqueConstraint( - "addon_key_hash", name="uq_stremio_addon_configs_addon_key_hash" - ), - ) - op.create_index( - "ix_stremio_addon_configs_addon_key_hash", - "stremio_addon_configs", - ["addon_key_hash"], ) op.create_index( op.f("ix_stremio_addon_configs_user_id"), @@ -147,7 +132,4 @@ def downgrade() -> None: op.drop_index( op.f("ix_stremio_addon_configs_user_id"), table_name="stremio_addon_configs" ) - op.drop_index( - "ix_stremio_addon_configs_addon_key_hash", table_name="stremio_addon_configs" - ) op.drop_table("stremio_addon_configs") diff --git a/backend/src/librarysync/db/migrations/versions/d2f6a1b7c3e9_drop_stremio_addon_key_columns.py b/backend/src/librarysync/db/migrations/versions/d2f6a1b7c3e9_drop_stremio_addon_key_columns.py deleted file mode 100644 index da02c5e..0000000 --- a/backend/src/librarysync/db/migrations/versions/d2f6a1b7c3e9_drop_stremio_addon_key_columns.py +++ /dev/null @@ -1,56 +0,0 @@ -"""drop stremio addon key columns - -Revision ID: d2f6a1b7c3e9 -Revises: c9a6b5d7e8f1 -Create Date: 2026-02-01 00:00:00.000000 -""" - -from alembic import op -import sqlalchemy as sa - - -revision = "d2f6a1b7c3e9" -down_revision = "c9a6b5d7e8f1" -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.drop_index( - "ix_stremio_addon_configs_addon_key_hash", - table_name="stremio_addon_configs", - ) - op.drop_constraint( - "uq_stremio_addon_configs_addon_key_hash", - "stremio_addon_configs", - type_="unique", - ) - op.drop_column("stremio_addon_configs", "addon_key_last_rotated_at") - op.drop_column("stremio_addon_configs", "addon_key_hash") - - -def downgrade() -> None: - op.add_column( - "stremio_addon_configs", - sa.Column("addon_key_hash", sa.String(length=64), nullable=False, server_default=""), - ) - op.add_column( - "stremio_addon_configs", - sa.Column( - "addon_key_last_rotated_at", - sa.DateTime(timezone=True), - nullable=False, - server_default=sa.text("CURRENT_TIMESTAMP"), - ), - ) - op.alter_column("stremio_addon_configs", "addon_key_hash", server_default=None) - op.create_unique_constraint( - "uq_stremio_addon_configs_addon_key_hash", - "stremio_addon_configs", - ["addon_key_hash"], - ) - op.create_index( - "ix_stremio_addon_configs_addon_key_hash", - "stremio_addon_configs", - ["addon_key_hash"], - ) diff --git a/backend/src/librarysync/static/page-stremio-addon.js b/backend/src/librarysync/static/page-stremio-addon.js index 9499bb9..3dcaaf6 100644 --- a/backend/src/librarysync/static/page-stremio-addon.js +++ b/backend/src/librarysync/static/page-stremio-addon.js @@ -32,12 +32,18 @@ const BUILTIN_CATALOG_DETAILS = { }, }; -const STATUS_OPTIONS = [ - { value: "added", label: "Added" }, - { value: "in_progress", label: "In progress" }, - { value: "not_released", label: "Not released" }, - { value: "watched", label: "Watched" }, -]; +const STATUS_LABELS = { + watched: "Show watched", + not_released: "Show unreleased", + in_progress: "Show in progress", +}; + +const CATALOG_STATUS_OPTIONS = { + watchlist_movies: ["watched", "not_released"], + watchlist_shows: ["in_progress", "watched", "not_released"], + watchlist_anime: ["in_progress", "watched", "not_released"], + in_progress_shows: [], +}; const ORDER_OPTIONS = [ { value: "date_added", label: "Date added" }, @@ -128,11 +134,30 @@ function buildSelect(options, selectedValue) { function normalizeStatuses(catalog) { const filters = catalog && catalog.filters ? catalog.filters : {}; - const statuses = Array.isArray(filters.statuses) ? filters.statuses : null; - if (Array.isArray(statuses)) { - return statuses; + const statuses = Array.isArray(filters.statuses) ? filters.statuses : []; + return statuses.filter((status) => status && status !== "added"); +} + +function normalizeShowInHome(catalog) { + if (catalog && typeof catalog.showInHome === "boolean") { + return catalog.showInHome; } - return ["added", "in_progress", "not_released"]; + return true; +} + +function getCatalogStatusOptions(catalogId) { + const options = CATALOG_STATUS_OPTIONS[catalogId] || []; + return options.map((value) => ({ + value, + label: STATUS_LABELS[value] || value, + })); +} + +function buildCatalogStatuses(statusChecks) { + const selected = statusChecks + .filter((input) => input.checked) + .map((input) => input.value); + return Array.from(new Set(selected)); } function normalizeOrdering(catalog) { @@ -194,26 +219,44 @@ function renderBuiltInCatalogs() { const body = document.createElement("div"); body.className = "mt-4 grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]"; + const statusOptions = getCatalogStatusOptions(catalog.id); + const showInHomeValue = normalizeShowInHome(catalog); const statusBlock = document.createElement("div"); const statusLabel = document.createElement("p"); statusLabel.className = "text-xs font-semibold uppercase tracking-[0.2em] text-muted"; - statusLabel.textContent = "Statuses"; + statusLabel.textContent = "Visibility"; const statusGroup = document.createElement("div"); - statusGroup.className = "mt-2 flex flex-wrap gap-3"; - STATUS_OPTIONS.forEach((option) => { - const label = document.createElement("label"); - label.className = "inline-control"; - const input = document.createElement("input"); - input.type = "checkbox"; - input.value = option.value; - input.dataset.catalogStatus = "true"; - input.checked = statuses.includes(option.value); - const span = document.createElement("span"); - span.textContent = option.label; - label.appendChild(input); - label.appendChild(span); - statusGroup.appendChild(label); - }); + statusGroup.className = "mt-2 space-y-2"; + if (statusOptions.length) { + const toggleGroup = document.createElement("div"); + toggleGroup.className = "flex flex-wrap gap-3"; + statusOptions.forEach((option) => { + const label = document.createElement("label"); + label.className = "inline-control"; + const input = document.createElement("input"); + input.type = "checkbox"; + input.value = option.value; + input.dataset.catalogStatus = "true"; + input.checked = statuses.includes(option.value); + const span = document.createElement("span"); + span.textContent = option.label; + label.appendChild(input); + label.appendChild(span); + toggleGroup.appendChild(label); + }); + statusGroup.appendChild(toggleGroup); + } + const homeLabel = document.createElement("label"); + homeLabel.className = "inline-control"; + const homeInput = document.createElement("input"); + homeInput.type = "checkbox"; + homeInput.checked = showInHomeValue; + homeInput.dataset.catalogShowInHome = "true"; + const homeText = document.createElement("span"); + homeText.textContent = "Show in home"; + homeLabel.appendChild(homeInput); + homeLabel.appendChild(homeText); + statusGroup.appendChild(homeLabel); statusBlock.appendChild(statusLabel); statusBlock.appendChild(statusGroup); @@ -913,16 +956,16 @@ async function handleCatalogSave(button) { setAddonMessage(""); const enabledToggle = card.querySelector("[data-catalog-enabled]"); const statusChecks = Array.from(card.querySelectorAll("[data-catalog-status]")); + const showInHomeToggle = card.querySelector("[data-catalog-show-in-home]"); const orderBySelect = card.querySelector("[data-catalog-order-by]"); const orderDirSelect = card.querySelector("[data-catalog-order-dir]"); - const statuses = statusChecks - .filter((input) => input.checked) - .map((input) => input.value); + const statuses = buildCatalogStatuses(statusChecks); const payload = { catalogs: [ { id: catalogId, enabled: enabledToggle ? enabledToggle.checked : true, + showInHome: showInHomeToggle ? showInHomeToggle.checked : true, filters: { statuses }, ordering: { order_by: orderBySelect ? orderBySelect.value : "date_added", diff --git a/uv.lock b/uv.lock index e5e9497..c30cbeb 100644 --- a/uv.lock +++ b/uv.lock @@ -467,7 +467,7 @@ wheels = [ [[package]] name = "librarysync" -version = "0.11.0" +version = "0.11.2" source = { editable = "backend" } dependencies = [ { name = "alembic" }, From d5017ed696b4969994bc94b207a1bf043b47690e Mon Sep 17 00:00:00 2001 From: Thomas Willems Date: Sat, 17 Jan 2026 20:18:27 +0100 Subject: [PATCH 5/7] working in omni with pagination --- .../api/routes_stremio_addon_public.py | 63 +++++++++++++++++-- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/backend/src/librarysync/api/routes_stremio_addon_public.py b/backend/src/librarysync/api/routes_stremio_addon_public.py index a6c113a..7d527e9 100644 --- a/backend/src/librarysync/api/routes_stremio_addon_public.py +++ b/backend/src/librarysync/api/routes_stremio_addon_public.py @@ -111,10 +111,21 @@ def _apply_search_filter(query, search: str): return query.where(or_(*search_clauses)) -def _resolve_pagination(request: Request) -> tuple[int, int, str | None]: +def _resolve_pagination( + request: Request, + extra_overrides: dict[str, str] | None = None, +) -> tuple[int, int, str | None]: search = _extract_extra_param(request, "search") - skip = _parse_int_param(_extract_extra_param(request, "skip"), 0) - limit = _parse_int_param(_extract_extra_param(request, "limit"), 50) + if not search and extra_overrides: + search = extra_overrides.get("search") + skip_value = _extract_extra_param(request, "skip") + if not skip_value and extra_overrides: + skip_value = extra_overrides.get("skip") + limit_value = _extract_extra_param(request, "limit") + if not limit_value and extra_overrides: + limit_value = extra_overrides.get("limit") + skip = _parse_int_param(skip_value, 0) + limit = _parse_int_param(limit_value, 50) if skip < 0: skip = 0 if limit <= 0: @@ -124,6 +135,23 @@ def _resolve_pagination(request: Request) -> tuple[int, int, str | None]: return skip, limit, search +def _parse_extra_path(extra_path: str | None) -> dict[str, str]: + if not extra_path: + return {} + extras: dict[str, str] = {} + for segment in extra_path.split("&"): + if "=" not in segment: + continue + key, value = segment.split("=", 1) + normalized = key.strip() + if normalized.startswith("extra[") and normalized.endswith("]"): + normalized = normalized[6:-1] + if not normalized: + continue + extras[normalized] = value + return extras + + def _normalize_catalogs(config_catalogs: list[dict] | None) -> list[dict]: return normalize_default_catalogs(config_catalogs) @@ -396,6 +424,33 @@ async def stremio_addon_catalog( catalog_id: str, request: Request, db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + return await _serve_catalog(addon_id, catalog_type, catalog_id, request, db, None) + + +@router.get( + "/{addon_id}/catalog/{catalog_type}/{catalog_id}/{extra_path}.json", + include_in_schema=False, +) +async def stremio_addon_catalog_extra( + addon_id: str, + catalog_type: Literal["movie", "series"], + catalog_id: str, + extra_path: str, + request: Request, + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + extras = _parse_extra_path(extra_path) + return await _serve_catalog(addon_id, catalog_type, catalog_id, request, db, extras) + + +async def _serve_catalog( + addon_id: str, + catalog_type: Literal["movie", "series"], + catalog_id: str, + request: Request, + db: AsyncSession, + extra_overrides: dict[str, str] | None, ) -> dict[str, Any]: config = await get_addon_config_by_id(db, addon_id) if not config or not config.is_enabled: @@ -425,7 +480,7 @@ async def stremio_addon_catalog( if expected_type != catalog_type: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Catalog not found") - skip, limit, search = _resolve_pagination(request) + skip, limit, search = _resolve_pagination(request, extra_overrides) if custom_catalog: query = await _build_custom_catalog_query(custom_catalog, search) From 09c36a6a19cb8ddcc1ca5226bd9ae9a202ecd693 Mon Sep 17 00:00:00 2001 From: Thomas Willems Date: Sat, 17 Jan 2026 21:27:24 +0100 Subject: [PATCH 6/7] code cleanup --- AGENTS.md | 358 +++++++++++------- .../librarysync/api/routes_stremio_addon.py | 94 +++-- .../api/routes_stremio_addon_public.py | 192 ++++------ .../src/librarysync/api/routes_watchlist.py | 29 +- .../src/librarysync/core/catalog_ordering.py | 4 +- backend/src/librarysync/main.py | 40 +- .../librarysync/static/page-stremio-addon.js | 36 +- backend/tests/test_stremio_addon_catalogs.py | 8 +- 8 files changed, 385 insertions(+), 376 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d77f938..41b673b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,43 +1,41 @@ # librarySync — AGENTS.md -## 1) Mission / Current Snapshot +## 1) Mission -Build **librarySync**, a self-hosted, Docker-compose-deployable, **multi-user** hub for -watch history + ratings. The current code base focuses on **watched history** (manual and -imported) and syncs that history to **Trakt, SIMKL, Letterboxd, and Stremio**, backed by -an async metadata lookup/enrichment pipeline. +**librarySync** is a self-hosted, Docker-deployable, multi-user hub for watch history and ratings. It syncs watch history to **Trakt**, **SIMKL**, **Letterboxd**, and **Stremio**, powered by an async metadata lookup and enrichment pipeline. --- -## 2) Current Feature Set (Implemented) +## 2) Feature Set -- Multi-user auth with JWT cookies; optional registration toggle. -- Manual history management: add, update, delete, bulk delete; optional delete in integrations. -- Ratings support (0.5–5.0 stars) synced to providers where supported. -- Metadata providers (per user): TMDB, TVDB, IMDb, TVMaze, Kitsu, MyAnimeList. -- Async metadata lookup and enrichment (posters/IDs), with local cache reuse. -- Imports from Trakt, SIMKL, Letterboxd, Stremio (quick import + import all). -- Import-all queue (priority-ordered per user) + post-import history merge dedupe. -- Outbox-based delivery with retries + per-user rate limiting + configurable batch sizes. -- Configurable per-provider batch sizes for efficient large library syncing. -- Minimal UI (static HTML + JS): login, integrations, settings, activity, history, add-watched. +- **Authentication**: Multi-user JWT cookies with optional registration toggle +- **History Management**: Manual add, update, delete, bulk delete; optional deletion in integrations +- **Ratings**: 0.5–5.0 star ratings synced to supported providers +- **Metadata Providers**: TMDB, TVDB, IMDb, TVMaze, Kitsu, MyAnimeList (per-user configuration) +- **Metadata Pipeline**: Async lookup and enrichment (posters/IDs) with local cache reuse +- **Import Sources**: Trakt, SIMKL, Letterboxd, Stremio (quick import and full import) +- **Import Queue**: Priority-ordered per-user queue with post-import deduplication +- **Sync Engine**: Outbox-based delivery with retries, per-user rate limiting, and configurable batch sizes +- **UI**: Minimal static HTML/JS interface (login, integrations, settings, activity, history) --- -## 3) Architecture / Data Flow +## 3) Architecture -- **API**: FastAPI serves JSON endpoints and static UI under `/static`. -- **Worker**: async loops for outbox, metadata lookups, quick import, and import-all. -- **Canonical flow**: - 1. Manual add or import -> `MediaItem`/`EpisodeItem` + `WatchedItem` - 2. Append `WatchEvent` for auditing. - 3. Enqueue internal outbox job -> provider sync jobs + `WatchSync` rows. - 4. Worker delivers jobs and records `SyncAttempt` + `WatchSync` status. - 5. Metadata enrichment fills missing IDs/posters when possible. +### Components +- **API**: FastAPI serving JSON endpoints and static UI at `/static` +- **Worker**: Async loops for outbox processing, metadata lookups, quick import, and full import + +### Data Flow +1. Manual add or import → `MediaItem`/`EpisodeItem` + `WatchedItem` +2. Append `WatchEvent` for auditing +3. Enqueue internal outbox job → provider sync jobs + `WatchSync` rows +4. Worker delivers jobs and records `SyncAttempt` + `WatchSync` status +5. Metadata enrichment fills missing IDs/posters when available --- -## 4) Repository Layout (Actual) +## 4) Repository Layout ``` librarySync/ @@ -53,7 +51,7 @@ librarySync/ src/librarysync/ main.py # FastAPI app entry worker.py # Async worker entrypoint - config.py # env/config handling + config.py # Environment/config handling db/ models.py session.py @@ -105,203 +103,269 @@ librarySync/ letterboxd_import.py stremio_import.py merge_history.py - static/ - app.js - styles.css - templates/ - index.html - login.html - integrations.html - settings.html - activity.html - history.html - add-watched.html - settings/ - providers.html - metadata.html - watchlists.html - blacklist.html - activity.html - history.html - imports.html - preferences.html - modals.html - base.html - watchlist.html - offline.html - + static/ + app.js + styles.css + templates/ + index.html + login.html + integrations.html + settings.html + activity.html + history.html + add-watched.html + settings/ + providers.html + metadata.html + watchlists.html + blacklist.html + activity.html + history.html + imports.html + preferences.html + modals.html + base.html + watchlist.html + offline.html ``` --- -## 5) Data Model (DB Highlights) +## 5) Data Model + +### Core Tables +- **`users`**: Authentication and per-user settings (e.g., include adult content in search) +- **`integrations`**: Per-user provider configurations +- **`integration_secrets`**: Encrypted provider credentials +- **`media_items`**: Canonical movie/show catalog +- **`episode_items`**: Canonical episode catalog +- **`watched_items`**: Per-user watch history (watched_at, rating, source) + +### Audit & Sync +- **`watch_events`**: Append-only event log for imports and manual changes +- **`watch_syncs`**: Per-provider sync status with external IDs and error details +- **`outbox`**: Delivery queue for sync jobs +- **`sync_attempts`**: Attempt history for deliveries + +### Metadata & Jobs +- **`metadata_lookup_requests`**: Async lookup pipeline requests +- **`metadata_lookup_candidates`**: Async lookup pipeline candidates +- **`scheduled_jobs`**: Job leases for recurring worker tasks +- **`rate_limit_buckets`**: Per-user/provider token buckets -- `users`: auth + per-user settings (e.g., include adult in search). -- `integrations` + `integration_secrets`: per-user provider config + encrypted secrets. -- `media_items` + `episode_items`: canonical media catalog. -- `watched_items`: per-user watch history (watched_at, rating, source). -- `watch_events`: append-only event log for imports/manual changes. -- `watch_syncs`: per-provider sync status + external IDs/errors. -- `outbox` + `sync_attempts`: delivery queue and attempt history. -- `metadata_lookup_requests` + `metadata_lookup_candidates`: async lookup pipeline. -- `scheduled_jobs`: leases for recurring jobs. -- `rate_limit_buckets`: per-user/provider token buckets. -- `progress_events`: legacy progress model (not wired to outbox yet). +### Legacy +- **`progress_events`**: Progress model (not yet wired to outbox) --- ## 6) Integrations & Metadata Providers -- **Downstream + import**: Trakt (OAuth), SIMKL (OAuth), Letterboxd (client + refresh token), - Stremio (auth key). -- **Metadata providers**: TMDB (API key), TVDB (API key + optional PIN), IMDb, TVMaze, - Kitsu, MyAnimeList (no secrets). -- Provider configs live in `integrations`; sensitive fields in `integration_secrets`. +### Service Integrations (Sync + Import) +- **Trakt**: OAuth authentication +- **SIMKL**: OAuth authentication +- **Letterboxd**: Client credentials with refresh token +- **Stremio**: Auth key + +### Metadata Providers (Lookup Only) +- **TMDB**: API key required +- **TVDB**: API key required, optional PIN +- **IMDb**: No authentication required +- **TVMaze**: No authentication required +- **Kitsu**: No authentication required +- **MyAnimeList**: No authentication required + +**Storage**: Provider configurations in `integrations` table; sensitive credentials in `integration_secrets` (encrypted). --- ## 7) Worker Modes & Jobs -- `LIBRARYSYNC_WORKER_MODES`: `outbox`, `metadata`, `metadata_cache`, `quick_import`, `import_all`, `watchlist`, `merge_all_history`. -- `process_outbox` handles `push_watched`, `push_rating`, `update_history`, - `remove_history`, `update_log_entry`, `delete_log_entry`, `remove_watched`, - and internal `new_item_added`. -- `metadata_lookup` resolves lookup requests into candidates. -- `metadata_cache` scans recent lookup candidates and seeds `media_items` to speed up add-menu searches. -- `quick_import` runs the 7-day import window; `import_all` sequences providers per user. - `merge_history` runs after quick/import-all to dedupe same-day movie entries and repoint sync/outbox rows. -- `merge_all_history` runs periodically to permanently dedupe watched history entries in DB for all users (API still merges on-the-fly until DB is clean). +### Worker Modes +Configured via `LIBRARYSYNC_WORKER_MODES`: `outbox`, `metadata`, `metadata_cache`, `quick_import`, `import_all`, `watchlist`, `merge_all_history` + +### Job Types + +#### Outbox Processing (`process_outbox`) +Handles job types: `push_watched`, `push_rating`, `update_history`, `remove_history`, `update_log_entry`, `delete_log_entry`, `remove_watched`, and internal `new_item_added` + +#### Metadata Jobs +- **`metadata_lookup`**: Resolves lookup requests into candidates +- **`metadata_cache`**: Scans recent candidates and seeds `media_items` to accelerate search + +#### Import Jobs +- **`quick_import`**: Runs 7-day import window +- **`import_all`**: Sequences providers per user for full import +- **`merge_history`**: Post-import deduplication (same-day movie entries) and repoints sync/outbox rows +- **`merge_all_history`**: Periodic deduplication of all user history in database (API merges on-the-fly until DB is clean) --- -## 8) API Surface (Current) +## 8) API Surface ### Auth -- `POST /api/auth/register` (if enabled) -- `POST /api/auth/login` -- `POST /api/auth/logout` -- `GET /api/auth/me` +``` +POST /api/auth/register # If registration enabled +POST /api/auth/login +POST /api/auth/logout +GET /api/auth/me +``` ### Settings -- `GET /api/settings` -- `POST /api/settings` +``` +GET /api/settings +POST /api/settings +``` ### Integrations -- `GET /api/integrations` -- `POST /api/integrations/letterboxd` -- `POST /api/integrations/letterboxd/test` -- `POST /api/integrations/letterboxd/disconnect` -- `GET /api/integrations/trakt/start` -- `GET /api/integrations/trakt/callback` -- `POST /api/integrations/trakt/disconnect` -- `GET /api/integrations/simkl/start` -- `GET /api/integrations/simkl/callback` -- `POST /api/integrations/simkl/disconnect` -- `POST /api/integrations/stremio/login` -- `POST /api/integrations/stremio/disconnect` -- `POST /api/integrations/import/quick/schedule` -- `POST /api/integrations/import/quick` -- `POST /api/integrations/import/all` +``` +GET /api/integrations +POST /api/integrations/letterboxd +POST /api/integrations/letterboxd/test +POST /api/integrations/letterboxd/disconnect +GET /api/integrations/trakt/start +GET /api/integrations/trakt/callback +POST /api/integrations/trakt/disconnect +GET /api/integrations/simkl/start +GET /api/integrations/simkl/callback +POST /api/integrations/simkl/disconnect +POST /api/integrations/stremio/login +POST /api/integrations/stremio/disconnect +POST /api/integrations/import/quick/schedule +POST /api/integrations/import/quick +POST /api/integrations/import/all +``` ### Metadata -- `GET /api/metadata/providers` -- `POST /api/metadata/providers/{tmdb|tvdb|kitsu|tvmaze|imdb|myanimelist}` -- `POST /api/metadata/providers/{provider}/test` -- `POST /api/metadata/lookup` -- `GET /api/metadata/lookup/{lookup_id}` -- `GET /api/metadata/tv/{provider}/{provider_item_id}/seasons` -- `GET /api/metadata/tv/{provider}/{provider_item_id}/seasons/{season_number}/episodes` +``` +GET /api/metadata/providers +POST /api/metadata/providers/{tmdb|tvdb|kitsu|tvmaze|imdb|myanimelist} +POST /api/metadata/providers/{provider}/test +POST /api/metadata/lookup +GET /api/metadata/lookup/{lookup_id} +GET /api/metadata/tv/{provider}/{provider_item_id}/seasons +GET /api/metadata/tv/{provider}/{provider_item_id}/seasons/{season_number}/episodes +``` ### History -- `POST /api/history/items` -- `GET /api/history/items` -- `PATCH /api/history/items/{watched_id}` -- `DELETE /api/history/items` -- `DELETE /api/history/items/{watched_id}` -- `POST /api/history/items/bulk-delete` -- `POST /api/history/items/sync` +``` +POST /api/history/items +GET /api/history/items +PATCH /api/history/items/{watched_id} +DELETE /api/history/items +DELETE /api/history/items/{watched_id} +POST /api/history/items/bulk-delete +POST /api/history/items/sync +``` ### Activity / Status -- `GET /api/activity/events` -- `GET /api/activity/sessions` -- `GET /api/outbox` -- `GET /api/status` +``` +GET /api/activity/events +GET /api/activity/sessions +GET /api/outbox +GET /api/status +``` ### Admin (requires `X-API-Key`) -- `POST /api/admin/reset-outbox-jobs` -- `DELETE /api/admin/purge-jobs` -- `POST /api/admin/merge-history` +``` +POST /api/admin/reset-outbox-jobs +DELETE /api/admin/purge-jobs +POST /api/admin/merge-history +``` --- -## 9) Configuration (Env Vars) +## 9) Configuration +### Core Settings - `DATABASE_URL` - `LIBRARYSYNC_SECRET_KEY` - `LIBRARYSYNC_ADMIN_API_KEY` - `LIBRARYSYNC_BASE_URL` - `LOG_LEVEL` -- `HISTORY_LOOKBACK_DAYS` + +### Authentication & Authorization - `LIBRARYSYNC_JWT_ACCESS_TOKEN_MINUTES` - `LIBRARYSYNC_JWT_ALGORITHM` - `LIBRARYSYNC_ALLOW_REGISTRATION` -- `TRAKT_CLIENT_ID`, `TRAKT_CLIENT_SECRET` -- `SIMKL_CLIENT_ID`, `SIMKL_CLIENT_SECRET` + +### Import & History +- `HISTORY_LOOKBACK_DAYS` + +### OAuth Credentials +- `TRAKT_CLIENT_ID` +- `TRAKT_CLIENT_SECRET` +- `SIMKL_CLIENT_ID` +- `SIMKL_CLIENT_SECRET` + +### Worker Configuration - `LIBRARYSYNC_WORKER_MODES` - `LIBRARYSYNC_WORKER_OUTBOX_CONCURRENCY` - `LIBRARYSYNC_WORKER_METADATA_CONCURRENCY` - `LIBRARYSYNC_WORKER_METADATA_CACHE_CONCURRENCY` - `LIBRARYSYNC_WORKER_QUICK_IMPORT_CONCURRENCY` - `LIBRARYSYNC_WORKER_IMPORT_ALL_CONCURRENCY` + +### Rate Limiting - `LIBRARYSYNC_TRAKT_RATE_LIMIT_PER_MINUTE` - `LIBRARYSYNC_SIMKL_RATE_LIMIT_PER_MINUTE` - `LIBRARYSYNC_LETTERBOXD_RATE_LIMIT_PER_MINUTE` - `LIBRARYSYNC_STREMIO_RATE_LIMIT_PER_MINUTE` -- `LIBRARYSYNC_TRAKT_MAX_BATCH_SIZE` (default 750): Maximum items per Trakt batch request -- `LIBRARYSYNC_SIMKL_MAX_BATCH_SIZE` (default 750): Maximum items per SIMKL batch request + +### Batch Sizes +- `LIBRARYSYNC_TRAKT_MAX_BATCH_SIZE` (default: 750) +- `LIBRARYSYNC_SIMKL_MAX_BATCH_SIZE` (default: 750) --- ## 10) Security Requirements -- Encrypt secrets at rest using `LIBRARYSYNC_SECRET_KEY` (see `core/security.py`). -- Passwords hashed with bcrypt; enforce 8+ chars and 72-byte max (no truncation). -- OAuth state validation for Trakt/SIMKL. -- Never log raw secrets or tokens. +- **Secrets**: Encrypt at rest using `LIBRARYSYNC_SECRET_KEY` (see `core/security.py`) +- **Passwords**: Bcrypt hashing with 8+ character minimum and 72-byte maximum (no truncation) +- **OAuth**: State validation for Trakt and SIMKL flows +- **Logging**: Never log raw secrets or tokens --- -## 11) Observability / Debuggability +## 11) Observability + +### Audit Trail +Primary audit sources: `watch_events`, `outbox`, `sync_attempts`, `watch_syncs` -- `watch_events`, `outbox`, `sync_attempts`, and `watch_syncs` are the primary audit trail. -- `/api/status` surfaces schedule/queue state for UI. -- Provider responses and payloads are sanitized before storage/logging. +### Monitoring +- **`/api/status`**: Exposes schedule and queue state for UI +- **Provider responses**: Sanitized before storage and logging --- ## 12) Developer Guidance -- Keep connectors pure: **no DB writes inside connectors**. -- Use `watch_pipeline.py` helpers to enqueue sync jobs. -- Store secrets in `integration_secrets` (encrypted), not `integrations.config`. -- **Use `get_http_client()` from `core/http_client.py` for all HTTP requests**. This ensures consistent User-Agent headers (`librarySync Version/`) across all outbound requests to metadata providers and service integrations. -- Ruff is the linter; line length is 100 (see `pyproject.toml`). -- Do not edit `backend/src/librarysync/static/styles.css` directly; update `frontend/input.css`. -- When updating Tailwind styles in `frontend/input.css`, rebuild `backend/src/librarysync/static/styles.css`. +### Code Practices +- **Connectors**: Keep pure—no database writes inside connectors +- **Sync Jobs**: Use `watch_pipeline.py` helpers to enqueue sync jobs +- **Secrets**: Store in `integration_secrets` (encrypted), never in `integrations.config` +- **HTTP Requests**: Always use `get_http_client()` from `core/http_client.py` to ensure consistent User-Agent headers (`librarySync Version/`) + +### Tooling +- **Linter**: Ruff with 100-character line length (see `pyproject.toml`) +- **Styles**: Edit `frontend/input.css` (not `backend/src/librarysync/static/styles.css` directly) +- **Build**: Rebuild `backend/src/librarysync/static/styles.css` after Tailwind changes --- ## 13) Tests +### Existing Tests - `backend/tests/test_routes_history.py` - `backend/tests/test_stremio_watched_bitfield.py` - `backend/tests/test_http_client.py` -- Add tests around outbox transitions, import scheduling, and metadata lookups when changing those. + +### Testing Guidance +Add tests for outbox transitions, import scheduling, and metadata lookups when modifying those areas. --- -## 14) Known Gaps / Open Items +## 14) Known Gaps -- CI/CD pipeline still missing (see README TODO). -- UI/UX polish is still pending. -- Progress/scrobble ingestion exists in canonical models but is not wired to the outbox yet. +- CI/CD pipeline (see README TODO) +- UI/UX polish +- Progress/scrobble ingestion (exists in models but not wired to outbox) diff --git a/backend/src/librarysync/api/routes_stremio_addon.py b/backend/src/librarysync/api/routes_stremio_addon.py index 647f596..d280513 100644 --- a/backend/src/librarysync/api/routes_stremio_addon.py +++ b/backend/src/librarysync/api/routes_stremio_addon.py @@ -101,9 +101,8 @@ class StremioCustomCatalogReorder(BaseModel): def _resolve_base_url(request: Request) -> str: - if settings.base_url: - return settings.base_url.rstrip("/") - return str(request.base_url).rstrip("/") + base = settings.base_url or str(request.base_url) + return base.rstrip("/") def _build_manifest_links(base_url: str, addon_id: str) -> dict[str, str]: @@ -118,11 +117,9 @@ def _slugify(value: str) -> str: def _truncate_slug(value: str, suffix: str | None = None) -> str: - limit = 64 - (len(suffix) if suffix else 0) - trimmed = value[:limit].rstrip("-") - if suffix: - return f"{trimmed}{suffix}" - return trimmed + suffix_len = len(suffix) if suffix else 0 + trimmed = value[: 64 - suffix_len].rstrip("-") + return f"{trimmed}{suffix}" if suffix else trimmed async def _build_unique_slug( @@ -156,15 +153,12 @@ def _normalize_custom_order_by(value: str | None) -> str: if not value: return "manual" normalized = value.strip().lower() - if normalized in CUSTOM_ORDER_BY: - return normalized - return "manual" + return normalized if normalized in CUSTOM_ORDER_BY else "manual" def _normalize_custom_order_dir(value: str | None) -> str: - if value and value.strip().lower() in CUSTOM_ORDER_DIR: - return value.strip().lower() - return "asc" + normalized = value.strip().lower() if value else "" + return normalized if normalized in CUSTOM_ORDER_DIR else "asc" def _custom_catalog_out(catalog: StremioCustomCatalog) -> dict: @@ -291,7 +285,9 @@ async def _resolve_custom_media_item( if payload.media_item_id: media_item = await db.get(MediaItem, payload.media_item_id) if not media_item: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Media item not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Media item not found" + ) if media_item.media_type != catalog.media_type: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -335,13 +331,13 @@ async def _resolve_custom_media_item( ) if not media_item: - resolved_title = payload.title or fallback_title(ids) raw = {"source": "stremio_custom_catalog", "ids": ids} if payload.stremio_id: raw["stremio_id"] = payload.stremio_id + media_item = MediaItem( media_type=media_type, - title=resolved_title, + title=payload.title or fallback_title(ids), year=payload.year, poster_url=payload.poster_url, imdb_id=ids.get("imdb_id"), @@ -357,13 +353,16 @@ async def _resolve_custom_media_item( await db.flush() return media_item - apply_media_id_update(media_item, "imdb_id", ids.get("imdb_id")) - apply_media_id_update(media_item, "tmdb_id", ids.get("tmdb_id")) - apply_media_id_update(media_item, "tvdb_id", ids.get("tvdb_id")) - apply_media_id_update(media_item, "tvmaze_id", ids.get("tvmaze_id")) - apply_media_id_update(media_item, "kitsu_id", ids.get("kitsu_id")) - apply_media_id_update(media_item, "myanimelist_id", ids.get("myanimelist_id")) - apply_media_id_update(media_item, "anilist_id", ids.get("anilist_id")) + for id_field in [ + "imdb_id", + "tmdb_id", + "tvdb_id", + "tvmaze_id", + "kitsu_id", + "myanimelist_id", + "anilist_id", + ]: + apply_media_id_update(media_item, id_field, ids.get(id_field)) if payload.year is not None and media_item.year is None: media_item.year = payload.year if payload.poster_url and not media_item.poster_url: @@ -388,19 +387,19 @@ async def get_stremio_addon_config( ) -> dict: config = await ensure_addon_config(db, current_user.id) catalogs = await _ensure_default_catalogs(db, config) + custom_result = await db.execute( select(StremioCustomCatalog).where(StremioCustomCatalog.user_id == current_user.id) ) custom_catalogs = [_custom_catalog_out(catalog) for catalog in custom_result.scalars().all()] - manifest_links = _build_manifest_links(_resolve_base_url(request), config.id) - payload: dict[str, object] = { + + return { "addon_id": config.id, "is_enabled": bool(config.is_enabled), "catalogs": catalogs, "custom_catalogs": custom_catalogs, - **manifest_links, + **_build_manifest_links(_resolve_base_url(request), config.id), } - return payload @router.post( @@ -413,25 +412,22 @@ async def update_stremio_addon_config( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> dict: - result = await db.execute( - select(StremioAddonConfig).where(StremioAddonConfig.user_id == current_user.id) - ) - config = result.scalars().first() - if not config: - config = await ensure_addon_config(db, current_user.id) + config = await ensure_addon_config(db, current_user.id) catalogs = await _ensure_default_catalogs(db, config) - fields = payload.model_fields_set - if "is_enabled" in fields: + if "is_enabled" in payload.model_fields_set: config.is_enabled = bool(payload.is_enabled) + if payload.catalogs: catalogs = _merge_catalog_updates(catalogs, payload.catalogs) config.default_catalogs = catalogs flag_modified(config, "default_catalogs") + config.updated_at = datetime.now(timezone.utc) db.add(config) await db.commit() await db.refresh(config) + return { "is_enabled": config.is_enabled, "catalogs": config.default_catalogs, @@ -452,16 +448,14 @@ async def create_custom_catalog( name = payload.name.strip() if not name: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Name is required") - slug = await _build_unique_slug(db, current_user.id, name) - order_by = _normalize_custom_order_by(payload.order_by) - order_dir = _normalize_custom_order_dir(payload.order_dir) + catalog = StremioCustomCatalog( user_id=current_user.id, name=name, - slug=slug, + slug=await _build_unique_slug(db, current_user.id, name), media_type=payload.media_type, - order_by=order_by, - order_dir=order_dir, + order_by=_normalize_custom_order_by(payload.order_by), + order_dir=_normalize_custom_order_dir(payload.order_dir), ) db.add(catalog) await db.commit() @@ -482,29 +476,29 @@ async def update_custom_catalog( ) -> dict: catalog = await _load_custom_catalog(db, current_user.id, catalog_id) fields = payload.model_fields_set + if "name" in fields: name = (payload.name or "").strip() if not name: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Name is required") if name != catalog.name: catalog.slug = await _build_unique_slug( - db, - current_user.id, - name, - exclude_catalog_id=catalog.id, + db, current_user.id, name, exclude_catalog_id=catalog.id ) catalog.name = name + if "media_type" in fields and payload.media_type: if payload.media_type not in CUSTOM_MEDIA_TYPES: raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid media type", + status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid media type" ) catalog.media_type = payload.media_type + if "order_by" in fields: catalog.order_by = _normalize_custom_order_by(payload.order_by) if "order_dir" in fields: catalog.order_dir = _normalize_custom_order_dir(payload.order_dir) + catalog.updated_at = datetime.now(timezone.utc) db.add(catalog) await db.commit() @@ -635,9 +629,7 @@ async def reorder_custom_catalog( ) -> dict: catalog = await _load_custom_catalog(db, current_user.id, catalog_id) result = await db.execute( - select(StremioCustomCatalogItem).where( - StremioCustomCatalogItem.catalog_id == catalog.id - ) + select(StremioCustomCatalogItem).where(StremioCustomCatalogItem.catalog_id == catalog.id) ) items = result.scalars().all() existing_ids = [item.media_item_id for item in items] diff --git a/backend/src/librarysync/api/routes_stremio_addon_public.py b/backend/src/librarysync/api/routes_stremio_addon_public.py index 7d527e9..5c932d3 100644 --- a/backend/src/librarysync/api/routes_stremio_addon_public.py +++ b/backend/src/librarysync/api/routes_stremio_addon_public.py @@ -1,15 +1,15 @@ from __future__ import annotations -from datetime import date, datetime, timezone +from datetime import datetime, timezone from importlib import metadata from typing import Any, Literal from fastapi import APIRouter, Depends, HTTPException, Request, status -from sqlalchemy import func, or_, select +from sqlalchemy import and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from librarysync.api.deps import get_db -from librarysync.core.catalog_ordering import apply_catalog_ordering +from librarysync.core.catalog_ordering import apply_catalog_ordering, build_show_progress_subquery from librarysync.core.stremio_addon import ( DEFAULT_PAGE_SIZE, DEFAULT_SHOW_IN_HOME, @@ -17,11 +17,9 @@ normalize_default_catalogs, ) from librarysync.db.models import ( - EpisodeItem, MediaItem, StremioCustomCatalog, StremioCustomCatalogItem, - WatchedItem, WatchlistItem, ) @@ -41,23 +39,19 @@ def _get_app_version() -> str: def _resolve_meta_id(media_item: MediaItem) -> str | None: raw = media_item.raw if isinstance(media_item.raw, dict) else {} stremio_id = raw.get("stremio_id") + if not stremio_id: stremio_payload = raw.get("stremio") if isinstance(stremio_payload, dict): stremio_id = stremio_payload.get("id") or stremio_payload.get("_id") - if stremio_id: - return str(stremio_id) - if media_item.imdb_id: - return media_item.imdb_id - return None + + return str(stremio_id) if stremio_id else media_item.imdb_id def _resolve_stremio_type(media_type: str) -> Literal["movie", "series"] | None: if media_type == "movie": return "movie" - if media_type in {"tv", "anime", "series"}: - return "series" - return None + return "series" if media_type in {"tv", "anime", "series"} else None def _build_meta(media_item: MediaItem, catalog_type: str) -> dict[str, Any] | None: @@ -85,10 +79,9 @@ def _parse_int_param(value: str | None, default: int) -> int: if not value: return default try: - parsed = int(value) + return int(value) except ValueError: return default - return parsed def _apply_search_filter(query, search: str): @@ -115,23 +108,20 @@ def _resolve_pagination( request: Request, extra_overrides: dict[str, str] | None = None, ) -> tuple[int, int, str | None]: - search = _extract_extra_param(request, "search") - if not search and extra_overrides: - search = extra_overrides.get("search") - skip_value = _extract_extra_param(request, "skip") - if not skip_value and extra_overrides: - skip_value = extra_overrides.get("skip") - limit_value = _extract_extra_param(request, "limit") - if not limit_value and extra_overrides: - limit_value = extra_overrides.get("limit") - skip = _parse_int_param(skip_value, 0) + search = _extract_extra_param(request, "search") or ( + extra_overrides.get("search") if extra_overrides else None + ) + skip_value = _extract_extra_param(request, "skip") or ( + extra_overrides.get("skip") if extra_overrides else None + ) + limit_value = _extract_extra_param(request, "limit") or ( + extra_overrides.get("limit") if extra_overrides else None + ) + + skip = max(0, _parse_int_param(skip_value, 0)) limit = _parse_int_param(limit_value, 50) - if skip < 0: - skip = 0 - if limit <= 0: - limit = 50 - if limit > MAX_LIMIT: - limit = MAX_LIMIT + limit = min(MAX_LIMIT, max(1, limit)) + return skip, limit, search @@ -163,35 +153,29 @@ def _catalogs_by_id(catalogs: list[dict]) -> dict[str, dict]: def _resolve_status_filter(catalog: dict | None, base_statuses: list[str]) -> list[str]: filters = catalog.get("filters") if isinstance(catalog, dict) else {} statuses = filters.get("statuses") if isinstance(filters, dict) else None - extras: list[str] = [] - if isinstance(statuses, list): - for status_value in statuses: - if not status_value: - continue - status = str(status_value) - if status == "added": - continue - if status in base_statuses: - continue - extras.append(status) + + extras = [ + str(status_value) + for status_value in (statuses if isinstance(statuses, list) else []) + if status_value and str(status_value) != "added" and str(status_value) not in base_statuses + ] + return list(dict.fromkeys([*base_statuses, *extras])) def _coerce_page_size(catalog: dict | None) -> int: - if not isinstance(catalog, dict): - return DEFAULT_PAGE_SIZE - value = catalog.get("pageSize") - if isinstance(value, int) and value > 0: - return value + if isinstance(catalog, dict): + value = catalog.get("pageSize") + if isinstance(value, int) and value > 0: + return value return DEFAULT_PAGE_SIZE def _coerce_show_in_home(catalog: dict | None) -> bool: - if not isinstance(catalog, dict): - return DEFAULT_SHOW_IN_HOME - value = catalog.get("showInHome") - if isinstance(value, bool): - return value + if isinstance(catalog, dict): + value = catalog.get("showInHome") + if isinstance(value, bool): + return value return DEFAULT_SHOW_IN_HOME @@ -281,62 +265,13 @@ async def _build_watchlist_query( return query -def _build_progress_subquery(user_id: str, now_date: date): - base = ( - select(EpisodeItem.show_media_item_id.label("media_item_id")) - .where(EpisodeItem.show_media_item_id.is_not(None)) - .group_by(EpisodeItem.show_media_item_id) - .subquery() - ) - released_subq = ( - select( - EpisodeItem.show_media_item_id.label("media_item_id"), - func.count(EpisodeItem.id).label("total_released"), - ) - .where( - EpisodeItem.air_date.is_not(None), - EpisodeItem.air_date <= now_date, - EpisodeItem.season_number > 0, - ) - .group_by(EpisodeItem.show_media_item_id) - .subquery() - ) - watched_subq = ( - select( - EpisodeItem.show_media_item_id.label("media_item_id"), - func.count(func.distinct(WatchedItem.episode_item_id)).label("watched_count"), - ) - .join(WatchedItem, WatchedItem.episode_item_id == EpisodeItem.id) - .where( - WatchedItem.user_id == user_id, - WatchedItem.media_item_id.is_(None), - EpisodeItem.air_date.is_not(None), - EpisodeItem.air_date <= now_date, - EpisodeItem.season_number > 0, - ) - .group_by(EpisodeItem.show_media_item_id) - .subquery() - ) - return ( - select( - base.c.media_item_id, - func.coalesce(released_subq.c.total_released, 0).label("total_released"), - func.coalesce(watched_subq.c.watched_count, 0).label("watched_count"), - ) - .select_from(base) - .outerjoin(released_subq, released_subq.c.media_item_id == base.c.media_item_id) - .outerjoin(watched_subq, watched_subq.c.media_item_id == base.c.media_item_id) - .subquery() - ) - - async def _build_in_progress_query( user_id: str, catalog: dict | None, search: str | None, ): now_date = datetime.now(timezone.utc).date() - progress_subq = _build_progress_subquery(user_id, now_date) + progress_subq = build_show_progress_subquery(user_id, now_date) query = ( select(MediaItem) .join(WatchlistItem, WatchlistItem.media_item_id == MediaItem.id) @@ -346,14 +281,21 @@ async def _build_in_progress_query( WatchlistItem.status != "removed", WatchlistItem.type.in_(["tv", "anime"]), MediaItem.media_type.in_(["tv", "anime"]), - progress_subq.c.total_released > 0, - progress_subq.c.watched_count > 0, - progress_subq.c.watched_count < progress_subq.c.total_released, ) ) statuses = _resolve_status_filter(catalog, ["in_progress"]) - if statuses: - query = query.where(WatchlistItem.status.in_(statuses)) + in_progress_clause = and_( + progress_subq.c.total_released > 0, + progress_subq.c.watched_count > 0, + progress_subq.c.watched_count < progress_subq.c.total_released, + ) + other_statuses = [status_value for status_value in statuses if status_value != "in_progress"] + if other_statuses: + query = query.where( + or_(WatchlistItem.status.in_(other_statuses), in_progress_clause) + ) + else: + query = query.where(in_progress_clause) if search: query = _apply_search_filter(query, search) return query @@ -457,9 +399,9 @@ async def _serve_catalog( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") catalogs = _normalize_catalogs(config.default_catalogs) - catalogs_by_id = _catalogs_by_id(catalogs) - catalog = catalogs_by_id.get(catalog_id) + catalog = _catalogs_by_id(catalogs).get(catalog_id) custom_catalog = None + if not catalog: custom_result = await db.execute( select(StremioCustomCatalog).where( @@ -470,14 +412,14 @@ async def _serve_catalog( custom_catalog = custom_result.scalars().first() if not custom_catalog: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Catalog not found") - expected_type = _resolve_stremio_type(custom_catalog.media_type) - if expected_type != catalog_type: + + if _resolve_stremio_type(custom_catalog.media_type) != catalog_type: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Catalog not found") else: if not catalog.get("enabled", True): raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Catalog not found") - expected_type = _resolve_stremio_type(str(catalog.get("media_type"))) - if expected_type != catalog_type: + + if _resolve_stremio_type(str(catalog.get("media_type"))) != catalog_type: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Catalog not found") skip, limit, search = _resolve_pagination(request, extra_overrides) @@ -522,20 +464,14 @@ async def _serve_catalog( tie_breaker_col=MediaItem.id, ) - query = query.offset(skip).limit(limit + 1) - result = await db.execute(query) + result = await db.execute(query.offset(skip).limit(limit + 1)) media_items = result.scalars().all() has_more = len(media_items) > limit - if has_more: - media_items = media_items[:limit] - - metas: list[dict[str, Any]] = [] - for media in media_items: - meta = _build_meta(media, catalog_type) - if meta: - metas.append(meta) - - payload: dict[str, Any] = {"metas": metas} - if has_more: - payload["hasMore"] = True - return payload + + metas = [ + meta + for media in media_items[:limit] + if (meta := _build_meta(media, catalog_type)) is not None + ] + + return {"metas": metas, "hasMore": has_more} if has_more else {"metas": metas} diff --git a/backend/src/librarysync/api/routes_watchlist.py b/backend/src/librarysync/api/routes_watchlist.py index d958268..d3bdbc8 100644 --- a/backend/src/librarysync/api/routes_watchlist.py +++ b/backend/src/librarysync/api/routes_watchlist.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel -from sqlalchemy import func, or_, select +from sqlalchemy import and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession from librarysync.api.deps import get_current_user, get_db @@ -15,6 +15,7 @@ CatalogOrderBy, CatalogOrderDirection, apply_catalog_ordering, + build_show_progress_subquery, ) from librarysync.core.watch_pipeline import enqueue_new_item_job from librarysync.core.watchlist import ( @@ -520,7 +521,31 @@ async def list_watchlist_items( if status and status != "all": statuses = [s.strip() for s in status.split(",") if s.strip()] if statuses: - query = query.where(WatchlistItem.status.in_(statuses)) + if "in_progress" in statuses: + other_statuses = [ + status_value for status_value in statuses if status_value != "in_progress" + ] + now_date = datetime.now(timezone.utc).date() + progress_subq = build_show_progress_subquery(current_user.id, now_date) + query = query.outerjoin( + progress_subq, + progress_subq.c.media_item_id == MediaItem.id, + ) + in_progress_clause = and_( + WatchlistItem.status != "removed", + MediaItem.media_type.in_(["tv", "anime"]), + progress_subq.c.total_released > 0, + progress_subq.c.watched_count > 0, + progress_subq.c.watched_count < progress_subq.c.total_released, + ) + if other_statuses: + query = query.where( + or_(WatchlistItem.status.in_(other_statuses), in_progress_clause) + ) + else: + query = query.where(in_progress_clause) + else: + query = query.where(WatchlistItem.status.in_(statuses)) if media_type: query = query.where(WatchlistItem.type == media_type) diff --git a/backend/src/librarysync/core/catalog_ordering.py b/backend/src/librarysync/core/catalog_ordering.py index be89f9e..f7bee78 100644 --- a/backend/src/librarysync/core/catalog_ordering.py +++ b/backend/src/librarysync/core/catalog_ordering.py @@ -51,7 +51,7 @@ def apply_catalog_ordering( order_expression = last_watched_subq.c.last_watched_at elif order_by in {"episodes_left", "progress"}: now_date = now_date or datetime.now(timezone.utc).date() - progress_subq = _build_show_progress_subquery(user_id, now_date) + progress_subq = build_show_progress_subquery(user_id, now_date) updated_query = updated_query.outerjoin( progress_subq, progress_subq.c.media_item_id == base_media_id_col, @@ -108,7 +108,7 @@ def _build_last_watched_subquery(user_id: str): ) -def _build_show_progress_subquery(user_id: str, now_date: date): +def build_show_progress_subquery(user_id: str, now_date: date): base = ( select(EpisodeItem.show_media_item_id.label("media_item_id")) .where(EpisodeItem.show_media_item_id.is_not(None)) diff --git a/backend/src/librarysync/main.py b/backend/src/librarysync/main.py index a9d3690..d64ad5a 100644 --- a/backend/src/librarysync/main.py +++ b/backend/src/librarysync/main.py @@ -54,7 +54,6 @@ def get_app_version() -> str: - """Get the application version from package metadata.""" try: return metadata.version("librarysync") except metadata.PackageNotFoundError: @@ -62,10 +61,7 @@ def get_app_version() -> str: def make_static_url(version: str) -> callable: - """Create a static_url function with the given version.""" - def static_url(path: str) -> str: - """Generate a versioned static URL for cache busting.""" return f"{path}?v={version}" return static_url @@ -105,11 +101,13 @@ async def get_response(self, path: str, scope): response = await super().get_response(path, scope) if response.status_code != 200 or "cache-control" in response.headers: return response + suffix = Path(path).suffix.lower() + if path.endswith("service-worker.js"): - response.headers["Cache-Control"] = "no-cache" - return response - if suffix in { + cache_max_age = 0 + cache_directive = "no-cache" + elif suffix in { ".woff2", ".woff", ".ttf", @@ -122,12 +120,16 @@ async def get_response(self, path: str, scope): ".svg", ".ico", }: - response.headers["Cache-Control"] = f"public, max-age={STATIC_CACHE_LONG}" - return response - if suffix in {".css", ".js", ".webmanifest"}: - response.headers["Cache-Control"] = f"public, max-age={STATIC_CACHE_MEDIUM}" - return response - response.headers["Cache-Control"] = f"public, max-age={STATIC_CACHE_DEFAULT}" + cache_max_age = STATIC_CACHE_LONG + cache_directive = f"public, max-age={cache_max_age}" + elif suffix in {".css", ".js", ".webmanifest"}: + cache_max_age = STATIC_CACHE_MEDIUM + cache_directive = f"public, max-age={cache_max_age}" + else: + cache_max_age = STATIC_CACHE_DEFAULT + cache_directive = f"public, max-age={cache_max_age}" + + response.headers["Cache-Control"] = cache_directive return response app.mount("/static", CachedStaticFiles(directory=STATIC_DIR), name="static") @@ -135,6 +137,7 @@ async def get_response(self, path: str, scope): def _static_asset(filename: str) -> FileResponse: response = FileResponse(STATIC_DIR / filename) suffix = Path(filename).suffix.lower() + if suffix in { ".woff2", ".woff", @@ -148,11 +151,13 @@ def _static_asset(filename: str) -> FileResponse: ".svg", ".ico", }: - response.headers["Cache-Control"] = f"public, max-age={STATIC_CACHE_LONG}" + cache_max_age = STATIC_CACHE_LONG elif suffix in {".css", ".js", ".webmanifest"}: - response.headers["Cache-Control"] = f"public, max-age={STATIC_CACHE_MEDIUM}" + cache_max_age = STATIC_CACHE_MEDIUM else: - response.headers["Cache-Control"] = f"public, max-age={STATIC_CACHE_DEFAULT}" + cache_max_age = STATIC_CACHE_DEFAULT + + response.headers["Cache-Control"] = f"public, max-age={cache_max_age}" return response @app.get("/favicon.ico", include_in_schema=False) @@ -194,13 +199,12 @@ def _render_page( current_user: User | None = None, **context: object, ): - auth_state = "auth" if current_user else "guest" return templates.TemplateResponse( template_name, { "request": request, "app_version": app_version, - "auth_state": auth_state, + "auth_state": "auth" if current_user else "guest", "current_user": current_user, **context, }, diff --git a/backend/src/librarysync/static/page-stremio-addon.js b/backend/src/librarysync/static/page-stremio-addon.js index 3dcaaf6..a850124 100644 --- a/backend/src/librarysync/static/page-stremio-addon.js +++ b/backend/src/librarysync/static/page-stremio-addon.js @@ -70,14 +70,13 @@ function setControlsMessage(message, isError = false) { } function updateInstallLinks(payload) { - if (!payload) { - return; - } - if (payload.manifest_url) { - addonState.installLinks.manifestUrl = payload.manifest_url; - } - if (payload.install_url) { - addonState.installLinks.installUrl = payload.install_url; + if (payload) { + if (payload.manifest_url) { + addonState.installLinks.manifestUrl = payload.manifest_url; + } + if (payload.install_url) { + addonState.installLinks.installUrl = payload.install_url; + } } } @@ -508,29 +507,16 @@ function catalogSearchScope(catalog) { if (!catalog) { return "all"; } - if (catalog.media_type === "anime") { - return "anime"; - } - if (catalog.media_type === "tv") { - return "tv"; - } - return "movie"; + const typeMap = { anime: "anime", tv: "tv" }; + return typeMap[catalog.media_type] || "movie"; } function candidateMatchesItem(candidate, item) { if (!candidate || !item) { return false; } - const pairs = [ - ["imdb_id"], - ["tmdb_id"], - ["tvdb_id"], - ["tvmaze_id"], - ["kitsu_id"], - ["myanimelist_id"], - ["anilist_id"], - ]; - return pairs.some(([key]) => { + const idFields = ["imdb_id", "tmdb_id", "tvdb_id", "tvmaze_id", "kitsu_id", "myanimelist_id", "anilist_id"]; + return idFields.some((key) => { const left = candidate[key]; const right = item[key]; return left && right && String(left) === String(right); diff --git a/backend/tests/test_stremio_addon_catalogs.py b/backend/tests/test_stremio_addon_catalogs.py index dc0048c..0b3e488 100644 --- a/backend/tests/test_stremio_addon_catalogs.py +++ b/backend/tests/test_stremio_addon_catalogs.py @@ -34,7 +34,9 @@ def test_merge_catalog_updates_does_not_mutate_existing(self) -> None: def test_build_manifest_includes_custom_and_enabled_catalogs(self) -> None: catalogs = build_default_catalogs() - custom_catalog = SimpleNamespace(name="Curated Picks", slug="curated_picks", media_type="movie") + custom_catalog = SimpleNamespace( + name="Curated Picks", slug="curated_picks", media_type="movie" + ) manifest = routes_stremio_addon_public._build_manifest(catalogs, [custom_catalog]) @@ -55,17 +57,17 @@ def test_build_meta_prefers_stremio_id(self) -> None: raw={"stremio_id": "stremio:movie:123"}, ) meta = routes_stremio_addon_public._build_meta(media_item, "movie") + self.assertIsNotNone(meta) - assert meta is not None self.assertEqual(meta["id"], "stremio:movie:123") self.assertEqual(meta["type"], "movie") def test_in_progress_query_applies_status_filter(self) -> None: catalog = {"filters": {"statuses": ["added"]}} - query = asyncio.run( routes_stremio_addon_public._build_in_progress_query("user-id", catalog, None) ) + compiled = str(query.compile(compile_kwargs={"literal_binds": True})).lower() self.assertIn("watchlist_items.status in", compiled) From 842a429988c143d5e42fa5b752d1f517bfe66515 Mon Sep 17 00:00:00 2001 From: Thomas Willems Date: Sat, 17 Jan 2026 23:00:17 +0100 Subject: [PATCH 7/7] linting --- .../api/routes_stremio_addon_public.py | 2 +- backend/tests/test_stremio_addon_catalogs.py | 16 ++++++---------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/backend/src/librarysync/api/routes_stremio_addon_public.py b/backend/src/librarysync/api/routes_stremio_addon_public.py index 5c932d3..523d71f 100644 --- a/backend/src/librarysync/api/routes_stremio_addon_public.py +++ b/backend/src/librarysync/api/routes_stremio_addon_public.py @@ -157,7 +157,7 @@ def _resolve_status_filter(catalog: dict | None, base_statuses: list[str]) -> li extras = [ str(status_value) for status_value in (statuses if isinstance(statuses, list) else []) - if status_value and str(status_value) != "added" and str(status_value) not in base_statuses + if status_value and str(status_value) not in base_statuses ] return list(dict.fromkeys([*base_statuses, *extras])) diff --git a/backend/tests/test_stremio_addon_catalogs.py b/backend/tests/test_stremio_addon_catalogs.py index 0b3e488..ee72b62 100644 --- a/backend/tests/test_stremio_addon_catalogs.py +++ b/backend/tests/test_stremio_addon_catalogs.py @@ -1,19 +1,15 @@ import asyncio import copy -import sys import unittest -from pathlib import Path from types import SimpleNamespace from fastapi import HTTPException - -PROJECT_ROOT = Path(__file__).resolve().parents[1] -sys.path.append(str(PROJECT_ROOT / "src")) - -from librarysync.api import routes_stremio_addon # noqa: E402 -from librarysync.api import routes_stremio_addon_public # noqa: E402 -from librarysync.core.stremio_addon import build_default_catalogs # noqa: E402 -from librarysync.db.models import MediaItem # noqa: E402 +from librarysync.api import ( + routes_stremio_addon, + routes_stremio_addon_public, +) +from librarysync.core.stremio_addon import build_default_catalogs +from librarysync.db.models import MediaItem class TestStremioAddonCatalogs(unittest.TestCase):