diff --git a/backend/app/plex/cache.py b/backend/app/plex/cache.py new file mode 100644 index 00000000..b1aec046 --- /dev/null +++ b/backend/app/plex/cache.py @@ -0,0 +1,13 @@ +"""Shared on-disk cache root for proxied Plex assets. + +Both poster/art images (imgcache.py) and converted subtitle VTTs (subtitles.py) are cached here, so +the directory belongs to neither module — it lives in this neutral home to avoid a cross-import. +On-demand + bounded by the library size; no eviction (a few hundred MB at most for a big library — +acceptable, matching the download disk trade-offs). +""" + +from pathlib import Path + +from app.config import settings + +CACHE_DIR = Path(settings.download_root) / ".plex-img-cache" diff --git a/backend/app/plex/imgcache.py b/backend/app/plex/imgcache.py index 10fcb4aa..2dbeb2a3 100644 --- a/backend/app/plex/imgcache.py +++ b/backend/app/plex/imgcache.py @@ -5,18 +5,15 @@ LAN (janky). We cache the fetched bytes on local disk keyed by a stable id, so r other users) serve instantly from disk. On-demand + bounded by the library size; no eviction (a few hundred MB at most for a big library — acceptable, matching the download disk trade-offs). -`CACHE_DIR` is the shared Plex on-disk cache root; converted subtitle VTTs live here too -(see ``subtitles.py``). +The cache directory (``cache.CACHE_DIR``) is shared with the subtitle cache (see ``subtitles.py``). """ -from pathlib import Path from fastapi import Response from sqlalchemy.orm import Session -from app.config import settings from app.models import PlexCollection, PlexItem, PlexSeason, PlexShow +from app.plex.cache import CACHE_DIR -CACHE_DIR = Path(settings.download_root) / ".plex-img-cache" _EXT_CT = [("jpg", "image/jpeg"), ("png", "image/png"), ("webp", "image/webp")] _CT_EXT = {ct: ext for ext, ct in _EXT_CT} # Target box per variant — Plex resizes to these server-side. Posters fill ≤176px cells (grid/info/ @@ -65,4 +62,6 @@ def write(key: str, data: bytes, ct: str) -> None: def response(data: bytes, ct: str) -> Response: - return Response(content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"}) + return Response( + content=data, media_type=ct, headers={"Cache-Control": "public, max-age=86400"} + ) diff --git a/backend/app/plex/rollup.py b/backend/app/plex/rollup.py index f71734d9..fb6497ee 100644 --- a/backend/app/plex/rollup.py +++ b/backend/app/plex/rollup.py @@ -2,6 +2,7 @@ Drives the show/season grid badges and the Resume / Play-from-start / mark-all buttons. """ + from sqlalchemy import and_, case, func, or_ from sqlalchemy.orm import Session, aliased @@ -23,7 +24,11 @@ def rollup(cards: list[dict]) -> dict: the episode count. Drives the show/season Resume + Play + mark-all buttons.""" total = len(cards) watched = sum(1 for c in cards if c.get("status") == "watched") - inprog = [c for c in cards if (c.get("position_seconds") or 0) > 0 and c.get("status") != "watched"] + inprog = [ + c + for c in cards + if (c.get("position_seconds") or 0) > 0 and c.get("status") != "watched" + ] resume = None if inprog: resume = inprog[-1] # continue the latest-started episode @@ -49,7 +54,10 @@ def agg_subquery(db: Session, user_id: int): func.count( case( ( - and_(ps.position_seconds > 0, or_(ps.status.is_(None), ps.status != "watched")), + and_( + ps.position_seconds > 0, + or_(ps.status.is_(None), ps.status != "watched"), + ), 1, ) ) diff --git a/backend/app/plex/subtitles.py b/backend/app/plex/subtitles.py index b0f76289..85a7ee03 100644 --- a/backend/app/plex/subtitles.py +++ b/backend/app/plex/subtitles.py @@ -2,22 +2,33 @@ Text subtitles (embedded or external) are turned into WebVTT so the browser can render them; bitmap subtitles (Blu-ray/DVD) can't be (would need burn-in). Converted VTTs are cached on the shared -Plex on-disk cache root (``imgcache.CACHE_DIR``). +Plex on-disk cache root (``cache.CACHE_DIR``). """ + import re import subprocess +from app.plex.cache import CACHE_DIR from app.plex.client import PlexError -from app.plex.imgcache import CACHE_DIR # Image-based subtitle codecs (Blu-ray/DVD) — can't be turned into text WebVTT (would need burn-in). -BITMAP_SUBS = {"pgs", "hdmv_pgs_subtitle", "dvd_subtitle", "dvdsub", "vobsub", "dvbsub", "dvb_subtitle"} +BITMAP_SUBS = { + "pgs", + "hdmv_pgs_subtitle", + "dvd_subtitle", + "dvdsub", + "vobsub", + "dvbsub", + "dvb_subtitle", +} def srt_to_vtt(text: str) -> str: """Minimal SubRip→WebVTT: add the header and switch the ms separator (`,`→`.`). SRT cue-index lines are valid in WebVTT, so they're left as-is.""" - body = re.sub(r"(\d\d:\d\d:\d\d),(\d\d\d)", r"\1.\2", text.replace("\r\n", "\n").lstrip("")) + body = re.sub( + r"(\d\d:\d\d:\d\d),(\d\d\d)", r"\1.\2", text.replace("\r\n", "\n").lstrip("") + ) return "WEBVTT\n\n" + body.strip() + "\n" @@ -75,7 +86,9 @@ def shift_vtt(vtt: str, offset: float) -> str: if en <= 0: keep = False # whole cue is before the session start → drop the block else: - lines[i] = f"{_vtt_s_to_ts(st)} --> {_vtt_s_to_ts(en)}{settings}" # _vtt_s_to_ts clamps st>=0 + lines[i] = ( + f"{_vtt_s_to_ts(st)} --> {_vtt_s_to_ts(en)}{settings}" # _vtt_s_to_ts clamps st>=0 + ) break if keep: out_blocks.append("\n".join(lines)) @@ -85,11 +98,25 @@ def shift_vtt(vtt: str, offset: float) -> str: def ffmpeg_to_vtt(src: str, sub_ord: int | None) -> str: """Convert one subtitle stream of a file to WebVTT via ffmpeg (for embedded subs, and non-SRT external subs written to a temp file). `sub_ord` = ordinal among the file's subtitle streams.""" - cmd = ["ffmpeg", "-nostdin", "-loglevel", "error", "-i", src, - "-map", f"0:s:{sub_ord if sub_ord is not None else 0}", "-f", "webvtt", "pipe:1"] + cmd = [ + "ffmpeg", + "-nostdin", + "-loglevel", + "error", + "-i", + src, + "-map", + f"0:s:{sub_ord if sub_ord is not None else 0}", + "-f", + "webvtt", + "pipe:1", + ] r = subprocess.run(cmd, capture_output=True) if r.returncode != 0 or not r.stdout: - raise PlexError((r.stderr or b"").decode("utf-8", "replace")[:200] or "ffmpeg subtitle convert failed") + raise PlexError( + (r.stderr or b"").decode("utf-8", "replace")[:200] + or "ffmpeg subtitle convert failed" + ) return r.stdout.decode("utf-8", "replace") diff --git a/backend/app/routes/plex/__init__.py b/backend/app/routes/plex/__init__.py index 6bfe2fae..8c3cdc72 100644 --- a/backend/app/routes/plex/__init__.py +++ b/backend/app/routes/plex/__init__.py @@ -1,8 +1,13 @@ """Plex route package — assembles the admin/browse/playback sub-routers. +Read endpoints (libraries/browse/show/image) are available to ANY authenticated user (demo + +pure-Siftlode included) — this is an access layer to the Plex library WITHOUT requiring a plex.tv +account, and playback is a local file. Admin endpoints test the connection and run the catalog sync. + Split out of the former single routes/plex.py (R12 S1). `router` keeps the same shape the app wiring expects (`plex_routes.router`), so main.py is unchanged. """ + from fastapi import APIRouter from app.routes.plex import admin, browse, playback diff --git a/backend/app/routes/plex/_shared.py b/backend/app/routes/plex/_shared.py index c56f9d24..53c68c58 100644 --- a/backend/app/routes/plex/_shared.py +++ b/backend/app/routes/plex/_shared.py @@ -1,14 +1,17 @@ """Shared helpers, serializers and constants for the plex route package.""" import logging + from fastapi import HTTPException from sqlalchemy.orm import Session + from app.models import ( PlexItem, ) log = logging.getLogger("siftlode.plex") + def _item_or_404(db: Session, rating_key: str) -> PlexItem: it = db.query(PlexItem).filter_by(rating_key=str(rating_key)).first() if it is None: diff --git a/backend/app/routes/plex/admin.py b/backend/app/routes/plex/admin.py index 55a64020..5c5ddee8 100644 --- a/backend/app/routes/plex/admin.py +++ b/backend/app/routes/plex/admin.py @@ -1,9 +1,11 @@ """Plex admin: connection test, catalog sync, and owner watch-link config.""" from datetime import datetime, timezone + from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy.orm import Session + from app import sysconfig from app.auth import current_user from app.db import get_db @@ -21,8 +23,11 @@ from app.utils import iso router = APIRouter(prefix="/api/plex", tags=["plex"]) + @router.post("/test") -def test_connection(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: +def test_connection( + _: User = Depends(admin_user), db: Session = Depends(get_db) +) -> dict: """Admin: verify the configured Plex server URL + token, returning the server name and the movie/show sections (for the config library-picker). Also probes ONE real media file through the local mount, so a missing bind-mount / wrong path-map is caught here rather than only @@ -41,14 +46,22 @@ def test_connection(_: User = Depends(admin_user), db: Session = Depends(get_db) "server_name": info.get("friendlyName") or info.get("title1") or "Plex", "version": info.get("version"), "sections": [ - {"key": str(s.get("key")), "title": s.get("title"), "type": s.get("type"), "count": s.get("count")} + { + "key": str(s.get("key")), + "title": s.get("title"), + "type": s.get("type"), + "count": s.get("count"), + } for s in sections if s.get("type") in ("movie", "show") ], "media_check": media_check, } -def _sample_plex_path(db: Session, plex: PlexClient, sections: list[dict]) -> str | None: + +def _sample_plex_path( + db: Session, plex: PlexClient, sections: list[dict] +) -> str | None: """A real Plex-side media file path to validate the mount + path-map against. Prefers the already-mirrored catalog (cheap); before the first sync, pulls one movie leaf from an enabled section straight from Plex.""" @@ -69,6 +82,7 @@ def _sample_plex_path(db: Session, plex: PlexClient, sections: list[dict]) -> st return fp return None + def _media_check(db: Session, plex: PlexClient, sections: list[dict]) -> dict: """Resolve a sample Plex file path to its local mount path and confirm it's readable, so the admin sees at Test time whether the physical media is actually reachable (bind-mount + @@ -80,12 +94,14 @@ def _media_check(db: Session, plex: PlexClient, sections: list[dict]) -> dict: ok = plex_paths.local_media_path(db, plex_path) is not None return {"checked": True, "ok": ok, "plex_path": plex_path, "local_path": local_path} + @router.post("/sync") def trigger_sync(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> dict: """Admin: mirror the enabled Plex sections into the local catalog. Synchronous (can take a while on a large library); a background/scheduled sync also runs periodically.""" return plex_sync.sync(db) + def _link_dict(link: PlexLink | None) -> dict: return { "linked": link is not None, @@ -95,6 +111,7 @@ def _link_dict(link: PlexLink | None) -> dict: "last_watch_sync_at": iso(link.last_watch_sync_at) if link else None, } + @router.get("/watch/link") def watch_link_status( user: User = Depends(current_user), db: Session = Depends(get_db) @@ -103,9 +120,11 @@ def watch_link_status( link = db.query(PlexLink).filter_by(user_id=user.id).first() return _link_dict(link) + class WatchLinkIn(BaseModel): enabled: bool = False + @router.post("/watch/link") def watch_link_set( body: WatchLinkIn, @@ -120,7 +139,9 @@ def watch_link_set( enabled = body.enabled link = db.query(PlexLink).filter_by(user_id=user.id).first() if link is None: - link = PlexLink(user_id=user.id, uses_admin=True, linked_at=datetime.now(timezone.utc)) + link = PlexLink( + user_id=user.id, uses_admin=True, linked_at=datetime.now(timezone.utc) + ) db.add(link) link.sync_enabled = enabled db.commit() @@ -130,6 +151,7 @@ def watch_link_set( result = plex_watch.import_owner_watch_state(db, link) return {**_link_dict(link), "import": result or None} + @router.post("/watch/import") def watch_import_now( user: User = Depends(admin_user), db: Session = Depends(get_db) diff --git a/backend/app/routes/plex/browse.py b/backend/app/routes/plex/browse.py index 0315e192..54a46ef2 100644 --- a/backend/app/routes/plex/browse.py +++ b/backend/app/routes/plex/browse.py @@ -5,10 +5,12 @@ import time from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass from datetime import datetime, timedelta, timezone + from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import Integer, String, and_, case, cast, func, literal, null, or_ from sqlalchemy.orm import Session, aliased + from app import sysconfig from app.auth import current_user from app.db import get_db @@ -25,34 +27,50 @@ from app.models import ( User, ) from app.plex import people as plex_people +from app.plex import rollup, subtitles from app.plex import sync as plex_sync from app.plex.client import PlexClient, PlexError, PlexNotConfigured -from app.plex import rollup, subtitles from app.routes._common import owned_or_404 from app.routes.admin import admin_user from app.routes.feed import _to_tsquery_str +from app.routes.plex._shared import _item_or_404 router = APIRouter(prefix="/api/plex", tags=["plex"]) -from app.routes.plex._shared import ( - _item_or_404, -) - _TS_CONFIG = "public.unaccent_simple" + @router.get("/libraries") -def list_libraries(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: +def list_libraries( + user: User = Depends(current_user), db: Session = Depends(get_db) +) -> dict: """The mirrored libraries the user can browse (drives the Plex scope selector).""" - libs = db.query(PlexLibrary).order_by(PlexLibrary.kind.desc(), PlexLibrary.title).all() + libs = ( + db.query(PlexLibrary).order_by(PlexLibrary.kind.desc(), PlexLibrary.title).all() + ) out = [] for lib in libs: if lib.kind == "movie": - count = db.query(func.count(PlexItem.id)).filter_by(library_id=lib.id, kind="movie").scalar() + count = ( + db.query(func.count(PlexItem.id)) + .filter_by(library_id=lib.id, kind="movie") + .scalar() + ) else: - count = db.query(func.count(PlexShow.id)).filter_by(library_id=lib.id).scalar() - out.append({"key": lib.plex_key, "title": lib.title, "kind": lib.kind, "count": count or 0}) + count = ( + db.query(func.count(PlexShow.id)).filter_by(library_id=lib.id).scalar() + ) + out.append( + { + "key": lib.plex_key, + "title": lib.title, + "kind": lib.kind, + "count": count or 0, + } + ) return {"enabled": sysconfig.get_bool(db, "plex_enabled"), "libraries": out} + def _movie_card(it: PlexItem, st: PlexState | None) -> dict: return { "id": it.rating_key, @@ -66,6 +84,7 @@ def _movie_card(it: PlexItem, st: PlexState | None) -> dict: "position_seconds": st.position_seconds if st else 0, } + def _show_card(sh: PlexShow, status: str = "new") -> dict: return { "id": sh.rating_key, @@ -78,6 +97,7 @@ def _show_card(sh: PlexShow, status: str = "new") -> dict: "status": status, } + def _lib_key(db: Session, library_id: int | None) -> str | None: """The Plex section key for a library id — the info/show page passes it to the admin collection editor (in the unified cross-library view there's no single library prop otherwise).""" @@ -86,6 +106,7 @@ def _lib_key(db: Session, library_id: int | None) -> str | None: lib = db.get(PlexLibrary, library_id) return lib.plex_key if lib else None + def _episode_card(e: PlexItem, st: PlexState | None) -> dict: return { "id": e.rating_key, @@ -101,8 +122,12 @@ def _episode_card(e: PlexItem, st: PlexState | None) -> dict: "position_seconds": st.position_seconds if st else 0, } + def _leaf_card( - it: PlexItem, st: PlexState | None, show_title: str | None = None, show_rk: str | None = None + it: PlexItem, + st: PlexState | None, + show_title: str | None = None, + show_rk: str | None = None, ) -> dict: """A playable-leaf card (movie or episode). Used by playlists, which can mix both kinds. `show_rk` (the show's rating_key) lets the client group a playlist's episodes by show/season.""" @@ -113,15 +138,19 @@ def _leaf_card( return card return _movie_card(it, st) + def _csv(v: str | None) -> list[str]: return [s.strip() for s in v.split(",") if s.strip()] if v else [] + _ADDED_WINDOWS = {"24h": 1, "1w": 7, "1m": 30, "6m": 182, "1y": 365} + def _added_cutoff(within: str | None) -> datetime | None: days = _ADDED_WINDOWS.get(within or "") return datetime.now(timezone.utc) - timedelta(days=days) if days else None + def _meta_filter_conds(model, p: dict) -> list: """The shared metadata-filter conditions (identical column names on plex_items + plex_shows) for `p` (the request's filter params). Duration is movie-only, handled by the caller. Returned as a @@ -156,10 +185,12 @@ def _meta_filter_conds(model, p: dict) -> list: conds.append(model.collection_keys.contains([p["collection"]])) return conds + def _apply_meta_filters(q, model, p: dict): conds = _meta_filter_conds(model, p) return q.filter(*conds) if conds else q + @dataclass class LibraryFilters: """The shared sidebar filter query-params for /library + /facets, declared once and injected via @@ -177,21 +208,31 @@ class LibraryFilters: added_within: str | None = None directors: str | None = None actors: str | None = None - actor_mode: str = "any" # how multiple actors combine: any (OR, default) | all (AND) + actor_mode: str = ( + "any" # how multiple actors combine: any (OR, default) | all (AND) + ) studios: str | None = None collection: str | None = None def as_dict(self) -> dict: return { - "genres": self.genres, "genre_mode": self.genre_mode, - "content_ratings": self.content_ratings, "year_min": self.year_min, - "year_max": self.year_max, "rating_min": self.rating_min, - "duration_min": self.duration_min, "duration_max": self.duration_max, - "added_within": self.added_within, "directors": self.directors, - "actors": self.actors, "actor_mode": self.actor_mode, - "studios": self.studios, "collection": self.collection, + "genres": self.genres, + "genre_mode": self.genre_mode, + "content_ratings": self.content_ratings, + "year_min": self.year_min, + "year_max": self.year_max, + "rating_min": self.rating_min, + "duration_min": self.duration_min, + "duration_max": self.duration_max, + "added_within": self.added_within, + "directors": self.directors, + "actors": self.actors, + "actor_mode": self.actor_mode, + "studios": self.studios, + "collection": self.collection, } + @router.get("/library") def unified_library( scope: str = "both", @@ -225,7 +266,13 @@ def unified_library( st = aliased(PlexState) m_status = case( (st.status == "watched", "watched"), - (and_(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched")), "in_progress"), + ( + and_( + st.position_seconds > 0, + or_(st.status.is_(None), st.status != "watched"), + ), + "in_progress", + ), else_="new", ) mq = ( @@ -242,7 +289,11 @@ def unified_library( cast(null(), Integer).label("season_count"), func.coalesce(st.position_seconds, 0).label("position_seconds"), m_status.label("status"), - (func.ts_rank(PlexItem.search_vector, tsq) if tsq is not None else literal(0.0)).label("rank"), + ( + func.ts_rank(PlexItem.search_vector, tsq) + if tsq is not None + else literal(0.0) + ).label("rank"), ) .outerjoin(st, and_(st.item_id == PlexItem.id, st.user_id == user.id)) .filter(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_lib_ids)) @@ -250,9 +301,14 @@ def unified_library( if show == "watched": mq = mq.filter(st.status == "watched") elif show == "in_progress": - mq = mq.filter(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched")) + mq = mq.filter( + st.position_seconds > 0, + or_(st.status.is_(None), st.status != "watched"), + ) elif show == "unwatched": - mq = mq.filter(or_(st.status.is_(None), st.status.notin_(["watched", "hidden"]))) + mq = mq.filter( + or_(st.status.is_(None), st.status.notin_(["watched", "hidden"])) + ) else: mq = mq.filter(or_(st.status.is_(None), st.status != "hidden")) mq = _apply_meta_filters(mq, PlexItem, p) @@ -287,7 +343,11 @@ def unified_library( PlexShow.child_count.label("season_count"), literal(0).label("position_seconds"), s_status.label("status"), - (func.ts_rank(PlexShow.search_vector, tsq) if tsq is not None else literal(0.0)).label("rank"), + ( + func.ts_rank(PlexShow.search_vector, tsq) + if tsq is not None + else literal(0.0) + ).label("rank"), ) .outerjoin(agg, agg.c.show_id == PlexShow.id) .filter(PlexShow.library_id.in_(show_lib_ids)) @@ -304,7 +364,14 @@ def unified_library( selects.append(sq) if not selects: - return {"scope": scope, "total": 0, "offset": offset, "limit": limit, "items": [], "episodes": []} + return { + "scope": scope, + "total": 0, + "offset": offset, + "limit": limit, + "items": [], + "episodes": [], + } union = selects[0] if len(selects) == 1 else selects[0].union_all(*selects[1:]) u = union.subquery() total = db.query(func.count()).select_from(u).scalar() or 0 @@ -326,23 +393,40 @@ def unified_library( items = [] for r in rows: if r.kind == "movie": - items.append({ - "id": r.rk, "type": "movie", "title": r.title, "year": r.year, - "duration_seconds": r.duration_s, "thumb": f"/api/plex/image/{r.rk}", - "playable": r.playable, "status": r.status, "position_seconds": r.position_seconds, - }) + items.append( + { + "id": r.rk, + "type": "movie", + "title": r.title, + "year": r.year, + "duration_seconds": r.duration_s, + "thumb": f"/api/plex/image/{r.rk}", + "playable": r.playable, + "status": r.status, + "position_seconds": r.position_seconds, + } + ) else: - items.append({ - "id": r.rk, "type": "show", "title": r.title, "year": r.year, - "thumb": f"/api/plex/image/{r.rk}", "season_count": r.season_count, "status": r.status, - }) + items.append( + { + "id": r.rk, + "type": "show", + "title": r.title, + "year": r.year, + "thumb": f"/api/plex/image/{r.rk}", + "season_count": r.season_count, + "status": r.status, + } + ) episodes = [] if tsq is not None and want_shows: ep_st = aliased(PlexState) eps = ( db.query(PlexItem, ep_st, PlexShow.title.label("show_title")) - .outerjoin(ep_st, and_(ep_st.item_id == PlexItem.id, ep_st.user_id == user.id)) + .outerjoin( + ep_st, and_(ep_st.item_id == PlexItem.id, ep_st.user_id == user.id) + ) .outerjoin(PlexShow, PlexShow.id == PlexItem.show_id) .filter( PlexItem.kind == "episode", @@ -358,7 +442,15 @@ def unified_library( card["show_title"] = show_title episodes.append(card) - return {"scope": scope, "total": total, "offset": offset, "limit": limit, "items": items, "episodes": episodes} + return { + "scope": scope, + "total": total, + "offset": offset, + "limit": limit, + "items": items, + "episodes": episodes, + } + @router.get("/facets") def facets( @@ -383,8 +475,14 @@ def facets( "duration_max": None, } libs = db.query(PlexLibrary).filter_by(enabled=True).all() - movie_ids = [lb.id for lb in libs if lb.kind == "movie"] if scope in ("movie", "both") else [] - show_ids = [lb.id for lb in libs if lb.kind == "show"] if scope in ("show", "both") else [] + movie_ids = ( + [lb.id for lb in libs if lb.kind == "movie"] + if scope in ("movie", "both") + else [] + ) + show_ids = ( + [lb.id for lb in libs if lb.kind == "show"] if scope in ("show", "both") else [] + ) if not movie_ids and not show_ids: return empty p = f.as_dict() @@ -408,9 +506,14 @@ def facets( if show == "watched": query = query.filter(st.status == "watched") elif show == "in_progress": - query = query.filter(st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched")) + query = query.filter( + st.position_seconds > 0, + or_(st.status.is_(None), st.status != "watched"), + ) elif show == "unwatched": - query = query.filter(or_(st.status.is_(None), st.status.notin_(["watched", "hidden"]))) + query = query.filter( + or_(st.status.is_(None), st.status.notin_(["watched", "hidden"])) + ) else: query = query.filter(or_(st.status.is_(None), st.status != "hidden")) else: @@ -448,36 +551,68 @@ def facets( # jsonb_array_elements_text() raises on a scalar ("cannot extract elements from a scalar") — # so a single metadata-poor library (e.g. one bare Shows library with no genres) 500'd the # whole facets endpoint. Mirror the exact guard people.py already applies to the same columns. - expanded = wsq( - db.query(func.jsonb_array_elements_text(model.genres).label("g")), - model, ids, genre_pp, kind_movie, True, - ).filter(func.jsonb_typeof(model.genres) == "array").subquery() + expanded = ( + wsq( + db.query(func.jsonb_array_elements_text(model.genres).label("g")), + model, + ids, + genre_pp, + kind_movie, + True, + ) + .filter(func.jsonb_typeof(model.genres) == "array") + .subquery() + ) for g, c in db.query(expanded.c.g, func.count()).group_by(expanded.c.g).all(): genre_counts[g] = genre_counts.get(g, 0) + c # Content ratings — exclude its own. - crq = wsq( - db.query(model.content_rating, func.count()), - model, ids, {**p, "content_ratings": None}, kind_movie, True, - ).filter(model.content_rating.isnot(None)).group_by(model.content_rating) + crq = ( + wsq( + db.query(model.content_rating, func.count()), + model, + ids, + {**p, "content_ratings": None}, + kind_movie, + True, + ) + .filter(model.content_rating.isnot(None)) + .group_by(model.content_rating) + ) for cr, c in crq.all(): cr_counts[cr] = cr_counts.get(cr, 0) + c # Year bounds — exclude the year filter. by = wsq( db.query(func.min(model.year), func.max(model.year)), - model, ids, {**p, "year_min": None, "year_max": None}, kind_movie, True, + model, + ids, + {**p, "year_min": None, "year_max": None}, + kind_movie, + True, ).first() if by[0] is not None: years.append(by[0]) if by[1] is not None: years.append(by[1]) # Rating max — exclude the rating filter. - br = wsq(db.query(func.max(model.rating)), model, ids, {**p, "rating_min": None}, kind_movie, True).scalar() + br = wsq( + db.query(func.max(model.rating)), + model, + ids, + {**p, "rating_min": None}, + kind_movie, + True, + ).scalar() if br is not None: ratings.append(float(br)) # Duration bounds (movies) — exclude the duration filter. if kind_movie: bd = wsq( - db.query(func.min(model.duration_s), func.max(model.duration_s)), model, ids, p, kind_movie, False + db.query(func.min(model.duration_s), func.max(model.duration_s)), + model, + ids, + p, + kind_movie, + False, ).first() if bd[0] is not None: durs.append(bd[0]) @@ -493,8 +628,14 @@ def facets( for g in _csv(f.genres): genre_counts.setdefault(g, 0) return { - "genres": [{"value": g, "count": c} for g, c in sorted(genre_counts.items(), key=lambda kv: kv[0])], - "content_ratings": [{"value": cr, "count": c} for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1])], + "genres": [ + {"value": g, "count": c} + for g, c in sorted(genre_counts.items(), key=lambda kv: kv[0]) + ], + "content_ratings": [ + {"value": cr, "count": c} + for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1]) + ], "year_min": min(years) if years else None, "year_max": max(years) if years else None, "rating_max": max(ratings) if ratings else None, @@ -502,14 +643,17 @@ def facets( "duration_max": max(durs) if durs else None, } + def _like_escape(s: str) -> str: return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + def _collection_editable(c: PlexCollection) -> bool: """Effective editability: the admin marked it editable AND it's a plain manual collection — never smart, never an external auto-list (IMDb/TMDb/…), which are managed outside Plex.""" return bool(c.editable) and not c.smart and _collection_source(c) == "collection" + def _collection_card(c: PlexCollection) -> dict: return { "id": c.rating_key, @@ -520,9 +664,12 @@ def _collection_card(c: PlexCollection) -> dict: "smart": c.smart, "source": _collection_source(c), "editable": c.editable, - "can_edit": _collection_editable(c), # whether the edit controls should be offered (admin UI) + "can_edit": _collection_editable( + c + ), # whether the edit controls should be offered (admin UI) } + @router.get("/collections") def collections( library: str | None = None, @@ -543,7 +690,9 @@ def collections( enabled_ids = [lb.id for lb in db.query(PlexLibrary.id).filter_by(enabled=True)] if not enabled_ids: return {"collections": []} - query = db.query(PlexCollection).filter(PlexCollection.library_id.in_(enabled_ids)) + query = db.query(PlexCollection).filter( + PlexCollection.library_id.in_(enabled_ids) + ) term = (q or "").strip() if term: query = query.filter(PlexCollection.title.ilike(f"%{_like_escape(term)}%")) @@ -552,6 +701,7 @@ def collections( ).all() return {"collections": [_collection_card(c) for c in rows]} + def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection: col = db.query(PlexCollection).filter_by(rating_key=str(rating_key)).first() if col is None: @@ -560,21 +710,27 @@ def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection: raise HTTPException(status_code=403, detail="This collection is read-only") return col + class CreateCollectionIn(BaseModel): library: str = "" title: str = "" item_rating_key: str = "" + @router.post("/collections") def create_collection( - body: CreateCollectionIn, _: User = Depends(admin_user), db: Session = Depends(get_db) + body: CreateCollectionIn, + _: User = Depends(admin_user), + db: Session = Depends(get_db), ) -> dict: """Create a manual collection (seeded with one item — Plex needs at least one). Editable by default.""" library = body.library title = body.title.strip() seed = body.item_rating_key if not title or not seed: - raise HTTPException(status_code=400, detail="title and item_rating_key are required") + raise HTTPException( + status_code=400, detail="title and item_rating_key are required" + ) lib = db.query(PlexLibrary).filter_by(plex_key=library).first() if lib is None: raise HTTPException(status_code=404, detail="Unknown library") @@ -589,9 +745,13 @@ def create_collection( raise HTTPException(status_code=422, detail=f"Plex write failed: {e}") return _collection_card(col) + @router.post("/collections/{rating_key}/items/{item_rating_key}") def collection_add_item( - rating_key: str, item_rating_key: str, _: User = Depends(admin_user), db: Session = Depends(get_db) + rating_key: str, + item_rating_key: str, + _: User = Depends(admin_user), + db: Session = Depends(get_db), ) -> dict: col = _editable_collection_or_403(db, rating_key) lib = db.get(PlexLibrary, col.library_id) @@ -605,9 +765,13 @@ def collection_add_item( raise HTTPException(status_code=422, detail=f"Plex write failed: {e}") return _collection_card(col) + @router.delete("/collections/{rating_key}/items/{item_rating_key}") def collection_remove_item( - rating_key: str, item_rating_key: str, _: User = Depends(admin_user), db: Session = Depends(get_db) + rating_key: str, + item_rating_key: str, + _: User = Depends(admin_user), + db: Session = Depends(get_db), ) -> dict: col = _editable_collection_or_403(db, rating_key) lib = db.get(PlexLibrary, col.library_id) @@ -621,12 +785,17 @@ def collection_remove_item( raise HTTPException(status_code=422, detail=f"Plex write failed: {e}") return _collection_card(col) + class CollectionRenameIn(BaseModel): title: str = "" + @router.patch("/collections/{rating_key}") def collection_rename( - rating_key: str, body: CollectionRenameIn, _: User = Depends(admin_user), db: Session = Depends(get_db) + rating_key: str, + body: CollectionRenameIn, + _: User = Depends(admin_user), + db: Session = Depends(get_db), ) -> dict: col = _editable_collection_or_403(db, rating_key) title = body.title.strip() @@ -643,6 +812,7 @@ def collection_rename( raise HTTPException(status_code=422, detail=f"Plex write failed: {e}") return _collection_card(col) + @router.delete("/collections/{rating_key}") def collection_delete( rating_key: str, _: User = Depends(admin_user), db: Session = Depends(get_db) @@ -659,12 +829,17 @@ def collection_delete( raise HTTPException(status_code=422, detail=f"Plex write failed: {e}") return {"deleted": rating_key} + class CollectionEditableIn(BaseModel): editable: bool = False + @router.post("/collections/{rating_key}/editable") def collection_set_editable( - rating_key: str, body: CollectionEditableIn, _: User = Depends(admin_user), db: Session = Depends(get_db) + rating_key: str, + body: CollectionEditableIn, + _: User = Depends(admin_user), + db: Session = Depends(get_db), ) -> dict: """Mark an existing Plex collection as 'mine to edit' (or unmark). Smart / auto-list collections (IMDb/TMDb/…) can never be made editable — they're regenerated outside Plex.""" @@ -673,14 +848,19 @@ def collection_set_editable( raise HTTPException(status_code=404, detail="Unknown collection") want = body.editable if want and (col.smart or _collection_source(col) != "collection"): - raise HTTPException(status_code=400, detail="Smart / auto-list collections can't be made editable") + raise HTTPException( + status_code=400, + detail="Smart / auto-list collections can't be made editable", + ) col.editable = want db.commit() return _collection_card(col) + def _own_playlist_or_404(db: Session, pid: int, user: User) -> PlexPlaylist: return owned_or_404(db, PlexPlaylist, pid, user, detail="Playlist not found") + def _playlist_card(pl: PlexPlaylist, count: int, thumb_rk: str | None) -> dict: return { "id": pl.id, @@ -689,6 +869,7 @@ def _playlist_card(pl: PlexPlaylist, count: int, thumb_rk: str | None) -> dict: "thumb": f"/api/plex/image/{thumb_rk}" if thumb_rk else None, } + def _playlist_item_query(db: Session, pid: int): return ( db.query(PlexPlaylistItem, PlexItem) @@ -697,6 +878,7 @@ def _playlist_item_query(db: Session, pid: int): .order_by(PlexPlaylistItem.position, PlexPlaylistItem.id) ) + @router.get("/playlists") def playlists( contains: str | None = None, @@ -718,19 +900,33 @@ def playlists( rows = db.query(PlexItem.id).filter(PlexItem.rating_key.in_(rks or [""])).all() group_ids = {r[0] for r in rows} out = [] - for pl in db.query(PlexPlaylist).filter_by(user_id=user.id).order_by(PlexPlaylist.updated_at.desc()): - count = db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pl.id).scalar() or 0 + for pl in ( + db.query(PlexPlaylist) + .filter_by(user_id=user.id) + .order_by(PlexPlaylist.updated_at.desc()) + ): + count = ( + db.query(func.count(PlexPlaylistItem.id)) + .filter_by(playlist_id=pl.id) + .scalar() + or 0 + ) first = _playlist_item_query(db, pl.id).first() card = _playlist_card(pl, count, first[1].rating_key if first else None) if contains_id is not None: card["has_item"] = ( - db.query(PlexPlaylistItem.id).filter_by(playlist_id=pl.id, item_id=contains_id).first() + db.query(PlexPlaylistItem.id) + .filter_by(playlist_id=pl.id, item_id=contains_id) + .first() is not None ) if group_ids is not None: card["group_in"] = ( db.query(func.count(PlexPlaylistItem.id)) - .filter(PlexPlaylistItem.playlist_id == pl.id, PlexPlaylistItem.item_id.in_(group_ids or [0])) + .filter( + PlexPlaylistItem.playlist_id == pl.id, + PlexPlaylistItem.item_id.in_(group_ids or [0]), + ) .scalar() or 0 ) @@ -740,13 +936,17 @@ def playlists( resp["group_size"] = len(group_ids) return resp + class CreatePlexPlaylistIn(BaseModel): title: str = "" item_rating_key: str = "" + @router.post("/playlists") def create_playlist( - body: CreatePlexPlaylistIn, user: User = Depends(current_user), db: Session = Depends(get_db) + body: CreatePlexPlaylistIn, + user: User = Depends(current_user), + db: Session = Depends(get_db), ) -> dict: title = body.title.strip() if not title: @@ -765,20 +965,26 @@ def create_playlist( db.commit() return _playlist_card(pl, count, thumb_rk) + @router.get("/playlists/{pid}") -def playlist_detail(pid: int, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: +def playlist_detail( + pid: int, user: User = Depends(current_user), db: Session = Depends(get_db) +) -> dict: pl = _own_playlist_or_404(db, pid, user) rows = _playlist_item_query(db, pid).all() items = [it for (_pi, it) in rows] states = { s.item_id: s for s in db.query(PlexState).filter( - PlexState.user_id == user.id, PlexState.item_id.in_([it.id for it in items] or [0]) + PlexState.user_id == user.id, + PlexState.item_id.in_([it.id for it in items] or [0]), ) } shows = { sh.id: sh - for sh in db.query(PlexShow).filter(PlexShow.id.in_([it.show_id for it in items if it.show_id] or [0])) + for sh in db.query(PlexShow).filter( + PlexShow.id.in_([it.show_id for it in items if it.show_id] or [0]) + ) } cards = [ _leaf_card( @@ -791,12 +997,17 @@ def playlist_detail(pid: int, user: User = Depends(current_user), db: Session = ] return {"id": pl.id, "title": pl.title, "items": cards} + class RenamePlexPlaylistIn(BaseModel): title: str = "" + @router.patch("/playlists/{pid}") def rename_playlist( - pid: int, body: RenamePlexPlaylistIn, user: User = Depends(current_user), db: Session = Depends(get_db) + pid: int, + body: RenamePlexPlaylistIn, + user: User = Depends(current_user), + db: Session = Depends(get_db), ) -> dict: pl = _own_playlist_or_404(db, pid, user) title = body.title.strip() @@ -804,38 +1015,63 @@ def rename_playlist( raise HTTPException(status_code=400, detail="title is required") pl.title = title db.commit() - count = db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pl.id).scalar() or 0 + count = ( + db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pl.id).scalar() + or 0 + ) first = _playlist_item_query(db, pl.id).first() return _playlist_card(pl, count, first[1].rating_key if first else None) + @router.delete("/playlists/{pid}") -def delete_playlist(pid: int, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: +def delete_playlist( + pid: int, user: User = Depends(current_user), db: Session = Depends(get_db) +) -> dict: pl = _own_playlist_or_404(db, pid, user) db.delete(pl) db.commit() return {"deleted": pid} + class PlexItemRefIn(BaseModel): item_rating_key: str = "" + @router.post("/playlists/{pid}/items") def playlist_add_item( - pid: int, body: PlexItemRefIn, user: User = Depends(current_user), db: Session = Depends(get_db) + pid: int, + body: PlexItemRefIn, + user: User = Depends(current_user), + db: Session = Depends(get_db), ) -> dict: pl = _own_playlist_or_404(db, pid, user) it = db.query(PlexItem).filter_by(rating_key=body.item_rating_key).first() if it is None: raise HTTPException(status_code=404, detail="Unknown item") - exists = db.query(PlexPlaylistItem).filter_by(playlist_id=pid, item_id=it.id).first() + exists = ( + db.query(PlexPlaylistItem).filter_by(playlist_id=pid, item_id=it.id).first() + ) if exists is None: - maxpos = db.query(func.coalesce(func.max(PlexPlaylistItem.position), -1)).filter_by(playlist_id=pid).scalar() - db.add(PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=int(maxpos) + 1)) + maxpos = ( + db.query(func.coalesce(func.max(PlexPlaylistItem.position), -1)) + .filter_by(playlist_id=pid) + .scalar() + ) + db.add( + PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=int(maxpos) + 1) + ) pl.updated_at = datetime.now(timezone.utc) db.commit() return {"ok": True} + @router.delete("/playlists/{pid}/items/{item_rating_key}") -def playlist_remove_item(pid: int, item_rating_key: str, user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: +def playlist_remove_item( + pid: int, + item_rating_key: str, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: pl = _own_playlist_or_404(db, pid, user) it = db.query(PlexItem).filter_by(rating_key=str(item_rating_key)).first() if it is not None: @@ -844,15 +1080,20 @@ def playlist_remove_item(pid: int, item_rating_key: str, user: User = Depends(cu db.commit() return {"ok": True} + class PlexItemKeysIn(BaseModel): item_rating_keys: list[str] = [] + def _items_by_rks(db: Session, rks: list) -> list[PlexItem]: """Resolve rating_keys to PlexItems, preserving the input order (drops unknown keys).""" order = [str(k) for k in rks if str(k)] if not order: return [] - by_rk = {it.rating_key: it for it in db.query(PlexItem).filter(PlexItem.rating_key.in_(order))} + by_rk = { + it.rating_key: it + for it in db.query(PlexItem).filter(PlexItem.rating_key.in_(order)) + } seen: set[str] = set() out: list[PlexItem] = [] for rk in order: @@ -862,16 +1103,26 @@ def _items_by_rks(db: Session, rks: list) -> list[PlexItem]: out.append(it) return out + @router.post("/playlists/{pid}/items/bulk") def playlist_add_bulk( - pid: int, body: PlexItemKeysIn, user: User = Depends(current_user), db: Session = Depends(get_db) + pid: int, + body: PlexItemKeysIn, + user: User = Depends(current_user), + db: Session = Depends(get_db), ) -> dict: """Append many items at once (a whole season/show). Already-present items are skipped; the input order is preserved for the newly-added ones. Returns how many were added + the new total.""" pl = _own_playlist_or_404(db, pid, user) items = _items_by_rks(db, body.item_rating_keys) - existing = {row[0] for row in db.query(PlexPlaylistItem.item_id).filter_by(playlist_id=pid)} - pos = int(db.query(func.coalesce(func.max(PlexPlaylistItem.position), -1)).filter_by(playlist_id=pid).scalar()) + existing = { + row[0] for row in db.query(PlexPlaylistItem.item_id).filter_by(playlist_id=pid) + } + pos = int( + db.query(func.coalesce(func.max(PlexPlaylistItem.position), -1)) + .filter_by(playlist_id=pid) + .scalar() + ) added = 0 for it in items: if it.id in existing: @@ -883,12 +1134,19 @@ def playlist_add_bulk( if added: pl.updated_at = datetime.now(timezone.utc) db.commit() - total = db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pid).scalar() or 0 + total = ( + db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pid).scalar() + or 0 + ) return {"added": added, "item_count": total} + @router.post("/playlists/{pid}/items/remove-bulk") def playlist_remove_bulk( - pid: int, body: PlexItemKeysIn, user: User = Depends(current_user), db: Session = Depends(get_db) + pid: int, + body: PlexItemKeysIn, + user: User = Depends(current_user), + db: Session = Depends(get_db), ) -> dict: """Remove many items at once (a whole season/show, or a multi-select). Returns how many were removed + the new total.""" @@ -898,37 +1156,53 @@ def playlist_remove_bulk( if ids: removed = ( db.query(PlexPlaylistItem) - .filter(PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id.in_(ids)) + .filter( + PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id.in_(ids) + ) .delete(synchronize_session=False) ) if removed: pl.updated_at = datetime.now(timezone.utc) db.commit() - total = db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pid).scalar() or 0 + total = ( + db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pid).scalar() + or 0 + ) return {"removed": removed, "item_count": total} + @router.put("/playlists/{pid}/order") def reorder_playlist( - pid: int, body: PlexItemKeysIn, user: User = Depends(current_user), db: Session = Depends(get_db) + pid: int, + body: PlexItemKeysIn, + user: User = Depends(current_user), + db: Session = Depends(get_db), ) -> dict: """Set the playlist order from an explicit list of item rating_keys (0-based positions).""" pl = _own_playlist_or_404(db, pid, user) order = body.item_rating_keys - id_by_rk = {it.rating_key: it.id for it in db.query(PlexItem).filter(PlexItem.rating_key.in_(order or [""]))} + id_by_rk = { + it.rating_key: it.id + for it in db.query(PlexItem).filter(PlexItem.rating_key.in_(order or [""])) + } for pos, rk in enumerate(order): iid = id_by_rk.get(rk) if iid is not None: - db.query(PlexPlaylistItem).filter_by(playlist_id=pid, item_id=iid).update({"position": pos}) + db.query(PlexPlaylistItem).filter_by(playlist_id=pid, item_id=iid).update( + {"position": pos} + ) pl.updated_at = datetime.now(timezone.utc) db.commit() return {"ok": True} + _SHOW_ENRICH_TTL_S = 300 _show_enrich_cache: dict[str, tuple[float, dict, list]] = {} _show_enrich_lock = threading.Lock() + def _show_enrichment(db: Session, rating_key: str) -> tuple[dict, list]: """(meta, related_raw) for a show's live Plex enrichment — served from the per-show TTL cache when warm, otherwise fetched with the two Plex calls (metadata + related) run IN PARALLEL. Returns @@ -942,7 +1216,9 @@ def _show_enrichment(db: Session, rating_key: str) -> tuple[dict, list]: with PlexClient(db) as plex: with ThreadPoolExecutor(max_workers=2) as ex: meta_f = ex.submit(plex.metadata, rating_key) - related_f = ex.submit(plex.related, rating_key) # swallows its own errors → [] + related_f = ex.submit( + plex.related, rating_key + ) # swallows its own errors → [] meta = meta_f.result() or {} related_raw = related_f.result() except (PlexError, PlexNotConfigured): @@ -953,9 +1229,14 @@ def _show_enrichment(db: Session, rating_key: str) -> tuple[dict, list]: # have related items — the payload worth caching — still get cached. if related_raw: with _show_enrich_lock: - _show_enrich_cache[rating_key] = (now + _SHOW_ENRICH_TTL_S, meta, related_raw) + _show_enrich_cache[rating_key] = ( + now + _SHOW_ENRICH_TTL_S, + meta, + related_raw, + ) return meta, related_raw + @router.get("/show/{rating_key}") def show_detail( rating_key: str, @@ -967,22 +1248,31 @@ def show_detail( if sh is None: raise HTTPException(status_code=404, detail="Unknown Plex show") seasons = ( - db.query(PlexSeason).filter_by(show_id=sh.id).order_by(PlexSeason.season_number.asc().nullsfirst()).all() + db.query(PlexSeason) + .filter_by(show_id=sh.id) + .order_by(PlexSeason.season_number.asc().nullsfirst()) + .all() ) eps = ( db.query(PlexItem) .filter_by(show_id=sh.id, kind="episode") - .order_by(PlexItem.season_number.asc().nullsfirst(), PlexItem.episode_number.asc().nullsfirst()) + .order_by( + PlexItem.season_number.asc().nullsfirst(), + PlexItem.episode_number.asc().nullsfirst(), + ) .all() ) states = { s.item_id: s for s in db.query(PlexState).filter( - PlexState.user_id == user.id, PlexState.item_id.in_([e.id for e in eps] or [0]) + PlexState.user_id == user.id, + PlexState.item_id.in_([e.id for e in eps] or [0]), ) } by_season: dict[int | None, list] = {} - all_cards: list[dict] = [] # every episode, in season/episode order → show-level rollup + all_cards: list[ + dict + ] = [] # every episode, in season/episode order → show-level rollup for e in eps: card = _episode_card(e, states.get(e.id)) by_season.setdefault(e.season_id, []).append(card) @@ -1039,13 +1329,11 @@ def show_detail( "episode_count": show_roll["episode_count"], "collection_keys": sh.collection_keys or [], }, - "seasons": [ - _season_block(se, by_season.get(se.id, [])) - for se in seasons - ], + "seasons": [_season_block(se, by_season.get(se.id, [])) for se in seasons], "related": related, } + def _season_block(se: PlexSeason, cards: list[dict]) -> dict: """A season's card for the show page (with its aggregate rollup) plus its episodes (used by the season subpage, read from the same cached show-detail payload).""" @@ -1053,7 +1341,8 @@ def _season_block(se: PlexSeason, cards: list[dict]) -> dict: return { "id": se.rating_key, "season_number": se.season_number, - "title": se.title or (f"Season {se.season_number}" if se.season_number else "Season"), + "title": se.title + or (f"Season {se.season_number}" if se.season_number else "Season"), "thumb": f"/api/plex/image/{se.rating_key}", "episode_count": roll["episode_count"], "status": roll["status"], @@ -1062,7 +1351,10 @@ def _season_block(se: PlexSeason, cards: list[dict]) -> dict: "episodes": cards, } -def _related_show_cards(db: Session, user_id: int, related: list[dict], exclude: int) -> list[dict]: + +def _related_show_cards( + db: Session, user_id: int, related: list[dict], exclude: int +) -> list[dict]: """Map Plex 'related' metadata to show cards for the shows we actually mirror (so they're openable), excluding the current show. Order preserved; capped.""" rks = [str(m.get("ratingKey")) for m in related if m.get("ratingKey")] @@ -1070,7 +1362,9 @@ def _related_show_cards(db: Session, user_id: int, related: list[dict], exclude: return [] shows = { s.rating_key: s - for s in db.query(PlexShow).filter(PlexShow.rating_key.in_(rks[:60]), PlexShow.id != exclude) + for s in db.query(PlexShow).filter( + PlexShow.rating_key.in_(rks[:60]), PlexShow.id != exclude + ) } statuses = rollup.state_map(db, user_id, [s.id for s in shows.values()]) out: list[dict] = [] @@ -1085,6 +1379,7 @@ def _related_show_cards(db: Session, user_id: int, related: list[dict], exclude: break return out + def _rich_meta(meta: dict) -> dict: """Pull the "info page" extras out of a full Plex metadata dict: an IMDb score + link, content rating, genres, director(s), studio, tagline, and cast (name/role/photo). Best-effort — any @@ -1100,7 +1395,9 @@ def _rich_meta(meta: dict) -> dict: break if imdb_rating is None: try: - imdb_rating = round(float(meta.get("audienceRating") or meta.get("rating")), 1) + imdb_rating = round( + float(meta.get("audienceRating") or meta.get("rating")), 1 + ) except (TypeError, ValueError): imdb_rating = None # IMDb id (tt…) from the Guid list → a clickable link. @@ -1116,12 +1413,15 @@ def _rich_meta(meta: dict) -> dict: "imdb_url": f"https://www.imdb.com/title/{imdb_id}/" if imdb_id else None, "content_rating": meta.get("contentRating"), "genres": [g.get("tag") for g in (meta.get("Genre") or []) if g.get("tag")][:8], - "directors": [d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")][:4], + "directors": [ + d.get("tag") for d in (meta.get("Director") or []) if d.get("tag") + ][:4], "studio": meta.get("studio"), "tagline": meta.get("tagline"), "cast": plex_people.cast_from_meta(meta), } + @router.get("/people/cards") def people_cards( names: str, @@ -1141,7 +1441,10 @@ def people_cards( show_lib_ids = [lb.id for lb in libs if lb.kind == "show"] want_movies = scope in ("movie", "both") and bool(movie_lib_ids) want_shows = scope in ("show", "both") and bool(show_lib_ids) - photos = {p.name: p.photo_thumb for p in db.query(PlexPerson).filter(PlexPerson.name.in_(wanted))} + photos = { + p.name: p.photo_thumb + for p in db.query(PlexPerson).filter(PlexPerson.name.in_(wanted)) + } out = [] for nm in wanted: count = 0 @@ -1159,15 +1462,20 @@ def people_cards( if want_shows: count += ( db.query(func.count(PlexShow.id)) - .filter(PlexShow.library_id.in_(show_lib_ids), PlexShow.cast_names.contains([nm])) + .filter( + PlexShow.library_id.in_(show_lib_ids), + PlexShow.cast_names.contains([nm]), + ) .scalar() or 0 ) out.append({"name": nm, "photo": photos.get(nm), "count": count}) return {"people": out} + _STRIP_PROVIDERS = ("imdb", "tmdb", "tvdb", "trakt") + def _collection_source(col: PlexCollection) -> str: first = (col.title or "").strip().split(" ", 1)[0].lower() if first in _STRIP_PROVIDERS: @@ -1176,15 +1484,21 @@ def _collection_source(col: PlexCollection) -> str: return "smart" return "collection" + def _collection_strips(db: Session, it: PlexItem, user_id: int) -> list[dict]: """The collections this movie belongs to, each with its member titles (playable cards) — the 'Avatar Collection' strip on the info page. Pure local lookup (no Plex call).""" keys = it.collection_keys or [] if not keys or it.kind != "movie": return [] - cols = {c.rating_key: c for c in db.query(PlexCollection).filter(PlexCollection.rating_key.in_(keys))} + cols = { + c.rating_key: c + for c in db.query(PlexCollection).filter(PlexCollection.rating_key.in_(keys)) + } # Smallest (most specific — e.g. a franchise) first; the big auto-lists (IMDb/TMDb Popular) last. - ordered = sorted((c for c in cols.values()), key=lambda c: (c.child_count or 0, c.title)) + ordered = sorted( + (c for c in cols.values()), key=lambda c: (c.child_count or 0, c.title) + ) out = [] for col in ordered: rk = col.rating_key @@ -1216,6 +1530,7 @@ def _collection_strips(db: Session, it: PlexItem, user_id: int) -> list[dict]: ) return out + def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]: if it.kind != "episode" or it.show_id is None: return None, None @@ -1223,14 +1538,20 @@ def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]: r[0] for r in db.query(PlexItem.rating_key) .filter_by(show_id=it.show_id, kind="episode") - .order_by(PlexItem.season_number.asc().nullsfirst(), PlexItem.episode_number.asc().nullsfirst()) + .order_by( + PlexItem.season_number.asc().nullsfirst(), + PlexItem.episode_number.asc().nullsfirst(), + ) .all() ] try: i = keys.index(it.rating_key) except ValueError: return None, None - return (keys[i - 1] if i > 0 else None), (keys[i + 1] if i < len(keys) - 1 else None) + return (keys[i - 1] if i > 0 else None), ( + keys[i + 1] if i < len(keys) - 1 else None + ) + @router.get("/item/{rating_key}") def item_detail( @@ -1267,7 +1588,9 @@ def item_detail( audio_streams.append( { "ord": len(audio_streams), - "label": s.get("displayTitle") or s.get("language") or f"Audio {len(audio_streams) + 1}", + "label": s.get("displayTitle") + or s.get("language") + or f"Audio {len(audio_streams) + 1}", "language": s.get("languageTag") or s.get("language"), "default": bool(s.get("selected") or s.get("default")), } @@ -1277,7 +1600,9 @@ def item_detail( subtitle_streams.append( { "ord": len(subtitle_streams), - "label": s.get("displayTitle") or s.get("language") or f"Subtitle {len(subtitle_streams) + 1}", + "label": s.get("displayTitle") + or s.get("language") + or f"Subtitle {len(subtitle_streams) + 1}", "language": s.get("languageTag") or s.get("language"), "codec": codec, # Text subs (srt/ass/…) can be served as WebVTT tracks; image subs (PGS/VobSub) diff --git a/backend/app/routes/plex/playback.py b/backend/app/routes/plex/playback.py index deae08dc..eb0523ac 100644 --- a/backend/app/routes/plex/playback.py +++ b/backend/app/routes/plex/playback.py @@ -4,11 +4,20 @@ import hashlib import os import tempfile from datetime import datetime, timezone + import httpx -from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, Response +from fastapi import ( + APIRouter, + BackgroundTasks, + Depends, + HTTPException, + Request, + Response, +) from fastapi.responses import FileResponse from pydantic import BaseModel from sqlalchemy.orm import Session + from app.auth import current_user, resolved_user_id from app.db import SessionLocal, get_db from app.models import ( @@ -18,18 +27,16 @@ from app.models import ( PlexState, User, ) +from app.plex import imgcache, subtitles from app.plex import paths as plex_paths from app.plex import people as plex_people from app.plex import stream as plex_stream from app.plex import watch_sync as plex_watch from app.plex.client import PlexClient, PlexError, PlexNotConfigured -from app.plex import imgcache, subtitles +from app.routes.plex._shared import _item_or_404 router = APIRouter(prefix="/api/plex", tags=["plex"]) -from app.routes.plex._shared import ( - _item_or_404, -) def _require_session(request: Request) -> int: """Lightweight auth for the high-fan-out image proxies: validate the signed session cookie WITHOUT @@ -40,6 +47,7 @@ def _require_session(request: Request) -> int: raise HTTPException(status_code=401, detail="Not authenticated") return uid + @router.get("/image/{rating_key}") def image(rating_key: str, request: Request, variant: str = "thumb") -> Response: """Proxy a Plex poster/art image (keeps the admin token server-side; no image duplication). A @@ -66,7 +74,9 @@ def image(rating_key: str, request: Request, variant: str = "thumb") -> Response key = imgcache.key_for(db, str(rating_key), v) if not key: raise HTTPException(status_code=404, detail="No image") - plex = PlexClient(db) # reads base URL + token now; the fetch below needs no DB + plex = PlexClient( + db + ) # reads base URL + token now; the fetch below needs no DB except PlexNotConfigured as e: raise HTTPException(status_code=400, detail=str(e)) try: @@ -77,8 +87,11 @@ def image(rating_key: str, request: Request, variant: str = "thumb") -> Response imgcache.write(cache_key, data, ct) return imgcache.response(data, ct) + @router.get("/subtitle/{rating_key}/{sub_ord}") -def subtitle(rating_key: str, sub_ord: int, request: Request, offset: float = 0.0) -> Response: +def subtitle( + rating_key: str, sub_ord: int, request: Request, offset: float = 0.0 +) -> Response: """Serve the ord-th subtitle track as WebVTT for the browser's — so choosing a subtitle overlays it directly (no HLS re-mux / session restart). Handles EXTERNAL sidecar subs (fetched from Plex via the stream `key` — the old `-map 0:s` broke on these since they're not in the file) @@ -94,14 +107,21 @@ def subtitle(rating_key: str, sub_ord: int, request: Request, offset: float = 0. with SessionLocal() as db: it = _item_or_404(db, rating_key) with PlexClient(db) as plex: - part = ((plex.metadata(it.rating_key) or {}).get("Media") or [{}])[0].get("Part") or [{}] - subs = [s for s in (part[0].get("Stream") or []) if s.get("streamType") == 3] + part = ((plex.metadata(it.rating_key) or {}).get("Media") or [{}])[ + 0 + ].get("Part") or [{}] + subs = [ + s for s in (part[0].get("Stream") or []) if s.get("streamType") == 3 + ] if sub_ord < 0 or sub_ord >= len(subs): raise HTTPException(status_code=404, detail="No such subtitle") s = subs[sub_ord] codec = (s.get("codec") or s.get("format") or "").lower() if codec in subtitles.BITMAP_SUBS: - raise HTTPException(status_code=415, detail="Image-based subtitle can't be shown as text") + raise HTTPException( + status_code=415, + detail="Image-based subtitle can't be shown as text", + ) if s.get("key"): # external sidecar → fetch from Plex, convert to VTT raw = plex.raw_get(s["key"]) if codec in ("srt", "subrip", ""): @@ -114,14 +134,22 @@ def subtitle(rating_key: str, sub_ord: int, request: Request, offset: float = 0. vtt = subtitles.ffmpeg_to_vtt(tmp, None) finally: os.unlink(tmp) - elif s.get("index") is not None: # embedded → extract from the local file + elif ( + s.get("index") is not None + ): # embedded → extract from the local file src = plex_paths.local_media_path(db, it.file_path) if src is None: - raise HTTPException(status_code=404, detail="Media file not found on disk") - emb_ord = sum(1 for x in subs[:sub_ord] if x.get("index") is not None) + raise HTTPException( + status_code=404, detail="Media file not found on disk" + ) + emb_ord = sum( + 1 for x in subs[:sub_ord] if x.get("index") is not None + ) vtt = subtitles.ffmpeg_to_vtt(str(src), emb_ord) else: - raise HTTPException(status_code=404, detail="Subtitle not fetchable") + raise HTTPException( + status_code=404, detail="Subtitle not fetchable" + ) except PlexNotConfigured as e: raise HTTPException(status_code=400, detail=str(e)) except PlexError as e: @@ -129,10 +157,12 @@ def subtitle(rating_key: str, sub_ord: int, request: Request, offset: float = 0. subtitles.cache_write(cache_key, vtt) return Response(subtitles.shift_vtt(vtt, offset), media_type="text/vtt") + _PROGRESS_MIN_S = 5 _FINISH_MARGIN_S = 30 + @router.get("/person-image") def person_image(u: str, request: Request) -> Response: """Proxy a cast/crew photo from Plex's public metadata CDN (host-whitelisted; no token needed). @@ -154,6 +184,7 @@ def person_image(u: str, request: Request) -> Response: imgcache.write(cache_key, r.content, ct) return imgcache.response(r.content, ct) + def _push_watch( background: BackgroundTasks, db: Session, @@ -173,10 +204,17 @@ def _push_watch( # lands before it settles synced_to_plex (which would otherwise strand that newer value). st = db.query(PlexState).filter_by(user_id=user_id, item_id=item_id).first() background.add_task( - plex_watch.push_state_to_plex, user_id, item_id, rating_key, action, time_ms, duration_ms, + plex_watch.push_state_to_plex, + user_id, + item_id, + rating_key, + action, + time_ms, + duration_ms, plex_watch._state_marker(st), ) + class ItemProgressIn(BaseModel): # float, truncated in the handler — the player may post a raw currentTime (a float); a plain # `int` field would 422 it on the periodic progress checkpoint (the old `int(...)` truncated). @@ -184,6 +222,7 @@ class ItemProgressIn(BaseModel): duration_seconds: float = 0 final: bool = False + @router.post("/item/{rating_key}/progress") def item_progress( rating_key: str, @@ -241,13 +280,22 @@ def item_progress( db.commit() if final: _push_watch( - background, db, user.id, it.id, rating_key, "resume", position * 1000, duration * 1000 + background, + db, + user.id, + it.id, + rating_key, + "resume", + position * 1000, + duration * 1000, ) return {"position_seconds": position} + class ItemStateIn(BaseModel): status: str = "" + @router.post("/item/{rating_key}/state") def item_state( rating_key: str, @@ -285,8 +333,13 @@ def item_state( _push_watch(background, db, user.id, it.id, rating_key, "watched") return {"status": status} + def _bulk_state( - background: BackgroundTasks, db: Session, user_id: int, eps: list[PlexItem], watched: bool + background: BackgroundTasks, + db: Session, + user_id: int, + eps: list[PlexItem], + watched: bool, ) -> int: """Mark every episode in `eps` watched (or unwatched) for a user, mirroring each change to a linked Plex account in the background (best-effort). Returns how many rows actually changed.""" @@ -294,7 +347,8 @@ def _bulk_state( existing = { s.item_id: s for s in db.query(PlexState).filter( - PlexState.user_id == user_id, PlexState.item_id.in_([e.id for e in eps] or [0]) + PlexState.user_id == user_id, + PlexState.item_id.in_([e.id for e in eps] or [0]), ) } pushable = plex_watch.link_for_push(db, user_id) is not None @@ -324,9 +378,11 @@ def _bulk_state( background.add_task(plex_watch.push_bulk_state_to_plex, user_id, to_push) return len(to_push) + class WatchedIn(BaseModel): watched: bool = False + @router.post("/show/{rating_key}/state") def show_state( rating_key: str, @@ -344,6 +400,7 @@ def show_state( changed = _bulk_state(background, db, user.id, eps, watched) return {"changed": changed, "watched": watched} + @router.post("/season/{rating_key}/state") def season_state( rating_key: str, @@ -361,6 +418,7 @@ def season_state( changed = _bulk_state(background, db, user.id, eps, watched) return {"changed": changed, "watched": watched} + @router.post("/stream/{rating_key}/session") def stream_session( rating_key: str, @@ -378,11 +436,17 @@ def stream_session( plex_stream.reap_idle() # opportunistic cleanup of idle sessions on each new playback it = _item_or_404(db, rating_key) if it.playable == "transcode": - raise HTTPException(status_code=501, detail="This format needs transcoding (a later phase).") + raise HTTPException( + status_code=501, detail="This format needs transcoding (a later phase)." + ) # `multi` (item has ≥2 audio tracks) forces the HLS path so ALL audio tracks ship as renditions and # the client can switch audio without a restart — even for otherwise direct-playable files. if it.playable == "direct" and audio is None and not aoff and not multi: - return {"mode": "direct", "url": f"/api/plex/stream/{it.rating_key}/file", "start_s": 0.0} + return { + "mode": "direct", + "url": f"/api/plex/stream/{it.rating_key}/file", + "start_s": 0.0, + } if plex_paths.local_media_path(db, it.file_path) is None: raise HTTPException(status_code=404, detail="Media file not found on disk") s = plex_stream.start_session(db, it, start, audio, aoff, multi) @@ -390,7 +454,12 @@ def stream_session( raise HTTPException(status_code=404, detail="Media file not found on disk") # Report the REAL media start (the keyframe ffmpeg landed on, which hls.js zero-bases to), not the # requested offset — the client's absolute clock and the subtitle cue-shift both key off this. - return {"mode": "hls", "start_s": s.media_start_s, "url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}"} + return { + "mode": "hls", + "start_s": s.media_start_s, + "url": f"/api/plex/stream/{it.rating_key}/hls/{s.entry}", + } + @router.get("/stream/{rating_key}/file") def stream_file( @@ -409,11 +478,22 @@ def stream_file( if download: # A clean, safe download name from the title + the real file extension. ext = src.suffix or (f".{it.container}" if it.container else "") - base = "".join(c for c in (it.title or "video") if c.isalnum() or c in " -_.").strip() or "video" + base = ( + "".join( + c for c in (it.title or "video") if c.isalnum() or c in " -_." + ).strip() + or "video" + ) return FileResponse(str(src), filename=f"{base}{ext}") return FileResponse(str(src)) # Starlette honours the Range header (206) -_HLS_CT = {".m3u8": "application/vnd.apple.mpegurl", ".ts": "video/mp2t", ".vtt": "text/vtt"} + +_HLS_CT = { + ".m3u8": "application/vnd.apple.mpegurl", + ".ts": "video/mp2t", + ".vtt": "text/vtt", +} + @router.get("/stream/{rating_key}/hls/{filename}") def stream_hls_file( @@ -446,5 +526,7 @@ def stream_hls_file( if not plex_stream.wait_for(path, timeout): raise HTTPException(status_code=404, detail="Not available yet") return Response( - content=path.read_bytes(), media_type=_HLS_CT[ext], headers={"Cache-Control": "no-store"} + content=path.read_bytes(), + media_type=_HLS_CT[ext], + headers={"Cache-Control": "no-store"}, )