Files
siftlode/backend/app/plex/queries.py
T
peter 3b0e365e26 refactor(plex): the browse, detail, people and collection pages read through queries and write through the service
The HTTP files browse, detail, people, collections and _shared do no database work of their own
now. New plex/queries.py (the rows a page shows, a user's watch state through userscope),
plex/service.py (the info pages' enrichment, a collection's editable flag, the row kept after a
failed follow-up read, the person bio's background fetch; none commits), plex/library.py (the
unified browse feed, its search and facets) and plex/collections.py (a collection's source,
editability, card, certificate and member order). Every watch-state join in the moved code names
its owner through owned_by.

Pins 354 -> 178 in 4 files; basedpyright baseline 132 -> 118.
2026-10-19 22:11:20 +02:00

383 lines
13 KiB
Python

"""Plex's reads for the HTTP layer: the mirrored rows a page shows, and a user's watch state on them.
A user's own rows (`PlexState`) are read through `userscope`, so "someone else's row" is the same
answer as a missing one. A rating key with a NUL in it names no row (`lookups.by_rating_key`).
"""
from typing import Literal
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.collation import collated
from app.models import User
from app.plex import rollup
from app.plex.lookups import by_rating_key
from app.plex.models import (
PlexCollection,
PlexItem,
PlexLibrary,
PlexPerson,
PlexSeason,
PlexShow,
PlexState,
)
from app.plex.query import content_libs, runtime_by_key, title_col, top_art_keys
from app.userscope import owned_by
from app.utils import like_escape
# --- one row ---------------------------------------------------------------------------------
def item(db: Session, rating_key: str) -> PlexItem | None:
return by_rating_key(db, PlexItem, rating_key)
def movie(db: Session, rating_key: str) -> PlexItem | None:
"""The movie with this key — an episode's key is not one."""
it = item(db, rating_key)
return it if it is not None and it.kind == "movie" else None
def show(db: Session, rating_key: str) -> PlexShow | None:
return by_rating_key(db, PlexShow, rating_key)
def collection(db: Session, rating_key: str) -> PlexCollection | None:
return by_rating_key(db, PlexCollection, rating_key)
def show_by_id(db: Session, show_id: int) -> PlexShow | None:
return db.get(PlexShow, show_id)
def season_by_id(db: Session, season_id: int) -> PlexSeason | None:
return db.get(PlexSeason, season_id)
def library(db: Session, library_id: int) -> PlexLibrary | None:
return db.get(PlexLibrary, library_id)
def library_by_key(db: Session, plex_key: str) -> PlexLibrary | None:
return (
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == str(plex_key)))
.scalars()
.first()
)
def library_key(db: Session, library_id: int | None) -> str | None:
"""The Plex section key for a library id — the info/show page passes it to the admin collection
editor (in the unified cross-library view there's no single library prop otherwise)."""
if library_id is None:
return None
lib = db.get(PlexLibrary, library_id)
return lib.plex_key if lib else None
def person(db: Session, name: str) -> PlexPerson | None:
return db.execute(select(PlexPerson).where(PlexPerson.name == name)).scalars().first()
# --- a user's watch state --------------------------------------------------------------------
def state(db: Session, user: User | int, it: PlexItem) -> PlexState | None:
return (
db.execute(
select(PlexState).where(owned_by(PlexState.user_id, user), PlexState.item_id == it.id)
)
.scalars()
.first()
)
def states(db: Session, user: User | int, item_ids: list[int]) -> dict[int, PlexState]:
"""The user's state on each of these items that has one, by item id."""
if not item_ids:
return {}
return {
s.item_id: s
for s in db.execute(
select(PlexState).where(
owned_by(PlexState.user_id, user), PlexState.item_id.in_(item_ids)
)
).scalars()
}
# --- a show ----------------------------------------------------------------------------------
def seasons(db: Session, sh: PlexShow) -> list[PlexSeason]:
return list(
db.execute(
select(PlexSeason)
.where(PlexSeason.show_id == sh.id)
.order_by(PlexSeason.season_number.asc().nullsfirst())
).scalars()
)
def episodes(db: Session, sh: PlexShow) -> list[PlexItem]:
"""A show's episodes in season/episode order."""
return list(
db.execute(
select(PlexItem)
.where(PlexItem.show_id == sh.id, PlexItem.kind == "episode")
.order_by(
PlexItem.season_number.asc().nullsfirst(),
PlexItem.episode_number.asc().nullsfirst(),
)
).scalars()
)
def episode_keys(db: Session, show_id: int) -> list[str]:
"""The rating keys of a show's episodes, in the order `episodes` gives."""
return list(
db.execute(
select(PlexItem.rating_key)
.where(PlexItem.show_id == show_id, PlexItem.kind == "episode")
.order_by(
PlexItem.season_number.asc().nullsfirst(),
PlexItem.episode_number.asc().nullsfirst(),
)
).scalars()
)
def shows_by_keys(db: Session, rating_keys: list[str], exclude: int) -> dict[str, PlexShow]:
"""The mirrored shows among these keys, but `exclude`, by key."""
return {
s.rating_key: s
for s in db.execute(
select(PlexShow).where(PlexShow.rating_key.in_(rating_keys), PlexShow.id != exclude)
).scalars()
}
# --- libraries -------------------------------------------------------------------------------
def browsable_libraries(db: Session, lang: str) -> list[tuple[PlexLibrary, int]]:
"""The mirrored libraries a user can browse, each with its title count (movies, or shows). An
HU overlay library is left out: it is never browsed, it only feeds its EN twin's Hungarian
metadata."""
libs = db.execute(
select(PlexLibrary)
.where(PlexLibrary.overlay_of.is_(None))
.order_by(PlexLibrary.kind.desc(), collated(PlexLibrary.title, lang))
).scalars()
out = []
for lib in libs:
if lib.kind == "movie":
count = db.scalar(
select(func.count(PlexItem.id)).where(
PlexItem.library_id == lib.id, PlexItem.kind == "movie"
)
)
else:
count = db.scalar(select(func.count(PlexShow.id)).where(PlexShow.library_id == lib.id))
out.append((lib, count or 0))
return out
# --- people ----------------------------------------------------------------------------------
def person_photos(db: Session, names: list[str]) -> dict[str, str | None]:
return {
p.name: p.photo_thumb
for p in db.execute(select(PlexPerson).where(PlexPerson.name.in_(names))).scalars()
}
def cast_count(db: Session, name: str, movie_lib_ids: list[int], show_lib_ids: list[int]) -> int:
"""How many movies (in `movie_lib_ids`) and shows (in `show_lib_ids`) name this person in their
cast. An empty id list leaves its side out."""
count = 0
if movie_lib_ids:
count += (
db.scalar(
select(func.count(PlexItem.id)).where(
PlexItem.kind == "movie",
PlexItem.library_id.in_(movie_lib_ids),
PlexItem.cast_names.contains([name]),
)
)
or 0
)
if show_lib_ids:
count += (
db.scalar(
select(func.count(PlexShow.id)).where(
PlexShow.library_id.in_(show_lib_ids),
PlexShow.cast_names.contains([name]),
)
)
or 0
)
return count
def filmography(
db: Session,
kind: Literal["movie", "show"],
name: str,
lib_ids: list[int],
lang: str,
limit: int,
) -> list:
"""The movies or shows in these libraries whose cast or directors name this person, newest
first."""
model: type[PlexItem] | type[PlexShow] = PlexItem if kind == "movie" else PlexShow
query = select(model).where(
model.library_id.in_(lib_ids),
or_(model.cast_names.contains([name]), model.directors.contains([name])),
)
if model is PlexItem:
query = query.where(PlexItem.kind == "movie")
return list(
db.execute(
query.order_by(
model.year.desc().nullslast(), collated(title_col(model, lang), lang)
).limit(limit)
).scalars()
)
# --- collections -----------------------------------------------------------------------------
def collections_page(
db: Session, library: str | None, q: str | None, keys: list[str] | None, lang: str
) -> list[PlexCollection]:
"""Collections, most-populated first: one library's (by its plex key), else every enabled
library's; name-filtered by `q`; narrowed to `keys` when given (an empty list asks for none)."""
if library is not None:
lib = library_by_key(db, library)
if lib is None:
return []
query = select(PlexCollection).where(PlexCollection.library_id == lib.id)
else:
enabled_ids = list(
db.execute(
select(PlexLibrary.id).where(
PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None)
)
).scalars()
)
if not enabled_ids:
return []
query = select(PlexCollection).where(PlexCollection.library_id.in_(enabled_ids))
term = (q or "").strip()
if term:
query = query.where(PlexCollection.title.ilike(f"%{like_escape(term)}%"))
if keys is not None:
# An explicit but empty `keys=` asks for nothing, which is not the same as no `keys` (every
# collection): answered here without a query. The empty IN () would also match nothing.
if not keys:
return []
query = query.where(PlexCollection.rating_key.in_(keys))
return list(
db.execute(
query.order_by(
PlexCollection.child_count.desc().nullslast(), collated(PlexCollection.title, lang)
)
).scalars()
)
def library_collections(db: Session, library_id: int, lang: str) -> list[PlexCollection]:
return list(
db.execute(
select(PlexCollection)
.where(PlexCollection.library_id == library_id)
.order_by(collated(PlexCollection.title, lang))
).scalars()
)
def collections_by_keys(db: Session, keys: list[str]) -> list[PlexCollection]:
return list(
db.execute(select(PlexCollection).where(PlexCollection.rating_key.in_(keys))).scalars()
)
def collection_named(db: Session, library_id: int, title: str) -> PlexCollection | None:
"""A collection of this library with this name, case aside."""
return (
db.execute(
select(PlexCollection).where(
PlexCollection.library_id == library_id,
func.lower(PlexCollection.title) == title.lower(),
)
)
.scalars()
.first()
)
def collection_members(
db: Session, rating_key: str, lang: str
) -> tuple[list[PlexItem], list[PlexShow]]:
"""A collection's movies and shows, each by year then title."""
movies = db.execute(
select(PlexItem)
.where(PlexItem.kind == "movie", PlexItem.collection_keys.contains([rating_key]))
.order_by(PlexItem.year.asc().nullslast(), collated(title_col(PlexItem, lang), lang))
).scalars()
shows = db.execute(
select(PlexShow)
.where(PlexShow.collection_keys.contains([rating_key]))
.order_by(PlexShow.year.asc().nullslast(), collated(title_col(PlexShow, lang), lang))
).scalars()
return list(movies), list(shows)
def library_movies_in(
db: Session, library_id: int, rating_key: str, lang: str, limit: int
) -> list[PlexItem]:
"""The movies of one library in this collection, by year then title."""
return list(
db.execute(
select(PlexItem)
.where(
PlexItem.library_id == library_id,
PlexItem.kind == "movie",
PlexItem.collection_keys.contains([rating_key]),
)
.order_by(PlexItem.year.asc().nullslast(), collated(title_col(PlexItem, lang), lang))
.limit(limit)
).scalars()
)
def show_rollups(db: Session, user_id: int, show_ids: list[int]) -> list:
"""The user's episode rollup (total, watched, in progress, last resumed) of each of these shows
that has episodes."""
if not show_ids:
return []
return list(
db.execute(
rollup.agg_subquery(db, user_id, include_resumed=True).where(
PlexItem.show_id.in_(show_ids)
)
).all()
)
def collection_tiles(
db: Session, keys: list[str]
) -> tuple[dict[str, list[str]], dict[str, list[str]], dict[str, int]]:
"""What each collection's tile shows: the top two movie members' keys, the top two show members'
keys (a collection lives in one library, so exactly one side has any), and its movies' total
runtime (a show has no single duration)."""
movie_ids, show_ids = content_libs(db)
art_m = top_art_keys(db, PlexItem, movie_ids, PlexItem.collection_keys, keys, per=2)
art_s = top_art_keys(db, PlexShow, show_ids, PlexShow.collection_keys, keys, per=2)
runtime = runtime_by_key(db, PlexItem, movie_ids, PlexItem.collection_keys, keys)
return art_m, art_s, runtime