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/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 new file mode 100644 index 0000000..d280513 --- /dev/null +++ b/backend/src/librarysync/api/routes_stremio_addon.py @@ -0,0 +1,642 @@ +from __future__ import annotations + +import copy +import re +import secrets +from datetime import datetime, timezone +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel +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 ( + ensure_addon_config, + normalize_default_catalogs, +) +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 + + +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 + showInHome: bool | None = None + + +class StremioAddonConfigUpdate(BaseModel): + is_enabled: bool | None = None + 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: + 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]: + 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: + 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( + 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() + return normalized if normalized in CUSTOM_ORDER_BY else "manual" + + +def _normalize_custom_order_dir(value: str | None) -> str: + normalized = value.strip().lower() if value else "" + return normalized if normalized in CUSTOM_ORDER_DIR else "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]: + 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: + 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) + if update.showInHome is not None: + catalog["showInHome"] = bool(update.showInHome) + return list(by_id.values()) + + +async def _ensure_default_catalogs( + db: AsyncSession, + config: StremioAddonConfig, +) -> list[dict]: + catalogs = normalize_default_catalogs(config.default_catalogs) + if config.default_catalogs != catalogs: + config.default_catalogs = catalogs + db.add(config) + await db.commit() + await db.refresh(config) + 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: + 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=payload.title or fallback_title(ids), + 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 + + 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: + 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", + 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 = 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()] + + return { + "addon_id": config.id, + "is_enabled": bool(config.is_enabled), + "catalogs": catalogs, + "custom_catalogs": custom_catalogs, + **_build_manifest_links(_resolve_base_url(request), config.id), + } + + +@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: + config = await ensure_addon_config(db, current_user.id) + catalogs = await _ensure_default_catalogs(db, config) + + 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, + } + + +@router.post( + "/custom-catalogs", + status_code=status.HTTP_201_CREATED, + summary="Create custom catalog", + description="Create a new custom Stremio catalog.", +) +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") + + catalog = StremioCustomCatalog( + user_id=current_user.id, + name=name, + slug=await _build_unique_slug(db, current_user.id, name), + media_type=payload.media_type, + order_by=_normalize_custom_order_by(payload.order_by), + order_dir=_normalize_custom_order_dir(payload.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(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(), + ) + ) + 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 new file mode 100644 index 0000000..523d71f --- /dev/null +++ b/backend/src/librarysync/api/routes_stremio_addon_public.py @@ -0,0 +1,477 @@ +from __future__ import annotations + +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 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, build_show_progress_subquery +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 ( + MediaItem, + StremioCustomCatalog, + StremioCustomCatalogItem, + 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") + + 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" + return "series" if media_type in {"tv", "anime", "series"} else 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: + return int(value) + except ValueError: + return default + + +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, + extra_overrides: dict[str, str] | None = None, +) -> tuple[int, int, str | None]: + 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) + limit = min(MAX_LIMIT, max(1, limit)) + + 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) + + +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 = [ + str(status_value) + for status_value in (statuses if isinstance(statuses, list) else []) + if status_value and str(status_value) not in base_statuses + ] + + return list(dict.fromkeys([*base_statuses, *extras])) + + +def _coerce_page_size(catalog: dict | None) -> int: + 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 isinstance(catalog, dict): + 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], +) -> 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 + 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) + + 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, + "pageSize": DEFAULT_PAGE_SIZE, + "showInHome": DEFAULT_SHOW_IN_HOME, + } + ) + 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) + statuses = _resolve_status_filter(catalog, ["added"]) + if statuses: + query = query.where(WatchlistItem.status.in_(statuses)) + if search: + query = _apply_search_filter(query, search) + return query + + +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_show_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"]), + ) + ) + statuses = _resolve_status_filter(catalog, ["in_progress"]) + 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 + + +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_id}/manifest.json", include_in_schema=False) +async def stremio_addon_manifest( + addon_id: str, + db: AsyncSession = Depends(get_db), +) -> dict[str, Any]: + 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) + custom_result = await db.execute( + select(StremioCustomCatalog).where(StremioCustomCatalog.user_id == config.user_id) + ) + custom_catalogs = custom_result.scalars().all() + return _build_manifest(catalogs, custom_catalogs) + + +@router.get("/{addon_id}/catalog/{catalog_type}/{catalog_id}.json", include_in_schema=False) +async def stremio_addon_catalog( + addon_id: str, + catalog_type: Literal["movie", "series"], + 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: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Not found") + + catalogs = _normalize_catalogs(config.default_catalogs) + catalog = _catalogs_by_id(catalogs).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") + + 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") + + 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) + + 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, catalog, 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, + ) + + result = await db.execute(query.offset(skip).limit(limit + 1)) + media_items = result.scalars().all() + has_more = len(media_items) > limit + + 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/core/stremio_addon.py b/backend/src/librarysync/core/stremio_addon.py new file mode 100644 index 0000000..1aaf54a --- /dev/null +++ b/backend/src/librarysync/core/stremio_addon.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import copy +import uuid +from typing import Any + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +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": []}, + "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": []}, + "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": []}, + "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": []}, + "ordering": {"order_by": "episodes_left", "order_dir": "asc"}, + "pageSize": DEFAULT_PAGE_SIZE, + "showInHome": DEFAULT_SHOW_IN_HOME, + }, +] + + +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, +) -> StremioAddonConfig | None: + result = await db.execute( + select(StremioAddonConfig).where(StremioAddonConfig.user_id == user_id) + ) + return result.scalars().first() + + +async def get_addon_config_by_id( + db: AsyncSession, + addon_id: str, +) -> StremioAddonConfig | None: + result = await db.execute( + select(StremioAddonConfig).where(StremioAddonConfig.id == addon_id) + ) + return result.scalars().first() + + +async def ensure_addon_config( + db: AsyncSession, + user_id: str, +) -> StremioAddonConfig: + config = await get_addon_config_by_user(db, user_id) + if config: + return config + config = StremioAddonConfig( + id=str(uuid.uuid4()), + user_id=user_id, + is_enabled=True, + default_catalogs=build_default_catalogs(), + ) + db.add(config) + await db.commit() + await db.refresh(config) + return config 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..6a80187 --- /dev/null +++ b/backend/src/librarysync/db/migrations/versions/c9a6b5d7e8f1_add_stremio_addon_tables.py @@ -0,0 +1,135 @@ +"""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("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"), + ) + 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_table("stremio_addon_configs") diff --git a/backend/src/librarysync/db/models.py b/backend/src/librarysync/db/models.py index ebf8c22..b9eb393 100644 --- a/backend/src/librarysync/db/models.py +++ b/backend/src/librarysync/db/models.py @@ -385,6 +385,88 @@ class WatchSync(Base): ) +class StremioAddonConfig(Base): + __tablename__ = "stremio_addon_configs" + __table_args__ = ( + UniqueConstraint("user_id", name="uq_stremio_addon_configs_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 + ) + is_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + 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..d64ad5a 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,13 +47,13 @@ {"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."}, ] def get_app_version() -> str: - """Get the application version from package metadata.""" try: return metadata.version("librarysync") except metadata.PackageNotFoundError: @@ -59,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 @@ -89,6 +88,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() @@ -100,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", @@ -117,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") @@ -130,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", @@ -143,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) @@ -189,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, }, @@ -293,6 +302,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..a850124 --- /dev/null +++ b/backend/src/librarysync/static/page-stremio-addon.js @@ -0,0 +1,1125 @@ +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_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" }, + { 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) { + 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 : []; + return statuses.filter((status) => status && status !== "added"); +} + +function normalizeShowInHome(catalog) { + if (catalog && typeof catalog.showInHome === "boolean") { + return catalog.showInHome; + } + 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) { + 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 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 = "Visibility"; + const statusGroup = document.createElement("div"); + 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); + + 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"; + } + const typeMap = { anime: "anime", tv: "tv" }; + return typeMap[catalog.media_type] || "movie"; +} + +function candidateMatchesItem(candidate, item) { + if (!candidate || !item) { + return false; + } + 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); + }); +} + +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 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 = 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", + 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..ee72b62 --- /dev/null +++ b/backend/tests/test_stremio_addon_catalogs.py @@ -0,0 +1,82 @@ +import asyncio +import copy +import unittest +from types import SimpleNamespace + +from fastapi import HTTPException +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): + 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) + 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 new file mode 100644 index 0000000..3885c02 --- /dev/null +++ b/docs/stremio-catalogs-plan.md @@ -0,0 +1,191 @@ +# 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. +- 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. + +## Non-goals (for this phase) +- 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. + +## 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 controls (enable/disable). + - 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) + - `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 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: + - `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`: +- `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. +- 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` +- 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 via the addon config id in the URL. + +Suggested URL scheme: +- `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`. +- 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. + +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`). +- 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. +- 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`. +- 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). +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). +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 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. + - Access control for disabled configs. +- 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. + +## Known Bugs (needs fixing) + - 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; } 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" },