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.
This commit is contained in:
2026-08-16 06:06:21 +02:00
parent 59d50615c4
commit f8df504d18
2 changed files with 303 additions and 196 deletions
+6 -4
View File
@@ -3,7 +3,7 @@
Drives the show/season grid badges and the Resume / Play-from-start / mark-all buttons. 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 sqlalchemy.orm import Session, aliased
from app.models import PlexItem, PlexState 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.""" grid by aggregate watch-state and (b) compute each card's badge."""
ps = aliased(PlexState) ps = aliased(PlexState)
return ( return (
db.query( select(
PlexItem.show_id.label("show_id"), PlexItem.show_id.label("show_id"),
func.count(PlexItem.id).label("total"), func.count(PlexItem.id).label("total"),
func.count(case((ps.status == "watched", 1))).label("watched"), func.count(case((ps.status == "watched", 1))).label("watched"),
@@ -64,7 +64,7 @@ def agg_subquery(db: Session, user_id: int):
).label("inprog"), ).label("inprog"),
) )
.outerjoin(ps, and_(ps.item_id == PlexItem.id, ps.user_id == user_id)) .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) .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]: def state_map(db: Session, user_id: int, show_ids: list[int]) -> dict[int, str]:
if not show_ids: if not show_ids:
return {} 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} return {r.show_id: show_status(r.total, r.watched, r.inprog) for r in rows}
+297 -192
View File
@@ -8,7 +8,20 @@ from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel 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 sqlalchemy.orm import Session, aliased
from app import sysconfig from app import sysconfig
@@ -45,20 +58,20 @@ def list_libraries(
user: User = Depends(current_user), db: Session = Depends(get_db) user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict: ) -> dict:
"""The mirrored libraries the user can browse (drives the Plex scope selector).""" """The mirrored libraries the user can browse (drives the Plex scope selector)."""
libs = ( libs = db.execute(
db.query(PlexLibrary).order_by(PlexLibrary.kind.desc(), PlexLibrary.title).all() select(PlexLibrary).order_by(PlexLibrary.kind.desc(), PlexLibrary.title)
) ).scalars().all()
out = [] out = []
for lib in libs: for lib in libs:
if lib.kind == "movie": if lib.kind == "movie":
count = ( count = db.scalar(
db.query(func.count(PlexItem.id)) select(func.count(PlexItem.id)).where(
.filter_by(library_id=lib.id, kind="movie") PlexItem.library_id == lib.id, PlexItem.kind == "movie"
.scalar() )
) )
else: else:
count = ( count = db.scalar(
db.query(func.count(PlexShow.id)).filter_by(library_id=lib.id).scalar() select(func.count(PlexShow.id)).where(PlexShow.library_id == lib.id)
) )
out.append( out.append(
{ {
@@ -188,7 +201,7 @@ def _meta_filter_conds(model, p: dict) -> list:
def _apply_meta_filters(q, model, p: dict): def _apply_meta_filters(q, model, p: dict):
conds = _meta_filter_conds(model, p) conds = _meta_filter_conds(model, p)
return q.filter(*conds) if conds else q return q.where(*conds) if conds else q
@dataclass @dataclass
@@ -252,7 +265,9 @@ def unified_library(
episodes come back in a separate `episodes` list (grouped result — the 'Episodes' section).""" episodes come back in a separate `episodes` list (grouped result — the 'Episodes' section)."""
offset = max(0, offset) offset = max(0, offset)
p = f.as_dict() 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"] 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"] 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_movies = scope in ("movie", "both") and bool(movie_lib_ids)
@@ -276,7 +291,7 @@ def unified_library(
else_="new", else_="new",
) )
mq = ( mq = (
db.query( select(
PlexItem.rating_key.label("rk"), PlexItem.rating_key.label("rk"),
literal("movie").label("kind"), literal("movie").label("kind"),
PlexItem.title.label("title"), PlexItem.title.label("title"),
@@ -296,28 +311,28 @@ def unified_library(
).label("rank"), ).label("rank"),
) )
.outerjoin(st, and_(st.item_id == PlexItem.id, st.user_id == user.id)) .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": if show == "watched":
mq = mq.filter(st.status == "watched") mq = mq.where(st.status == "watched")
elif show == "in_progress": elif show == "in_progress":
mq = mq.filter( mq = mq.where(
st.position_seconds > 0, st.position_seconds > 0,
or_(st.status.is_(None), st.status != "watched"), or_(st.status.is_(None), st.status != "watched"),
) )
elif show == "unwatched": elif show == "unwatched":
mq = mq.filter( mq = mq.where(
or_(st.status.is_(None), st.status.notin_(["watched", "hidden"])) or_(st.status.is_(None), st.status.notin_(["watched", "hidden"]))
) )
else: 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) mq = _apply_meta_filters(mq, PlexItem, p)
if f.duration_min is not None: 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: 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: if tsq is not None:
mq = mq.filter(PlexItem.search_vector.op("@@")(tsq)) mq = mq.where(PlexItem.search_vector.op("@@")(tsq))
selects.append(mq) selects.append(mq)
if want_shows: if want_shows:
agg = rollup.agg_subquery(db, user.id).subquery() agg = rollup.agg_subquery(db, user.id).subquery()
@@ -330,7 +345,7 @@ def unified_library(
else_="new", else_="new",
) )
sq = ( sq = (
db.query( select(
PlexShow.rating_key.label("rk"), PlexShow.rating_key.label("rk"),
literal("show").label("kind"), literal("show").label("kind"),
PlexShow.title.label("title"), PlexShow.title.label("title"),
@@ -350,17 +365,17 @@ def unified_library(
).label("rank"), ).label("rank"),
) )
.outerjoin(agg, agg.c.show_id == PlexShow.id) .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": if show == "watched":
sq = sq.filter(tot > 0, wat >= tot) sq = sq.where(tot > 0, wat >= tot)
elif show == "in_progress": 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": elif show == "unwatched":
sq = sq.filter(wat == 0, inp == 0) sq = sq.where(wat == 0, inp == 0)
sq = _apply_meta_filters(sq, PlexShow, p) sq = _apply_meta_filters(sq, PlexShow, p)
if tsq is not None: if tsq is not None:
sq = sq.filter(PlexShow.search_vector.op("@@")(tsq)) sq = sq.where(PlexShow.search_vector.op("@@")(tsq))
selects.append(sq) selects.append(sq)
if not selects: if not selects:
@@ -374,7 +389,7 @@ def unified_library(
} }
union = selects[0] if len(selects) == 1 else selects[0].union_all(*selects[1:]) union = selects[0] if len(selects) == 1 else selects[0].union_all(*selects[1:])
u = union.subquery() 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: if tsq is not None:
order = [u.c.rank.desc()] order = [u.c.rank.desc()]
@@ -388,7 +403,9 @@ def unified_library(
}.get(sort, u.c.added_at) }.get(sort, u.c.added_at)
order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()] order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()]
order.append(u.c.rk.desc()) 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 = [] items = []
for r in rows: for r in rows:
@@ -422,21 +439,20 @@ def unified_library(
episodes = [] episodes = []
if tsq is not None and want_shows: if tsq is not None and want_shows:
ep_st = aliased(PlexState) ep_st = aliased(PlexState)
eps = ( eps = db.execute(
db.query(PlexItem, ep_st, PlexShow.title.label("show_title")) select(PlexItem, ep_st, PlexShow.title.label("show_title"))
.outerjoin( .outerjoin(
ep_st, and_(ep_st.item_id == PlexItem.id, ep_st.user_id == user.id) ep_st, and_(ep_st.item_id == PlexItem.id, ep_st.user_id == user.id)
) )
.outerjoin(PlexShow, PlexShow.id == PlexItem.show_id) .outerjoin(PlexShow, PlexShow.id == PlexItem.show_id)
.filter( .where(
PlexItem.kind == "episode", PlexItem.kind == "episode",
PlexItem.library_id.in_(show_lib_ids), PlexItem.library_id.in_(show_lib_ids),
PlexItem.search_vector.op("@@")(tsq), PlexItem.search_vector.op("@@")(tsq),
) )
.order_by(func.ts_rank(PlexItem.search_vector, tsq).desc()) .order_by(func.ts_rank(PlexItem.search_vector, tsq).desc())
.limit(24) .limit(24)
.all() ).all()
)
for e, s, show_title in eps: for e, s, show_title in eps:
card = _episode_card(e, s) card = _episode_card(e, s)
card["show_title"] = show_title card["show_title"] = show_title
@@ -474,7 +490,9 @@ def facets(
"duration_min": None, "duration_min": None,
"duration_max": 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 = ( movie_ids = (
[lb.id for lb in libs if lb.kind == "movie"] [lb.id for lb in libs if lb.kind == "movie"]
if scope in ("movie", "both") if scope in ("movie", "both")
@@ -492,30 +510,30 @@ def facets(
def wsq(query, model, ids, pp, kind_movie, with_dur): 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 """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.""" 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: 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: 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: 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 — # 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. # hidden movies never appear in the grid, so they must not inflate facet counts/bounds either.
st = aliased(PlexState) st = aliased(PlexState)
query = query.outerjoin(st, and_(st.item_id == model.id, st.user_id == uid)) query = query.outerjoin(st, and_(st.item_id == model.id, st.user_id == uid))
if show == "watched": if show == "watched":
query = query.filter(st.status == "watched") query = query.where(st.status == "watched")
elif show == "in_progress": elif show == "in_progress":
query = query.filter( query = query.where(
st.position_seconds > 0, st.position_seconds > 0,
or_(st.status.is_(None), st.status != "watched"), or_(st.status.is_(None), st.status != "watched"),
) )
elif show == "unwatched": elif show == "unwatched":
query = query.filter( query = query.where(
or_(st.status.is_(None), st.status.notin_(["watched", "hidden"])) or_(st.status.is_(None), st.status.notin_(["watched", "hidden"]))
) )
else: else:
query = query.filter(or_(st.status.is_(None), st.status != "hidden")) query = query.where(or_(st.status.is_(None), st.status != "hidden"))
else: else:
if show in ("watched", "in_progress", "unwatched"): if show in ("watched", "in_progress", "unwatched"):
agg = rollup.agg_subquery(db, uid).subquery() agg = rollup.agg_subquery(db, uid).subquery()
@@ -524,11 +542,11 @@ def facets(
wat = func.coalesce(agg.c.watched, 0) wat = func.coalesce(agg.c.watched, 0)
inp = func.coalesce(agg.c.inprog, 0) inp = func.coalesce(agg.c.inprog, 0)
if show == "watched": if show == "watched":
query = query.filter(tot > 0, wat >= tot) query = query.where(tot > 0, wat >= tot)
elif show == "in_progress": 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: else:
query = query.filter(wat == 0, inp == 0) query = query.where(wat == 0, inp == 0)
return query return query
genre_counts: dict[str, int] = {} 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. # whole facets endpoint. Mirror the exact guard people.py already applies to the same columns.
expanded = ( expanded = (
wsq( wsq(
db.query(func.jsonb_array_elements_text(model.genres).label("g")), select(func.jsonb_array_elements_text(model.genres).label("g")),
model, model,
ids, ids,
genre_pp, genre_pp,
kind_movie, kind_movie,
True, True,
) )
.filter(func.jsonb_typeof(model.genres) == "array") .where(func.jsonb_typeof(model.genres) == "array")
.subquery() .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 genre_counts[g] = genre_counts.get(g, 0) + c
# Content ratings — exclude its own. # Content ratings — exclude its own.
crq = ( crq = (
wsq( wsq(
db.query(model.content_rating, func.count()), select(model.content_rating, func.count()),
model, model,
ids, ids,
{**p, "content_ratings": None}, {**p, "content_ratings": None},
kind_movie, kind_movie,
True, True,
) )
.filter(model.content_rating.isnot(None)) .where(model.content_rating.isnot(None))
.group_by(model.content_rating) .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 cr_counts[cr] = cr_counts.get(cr, 0) + c
# Year bounds — exclude the year filter. # Year bounds — exclude the year filter.
by = wsq( by = db.execute(
db.query(func.min(model.year), func.max(model.year)), wsq(
model, select(func.min(model.year), func.max(model.year)),
ids, model,
{**p, "year_min": None, "year_max": None}, ids,
kind_movie, {**p, "year_min": None, "year_max": None},
True, kind_movie,
True,
)
).first() ).first()
if by[0] is not None: if by[0] is not None:
years.append(by[0]) years.append(by[0])
if by[1] is not None: if by[1] is not None:
years.append(by[1]) years.append(by[1])
# Rating max — exclude the rating filter. # Rating max — exclude the rating filter.
br = wsq( br = db.scalar(
db.query(func.max(model.rating)), wsq(
model, select(func.max(model.rating)),
ids, model,
{**p, "rating_min": None}, ids,
kind_movie, {**p, "rating_min": None},
True, kind_movie,
).scalar() True,
)
)
if br is not None: if br is not None:
ratings.append(float(br)) ratings.append(float(br))
# Duration bounds (movies) — exclude the duration filter. # Duration bounds (movies) — exclude the duration filter.
if kind_movie: if kind_movie:
bd = wsq( bd = db.execute(
db.query(func.min(model.duration_s), func.max(model.duration_s)), wsq(
model, select(func.min(model.duration_s), func.max(model.duration_s)),
ids, model,
p, ids,
kind_movie, p,
False, kind_movie,
False,
)
).first() ).first()
if bd[0] is not None: if bd[0] is not None:
durs.append(bd[0]) 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 the UNION across all enabled libraries (the unified-scope sidebar picker, which has no single
library selected).""" library selected)."""
if library is not None: 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: if lib is None:
return {"collections": []} return {"collections": []}
query = db.query(PlexCollection).filter(PlexCollection.library_id == lib.id) query = select(PlexCollection).where(PlexCollection.library_id == lib.id)
else: 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: if not enabled_ids:
return {"collections": []} return {"collections": []}
query = db.query(PlexCollection).filter( query = select(PlexCollection).where(
PlexCollection.library_id.in_(enabled_ids) PlexCollection.library_id.in_(enabled_ids)
) )
term = (q or "").strip() term = (q or "").strip()
if term: if term:
query = query.filter(PlexCollection.title.ilike(f"%{_like_escape(term)}%")) query = query.where(PlexCollection.title.ilike(f"%{_like_escape(term)}%"))
rows = query.order_by( rows = db.execute(
PlexCollection.child_count.desc().nullslast(), func.lower(PlexCollection.title) query.order_by(
).all() PlexCollection.child_count.desc().nullslast(),
func.lower(PlexCollection.title),
)
).scalars().all()
return {"collections": [_collection_card(c) for c in rows]} return {"collections": [_collection_card(c) for c in rows]}
def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection: 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: if col is None:
raise HTTPException(status_code=404, detail="Unknown collection") raise HTTPException(status_code=404, detail="Unknown collection")
if not _collection_editable(col): if not _collection_editable(col):
@@ -731,7 +768,9 @@ def create_collection(
raise HTTPException( raise HTTPException(
status_code=400, detail="title and item_rating_key are required" 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: if lib is None:
raise HTTPException(status_code=404, detail="Unknown library") raise HTTPException(status_code=404, detail="Unknown library")
try: try:
@@ -843,7 +882,9 @@ def collection_set_editable(
) -> dict: ) -> dict:
"""Mark an existing Plex collection as 'mine to edit' (or unmark). Smart / auto-list collections """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.""" (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: if col is None:
raise HTTPException(status_code=404, detail="Unknown collection") raise HTTPException(status_code=404, detail="Unknown collection")
want = body.editable 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 ( return (
db.query(PlexPlaylistItem, PlexItem) select(PlexPlaylistItem, PlexItem)
.join(PlexItem, PlexItem.id == PlexPlaylistItem.item_id) .join(PlexItem, PlexItem.id == PlexPlaylistItem.item_id)
.filter(PlexPlaylistItem.playlist_id == pid) .where(PlexPlaylistItem.playlist_id == pid)
.order_by(PlexPlaylistItem.position, PlexPlaylistItem.id) .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.""" group it already holds) + a top-level `group_size`, for the 'add a whole season/show' dialog."""
contains_id = None contains_id = None
if contains: if contains:
row = db.query(PlexItem.id).filter_by(rating_key=str(contains)).first() contains_id = db.scalar(
contains_id = row[0] if row else None select(PlexItem.id).where(PlexItem.rating_key == str(contains))
)
group_ids: set[int] | None = None group_ids: set[int] | None = None
if contains_group is not None: if contains_group is not None:
rks = [s for s in (contains_group or "").split(",") if s] 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 = set(
group_ids = {r[0] for r in rows} db.execute(
select(PlexItem.id).where(PlexItem.rating_key.in_(rks or [""]))
).scalars()
)
out = [] out = []
for pl in ( for pl in db.execute(
db.query(PlexPlaylist) select(PlexPlaylist)
.filter_by(user_id=user.id) .where(PlexPlaylist.user_id == user.id)
.order_by(PlexPlaylist.updated_at.desc()) .order_by(PlexPlaylist.updated_at.desc())
): ).scalars():
count = ( count = (
db.query(func.count(PlexPlaylistItem.id)) db.scalar(
.filter_by(playlist_id=pl.id) select(func.count(PlexPlaylistItem.id)).where(
.scalar() PlexPlaylistItem.playlist_id == pl.id
)
)
or 0 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) card = _playlist_card(pl, count, first[1].rating_key if first else None)
if contains_id is not None: if contains_id is not None:
card["has_item"] = ( card["has_item"] = (
db.query(PlexPlaylistItem.id) db.execute(
.filter_by(playlist_id=pl.id, item_id=contains_id) select(PlexPlaylistItem.id).where(
.first() PlexPlaylistItem.playlist_id == pl.id,
PlexPlaylistItem.item_id == contains_id,
)
).first()
is not None is not None
) )
if group_ids is not None: if group_ids is not None:
card["group_in"] = ( card["group_in"] = (
db.query(func.count(PlexPlaylistItem.id)) db.scalar(
.filter( select(func.count(PlexPlaylistItem.id)).where(
PlexPlaylistItem.playlist_id == pl.id, PlexPlaylistItem.playlist_id == pl.id,
PlexPlaylistItem.item_id.in_(group_ids or [0]), PlexPlaylistItem.item_id.in_(group_ids or [0]),
)
) )
.scalar()
or 0 or 0
) )
out.append(card) out.append(card)
@@ -958,7 +1008,9 @@ def create_playlist(
count = 0 count = 0
thumb_rk = None thumb_rk = None
if seed: 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: if it is not None:
db.add(PlexPlaylistItem(playlist_id=pl.id, item_id=it.id, position=0)) db.add(PlexPlaylistItem(playlist_id=pl.id, item_id=it.id, position=0))
count, thumb_rk = 1, it.rating_key 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) pid: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict: ) -> dict:
pl = _own_playlist_or_404(db, pid, user) 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] items = [it for (_pi, it) in rows]
states = { states = {
s.item_id: s s.item_id: s
for s in db.query(PlexState).filter( for s in db.execute(
PlexState.user_id == user.id, select(PlexState).where(
PlexState.item_id.in_([it.id for it in items] or [0]), PlexState.user_id == user.id,
) PlexState.item_id.in_([it.id for it in items] or [0]),
)
).scalars()
} }
shows = { shows = {
sh.id: sh sh.id: sh
for sh in db.query(PlexShow).filter( for sh in db.execute(
PlexShow.id.in_([it.show_id for it in items if it.show_id] or [0]) select(PlexShow).where(
) PlexShow.id.in_([it.show_id for it in items if it.show_id] or [0])
)
).scalars()
} }
cards = [ cards = [
_leaf_card( _leaf_card(
@@ -1016,10 +1072,14 @@ def rename_playlist(
pl.title = title pl.title = title
db.commit() db.commit()
count = ( 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 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) 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), db: Session = Depends(get_db),
) -> dict: ) -> dict:
pl = _own_playlist_or_404(db, pid, user) 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: if it is None:
raise HTTPException(status_code=404, detail="Unknown item") raise HTTPException(status_code=404, detail="Unknown item")
exists = ( exists = db.execute(
db.query(PlexPlaylistItem).filter_by(playlist_id=pid, item_id=it.id).first() select(PlexPlaylistItem).where(
) PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id == it.id
)
).scalars().first()
if exists is None: if exists is None:
maxpos = ( maxpos = db.scalar(
db.query(func.coalesce(func.max(PlexPlaylistItem.position), -1)) select(func.coalesce(func.max(PlexPlaylistItem.position), -1)).where(
.filter_by(playlist_id=pid) PlexPlaylistItem.playlist_id == pid
.scalar() )
) )
db.add( db.add(
PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=int(maxpos) + 1) 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), db: Session = Depends(get_db),
) -> dict: ) -> dict:
pl = _own_playlist_or_404(db, pid, user) 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: 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) pl.updated_at = datetime.now(timezone.utc)
db.commit() db.commit()
return {"ok": True} return {"ok": True}
@@ -1092,7 +1162,9 @@ def _items_by_rks(db: Session, rks: list) -> list[PlexItem]:
return [] return []
by_rk = { by_rk = {
it.rating_key: it 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() seen: set[str] = set()
out: list[PlexItem] = [] 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.""" order is preserved for the newly-added ones. Returns how many were added + the new total."""
pl = _own_playlist_or_404(db, pid, user) pl = _own_playlist_or_404(db, pid, user)
items = _items_by_rks(db, body.item_rating_keys) items = _items_by_rks(db, body.item_rating_keys)
existing = { existing = set(
row[0] for row in db.query(PlexPlaylistItem.item_id).filter_by(playlist_id=pid) db.execute(
} select(PlexPlaylistItem.item_id).where(PlexPlaylistItem.playlist_id == pid)
).scalars()
)
pos = int( pos = int(
db.query(func.coalesce(func.max(PlexPlaylistItem.position), -1)) db.scalar(
.filter_by(playlist_id=pid) select(func.coalesce(func.max(PlexPlaylistItem.position), -1)).where(
.scalar() PlexPlaylistItem.playlist_id == pid
)
)
) )
added = 0 added = 0
for it in items: for it in items:
@@ -1135,7 +1211,11 @@ def playlist_add_bulk(
pl.updated_at = datetime.now(timezone.utc) pl.updated_at = datetime.now(timezone.utc)
db.commit() db.commit()
total = ( 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 or 0
) )
return {"added": added, "item_count": total} 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)] ids = [it.id for it in _items_by_rks(db, body.item_rating_keys)]
removed = 0 removed = 0
if ids: if ids:
removed = ( removed = db.execute(
db.query(PlexPlaylistItem) delete(PlexPlaylistItem).where(
.filter(
PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id.in_(ids) PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id.in_(ids)
) )
.delete(synchronize_session=False) ).rowcount
)
if removed: if removed:
pl.updated_at = datetime.now(timezone.utc) pl.updated_at = datetime.now(timezone.utc)
db.commit() db.commit()
total = ( 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 or 0
) )
return {"removed": removed, "item_count": total} return {"removed": removed, "item_count": total}
@@ -1183,13 +1265,19 @@ def reorder_playlist(
order = body.item_rating_keys order = body.item_rating_keys
id_by_rk = { id_by_rk = {
it.rating_key: it.id 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): for pos, rk in enumerate(order):
iid = id_by_rk.get(rk) iid = id_by_rk.get(rk)
if iid is not None: if iid is not None:
db.query(PlexPlaylistItem).filter_by(playlist_id=pid, item_id=iid).update( db.execute(
{"position": pos} update(PlexPlaylistItem)
.where(
PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id == iid
)
.values(position=pos)
) )
pl.updated_at = datetime.now(timezone.utc) pl.updated_at = datetime.now(timezone.utc)
db.commit() db.commit()
@@ -1244,30 +1332,32 @@ def show_detail(
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
"""A show's seasons + episodes (the drill-down view) with per-user watch state.""" """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: if sh is None:
raise HTTPException(status_code=404, detail="Unknown Plex show") raise HTTPException(status_code=404, detail="Unknown Plex show")
seasons = ( seasons = db.execute(
db.query(PlexSeason) select(PlexSeason)
.filter_by(show_id=sh.id) .where(PlexSeason.show_id == sh.id)
.order_by(PlexSeason.season_number.asc().nullsfirst()) .order_by(PlexSeason.season_number.asc().nullsfirst())
.all() ).scalars().all()
) eps = db.execute(
eps = ( select(PlexItem)
db.query(PlexItem) .where(PlexItem.show_id == sh.id, PlexItem.kind == "episode")
.filter_by(show_id=sh.id, kind="episode")
.order_by( .order_by(
PlexItem.season_number.asc().nullsfirst(), PlexItem.season_number.asc().nullsfirst(),
PlexItem.episode_number.asc().nullsfirst(), PlexItem.episode_number.asc().nullsfirst(),
) )
.all() ).scalars().all()
)
states = { states = {
s.item_id: s s.item_id: s
for s in db.query(PlexState).filter( for s in db.execute(
PlexState.user_id == user.id, select(PlexState).where(
PlexState.item_id.in_([e.id for e in eps] or [0]), PlexState.user_id == user.id,
) PlexState.item_id.in_([e.id for e in eps] or [0]),
)
).scalars()
} }
by_season: dict[int | None, list] = {} by_season: dict[int | None, list] = {}
all_cards: list[ all_cards: list[
@@ -1362,9 +1452,11 @@ def _related_show_cards(
return [] return []
shows = { shows = {
s.rating_key: s s.rating_key: s
for s in db.query(PlexShow).filter( for s in db.execute(
PlexShow.rating_key.in_(rks[:60]), PlexShow.id != exclude 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()]) statuses = rollup.state_map(db, user_id, [s.id for s in shows.values()])
out: list[dict] = [] out: list[dict] = []
@@ -1436,37 +1528,41 @@ def people_cards(
wanted = _csv(names)[:16] wanted = _csv(names)[:16]
if not wanted: if not wanted:
return {"people": []} 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"] 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"] 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_movies = scope in ("movie", "both") and bool(movie_lib_ids)
want_shows = scope in ("show", "both") and bool(show_lib_ids) want_shows = scope in ("show", "both") and bool(show_lib_ids)
photos = { photos = {
p.name: p.photo_thumb 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 = [] out = []
for nm in wanted: for nm in wanted:
count = 0 count = 0
if want_movies: if want_movies:
count += ( count += (
db.query(func.count(PlexItem.id)) db.scalar(
.filter( select(func.count(PlexItem.id)).where(
PlexItem.kind == "movie", PlexItem.kind == "movie",
PlexItem.library_id.in_(movie_lib_ids), PlexItem.library_id.in_(movie_lib_ids),
PlexItem.cast_names.contains([nm]), PlexItem.cast_names.contains([nm]),
)
) )
.scalar()
or 0 or 0
) )
if want_shows: if want_shows:
count += ( count += (
db.query(func.count(PlexShow.id)) db.scalar(
.filter( select(func.count(PlexShow.id)).where(
PlexShow.library_id.in_(show_lib_ids), PlexShow.library_id.in_(show_lib_ids),
PlexShow.cast_names.contains([nm]), PlexShow.cast_names.contains([nm]),
)
) )
.scalar()
or 0 or 0
) )
out.append({"name": nm, "photo": photos.get(nm), "count": count}) 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 [] return []
cols = { cols = {
c.rating_key: c 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. # Smallest (most specific — e.g. a franchise) first; the big auto-lists (IMDb/TMDb Popular) last.
ordered = sorted( ordered = sorted(
@@ -1502,23 +1600,24 @@ def _collection_strips(db: Session, it: PlexItem, user_id: int) -> list[dict]:
out = [] out = []
for col in ordered: for col in ordered:
rk = col.rating_key rk = col.rating_key
members = ( members = db.execute(
db.query(PlexItem) select(PlexItem)
.filter( .where(
PlexItem.library_id == it.library_id, PlexItem.library_id == it.library_id,
PlexItem.kind == "movie", PlexItem.kind == "movie",
PlexItem.collection_keys.contains([rk]), PlexItem.collection_keys.contains([rk]),
) )
.order_by(PlexItem.year.asc().nullslast(), func.lower(PlexItem.title)) .order_by(PlexItem.year.asc().nullslast(), func.lower(PlexItem.title))
.limit(60) .limit(60)
.all() ).scalars().all()
)
ids = [m.id for m in members] ids = [m.id for m in members]
states = { states = {
s.item_id: s s.item_id: s
for s in db.query(PlexState).filter( for s in db.execute(
PlexState.user_id == user_id, PlexState.item_id.in_(ids or [0]) select(PlexState).where(
) PlexState.user_id == user_id, PlexState.item_id.in_(ids or [0])
)
).scalars()
} }
out.append( out.append(
{ {
@@ -1536,11 +1635,13 @@ def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]:
return None, None return None, None
keys = [ keys = [
r[0] r[0]
for r in db.query(PlexItem.rating_key) for r in db.execute(
.filter_by(show_id=it.show_id, kind="episode") select(PlexItem.rating_key)
.order_by( .where(PlexItem.show_id == it.show_id, PlexItem.kind == "episode")
PlexItem.season_number.asc().nullsfirst(), .order_by(
PlexItem.episode_number.asc().nullsfirst(), PlexItem.season_number.asc().nullsfirst(),
PlexItem.episode_number.asc().nullsfirst(),
)
) )
.all() .all()
] ]
@@ -1562,7 +1663,11 @@ def item_detail(
"""Everything the player needs: metadata, cast, intro/credit markers (from Plex), episode """Everything the player needs: metadata, cast, intro/credit markers (from Plex), episode
prev/next, and the per-user resume position + watch status.""" prev/next, and the per-user resume position + watch status."""
it = item_or_404(db, rating_key) 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 = {} rich: dict = {}
markers: list[dict] = [] markers: list[dict] = []