From 52b6535dc6f5d7bb28be40132de86a86cbdcbcbd Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 18 Aug 2026 21:06:04 +0200 Subject: [PATCH] refactor(plex): split browse.py god-module into per-concern routers (E3 S1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit routes/plex/browse.py was a 1693-line god-module (libraries + facets + collections + playlists + show/item detail + people). Split it into focused sibling routers under the same /api/plex prefix — pure code motion, no SQL / route-path / response-shape changes: browse.py 1693 -> 469 (list_libraries, unified_library, facets) collections.py (new 253) browse + admin collection editor; owns _collection_source playlists.py (new 405) the user's orderable playlists detail.py (new 438) show/item detail + enrichment; _lib_key lives here people.py (new 139) cast/crew chips + person filmography Cross-module helpers _norm_lang + _content_libs move to _shared.py (their one home); detail imports _collection_source from collections (one-way, no cycle). __init__.py includes the four new routers. Three DB-lane tests follow the moved symbols (import path for _content_libs; monkeypatch targets for PlexClient / _show_enrichment now on detail). Behaviour-equivalent: ruff clean, 348 DB-lane pass (unchanged), parity net green. --- backend/app/routes/plex/__init__.py | 14 +- backend/app/routes/plex/_shared.py | 21 + backend/app/routes/plex/browse.py | 1191 +----------------------- backend/app/routes/plex/collections.py | 253 +++++ backend/app/routes/plex/detail.py | 438 +++++++++ backend/app/routes/plex/people.py | 139 +++ backend/app/routes/plex/playlists.py | 405 ++++++++ backend/tests/test_plex_detail.py | 12 +- backend/tests/test_plex_overlay.py | 2 +- backend/tests/test_plex_person.py | 2 +- 10 files changed, 1283 insertions(+), 1194 deletions(-) create mode 100644 backend/app/routes/plex/collections.py create mode 100644 backend/app/routes/plex/detail.py create mode 100644 backend/app/routes/plex/people.py create mode 100644 backend/app/routes/plex/playlists.py diff --git a/backend/app/routes/plex/__init__.py b/backend/app/routes/plex/__init__.py index 8c3cdc72..60fbfc88 100644 --- a/backend/app/routes/plex/__init__.py +++ b/backend/app/routes/plex/__init__.py @@ -10,9 +10,21 @@ wiring expects (`plex_routes.router`), so main.py is unchanged. from fastapi import APIRouter -from app.routes.plex import admin, browse, playback +from app.routes.plex import ( + admin, + browse, + collections, + detail, + people, + playback, + playlists, +) router = APIRouter() router.include_router(admin.router) router.include_router(browse.router) +router.include_router(collections.router) +router.include_router(detail.router) +router.include_router(people.router) router.include_router(playback.router) +router.include_router(playlists.router) diff --git a/backend/app/routes/plex/_shared.py b/backend/app/routes/plex/_shared.py index a5ecd2b4..4f23c57c 100644 --- a/backend/app/routes/plex/_shared.py +++ b/backend/app/routes/plex/_shared.py @@ -3,10 +3,12 @@ import logging from fastapi import HTTPException +from sqlalchemy import select from sqlalchemy.orm import Session from app.models import ( PlexItem, + PlexLibrary, ) from app.plex.lookups import by_rating_key @@ -18,3 +20,22 @@ def item_or_404(db: Session, rating_key: str) -> PlexItem: if it is None: raise HTTPException(status_code=404, detail="Unknown Plex item") return it + + +def _norm_lang(lang: str | None) -> str: + """The overlay has an EN base + an optional HU twin; anything other than `hu` serves EN.""" + return "hu" if lang == "hu" else "en" + + +def _content_libs(db: Session) -> tuple[list[int], list[int]]: + """The BROWSABLE content libraries, partitioned into (movie_lib_ids, show_lib_ids) — the single + source of the browse scope. Excludes HU overlay libraries (`overlay_of` set): those are never + browsed on their own, they only lend their Hungarian metadata to the EN twin's rows (E5 S5).""" + libs = db.execute( + select(PlexLibrary).where( + PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None) + ) + ).scalars().all() + movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"] + show_lib_ids = [lb.id for lb in libs if lb.kind == "show"] + return movie_lib_ids, show_lib_ids diff --git a/backend/app/routes/plex/browse.py b/backend/app/routes/plex/browse.py index 637dcb30..f333ffb9 100644 --- a/backend/app/routes/plex/browse.py +++ b/backend/app/routes/plex/browse.py @@ -1,49 +1,30 @@ -"""Plex browse: libraries, facets, collections, playlists, show/item detail, people.""" +"""Plex browse: the library list, the unified cross-library browse feed, and its faceted filters.""" -import threading -import time -from concurrent.futures import ThreadPoolExecutor -from datetime import datetime, timezone - -from fastapi import APIRouter, Depends, HTTPException, Query -from pydantic import BaseModel +from fastapi import APIRouter, Depends, Query from sqlalchemy import ( Integer, String, and_, cast, - delete, func, literal, null, - or_, select, - update, ) from sqlalchemy.orm import Session, aliased from app import sysconfig -from app.auth import admin_user, current_user +from app.auth import current_user from app.db import get_db from app.models import ( - PlexCollection, PlexItem, PlexLibrary, - PlexPerson, - PlexPlaylist, - PlexPlaylistItem, - PlexSeason, PlexShow, PlexState, User, ) -from app.plex import detail as plex_detail -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.cards import episode_card, leaf_card, movie_card, show_card -from app.plex.client import PlexClient, PlexError, PlexNotConfigured -from app.plex.lookups import by_rating_key +from app.plex import rollup +from app.plex.cards import episode_card from app.plex.query import ( FIELD_COLUMNS, TS_CONFIG, @@ -53,7 +34,6 @@ from app.plex.query import ( apply_show_state_filter, field_recheck, genres_col, - like_escape, meta_filter_conds, movie_status_case, show_status_case, @@ -62,8 +42,7 @@ from app.plex.query import ( vector_col, vector_match, ) -from app.routes._common import owned_or_404 -from app.routes.plex._shared import item_or_404 +from app.routes.plex._shared import _content_libs, _norm_lang from app.utils import to_tsquery_str router = APIRouter(prefix="/api/plex", tags=["plex"]) @@ -103,20 +82,6 @@ def list_libraries( return {"enabled": sysconfig.get_bool(db, "plex_enabled"), "libraries": out} -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).""" - if library_id is None: - return None - lib = db.get(PlexLibrary, library_id) - return lib.plex_key if lib else None - - -def _norm_lang(lang: str | None) -> str: - """The overlay has an EN base + an optional HU twin; anything other than `hu` serves EN.""" - return "hu" if lang == "hu" else "en" - - @router.get("/library") def unified_library( scope: str = "both", @@ -502,1147 +467,3 @@ def facets( "duration_min": min(durs) if durs else None, "duration_max": max(durs) if durs else None, } - - -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, - "title": c.title, - "summary": c.summary, - "thumb": f"/api/plex/image/{c.rating_key}" if c.thumb_key else None, - "child_count": c.child_count, - "smart": c.smart, - "source": _collection_source(c), - "editable": c.editable, - "can_edit": _collection_editable( - c - ), # whether the edit controls should be offered (admin UI) - } - - -@router.get("/collections") -def collections( - library: str | None = None, - q: str | None = None, - user: User = Depends(current_user), - db: Session = Depends(get_db), -) -> dict: - """Mirrored collections as cards (most-populated first), optionally name-filtered by `q`. With a - `library` plex_key → that one library's collections (used by the collection editor). WITHOUT one → - the UNION across all enabled libraries (the unified-scope sidebar picker, which has no single - library selected).""" - if library is not None: - lib = db.execute( - select(PlexLibrary).where(PlexLibrary.plex_key == str(library)) - ).scalars().first() - if lib is None: - return {"collections": []} - query = select(PlexCollection).where(PlexCollection.library_id == lib.id) - else: - enabled_ids = list( - db.execute( - select(PlexLibrary.id).where( - PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None) - ) - ).scalars() - ) - if not enabled_ids: - return {"collections": []} - query = select(PlexCollection).where( - PlexCollection.library_id.in_(enabled_ids) - ) - term = (q or "").strip() - if term: - query = query.where(PlexCollection.title.ilike(f"%{like_escape(term)}%")) - rows = db.execute( - query.order_by( - PlexCollection.child_count.desc().nullslast(), - func.lower(PlexCollection.title), - ) - ).scalars().all() - return {"collections": [_collection_card(c) for c in rows]} - - -def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection: - col = by_rating_key(db, PlexCollection, rating_key) - if col is None: - raise HTTPException(status_code=404, detail="Unknown collection") - if not _collection_editable(col): - 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), -) -> 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" - ) - lib = db.execute( - select(PlexLibrary).where(PlexLibrary.plex_key == library) - ).scalars().first() - if lib is None: - raise HTTPException(status_code=404, detail="Unknown library") - try: - with PlexClient(db) as plex: - new = plex.create_collection(lib.plex_key, title, seed) - col = plex_sync.resync_collection(db, plex, lib, str(new.get("ratingKey"))) - col.editable = True - db.commit() - except (PlexError, PlexNotConfigured) as e: - db.rollback() - 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), -) -> dict: - col = _editable_collection_or_403(db, rating_key) - lib = db.get(PlexLibrary, col.library_id) - try: - with PlexClient(db) as plex: - plex.add_collection_item(rating_key, str(item_rating_key)) - col = plex_sync.resync_collection(db, plex, lib, rating_key) - db.commit() - except (PlexError, PlexNotConfigured) as e: - db.rollback() - 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), -) -> dict: - col = _editable_collection_or_403(db, rating_key) - lib = db.get(PlexLibrary, col.library_id) - try: - with PlexClient(db) as plex: - plex.remove_collection_item(rating_key, str(item_rating_key)) - col = plex_sync.resync_collection(db, plex, lib, rating_key) - db.commit() - except (PlexError, PlexNotConfigured) as e: - db.rollback() - 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), -) -> dict: - col = _editable_collection_or_403(db, rating_key) - title = body.title.strip() - if not title: - raise HTTPException(status_code=400, detail="title is required") - lib = db.get(PlexLibrary, col.library_id) - try: - with PlexClient(db) as plex: - plex.rename_collection(lib.plex_key, rating_key, title) - col = plex_sync.resync_collection(db, plex, lib, rating_key) - db.commit() - except (PlexError, PlexNotConfigured) as e: - db.rollback() - 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) -) -> dict: - col = _editable_collection_or_403(db, rating_key) - lib = db.get(PlexLibrary, col.library_id) - try: - with PlexClient(db) as plex: - plex.delete_collection(rating_key) - plex_sync.delete_collection_local(db, lib, rating_key) - db.commit() - except (PlexError, PlexNotConfigured) as e: - db.rollback() - 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), -) -> 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.""" - col = by_rating_key(db, PlexCollection, rating_key) - if col is None: - 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", - ) - 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, - "title": pl.title, - "item_count": count, - "thumb": f"/api/plex/image/{thumb_rk}" if thumb_rk else None, - } - - -def _playlist_item_query(pid: int): - return ( - select(PlexPlaylistItem, PlexItem) - .join(PlexItem, PlexItem.id == PlexPlaylistItem.item_id) - .where(PlexPlaylistItem.playlist_id == pid) - .order_by(PlexPlaylistItem.position, PlexPlaylistItem.id) - ) - - -@router.get("/playlists") -def playlists( - contains: str | None = None, - contains_group: str | None = None, - user: User = Depends(current_user), - db: Session = Depends(get_db), -) -> dict: - """The current user's playlists (newest-touched first), each with an item count + a cover thumb. - `contains`=an item rating_key adds `has_item` per playlist (for the single 'add to playlist' dialog). - `contains_group`=a comma-separated list of rating_keys adds `group_in` per playlist (how many of the - group it already holds) + a top-level `group_size`, for the 'add a whole season/show' dialog.""" - contains_id = None - if contains: - contains_id = db.scalar( - select(PlexItem.id).where(PlexItem.rating_key == str(contains)) - ) - group_ids: set[int] | None = None - if contains_group is not None: - rks = [s for s in (contains_group or "").split(",") if s] - group_ids = set( - db.execute( - select(PlexItem.id).where(PlexItem.rating_key.in_(rks or [""])) - ).scalars() - ) - out = [] - for pl in db.execute( - select(PlexPlaylist) - .where(PlexPlaylist.user_id == user.id) - .order_by(PlexPlaylist.updated_at.desc()) - ).scalars(): - count = ( - db.scalar( - select(func.count(PlexPlaylistItem.id)).where( - PlexPlaylistItem.playlist_id == pl.id - ) - ) - or 0 - ) - first = db.execute(_playlist_item_query(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.execute( - select(PlexPlaylistItem.id).where( - PlexPlaylistItem.playlist_id == pl.id, - PlexPlaylistItem.item_id == contains_id, - ) - ).first() - is not None - ) - if group_ids is not None: - card["group_in"] = ( - db.scalar( - select(func.count(PlexPlaylistItem.id)).where( - PlexPlaylistItem.playlist_id == pl.id, - PlexPlaylistItem.item_id.in_(group_ids or [0]), - ) - ) - or 0 - ) - out.append(card) - resp: dict = {"playlists": out} - if group_ids is not None: - 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), -) -> dict: - title = body.title.strip() - if not title: - raise HTTPException(status_code=400, detail="title is required") - pl = PlexPlaylist(user_id=user.id, title=title) - db.add(pl) - db.flush() - seed = body.item_rating_key - count = 0 - thumb_rk = None - if seed: - it = by_rating_key(db, PlexItem, seed) - if it is not None: - db.add(PlexPlaylistItem(playlist_id=pl.id, item_id=it.id, position=0)) - count, thumb_rk = 1, it.rating_key - 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: - pl = _own_playlist_or_404(db, pid, user) - rows = db.execute(_playlist_item_query(pid)).all() - items = [it for (_pi, it) in rows] - states = { - s.item_id: s - for s in db.execute( - select(PlexState).where( - PlexState.user_id == user.id, - PlexState.item_id.in_([it.id for it in items] or [0]), - ) - ).scalars() - } - shows = { - sh.id: sh - for sh in db.execute( - select(PlexShow).where( - PlexShow.id.in_([it.show_id for it in items if it.show_id] or [0]) - ) - ).scalars() - } - cards = [ - leaf_card( - it, - states.get(it.id), - shows[it.show_id].title if it.show_id in shows else None, - shows[it.show_id].rating_key if it.show_id in shows else None, - ) - for it in items - ] - 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), -) -> dict: - pl = _own_playlist_or_404(db, pid, user) - title = body.title.strip() - if not title: - raise HTTPException(status_code=400, detail="title is required") - pl.title = title - db.commit() - count = ( - db.scalar( - select(func.count(PlexPlaylistItem.id)).where( - PlexPlaylistItem.playlist_id == pl.id - ) - ) - or 0 - ) - first = db.execute(_playlist_item_query(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: - 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), -) -> dict: - pl = _own_playlist_or_404(db, pid, user) - it = by_rating_key(db, PlexItem, body.item_rating_key) - if it is None: - raise HTTPException(status_code=404, detail="Unknown item") - exists = db.execute( - select(PlexPlaylistItem).where( - PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id == it.id - ) - ).scalars().first() - if exists is None: - maxpos = db.scalar( - select(func.coalesce(func.max(PlexPlaylistItem.position), -1)).where( - PlexPlaylistItem.playlist_id == pid - ) - ) - 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: - pl = _own_playlist_or_404(db, pid, user) - it = by_rating_key(db, PlexItem, item_rating_key) - if it is not None: - db.execute( - delete(PlexPlaylistItem).where( - PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id == it.id - ) - ) - pl.updated_at = datetime.now(timezone.utc) - 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.execute( - select(PlexItem).where(PlexItem.rating_key.in_(order)) - ).scalars() - } - seen: set[str] = set() - out: list[PlexItem] = [] - for rk in order: - it = by_rk.get(rk) - if it is not None and rk not in seen: - seen.add(rk) - 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), -) -> 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 = set( - db.execute( - select(PlexPlaylistItem.item_id).where(PlexPlaylistItem.playlist_id == pid) - ).scalars() - ) - pos = int( - db.scalar( - select(func.coalesce(func.max(PlexPlaylistItem.position), -1)).where( - PlexPlaylistItem.playlist_id == pid - ) - ) - ) - added = 0 - for it in items: - if it.id in existing: - continue - pos += 1 - db.add(PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=pos)) - existing.add(it.id) - added += 1 - if added: - pl.updated_at = datetime.now(timezone.utc) - db.commit() - total = ( - db.scalar( - select(func.count(PlexPlaylistItem.id)).where( - PlexPlaylistItem.playlist_id == pid - ) - ) - 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), -) -> dict: - """Remove many items at once (a whole season/show, or a multi-select). Returns how many were - removed + the new total.""" - pl = _own_playlist_or_404(db, pid, user) - ids = [it.id for it in _items_by_rks(db, body.item_rating_keys)] - removed = 0 - if ids: - removed = db.execute( - delete(PlexPlaylistItem).where( - PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id.in_(ids) - ), - execution_options={"synchronize_session": False}, - ).rowcount - if removed: - pl.updated_at = datetime.now(timezone.utc) - db.commit() - total = ( - db.scalar( - select(func.count(PlexPlaylistItem.id)).where( - PlexPlaylistItem.playlist_id == pid - ) - ) - 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), -) -> 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.execute( - select(PlexItem).where(PlexItem.rating_key.in_(order or [""])) - ).scalars() - } - for pos, rk in enumerate(order): - iid = id_by_rk.get(rk) - if iid is not None: - db.execute( - update(PlexPlaylistItem) - .where( - PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id == iid - ) - .values(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 - ({}, []) when Plex is unavailable/unconfigured (not cached, so it retries on the next open).""" - now = time.monotonic() - with _show_enrich_lock: - hit = _show_enrich_cache.get(rating_key) - if hit is not None and hit[0] > now: - return hit[1], hit[2] - try: - 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 → [] - meta = meta_f.result() or {} - related_raw = related_f.result() - except (PlexError, PlexNotConfigured): - return {}, [] - # Don't cache an EMPTY related list: plex.related() swallows a transient failure as [] (same as a - # genuinely no-related show), so caching it would serve an empty "related" section for the whole TTL - # even after Plex recovers. Skip the cache in that case (retry next open, as before); shows that DO - # 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, - ) - return meta, related_raw - - -@router.get("/show/{rating_key}") -def show_detail( - rating_key: str, - lang: str = "en", - user: User = Depends(current_user), - db: Session = Depends(get_db), -) -> dict: - """A show's seasons + episodes (the drill-down view) with per-user watch state.""" - hu = _norm_lang(lang) == "hu" - sh = by_rating_key(db, PlexShow, rating_key) - if sh is None: - raise HTTPException(status_code=404, detail="Unknown Plex show") - seasons = db.execute( - select(PlexSeason) - .where(PlexSeason.show_id == sh.id) - .order_by(PlexSeason.season_number.asc().nullsfirst()) - ).scalars().all() - eps = db.execute( - select(PlexItem) - .where(PlexItem.show_id == sh.id, PlexItem.kind == "episode") - .order_by( - PlexItem.season_number.asc().nullsfirst(), - PlexItem.episode_number.asc().nullsfirst(), - ) - ).scalars().all() - states = { - s.item_id: s - for s in db.execute( - select(PlexState).where( - PlexState.user_id == user.id, - PlexState.item_id.in_([e.id for e in eps] or [0]), - ) - ).scalars() - } - by_season: dict[int | None, list] = {} - 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) - all_cards.append(card) - show_roll = rollup.rollup(all_cards) - - # Cast + IMDb + tagline are served from the DB row (persisted by the cast backfill, E5 S5b). The one - # remaining live call is `related` — a Plex recommendation hub we don't mirror — which also carries - # the metadata used to lazily fill a not-yet-enriched show. Any live failure just drops `related` and - # the lazy fill; the row-served detail below still renders (outage-resilient). - related: list[dict] = [] - try: - meta, related_raw = _show_enrichment(db, sh.rating_key) - related = _relatedshow_cards(db, user.id, related_raw, exclude=sh.id) - if sh.people_enriched_at is None and meta: - plex_people.enrich_row_people(db, sh, plex_people.cast_from_meta(meta)) - plex_detail.apply_rich_detail(sh, meta) # imdb/tagline/full_cast for future views - sh.people_enriched_at = datetime.now(timezone.utc) - try: - db.commit() - except Exception: - db.rollback() - except Exception: - # Best-effort only — the local episode list is already assembled, so any live-Plex failure - # (not configured, HTTP/JSON shape) must degrade, not 500. - pass - - imdb_id = sh.imdb_id - return { - "show": { - "id": sh.rating_key, - "title": (sh.title_hu or sh.title) if hu else sh.title, - "summary": (sh.summary_hu or sh.summary) if hu else sh.summary, - "year": sh.year, - "thumb": f"/api/plex/image/{sh.rating_key}", - "art": f"/api/plex/image/{sh.rating_key}?variant=art", - "library": _lib_key(db, sh.library_id), - "content_rating": sh.content_rating, - "rating": sh.rating, - "genres": (sh.genres_hu or sh.genres if hu else sh.genres) or [], - "studio": sh.studio, - "season_count": sh.child_count, - "imdb_rating": sh.imdb_rating, - "imdb_id": imdb_id, - "imdb_url": f"https://www.imdb.com/title/{imdb_id}/" if imdb_id else None, - "cast": sh.full_cast or [], - # Show-level rollup for the hero action buttons. - "status": show_roll["status"], - "resume": show_roll["resume"], - "first": show_roll["first"], - "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], - "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).""" - roll = rollup.rollup(cards) - 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"), - "thumb": f"/api/plex/image/{se.rating_key}", - "episode_count": roll["episode_count"], - "status": roll["status"], - "resume": roll["resume"], - "first": roll["first"], - "episodes": cards, - } - - -def _relatedshow_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")] - if not rks: - return [] - shows = { - s.rating_key: s - for s in db.execute( - select(PlexShow).where( - PlexShow.rating_key.in_(rks[:60]), PlexShow.id != exclude - ) - ).scalars() - } - statuses = rollup.state_map(db, user_id, [s.id for s in shows.values()]) - out: list[dict] = [] - seen: set[str] = set() - for rk in rks: - sh = shows.get(rk) - if sh is None or rk in seen: - continue - seen.add(rk) - out.append(show_card(sh, statuses.get(sh.id, "new"))) - if len(out) >= 20: - break - return out - - -def _content_libs(db: Session) -> tuple[list[int], list[int]]: - """The BROWSABLE content libraries, partitioned into (movie_lib_ids, show_lib_ids) — the single - source of the browse scope. Excludes HU overlay libraries (`overlay_of` set): those are never - browsed on their own, they only lend their Hungarian metadata to the EN twin's rows (E5 S5).""" - libs = db.execute( - select(PlexLibrary).where( - PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None) - ) - ).scalars().all() - movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"] - show_lib_ids = [lb.id for lb in libs if lb.kind == "show"] - return movie_lib_ids, show_lib_ids - - -@router.get("/people/cards") -def people_cards( - names: str, - scope: str = "both", - user: User = Depends(current_user), - db: Session = Depends(get_db), -) -> dict: - """Cards for the actor filter-chips currently applied: each name + its cached headshot + how many - movies/shows (in the given scope) feature them. Photos come from the plex_people cache (populated - as items are rich-fetched — info-page view + the rich re-sync); counts are live against cast_names. - A name with no cached photo yet still returns a card (the UI shows a placeholder).""" - wanted = split_csv(names)[:16] - if not wanted: - return {"people": []} - movie_lib_ids, show_lib_ids = _content_libs(db) - 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.execute( - select(PlexPerson).where(PlexPerson.name.in_(wanted)) - ).scalars() - } - out = [] - for nm in wanted: - count = 0 - if want_movies: - count += ( - db.scalar( - select(func.count(PlexItem.id)).where( - PlexItem.kind == "movie", - PlexItem.library_id.in_(movie_lib_ids), - PlexItem.cast_names.contains([nm]), - ) - ) - or 0 - ) - if want_shows: - count += ( - db.scalar( - select(func.count(PlexShow.id)).where( - PlexShow.library_id.in_(show_lib_ids), - PlexShow.cast_names.contains([nm]), - ) - ) - or 0 - ) - out.append({"name": nm, "photo": photos.get(nm), "count": count}) - return {"people": out} - - -# Cap each filmography section — a prolific person shouldn't return an unbounded list the client then -# mounts a card per row for (the grid paginates at PAGE; this page renders all at once). Ample for a -# personal library; the most-recent titles win via the year-desc order. -_PERSON_SECTION_MAX = 200 - - -@router.get("/person/{name}") -def person_detail( - name: str, - user: User = Depends(current_user), - db: Session = Depends(get_db), -) -> dict: - """One cast/crew member's filmography: their cached headshot + the movies and shows (in the enabled - libraries) whose cast OR directors include this name — the inverse of the cast-name → library-filter - link. The name string is the identity key (Plex cast carries no stable per-person id), so distinct - people who share a name share a page. Each section is capped at _PERSON_SECTION_MAX. An unknown name - simply returns empty sections.""" - photo = db.scalar(select(PlexPerson.photo_thumb).where(PlexPerson.name == name)) - movie_lib_ids, show_lib_ids = _content_libs(db) - - movies: list[dict] = [] - if movie_lib_ids: - items = db.execute( - select(PlexItem) - .where( - PlexItem.kind == "movie", - PlexItem.library_id.in_(movie_lib_ids), - or_( - PlexItem.cast_names.contains([name]), - PlexItem.directors.contains([name]), - ), - ) - .order_by(PlexItem.year.desc().nullslast(), func.lower(PlexItem.title)) - .limit(_PERSON_SECTION_MAX) - ).scalars().all() - item_ids = [it.id for it in items] - states = { - s.item_id: s - for s in db.execute( - select(PlexState).where( - PlexState.user_id == user.id, - PlexState.item_id.in_(item_ids or [0]), - ) - ).scalars() - } - movies = [movie_card(it, states.get(it.id)) for it in items] - - shows: list[dict] = [] - if show_lib_ids: - show_rows = db.execute( - select(PlexShow) - .where( - PlexShow.library_id.in_(show_lib_ids), - or_( - PlexShow.cast_names.contains([name]), - PlexShow.directors.contains([name]), - ), - ) - .order_by(PlexShow.year.desc().nullslast(), func.lower(PlexShow.title)) - .limit(_PERSON_SECTION_MAX) - ).scalars().all() - statuses = rollup.state_map(db, user.id, [s.id for s in show_rows]) - shows = [show_card(sh, statuses.get(sh.id, "new")) for sh in show_rows] - - return {"name": name, "photo": photo, "movies": movies, "shows": shows} - - -_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: - return first - if col.smart: - 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.execute( - select(PlexCollection).where(PlexCollection.rating_key.in_(keys)) - ).scalars() - } - # 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) - ) - out = [] - for col in ordered: - rk = col.rating_key - members = db.execute( - select(PlexItem) - .where( - PlexItem.library_id == it.library_id, - PlexItem.kind == "movie", - PlexItem.collection_keys.contains([rk]), - ) - .order_by(PlexItem.year.asc().nullslast(), func.lower(PlexItem.title)) - .limit(60) - ).scalars().all() - ids = [m.id for m in members] - states = { - s.item_id: s - for s in db.execute( - select(PlexState).where( - PlexState.user_id == user_id, PlexState.item_id.in_(ids or [0]) - ) - ).scalars() - } - out.append( - { - "id": rk, - "title": col.title, - "source": _collection_source(col), - "items": [movie_card(m, states.get(m.id)) for m in members], - } - ) - 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 - keys = list( - db.execute( - select(PlexItem.rating_key) - .where(PlexItem.show_id == it.show_id, PlexItem.kind == "episode") - .order_by( - PlexItem.season_number.asc().nullsfirst(), - PlexItem.episode_number.asc().nullsfirst(), - ) - ).scalars() - ) - 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 - ) - - -@router.get("/item/{rating_key}") -def item_detail( - rating_key: str, - lang: str = "en", - user: User = Depends(current_user), - db: Session = Depends(get_db), -) -> dict: - """Everything the player needs: metadata, cast, intro/credit markers (from Plex), episode - prev/next, and the per-user resume position + watch status.""" - lang = _norm_lang(lang) - it = item_or_404(db, rating_key) - st = db.execute( - select(PlexState).where( - PlexState.user_id == user.id, PlexState.item_id == it.id - ) - ).scalars().first() - - # Markers + audio/subtitle streams are file-specific, so they stay LIVE; the rich detail (cast/IMDb/ - # tagline/genres) is served from the DB row for movies (persisted below / by the backfill) and only - # parsed from this live meta for episodes (never enriched — not in the grid, no HU overlay). - meta: dict = {} - markers: list[dict] = [] - audio_streams: list[dict] = [] - subtitle_streams: list[dict] = [] - try: - with PlexClient(db) as plex: - meta = plex.metadata(it.rating_key, markers=True) or {} - for m in meta.get("Marker") or []: - if m.get("type") in ("intro", "credits"): - markers.append( - { - "type": m.get("type"), - "start_s": round((m.get("startTimeOffset") or 0) / 1000, 1), - "end_s": round((m.get("endTimeOffset") or 0) / 1000, 1), - } - ) - # Audio + subtitle tracks (ordinals among their stream type — used to -map the selection). - part = ((meta.get("Media") or [{}])[0].get("Part") or [{}])[0] - for s in part.get("Stream") or []: - if s.get("streamType") == 2: - audio_streams.append( - { - "ord": len(audio_streams), - "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")), - } - ) - elif s.get("streamType") == 3: - codec = (s.get("codec") or s.get("format") or "").lower() - subtitle_streams.append( - { - "ord": len(subtitle_streams), - "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) - # can't be turned into text, so the player hides them (burn-in is a later phase). - "text": codec not in subtitles.BITMAP_SUBS, - } - ) - except Exception: - pass # live extras (markers/streams) are best-effort; any live-Plex failure must degrade, not 500 — playback works without them - - # Lazy enrichment (movies only): the live fetch above carries the FULL cast + rich detail (the sync - # only stored the listing's top ~3), so fill a not-yet-enriched movie — its cast becomes filterable - # and future views serve from the row. Episodes aren't enriched; a show's cast is filled from /show. - if it.kind == "movie" and it.people_enriched_at is None and meta: - cast = plex_people.cast_from_meta(meta) - if cast: - plex_people.enrich_row_people(db, it, cast) - plex_detail.apply_rich_detail(it, meta) - it.people_enriched_at = datetime.now(timezone.utc) - try: - db.commit() - except Exception: - db.rollback() - - hu = lang == "hu" - # Movies serve rich detail from the DB row; episodes from the live meta just fetched. - if it.kind == "movie": - _imdb_id = it.imdb_id - rich = { - "cast": it.full_cast or [], - "imdb_rating": it.imdb_rating, - "imdb_id": _imdb_id, - "imdb_url": f"https://www.imdb.com/title/{_imdb_id}/" if _imdb_id else None, - "content_rating": it.content_rating, - "genres": (it.genres_hu or it.genres if hu else it.genres) or [], - "directors": it.directors or [], - "studio": it.studio, - "tagline": it.tagline, - } - else: - rich = plex_detail.rich_detail(meta) - - prev_id, next_id = _episode_nav(db, it) - show = db.get(PlexShow, it.show_id) if it.kind == "episode" and it.show_id else None - return { - "id": it.rating_key, - "kind": it.kind, - "title": (it.title_hu or it.title) if hu else it.title, - "summary": (it.summary_hu or it.summary) if hu else it.summary, - "year": it.year, - "duration_seconds": it.duration_s, - "playable": it.playable, - "thumb": f"/api/plex/image/{it.rating_key}", - "art": f"/api/plex/image/{it.rating_key}?variant=art", - "library": _lib_key(db, it.library_id), - "cast": rich.get("cast", []), - "imdb_rating": rich.get("imdb_rating"), - "imdb_id": rich.get("imdb_id"), - "imdb_url": rich.get("imdb_url"), - # Audience score (Plex audienceRating) served alongside the IMDb score — the UI shows the - # IMDb badge when imdb_rating is present, else this. Episodes carry it on the row too. - "rating": it.rating, - "content_rating": rich.get("content_rating"), - "genres": rich.get("genres", []), - "directors": rich.get("directors", []), - "studio": rich.get("studio"), - "tagline": rich.get("tagline"), - "markers": markers, - "audio_streams": audio_streams, - "subtitle_streams": subtitle_streams, - "status": st.status if st else "new", - "position_seconds": st.position_seconds if st else 0, - "show_title": ( - (show.title_hu or show.title if hu else show.title) if show else None - ), - "season_number": it.season_number, - "episode_number": it.episode_number, - "prev_id": prev_id, - "next_id": next_id, - "collections": _collection_strips(db, it, user.id), - } diff --git a/backend/app/routes/plex/collections.py b/backend/app/routes/plex/collections.py new file mode 100644 index 00000000..36540b94 --- /dev/null +++ b/backend/app/routes/plex/collections.py @@ -0,0 +1,253 @@ +"""Plex collections: browse mirrored collections + the admin manual-collection editor.""" + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.auth import admin_user, current_user +from app.db import get_db +from app.models import ( + PlexCollection, + PlexLibrary, + User, +) +from app.plex import sync as plex_sync +from app.plex.client import PlexClient, PlexError, PlexNotConfigured +from app.plex.lookups import by_rating_key +from app.plex.query import like_escape + +router = APIRouter(prefix="/api/plex", tags=["plex"]) + +_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: + return first + if col.smart: + return "smart" + return "collection" + + +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, + "title": c.title, + "summary": c.summary, + "thumb": f"/api/plex/image/{c.rating_key}" if c.thumb_key else None, + "child_count": c.child_count, + "smart": c.smart, + "source": _collection_source(c), + "editable": c.editable, + "can_edit": _collection_editable( + c + ), # whether the edit controls should be offered (admin UI) + } + + +@router.get("/collections") +def collections( + library: str | None = None, + q: str | None = None, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Mirrored collections as cards (most-populated first), optionally name-filtered by `q`. With a + `library` plex_key → that one library's collections (used by the collection editor). WITHOUT one → + the UNION across all enabled libraries (the unified-scope sidebar picker, which has no single + library selected).""" + if library is not None: + lib = db.execute( + select(PlexLibrary).where(PlexLibrary.plex_key == str(library)) + ).scalars().first() + if lib is None: + return {"collections": []} + query = select(PlexCollection).where(PlexCollection.library_id == lib.id) + else: + enabled_ids = list( + db.execute( + select(PlexLibrary.id).where( + PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None) + ) + ).scalars() + ) + if not enabled_ids: + return {"collections": []} + query = select(PlexCollection).where( + PlexCollection.library_id.in_(enabled_ids) + ) + term = (q or "").strip() + if term: + query = query.where(PlexCollection.title.ilike(f"%{like_escape(term)}%")) + rows = db.execute( + query.order_by( + PlexCollection.child_count.desc().nullslast(), + func.lower(PlexCollection.title), + ) + ).scalars().all() + return {"collections": [_collection_card(c) for c in rows]} + + +def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection: + col = by_rating_key(db, PlexCollection, rating_key) + if col is None: + raise HTTPException(status_code=404, detail="Unknown collection") + if not _collection_editable(col): + 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), +) -> 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" + ) + lib = db.execute( + select(PlexLibrary).where(PlexLibrary.plex_key == library) + ).scalars().first() + if lib is None: + raise HTTPException(status_code=404, detail="Unknown library") + try: + with PlexClient(db) as plex: + new = plex.create_collection(lib.plex_key, title, seed) + col = plex_sync.resync_collection(db, plex, lib, str(new.get("ratingKey"))) + col.editable = True + db.commit() + except (PlexError, PlexNotConfigured) as e: + db.rollback() + 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), +) -> dict: + col = _editable_collection_or_403(db, rating_key) + lib = db.get(PlexLibrary, col.library_id) + try: + with PlexClient(db) as plex: + plex.add_collection_item(rating_key, str(item_rating_key)) + col = plex_sync.resync_collection(db, plex, lib, rating_key) + db.commit() + except (PlexError, PlexNotConfigured) as e: + db.rollback() + 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), +) -> dict: + col = _editable_collection_or_403(db, rating_key) + lib = db.get(PlexLibrary, col.library_id) + try: + with PlexClient(db) as plex: + plex.remove_collection_item(rating_key, str(item_rating_key)) + col = plex_sync.resync_collection(db, plex, lib, rating_key) + db.commit() + except (PlexError, PlexNotConfigured) as e: + db.rollback() + 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), +) -> dict: + col = _editable_collection_or_403(db, rating_key) + title = body.title.strip() + if not title: + raise HTTPException(status_code=400, detail="title is required") + lib = db.get(PlexLibrary, col.library_id) + try: + with PlexClient(db) as plex: + plex.rename_collection(lib.plex_key, rating_key, title) + col = plex_sync.resync_collection(db, plex, lib, rating_key) + db.commit() + except (PlexError, PlexNotConfigured) as e: + db.rollback() + 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) +) -> dict: + col = _editable_collection_or_403(db, rating_key) + lib = db.get(PlexLibrary, col.library_id) + try: + with PlexClient(db) as plex: + plex.delete_collection(rating_key) + plex_sync.delete_collection_local(db, lib, rating_key) + db.commit() + except (PlexError, PlexNotConfigured) as e: + db.rollback() + 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), +) -> 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.""" + col = by_rating_key(db, PlexCollection, rating_key) + if col is None: + 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", + ) + col.editable = want + db.commit() + return _collection_card(col) diff --git a/backend/app/routes/plex/detail.py b/backend/app/routes/plex/detail.py new file mode 100644 index 00000000..a65ce916 --- /dev/null +++ b/backend/app/routes/plex/detail.py @@ -0,0 +1,438 @@ +"""Plex show/item detail: the show drill-down + the per-item player payload (markers, cast, nav).""" + +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from app.auth import current_user +from app.db import get_db +from app.models import ( + PlexCollection, + PlexItem, + PlexLibrary, + PlexSeason, + PlexShow, + PlexState, + User, +) +from app.plex import detail as plex_detail +from app.plex import people as plex_people +from app.plex import rollup, subtitles +from app.plex.cards import episode_card, movie_card, show_card +from app.plex.client import PlexClient, PlexError, PlexNotConfigured +from app.plex.lookups import by_rating_key +from app.routes.plex._shared import _norm_lang, item_or_404 +from app.routes.plex.collections import _collection_source + +router = APIRouter(prefix="/api/plex", tags=["plex"]) + + +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).""" + if library_id is None: + return None + lib = db.get(PlexLibrary, library_id) + return lib.plex_key if lib else None + + +_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 + ({}, []) when Plex is unavailable/unconfigured (not cached, so it retries on the next open).""" + now = time.monotonic() + with _show_enrich_lock: + hit = _show_enrich_cache.get(rating_key) + if hit is not None and hit[0] > now: + return hit[1], hit[2] + try: + 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 → [] + meta = meta_f.result() or {} + related_raw = related_f.result() + except (PlexError, PlexNotConfigured): + return {}, [] + # Don't cache an EMPTY related list: plex.related() swallows a transient failure as [] (same as a + # genuinely no-related show), so caching it would serve an empty "related" section for the whole TTL + # even after Plex recovers. Skip the cache in that case (retry next open, as before); shows that DO + # 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, + ) + return meta, related_raw + + +@router.get("/show/{rating_key}") +def show_detail( + rating_key: str, + lang: str = "en", + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """A show's seasons + episodes (the drill-down view) with per-user watch state.""" + hu = _norm_lang(lang) == "hu" + sh = by_rating_key(db, PlexShow, rating_key) + if sh is None: + raise HTTPException(status_code=404, detail="Unknown Plex show") + seasons = db.execute( + select(PlexSeason) + .where(PlexSeason.show_id == sh.id) + .order_by(PlexSeason.season_number.asc().nullsfirst()) + ).scalars().all() + eps = db.execute( + select(PlexItem) + .where(PlexItem.show_id == sh.id, PlexItem.kind == "episode") + .order_by( + PlexItem.season_number.asc().nullsfirst(), + PlexItem.episode_number.asc().nullsfirst(), + ) + ).scalars().all() + states = { + s.item_id: s + for s in db.execute( + select(PlexState).where( + PlexState.user_id == user.id, + PlexState.item_id.in_([e.id for e in eps] or [0]), + ) + ).scalars() + } + by_season: dict[int | None, list] = {} + 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) + all_cards.append(card) + show_roll = rollup.rollup(all_cards) + + # Cast + IMDb + tagline are served from the DB row (persisted by the cast backfill, E5 S5b). The one + # remaining live call is `related` — a Plex recommendation hub we don't mirror — which also carries + # the metadata used to lazily fill a not-yet-enriched show. Any live failure just drops `related` and + # the lazy fill; the row-served detail below still renders (outage-resilient). + related: list[dict] = [] + try: + meta, related_raw = _show_enrichment(db, sh.rating_key) + related = _relatedshow_cards(db, user.id, related_raw, exclude=sh.id) + if sh.people_enriched_at is None and meta: + plex_people.enrich_row_people(db, sh, plex_people.cast_from_meta(meta)) + plex_detail.apply_rich_detail(sh, meta) # imdb/tagline/full_cast for future views + sh.people_enriched_at = datetime.now(timezone.utc) + try: + db.commit() + except Exception: + db.rollback() + except Exception: + # Best-effort only — the local episode list is already assembled, so any live-Plex failure + # (not configured, HTTP/JSON shape) must degrade, not 500. + pass + + imdb_id = sh.imdb_id + return { + "show": { + "id": sh.rating_key, + "title": (sh.title_hu or sh.title) if hu else sh.title, + "summary": (sh.summary_hu or sh.summary) if hu else sh.summary, + "year": sh.year, + "thumb": f"/api/plex/image/{sh.rating_key}", + "art": f"/api/plex/image/{sh.rating_key}?variant=art", + "library": _lib_key(db, sh.library_id), + "content_rating": sh.content_rating, + "rating": sh.rating, + "genres": (sh.genres_hu or sh.genres if hu else sh.genres) or [], + "studio": sh.studio, + "season_count": sh.child_count, + "imdb_rating": sh.imdb_rating, + "imdb_id": imdb_id, + "imdb_url": f"https://www.imdb.com/title/{imdb_id}/" if imdb_id else None, + "cast": sh.full_cast or [], + # Show-level rollup for the hero action buttons. + "status": show_roll["status"], + "resume": show_roll["resume"], + "first": show_roll["first"], + "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], + "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).""" + roll = rollup.rollup(cards) + 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"), + "thumb": f"/api/plex/image/{se.rating_key}", + "episode_count": roll["episode_count"], + "status": roll["status"], + "resume": roll["resume"], + "first": roll["first"], + "episodes": cards, + } + + +def _relatedshow_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")] + if not rks: + return [] + shows = { + s.rating_key: s + for s in db.execute( + select(PlexShow).where( + PlexShow.rating_key.in_(rks[:60]), PlexShow.id != exclude + ) + ).scalars() + } + statuses = rollup.state_map(db, user_id, [s.id for s in shows.values()]) + out: list[dict] = [] + seen: set[str] = set() + for rk in rks: + sh = shows.get(rk) + if sh is None or rk in seen: + continue + seen.add(rk) + out.append(show_card(sh, statuses.get(sh.id, "new"))) + if len(out) >= 20: + break + return out + + +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.execute( + select(PlexCollection).where(PlexCollection.rating_key.in_(keys)) + ).scalars() + } + # 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) + ) + out = [] + for col in ordered: + rk = col.rating_key + members = db.execute( + select(PlexItem) + .where( + PlexItem.library_id == it.library_id, + PlexItem.kind == "movie", + PlexItem.collection_keys.contains([rk]), + ) + .order_by(PlexItem.year.asc().nullslast(), func.lower(PlexItem.title)) + .limit(60) + ).scalars().all() + ids = [m.id for m in members] + states = { + s.item_id: s + for s in db.execute( + select(PlexState).where( + PlexState.user_id == user_id, PlexState.item_id.in_(ids or [0]) + ) + ).scalars() + } + out.append( + { + "id": rk, + "title": col.title, + "source": _collection_source(col), + "items": [movie_card(m, states.get(m.id)) for m in members], + } + ) + 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 + keys = list( + db.execute( + select(PlexItem.rating_key) + .where(PlexItem.show_id == it.show_id, PlexItem.kind == "episode") + .order_by( + PlexItem.season_number.asc().nullsfirst(), + PlexItem.episode_number.asc().nullsfirst(), + ) + ).scalars() + ) + 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 + ) + + +@router.get("/item/{rating_key}") +def item_detail( + rating_key: str, + lang: str = "en", + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Everything the player needs: metadata, cast, intro/credit markers (from Plex), episode + prev/next, and the per-user resume position + watch status.""" + lang = _norm_lang(lang) + it = item_or_404(db, rating_key) + st = db.execute( + select(PlexState).where( + PlexState.user_id == user.id, PlexState.item_id == it.id + ) + ).scalars().first() + + # Markers + audio/subtitle streams are file-specific, so they stay LIVE; the rich detail (cast/IMDb/ + # tagline/genres) is served from the DB row for movies (persisted below / by the backfill) and only + # parsed from this live meta for episodes (never enriched — not in the grid, no HU overlay). + meta: dict = {} + markers: list[dict] = [] + audio_streams: list[dict] = [] + subtitle_streams: list[dict] = [] + try: + with PlexClient(db) as plex: + meta = plex.metadata(it.rating_key, markers=True) or {} + for m in meta.get("Marker") or []: + if m.get("type") in ("intro", "credits"): + markers.append( + { + "type": m.get("type"), + "start_s": round((m.get("startTimeOffset") or 0) / 1000, 1), + "end_s": round((m.get("endTimeOffset") or 0) / 1000, 1), + } + ) + # Audio + subtitle tracks (ordinals among their stream type — used to -map the selection). + part = ((meta.get("Media") or [{}])[0].get("Part") or [{}])[0] + for s in part.get("Stream") or []: + if s.get("streamType") == 2: + audio_streams.append( + { + "ord": len(audio_streams), + "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")), + } + ) + elif s.get("streamType") == 3: + codec = (s.get("codec") or s.get("format") or "").lower() + subtitle_streams.append( + { + "ord": len(subtitle_streams), + "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) + # can't be turned into text, so the player hides them (burn-in is a later phase). + "text": codec not in subtitles.BITMAP_SUBS, + } + ) + except Exception: + pass # live extras (markers/streams) are best-effort; any live-Plex failure must degrade, not 500 — playback works without them + + # Lazy enrichment (movies only): the live fetch above carries the FULL cast + rich detail (the sync + # only stored the listing's top ~3), so fill a not-yet-enriched movie — its cast becomes filterable + # and future views serve from the row. Episodes aren't enriched; a show's cast is filled from /show. + if it.kind == "movie" and it.people_enriched_at is None and meta: + cast = plex_people.cast_from_meta(meta) + if cast: + plex_people.enrich_row_people(db, it, cast) + plex_detail.apply_rich_detail(it, meta) + it.people_enriched_at = datetime.now(timezone.utc) + try: + db.commit() + except Exception: + db.rollback() + + hu = lang == "hu" + # Movies serve rich detail from the DB row; episodes from the live meta just fetched. + if it.kind == "movie": + _imdb_id = it.imdb_id + rich = { + "cast": it.full_cast or [], + "imdb_rating": it.imdb_rating, + "imdb_id": _imdb_id, + "imdb_url": f"https://www.imdb.com/title/{_imdb_id}/" if _imdb_id else None, + "content_rating": it.content_rating, + "genres": (it.genres_hu or it.genres if hu else it.genres) or [], + "directors": it.directors or [], + "studio": it.studio, + "tagline": it.tagline, + } + else: + rich = plex_detail.rich_detail(meta) + + prev_id, next_id = _episode_nav(db, it) + show = db.get(PlexShow, it.show_id) if it.kind == "episode" and it.show_id else None + return { + "id": it.rating_key, + "kind": it.kind, + "title": (it.title_hu or it.title) if hu else it.title, + "summary": (it.summary_hu or it.summary) if hu else it.summary, + "year": it.year, + "duration_seconds": it.duration_s, + "playable": it.playable, + "thumb": f"/api/plex/image/{it.rating_key}", + "art": f"/api/plex/image/{it.rating_key}?variant=art", + "library": _lib_key(db, it.library_id), + "cast": rich.get("cast", []), + "imdb_rating": rich.get("imdb_rating"), + "imdb_id": rich.get("imdb_id"), + "imdb_url": rich.get("imdb_url"), + # Audience score (Plex audienceRating) served alongside the IMDb score — the UI shows the + # IMDb badge when imdb_rating is present, else this. Episodes carry it on the row too. + "rating": it.rating, + "content_rating": rich.get("content_rating"), + "genres": rich.get("genres", []), + "directors": rich.get("directors", []), + "studio": rich.get("studio"), + "tagline": rich.get("tagline"), + "markers": markers, + "audio_streams": audio_streams, + "subtitle_streams": subtitle_streams, + "status": st.status if st else "new", + "position_seconds": st.position_seconds if st else 0, + "show_title": ( + (show.title_hu or show.title if hu else show.title) if show else None + ), + "season_number": it.season_number, + "episode_number": it.episode_number, + "prev_id": prev_id, + "next_id": next_id, + "collections": _collection_strips(db, it, user.id), + } diff --git a/backend/app/routes/plex/people.py b/backend/app/routes/plex/people.py new file mode 100644 index 00000000..d68da63f --- /dev/null +++ b/backend/app/routes/plex/people.py @@ -0,0 +1,139 @@ +"""Plex people: cast/crew filter-chip cards + a single person's filmography page.""" + +from fastapi import APIRouter, Depends +from sqlalchemy import func, or_, select +from sqlalchemy.orm import Session + +from app.auth import current_user +from app.db import get_db +from app.models import ( + PlexItem, + PlexPerson, + PlexShow, + PlexState, + User, +) +from app.plex import rollup +from app.plex.cards import movie_card, show_card +from app.plex.query import split_csv +from app.routes.plex._shared import _content_libs + +router = APIRouter(prefix="/api/plex", tags=["plex"]) + + +@router.get("/people/cards") +def people_cards( + names: str, + scope: str = "both", + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """Cards for the actor filter-chips currently applied: each name + its cached headshot + how many + movies/shows (in the given scope) feature them. Photos come from the plex_people cache (populated + as items are rich-fetched — info-page view + the rich re-sync); counts are live against cast_names. + A name with no cached photo yet still returns a card (the UI shows a placeholder).""" + wanted = split_csv(names)[:16] + if not wanted: + return {"people": []} + movie_lib_ids, show_lib_ids = _content_libs(db) + 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.execute( + select(PlexPerson).where(PlexPerson.name.in_(wanted)) + ).scalars() + } + out = [] + for nm in wanted: + count = 0 + if want_movies: + count += ( + db.scalar( + select(func.count(PlexItem.id)).where( + PlexItem.kind == "movie", + PlexItem.library_id.in_(movie_lib_ids), + PlexItem.cast_names.contains([nm]), + ) + ) + or 0 + ) + if want_shows: + count += ( + db.scalar( + select(func.count(PlexShow.id)).where( + PlexShow.library_id.in_(show_lib_ids), + PlexShow.cast_names.contains([nm]), + ) + ) + or 0 + ) + out.append({"name": nm, "photo": photos.get(nm), "count": count}) + return {"people": out} + + +# Cap each filmography section — a prolific person shouldn't return an unbounded list the client then +# mounts a card per row for (the grid paginates at PAGE; this page renders all at once). Ample for a +# personal library; the most-recent titles win via the year-desc order. +_PERSON_SECTION_MAX = 200 + + +@router.get("/person/{name}") +def person_detail( + name: str, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """One cast/crew member's filmography: their cached headshot + the movies and shows (in the enabled + libraries) whose cast OR directors include this name — the inverse of the cast-name → library-filter + link. The name string is the identity key (Plex cast carries no stable per-person id), so distinct + people who share a name share a page. Each section is capped at _PERSON_SECTION_MAX. An unknown name + simply returns empty sections.""" + photo = db.scalar(select(PlexPerson.photo_thumb).where(PlexPerson.name == name)) + movie_lib_ids, show_lib_ids = _content_libs(db) + + movies: list[dict] = [] + if movie_lib_ids: + items = db.execute( + select(PlexItem) + .where( + PlexItem.kind == "movie", + PlexItem.library_id.in_(movie_lib_ids), + or_( + PlexItem.cast_names.contains([name]), + PlexItem.directors.contains([name]), + ), + ) + .order_by(PlexItem.year.desc().nullslast(), func.lower(PlexItem.title)) + .limit(_PERSON_SECTION_MAX) + ).scalars().all() + item_ids = [it.id for it in items] + states = { + s.item_id: s + for s in db.execute( + select(PlexState).where( + PlexState.user_id == user.id, + PlexState.item_id.in_(item_ids or [0]), + ) + ).scalars() + } + movies = [movie_card(it, states.get(it.id)) for it in items] + + shows: list[dict] = [] + if show_lib_ids: + show_rows = db.execute( + select(PlexShow) + .where( + PlexShow.library_id.in_(show_lib_ids), + or_( + PlexShow.cast_names.contains([name]), + PlexShow.directors.contains([name]), + ), + ) + .order_by(PlexShow.year.desc().nullslast(), func.lower(PlexShow.title)) + .limit(_PERSON_SECTION_MAX) + ).scalars().all() + statuses = rollup.state_map(db, user.id, [s.id for s in show_rows]) + shows = [show_card(sh, statuses.get(sh.id, "new")) for sh in show_rows] + + return {"name": name, "photo": photo, "movies": movies, "shows": shows} diff --git a/backend/app/routes/plex/playlists.py b/backend/app/routes/plex/playlists.py new file mode 100644 index 00000000..23343124 --- /dev/null +++ b/backend/app/routes/plex/playlists.py @@ -0,0 +1,405 @@ +"""Plex playlists: the current user's personal, orderable playlists of playable items.""" + +from datetime import datetime, timezone + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from sqlalchemy import delete, func, select, update +from sqlalchemy.orm import Session + +from app.auth import current_user +from app.db import get_db +from app.models import ( + PlexItem, + PlexPlaylist, + PlexPlaylistItem, + PlexShow, + PlexState, + User, +) +from app.plex.cards import leaf_card +from app.plex.lookups import by_rating_key +from app.routes._common import owned_or_404 + +router = APIRouter(prefix="/api/plex", tags=["plex"]) + + +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, + "title": pl.title, + "item_count": count, + "thumb": f"/api/plex/image/{thumb_rk}" if thumb_rk else None, + } + + +def _playlist_item_query(pid: int): + return ( + select(PlexPlaylistItem, PlexItem) + .join(PlexItem, PlexItem.id == PlexPlaylistItem.item_id) + .where(PlexPlaylistItem.playlist_id == pid) + .order_by(PlexPlaylistItem.position, PlexPlaylistItem.id) + ) + + +@router.get("/playlists") +def playlists( + contains: str | None = None, + contains_group: str | None = None, + user: User = Depends(current_user), + db: Session = Depends(get_db), +) -> dict: + """The current user's playlists (newest-touched first), each with an item count + a cover thumb. + `contains`=an item rating_key adds `has_item` per playlist (for the single 'add to playlist' dialog). + `contains_group`=a comma-separated list of rating_keys adds `group_in` per playlist (how many of the + group it already holds) + a top-level `group_size`, for the 'add a whole season/show' dialog.""" + contains_id = None + if contains: + contains_id = db.scalar( + select(PlexItem.id).where(PlexItem.rating_key == str(contains)) + ) + group_ids: set[int] | None = None + if contains_group is not None: + rks = [s for s in (contains_group or "").split(",") if s] + group_ids = set( + db.execute( + select(PlexItem.id).where(PlexItem.rating_key.in_(rks or [""])) + ).scalars() + ) + out = [] + for pl in db.execute( + select(PlexPlaylist) + .where(PlexPlaylist.user_id == user.id) + .order_by(PlexPlaylist.updated_at.desc()) + ).scalars(): + count = ( + db.scalar( + select(func.count(PlexPlaylistItem.id)).where( + PlexPlaylistItem.playlist_id == pl.id + ) + ) + or 0 + ) + first = db.execute(_playlist_item_query(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.execute( + select(PlexPlaylistItem.id).where( + PlexPlaylistItem.playlist_id == pl.id, + PlexPlaylistItem.item_id == contains_id, + ) + ).first() + is not None + ) + if group_ids is not None: + card["group_in"] = ( + db.scalar( + select(func.count(PlexPlaylistItem.id)).where( + PlexPlaylistItem.playlist_id == pl.id, + PlexPlaylistItem.item_id.in_(group_ids or [0]), + ) + ) + or 0 + ) + out.append(card) + resp: dict = {"playlists": out} + if group_ids is not None: + 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), +) -> dict: + title = body.title.strip() + if not title: + raise HTTPException(status_code=400, detail="title is required") + pl = PlexPlaylist(user_id=user.id, title=title) + db.add(pl) + db.flush() + seed = body.item_rating_key + count = 0 + thumb_rk = None + if seed: + it = by_rating_key(db, PlexItem, seed) + if it is not None: + db.add(PlexPlaylistItem(playlist_id=pl.id, item_id=it.id, position=0)) + count, thumb_rk = 1, it.rating_key + 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: + pl = _own_playlist_or_404(db, pid, user) + rows = db.execute(_playlist_item_query(pid)).all() + items = [it for (_pi, it) in rows] + states = { + s.item_id: s + for s in db.execute( + select(PlexState).where( + PlexState.user_id == user.id, + PlexState.item_id.in_([it.id for it in items] or [0]), + ) + ).scalars() + } + shows = { + sh.id: sh + for sh in db.execute( + select(PlexShow).where( + PlexShow.id.in_([it.show_id for it in items if it.show_id] or [0]) + ) + ).scalars() + } + cards = [ + leaf_card( + it, + states.get(it.id), + shows[it.show_id].title if it.show_id in shows else None, + shows[it.show_id].rating_key if it.show_id in shows else None, + ) + for it in items + ] + 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), +) -> dict: + pl = _own_playlist_or_404(db, pid, user) + title = body.title.strip() + if not title: + raise HTTPException(status_code=400, detail="title is required") + pl.title = title + db.commit() + count = ( + db.scalar( + select(func.count(PlexPlaylistItem.id)).where( + PlexPlaylistItem.playlist_id == pl.id + ) + ) + or 0 + ) + first = db.execute(_playlist_item_query(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: + 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), +) -> dict: + pl = _own_playlist_or_404(db, pid, user) + it = by_rating_key(db, PlexItem, body.item_rating_key) + if it is None: + raise HTTPException(status_code=404, detail="Unknown item") + exists = db.execute( + select(PlexPlaylistItem).where( + PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id == it.id + ) + ).scalars().first() + if exists is None: + maxpos = db.scalar( + select(func.coalesce(func.max(PlexPlaylistItem.position), -1)).where( + PlexPlaylistItem.playlist_id == pid + ) + ) + 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: + pl = _own_playlist_or_404(db, pid, user) + it = by_rating_key(db, PlexItem, item_rating_key) + if it is not None: + db.execute( + delete(PlexPlaylistItem).where( + PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id == it.id + ) + ) + pl.updated_at = datetime.now(timezone.utc) + 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.execute( + select(PlexItem).where(PlexItem.rating_key.in_(order)) + ).scalars() + } + seen: set[str] = set() + out: list[PlexItem] = [] + for rk in order: + it = by_rk.get(rk) + if it is not None and rk not in seen: + seen.add(rk) + 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), +) -> 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 = set( + db.execute( + select(PlexPlaylistItem.item_id).where(PlexPlaylistItem.playlist_id == pid) + ).scalars() + ) + pos = int( + db.scalar( + select(func.coalesce(func.max(PlexPlaylistItem.position), -1)).where( + PlexPlaylistItem.playlist_id == pid + ) + ) + ) + added = 0 + for it in items: + if it.id in existing: + continue + pos += 1 + db.add(PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=pos)) + existing.add(it.id) + added += 1 + if added: + pl.updated_at = datetime.now(timezone.utc) + db.commit() + total = ( + db.scalar( + select(func.count(PlexPlaylistItem.id)).where( + PlexPlaylistItem.playlist_id == pid + ) + ) + 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), +) -> dict: + """Remove many items at once (a whole season/show, or a multi-select). Returns how many were + removed + the new total.""" + pl = _own_playlist_or_404(db, pid, user) + ids = [it.id for it in _items_by_rks(db, body.item_rating_keys)] + removed = 0 + if ids: + removed = db.execute( + delete(PlexPlaylistItem).where( + PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id.in_(ids) + ), + execution_options={"synchronize_session": False}, + ).rowcount + if removed: + pl.updated_at = datetime.now(timezone.utc) + db.commit() + total = ( + db.scalar( + select(func.count(PlexPlaylistItem.id)).where( + PlexPlaylistItem.playlist_id == pid + ) + ) + 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), +) -> 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.execute( + select(PlexItem).where(PlexItem.rating_key.in_(order or [""])) + ).scalars() + } + for pos, rk in enumerate(order): + iid = id_by_rk.get(rk) + if iid is not None: + db.execute( + update(PlexPlaylistItem) + .where( + PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id == iid + ) + .values(position=pos) + ) + pl.updated_at = datetime.now(timezone.utc) + db.commit() + return {"ok": True} diff --git a/backend/tests/test_plex_detail.py b/backend/tests/test_plex_detail.py index ae46e868..38223239 100644 --- a/backend/tests/test_plex_detail.py +++ b/backend/tests/test_plex_detail.py @@ -13,7 +13,7 @@ from types import SimpleNamespace from fastapi.testclient import TestClient -import app.routes.plex.browse as browse +import app.routes.plex.detail as detail_routes import app.state as state from app.auth import current_user from app.db import get_db @@ -216,7 +216,7 @@ def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch): user = _user(db, "outage@example.com") db.commit() - monkeypatch.setattr(browse, "PlexClient", lambda db: _StubClient(raise_=True)) + monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(raise_=True)) resp = _get(db, user, monkeypatch, "/api/plex/item/m1") assert resp.status_code == 200, resp.text body = resp.json() @@ -240,7 +240,7 @@ def test_item_detail_movie_lazy_fills_when_unenriched(db, monkeypatch): user = _user(db, "lazy@example.com") db.commit() - monkeypatch.setattr(browse, "PlexClient", lambda db: _StubClient(RICH_META)) + monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(RICH_META)) resp = _get(db, user, monkeypatch, "/api/plex/item/m1") assert resp.status_code == 200, resp.text assert resp.json()["imdb_id"] == "tt0068646" @@ -267,7 +267,7 @@ def test_item_detail_episode_serves_rich_from_live(db, monkeypatch): user = _user(db, "ep@example.com") db.commit() - monkeypatch.setattr(browse, "PlexClient", lambda db: _StubClient(RICH_META)) + monkeypatch.setattr(detail_routes, "PlexClient", lambda db: _StubClient(RICH_META)) resp = _get(db, user, monkeypatch, "/api/plex/item/e1") assert resp.status_code == 200, resp.text body = resp.json() @@ -298,7 +298,7 @@ def test_show_detail_serves_from_row(db, monkeypatch): def _boom(db_, rk): raise RuntimeError("plex unavailable") - monkeypatch.setattr(browse, "_show_enrichment", _boom) + monkeypatch.setattr(detail_routes, "_show_enrichment", _boom) resp = _get(db, user, monkeypatch, "/api/plex/show/s1") assert resp.status_code == 200, resp.text body = resp.json()["show"] @@ -322,7 +322,7 @@ def test_show_detail_lazy_fills_when_unenriched(db, monkeypatch): db.commit() # _show_enrichment returns (live meta, no related) → the fill branch runs. - monkeypatch.setattr(browse, "_show_enrichment", lambda db_, rk: (RICH_META, [])) + monkeypatch.setattr(detail_routes, "_show_enrichment", lambda db_, rk: (RICH_META, [])) resp = _get(db, user, monkeypatch, "/api/plex/show/s1") assert resp.status_code == 200, resp.text assert resp.json()["show"]["imdb_id"] == "tt0068646" diff --git a/backend/tests/test_plex_overlay.py b/backend/tests/test_plex_overlay.py index b49a31c7..a45cef33 100644 --- a/backend/tests/test_plex_overlay.py +++ b/backend/tests/test_plex_overlay.py @@ -11,7 +11,7 @@ import app.plex.sync as syncmod from app import sysconfig from app.models import PlexItem, PlexLibrary from app.plex.sync import _apply_item, _canonical_guid, _sync_overlay -from app.routes.plex.browse import _content_libs +from app.routes.plex._shared import _content_libs class _StubPlex: diff --git a/backend/tests/test_plex_person.py b/backend/tests/test_plex_person.py index 510cfc92..0caefac7 100644 --- a/backend/tests/test_plex_person.py +++ b/backend/tests/test_plex_person.py @@ -19,7 +19,7 @@ from app.models import ( PlexState, User, ) -from app.routes.plex.browse import _content_libs +from app.routes.plex._shared import _content_libs NAME = "Robert De Niro"