From f8df504d18fe77d433f42bc893d18cc8981230a7 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 16 Aug 2026 06:06:21 +0200 Subject: [PATCH] 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] = []