From ecbf5737b69cbea9b1fc40f6a575944f739bd121 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 16 Aug 2026 05:45:05 +0200 Subject: [PATCH 01/32] test(plex): characterization net for the db.query->select conversion Locks the observable behaviour of the previously-untested Plex query hot paths before the 2.0 conversion, so it can be proven behaviour-equivalent: - unified_library: both/movie/show scope, added-desc ordering, the four watch-state branches, search rank + episode grouping, pagination. - rollup.state_map: new / in_progress / watched rollup. - sync upsert idempotency: _sync_movies + _sync_shows run twice = no dup rows, in-place update, episode->show linking. Green on the current 1.x code (9 passed). The conversion commits must keep these identical. --- backend/tests/test_plex_query_conversion.py | 199 ++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 backend/tests/test_plex_query_conversion.py diff --git a/backend/tests/test_plex_query_conversion.py b/backend/tests/test_plex_query_conversion.py new file mode 100644 index 00000000..014bfe7a --- /dev/null +++ b/backend/tests/test_plex_query_conversion.py @@ -0,0 +1,199 @@ +"""S1 characterization net for the Plex-package db.query() -> SQLAlchemy 2.0 select() conversion. + +These lock the OBSERVABLE behaviour of the previously-untested hot query paths — the unified_library +union builder (+ its watch-state branches, ordering, search ranking, episode grouping), the rollup +aggregate, and the sync upsert idempotency — so the mechanical 1.x -> 2.0 conversion can be proven +behaviour-equivalent: the exact same assertions must pass before and after the conversion. DB lane +(`db` fixture) — skipped without Postgres. +""" +from datetime import datetime, timezone + +from fastapi.testclient import TestClient +from sqlalchemy import delete, select + +import app.state as state +from app.auth import current_user +from app.db import get_db +from app.main import app +from app.models import PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState, User +from app.plex import rollup, sync + + +def _dt(day: int) -> datetime: + return datetime(2024, 1, day, tzinfo=timezone.utc) + + +def _seed(db): + """A minimal cross-library corpus: 2 movies, 1 show with 2 episodes, and a per-user watch-state + where movie m1 is watched and episode e1 is in-progress (so the show rolls up to in_progress).""" + movie_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + show_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) + db.add_all([movie_lib, show_lib]) + db.flush() + m1 = PlexItem(rating_key="m1", library_id=movie_lib.id, kind="movie", + title="Alpha Movie", year=2001, added_at=_dt(1)) + m2 = PlexItem(rating_key="m2", library_id=movie_lib.id, kind="movie", + title="Bravo Movie", year=2002, added_at=_dt(2)) + show = PlexShow(rating_key="s1", library_id=show_lib.id, title="Charlie Show", + year=2003, added_at=_dt(3), child_count=1) + db.add_all([m1, m2, show]) + db.flush() + season = PlexSeason(rating_key="se1", show_id=show.id, season_number=1) + db.add(season) + db.flush() + e1 = PlexItem(rating_key="e1", library_id=show_lib.id, kind="episode", title="Charlie Pilot", + show_id=show.id, season_id=season.id, season_number=1, episode_number=1, added_at=_dt(4)) + e2 = PlexItem(rating_key="e2", library_id=show_lib.id, kind="episode", title="Charlie Finale", + show_id=show.id, season_id=season.id, season_number=1, episode_number=2, added_at=_dt(5)) + db.add_all([e1, e2]) + user = User(email="plexbrowse@example.com") + db.add(user) + db.flush() + db.add_all([ + PlexState(user_id=user.id, item_id=m1.id, status="watched"), + PlexState(user_id=user.id, item_id=e1.id, position_seconds=120), + ]) + db.commit() + return {"user": user, "movie_lib": movie_lib, "show_lib": show_lib, "show": show} + + +def _get(db, user, monkeypatch, path): + monkeypatch.setattr(state, "_configured_cache", True) # the request gate 503s an unconfigured app + app.dependency_overrides[get_db] = lambda: db + app.dependency_overrides[current_user] = lambda: user + try: + return TestClient(app).get(path) # no context manager: skip lifespan (scheduler/HLS sweep) + finally: + app.dependency_overrides.clear() + + +class TestUnifiedLibrary: + def test_both_scope_unions_movies_and_shows_ordered_by_added(self, db, monkeypatch): + s = _seed(db) + r = _get(db, s["user"], monkeypatch, "/api/plex/library?scope=both") + assert r.status_code == 200, r.text + body = r.json() + assert body["total"] == 3 # 2 movies + 1 show, unioned + # default sort = added_at desc: show (day 3) > bravo (day 2) > alpha (day 1) + assert [i["id"] for i in body["items"]] == ["s1", "m2", "m1"] + show_item = next(i for i in body["items"] if i["type"] == "show") + assert show_item["status"] == "in_progress" # e1 in-progress -> show rollup + assert show_item["season_count"] == 1 + assert next(i for i in body["items"] if i["id"] == "m1")["status"] == "watched" + + def test_scope_movie_only(self, db, monkeypatch): + s = _seed(db) + body = _get(db, s["user"], monkeypatch, "/api/plex/library?scope=movie").json() + assert body["total"] == 2 + assert all(i["type"] == "movie" for i in body["items"]) + + def test_scope_show_only(self, db, monkeypatch): + s = _seed(db) + body = _get(db, s["user"], monkeypatch, "/api/plex/library?scope=show").json() + assert [i["id"] for i in body["items"]] == ["s1"] + assert body["items"][0]["season_count"] == 1 + + def test_watch_state_branches(self, db, monkeypatch): + s = _seed(db) + # watched: only movie m1 (the show is in_progress, not fully watched) + watched = _get(db, s["user"], monkeypatch, "/api/plex/library?show=watched").json() + assert [i["id"] for i in watched["items"]] == ["m1"] + # in_progress: only the show (no movie is in progress) + inprog = _get(db, s["user"], monkeypatch, "/api/plex/library?show=in_progress").json() + assert [i["id"] for i in inprog["items"]] == ["s1"] + # unwatched: only movie m2 (no state). The show is EXCLUDED — "unwatched" for a show means + # 0 watched AND 0 in-progress episodes, and e1 is in-progress. (Locks the actual branch.) + unwatched = _get(db, s["user"], monkeypatch, "/api/plex/library?show=unwatched").json() + assert [i["id"] for i in unwatched["items"]] == ["m2"] + + def test_search_matches_by_rank_and_groups_episodes(self, db, monkeypatch): + s = _seed(db) + body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=charlie").json() + # only the show title matches in the item grid; the movies don't contain "charlie" + assert [i["id"] for i in body["items"]] == ["s1"] + assert body["total"] == 1 + # matching episodes come back grouped in the separate episodes list + assert sorted(e["title"] for e in body["episodes"]) == ["Charlie Finale", "Charlie Pilot"] + + def test_pagination_caps_items_not_total(self, db, monkeypatch): + s = _seed(db) + body = _get(db, s["user"], monkeypatch, "/api/plex/library?limit=2&offset=0").json() + assert body["total"] == 3 + assert [i["id"] for i in body["items"]] == ["s1", "m2"] # first page of the added-desc order + + +class TestRollup: + def test_state_map_new_inprogress_watched(self, db): + s = _seed(db) + show, user = s["show"], s["user"] + # e1 in-progress, e2 new -> in_progress + assert rollup.state_map(db, user.id, [show.id]) == {show.id: "in_progress"} + # empty input short-circuits + assert rollup.state_map(db, user.id, []) == {} + # a fresh user with no state -> new + other = User(email="rollup-other@example.com") + db.add(other) + db.commit() + assert rollup.state_map(db, other.id, [show.id]) == {show.id: "new"} + # mark every episode watched for `user` -> watched + db.execute(delete(PlexState).where(PlexState.user_id == user.id)) + for e in db.execute(select(PlexItem).where(PlexItem.kind == "episode")).scalars(): + db.add(PlexState(user_id=user.id, item_id=e.id, status="watched")) + db.commit() + assert rollup.state_map(db, user.id, [show.id]) == {show.id: "watched"} + + +def _fake_paginate(by_type): + def _p(_plex, _key, typ): + return list(by_type.get(typ, [])) + return _p + + +class TestSyncUpsertIdempotency: + def test_movies_upsert_in_place_no_duplicates(self, db, monkeypatch): + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.commit() + metas = [ + {"ratingKey": "100", "title": "First", "year": 2000, "addedAt": 1700000000}, + {"ratingKey": "101", "title": "Second", "year": 2001, "addedAt": 1700000001}, + ] + monkeypatch.setattr(sync, "_paginate", _fake_paginate({sync._TYPE_MOVIE: metas})) + sync._sync_movies(db, object(), lib, {"movies": 0}) + db.commit() + # second run with one title changed: same rows, updated in place (upsert, not re-insert) + metas[0]["title"] = "First Renamed" + sync._sync_movies(db, object(), lib, {"movies": 0}) + db.commit() + rows = db.execute( + select(PlexItem).where(PlexItem.library_id == lib.id, PlexItem.kind == "movie") + ).scalars().all() + assert {r.rating_key for r in rows} == {"100", "101"} # no duplicate rows + assert next(r for r in rows if r.rating_key == "100").title == "First Renamed" + + def test_shows_upsert_links_episodes_and_is_idempotent(self, db, monkeypatch): + lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) + db.add(lib) + db.commit() + by_type = { + sync._TYPE_SHOW: [ + {"ratingKey": "s1", "title": "Show One", "childCount": 1, "addedAt": 1700000000} + ], + sync._TYPE_SEASON: [ + {"ratingKey": "se1", "parentRatingKey": "s1", "title": "Season 1", "index": 1} + ], + sync._TYPE_EPISODE: [ + {"ratingKey": "e1", "title": "Ep One", "grandparentRatingKey": "s1", + "parentRatingKey": "se1", "parentIndex": 1, "index": 1, "addedAt": 1700000001} + ], + } + monkeypatch.setattr(sync, "_paginate", _fake_paginate(by_type)) + for _ in range(2): # run twice — must be idempotent + sync._sync_shows(db, object(), lib, {"shows": 0, "seasons": 0, "episodes": 0}) + db.commit() + shows = db.execute(select(PlexShow).where(PlexShow.library_id == lib.id)).scalars().all() + assert {sh.rating_key for sh in shows} == {"s1"} # no duplicate show + ep = db.execute(select(PlexItem).where(PlexItem.kind == "episode")).scalars().one() + assert ep.rating_key == "e1" + assert ep.show_id == shows[0].id # episode linked to its show + assert (ep.season_number, ep.episode_number) == (1, 1) From 59d50615c4f0d562b677dd0a15f9e5d4d40318ba Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 16 Aug 2026 05:52:40 +0200 Subject: [PATCH 02/32] refactor(plex): convert service-layer db.query() to 2.0 select() (wave 1) Mechanical 1.x->2.0 conversion of the self-contained plex modules, behaviour- equivalent (the characterization net + existing plex tests stay green, 39 passed): app/plex/{sync(15),watch_sync(9),people(1),imgcache(4)} and app/routes/plex/{admin(4),playback(8),_shared(1)}. Query builders become select(); .first()/.all()->execute().scalars(); a bulk .update() -> execute(update()); a single-column .first() -> db.scalar(). browse.py (61) + rollup.agg_subquery (the atomic unit with unified_library) remain for wave 2. --- backend/app/plex/imgcache.py | 17 ++++-- backend/app/plex/people.py | 7 ++- backend/app/plex/sync.py | 88 +++++++++++++++++++++-------- backend/app/plex/watch_sync.py | 60 +++++++++++++------- backend/app/routes/plex/_shared.py | 5 +- backend/app/routes/plex/admin.py | 19 +++++-- backend/app/routes/plex/playback.py | 45 +++++++++++---- 7 files changed, 174 insertions(+), 67 deletions(-) diff --git a/backend/app/plex/imgcache.py b/backend/app/plex/imgcache.py index 2dbeb2a3..215e18ea 100644 --- a/backend/app/plex/imgcache.py +++ b/backend/app/plex/imgcache.py @@ -9,6 +9,7 @@ The cache directory (``cache.CACHE_DIR``) is shared with the subtitle cache (see """ from fastapi import Response +from sqlalchemy import select from sqlalchemy.orm import Session from app.models import PlexCollection, PlexItem, PlexSeason, PlexShow @@ -24,16 +25,24 @@ SIZES = {"thumb": (400, 600), "art": (1280, 720)} def key_for(db: Session, rating_key: str, variant: str) -> str | None: """The stored Plex image path for a known rating_key (item/show/season). Only mirrored keys are proxyable — this is not an open image proxy.""" - it = db.query(PlexItem).filter_by(rating_key=rating_key).first() + it = db.execute( + select(PlexItem).where(PlexItem.rating_key == rating_key) + ).scalars().first() if it is not None: return it.art_key if variant == "art" else it.thumb_key - sh = db.query(PlexShow).filter_by(rating_key=rating_key).first() + sh = db.execute( + select(PlexShow).where(PlexShow.rating_key == rating_key) + ).scalars().first() if sh is not None: return sh.art_key if variant == "art" else sh.thumb_key - se = db.query(PlexSeason).filter_by(rating_key=rating_key).first() + se = db.execute( + select(PlexSeason).where(PlexSeason.rating_key == rating_key) + ).scalars().first() if se is not None: return se.thumb_key - col = db.query(PlexCollection).filter_by(rating_key=rating_key).first() + col = db.execute( + select(PlexCollection).where(PlexCollection.rating_key == rating_key) + ).scalars().first() if col is not None: return col.thumb_key return None diff --git a/backend/app/plex/people.py b/backend/app/plex/people.py index 7f5652c7..758ce276 100644 --- a/backend/app/plex/people.py +++ b/backend/app/plex/people.py @@ -5,7 +5,7 @@ re-sync (plex.sync), so the two paths never diverge.""" from urllib.parse import quote -from sqlalchemy import text +from sqlalchemy import select, text from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session @@ -55,7 +55,10 @@ def enrich_row_people(db: Session, row, cast: list[dict]) -> bool: dirty = True # Headshot cache: upsert name -> proxied thumb (a later fetch fills a prior NULL; names without a # whitelisted photo still get a row so a card can show a placeholder). - existing = {p.name: p for p in db.query(PlexPerson).filter(PlexPerson.name.in_(names))} + existing = { + p.name: p + for p in db.execute(select(PlexPerson).where(PlexPerson.name.in_(names))).scalars() + } missing: dict[str, str | None] = {} for c in cast: nm, th = c.get("name"), c.get("thumb") diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py index f1b9e82a..cdfa0711 100644 --- a/backend/app/plex/sync.py +++ b/backend/app/plex/sync.py @@ -12,6 +12,7 @@ Cast + intro/credit markers are fetched lazily on the item-detail/player path (P import logging from datetime import datetime, timezone +from sqlalchemy import select from sqlalchemy.orm import Session from app import sysconfig @@ -165,20 +166,20 @@ def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BAT shared enrich_row_people). Oldest-id first, movies before shows. Stamps people_enriched_at only on a SUCCESSFUL fetch, so a transient per-item failure retries next run rather than being lost. Returns how many items were enriched this run.""" - movies = ( - db.query(PlexItem) - .filter(PlexItem.kind == "movie", PlexItem.people_enriched_at.is_(None)) + movies = db.execute( + select(PlexItem) + .where(PlexItem.kind == "movie", PlexItem.people_enriched_at.is_(None)) .order_by(PlexItem.id) .limit(limit) - .all() - ) + ).scalars().all() remaining = limit - len(movies) shows = ( - db.query(PlexShow) - .filter(PlexShow.people_enriched_at.is_(None)) - .order_by(PlexShow.id) - .limit(remaining) - .all() + db.execute( + select(PlexShow) + .where(PlexShow.people_enriched_at.is_(None)) + .order_by(PlexShow.id) + .limit(remaining) + ).scalars().all() if remaining > 0 else [] ) @@ -198,7 +199,9 @@ def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BAT def _upsert_library(db: Session, key: str, title: str, kind: str) -> PlexLibrary: - lib = db.query(PlexLibrary).filter_by(plex_key=key).first() + lib = db.execute( + select(PlexLibrary).where(PlexLibrary.plex_key == key) + ).scalars().first() if lib is None: lib = PlexLibrary(plex_key=key) db.add(lib) @@ -261,7 +264,12 @@ def _apply_item(row: PlexItem, lib_id: int, kind: str, meta: dict) -> None: def _sync_movies(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> None: - existing = {r.rating_key: r for r in db.query(PlexItem).filter_by(library_id=lib.id, kind="movie")} + existing = { + r.rating_key: r + for r in db.execute( + select(PlexItem).where(PlexItem.library_id == lib.id, PlexItem.kind == "movie") + ).scalars() + } for meta in _paginate(plex, lib.plex_key, _TYPE_MOVIE): rk = str(meta.get("ratingKey") or "") if not rk: @@ -276,7 +284,12 @@ def _sync_movies(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) - def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> None: # 1) shows - shows = {r.rating_key: r for r in db.query(PlexShow).filter_by(library_id=lib.id)} + shows = { + r.rating_key: r + for r in db.execute( + select(PlexShow).where(PlexShow.library_id == lib.id) + ).scalars() + } for meta in _paginate(plex, lib.plex_key, _TYPE_SHOW): rk = str(meta.get("ratingKey") or "") if not rk: @@ -299,7 +312,12 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> show_id = {rk: sh.id for rk, sh in shows.items()} # 2) seasons - seasons = {r.rating_key: r for r in db.query(PlexSeason).join(PlexShow).filter(PlexShow.library_id == lib.id)} + seasons = { + r.rating_key: r + for r in db.execute( + select(PlexSeason).join(PlexShow).where(PlexShow.library_id == lib.id) + ).scalars() + } for meta in _paginate(plex, lib.plex_key, _TYPE_SEASON): rk = str(meta.get("ratingKey") or "") sid = show_id.get(str(meta.get("parentRatingKey") or "")) @@ -318,7 +336,12 @@ def _sync_shows(db: Session, plex: PlexClient, lib: PlexLibrary, stats: dict) -> season_id = {rk: se.id for rk, se in seasons.items()} # 3) episodes (playable leaves) - episodes = {r.rating_key: r for r in db.query(PlexItem).filter_by(library_id=lib.id, kind="episode")} + episodes = { + r.rating_key: r + for r in db.execute( + select(PlexItem).where(PlexItem.library_id == lib.id, PlexItem.kind == "episode") + ).scalars() + } for meta in _paginate(plex, lib.plex_key, _TYPE_EPISODE): rk = str(meta.get("ratingKey") or "") if not rk: @@ -359,17 +382,27 @@ def resync_collection(db: Session, plex: PlexClient, lib: PlexLibrary, rk: str) collection's membership on the affected member rows.""" meta = plex.metadata(rk) or {} col = _upsert_collection( - db, db.query(PlexCollection).filter_by(rating_key=rk).first(), rk, lib.id, meta + db, + db.execute( + select(PlexCollection).where(PlexCollection.rating_key == rk) + ).scalars().first(), + rk, + lib.id, + meta, ) Model = PlexItem if lib.kind == "movie" else PlexShow new_members = {str(c.get("ratingKey")) for c in plex.collection_children(rk) if c.get("ratingKey")} - current = db.query(Model).filter(Model.collection_keys.contains([rk])).all() + current = db.execute( + select(Model).where(Model.collection_keys.contains([rk])) + ).scalars().all() for row in current: # drop rk from rows that are no longer members if row.rating_key not in new_members: row.collection_keys = [k for k in (row.collection_keys or []) if k != rk] or None to_add = new_members - {r.rating_key for r in current} if to_add: - for row in db.query(Model).filter(Model.rating_key.in_(to_add)): + for row in db.execute( + select(Model).where(Model.rating_key.in_(to_add)) + ).scalars(): row.collection_keys = list(dict.fromkeys([*(row.collection_keys or []), rk])) db.flush() return col @@ -378,9 +411,13 @@ def resync_collection(db: Session, plex: PlexClient, lib: PlexLibrary, rk: str) def delete_collection_local(db: Session, lib: PlexLibrary, rk: str) -> None: """Local cleanup after a collection is deleted from Plex: strip rk from members + drop the row.""" Model = PlexItem if lib.kind == "movie" else PlexShow - for row in db.query(Model).filter(Model.collection_keys.contains([rk])): + for row in db.execute( + select(Model).where(Model.collection_keys.contains([rk])) + ).scalars(): row.collection_keys = [k for k in (row.collection_keys or []) if k != rk] or None - col = db.query(PlexCollection).filter_by(rating_key=rk).first() + col = db.execute( + select(PlexCollection).where(PlexCollection.rating_key == rk) + ).scalars().first() if col is not None: db.delete(col) db.flush() @@ -391,7 +428,12 @@ def _sync_collections(db: Session, plex: PlexClient, lib: PlexLibrary, stats: di written as `collection_keys` on member movies (plex_items) / shows (plex_shows). A full reconcile: collections gone from Plex are pruned, and each member row's keys are rebuilt from scratch.""" cols = plex.collections(lib.plex_key) - existing = {c.rating_key: c for c in db.query(PlexCollection).filter_by(library_id=lib.id)} + existing = { + c.rating_key: c + for c in db.execute( + select(PlexCollection).where(PlexCollection.library_id == lib.id) + ).scalars() + } seen: set[str] = set() membership: dict[str, list[str]] = {} # member rating_key -> [collection rating_key, ...] for meta in cols: @@ -410,8 +452,8 @@ def _sync_collections(db: Session, plex: PlexClient, lib: PlexLibrary, stats: di if rk not in seen: db.delete(col) # Rebuild collection_keys on every member row of this library (clear + set). - for row in db.query(PlexItem).filter_by(library_id=lib.id): + for row in db.execute(select(PlexItem).where(PlexItem.library_id == lib.id)).scalars(): row.collection_keys = membership.get(row.rating_key) or None - for row in db.query(PlexShow).filter_by(library_id=lib.id): + for row in db.execute(select(PlexShow).where(PlexShow.library_id == lib.id)).scalars(): row.collection_keys = membership.get(row.rating_key) or None db.flush() diff --git a/backend/app/plex/watch_sync.py b/backend/app/plex/watch_sync.py index 6e1c3d7e..a72d5b01 100644 --- a/backend/app/plex/watch_sync.py +++ b/backend/app/plex/watch_sync.py @@ -16,7 +16,7 @@ history + onDeck, last-write-wins) build on this and land in later ships. import logging from datetime import datetime, timedelta, timezone -from sqlalchemy import func +from sqlalchemy import func, select, update from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session @@ -61,7 +61,10 @@ def _plex_watch_to_state( def _rk_to_id(db: Session) -> dict[str, int]: """The rating_key → PlexItem.id lookup used to resolve a Plex leaf back to its mirrored row.""" - return {rk: iid for rk, iid in db.query(PlexItem.rating_key, PlexItem.id)} + return { + rk: iid + for rk, iid in db.execute(select(PlexItem.rating_key, PlexItem.id)).all() + } def _scan_plex_states( @@ -162,7 +165,9 @@ def link_for_push(db: Session, user_id: int) -> PlexLink | None: """The active owner watch-push link for this user, or None when push is off / not the owner. Push is gated on `initial_import_done` so we never push a half-known local state over Plex's authoritative one before the one-time "Plex is master" import has reconciled them.""" - link = db.query(PlexLink).filter_by(user_id=user_id).first() + link = db.execute( + select(PlexLink).where(PlexLink.user_id == user_id) + ).scalars().first() if link and link.uses_admin and link.sync_enabled and link.initial_import_done: return link return None @@ -217,7 +222,11 @@ def push_state_to_plex( # Flag the row synced — but an "unwatched" via status=new deletes the row, so there may be # nothing to flag; that's fine (unscrobble still happened). Skip the flag if the row was # re-edited since we captured `expect_ts` (leave it dirty for its own push / the reconcile). - st = db.query(PlexState).filter_by(user_id=user_id, item_id=item_id).first() + st = db.execute( + select(PlexState).where( + PlexState.user_id == user_id, PlexState.item_id == item_id + ) + ).scalars().first() if st is not None and _row_matches(st, expect_ts): st.synced_to_plex = True db.commit() @@ -272,9 +281,11 @@ def push_bulk_state_to_plex(user_id: int, changes: list[tuple[int, str, str]]) - # Flag every successfully-pushed row that still exists (an "unwatched" deletes the row, so # only the "watched" changes have a row to flag). if synced_ids: - db.query(PlexState).filter( - PlexState.user_id == user_id, PlexState.item_id.in_(synced_ids) - ).update({PlexState.synced_to_plex: True}, synchronize_session=False) + db.execute( + update(PlexState) + .where(PlexState.user_id == user_id, PlexState.item_id.in_(synced_ids)) + .values(synced_to_plex=True) + ) db.commit() @@ -290,11 +301,13 @@ _LWW_TOLERANCE_S = 90 def _active_sync_links(db: Session) -> list[PlexLink]: """Every link with two-way sync actually running (owner MVP = one row). Iterated uniformly so P5b friend links slot in later without touching this loop.""" - return ( - db.query(PlexLink) - .filter_by(uses_admin=True, sync_enabled=True, initial_import_done=True) - .all() - ) + return db.execute( + select(PlexLink).where( + PlexLink.uses_admin.is_(True), + PlexLink.sync_enabled.is_(True), + PlexLink.initial_import_done.is_(True), + ) + ).scalars().all() def _resolve_account_id(db: Session, plex: PlexClient, link: PlexLink) -> int: @@ -340,7 +353,11 @@ def _pull_apply( """Bring one Plex-side positive signal into Siftlode under last-write-wins. Returns "watched"/"in_progress" if it changed the row, else None. Never clears a row (un-watch is the full reconcile's job) — this only pulls in watched/resume.""" - st = db.query(PlexState).filter_by(user_id=user_id, item_id=item_id).first() + st = db.execute( + select(PlexState).where( + PlexState.user_id == user_id, PlexState.item_id == item_id + ) + ).scalars().first() if _same_state(st, status, pos): if st is not None and not st.synced_to_plex: st.synced_to_plex = True # already matches Plex → just settle the flag, no churn @@ -368,17 +385,18 @@ def _repush_dirty(db: Session, plex: PlexClient, link: PlexLink) -> int: # re-push doesn't need an extra per-item plex.metadata() round-trip just to fill set_timeline's # duration argument. info_by_id: dict[int, tuple[str, int | None]] = { - iid: (rk, dur) for iid, rk, dur in db.query(PlexItem.id, PlexItem.rating_key, PlexItem.duration_s) + iid: (rk, dur) + for iid, rk, dur in db.execute( + select(PlexItem.id, PlexItem.rating_key, PlexItem.duration_s) + ).all() } - dirty = ( - db.query(PlexState) - .filter( + dirty = db.execute( + select(PlexState).where( PlexState.user_id == link.user_id, PlexState.synced_to_plex.is_(False), PlexState.status != "hidden", ) - .all() - ) + ).scalars().all() n = 0 for st in dirty: info = info_by_id.get(st.item_id) @@ -477,7 +495,9 @@ def run_plex_watch_reconcile(db: Session) -> dict: # Siftlode rows Plex no longer has a record for: clear the ones that were mirrored # (Plex un-watched them); leave hidden + dirty (dirty gets re-pushed just below). - for st in db.query(PlexState).filter_by(user_id=link.user_id).all(): + for st in db.execute( + select(PlexState).where(PlexState.user_id == link.user_id) + ).scalars().all(): if st.item_id in plex_states or st.status == "hidden": continue if st.synced_to_plex: diff --git a/backend/app/routes/plex/_shared.py b/backend/app/routes/plex/_shared.py index ecd7f8c2..a35e216e 100644 --- a/backend/app/routes/plex/_shared.py +++ b/backend/app/routes/plex/_shared.py @@ -3,6 +3,7 @@ import logging from fastapi import HTTPException +from sqlalchemy import select from sqlalchemy.orm import Session from app.models import ( @@ -13,7 +14,9 @@ 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() + it = db.execute( + select(PlexItem).where(PlexItem.rating_key == str(rating_key)) + ).scalars().first() if it is None: raise HTTPException(status_code=404, detail="Unknown Plex item") return it diff --git a/backend/app/routes/plex/admin.py b/backend/app/routes/plex/admin.py index 5c5ddee8..4681542b 100644 --- a/backend/app/routes/plex/admin.py +++ b/backend/app/routes/plex/admin.py @@ -4,6 +4,7 @@ from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel +from sqlalchemy import select from sqlalchemy.orm import Session from app import sysconfig @@ -65,9 +66,9 @@ def _sample_plex_path( """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.""" - row = db.query(PlexItem.file_path).filter(PlexItem.file_path.isnot(None)).first() - if row and row[0]: - return row[0] + fp = db.scalar(select(PlexItem.file_path).where(PlexItem.file_path.isnot(None))) + if fp: + return fp wanted = plex_sync._enabled_section_keys(db) for s in sections: if s.get("type") != "movie": @@ -117,7 +118,9 @@ def watch_link_status( user: User = Depends(current_user), db: Session = Depends(get_db) ) -> dict: """This user's Plex watch-sync link status (drives the Settings toggle).""" - link = db.query(PlexLink).filter_by(user_id=user.id).first() + link = db.execute( + select(PlexLink).where(PlexLink.user_id == user.id) + ).scalars().first() return _link_dict(link) @@ -137,7 +140,9 @@ def watch_link_set( if not sysconfig.get_bool(db, "plex_enabled"): raise HTTPException(status_code=409, detail="The Plex module is disabled.") enabled = body.enabled - link = db.query(PlexLink).filter_by(user_id=user.id).first() + link = db.execute( + select(PlexLink).where(PlexLink.user_id == user.id) + ).scalars().first() if link is None: link = PlexLink( user_id=user.id, uses_admin=True, linked_at=datetime.now(timezone.utc) @@ -158,7 +163,9 @@ def watch_import_now( ) -> dict: """Admin: (re-)run the "Plex is master" import for this account — Plex re-wins on the intersection; Siftlode-only states are preserved. Idempotent.""" - link = db.query(PlexLink).filter_by(user_id=user.id).first() + link = db.execute( + select(PlexLink).where(PlexLink.user_id == user.id) + ).scalars().first() if link is None or not link.sync_enabled: raise HTTPException(status_code=409, detail="Enable Plex watch-sync first.") result = plex_watch.import_owner_watch_state(db, link) diff --git a/backend/app/routes/plex/playback.py b/backend/app/routes/plex/playback.py index 42495705..fed33581 100644 --- a/backend/app/routes/plex/playback.py +++ b/backend/app/routes/plex/playback.py @@ -16,6 +16,7 @@ from fastapi import ( ) from fastapi.responses import FileResponse from pydantic import BaseModel +from sqlalchemy import select from sqlalchemy.orm import Session from app.auth import current_user, resolved_user_id @@ -207,7 +208,11 @@ def _push_watch( return # Capture the row's freshest timestamp NOW so the background push can tell if a newer local edit # 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() + st = db.execute( + select(PlexState).where( + PlexState.user_id == user_id, PlexState.item_id == item_id + ) + ).scalars().first() background.add_task( plex_watch.push_state_to_plex, user_id, @@ -246,7 +251,11 @@ def item_progress( duration = int(body.duration_seconds) final = body.final now = datetime.now(timezone.utc) - st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first() + st = db.execute( + select(PlexState).where( + PlexState.user_id == user.id, PlexState.item_id == it.id + ) + ).scalars().first() was_watched = st is not None and st.status == "watched" near_end = duration > 0 and position > duration - _FINISH_MARGIN_S @@ -316,7 +325,11 @@ def item_state( status = body.status if status not in ("new", "watched", "hidden"): raise HTTPException(status_code=400, detail="Invalid status") - st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first() + st = db.execute( + select(PlexState).where( + PlexState.user_id == user.id, PlexState.item_id == it.id + ) + ).scalars().first() prev_status = st.status if st is not None else "new" if status == "new": if st is not None: @@ -351,10 +364,12 @@ def _bulk_state( now = datetime.now(timezone.utc) 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]), - ) + 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() } pushable = plex_watch.link_for_push(db, user_id) is not None to_push: list[tuple[int, str, str]] = [] @@ -397,11 +412,15 @@ def show_state( db: Session = Depends(get_db), ) -> dict: """Mark a whole show watched/unwatched (all its episodes) for the current user + push to Plex.""" - sh = db.query(PlexShow).filter_by(rating_key=str(rating_key)).first() + sh = db.execute( + select(PlexShow).where(PlexShow.rating_key == str(rating_key)) + ).scalars().first() if sh is None: raise HTTPException(status_code=404, detail="Unknown Plex show") watched = body.watched - eps = db.query(PlexItem).filter_by(show_id=sh.id, kind="episode").all() + eps = db.execute( + select(PlexItem).where(PlexItem.show_id == sh.id, PlexItem.kind == "episode") + ).scalars().all() changed = _bulk_state(background, db, user.id, eps, watched) return {"changed": changed, "watched": watched} @@ -415,11 +434,15 @@ def season_state( db: Session = Depends(get_db), ) -> dict: """Mark a whole season watched/unwatched (all its episodes) for the current user + push to Plex.""" - se = db.query(PlexSeason).filter_by(rating_key=str(rating_key)).first() + se = db.execute( + select(PlexSeason).where(PlexSeason.rating_key == str(rating_key)) + ).scalars().first() if se is None: raise HTTPException(status_code=404, detail="Unknown Plex season") watched = body.watched - eps = db.query(PlexItem).filter_by(season_id=se.id, kind="episode").all() + eps = db.execute( + select(PlexItem).where(PlexItem.season_id == se.id, PlexItem.kind == "episode") + ).scalars().all() changed = _bulk_state(background, db, user.id, eps, watched) return {"changed": changed, "watched": watched} From f8df504d18fe77d433f42bc893d18cc8981230a7 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 16 Aug 2026 06:06:21 +0200 Subject: [PATCH 03/32] refactor(plex): convert browse.py + rollup db.query() to 2.0 select() (wave 2) Completes the plex-package 1.x->2.0 conversion, behaviour-equivalent (full suite 253 passed, 2 skipped; the characterization net locks every path): - routes/plex/browse.py (61 sites): libraries, facets (the wsq builder + jsonb genre unnest), collections, playlists (+ bulk add/remove/reorder), seasons, people/cards, collection strips, episode-nav, item detail, and the unified_library union builder. - plex/rollup.py: agg_subquery now returns a select(); state_map executes it (the atomic unit coupled to unified_library via .subquery()). Query builders -> select(); .filter/.filter_by -> .where; terminals -> db.execute(...).scalars()/.all()/db.scalar(...); bulk .delete()/.update() -> execute(delete()/update()). Zero db.query()/.filter_by remain in the package. --- backend/app/plex/rollup.py | 10 +- backend/app/routes/plex/browse.py | 489 ++++++++++++++++++------------ 2 files changed, 303 insertions(+), 196 deletions(-) diff --git a/backend/app/plex/rollup.py b/backend/app/plex/rollup.py index fb6497ee..fa762610 100644 --- a/backend/app/plex/rollup.py +++ b/backend/app/plex/rollup.py @@ -3,7 +3,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 import and_, case, func, or_, select from sqlalchemy.orm import Session, aliased from app.models import PlexItem, PlexState @@ -47,7 +47,7 @@ def agg_subquery(db: Session, user_id: int): grid by aggregate watch-state and (b) compute each card's badge.""" ps = aliased(PlexState) return ( - db.query( + select( PlexItem.show_id.label("show_id"), func.count(PlexItem.id).label("total"), func.count(case((ps.status == "watched", 1))).label("watched"), @@ -64,7 +64,7 @@ def agg_subquery(db: Session, user_id: int): ).label("inprog"), ) .outerjoin(ps, and_(ps.item_id == PlexItem.id, ps.user_id == user_id)) - .filter(PlexItem.kind == "episode", PlexItem.show_id.isnot(None)) + .where(PlexItem.kind == "episode", PlexItem.show_id.isnot(None)) .group_by(PlexItem.show_id) ) @@ -72,5 +72,7 @@ def agg_subquery(db: Session, user_id: int): def state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, str]: if not show_ids: return {} - rows = agg_subquery(db, user_id).filter(PlexItem.show_id.in_(show_ids)).all() + rows = db.execute( + agg_subquery(db, user_id).where(PlexItem.show_id.in_(show_ids)) + ).all() return {r.show_id: show_status(r.total, r.watched, r.inprog) for r in rows} diff --git a/backend/app/routes/plex/browse.py b/backend/app/routes/plex/browse.py index 9d0efef1..99a3f20f 100644 --- a/backend/app/routes/plex/browse.py +++ b/backend/app/routes/plex/browse.py @@ -8,7 +8,20 @@ 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 import ( + Integer, + String, + and_, + case, + cast, + delete, + func, + literal, + null, + or_, + select, + update, +) from sqlalchemy.orm import Session, aliased from app import sysconfig @@ -45,20 +58,20 @@ 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.execute( + select(PlexLibrary).order_by(PlexLibrary.kind.desc(), PlexLibrary.title) + ).scalars().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.scalar( + select(func.count(PlexItem.id)).where( + PlexItem.library_id == lib.id, PlexItem.kind == "movie" + ) ) else: - count = ( - db.query(func.count(PlexShow.id)).filter_by(library_id=lib.id).scalar() + count = db.scalar( + select(func.count(PlexShow.id)).where(PlexShow.library_id == lib.id) ) out.append( { @@ -188,7 +201,7 @@ def _meta_filter_conds(model, p: dict) -> list: def _apply_meta_filters(q, model, p: dict): conds = _meta_filter_conds(model, p) - return q.filter(*conds) if conds else q + return q.where(*conds) if conds else q @dataclass @@ -252,7 +265,9 @@ def unified_library( episodes come back in a separate `episodes` list (grouped result — the 'Episodes' section).""" offset = max(0, offset) p = f.as_dict() - libs = db.query(PlexLibrary).filter_by(enabled=True).all() + libs = db.execute( + select(PlexLibrary).where(PlexLibrary.enabled.is_(True)) + ).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"] want_movies = scope in ("movie", "both") and bool(movie_lib_ids) @@ -276,7 +291,7 @@ def unified_library( else_="new", ) mq = ( - db.query( + select( PlexItem.rating_key.label("rk"), literal("movie").label("kind"), PlexItem.title.label("title"), @@ -296,28 +311,28 @@ def unified_library( ).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)) + .where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_lib_ids)) ) if show == "watched": - mq = mq.filter(st.status == "watched") + mq = mq.where(st.status == "watched") elif show == "in_progress": - mq = mq.filter( + mq = mq.where( st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched"), ) elif show == "unwatched": - mq = mq.filter( + mq = mq.where( or_(st.status.is_(None), st.status.notin_(["watched", "hidden"])) ) else: - mq = mq.filter(or_(st.status.is_(None), st.status != "hidden")) + mq = mq.where(or_(st.status.is_(None), st.status != "hidden")) mq = _apply_meta_filters(mq, PlexItem, p) if f.duration_min is not None: - mq = mq.filter(PlexItem.duration_s >= f.duration_min) + mq = mq.where(PlexItem.duration_s >= f.duration_min) if f.duration_max is not None: - mq = mq.filter(PlexItem.duration_s <= f.duration_max) + mq = mq.where(PlexItem.duration_s <= f.duration_max) if tsq is not None: - mq = mq.filter(PlexItem.search_vector.op("@@")(tsq)) + mq = mq.where(PlexItem.search_vector.op("@@")(tsq)) selects.append(mq) if want_shows: agg = rollup.agg_subquery(db, user.id).subquery() @@ -330,7 +345,7 @@ def unified_library( else_="new", ) sq = ( - db.query( + select( PlexShow.rating_key.label("rk"), literal("show").label("kind"), PlexShow.title.label("title"), @@ -350,17 +365,17 @@ def unified_library( ).label("rank"), ) .outerjoin(agg, agg.c.show_id == PlexShow.id) - .filter(PlexShow.library_id.in_(show_lib_ids)) + .where(PlexShow.library_id.in_(show_lib_ids)) ) if show == "watched": - sq = sq.filter(tot > 0, wat >= tot) + sq = sq.where(tot > 0, wat >= tot) elif show == "in_progress": - sq = sq.filter(or_(wat > 0, inp > 0), wat < tot) + sq = sq.where(or_(wat > 0, inp > 0), wat < tot) elif show == "unwatched": - sq = sq.filter(wat == 0, inp == 0) + sq = sq.where(wat == 0, inp == 0) sq = _apply_meta_filters(sq, PlexShow, p) if tsq is not None: - sq = sq.filter(PlexShow.search_vector.op("@@")(tsq)) + sq = sq.where(PlexShow.search_vector.op("@@")(tsq)) selects.append(sq) if not selects: @@ -374,7 +389,7 @@ def unified_library( } 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 + total = db.scalar(select(func.count()).select_from(u)) or 0 if tsq is not None: order = [u.c.rank.desc()] @@ -388,7 +403,9 @@ def unified_library( }.get(sort, u.c.added_at) order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()] order.append(u.c.rk.desc()) - rows = db.query(u).order_by(*order).offset(offset).limit(limit).all() + rows = db.execute( + select(u).order_by(*order).offset(offset).limit(limit) + ).all() items = [] for r in rows: @@ -422,21 +439,20 @@ def unified_library( episodes = [] if tsq is not None and want_shows: ep_st = aliased(PlexState) - eps = ( - db.query(PlexItem, ep_st, PlexShow.title.label("show_title")) + eps = db.execute( + select(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(PlexShow, PlexShow.id == PlexItem.show_id) - .filter( + .where( PlexItem.kind == "episode", PlexItem.library_id.in_(show_lib_ids), PlexItem.search_vector.op("@@")(tsq), ) .order_by(func.ts_rank(PlexItem.search_vector, tsq).desc()) .limit(24) - .all() - ) + ).all() for e, s, show_title in eps: card = _episode_card(e, s) card["show_title"] = show_title @@ -474,7 +490,9 @@ def facets( "duration_min": None, "duration_max": None, } - libs = db.query(PlexLibrary).filter_by(enabled=True).all() + libs = db.execute( + select(PlexLibrary).where(PlexLibrary.enabled.is_(True)) + ).scalars().all() movie_ids = ( [lb.id for lb in libs if lb.kind == "movie"] if scope in ("movie", "both") @@ -492,30 +510,30 @@ def facets( def wsq(query, model, ids, pp, kind_movie, with_dur): """Apply the (self-excluded) metadata filters + library scope + the per-user watch-state to a facet aggregation query, so the facets reflect exactly the visible result set.""" - query = query.filter(model.library_id.in_(ids), *_meta_filter_conds(model, pp)) + query = query.where(model.library_id.in_(ids), *_meta_filter_conds(model, pp)) if kind_movie: - query = query.filter(model.kind == "movie") + query = query.where(model.kind == "movie") if with_dur and pp.get("duration_min") is not None: - query = query.filter(model.duration_s >= pp["duration_min"]) + query = query.where(model.duration_s >= pp["duration_min"]) if with_dur and pp.get("duration_max") is not None: - query = query.filter(model.duration_s <= pp["duration_max"]) + query = query.where(model.duration_s <= pp["duration_max"]) # Always join watch-state and mirror the grid's status branches (unified_library) exactly — # hidden movies never appear in the grid, so they must not inflate facet counts/bounds either. st = aliased(PlexState) query = query.outerjoin(st, and_(st.item_id == model.id, st.user_id == uid)) if show == "watched": - query = query.filter(st.status == "watched") + query = query.where(st.status == "watched") elif show == "in_progress": - query = query.filter( + query = query.where( st.position_seconds > 0, or_(st.status.is_(None), st.status != "watched"), ) elif show == "unwatched": - query = query.filter( + query = query.where( or_(st.status.is_(None), st.status.notin_(["watched", "hidden"])) ) else: - query = query.filter(or_(st.status.is_(None), st.status != "hidden")) + query = query.where(or_(st.status.is_(None), st.status != "hidden")) else: if show in ("watched", "in_progress", "unwatched"): agg = rollup.agg_subquery(db, uid).subquery() @@ -524,11 +542,11 @@ def facets( wat = func.coalesce(agg.c.watched, 0) inp = func.coalesce(agg.c.inprog, 0) if show == "watched": - query = query.filter(tot > 0, wat >= tot) + query = query.where(tot > 0, wat >= tot) elif show == "in_progress": - query = query.filter(or_(wat > 0, inp > 0), wat < tot) + query = query.where(or_(wat > 0, inp > 0), wat < tot) else: - query = query.filter(wat == 0, inp == 0) + query = query.where(wat == 0, inp == 0) return query genre_counts: dict[str, int] = {} @@ -553,66 +571,74 @@ def facets( # 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")), + select(func.jsonb_array_elements_text(model.genres).label("g")), model, ids, genre_pp, kind_movie, True, ) - .filter(func.jsonb_typeof(model.genres) == "array") + .where(func.jsonb_typeof(model.genres) == "array") .subquery() ) - for g, c in db.query(expanded.c.g, func.count()).group_by(expanded.c.g).all(): + for g, c in db.execute( + select(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()), + select(model.content_rating, func.count()), model, ids, {**p, "content_ratings": None}, kind_movie, True, ) - .filter(model.content_rating.isnot(None)) + .where(model.content_rating.isnot(None)) .group_by(model.content_rating) ) - for cr, c in crq.all(): + for cr, c in db.execute(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, + by = db.execute( + wsq( + select(func.min(model.year), func.max(model.year)), + 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 = db.scalar( + wsq( + select(func.max(model.rating)), + model, + ids, + {**p, "rating_min": None}, + kind_movie, + True, + ) + ) 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, + bd = db.execute( + wsq( + select(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]) @@ -682,28 +708,39 @@ def collections( the UNION across all enabled libraries (the unified-scope sidebar picker, which has no single library selected).""" if library is not None: - lib = db.query(PlexLibrary).filter_by(plex_key=str(library)).first() + lib = db.execute( + select(PlexLibrary).where(PlexLibrary.plex_key == str(library)) + ).scalars().first() if lib is None: return {"collections": []} - query = db.query(PlexCollection).filter(PlexCollection.library_id == lib.id) + query = select(PlexCollection).where(PlexCollection.library_id == lib.id) else: - enabled_ids = [lb.id for lb in db.query(PlexLibrary.id).filter_by(enabled=True)] + enabled_ids = list( + db.execute( + select(PlexLibrary.id).where(PlexLibrary.enabled.is_(True)) + ).scalars() + ) if not enabled_ids: return {"collections": []} - query = db.query(PlexCollection).filter( + query = select(PlexCollection).where( PlexCollection.library_id.in_(enabled_ids) ) term = (q or "").strip() if term: - query = query.filter(PlexCollection.title.ilike(f"%{_like_escape(term)}%")) - rows = query.order_by( - PlexCollection.child_count.desc().nullslast(), func.lower(PlexCollection.title) - ).all() + 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 = db.query(PlexCollection).filter_by(rating_key=str(rating_key)).first() + col = db.execute( + select(PlexCollection).where(PlexCollection.rating_key == str(rating_key)) + ).scalars().first() if col is None: raise HTTPException(status_code=404, detail="Unknown collection") if not _collection_editable(col): @@ -731,7 +768,9 @@ def create_collection( raise HTTPException( status_code=400, detail="title and item_rating_key are required" ) - lib = db.query(PlexLibrary).filter_by(plex_key=library).first() + 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: @@ -843,7 +882,9 @@ def collection_set_editable( ) -> 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 = db.query(PlexCollection).filter_by(rating_key=str(rating_key)).first() + col = db.execute( + select(PlexCollection).where(PlexCollection.rating_key == str(rating_key)) + ).scalars().first() if col is None: raise HTTPException(status_code=404, detail="Unknown collection") want = body.editable @@ -870,11 +911,11 @@ def _playlist_card(pl: PlexPlaylist, count: int, thumb_rk: str | None) -> dict: } -def _playlist_item_query(db: Session, pid: int): +def _playlist_item_query(pid: int): return ( - db.query(PlexPlaylistItem, PlexItem) + select(PlexPlaylistItem, PlexItem) .join(PlexItem, PlexItem.id == PlexPlaylistItem.item_id) - .filter(PlexPlaylistItem.playlist_id == pid) + .where(PlexPlaylistItem.playlist_id == pid) .order_by(PlexPlaylistItem.position, PlexPlaylistItem.id) ) @@ -892,42 +933,51 @@ def playlists( group it already holds) + a top-level `group_size`, for the 'add a whole season/show' dialog.""" contains_id = None if contains: - row = db.query(PlexItem.id).filter_by(rating_key=str(contains)).first() - contains_id = row[0] if row else None + 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] - rows = db.query(PlexItem.id).filter(PlexItem.rating_key.in_(rks or [""])).all() - group_ids = {r[0] for r in rows} + group_ids = set( + db.execute( + select(PlexItem.id).where(PlexItem.rating_key.in_(rks or [""])) + ).scalars() + ) out = [] - for pl in ( - db.query(PlexPlaylist) - .filter_by(user_id=user.id) + for pl in db.execute( + select(PlexPlaylist) + .where(PlexPlaylist.user_id == user.id) .order_by(PlexPlaylist.updated_at.desc()) - ): + ).scalars(): count = ( - db.query(func.count(PlexPlaylistItem.id)) - .filter_by(playlist_id=pl.id) - .scalar() + db.scalar( + select(func.count(PlexPlaylistItem.id)).where( + PlexPlaylistItem.playlist_id == pl.id + ) + ) or 0 ) - first = _playlist_item_query(db, pl.id).first() + 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.query(PlexPlaylistItem.id) - .filter_by(playlist_id=pl.id, item_id=contains_id) - .first() + 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.query(func.count(PlexPlaylistItem.id)) - .filter( - PlexPlaylistItem.playlist_id == pl.id, - PlexPlaylistItem.item_id.in_(group_ids or [0]), + db.scalar( + select(func.count(PlexPlaylistItem.id)).where( + PlexPlaylistItem.playlist_id == pl.id, + PlexPlaylistItem.item_id.in_(group_ids or [0]), + ) ) - .scalar() or 0 ) out.append(card) @@ -958,7 +1008,9 @@ def create_playlist( count = 0 thumb_rk = None if seed: - it = db.query(PlexItem).filter_by(rating_key=seed).first() + it = db.execute( + select(PlexItem).where(PlexItem.rating_key == seed) + ).scalars().first() 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 @@ -971,20 +1023,24 @@ 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() + rows = db.execute(_playlist_item_query(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]), - ) + 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.query(PlexShow).filter( - PlexShow.id.in_([it.show_id for it in items if it.show_id] or [0]) - ) + 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( @@ -1016,10 +1072,14 @@ def rename_playlist( pl.title = title db.commit() count = ( - db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pl.id).scalar() + db.scalar( + select(func.count(PlexPlaylistItem.id)).where( + PlexPlaylistItem.playlist_id == pl.id + ) + ) or 0 ) - first = _playlist_item_query(db, pl.id).first() + first = db.execute(_playlist_item_query(pl.id)).first() return _playlist_card(pl, count, first[1].rating_key if first else None) @@ -1045,17 +1105,21 @@ def playlist_add_item( 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() + it = db.execute( + select(PlexItem).where(PlexItem.rating_key == body.item_rating_key) + ).scalars().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.execute( + select(PlexPlaylistItem).where( + PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id == it.id + ) + ).scalars().first() if exists is None: - maxpos = ( - db.query(func.coalesce(func.max(PlexPlaylistItem.position), -1)) - .filter_by(playlist_id=pid) - .scalar() + 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) @@ -1073,9 +1137,15 @@ def playlist_remove_item( 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() + it = db.execute( + select(PlexItem).where(PlexItem.rating_key == str(item_rating_key)) + ).scalars().first() if it is not None: - db.query(PlexPlaylistItem).filter_by(playlist_id=pid, item_id=it.id).delete() + 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} @@ -1092,7 +1162,9 @@ def _items_by_rks(db: Session, rks: list) -> list[PlexItem]: return [] by_rk = { it.rating_key: it - for it in db.query(PlexItem).filter(PlexItem.rating_key.in_(order)) + for it in db.execute( + select(PlexItem).where(PlexItem.rating_key.in_(order)) + ).scalars() } seen: set[str] = set() out: list[PlexItem] = [] @@ -1115,13 +1187,17 @@ def playlist_add_bulk( 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) - } + existing = set( + db.execute( + select(PlexPlaylistItem.item_id).where(PlexPlaylistItem.playlist_id == pid) + ).scalars() + ) pos = int( - db.query(func.coalesce(func.max(PlexPlaylistItem.position), -1)) - .filter_by(playlist_id=pid) - .scalar() + db.scalar( + select(func.coalesce(func.max(PlexPlaylistItem.position), -1)).where( + PlexPlaylistItem.playlist_id == pid + ) + ) ) added = 0 for it in items: @@ -1135,7 +1211,11 @@ def playlist_add_bulk( pl.updated_at = datetime.now(timezone.utc) db.commit() total = ( - db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pid).scalar() + db.scalar( + select(func.count(PlexPlaylistItem.id)).where( + PlexPlaylistItem.playlist_id == pid + ) + ) or 0 ) return {"added": added, "item_count": total} @@ -1154,18 +1234,20 @@ def playlist_remove_bulk( ids = [it.id for it in _items_by_rks(db, body.item_rating_keys)] removed = 0 if ids: - removed = ( - db.query(PlexPlaylistItem) - .filter( + removed = db.execute( + delete(PlexPlaylistItem).where( PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id.in_(ids) ) - .delete(synchronize_session=False) - ) + ).rowcount if removed: pl.updated_at = datetime.now(timezone.utc) db.commit() total = ( - db.query(func.count(PlexPlaylistItem.id)).filter_by(playlist_id=pid).scalar() + db.scalar( + select(func.count(PlexPlaylistItem.id)).where( + PlexPlaylistItem.playlist_id == pid + ) + ) or 0 ) return {"removed": removed, "item_count": total} @@ -1183,13 +1265,19 @@ def reorder_playlist( 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 [""])) + 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.query(PlexPlaylistItem).filter_by(playlist_id=pid, item_id=iid).update( - {"position": pos} + 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() @@ -1244,30 +1332,32 @@ def show_detail( db: Session = Depends(get_db), ) -> dict: """A show's seasons + episodes (the drill-down view) with per-user watch state.""" - sh = db.query(PlexShow).filter_by(rating_key=str(rating_key)).first() + sh = db.execute( + select(PlexShow).where(PlexShow.rating_key == str(rating_key)) + ).scalars().first() if sh is None: raise HTTPException(status_code=404, detail="Unknown Plex show") - seasons = ( - db.query(PlexSeason) - .filter_by(show_id=sh.id) + seasons = db.execute( + select(PlexSeason) + .where(PlexSeason.show_id == sh.id) .order_by(PlexSeason.season_number.asc().nullsfirst()) - .all() - ) - eps = ( - db.query(PlexItem) - .filter_by(show_id=sh.id, kind="episode") + ).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(), ) - .all() - ) + ).scalars().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]), - ) + 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[ @@ -1362,9 +1452,11 @@ def _related_show_cards( 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.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] = [] @@ -1436,37 +1528,41 @@ def people_cards( wanted = _csv(names)[:16] if not wanted: return {"people": []} - libs = db.query(PlexLibrary).filter_by(enabled=True).all() + libs = db.execute( + select(PlexLibrary).where(PlexLibrary.enabled.is_(True)) + ).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"] 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)) + 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.query(func.count(PlexItem.id)) - .filter( - PlexItem.kind == "movie", - PlexItem.library_id.in_(movie_lib_ids), - PlexItem.cast_names.contains([nm]), + db.scalar( + select(func.count(PlexItem.id)).where( + PlexItem.kind == "movie", + PlexItem.library_id.in_(movie_lib_ids), + PlexItem.cast_names.contains([nm]), + ) ) - .scalar() or 0 ) if want_shows: count += ( - db.query(func.count(PlexShow.id)) - .filter( - PlexShow.library_id.in_(show_lib_ids), - PlexShow.cast_names.contains([nm]), + db.scalar( + select(func.count(PlexShow.id)).where( + 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}) @@ -1493,7 +1589,9 @@ def _collection_strips(db: Session, it: PlexItem, user_id: int) -> list[dict]: return [] cols = { c.rating_key: c - for c in db.query(PlexCollection).filter(PlexCollection.rating_key.in_(keys)) + 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( @@ -1502,23 +1600,24 @@ def _collection_strips(db: Session, it: PlexItem, user_id: int) -> list[dict]: out = [] for col in ordered: rk = col.rating_key - members = ( - db.query(PlexItem) - .filter( + 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) - .all() - ) + ).scalars().all() ids = [m.id for m in members] states = { s.item_id: s - for s in db.query(PlexState).filter( - PlexState.user_id == user_id, PlexState.item_id.in_(ids or [0]) - ) + for s in db.execute( + select(PlexState).where( + PlexState.user_id == user_id, PlexState.item_id.in_(ids or [0]) + ) + ).scalars() } out.append( { @@ -1536,11 +1635,13 @@ def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]: return None, None keys = [ 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(), + for r in 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(), + ) ) .all() ] @@ -1562,7 +1663,11 @@ def item_detail( """Everything the player needs: metadata, cast, intro/credit markers (from Plex), episode prev/next, and the per-user resume position + watch status.""" it = item_or_404(db, rating_key) - st = db.query(PlexState).filter_by(user_id=user.id, item_id=it.id).first() + st = db.execute( + select(PlexState).where( + PlexState.user_id == user.id, PlexState.item_id == it.id + ) + ).scalars().first() rich: dict = {} markers: list[dict] = [] From e008c482848a1dafe8aac02fcb4eb4474af62e2e Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 16 Aug 2026 07:06:19 +0200 Subject: [PATCH 04/32] fix(plex): address S1 code-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore synchronize_session=False on the remove-bulk Core delete (the 2.0 default is 'auto'; keep the pre-conversion contract explicit). - _episode_nav: list(...scalars()) instead of [r[0] for r in ...all()], matching the house style used across the conversion. - Extract app/plex/lookups.by_rating_key() and migrate the get-by-rating_key pattern at 13 sites (imgcache x4, _shared.item_or_404, playback show/season, browse x6). - Coverage: add TestFacetsWithData (the converted wsq aggregation with real data — the highest-risk untested builder), TestPlaylistAndCollectionEndpoints (playlist CRUD + collections list), and an item-state playback test. Full suite 256 passed, 2 skipped; ruff clean. --- backend/app/plex/imgcache.py | 18 +-- backend/app/plex/lookups.py | 11 ++ backend/app/routes/plex/_shared.py | 6 +- backend/app/routes/plex/browse.py | 38 ++---- backend/app/routes/plex/playback.py | 9 +- backend/tests/test_plex_query_conversion.py | 134 +++++++++++++++++++- 6 files changed, 166 insertions(+), 50 deletions(-) create mode 100644 backend/app/plex/lookups.py diff --git a/backend/app/plex/imgcache.py b/backend/app/plex/imgcache.py index 215e18ea..01a4a50d 100644 --- a/backend/app/plex/imgcache.py +++ b/backend/app/plex/imgcache.py @@ -9,11 +9,11 @@ The cache directory (``cache.CACHE_DIR``) is shared with the subtitle cache (see """ from fastapi import Response -from sqlalchemy import select from sqlalchemy.orm import Session from app.models import PlexCollection, PlexItem, PlexSeason, PlexShow from app.plex.cache import CACHE_DIR +from app.plex.lookups import by_rating_key _EXT_CT = [("jpg", "image/jpeg"), ("png", "image/png"), ("webp", "image/webp")] _CT_EXT = {ct: ext for ext, ct in _EXT_CT} @@ -25,24 +25,16 @@ SIZES = {"thumb": (400, 600), "art": (1280, 720)} def key_for(db: Session, rating_key: str, variant: str) -> str | None: """The stored Plex image path for a known rating_key (item/show/season). Only mirrored keys are proxyable — this is not an open image proxy.""" - it = db.execute( - select(PlexItem).where(PlexItem.rating_key == rating_key) - ).scalars().first() + it = by_rating_key(db, PlexItem, rating_key) if it is not None: return it.art_key if variant == "art" else it.thumb_key - sh = db.execute( - select(PlexShow).where(PlexShow.rating_key == rating_key) - ).scalars().first() + sh = by_rating_key(db, PlexShow, rating_key) if sh is not None: return sh.art_key if variant == "art" else sh.thumb_key - se = db.execute( - select(PlexSeason).where(PlexSeason.rating_key == rating_key) - ).scalars().first() + se = by_rating_key(db, PlexSeason, rating_key) if se is not None: return se.thumb_key - col = db.execute( - select(PlexCollection).where(PlexCollection.rating_key == rating_key) - ).scalars().first() + col = by_rating_key(db, PlexCollection, rating_key) if col is not None: return col.thumb_key return None diff --git a/backend/app/plex/lookups.py b/backend/app/plex/lookups.py new file mode 100644 index 00000000..fceba665 --- /dev/null +++ b/backend/app/plex/lookups.py @@ -0,0 +1,11 @@ +"""Small shared Plex row lookups, so the get-by-rating_key pattern lives in one place.""" +from sqlalchemy import select +from sqlalchemy.orm import Session + + +def by_rating_key(db: Session, model, rating_key: str): + """The mirrored row for a Plex rating_key, or None. `model` is any of the rating_key-keyed Plex + tables (PlexItem / PlexShow / PlexSeason / PlexCollection).""" + return db.execute( + select(model).where(model.rating_key == str(rating_key)) + ).scalars().first() diff --git a/backend/app/routes/plex/_shared.py b/backend/app/routes/plex/_shared.py index a35e216e..a5ecd2b4 100644 --- a/backend/app/routes/plex/_shared.py +++ b/backend/app/routes/plex/_shared.py @@ -3,20 +3,18 @@ import logging from fastapi import HTTPException -from sqlalchemy import select from sqlalchemy.orm import Session from app.models import ( PlexItem, ) +from app.plex.lookups import by_rating_key log = logging.getLogger("siftlode.plex") def item_or_404(db: Session, rating_key: str) -> PlexItem: - it = db.execute( - select(PlexItem).where(PlexItem.rating_key == str(rating_key)) - ).scalars().first() + it = by_rating_key(db, PlexItem, rating_key) if it is None: raise HTTPException(status_code=404, detail="Unknown Plex item") return it diff --git a/backend/app/routes/plex/browse.py b/backend/app/routes/plex/browse.py index 99a3f20f..1c13380a 100644 --- a/backend/app/routes/plex/browse.py +++ b/backend/app/routes/plex/browse.py @@ -43,6 +43,7 @@ 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.lookups import by_rating_key from app.routes._common import owned_or_404 from app.routes.admin import admin_user from app.routes.feed import to_tsquery_str @@ -738,9 +739,7 @@ def collections( def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection: - col = db.execute( - select(PlexCollection).where(PlexCollection.rating_key == str(rating_key)) - ).scalars().first() + 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): @@ -882,9 +881,7 @@ def collection_set_editable( ) -> 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 = db.execute( - select(PlexCollection).where(PlexCollection.rating_key == str(rating_key)) - ).scalars().first() + col = by_rating_key(db, PlexCollection, rating_key) if col is None: raise HTTPException(status_code=404, detail="Unknown collection") want = body.editable @@ -1008,9 +1005,7 @@ def create_playlist( count = 0 thumb_rk = None if seed: - it = db.execute( - select(PlexItem).where(PlexItem.rating_key == seed) - ).scalars().first() + 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 @@ -1105,9 +1100,7 @@ def playlist_add_item( db: Session = Depends(get_db), ) -> dict: pl = _own_playlist_or_404(db, pid, user) - it = db.execute( - select(PlexItem).where(PlexItem.rating_key == body.item_rating_key) - ).scalars().first() + 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( @@ -1137,9 +1130,7 @@ def playlist_remove_item( db: Session = Depends(get_db), ) -> dict: pl = _own_playlist_or_404(db, pid, user) - it = db.execute( - select(PlexItem).where(PlexItem.rating_key == str(item_rating_key)) - ).scalars().first() + it = by_rating_key(db, PlexItem, item_rating_key) if it is not None: db.execute( delete(PlexPlaylistItem).where( @@ -1237,7 +1228,8 @@ def playlist_remove_bulk( 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) @@ -1332,9 +1324,7 @@ def show_detail( db: Session = Depends(get_db), ) -> dict: """A show's seasons + episodes (the drill-down view) with per-user watch state.""" - sh = db.execute( - select(PlexShow).where(PlexShow.rating_key == str(rating_key)) - ).scalars().first() + sh = by_rating_key(db, PlexShow, rating_key) if sh is None: raise HTTPException(status_code=404, detail="Unknown Plex show") seasons = db.execute( @@ -1633,18 +1623,16 @@ def _collection_strips(db: Session, it: PlexItem, user_id: int) -> list[dict]: 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 = [ - r[0] - for r in db.execute( + 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(), ) - ) - .all() - ] + ).scalars() + ) try: i = keys.index(it.rating_key) except ValueError: diff --git a/backend/app/routes/plex/playback.py b/backend/app/routes/plex/playback.py index fed33581..58eafb3b 100644 --- a/backend/app/routes/plex/playback.py +++ b/backend/app/routes/plex/playback.py @@ -30,6 +30,7 @@ from app.models import ( ) from app.plex import imgcache, subtitles from app.plex import paths as plex_paths +from app.plex.lookups import by_rating_key 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 @@ -412,9 +413,7 @@ def show_state( db: Session = Depends(get_db), ) -> dict: """Mark a whole show watched/unwatched (all its episodes) for the current user + push to Plex.""" - sh = db.execute( - select(PlexShow).where(PlexShow.rating_key == str(rating_key)) - ).scalars().first() + sh = by_rating_key(db, PlexShow, rating_key) if sh is None: raise HTTPException(status_code=404, detail="Unknown Plex show") watched = body.watched @@ -434,9 +433,7 @@ def season_state( db: Session = Depends(get_db), ) -> dict: """Mark a whole season watched/unwatched (all its episodes) for the current user + push to Plex.""" - se = db.execute( - select(PlexSeason).where(PlexSeason.rating_key == str(rating_key)) - ).scalars().first() + se = by_rating_key(db, PlexSeason, rating_key) if se is None: raise HTTPException(status_code=404, detail="Unknown Plex season") watched = body.watched diff --git a/backend/tests/test_plex_query_conversion.py b/backend/tests/test_plex_query_conversion.py index 014bfe7a..d4b2ee98 100644 --- a/backend/tests/test_plex_query_conversion.py +++ b/backend/tests/test_plex_query_conversion.py @@ -57,16 +57,21 @@ def _seed(db): return {"user": user, "movie_lib": movie_lib, "show_lib": show_lib, "show": show} -def _get(db, user, monkeypatch, path): +def _request(db, user, monkeypatch, method, path, json=None): monkeypatch.setattr(state, "_configured_cache", True) # the request gate 503s an unconfigured app app.dependency_overrides[get_db] = lambda: db app.dependency_overrides[current_user] = lambda: user try: - return TestClient(app).get(path) # no context manager: skip lifespan (scheduler/HLS sweep) + client = TestClient(app) # no context manager: skip lifespan (scheduler/HLS sweep) + return client.request(method, path, json=json) finally: app.dependency_overrides.clear() +def _get(db, user, monkeypatch, path): + return _request(db, user, monkeypatch, "GET", path) + + class TestUnifiedLibrary: def test_both_scope_unions_movies_and_shows_ordered_by_added(self, db, monkeypatch): s = _seed(db) @@ -197,3 +202,128 @@ class TestSyncUpsertIdempotency: assert ep.rating_key == "e1" assert ep.show_id == shows[0].id # episode linked to its show assert (ep.season_number, ep.episode_number) == (1, 1) + + +class TestFacetsWithData: + """The facets endpoint's wsq builder is the most complex converted query (genre/content-rating + JSONB unnest + year/rating/duration bounds + the watch-state join). The existing smoke test only + covers the null-genre guard; this locks the aggregated numbers with real data.""" + + def test_facet_aggregation(self, db, monkeypatch): + movie_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + show_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) + db.add_all([movie_lib, show_lib]) + db.flush() + db.add_all([ + PlexItem(rating_key="m1", library_id=movie_lib.id, kind="movie", title="M1", + genres=["Comedy", "Drama"], content_rating="PG", year=2000, rating=7.5, + duration_s=6000), + PlexItem(rating_key="m2", library_id=movie_lib.id, kind="movie", title="M2", + genres=["Comedy"], content_rating="R", year=2010, rating=8.0, duration_s=7200), + PlexShow(rating_key="s1", library_id=show_lib.id, title="S1", + genres=["Drama"], content_rating="TV-14", year=2005, rating=9.0), + ]) + user = User(email="facetdata@example.com") + db.add(user) + db.commit() + + body = _get(db, user, monkeypatch, "/api/plex/facets?scope=both").json() + assert {g["value"]: g["count"] for g in body["genres"]} == {"Comedy": 2, "Drama": 2} + assert {c["value"]: c["count"] for c in body["content_ratings"]} == { + "PG": 1, "R": 1, "TV-14": 1 + } + assert (body["year_min"], body["year_max"]) == (2000, 2010) + assert body["rating_max"] == 9.0 + # Duration bounds are movie-only. + assert (body["duration_min"], body["duration_max"]) == (6000, 7200) + + +class TestPlaylistAndCollectionEndpoints: + """Smoke coverage for the converted CRUD endpoints that had no tests: playlist create/list/detail/ + add/reorder/remove/delete, and the collections list. A 1.x->2.0 conversion slip in any of these + (a lost .scalars(), a wrong .all(), a broken order_by) surfaces here instead of shipping green.""" + + def _movie(self, db, lib_id, rk, title): + return PlexItem(rating_key=rk, library_id=lib_id, kind="movie", title=title) + + def test_playlist_crud_roundtrip(self, db, monkeypatch): + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + db.add_all([self._movie(db, lib.id, "m1", "First"), self._movie(db, lib.id, "m2", "Second")]) + user = User(email="pl@example.com") + db.add(user) + db.commit() + + # create (seeded with m1) -> item_count 1 + created = _request(db, user, monkeypatch, "POST", "/api/plex/playlists", + {"title": "Faves", "item_rating_key": "m1"}) + assert created.status_code == 200, created.text + pid = created.json()["id"] + assert created.json()["item_count"] == 1 + + # list -> the playlist with its count + listed = _get(db, user, monkeypatch, "/api/plex/playlists").json()["playlists"] + assert [(p["id"], p["item_count"]) for p in listed] == [(pid, 1)] + + # add m2, then reorder to [m2, m1] + add = _request(db, user, monkeypatch, "POST", f"/api/plex/playlists/{pid}/items", + {"item_rating_key": "m2"}) + assert add.status_code == 200, add.text + _request(db, user, monkeypatch, "PUT", f"/api/plex/playlists/{pid}/order", + {"item_rating_keys": ["m2", "m1"]}) + detail = _get(db, user, monkeypatch, f"/api/plex/playlists/{pid}").json() + assert [it["id"] for it in detail["items"]] == ["m2", "m1"] # reorder applied, in order + + # remove m1 -> one item left + rm = _request(db, user, monkeypatch, "DELETE", f"/api/plex/playlists/{pid}/items/m1") + assert rm.status_code == 200, rm.text + after = _get(db, user, monkeypatch, f"/api/plex/playlists/{pid}").json() + assert [it["id"] for it in after["items"]] == ["m2"] + + # delete the playlist -> gone from the list + _request(db, user, monkeypatch, "DELETE", f"/api/plex/playlists/{pid}") + assert _get(db, user, monkeypatch, "/api/plex/playlists").json()["playlists"] == [] + + def test_collections_list(self, db, monkeypatch): + from app.models import PlexCollection + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + db.add_all([ + PlexCollection(rating_key="c1", library_id=lib.id, title="Marvel", child_count=12), + PlexCollection(rating_key="c2", library_id=lib.id, title="Pixar", child_count=5), + ]) + user = User(email="col@example.com") + db.add(user) + db.commit() + + # unified scope (no library param) -> most-populated first + body = _get(db, user, monkeypatch, "/api/plex/collections").json() + assert [c["title"] for c in body["collections"]] == ["Marvel", "Pixar"] + # name filter + filtered = _get(db, user, monkeypatch, "/api/plex/collections?q=pix").json() + assert [c["title"] for c in filtered["collections"]] == ["Pixar"] + + def test_item_state_watched_then_new(self, db, monkeypatch): + # No PlexLink -> the background Plex push is a no-op; only the local PlexState path runs, which + # is what the conversion touched (the st lookup + the delete on ->new). + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + db.add(PlexItem(rating_key="m1", library_id=lib.id, kind="movie", title="M1")) + user = User(email="state@example.com") + db.add(user) + db.commit() + + watched = _request(db, user, monkeypatch, "POST", "/api/plex/item/m1/state", + {"status": "watched"}) + assert watched.status_code == 200, watched.text + assert watched.json()["status"] == "watched" + row = db.execute(select(PlexState).where(PlexState.user_id == user.id)).scalars().one() + assert row.status == "watched" + + # -> new deletes the state row + back = _request(db, user, monkeypatch, "POST", "/api/plex/item/m1/state", {"status": "new"}) + assert back.status_code == 200, back.text + assert db.execute(select(PlexState).where(PlexState.user_id == user.id)).scalars().first() is None From 73a58674cf06765c8c5f735b99bc36d2a6417f91 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 16 Aug 2026 07:14:09 +0200 Subject: [PATCH 05/32] fix(plex): address S1 review round-2 findings - by_rating_key: generic-typed (type[_M] -> _M | None) so call sites keep their Model | None static type. - Migrate the last 2 open-coded PlexCollection-by-rating_key lookups (sync.py resync_collection + delete_collection_local) to by_rating_key; zero open-coded rating_key lookups now remain in the package. - Add test_playlist_bulk_add_and_remove covering the bulk add/remove endpoints and pinning the remove-bulk synchronize_session=False path. Full suite 258 passed, 2 skipped; ruff clean. --- backend/app/plex/lookups.py | 9 +++++-- backend/app/plex/sync.py | 13 +++------- backend/tests/test_plex_query_conversion.py | 28 +++++++++++++++++++++ 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/backend/app/plex/lookups.py b/backend/app/plex/lookups.py index fceba665..577f0ba6 100644 --- a/backend/app/plex/lookups.py +++ b/backend/app/plex/lookups.py @@ -1,11 +1,16 @@ """Small shared Plex row lookups, so the get-by-rating_key pattern lives in one place.""" +from typing import TypeVar + from sqlalchemy import select from sqlalchemy.orm import Session +_M = TypeVar("_M") -def by_rating_key(db: Session, model, rating_key: str): + +def by_rating_key(db: Session, model: type[_M], rating_key: str) -> _M | None: """The mirrored row for a Plex rating_key, or None. `model` is any of the rating_key-keyed Plex - tables (PlexItem / PlexShow / PlexSeason / PlexCollection).""" + tables (PlexItem / PlexShow / PlexSeason / PlexCollection); the return type is that model, so call + sites keep their `Model | None` static type.""" return db.execute( select(model).where(model.rating_key == str(rating_key)) ).scalars().first() diff --git a/backend/app/plex/sync.py b/backend/app/plex/sync.py index cdfa0711..f626f7a0 100644 --- a/backend/app/plex/sync.py +++ b/backend/app/plex/sync.py @@ -19,6 +19,7 @@ from app import sysconfig from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow from app.plex import people from app.plex.client import PlexClient, PlexError, PlexNotConfigured +from app.plex.lookups import by_rating_key log = logging.getLogger("siftlode.plex") @@ -382,13 +383,7 @@ def resync_collection(db: Session, plex: PlexClient, lib: PlexLibrary, rk: str) collection's membership on the affected member rows.""" meta = plex.metadata(rk) or {} col = _upsert_collection( - db, - db.execute( - select(PlexCollection).where(PlexCollection.rating_key == rk) - ).scalars().first(), - rk, - lib.id, - meta, + db, by_rating_key(db, PlexCollection, rk), rk, lib.id, meta ) Model = PlexItem if lib.kind == "movie" else PlexShow new_members = {str(c.get("ratingKey")) for c in plex.collection_children(rk) if c.get("ratingKey")} @@ -415,9 +410,7 @@ def delete_collection_local(db: Session, lib: PlexLibrary, rk: str) -> None: select(Model).where(Model.collection_keys.contains([rk])) ).scalars(): row.collection_keys = [k for k in (row.collection_keys or []) if k != rk] or None - col = db.execute( - select(PlexCollection).where(PlexCollection.rating_key == rk) - ).scalars().first() + col = by_rating_key(db, PlexCollection, rk) if col is not None: db.delete(col) db.flush() diff --git a/backend/tests/test_plex_query_conversion.py b/backend/tests/test_plex_query_conversion.py index d4b2ee98..d11392b9 100644 --- a/backend/tests/test_plex_query_conversion.py +++ b/backend/tests/test_plex_query_conversion.py @@ -285,6 +285,34 @@ class TestPlaylistAndCollectionEndpoints: _request(db, user, monkeypatch, "DELETE", f"/api/plex/playlists/{pid}") assert _get(db, user, monkeypatch, "/api/plex/playlists").json()["playlists"] == [] + def test_playlist_bulk_add_and_remove(self, db, monkeypatch): + # Covers the bulk endpoints (add-bulk / remove-bulk) and pins the remove-bulk delete's + # synchronize_session=False path, which the single add/remove roundtrip never exercises. + lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + db.add(lib) + db.flush() + db.add_all([self._movie(db, lib.id, rk, rk.upper()) for rk in ("m1", "m2", "m3")]) + user = User(email="bulk@example.com") + db.add(user) + db.commit() + + created = _request(db, user, monkeypatch, "POST", "/api/plex/playlists", {"title": "Bulk"}) + pid = created.json()["id"] + + added = _request(db, user, monkeypatch, "POST", f"/api/plex/playlists/{pid}/items/bulk", + {"item_rating_keys": ["m1", "m2", "m3"]}) + assert added.status_code == 200, added.text + assert added.json() == {"added": 3, "item_count": 3} + + removed = _request(db, user, monkeypatch, "POST", + f"/api/plex/playlists/{pid}/items/remove-bulk", + {"item_rating_keys": ["m2"]}) + assert removed.status_code == 200, removed.text + assert removed.json() == {"removed": 1, "item_count": 2} + + detail = _get(db, user, monkeypatch, f"/api/plex/playlists/{pid}").json() + assert [it["id"] for it in detail["items"]] == ["m1", "m3"] # input order preserved, m2 gone + def test_collections_list(self, db, monkeypatch): from app.models import PlexCollection lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) From f9681deecfe9e678b38df0a0fe52718784393a78 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 16 Aug 2026 08:05:18 +0200 Subject: [PATCH 06/32] feat(plex): field-scoped search (S2) + instant search typeahead Add a search scope-pill to the Plex header search box (GitHub-style, one field-scope at a time) and an instant typeahead of the top matches below it. Backend (unified_library): - New `field` param: all | title | people | summary | episode. `all` is byte-identical to the pre-scope behaviour (untouched weighted search_vector); a specific field keeps the GIN `search_vector @@ tsq` prefilter AND ANDs a per-column tsvector recheck, so scoped hits are a strict subset of Everywhere. Per-field expressions mirror the search_vector Computed def (same TS config, same left(summary,1000) truncation). - `episode` scope empties the movie/show grid and title-only-matches the grouped Episodes section; the title/people/summary scopes suppress that section. - Unknown field falls back to `all`. New DB-lane characterization tests (test_plex_field_scoped_search.py): each scope narrows correctly, field=all is identical to today, episode empties the grid and needs shows in scope. Frontend: - PlexSearchBox wraps the shared SearchBar with the scope-pill (reusing ActionMenu) and the typeahead; SearchBar gains generic leading/below/focus/ keydown slots so other modules' search boxes are untouched. - Episode scope offered only when shows are in scope (movie-only hides it), healed at the point of use like the sort state. - Typeahead reuses /api/plex/library (limit=10), honours scope+field (not the sidebar filters), shows poster+title+year, keyboard nav (arrows/Enter/Esc), and jumps straight to a movie's info page or a show's drill-down via a new provider jumpTo hand-off (mirrors the playlistOpen pattern). - `field` persisted per-account; en/hu labels added. --- backend/app/routes/plex/browse.py | 162 +++++++++++------- .../tests/test_plex_field_scoped_search.py | 132 ++++++++++++++ frontend/src/components/Header.tsx | 13 +- frontend/src/components/PlexBrowse.tsx | 24 ++- frontend/src/components/PlexProvider.tsx | 35 +++- frontend/src/components/PlexSearchBox.tsx | 150 ++++++++++++++++ frontend/src/components/SearchBar.tsx | 92 ++++++---- frontend/src/i18n/locales/en/plex.json | 11 ++ frontend/src/i18n/locales/hu/plex.json | 11 ++ frontend/src/lib/api/plex.ts | 3 + frontend/src/lib/plexSearch.ts | 34 ++++ frontend/src/lib/queryKeys.ts | 1 + frontend/src/lib/storage.ts | 1 + 13 files changed, 555 insertions(+), 114 deletions(-) create mode 100644 backend/tests/test_plex_field_scoped_search.py create mode 100644 frontend/src/components/PlexSearchBox.tsx create mode 100644 frontend/src/lib/plexSearch.ts diff --git a/backend/app/routes/plex/browse.py b/backend/app/routes/plex/browse.py index 1c13380a..10733c9d 100644 --- a/backend/app/routes/plex/browse.py +++ b/backend/app/routes/plex/browse.py @@ -53,6 +53,30 @@ router = APIRouter(prefix="/api/plex", tags=["plex"]) _TS_CONFIG = "public.unaccent_simple" +# Field-scoped search: the scope-pill in the header search box narrows a full-text query to ONE +# facet of the row. `all` uses the GIN-indexed weighted `search_vector` untouched (byte-identical to +# the pre-scope behaviour); a specific field keeps that GIN prefilter AND re-checks a per-column +# tsvector, so the scoped hits are always a strict subset of Everywhere. The per-field expressions +# mirror the `search_vector` Computed definition (same `_TS_CONFIG`, same `left(summary, 1000)` +# truncation) so scoping never surfaces a match the vector itself wouldn't. `episode` is handled +# separately in the query body (it targets episode rows, not the movie/show grid). +_FIELD_COLUMNS = ("title", "people", "summary", "episode") + + +def _field_recheck(model, field: str, tsq): + """A per-field `to_tsvector(...) @@ tsq` clause for a non-`all`, non-`episode` scope, or None.""" + if field == "title": + expr = func.to_tsvector(_TS_CONFIG, func.coalesce(model.title, "")) + elif field == "people": + expr = func.to_tsvector(_TS_CONFIG, func.coalesce(model.people_text, "")) + elif field == "summary": + expr = func.to_tsvector( + _TS_CONFIG, func.left(func.coalesce(model.summary, ""), 1000) + ) + else: + return None + return expr.op("@@")(tsq) + @router.get("/libraries") def list_libraries( @@ -251,6 +275,7 @@ class LibraryFilters: def unified_library( scope: str = "both", q: str | None = None, + field: str = "all", sort: str = "added", sort_dir: str = "desc", show: str = "all", @@ -276,9 +301,17 @@ def unified_library( ts = to_tsquery_str(q) if q else None tsq = func.to_tsquery(_TS_CONFIG, ts) if ts else None + if field not in _FIELD_COLUMNS and field != "all": + field = "all" + # `episode` scope targets episode rows only — the movie/show grid stays empty; the recheck for + # the specific title/people/summary fields is a strict narrowing on top of the GIN prefilter. + grid_field = field != "episode" + recheck = ( + _field_recheck if tsq is not None and field not in ("all", "episode") else None + ) selects = [] - if want_movies: + if want_movies and grid_field: st = aliased(PlexState) m_status = case( (st.status == "watched", "watched"), @@ -334,8 +367,10 @@ def unified_library( mq = mq.where(PlexItem.duration_s <= f.duration_max) if tsq is not None: mq = mq.where(PlexItem.search_vector.op("@@")(tsq)) + if recheck is not None: + mq = mq.where(recheck(PlexItem, field, tsq)) selects.append(mq) - if want_shows: + if want_shows and grid_field: agg = rollup.agg_subquery(db, user.id).subquery() tot = func.coalesce(agg.c.total, 0) wat = func.coalesce(agg.c.watched, 0) @@ -377,69 +412,74 @@ def unified_library( sq = _apply_meta_filters(sq, PlexShow, p) if tsq is not None: sq = sq.where(PlexShow.search_vector.op("@@")(tsq)) + if recheck is not None: + sq = sq.where(recheck(PlexShow, field, tsq)) selects.append(sq) - if not selects: - 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.scalar(select(func.count()).select_from(u)) or 0 - - if tsq is not None: - order = [u.c.rank.desc()] - else: - col = { - "title": func.lower(u.c.title), - "year": u.c.year, - "rating": u.c.rating, - "duration": u.c.duration_s, - "release": u.c.rel, - }.get(sort, u.c.added_at) - order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()] - order.append(u.c.rk.desc()) - rows = db.execute( - select(u).order_by(*order).offset(offset).limit(limit) - ).all() - 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, - } - ) - 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, - } - ) + total = 0 + if selects: + union = ( + selects[0] if len(selects) == 1 else selects[0].union_all(*selects[1:]) + ) + u = union.subquery() + total = db.scalar(select(func.count()).select_from(u)) or 0 + if tsq is not None: + order = [u.c.rank.desc()] + else: + col = { + "title": func.lower(u.c.title), + "year": u.c.year, + "rating": u.c.rating, + "duration": u.c.duration_s, + "release": u.c.rel, + }.get(sort, u.c.added_at) + order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()] + order.append(u.c.rk.desc()) + rows = db.execute( + select(u).order_by(*order).offset(offset).limit(limit) + ).all() + + 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, + } + ) + 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, + } + ) + + # Episode "grouped result" section: shown for Everywhere (full-vector match, today's behaviour) + # and for the Episode scope (title-only match, empty item grid). The title/people/summary scopes + # target the movie/show grid, so they suppress episodes. episodes = [] - if tsq is not None and want_shows: + if tsq is not None and want_shows and field in ("all", "episode"): ep_st = aliased(PlexState) + ep_vec = ( + func.to_tsvector(_TS_CONFIG, func.coalesce(PlexItem.title, "")) + if field == "episode" + else PlexItem.search_vector + ) eps = db.execute( select(PlexItem, ep_st, PlexShow.title.label("show_title")) .outerjoin( @@ -449,9 +489,9 @@ def unified_library( .where( PlexItem.kind == "episode", PlexItem.library_id.in_(show_lib_ids), - PlexItem.search_vector.op("@@")(tsq), + ep_vec.op("@@")(tsq), ) - .order_by(func.ts_rank(PlexItem.search_vector, tsq).desc()) + .order_by(func.ts_rank(ep_vec, tsq).desc()) .limit(24) ).all() for e, s, show_title in eps: diff --git a/backend/tests/test_plex_field_scoped_search.py b/backend/tests/test_plex_field_scoped_search.py new file mode 100644 index 00000000..20da9425 --- /dev/null +++ b/backend/tests/test_plex_field_scoped_search.py @@ -0,0 +1,132 @@ +"""S2 · field-scoped search — the `field` param on /api/plex/library. + +The search box's scope-pill narrows a full-text query to ONE facet of the row: Everywhere (`all`, +the untouched weighted `search_vector`), Titles, Cast & crew (`people`), Descriptions (`summary`), +or Episodes (episode rows only, title-only). These lock that each scope is a strict narrowing of +Everywhere, that `all` is unchanged from the pre-scope behaviour, and that the Episode scope empties +the movie/show grid and only applies where shows are in scope. DB lane (`db` fixture) — skipped +without Postgres (the `search_vector` GIN + per-field `to_tsvector` recheck are real Postgres FTS). +""" +from datetime import datetime, timezone + +import app.state as state +from app.auth import current_user +from app.db import get_db +from app.main import app +from app.models import ( + PlexItem, + PlexLibrary, + PlexSeason, + PlexShow, + User, +) +from fastapi.testclient import TestClient + + +def _dt(day: int) -> datetime: + return datetime(2024, 2, day, tzinfo=timezone.utc) + + +def _seed(db): + """One rare token, `zephyr`, planted in exactly one facet per row so a scope's narrowing is + unambiguous: mt has it ONLY in the title, mp ONLY in cast (people_text), ms ONLY in the summary, + and episode ep ONLY in its title (its show carries none). Everywhere matches all four; each + scoped field matches exactly its one row.""" + movie_lib = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True) + show_lib = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True) + db.add_all([movie_lib, show_lib]) + db.flush() + mt = PlexItem(rating_key="mt", library_id=movie_lib.id, kind="movie", + title="Zephyr Rising", people_text="Alice Stone", summary="A calm coastal day.", + year=2001, added_at=_dt(1)) + mp = PlexItem(rating_key="mp", library_id=movie_lib.id, kind="movie", + title="Quiet Harbor", people_text="Zephyr Vance", summary="A calm coastal day.", + year=2002, added_at=_dt(2)) + ms = PlexItem(rating_key="ms", library_id=movie_lib.id, kind="movie", + title="Quiet Harbor Two", people_text="Alice Stone", + summary="A story about zephyr winds.", year=2003, added_at=_dt(3)) + show = PlexShow(rating_key="s1", library_id=show_lib.id, title="Nimbus Show", + summary="Weather anthology.", people_text="Bob Cloud", + year=2004, added_at=_dt(4), child_count=1) + db.add_all([mt, mp, ms, show]) + db.flush() + season = PlexSeason(rating_key="se1", show_id=show.id, season_number=1) + db.add(season) + db.flush() + ep = PlexItem(rating_key="ep", library_id=show_lib.id, kind="episode", title="Zephyr Episode", + show_id=show.id, season_id=season.id, season_number=1, episode_number=1, + added_at=_dt(5)) + db.add(ep) + user = User(email="plexfield@example.com") + db.add(user) + db.commit() + return {"user": user} + + +def _get(db, user, monkeypatch, path): + monkeypatch.setattr(state, "_configured_cache", True) + app.dependency_overrides[get_db] = lambda: db + app.dependency_overrides[current_user] = lambda: user + try: + return TestClient(app).get(path) + finally: + app.dependency_overrides.clear() + + +def _ids(body): + return sorted(i["id"] for i in body["items"]) + + +class TestFieldScopedSearch: + def test_everywhere_matches_every_facet(self, db, monkeypatch): + s = _seed(db) + body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=all").json() + # title (mt), cast (mp), summary (ms) all land in the item grid; the episode is grouped out + assert _ids(body) == ["mp", "ms", "mt"] + assert [e["title"] for e in body["episodes"]] == ["Zephyr Episode"] + + def test_field_all_is_identical_to_default(self, db, monkeypatch): + s = _seed(db) + default = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr").json() + scoped = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=all").json() + assert _ids(default) == _ids(scoped) == ["mp", "ms", "mt"] + assert default["total"] == scoped["total"] + assert [e["id"] for e in default["episodes"]] == [e["id"] for e in scoped["episodes"]] + + def test_title_scope_narrows_to_title_match(self, db, monkeypatch): + s = _seed(db) + body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=title").json() + assert _ids(body) == ["mt"] + assert body["total"] == 1 + assert body["episodes"] == [] # non-episode field scopes suppress the episode section + + def test_people_scope_narrows_to_cast_match(self, db, monkeypatch): + s = _seed(db) + body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=people").json() + assert _ids(body) == ["mp"] + + def test_summary_scope_narrows_to_description_match(self, db, monkeypatch): + s = _seed(db) + body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=summary").json() + assert _ids(body) == ["ms"] + + def test_episode_scope_empties_grid_and_returns_episode(self, db, monkeypatch): + s = _seed(db) + body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=episode").json() + assert body["items"] == [] + assert body["total"] == 0 + assert [e["title"] for e in body["episodes"]] == ["Zephyr Episode"] + + def test_episode_scope_needs_shows_in_scope(self, db, monkeypatch): + s = _seed(db) + body = _get( + db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=episode&scope=movie" + ).json() + # movie-only scope has no show libraries -> no episodes to surface, and the grid stays empty + assert body["items"] == [] + assert body["episodes"] == [] + + def test_unknown_field_falls_back_to_everywhere(self, db, monkeypatch): + s = _seed(db) + body = _get(db, s["user"], monkeypatch, "/api/plex/library?q=zephyr&field=bogus").json() + assert _ids(body) == ["mp", "ms", "mt"] diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 14de9e6c..8a049fba 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -5,11 +5,11 @@ import type { Me } from "../lib/api"; import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules"; import ModuleName from "./ModuleName"; import SearchBar from "./SearchBar"; +import PlexSearchBox from "./PlexSearchBox"; import SyncStatus from "./SyncStatus"; import { useNavigation, useNavigationActions } from "./NavigationProvider"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider"; -import { usePlex, usePlexActions } from "./PlexProvider"; import { useChannels, useChannelsActions } from "./ChannelsProvider"; // The floating top header: a row of glass pills over the content — a ModuleName pill (label + @@ -34,8 +34,6 @@ export default function Header({ // "go to full history" jump is a ChannelsProvider action. const { search: playlistsSearch } = usePlaylists(); const { setSearch: setPlaylistsSearch } = usePlaylistsActions(); - const { q: plexQ } = usePlex(); - const { setQ: setPlexQ } = usePlexActions(); const { search: channelsSearch } = useChannels(); const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions(); // The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so @@ -70,14 +68,7 @@ export default function Header({ /> ); } else if (page === "plex" && me.plex_enabled) { - search = ( - - ); + search = ; } else if (page === "channels") { search = ( ({ kind: "grid" }); // Sidebar asked to open a playlist → push it as a subview (browser Back returns to the grid), then @@ -112,6 +115,20 @@ export default function PlexBrowse() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [openPlaylist]); + // The header search typeahead picked a title → open it the same way a card click would (a movie's + // info page like the card's ⓘ, a show's drill-down), then clear the hand-off so it doesn't re-open. + useEffect(() => { + if (jumpTo != null) { + sub.open( + jumpTo.type === "show" + ? { kind: "show", id: jumpTo.id } + : { kind: "info", id: jumpTo.id }, + ); + setJumpTo(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [jumpTo]); + // Opening a show/player replaces the grid entirely (the component returns early below), so the // scroll container resets to the top. Remember where the grid was scrolled when leaving it, and // restore it when we come back — so the browser/mouse Back from the player lands on the same card @@ -151,13 +168,14 @@ export default function PlexBrowse() { }, [sub]); const browseQ = useInfiniteQuery({ - queryKey: qk.plexLibrary(scope, dq, sortState, show, filters), + queryKey: qk.plexLibrary(scope, dq, sortState, show, filters, fieldState), enabled: !!scope && sub.view.kind === "grid", initialPageParam: 0, queryFn: ({ pageParam }) => api.plexLibrary({ scope, q: dq || undefined, + field: fieldState, sort: sortState, show, filters, diff --git a/frontend/src/components/PlexProvider.tsx b/frontend/src/components/PlexProvider.tsx index 9fe36f7f..07cbe54f 100644 --- a/frontend/src/components/PlexProvider.tsx +++ b/frontend/src/components/PlexProvider.tsx @@ -1,6 +1,7 @@ import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"; import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api"; import { parseSortState, type SortState } from "../lib/columnSort"; +import { parsePlexField, type PlexSearchField } from "../lib/plexSearch"; import { PLEX_SORT_FALLBACK } from "../lib/plexSort"; import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; import { useAccountRehydrate } from "../lib/useAccountRehydrate"; @@ -19,13 +20,19 @@ import { useAccountRehydrate } from "../lib/useAccountRehydrate"; // search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks // into (and is never greeted by a stale value from) the feed. +// A "jump straight to this title" hand-off from the header typeahead: PlexBrowse opens a movie's +// info page (like the card's ⓘ) or a show's drill-down (like clicking a show card) by type. +export type PlexJumpTarget = { id: string; type: "movie" | "show" }; + type PlexState = { scope: string; // movie | show | both (unified cross-library scope) show: string; sort: SortState; // {key,dir}; healed against the scope's options at the point of use filters: PlexFilters; q: string; + field: PlexSearchField; // search scope-pill; healed against `scope` at the point of use playlistOpen: number | null; + jumpTo: PlexJumpTarget | null; // typeahead → grid hand-off ("open this title") }; interface PlexActions { @@ -34,7 +41,9 @@ interface PlexActions { setSort: (v: SortState) => void; setFilters: (f: PlexFilters) => void; setQ: (q: string) => void; + setField: (f: PlexSearchField) => void; setPlaylistOpen: (id: number | null) => void; + setJumpTo: (t: PlexJumpTarget | null) => void; } const StateContext = createContext({ @@ -43,7 +52,9 @@ const StateContext = createContext({ sort: PLEX_SORT_FALLBACK, filters: EMPTY_PLEX_FILTERS, q: "", + field: "all", playlistOpen: null, + jumpTo: null, }); const ActionsContext = createContext({ setScope: () => {}, @@ -51,7 +62,9 @@ const ActionsContext = createContext({ setSort: () => {}, setFilters: () => {}, setQ: () => {}, + setField: () => {}, setPlaylistOpen: () => {}, + setJumpTo: () => {}, }); export function usePlex(): PlexState { @@ -69,6 +82,7 @@ function readPlexScope(): string { const readPlexShow = (): string => getAccountRaw(LS.plexShow) ?? "all"; const readPlexFiltersRaw = (): string => getAccountRaw(LS.plexFilters) ?? "{}"; const readPlexQ = (): string => getAccountRaw(LS.plexQ) ?? ""; +const readPlexField = (): PlexSearchField => parsePlexField(getAccountRaw(LS.plexField)); // Sort is a {key,dir} SortState. Migrate the old shape: a legacy bare-key string in plexSort plus the // direction that used to live in plexFilters.sortDir — so an existing sort preference survives the // upgrade rather than snapping back to the default. @@ -118,7 +132,11 @@ export function PlexProvider({ children }: { children: ReactNode }) { }, [filtersRaw]); // Survives F5 (persisted), unlike the feed search — see the scope note above. const [q, setQState] = useState(readPlexQ); + const [field, setFieldState] = useState(readPlexField); const [playlistOpen, setPlaylistOpen] = useState(null); + // Ephemeral hand-off like `playlistOpen`: the header typeahead sets the title to jump to; + // PlexBrowse consumes it into an info/show history subview then clears it. + const [jumpTo, setJumpTo] = useState(null); // Re-read every per-account slice once the account is pinned after mount (first load on a fresh tab). const markDirty = useAccountRehydrate(() => { @@ -127,6 +145,7 @@ export function PlexProvider({ children }: { children: ReactNode }) { setSortState(readPlexSort()); setFiltersRaw(readPlexFiltersRaw()); setQState(readPlexQ()); + setFieldState(readPlexField()); }); const setScope = useCallback( @@ -170,14 +189,22 @@ export function PlexProvider({ children }: { children: ReactNode }) { }, [markDirty], ); + const setField = useCallback( + (v: PlexSearchField) => { + markDirty(); + setFieldState(v); + setAccountRaw(LS.plexField, v); + }, + [markDirty], + ); const state = useMemo( - () => ({ scope, show, sort, filters, q, playlistOpen }), - [scope, show, sort, filters, q, playlistOpen], + () => ({ scope, show, sort, filters, q, field, playlistOpen, jumpTo }), + [scope, show, sort, filters, q, field, playlistOpen, jumpTo], ); const actions = useMemo( - () => ({ setScope, setShow, setSort, setFilters, setQ, setPlaylistOpen }), - [setScope, setShow, setSort, setFilters, setQ], + () => ({ setScope, setShow, setSort, setFilters, setQ, setField, setPlaylistOpen, setJumpTo }), + [setScope, setShow, setSort, setFilters, setQ, setField], ); return ( diff --git a/frontend/src/components/PlexSearchBox.tsx b/frontend/src/components/PlexSearchBox.tsx new file mode 100644 index 00000000..3134bc42 --- /dev/null +++ b/frontend/src/components/PlexSearchBox.tsx @@ -0,0 +1,150 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronDown, Film, Tv } from "lucide-react"; +import SearchBar from "./SearchBar"; +import ActionMenu from "./ActionMenu"; +import { usePlex, usePlexActions } from "./PlexProvider"; +import { api } from "../lib/api"; +import { qk } from "../lib/queryKeys"; +import { useDebounced } from "../lib/useDebounced"; +import { + availablePlexFields, + healPlexField, + plexFieldLabelKey, +} from "../lib/plexSearch"; + +// The Plex header search box: the shared SearchBar enriched with the S2 search scope-pill (a +// GitHub-style field selector inside the box) and an instant typeahead of the top matches attached +// below it. Kept out of the generic SearchBar (and out of Header) so all Plex-specific wiring — +// provider state, the suggest query, the jump-to hand-off — lives in one place. Everything reuses +// existing platform pieces: ActionMenu (the pill), the /api/plex/library endpoint (limit=10 for the +// typeahead), and the provider's jumpTo hand-off (typeahead → PlexBrowse subview). +const MIN_CHARS = 2; +const SUGGEST_LIMIT = 10; + +export default function PlexSearchBox() { + const { t } = useTranslation(); + const { q, scope, field } = usePlex(); + const { setQ, setField, setJumpTo } = usePlexActions(); + const activeField = healPlexField(field, scope); + const dq = useDebounced(q.trim(), 200); + const [focused, setFocused] = useState(false); + const [hi, setHi] = useState(-1); // highlighted suggestion index (-1 = none) + const listRef = useRef(null); + + // The Episode scope surfaces episodes in the grid's grouped section, not as title cards, so the + // titles-only typeahead has nothing to show there — suppress it rather than show "no results". + const suggestEnabled = focused && dq.length >= MIN_CHARS && activeField !== "episode"; + const suggestQ = useQuery({ + queryKey: qk.plexSuggest(scope, dq, activeField), + enabled: suggestEnabled, + queryFn: () => + api.plexLibrary({ scope, q: dq, field: activeField, limit: SUGGEST_LIMIT }), + staleTime: 30_000, + }); + const suggestions = suggestEnabled ? (suggestQ.data?.items ?? []).slice(0, SUGGEST_LIMIT) : []; + const open = suggestEnabled && (suggestQ.isFetching || suggestions.length > 0); + + // A fresh query resets the highlight; keep it in range if the list shrank. + useEffect(() => setHi(-1), [dq, activeField, scope]); + + const pick = (i: number) => { + const c = suggestions[i]; + if (!c) return; + setJumpTo({ id: c.id, type: c.type === "show" ? "show" : "movie" }); + setFocused(false); + setHi(-1); + }; + + const fields = availablePlexFields(scope); + const pillLabel = t(plexFieldLabelKey(activeField)); + + return ( + setFocused(true)} + // Suggestion rows preventDefault on mousedown so the input keeps focus through the click; any + // other blur (Tab, the pill, clicking away) closes the panel. + onBlur={() => setFocused(false)} + onKeyDown={(e) => { + if (!open) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + setHi((h) => Math.min(h + 1, suggestions.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setHi((h) => Math.max(h - 1, -1)); + } else if (e.key === "Enter" && hi >= 0) { + e.preventDefault(); // suppress SearchBar's Go; open the highlighted title instead + pick(hi); + } else if (e.key === "Escape") { + setFocused(false); + } + }} + leading={ + + {pillLabel} +