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.
This commit is contained in:
2026-10-19 22:11:20 +02:00
parent 533571f572
commit 3b0e365e26
19 changed files with 1513 additions and 1124 deletions
-114
View File
@@ -650,121 +650,7 @@
} }
} }
], ],
"./app/routes/plex/browse.py": [
{
"code": "reportArgumentType",
"range": {
"startColumn": 26,
"endColumn": 61,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 26,
"endColumn": 61,
"lineCount": 1
}
},
{
"code": "reportOptionalSubscript",
"range": {
"startColumn": 11,
"endColumn": 13,
"lineCount": 1
}
},
{
"code": "reportOptionalSubscript",
"range": {
"startColumn": 25,
"endColumn": 27,
"lineCount": 1
}
},
{
"code": "reportOptionalSubscript",
"range": {
"startColumn": 11,
"endColumn": 13,
"lineCount": 1
}
},
{
"code": "reportOptionalSubscript",
"range": {
"startColumn": 25,
"endColumn": 27,
"lineCount": 1
}
},
{
"code": "reportOptionalSubscript",
"range": {
"startColumn": 15,
"endColumn": 17,
"lineCount": 1
}
},
{
"code": "reportOptionalSubscript",
"range": {
"startColumn": 28,
"endColumn": 30,
"lineCount": 1
}
},
{
"code": "reportOptionalSubscript",
"range": {
"startColumn": 15,
"endColumn": 17,
"lineCount": 1
}
},
{
"code": "reportOptionalSubscript",
"range": {
"startColumn": 28,
"endColumn": 30,
"lineCount": 1
}
}
],
"./app/routes/plex/collections.py": [ "./app/routes/plex/collections.py": [
{
"code": "reportAssignmentType",
"range": {
"startColumn": 11,
"endColumn": 39,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 76,
"endColumn": 80,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 75,
"endColumn": 79,
"lineCount": 1
}
},
{
"code": "reportArgumentType",
"range": {
"startColumn": 80,
"endColumn": 84,
"lineCount": 1
}
},
{ {
"code": "reportArgumentType", "code": "reportArgumentType",
"range": { "range": {
+122
View File
@@ -0,0 +1,122 @@
"""What a collection is to the app: where it comes from, whether it may be edited, its card, its
effective content rating, and how its members order — shared by the collection pages, the info
page's collection strips and the admin editor."""
from collections.abc import Callable
from datetime import date, datetime
from typing import Any, NamedTuple
from app.plex.models import PlexCollection
# The most-restrictive → least ordering used to pick a collection's effective content rating from its
# members (Plex shows one cert on the collection; we don't store it, so derive it).
_CERT_RANK = {
"G": 1,
"TV-Y": 1,
"TV-Y7": 1,
"TV-G": 1,
"PG": 2,
"TV-PG": 2,
"PG-13": 3,
"TV-14": 3,
"R": 4,
"TV-MA": 4,
"NC-17": 5,
}
def collection_cert(certs: list[str | None]) -> str | None:
ranked = [(c, _CERT_RANK.get(c, 0)) for c in certs if c]
return max(ranked, key=lambda x: x[1])[0] if ranked else None
_STRIP_PROVIDERS = ("imdb", "tmdb", "tvdb", "trakt")
def collection_source(col: PlexCollection) -> str:
first = (col.title or "").strip().split(" ", 1)[0].lower()
if first in _STRIP_PROVIDERS:
return first
if col.smart:
return "smart"
return "collection"
def collection_editable(c: PlexCollection) -> bool:
"""Effective editability: the admin marked it editable AND it's a plain manual collection — never
smart, never an external auto-list (IMDb/TMDb/…), which are managed outside Plex."""
return bool(c.editable) and not c.smart and collection_source(c) == "collection"
def collection_card(c: PlexCollection) -> dict:
return {
"id": c.rating_key,
"title": c.title,
"summary": c.summary,
"thumb": f"/api/plex/image/{c.rating_key}" if c.thumb_key else None,
"child_count": c.child_count,
"smart": c.smart,
"source": collection_source(c),
"editable": c.editable,
"can_edit": collection_editable(
c
), # whether the edit controls should be offered (admin UI)
}
class Member(NamedTuple):
"""One collection member flattened to everything the Sort control can order by. The page's two
legs are different tables (movies and shows), so there is no single query to ORDER BY — the
ordering is a pure list sort over these, which is also what makes it unit-testable."""
kind: str # "movie" | "show"
obj: Any # PlexItem | PlexShow, carried through to the card serializer
title: str # lower-cased; never NULL (the column is not nullable)
year: int | None
rating: float | None
added: datetime | None
release: date | None
duration: int | None # movies only — a show has no single runtime
last_watched: datetime | None
# Sort key -> what to read off a Member. Pinned to `PLEX_SORT_KEYS` by a test: the collection page
# shows the SAME Sort control as the browse grid, so a key this map is missing would be an option
# that silently does nothing here.
MEMBER_SORT_KEYS: dict[str, Callable[[Member], Any]] = {
"added": lambda m: m.added,
"lastwatched": lambda m: m.last_watched,
"release": lambda m: m.release,
"year": lambda m: m.year,
"rating": lambda m: m.rating,
"duration": lambda m: m.duration,
"title": lambda m: m.title,
}
# What the page ordered by before it had a control, and still its default: a film series wants to be
# read in release order, not by whenever Plex happened to ingest it.
DEFAULT_MEMBER_SORT = "year"
def sort_members(
members: list[Member], sort: str, sort_dir: str, alpha: dict[str, int] | None = None
) -> list[Member]:
"""Order a collection's members by one of `PLEX_SORT_KEYS`.
Two properties worth stating because they are easy to get wrong with a plain `reverse=True`:
members with no value for the key sink to the END in BOTH directions (matching the browse grid's
`nullslast()` — a movie with no rating is not "the worst rated"), and ties always resolve
title-ascending, so flipping the direction reverses the ranking rather than the alphabet inside
it. An unknown key falls back to the page's default instead of returning an arbitrary order.
`alpha` ranks each title in the reader's alphabet (`collation.sorted_words`); without it, titles
compare as Python strings, which puts every accented initial after "z"."""
by_title = (lambda m: alpha[m.title]) if alpha is not None else (lambda m: m.title)
key = MEMBER_SORT_KEYS.get(sort, MEMBER_SORT_KEYS[DEFAULT_MEMBER_SORT])
if key is MEMBER_SORT_KEYS["title"]:
key = by_title
ordered = sorted(members, key=by_title) # stable secondary, applied first
have = [m for m in ordered if key(m) is not None]
missing = [m for m in ordered if key(m) is None]
have.sort(key=key, reverse=sort_dir != "asc")
return have + missing
+5 -4
View File
@@ -1,10 +1,11 @@
"""Parse the "info page" rich metadata out of a full Plex metadata dict, and persist the subset that """Parse the "info page" rich metadata out of a full Plex metadata dict, and persist the subset that
the detail endpoints serve from the DB row. the detail endpoints serve from the DB row.
Shared by the live info-page path (routes.plex.browse) and the background rich re-sync (plex.sync) so Shared by the live info-page path (`plex.service.enrich_movie` / `enrich_show`) and the background
the two never diverge — the same one-parser rule that keeps `people.py`'s cast handling consistent. rich re-sync (plex.sync) so the two never diverge — the same one-parser rule that keeps `people.py`'s
`rich_detail` is the pure parser; `apply_rich_detail` writes the rich-ONLY fields onto a movie/show row cast handling consistent. `rich_detail` is the pure parser; `apply_rich_detail` writes the rich-ONLY
(genres/directors/studio/content_rating already land from the cheap listing at `_apply_facet_fields`).""" fields onto a movie/show row (genres/directors/studio/content_rating already land from the cheap
listing at `_apply_facet_fields`)."""
from app.plex import people from app.plex import people
from app.plex.plaintext import plain_text from app.plex.plaintext import plain_text
+473
View File
@@ -0,0 +1,473 @@
"""The Plex browse grid's reads: the unified movies + shows feed, its search, and its facets.
A user's watch state is joined through `userscope`; the feed and its facets share every filter
(`plex.query`), so a facet count is the size of what the grid would show.
"""
from sqlalchemy import Boolean, Integer, String, and_, cast, func, literal, null, or_, select
from sqlalchemy.orm import Session, aliased
from app.collation import collated, sorted_words
from app.models import User
from app.plex import rollup
from app.plex.cards import norm_lang
from app.plex.models import PlexItem, PlexShow, PlexState
from app.plex.query import (
FIELD_COLUMNS,
PLAYBACK_CLASSES,
TS_CONFIG,
LibraryFilters,
apply_meta_filters,
apply_movie_state_filter,
apply_show_state_filter,
content_libs,
field_recheck,
genres_col,
meta_filter_conds,
movie_status_case,
playable_conds,
playback_class_expr,
show_status_case,
split_csv,
summary_col,
title_col,
vector_col,
vector_match,
)
from app.userscope import owned_by
from app.utils import to_tsquery_str
def sort_column(u, sort: str, lang: str = "en"):
"""The browse grid's sort key -> orderable column on the union subquery. Module-level (not inline
in the handler) so a test can pin its vocabulary to `PLEX_SORT_KEYS` — a key the Sort control
offers but this cannot order would quietly fall back to `added_at` and look like a broken control.
An unknown key (a stale bookmark, a hand-edited URL) deliberately keeps that fallback. The title sorts
in the reader's alphabet (`collation`)."""
return {
"added": u.c.added_at,
"title": collated(u.c.title, lang),
"year": u.c.year,
"rating": u.c.rating,
"duration": u.c.duration_s,
"release": u.c.rel,
"lastwatched": u.c.last_watched,
}.get(sort, u.c.added_at)
def library_page(
db: Session,
user: User,
*,
scope: str,
q: str | None,
field: str,
sort: str,
sort_dir: str,
show: str,
lang: str,
offset: int,
limit: int,
f: LibraryFilters,
) -> tuple[int, list, list]:
"""One page of the unified cross-library browse: (total, rows, episodes). `rows` mixes movies
and shows (`kind` says which), scoped to movie|show|both, with the shared filters/search/watch
state (a show's state is the aggregate of its episodes). On a search that also matches episodes
(and shows are in scope), `episodes` holds the matching ones as (item, state, show) — the
grouped 'Episodes' section. `lang` is already normalised."""
p = f.as_dict()
movie_lib_ids, show_lib_ids = content_libs(db)
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
want_shows = scope in ("show", "both") and bool(show_lib_ids)
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 and grid_field:
st = aliased(PlexState)
m_status = movie_status_case(st)
mq = (
select(
PlexItem.rating_key.label("rk"),
literal("movie").label("kind"),
title_col(PlexItem, lang).label("title"),
PlexItem.year.label("year"),
PlexItem.rating.label("rating"),
# Truncated so the card hover-overview costs a few hundred bytes, not the full synopsis.
func.left(summary_col(PlexItem, lang), 300).label("summary"),
PlexItem.added_at.label("added_at"),
PlexItem.originally_available_at.label("rel"),
PlexItem.duration_s.label("duration_s"),
PlexItem.playable.label("playable"),
PlexItem.transcode_hw.label("transcode_hw"),
cast(null(), Integer).label("season_count"),
func.coalesce(st.position_seconds, 0).label("position_seconds"),
m_status.label("status"),
literal(False).label("resumable"),
# "last watched" sort key: when the movie was last resumed, else when it was marked
# watched. NULL for never-touched movies → nullslast sinks them (see the sort dict).
func.coalesce(st.progress_updated_at, st.watched_at).label("last_watched"),
(
func.ts_rank(vector_col(PlexItem, lang), tsq)
if tsq is not None
else literal(0.0)
).label("rank"),
)
.outerjoin(st, and_(st.item_id == PlexItem.id, owned_by(st.user_id, user)))
.where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_lib_ids))
)
mq = apply_movie_state_filter(mq, st, show)
mq = apply_meta_filters(mq, PlexItem, p, lang)
if f.duration_min is not None:
mq = mq.where(PlexItem.duration_s >= f.duration_min)
if f.duration_max is not None:
mq = mq.where(PlexItem.duration_s <= f.duration_max)
# Playback-class filter (movie-only — a show is an aggregate with no single class).
mq = mq.where(*playable_conds(split_csv(f.playable)))
if tsq is not None:
mq = mq.where(vector_match(PlexItem, lang, tsq))
if recheck is not None and (narrow := recheck(PlexItem, field, tsq, lang)) is not None:
mq = mq.where(narrow)
selects.append(mq)
if want_shows and grid_field:
agg = rollup.agg_subquery(db, user.id, include_resumed=True).subquery()
tot = func.coalesce(agg.c.total, 0)
wat = func.coalesce(agg.c.watched, 0)
inp = func.coalesce(agg.c.inprog, 0)
s_status = show_status_case(tot, wat, inp)
sq = (
select(
PlexShow.rating_key.label("rk"),
literal("show").label("kind"),
title_col(PlexShow, lang).label("title"),
PlexShow.year.label("year"),
PlexShow.rating.label("rating"),
func.left(summary_col(PlexShow, lang), 300).label("summary"),
PlexShow.added_at.label("added_at"),
PlexShow.originally_available_at.label("rel"),
cast(null(), Integer).label("duration_s"),
cast(null(), String).label("playable"),
cast(null(), Boolean).label("transcode_hw"),
PlexShow.child_count.label("season_count"),
literal(0).label("position_seconds"),
s_status.label("status"),
# An episode holds a resume point (`ShowState.resumable`, the card's Continue + Restart).
(inp > 0).label("resumable"),
# Show leg's "last watched": max(resume/watched time) across the show's episodes (see
# rollup.agg_subquery include_resumed). NULL for a never-watched show → nullslast.
agg.c.resumed_at.label("last_watched"),
(
func.ts_rank(vector_col(PlexShow, lang), tsq)
if tsq is not None
else literal(0.0)
).label("rank"),
)
.outerjoin(agg, agg.c.show_id == PlexShow.id)
.where(PlexShow.library_id.in_(show_lib_ids))
)
sq = apply_show_state_filter(sq, tot, wat, inp, show)
sq = apply_meta_filters(sq, PlexShow, p, lang)
if tsq is not None:
sq = sq.where(vector_match(PlexShow, lang, tsq))
if recheck is not None and (narrow := recheck(PlexShow, field, tsq, lang)) is not None:
sq = sq.where(narrow)
selects.append(sq)
total = 0
rows: list = []
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 = sort_column(u, sort, lang)
order = [(col.asc() if sort_dir == "asc" else col.desc()).nullslast()]
order.append(u.c.rk.desc())
rows = list(db.execute(select(u).order_by(*order).offset(offset).limit(limit)).all())
# 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.
eps: list = []
if tsq is not None and want_shows and field in ("all", "episode"):
ep_st = aliased(PlexState)
# In HU an episode is found by its Hungarian title too (the overlay fills it) — and
# still by its English one, like `vector_match` does for the grid.
if field == "episode":
ep_vec = func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title, ""))
ep_vec_hu = func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title_hu, ""))
else:
ep_vec, ep_vec_hu = PlexItem.search_vector, PlexItem.search_vector_hu
hit = ep_vec.op("@@")(tsq)
if lang == "hu":
hit = or_(ep_vec_hu.op("@@")(tsq), hit)
ep_vec = ep_vec_hu
eps = list(
db.execute(
select(PlexItem, ep_st, PlexShow)
.outerjoin(ep_st, and_(ep_st.item_id == PlexItem.id, owned_by(ep_st.user_id, user)))
.outerjoin(PlexShow, PlexShow.id == PlexItem.show_id)
.where(
PlexItem.kind == "episode",
PlexItem.library_id.in_(show_lib_ids),
hit,
)
.order_by(func.ts_rank(ep_vec, tsq).desc())
.limit(24)
).all()
)
return total, rows, eps
def facets(db: Session, user: User, *, scope: str, show: str, lang: str, f: LibraryFilters) -> dict:
"""Available filter values for the unified library (scope movie|show|both), ADAPTED to the active
filters (faceted search): each group's values/bounds are computed with all the OTHER active
filters applied but NOT its own — so selecting a filter narrows what the other groups offer, while
a multi-select group still lets you add more. Merged across the scope's libraries; duration is
movie-only. (Search text is not factored in — facets track the sidebar filters.) `lang` is
normalised here, after the empty answer."""
empty = {
"genres": [],
"content_ratings": [],
"studios": [],
"playable": [],
"year_min": None,
"year_max": None,
"rating_max": None,
"duration_min": None,
"duration_max": None,
}
all_movie_ids, all_show_ids = content_libs(db)
movie_ids = all_movie_ids if scope in ("movie", "both") else []
show_ids = all_show_ids if scope in ("show", "both") else []
if not movie_ids and not show_ids:
return empty
lang = norm_lang(lang)
p = f.as_dict()
uid = user.id
# The shows that pass the watch-state scope, asked ONCE. The per-user episode rollup behind it is a
# full pass over the user's episodes (~10 ms on a prod-sized library), and each of the show groups
# (genres, ratings, studios, years, rating max) asked it again: half of a filtered facets call.
# The same filter on the same rows, so the counts do not change
# (test_plex_facets_grid_parity).
shows_in_scope: list[int] | None = None
if show_ids and show in ("watched", "in_progress", "unwatched"):
agg = rollup.agg_subquery(db, uid).subquery()
in_scope = select(PlexShow.id).outerjoin(agg, agg.c.show_id == PlexShow.id)
in_scope = apply_show_state_filter(
in_scope,
func.coalesce(agg.c.total, 0),
func.coalesce(agg.c.watched, 0),
func.coalesce(agg.c.inprog, 0),
show,
)
shows_in_scope = list(db.scalars(in_scope.where(PlexShow.library_id.in_(show_ids))))
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.where(model.library_id.in_(ids), *meta_filter_conds(model, pp, lang))
if kind_movie:
query = query.where(model.kind == "movie")
if with_dur and pp.get("duration_min") is not None:
query = query.where(model.duration_s >= pp["duration_min"])
if with_dur and pp.get("duration_max") is not None:
query = query.where(model.duration_s <= pp["duration_max"])
# Playback-class filter (movie-only) — applied for the OTHER groups so an active class
# narrows them; the playable facet itself passes pp with playable=None (self-exclusion).
query = query.where(*playable_conds(split_csv(pp.get("playable"))))
# Join watch-state and reuse the grid's exact movie state filter (unified_library) —
# 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, owned_by(st.user_id, uid)))
query = apply_movie_state_filter(query, st, show)
elif shows_in_scope is not None:
query = query.where(model.id.in_(shows_in_scope))
return query
genre_counts: dict[str, int] = {}
cr_counts: dict[str, int] = {}
studio_counts: dict[str, int] = {}
playable_counts: dict[str, int] = {}
years: list[int] = []
ratings: list[float] = []
durs: list[int] = []
def per_table(model, ids, kind_movie: bool) -> None:
if not ids:
return
# Genres — in "any"/OR mode, exclude the genre filter itself so each offered genre is a valid
# OR-addition that only widens (keep adding). In "all"/AND mode, KEEP the filter applied so we
# offer only genres that CO-OCCUR on the current result set — a non-co-occurring genre would AND
# the result down to zero, i.e. a dead-end chip. (The already-selected genres still appear, since
# every matching title carries them, so they stay togglable.)
genre_pp = p if p.get("genre_mode") == "all" else {**p, "genres": None}
# Only rows whose `genres` is a JSON array can be unnested. A genre-less item stores JSON
# `null` (SQLAlchemy's JSONB persists a Python None as JSON null, not SQL NULL), and
# jsonb_array_elements_text() raises on a scalar ("cannot extract elements from a scalar") —
# so a single metadata-poor library (e.g. one bare Shows library with no genres) 500'd the
# whole facets endpoint. Mirror the exact guard people.py already applies to the same columns.
# In HU the vocabulary comes from the active-language genre column (coalesce(genres_hu, genres)).
gcol = genres_col(model, lang)
expanded = (
wsq(
select(func.jsonb_array_elements_text(gcol).label("g")),
model,
ids,
genre_pp,
kind_movie,
True,
)
.where(func.jsonb_typeof(gcol) == "array")
.subquery()
)
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(
select(model.content_rating, func.count()),
model,
ids,
{**p, "content_ratings": None},
kind_movie,
True,
)
.where(model.content_rating.isnot(None))
.group_by(model.content_rating)
)
for cr, c in db.execute(crq).all():
cr_counts[cr] = cr_counts.get(cr, 0) + c
# Studios — exclude its own (each offered studio is a valid OR-addition that only widens).
stq = (
wsq(
select(model.studio, func.count()),
model,
ids,
{**p, "studios": None},
kind_movie,
True,
)
.where(model.studio.isnot(None))
.group_by(model.studio)
)
for s, c in db.execute(stq).all():
studio_counts[s] = studio_counts.get(s, 0) + c
# Playback class (movie-only) — exclude its own so each class stays an addable OR option.
if kind_movie:
pcls = playback_class_expr()
pcq = (
wsq(
select(pcls.label("cls"), func.count()),
model,
ids,
{**p, "playable": None},
kind_movie,
True,
)
.where(pcls.isnot(None))
.group_by(pcls)
)
for cls, c in db.execute(pcq).all():
playable_counts[cls] = playable_counts.get(cls, 0) + c
# Year bounds — exclude the year filter.
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,
)
).one()
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. Coalesce(imdb, audience) to match the score the
# rating filter and the UI badge use (IMDb preferred, audience fallback).
br = db.scalar(
wsq(
select(func.max(func.coalesce(model.imdb_rating, 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 = db.execute(
wsq(
select(func.min(model.duration_s), func.max(model.duration_s)),
model,
ids,
p,
kind_movie,
False,
)
).one()
if bd[0] is not None:
durs.append(bd[0])
if bd[1] is not None:
durs.append(bd[1])
per_table(PlexItem, movie_ids, True)
per_table(PlexShow, show_ids, False)
# Always surface the actively-selected genres (even a zero-match AND combo, where they'd otherwise
# be absent): the sidebar renders genres solely from this list and hides the whole genre section —
# chips AND the Any/All toggle — when it's empty, which would trap the user with no per-chip way to
# undo a zero-result selection. Count 0 is fine (genre chips don't display counts).
for g in split_csv(f.genres):
genre_counts.setdefault(g, 0)
# Same for the actively-selected studios, so a selected studio stays removable even at zero matches.
for s in split_csv(f.studios):
studio_counts.setdefault(s, 0)
# Keep an actively-selected playback class visible even at zero matches (the sidebar hides the whole
# section when its facet list is empty, which would trap a zero-result selection with no way to undo).
for c in split_csv(f.playable):
if c in PLAYBACK_CLASSES:
playable_counts.setdefault(c, 0)
# Names in the reader's alphabet: the genres are Hungarian words for a Hungarian reader.
alpha = {w: i for i, w in enumerate(sorted_words(db, [*genre_counts, *studio_counts], lang))}
return {
"genres": [
{"value": g, "count": c}
for g, c in sorted(genre_counts.items(), key=lambda kv: alpha[kv[0]])
],
"content_ratings": [
{"value": cr, "count": c} for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1])
],
"studios": [
{"value": s, "count": c}
for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], alpha[kv[0]]))
],
# Fixed good→heavy order (not by count), so the class list reads as a severity scale.
"playable": [
{"value": c, "count": playable_counts[c]}
for c in PLAYBACK_CLASSES
if c in playable_counts
],
"year_min": min(years) if years else None,
"year_max": max(years) if years else None,
"rating_max": max(ratings) if ratings else None,
"duration_min": min(durs) if durs else None,
"duration_max": max(durs) if durs else None,
}
+382
View File
@@ -0,0 +1,382 @@
"""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
+1 -1
View File
@@ -236,7 +236,7 @@ def apply_meta_filters(q, model, p: dict, lang: str = "en"):
# The 4 classes the feed filter + facet expose, derived from the stored `playable` + `transcode_hw`. # The 4 classes the feed filter + facet expose, derived from the stored `playable` + `transcode_hw`.
# ONE definition (this SQL CASE) so the filter WHERE and the facet counts can't drift: `playable_conds` # ONE definition (this SQL CASE) so the filter WHERE and the facet counts can't drift: `playable_conds`
# filters on it, `facets` groups by it. transcode_hw IS NOT True groups NULL (unprobed) with software. # filters on it, `facets` groups by it. transcode_hw IS NOT True groups NULL (unprobed) with software.
# Public — it is the shared source of truth imported by routes.plex.browse (facet order + zero-fill). # Public — it is the shared source of truth imported by plex.library (facet order + zero-fill).
PLAYBACK_CLASSES = ("direct", "remux", "transcode_hw", "transcode_sw") PLAYBACK_CLASSES = ("direct", "remux", "transcode_hw", "transcode_sw")
+81
View File
@@ -0,0 +1,81 @@
"""Plex's writes for the HTTP layer, one function per use case. None of them commits: the handler
does, so a page that fails to save an enrichment still renders, and a Plex write and its local
mirror land together.
The rest of the mirror's writes stay with the sync (`plex.sync`): a collection's targeted re-sync
after a write-back, its local cleanup after a delete.
"""
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app import logs
from app.db import SessionLocal
from app.plex import detail as plex_detail
from app.plex import people as plex_people
from app.plex import tmdb as plex_tmdb
from app.plex.lookups import by_rating_key
from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexPerson, PlexShow
log = logs.get_logger(__name__)
def enrich_show(db: Session, sh: PlexShow, meta: dict) -> None:
"""Fill a not-yet-enriched show from its live metadata: the full cast (filterable), IMDb and
tagline, stamped so later views serve from the row."""
plex_people.enrich_row_people(db, sh, plex_people.cast_from_meta(meta))
plex_detail.apply_rich_detail(sh, meta)
sh.people_enriched_at = datetime.now(timezone.utc)
def enrich_movie(db: Session, it: PlexItem, meta: dict) -> None:
"""The same for a movie. The listing sync stored only its top ~3 cast; the live metadata
carries all of them."""
cast = plex_people.cast_from_meta(meta)
if cast:
plex_people.enrich_row_people(db, it, cast)
plex_detail.apply_rich_detail(it, meta)
it.people_enriched_at = datetime.now(timezone.utc)
def set_collection_editable(col: PlexCollection, editable: bool) -> None:
col.editable = editable
def keep_created_collection(
db: Session, lib: PlexLibrary, rating_key: str, title: str, seed: str
) -> None:
"""A collection Plex made, whose follow-up read failed: its row is kept, editable. The full sync
preserves `editable` but creates a missing row read-only, so the collection would drop out of
every "add to" list; and the duplicate-name check reads these rows, so a retry of the same name
would make a second one on Plex. Plex made it with exactly the seed in it, so the seed's row says
so too — otherwise the title showed unticked in the collection it was just added to, and the
collection empty, until that sync."""
db.add(
PlexCollection(
rating_key=rating_key, library_id=lib.id, title=title, editable=True, child_count=1
)
)
Model = PlexItem if lib.kind == "movie" else PlexShow
if row := by_rating_key(db, Model, seed):
row.collection_keys = list(dict.fromkeys([*(row.collection_keys or []), rating_key]))
db.flush()
def enrich_person_bg(person_id: int) -> None:
"""Fetch + persist one person's TMDB bio OFF the request path — a FastAPI background task that runs
AFTER the response is sent, so the outbound TMDB call never sits on the serve latency (the module's
"no live TMDB call at serve time" invariant). The request's session is already closed by then, so
open a fresh one. Best-effort: a TMDB failure just leaves `tmdb_enriched_at` NULL to retry on the
next open, and the bio lands from the row by then."""
db = SessionLocal()
try:
person = db.get(PlexPerson, person_id)
if person is not None:
plex_tmdb.enrich_person(db, person)
except Exception: # noqa: BLE001 — background best-effort; never surfaces to a user
db.rollback()
log.warning("background person bio enrich failed for id=%s", person_id, exc_info=True)
finally:
db.close()
+8 -4
View File
@@ -1,18 +1,22 @@
"""Shared helpers, serializers and constants for the plex route package.""" """Shared helpers, serializers and constants for the plex route package."""
from typing import TYPE_CHECKING
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import logs from app import logs
from app.plex.models import PlexItem from app.plex import queries
from app.plex.cards import norm_lang as _norm_lang # noqa: F401 — the package's historic name from app.plex.cards import norm_lang as _norm_lang # noqa: F401 — the package's historic name
from app.plex.lookups import by_rating_key
from app.routes._common import not_found from app.routes._common import not_found
if TYPE_CHECKING:
from app.plex.models import PlexItem
log = logs.get_logger(__name__) log = logs.get_logger(__name__)
def item_or_404(db: Session, rating_key: str) -> PlexItem: def item_or_404(db: Session, rating_key: str) -> "PlexItem":
it = by_rating_key(db, PlexItem, rating_key) it = queries.item(db, rating_key)
if it is None: if it is None:
raise not_found("plex_item") raise not_found("plex_item")
return it return it
+23 -464
View File
@@ -1,105 +1,30 @@
"""Plex browse: the library list, the unified cross-library browse feed, and its faceted filters.""" """Plex browse: the library list, the unified cross-library browse feed, and its faceted filters."""
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy import ( from sqlalchemy.orm import Session
Boolean,
Integer,
String,
and_,
cast,
func,
literal,
null,
or_,
select,
)
from sqlalchemy.orm import Session, aliased
from app.mailtext import lang_for_user
from app.collation import collated, sorted_words
from app import sysconfig from app import sysconfig
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.mailtext import lang_for_user
from app.models import User from app.models import User
from app.plex.models import PlexItem, PlexLibrary, PlexShow, PlexState from app.plex import library as plex_library
from app.plex import rollup from app.plex import queries
from app.plex.cards import display_title, episode_card from app.plex.cards import display_title, episode_card
from app.plex.query import ( from app.plex.query import LibraryFilters
FIELD_COLUMNS,
PLAYBACK_CLASSES,
TS_CONFIG,
LibraryFilters,
apply_meta_filters,
apply_movie_state_filter,
apply_show_state_filter,
content_libs,
field_recheck,
genres_col,
meta_filter_conds,
movie_status_case,
playable_conds,
playback_class_expr,
show_status_case,
split_csv,
summary_col,
title_col,
vector_col,
vector_match,
)
from app.routes.plex._shared import _norm_lang from app.routes.plex._shared import _norm_lang
from app.utils import to_tsquery_str
router = APIRouter(prefix="/api/plex", tags=["plex"]) router = APIRouter(prefix="/api/plex", tags=["plex"])
def sort_column(u, sort: str, lang: str = "en"):
"""The browse grid's sort key -> orderable column on the union subquery. Module-level (not inline
in the handler) so a test can pin its vocabulary to `PLEX_SORT_KEYS` — a key the Sort control
offers but this cannot order would quietly fall back to `added_at` and look like a broken control.
An unknown key (a stale bookmark, a hand-edited URL) deliberately keeps that fallback. The title sorts
in the reader's alphabet (`collation`)."""
return {
"added": u.c.added_at,
"title": collated(u.c.title, lang),
"year": u.c.year,
"rating": u.c.rating,
"duration": u.c.duration_s,
"release": u.c.rel,
"lastwatched": u.c.last_watched,
}.get(sort, u.c.added_at)
@router.get("/libraries") @router.get("/libraries")
def list_libraries(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: 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). HU overlay """The mirrored libraries the user can browse (drives the Plex scope selector). HU overlay
libraries are excluded — they're not browsable, they only feed the EN twin's Hungarian metadata.""" libraries are excluded — they're not browsable, they only feed the EN twin's Hungarian metadata."""
libs = ( out = [
db.execute( {"key": lib.plex_key, "title": lib.title, "kind": lib.kind, "count": count}
select(PlexLibrary) for lib, count in queries.browsable_libraries(db, lang_for_user(user))
.where(PlexLibrary.overlay_of.is_(None)) ]
.order_by(PlexLibrary.kind.desc(), collated(PlexLibrary.title, lang_for_user(user)))
)
.scalars()
.all()
)
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(
{
"key": lib.plex_key,
"title": lib.title,
"kind": lib.kind,
"count": count or 0,
}
)
return {"enabled": sysconfig.get_bool(db, "plex_enabled"), "libraries": out} return {"enabled": sysconfig.get_bool(db, "plex_enabled"), "libraries": out}
@@ -124,126 +49,21 @@ 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)
lang = _norm_lang(lang) lang = _norm_lang(lang)
p = f.as_dict() total, rows, eps = plex_library.library_page(
movie_lib_ids, show_lib_ids = content_libs(db) db,
want_movies = scope in ("movie", "both") and bool(movie_lib_ids) user,
want_shows = scope in ("show", "both") and bool(show_lib_ids) scope=scope,
q=q,
ts = to_tsquery_str(q) if q else None field=field,
tsq = func.to_tsquery(TS_CONFIG, ts) if ts else None sort=sort,
if field not in FIELD_COLUMNS and field != "all": sort_dir=sort_dir,
field = "all" show=show,
# `episode` scope targets episode rows only — the movie/show grid stays empty; the recheck for lang=lang,
# the specific title/people/summary fields is a strict narrowing on top of the GIN prefilter. offset=offset,
grid_field = field != "episode" limit=limit,
recheck = field_recheck if tsq is not None and field not in ("all", "episode") else None f=f,
selects = []
if want_movies and grid_field:
st = aliased(PlexState)
m_status = movie_status_case(st)
mq = (
select(
PlexItem.rating_key.label("rk"),
literal("movie").label("kind"),
title_col(PlexItem, lang).label("title"),
PlexItem.year.label("year"),
PlexItem.rating.label("rating"),
# Truncated so the card hover-overview costs a few hundred bytes, not the full synopsis.
func.left(summary_col(PlexItem, lang), 300).label("summary"),
PlexItem.added_at.label("added_at"),
PlexItem.originally_available_at.label("rel"),
PlexItem.duration_s.label("duration_s"),
PlexItem.playable.label("playable"),
PlexItem.transcode_hw.label("transcode_hw"),
cast(null(), Integer).label("season_count"),
func.coalesce(st.position_seconds, 0).label("position_seconds"),
m_status.label("status"),
literal(False).label("resumable"),
# "last watched" sort key: when the movie was last resumed, else when it was marked
# watched. NULL for never-touched movies → nullslast sinks them (see the sort dict).
func.coalesce(st.progress_updated_at, st.watched_at).label("last_watched"),
(
func.ts_rank(vector_col(PlexItem, lang), tsq)
if tsq is not None
else literal(0.0)
).label("rank"),
) )
.outerjoin(st, and_(st.item_id == PlexItem.id, st.user_id == user.id))
.where(PlexItem.kind == "movie", PlexItem.library_id.in_(movie_lib_ids))
)
mq = apply_movie_state_filter(mq, st, show)
mq = apply_meta_filters(mq, PlexItem, p, lang)
if f.duration_min is not None:
mq = mq.where(PlexItem.duration_s >= f.duration_min)
if f.duration_max is not None:
mq = mq.where(PlexItem.duration_s <= f.duration_max)
# Playback-class filter (movie-only — a show is an aggregate with no single class).
mq = mq.where(*playable_conds(split_csv(f.playable)))
if tsq is not None:
mq = mq.where(vector_match(PlexItem, lang, tsq))
if recheck is not None:
mq = mq.where(recheck(PlexItem, field, tsq, lang))
selects.append(mq)
if want_shows and grid_field:
agg = rollup.agg_subquery(db, user.id, include_resumed=True).subquery()
tot = func.coalesce(agg.c.total, 0)
wat = func.coalesce(agg.c.watched, 0)
inp = func.coalesce(agg.c.inprog, 0)
s_status = show_status_case(tot, wat, inp)
sq = (
select(
PlexShow.rating_key.label("rk"),
literal("show").label("kind"),
title_col(PlexShow, lang).label("title"),
PlexShow.year.label("year"),
PlexShow.rating.label("rating"),
func.left(summary_col(PlexShow, lang), 300).label("summary"),
PlexShow.added_at.label("added_at"),
PlexShow.originally_available_at.label("rel"),
cast(null(), Integer).label("duration_s"),
cast(null(), String).label("playable"),
cast(null(), Boolean).label("transcode_hw"),
PlexShow.child_count.label("season_count"),
literal(0).label("position_seconds"),
s_status.label("status"),
# An episode holds a resume point (`ShowState.resumable`, the card's Continue + Restart).
(inp > 0).label("resumable"),
# Show leg's "last watched": max(resume/watched time) across the show's episodes (see
# rollup.agg_subquery include_resumed). NULL for a never-watched show → nullslast.
agg.c.resumed_at.label("last_watched"),
(
func.ts_rank(vector_col(PlexShow, lang), tsq)
if tsq is not None
else literal(0.0)
).label("rank"),
)
.outerjoin(agg, agg.c.show_id == PlexShow.id)
.where(PlexShow.library_id.in_(show_lib_ids))
)
sq = apply_show_state_filter(sq, tot, wat, inp, show)
sq = apply_meta_filters(sq, PlexShow, p, lang)
if tsq is not None:
sq = sq.where(vector_match(PlexShow, lang, tsq))
if recheck is not None:
sq = sq.where(recheck(PlexShow, field, tsq, lang))
selects.append(sq)
items = [] items = []
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 = sort_column(u, sort, lang)
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: for r in rows:
if r.kind == "movie": if r.kind == "movie":
items.append( items.append(
@@ -278,35 +98,7 @@ def unified_library(
} }
) )
# 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 = [] episodes = []
if tsq is not None and want_shows and field in ("all", "episode"):
ep_st = aliased(PlexState)
# In HU an episode is found by its Hungarian title too (the overlay fills it) — and
# still by its English one, like `vector_match` does for the grid.
if field == "episode":
ep_vec = func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title, ""))
ep_vec_hu = func.to_tsvector(TS_CONFIG, func.coalesce(PlexItem.title_hu, ""))
else:
ep_vec, ep_vec_hu = PlexItem.search_vector, PlexItem.search_vector_hu
hit = ep_vec.op("@@")(tsq)
if lang == "hu":
hit = or_(ep_vec_hu.op("@@")(tsq), hit)
ep_vec = ep_vec_hu
eps = db.execute(
select(PlexItem, ep_st, PlexShow)
.outerjoin(ep_st, and_(ep_st.item_id == PlexItem.id, ep_st.user_id == user.id))
.outerjoin(PlexShow, PlexShow.id == PlexItem.show_id)
.where(
PlexItem.kind == "episode",
PlexItem.library_id.in_(show_lib_ids),
hit,
)
.order_by(func.ts_rank(ep_vec, tsq).desc())
.limit(24)
).all()
for e, s, sh in eps: for e, s, sh in eps:
card = episode_card(e, s, lang=lang) card = episode_card(e, s, lang=lang)
card["show_title"] = display_title(sh, lang) if sh is not None else None card["show_title"] = display_title(sh, lang) if sh is not None else None
@@ -336,237 +128,4 @@ def facets(
filters applied but NOT its own — so selecting a filter narrows what the other groups offer, while filters applied but NOT its own — so selecting a filter narrows what the other groups offer, while
a multi-select group still lets you add more. Merged across the scope's libraries; duration is a multi-select group still lets you add more. Merged across the scope's libraries; duration is
movie-only. (Search text is not factored in — facets track the sidebar filters.)""" movie-only. (Search text is not factored in — facets track the sidebar filters.)"""
empty = { return plex_library.facets(db, user, scope=scope, show=show, lang=lang, f=f)
"genres": [],
"content_ratings": [],
"studios": [],
"playable": [],
"year_min": None,
"year_max": None,
"rating_max": None,
"duration_min": None,
"duration_max": None,
}
all_movie_ids, all_show_ids = content_libs(db)
movie_ids = all_movie_ids if scope in ("movie", "both") else []
show_ids = all_show_ids if scope in ("show", "both") else []
if not movie_ids and not show_ids:
return empty
lang = _norm_lang(lang)
p = f.as_dict()
uid = user.id
# The shows that pass the watch-state scope, asked ONCE. The per-user episode rollup behind it is a
# full pass over the user's episodes (~10 ms on a prod-sized library), and each of the show groups
# (genres, ratings, studios, years, rating max) asked it again: half of a filtered facets call.
# The same filter on the same rows, so the counts do not change
# (test_plex_facets_grid_parity).
shows_in_scope: list[int] | None = None
if show_ids and show in ("watched", "in_progress", "unwatched"):
agg = rollup.agg_subquery(db, uid).subquery()
in_scope = select(PlexShow.id).outerjoin(agg, agg.c.show_id == PlexShow.id)
in_scope = apply_show_state_filter(
in_scope,
func.coalesce(agg.c.total, 0),
func.coalesce(agg.c.watched, 0),
func.coalesce(agg.c.inprog, 0),
show,
)
shows_in_scope = list(db.scalars(in_scope.where(PlexShow.library_id.in_(show_ids))))
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.where(model.library_id.in_(ids), *meta_filter_conds(model, pp, lang))
if kind_movie:
query = query.where(model.kind == "movie")
if with_dur and pp.get("duration_min") is not None:
query = query.where(model.duration_s >= pp["duration_min"])
if with_dur and pp.get("duration_max") is not None:
query = query.where(model.duration_s <= pp["duration_max"])
# Playback-class filter (movie-only) — applied for the OTHER groups so an active class
# narrows them; the playable facet itself passes pp with playable=None (self-exclusion).
query = query.where(*playable_conds(split_csv(pp.get("playable"))))
# Join watch-state and reuse the grid's exact movie state filter (unified_library) —
# 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))
query = apply_movie_state_filter(query, st, show)
elif shows_in_scope is not None:
query = query.where(model.id.in_(shows_in_scope))
return query
genre_counts: dict[str, int] = {}
cr_counts: dict[str, int] = {}
studio_counts: dict[str, int] = {}
playable_counts: dict[str, int] = {}
years: list[int] = []
ratings: list[float] = []
durs: list[int] = []
def per_table(model, ids, kind_movie: bool) -> None:
if not ids:
return
# Genres — in "any"/OR mode, exclude the genre filter itself so each offered genre is a valid
# OR-addition that only widens (keep adding). In "all"/AND mode, KEEP the filter applied so we
# offer only genres that CO-OCCUR on the current result set — a non-co-occurring genre would AND
# the result down to zero, i.e. a dead-end chip. (The already-selected genres still appear, since
# every matching title carries them, so they stay togglable.)
genre_pp = p if p.get("genre_mode") == "all" else {**p, "genres": None}
# Only rows whose `genres` is a JSON array can be unnested. A genre-less item stores JSON
# `null` (SQLAlchemy's JSONB persists a Python None as JSON null, not SQL NULL), and
# jsonb_array_elements_text() raises on a scalar ("cannot extract elements from a scalar") —
# so a single metadata-poor library (e.g. one bare Shows library with no genres) 500'd the
# whole facets endpoint. Mirror the exact guard people.py already applies to the same columns.
# In HU the vocabulary comes from the active-language genre column (coalesce(genres_hu, genres)).
gcol = genres_col(model, lang)
expanded = (
wsq(
select(func.jsonb_array_elements_text(gcol).label("g")),
model,
ids,
genre_pp,
kind_movie,
True,
)
.where(func.jsonb_typeof(gcol) == "array")
.subquery()
)
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(
select(model.content_rating, func.count()),
model,
ids,
{**p, "content_ratings": None},
kind_movie,
True,
)
.where(model.content_rating.isnot(None))
.group_by(model.content_rating)
)
for cr, c in db.execute(crq).all():
cr_counts[cr] = cr_counts.get(cr, 0) + c
# Studios — exclude its own (each offered studio is a valid OR-addition that only widens).
stq = (
wsq(
select(model.studio, func.count()),
model,
ids,
{**p, "studios": None},
kind_movie,
True,
)
.where(model.studio.isnot(None))
.group_by(model.studio)
)
for s, c in db.execute(stq).all():
studio_counts[s] = studio_counts.get(s, 0) + c
# Playback class (movie-only) — exclude its own so each class stays an addable OR option.
if kind_movie:
pcls = playback_class_expr()
pcq = (
wsq(
select(pcls.label("cls"), func.count()),
model,
ids,
{**p, "playable": None},
kind_movie,
True,
)
.where(pcls.isnot(None))
.group_by(pcls)
)
for cls, c in db.execute(pcq).all():
playable_counts[cls] = playable_counts.get(cls, 0) + c
# Year bounds — exclude the year filter.
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. Coalesce(imdb, audience) to match the score the
# rating filter and the UI badge use (IMDb preferred, audience fallback).
br = db.scalar(
wsq(
select(func.max(func.coalesce(model.imdb_rating, 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 = 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])
if bd[1] is not None:
durs.append(bd[1])
per_table(PlexItem, movie_ids, True)
per_table(PlexShow, show_ids, False)
# Always surface the actively-selected genres (even a zero-match AND combo, where they'd otherwise
# be absent): the sidebar renders genres solely from this list and hides the whole genre section —
# chips AND the Any/All toggle — when it's empty, which would trap the user with no per-chip way to
# undo a zero-result selection. Count 0 is fine (genre chips don't display counts).
for g in split_csv(f.genres):
genre_counts.setdefault(g, 0)
# Same for the actively-selected studios, so a selected studio stays removable even at zero matches.
for s in split_csv(f.studios):
studio_counts.setdefault(s, 0)
# Keep an actively-selected playback class visible even at zero matches (the sidebar hides the whole
# section when its facet list is empty, which would trap a zero-result selection with no way to undo).
for c in split_csv(f.playable):
if c in PLAYBACK_CLASSES:
playable_counts.setdefault(c, 0)
# Names in the reader's alphabet: the genres are Hungarian words for a Hungarian reader.
alpha = {w: i for i, w in enumerate(sorted_words(db, [*genre_counts, *studio_counts], lang))}
return {
"genres": [
{"value": g, "count": c}
for g, c in sorted(genre_counts.items(), key=lambda kv: alpha[kv[0]])
],
"content_ratings": [
{"value": cr, "count": c} for cr, c in sorted(cr_counts.items(), key=lambda kv: -kv[1])
],
"studios": [
{"value": s, "count": c}
for s, c in sorted(studio_counts.items(), key=lambda kv: (-kv[1], alpha[kv[0]]))
],
# Fixed good→heavy order (not by count), so the class list reads as a severity scale.
"playable": [
{"value": c, "count": playable_counts[c]}
for c in PLAYBACK_CLASSES
if c in playable_counts
],
"year_min": min(years) if years else None,
"year_max": max(years) if years else None,
"rating_max": max(ratings) if ratings else None,
"duration_min": min(durs) if durs else None,
"duration_max": max(durs) if durs else None,
}
+55 -262
View File
@@ -1,90 +1,41 @@
"""Plex collections: browse mirrored collections + the admin manual-collection editor.""" """Plex collections: browse mirrored collections + the admin manual-collection editor."""
from collections.abc import Callable from datetime import datetime
from datetime import date, datetime from typing import TYPE_CHECKING
from typing import Any, NamedTuple
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.mailtext import lang_for_user from app.mailtext import lang_for_user
from app.collation import collated, sorted_words from app.collation import sorted_words
from app import logs from app import logs
from app.auth import admin_user, current_user from app.auth import admin_user, current_user
from app.db import get_db from app.db import get_db
from app.models import User from app.models import User
from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, PlexState from app.plex import queries, rollup, service
from app.plex import rollup
from app.plex import sync as plex_sync from app.plex import sync as plex_sync
from app.plex.cards import SHOW_NEW, display_title, movie_card, norm_lang, show_card from app.plex.cards import SHOW_NEW, display_title, movie_card, norm_lang, show_card
from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.lookups import by_rating_key from app.plex.collections import (
from app.plex.query import content_libs, runtime_by_key, title_col, top_art_keys DEFAULT_MEMBER_SORT,
from app.utils import like_escape Member,
collection_card,
collection_cert,
collection_editable,
collection_source,
sort_members,
)
from app.refusal import refused from app.refusal import refused
from app.routes._common import not_found from app.routes._common import not_found
if TYPE_CHECKING:
from app.plex.models import PlexCollection, PlexState
log = logs.get_logger(__name__) log = logs.get_logger(__name__)
# The most-restrictive → least ordering used to pick a collection's effective content rating from its
# members (Plex shows one cert on the collection; we don't store it, so derive it).
_CERT_RANK = {
"G": 1,
"TV-Y": 1,
"TV-Y7": 1,
"TV-G": 1,
"PG": 2,
"TV-PG": 2,
"PG-13": 3,
"TV-14": 3,
"R": 4,
"TV-MA": 4,
"NC-17": 5,
}
def _collection_cert(certs: list[str | None]) -> str | None:
ranked = [(c, _CERT_RANK.get(c, 0)) for c in certs if c]
return max(ranked, key=lambda x: x[1])[0] if ranked else None
router = APIRouter(prefix="/api/plex", tags=["plex"]) router = APIRouter(prefix="/api/plex", tags=["plex"])
_STRIP_PROVIDERS = ("imdb", "tmdb", "tvdb", "trakt")
def _collection_source(col: PlexCollection) -> str:
first = (col.title or "").strip().split(" ", 1)[0].lower()
if first in _STRIP_PROVIDERS:
return first
if col.smart:
return "smart"
return "collection"
def _collection_editable(c: PlexCollection) -> bool:
"""Effective editability: the admin marked it editable AND it's a plain manual collection — never
smart, never an external auto-list (IMDb/TMDb/…), which are managed outside Plex."""
return bool(c.editable) and not c.smart and _collection_source(c) == "collection"
def _collection_card(c: PlexCollection) -> dict:
return {
"id": c.rating_key,
"title": c.title,
"summary": c.summary,
"thumb": f"/api/plex/image/{c.rating_key}" if c.thumb_key else None,
"child_count": c.child_count,
"smart": c.smart,
"source": _collection_source(c),
"editable": c.editable,
"can_edit": _collection_editable(
c
), # whether the edit controls should be offered (admin UI)
}
@router.get("/collections") @router.get("/collections")
def collections( def collections(
@@ -104,64 +55,22 @@ def collections(
sidebar token and the applied-filter chip had nothing to render but the raw id. Resolving them sidebar token and the applied-filter chip had nothing to render but the raw id. Resolving them
through this endpoint costs one small row each; pulling the whole list to find two titles is through this endpoint costs one small row each; pulling the whole list to find two titles is
~200 KB.""" ~200 KB."""
if library is not None: wanted = [k for k in keys.split(",") if k] if keys is not None else None
lib = ( rows = queries.collections_page(db, library, q, wanted, lang_for_user(user))
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == str(library)))
.scalars()
.first()
)
if lib is None:
return {"collections": []}
query = select(PlexCollection).where(PlexCollection.library_id == lib.id)
else:
enabled_ids = list(
db.execute(
select(PlexLibrary.id).where(
PlexLibrary.enabled.is_(True), PlexLibrary.overlay_of.is_(None)
)
).scalars()
)
if not enabled_ids:
return {"collections": []}
query = select(PlexCollection).where(PlexCollection.library_id.in_(enabled_ids))
term = (q or "").strip()
if term:
query = query.where(PlexCollection.title.ilike(f"%{like_escape(term)}%"))
if keys is not None:
wanted = [k for k in (keys or "").split(",") if k]
# An explicit but empty `keys=` asks for nothing — answer nothing, rather than falling through
# to "every collection" (which is what an unguarded empty IN () would have to become).
if not wanted:
return {"collections": []}
query = query.where(PlexCollection.rating_key.in_(wanted))
rows = (
db.execute(
query.order_by(
PlexCollection.child_count.desc().nullslast(),
collated(PlexCollection.title, lang_for_user(user)),
)
)
.scalars()
.all()
)
# A montage of the top titles per tile + the collection's total runtime. Art unions MOVIE and SHOW # A montage of the top titles per tile + the collection's total runtime. Art unions MOVIE and SHOW
# members (a collection lives in one library, so exactly one side has art) — a show collection would # members (a collection lives in one library, so exactly one side has art) — a show collection would
# otherwise render blank; the collection's own poster is the next fallback, then the colour hash. # otherwise render blank; the collection's own poster is the next fallback, then the colour hash.
# Runtime is movie-only (shows have no single duration). # Runtime is movie-only (shows have no single duration).
movie_ids, show_ids = content_libs(db) art_m, art_s, runtime = queries.collection_tiles(db, [c.rating_key for c in rows])
keys = [c.rating_key for c in rows]
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)
def _art(c: PlexCollection) -> list[str]: def _art(c: "PlexCollection") -> list[str]:
member = art_m.get(c.rating_key) or art_s.get(c.rating_key) member = art_m.get(c.rating_key) or art_s.get(c.rating_key)
return member or ([c.rating_key] if c.thumb_key else []) return member or ([c.rating_key] if c.thumb_key else [])
return { return {
"collections": [ "collections": [
{ {
**_collection_card(c), **collection_card(c),
"art": _art(c), "art": _art(c),
"total_runtime_s": runtime.get(c.rating_key, 0), "total_runtime_s": runtime.get(c.rating_key, 0),
} }
@@ -179,89 +88,21 @@ def collections_for_item(
"""What a movie or show card's "Add to collection" lists: the editable collections of the title's """What a movie or show card's "Add to collection" lists: the editable collections of the title's
library (a collection lives in one library, and only a manual one can take a title — the same library (a collection lives in one library, and only a manual one can take a title — the same
`can_edit` the editor offers), each saying whether the title is in it already, by name.""" `can_edit` the editor offers), each saying whether the title is in it already, by name."""
movie = by_rating_key(db, PlexItem, item_rating_key) movie = queries.movie(db, item_rating_key)
title: PlexItem | PlexShow | None = ( title = movie or queries.show(db, item_rating_key)
movie if movie is not None and movie.kind == "movie" else None
) or by_rating_key(db, PlexShow, item_rating_key)
if title is None: if title is None:
raise not_found("item") raise not_found("item")
member_of = set(title.collection_keys or []) member_of = set(title.collection_keys or [])
rows = ( rows = queries.library_collections(db, title.library_id, lang_for_user(user))
db.execute(
select(PlexCollection)
.where(PlexCollection.library_id == title.library_id)
.order_by(collated(PlexCollection.title, lang_for_user(user)))
)
.scalars()
.all()
)
return { return {
"collections": [ "collections": [
{**_collection_card(c), "contains": c.rating_key in member_of} {**collection_card(c), "contains": c.rating_key in member_of}
for c in rows for c in rows
if _collection_editable(c) if collection_editable(c)
] ]
} }
class Member(NamedTuple):
"""One collection member flattened to everything the Sort control can order by. The page's two
legs are different tables (movies and shows), so there is no single query to ORDER BY — the
ordering is a pure list sort over these, which is also what makes it unit-testable."""
kind: str # "movie" | "show"
obj: Any # PlexItem | PlexShow, carried through to the card serializer
title: str # lower-cased; never NULL (the column is not nullable)
year: int | None
rating: float | None
added: datetime | None
release: date | None
duration: int | None # movies only — a show has no single runtime
last_watched: datetime | None
# Sort key -> what to read off a Member. Pinned to `PLEX_SORT_KEYS` by a test: the collection page
# shows the SAME Sort control as the browse grid, so a key this map is missing would be an option
# that silently does nothing here.
MEMBER_SORT_KEYS: dict[str, Callable[[Member], Any]] = {
"added": lambda m: m.added,
"lastwatched": lambda m: m.last_watched,
"release": lambda m: m.release,
"year": lambda m: m.year,
"rating": lambda m: m.rating,
"duration": lambda m: m.duration,
"title": lambda m: m.title,
}
# What the page ordered by before it had a control, and still its default: a film series wants to be
# read in release order, not by whenever Plex happened to ingest it.
DEFAULT_MEMBER_SORT = "year"
def sort_members(
members: list[Member], sort: str, sort_dir: str, alpha: dict[str, int] | None = None
) -> list[Member]:
"""Order a collection's members by one of `PLEX_SORT_KEYS`.
Two properties worth stating because they are easy to get wrong with a plain `reverse=True`:
members with no value for the key sink to the END in BOTH directions (matching the browse grid's
`nullslast()` — a movie with no rating is not "the worst rated"), and ties always resolve
title-ascending, so flipping the direction reverses the ranking rather than the alphabet inside
it. An unknown key falls back to the page's default instead of returning an arbitrary order.
`alpha` ranks each title in the reader's alphabet (`collation.sorted_words`); without it, titles
compare as Python strings, which puts every accented initial after "z"."""
by_title = (lambda m: alpha[m.title]) if alpha is not None else (lambda m: m.title)
key = MEMBER_SORT_KEYS.get(sort, MEMBER_SORT_KEYS[DEFAULT_MEMBER_SORT])
if key is MEMBER_SORT_KEYS["title"]:
key = by_title
ordered = sorted(members, key=by_title) # stable secondary, applied first
have = [m for m in ordered if key(m) is not None]
missing = [m for m in ordered if key(m) is None]
have.sort(key=key, reverse=sort_dir != "asc")
return have + missing
@router.get("/collections/{rating_key}") @router.get("/collections/{rating_key}")
def collection_detail( def collection_detail(
rating_key: str, rating_key: str,
@@ -277,45 +118,20 @@ def collection_detail(
what this page did before it had a control. Read-only here; edits go through the admin mutation what this page did before it had a control. Read-only here; edits go through the admin mutation
endpoints below.""" endpoints below."""
lang = norm_lang(lang) lang = norm_lang(lang)
col = by_rating_key(db, PlexCollection, rating_key) col = queries.collection(db, rating_key)
if col is None: if col is None:
raise not_found("collection") raise not_found("collection")
movies = ( movies, shows = queries.collection_members(db, rating_key, lang)
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()
.all()
)
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()
.all()
)
mstate = rollup.item_state_map(db, user.id, [m.id for m in movies]) mstate = rollup.item_state_map(db, user.id, [m.id for m in movies])
# ONE rollup pass over the show members, for both the aggregate badge status and `resumed_at` — # ONE rollup pass over the show members, for both the aggregate badge status and `resumed_at` —
# the browse grid's "last watched" for a show (the newest resume/watched across its episodes). The # the browse grid's "last watched" for a show (the newest resume/watched across its episodes). The
# cheaper `rollup.state_map` gives only the status, which would leave `lastwatched` unorderable on # cheaper `rollup.state_map` gives only the status, which would leave `lastwatched` unorderable on
# a show collection while the control still offered it. # a show collection while the control still offered it.
show_rows = ( show_rows = queries.show_rollups(db, user.id, [s.id for s in shows])
db.execute(
rollup.agg_subquery(db, user.id, include_resumed=True).where(
PlexItem.show_id.in_([s.id for s in shows])
)
).all()
if shows
else []
)
sstate = {r.show_id: rollup.show_state(r.total, r.watched, r.inprog) for r in show_rows} sstate = {r.show_id: rollup.show_state(r.total, r.watched, r.inprog) for r in show_rows}
sresumed = {r.show_id: r.resumed_at for r in show_rows} sresumed = {r.show_id: r.resumed_at for r in show_rows}
def _movie_last_watched(st: PlexState | None) -> datetime | None: def _movie_last_watched(st: "PlexState | None") -> datetime | None:
# Same definition as the browse grid's: when it was last resumed, else when it was marked # Same definition as the browse grid's: when it was last resumed, else when it was marked
# watched (coalesce(progress_updated_at, watched_at)). # watched (coalesce(progress_updated_at, watched_at)).
return (st.progress_updated_at or st.watched_at) if st else None return (st.progress_updated_at or st.watched_at) if st else None
@@ -359,8 +175,8 @@ def collection_detail(
years = [m.year for m in members if m.year is not None] years = [m.year for m in members if m.year is not None]
certs = [m.content_rating for m in movies] + [s.content_rating for s in shows] certs = [m.content_rating for m in movies] + [s.content_rating for s in shows]
return { return {
**_collection_card(col), **collection_card(col),
"content_rating": _collection_cert(certs), "content_rating": collection_cert(certs),
"year_min": min(years) if years else None, "year_min": min(years) if years else None,
"year_max": max(years) if years else None, "year_max": max(years) if years else None,
"total_runtime_s": sum(m.duration_s or 0 for m in movies), "total_runtime_s": sum(m.duration_s or 0 for m in movies),
@@ -368,11 +184,11 @@ def collection_detail(
} }
def _editable_collection_or_403(db: Session, rating_key: str) -> PlexCollection: def _editable_collection_or_403(db: Session, rating_key: str) -> "PlexCollection":
col = by_rating_key(db, PlexCollection, rating_key) col = queries.collection(db, rating_key)
if col is None: if col is None:
raise not_found("collection") raise not_found("collection")
if not _collection_editable(col): if not collection_editable(col):
raise refused(403, "collection_read_only") raise refused(403, "collection_read_only")
return col return col
@@ -383,16 +199,7 @@ def _reject_duplicate_name(
"""No two collections in a section may share a name: Plex CONFLATES same-named collections' """No two collections in a section may share a name: Plex CONFLATES same-named collections'
membership (both rating_keys resolve to one merged member list — a real footgun), so forbid it up membership (both rating_keys resolve to one merged member list — a real footgun), so forbid it up
front on create + rename rather than mirror the mess.""" front on create + rename rather than mirror the mess."""
dup = ( dup = queries.collection_named(db, library_id, title)
db.execute(
select(PlexCollection).where(
PlexCollection.library_id == library_id,
func.lower(PlexCollection.title) == title.lower(),
)
)
.scalars()
.first()
)
if dup is not None and dup.rating_key != exclude_rk: if dup is not None and dup.rating_key != exclude_rk:
# Structured, so the bilingual UI says it in the reader's language (it was toasted in # Structured, so the bilingual UI says it in the reader's language (it was toasted in
# Hungarian beside an English error dialog saying the same thing). # Hungarian beside an English error dialog saying the same thing).
@@ -435,15 +242,13 @@ def create_collection(
seed = body.item_rating_key seed = body.item_rating_key
if not title or not seed: if not title or not seed:
raise refused(400, "collection_fields_required") raise refused(400, "collection_fields_required")
seed_show = by_rating_key(db, PlexShow, seed) seed_show = queries.show(db, seed)
if library: if library:
lib = ( lib = queries.library_by_key(db, library)
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == library)).scalars().first()
)
else: else:
# The "New collection" flow only knows the seed title → derive its library. # The "New collection" flow only knows the seed title → derive its library.
seed_item = by_rating_key(db, PlexItem, seed) or seed_show seed_item = queries.item(db, seed) or seed_show
lib = db.get(PlexLibrary, seed_item.library_id) if seed_item else None lib = queries.library(db, seed_item.library_id) if seed_item else None
if lib is None: if lib is None:
raise not_found("library") raise not_found("library")
_reject_duplicate_name(db, lib.id, title) _reject_duplicate_name(db, lib.id, title)
@@ -456,28 +261,16 @@ def create_collection(
wrote = True wrote = True
new_rk = str(new.get("ratingKey") or "") new_rk = str(new.get("ratingKey") or "")
col = plex_sync.resync_collection(db, plex, lib, new_rk) col = plex_sync.resync_collection(db, plex, lib, new_rk)
col.editable = True service.set_collection_editable(col, True)
db.commit() db.commit()
except (PlexError, PlexNotConfigured) as e: except (PlexError, PlexNotConfigured) as e:
db.rollback() db.rollback()
if wrote and new_rk: if wrote and new_rk:
# It is on Plex now, and only the follow-up read failed. Its row is kept, editable: the # It is on Plex now, and only the follow-up read failed: keep its row (see the service).
# full sync preserves `editable` but creates a missing row read-only, so the collection service.keep_created_collection(db, lib, new_rk, title, seed)
# would drop out of every "add to" list; and the duplicate-name check reads these rows,
# so a retry of the same name would make a second one on Plex. Plex made it with exactly
# the seed in it, so the seed's row says so too — otherwise the title showed unticked in
# the collection it was just added to, and the collection empty, until that sync.
db.add(
PlexCollection(
rating_key=new_rk, library_id=lib.id, title=title, editable=True, child_count=1
)
)
Model = PlexItem if lib.kind == "movie" else PlexShow
if row := by_rating_key(db, Model, seed):
row.collection_keys = list(dict.fromkeys([*(row.collection_keys or []), new_rk]))
db.commit() db.commit()
raise _write_or_pending_error(wrote, e) from e raise _write_or_pending_error(wrote, e) from e
return _collection_card(col) return collection_card(col)
@router.post("/collections/{rating_key}/items/{item_rating_key}") @router.post("/collections/{rating_key}/items/{item_rating_key}")
@@ -488,7 +281,7 @@ def collection_add_item(
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
col = _editable_collection_or_403(db, rating_key) col = _editable_collection_or_403(db, rating_key)
lib = db.get(PlexLibrary, col.library_id) lib = queries.library(db, col.library_id)
wrote = False wrote = False
try: try:
with PlexClient(db) as plex: with PlexClient(db) as plex:
@@ -499,7 +292,7 @@ def collection_add_item(
except (PlexError, PlexNotConfigured) as e: except (PlexError, PlexNotConfigured) as e:
db.rollback() db.rollback()
raise _write_or_pending_error(wrote, e) from e raise _write_or_pending_error(wrote, e) from e
return _collection_card(col) return collection_card(col)
@router.delete("/collections/{rating_key}/items/{item_rating_key}") @router.delete("/collections/{rating_key}/items/{item_rating_key}")
@@ -510,7 +303,7 @@ def collection_remove_item(
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
col = _editable_collection_or_403(db, rating_key) col = _editable_collection_or_403(db, rating_key)
lib = db.get(PlexLibrary, col.library_id) lib = queries.library(db, col.library_id)
wrote = False wrote = False
try: try:
with PlexClient(db) as plex: with PlexClient(db) as plex:
@@ -521,7 +314,7 @@ def collection_remove_item(
except (PlexError, PlexNotConfigured) as e: except (PlexError, PlexNotConfigured) as e:
db.rollback() db.rollback()
raise _write_or_pending_error(wrote, e) from e raise _write_or_pending_error(wrote, e) from e
return _collection_card(col) return collection_card(col)
class CollectionRenameIn(BaseModel): class CollectionRenameIn(BaseModel):
@@ -540,7 +333,7 @@ def collection_rename(
if not title: if not title:
raise refused(400, "title_required") raise refused(400, "title_required")
_reject_duplicate_name(db, col.library_id, title, exclude_rk=rating_key) _reject_duplicate_name(db, col.library_id, title, exclude_rk=rating_key)
lib = db.get(PlexLibrary, col.library_id) lib = queries.library(db, col.library_id)
wrote = False wrote = False
try: try:
with PlexClient(db) as plex: with PlexClient(db) as plex:
@@ -551,7 +344,7 @@ def collection_rename(
except (PlexError, PlexNotConfigured) as e: except (PlexError, PlexNotConfigured) as e:
db.rollback() db.rollback()
raise _write_or_pending_error(wrote, e) from e raise _write_or_pending_error(wrote, e) from e
return _collection_card(col) return collection_card(col)
@router.delete("/collections/{rating_key}") @router.delete("/collections/{rating_key}")
@@ -559,7 +352,7 @@ def collection_delete(
rating_key: str, _: User = Depends(admin_user), db: Session = Depends(get_db) rating_key: str, _: User = Depends(admin_user), db: Session = Depends(get_db)
) -> dict: ) -> dict:
col = _editable_collection_or_403(db, rating_key) col = _editable_collection_or_403(db, rating_key)
lib = db.get(PlexLibrary, col.library_id) lib = queries.library(db, col.library_id)
try: try:
with PlexClient(db) as plex: with PlexClient(db) as plex:
plex.delete_collection(rating_key) plex.delete_collection(rating_key)
@@ -584,12 +377,12 @@ 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 = by_rating_key(db, PlexCollection, rating_key) col = queries.collection(db, rating_key)
if col is None: if col is None:
raise not_found("collection") raise not_found("collection")
want = body.editable want = body.editable
if want and (col.smart or _collection_source(col) != "collection"): if want and (col.smart or collection_source(col) != "collection"):
raise refused(400, "collection_smart") raise refused(400, "collection_smart")
col.editable = want service.set_collection_editable(col, want)
db.commit() db.commit()
return _collection_card(col) return collection_card(col)
+30 -122
View File
@@ -4,45 +4,34 @@ import re
import threading import threading
import time import time
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone from typing import TYPE_CHECKING
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.collation import collated, sorted_words from app.collation import sorted_words
from app import logs from app import logs
from app.auth import current_user from app.auth import current_user
from app.db import get_db from app.db import get_db
from app.discover import queries from app.discover import queries as discover_queries
from app.models import User from app.models import User
from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow, PlexState
from app.plex import detail as plex_detail from app.plex import detail as plex_detail
from app.plex import people as plex_people from app.plex import queries, rollup, service, subtitles
from app.plex import rollup, subtitles
from app.plex.query import title_col
from app.plex import tmdb as plex_tmdb from app.plex import tmdb as plex_tmdb
from app.plex.collections import collection_source
from app.plex.cards import SHOW_NEW, episode_card, movie_card, show_card from app.plex.cards import SHOW_NEW, episode_card, movie_card, show_card
from app.plex.client import PlexClient, PlexError, PlexNotConfigured from app.plex.client import PlexClient, PlexError, PlexNotConfigured
from app.plex.lookups import by_rating_key
from app.routes.plex._shared import _norm_lang, item_or_404 from app.routes.plex._shared import _norm_lang, item_or_404
from app.routes.plex.collections import _collection_source
from app.routes._common import not_found from app.routes._common import not_found
if TYPE_CHECKING:
from app.plex.models import PlexItem, PlexSeason
log = logs.get_logger(__name__) log = logs.get_logger(__name__)
router = APIRouter(prefix="/api/plex", tags=["plex"]) router = APIRouter(prefix="/api/plex", tags=["plex"])
def _lib_key(db: Session, library_id: int | None) -> str | None:
"""The Plex section key for a library id — the info/show page passes it to the admin collection
editor (in the unified cross-library view there's no single library prop otherwise)."""
if library_id is None:
return None
lib = db.get(PlexLibrary, library_id)
return lib.plex_key if lib else None
_SHOW_ENRICH_TTL_S = 300 _SHOW_ENRICH_TTL_S = 300
_show_enrich_cache: dict[str, tuple[float, dict, list]] = {} _show_enrich_cache: dict[str, tuple[float, dict, list]] = {}
@@ -106,39 +95,12 @@ def show_detail(
"""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."""
lang = _norm_lang(lang) lang = _norm_lang(lang)
hu = lang == "hu" hu = lang == "hu"
sh = by_rating_key(db, PlexShow, rating_key) sh = queries.show(db, rating_key)
if sh is None: if sh is None:
raise not_found("show") raise not_found("show")
seasons = ( seasons = queries.seasons(db, sh)
db.execute( eps = queries.episodes(db, sh)
select(PlexSeason) states = queries.states(db, user, [e.id for e in eps])
.where(PlexSeason.show_id == sh.id)
.order_by(PlexSeason.season_number.asc().nullsfirst())
)
.scalars()
.all()
)
eps = (
db.execute(
select(PlexItem)
.where(PlexItem.show_id == sh.id, PlexItem.kind == "episode")
.order_by(
PlexItem.season_number.asc().nullsfirst(),
PlexItem.episode_number.asc().nullsfirst(),
)
)
.scalars()
.all()
)
states = {
s.item_id: s
for s in db.execute(
select(PlexState).where(
PlexState.user_id == user.id,
PlexState.item_id.in_([e.id for e in eps] or [0]),
)
).scalars()
}
by_season: dict[int | None, list] = {} by_season: dict[int | None, list] = {}
all_cards: list[dict] = [] # every episode, in season/episode order → show-level rollup all_cards: list[dict] = [] # every episode, in season/episode order → show-level rollup
for e in eps: for e in eps:
@@ -156,9 +118,7 @@ def show_detail(
meta, related_raw = _show_enrichment(db, sh.rating_key) meta, related_raw = _show_enrichment(db, sh.rating_key)
related = _relatedshow_cards(db, user.id, related_raw, exclude=sh.id, lang=lang) related = _relatedshow_cards(db, user.id, related_raw, exclude=sh.id, lang=lang)
if sh.people_enriched_at is None and meta: if sh.people_enriched_at is None and meta:
plex_people.enrich_row_people(db, sh, plex_people.cast_from_meta(meta)) service.enrich_show(db, sh, meta) # imdb/tagline/full_cast for future views
plex_detail.apply_rich_detail(sh, meta) # imdb/tagline/full_cast for future views
sh.people_enriched_at = datetime.now(timezone.utc)
try: try:
db.commit() db.commit()
except Exception: # noqa: BLE001 — a failed enrich must not fail the page except Exception: # noqa: BLE001 — a failed enrich must not fail the page
@@ -191,7 +151,7 @@ def show_detail(
"year": sh.year, "year": sh.year,
"thumb": f"/api/plex/image/{sh.rating_key}", "thumb": f"/api/plex/image/{sh.rating_key}",
"art": f"/api/plex/image/{sh.rating_key}?variant=art", "art": f"/api/plex/image/{sh.rating_key}?variant=art",
"library": _lib_key(db, sh.library_id), "library": queries.library_key(db, sh.library_id),
"content_rating": sh.content_rating, "content_rating": sh.content_rating,
"rating": sh.rating, "rating": sh.rating,
"genres": (sh.genres_hu or sh.genres if hu else sh.genres) or [], "genres": (sh.genres_hu or sh.genres if hu else sh.genres) or [],
@@ -227,13 +187,13 @@ def show_next(
Asked one show at a time, when a card is hovered or played, rather than carried on every card of a Asked one show at a time, when a card is hovered or played, rather than carried on every card of a
grid: three probes a show would cost a 60-card page 180 queries for the few cards anyone plays. grid: three probes a show would cost a 60-card page 180 queries for the few cards anyone plays.
`next` is null for a show with no episodes.""" `next` is null for a show with no episodes."""
sh = by_rating_key(db, PlexShow, rating_key) sh = queries.show(db, rating_key)
if sh is None: if sh is None:
raise not_found("show") raise not_found("show")
return {"next": queries.next_episode(db, user.id, sh.id, lang=_norm_lang(lang))} return {"next": discover_queries.next_episode(db, user.id, sh.id, lang=_norm_lang(lang))}
def _season_block(se: PlexSeason, cards: list[dict]) -> dict: def _season_block(se: "PlexSeason", cards: list[dict]) -> dict:
"""A season's card for the show page (with its aggregate rollup) plus its episodes (used by the """A season's card for the show page (with its aggregate rollup) plus its episodes (used by the
season subpage, read from the same cached show-detail payload).""" season subpage, read from the same cached show-detail payload)."""
roll = rollup.rollup(cards) roll = rollup.rollup(cards)
@@ -258,12 +218,7 @@ def _relatedshow_cards(
rks = [str(m.get("ratingKey")) for m in related if m.get("ratingKey")] rks = [str(m.get("ratingKey")) for m in related if m.get("ratingKey")]
if not rks: if not rks:
return [] return []
shows = { shows = queries.shows_by_keys(db, rks[:60], exclude)
s.rating_key: s
for s in db.execute(
select(PlexShow).where(PlexShow.rating_key.in_(rks[:60]), PlexShow.id != exclude)
).scalars()
}
statuses = rollup.state_map(db, user_id, [s.id for s in shows.values()]) statuses = rollup.state_map(db, user_id, [s.id for s in shows.values()])
out: list[dict] = [] out: list[dict] = []
seen: set[str] = set() seen: set[str] = set()
@@ -278,73 +233,36 @@ def _relatedshow_cards(
return out return out
def _collection_strips(db: Session, it: PlexItem, user_id: int, lang: str) -> list[dict]: def _collection_strips(db: Session, it: "PlexItem", user_id: int, lang: str) -> list[dict]:
"""The collections this movie belongs to, each with its member titles (playable cards) — the """The collections this movie belongs to, each with its member titles (playable cards) — the
'Avatar Collection' strip on the info page. Pure local lookup (no Plex call).""" 'Avatar Collection' strip on the info page. Pure local lookup (no Plex call)."""
keys = it.collection_keys or [] keys = it.collection_keys or []
if not keys or it.kind != "movie": if not keys or it.kind != "movie":
return [] return []
cols = { cols = {c.rating_key: c for c in queries.collections_by_keys(db, keys)}
c.rating_key: c
for c in db.execute(
select(PlexCollection).where(PlexCollection.rating_key.in_(keys))
).scalars()
}
# Smallest (most specific — e.g. a franchise) first; the big auto-lists (IMDb/TMDb Popular) last. # Smallest (most specific — e.g. a franchise) first; the big auto-lists (IMDb/TMDb Popular) last.
alpha = {w: i for i, w in enumerate(sorted_words(db, [c.title for c in cols.values()], lang))} alpha = {w: i for i, w in enumerate(sorted_words(db, [c.title for c in cols.values()], lang))}
ordered = sorted(cols.values(), key=lambda c: (c.child_count or 0, alpha[c.title])) ordered = sorted(cols.values(), key=lambda c: (c.child_count or 0, alpha[c.title]))
out = [] out = []
for col in ordered: for col in ordered:
rk = col.rating_key rk = col.rating_key
members = ( members = queries.library_movies_in(db, it.library_id, rk, lang, limit=60)
db.execute( states = queries.states(db, user_id, [m.id for m in members])
select(PlexItem)
.where(
PlexItem.library_id == it.library_id,
PlexItem.kind == "movie",
PlexItem.collection_keys.contains([rk]),
)
.order_by(
PlexItem.year.asc().nullslast(), collated(title_col(PlexItem, lang), lang)
)
.limit(60)
)
.scalars()
.all()
)
ids = [m.id for m in members]
states = {
s.item_id: s
for s in db.execute(
select(PlexState).where(
PlexState.user_id == user_id, PlexState.item_id.in_(ids or [0])
)
).scalars()
}
out.append( out.append(
{ {
"id": rk, "id": rk,
"title": col.title, "title": col.title,
"source": _collection_source(col), "source": collection_source(col),
"items": [movie_card(m, states.get(m.id), lang=lang) for m in members], "items": [movie_card(m, states.get(m.id), lang=lang) for m in members],
} }
) )
return out return out
def _episode_nav(db: Session, it: PlexItem) -> tuple[str | None, str | None]: def _episode_nav(db: Session, it: "PlexItem") -> tuple[str | None, str | None]:
if it.kind != "episode" or it.show_id is None: if it.kind != "episode" or it.show_id is None:
return None, None return None, None
keys = list( keys = queries.episode_keys(db, it.show_id)
db.execute(
select(PlexItem.rating_key)
.where(PlexItem.show_id == it.show_id, PlexItem.kind == "episode")
.order_by(
PlexItem.season_number.asc().nullsfirst(),
PlexItem.episode_number.asc().nullsfirst(),
)
).scalars()
)
try: try:
i = keys.index(it.rating_key) i = keys.index(it.rating_key)
except ValueError: except ValueError:
@@ -391,13 +309,7 @@ def item_detail(
prev/next, and the per-user resume position + watch status.""" prev/next, and the per-user resume position + watch status."""
lang = _norm_lang(lang) lang = _norm_lang(lang)
it = item_or_404(db, rating_key) it = item_or_404(db, rating_key)
st = ( st = queries.state(db, user, it)
db.execute(
select(PlexState).where(PlexState.user_id == user.id, PlexState.item_id == it.id)
)
.scalars()
.first()
)
# Markers + audio/subtitle streams are file-specific, so they stay LIVE; the rich detail (cast/IMDb/ # Markers + audio/subtitle streams are file-specific, so they stay LIVE; the rich detail (cast/IMDb/
# tagline/genres) is served from the DB row for movies (persisted below / by the backfill) and only # tagline/genres) is served from the DB row for movies (persisted below / by the backfill) and only
@@ -478,11 +390,7 @@ def item_detail(
# only stored the listing's top ~3), so fill a not-yet-enriched movie — its cast becomes filterable # only stored the listing's top ~3), so fill a not-yet-enriched movie — its cast becomes filterable
# and future views serve from the row. Episodes aren't enriched; a show's cast is filled from /show. # and future views serve from the row. Episodes aren't enriched; a show's cast is filled from /show.
if it.kind == "movie" and it.people_enriched_at is None and meta: if it.kind == "movie" and it.people_enriched_at is None and meta:
cast = plex_people.cast_from_meta(meta) service.enrich_movie(db, it, meta)
if cast:
plex_people.enrich_row_people(db, it, cast)
plex_detail.apply_rich_detail(it, meta)
it.people_enriched_at = datetime.now(timezone.utc)
try: try:
db.commit() db.commit()
except Exception: # noqa: BLE001 — a failed enrich must not fail the page except Exception: # noqa: BLE001 — a failed enrich must not fail the page
@@ -515,8 +423,8 @@ def item_detail(
rich = plex_detail.rich_detail(meta) rich = plex_detail.rich_detail(meta)
prev_id, next_id = _episode_nav(db, it) prev_id, next_id = _episode_nav(db, it)
show = db.get(PlexShow, it.show_id) if it.kind == "episode" and it.show_id else None show = queries.show_by_id(db, it.show_id) if it.kind == "episode" and it.show_id else None
season = db.get(PlexSeason, it.season_id) if show and it.season_id else None season = queries.season_by_id(db, it.season_id) if show and it.season_id else None
return { return {
"id": it.rating_key, "id": it.rating_key,
"kind": it.kind, "kind": it.kind,
@@ -531,7 +439,7 @@ def item_detail(
"video_bit_depth": it.video_bit_depth, "video_bit_depth": it.video_bit_depth,
"thumb": f"/api/plex/image/{it.rating_key}", "thumb": f"/api/plex/image/{it.rating_key}",
"art": f"/api/plex/image/{it.rating_key}?variant=art", "art": f"/api/plex/image/{it.rating_key}?variant=art",
"library": _lib_key(db, it.library_id), "library": queries.library_key(db, it.library_id),
"cast": rich.get("cast", []), "cast": rich.get("cast", []),
"imdb_rating": rich.get("imdb_rating"), "imdb_rating": rich.get("imdb_rating"),
"imdb_id": rich.get("imdb_id"), "imdb_id": rich.get("imdb_id"),
+15 -94
View File
@@ -4,22 +4,18 @@ import threading
import time import time
from fastapi import APIRouter, BackgroundTasks, Depends from fastapi import APIRouter, BackgroundTasks, Depends
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.collation import collated from app import sysconfig
from app import logs, sysconfig
from app.auth import current_user from app.auth import current_user
from app.db import SessionLocal, get_db from app.db import get_db
from app.models import User from app.models import User
from app.plex.models import PlexItem, PlexPerson, PlexShow from app.plex import queries, rollup, service
from app.plex import rollup
from app.plex import tmdb as plex_tmdb from app.plex import tmdb as plex_tmdb
from app.plex.cards import SHOW_NEW, movie_card, norm_lang, show_card from app.plex.cards import SHOW_NEW, movie_card, norm_lang, show_card
from app.plex.query import content_libs, split_csv, title_col from app.plex.query import content_libs, split_csv
router = APIRouter(prefix="/api/plex", tags=["plex"]) router = APIRouter(prefix="/api/plex", tags=["plex"])
log = logs.get_logger(__name__)
# How many "Known for" titles the person page shows (the stored pool is deeper; see plex.tmdb). # How many "Known for" titles the person page shows (the stored pool is deeper; see plex.tmdb).
@@ -76,35 +72,13 @@ def people_cards(
movie_lib_ids, show_lib_ids = content_libs(db) movie_lib_ids, show_lib_ids = content_libs(db)
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 = queries.person_photos(db, wanted)
p.name: p.photo_thumb movies = movie_lib_ids if want_movies else []
for p in db.execute(select(PlexPerson).where(PlexPerson.name.in_(wanted))).scalars() shows = show_lib_ids if want_shows else []
} out = [
out = [] {"name": nm, "photo": photos.get(nm), "count": queries.cast_count(db, nm, movies, shows)}
for nm in wanted: for nm in wanted
count = 0 ]
if want_movies:
count += (
db.scalar(
select(func.count(PlexItem.id)).where(
PlexItem.kind == "movie",
PlexItem.library_id.in_(movie_lib_ids),
PlexItem.cast_names.contains([nm]),
)
)
or 0
)
if want_shows:
count += (
db.scalar(
select(func.count(PlexShow.id)).where(
PlexShow.library_id.in_(show_lib_ids),
PlexShow.cast_names.contains([nm]),
)
)
or 0
)
out.append({"name": nm, "photo": photos.get(nm), "count": count})
return {"people": out} return {"people": out}
@@ -140,24 +114,6 @@ def _should_schedule_enrich(person_id: int) -> bool:
return True return True
def _enrich_person_bg(person_id: int) -> None:
"""Fetch + persist one person's TMDB bio OFF the request path — a FastAPI background task that runs
AFTER the response is sent, so the outbound TMDB call never sits on the serve latency (the module's
"no live TMDB call at serve time" invariant). The request's session is already closed by then, so
open a fresh one. Best-effort: a TMDB failure just leaves `tmdb_enriched_at` NULL to retry on the
next open, and the bio lands from the row by then."""
db = SessionLocal()
try:
person = db.get(PlexPerson, person_id)
if person is not None:
plex_tmdb.enrich_person(db, person)
except Exception: # noqa: BLE001 — background best-effort; never surfaces to a user
db.rollback()
log.warning("background person bio enrich failed for id=%s", person_id, exc_info=True)
finally:
db.close()
@router.get("/person/{name}") @router.get("/person/{name}")
def person_detail( def person_detail(
name: str, name: str,
@@ -179,7 +135,7 @@ def person_detail(
fetches everyone ahead of time, so this lazy path is only for a person the job hasn't reached yet. fetches everyone ahead of time, so this lazy path is only for a person the job hasn't reached yet.
""" """
lang = norm_lang(lang) lang = norm_lang(lang)
person = db.execute(select(PlexPerson).where(PlexPerson.name == name)).scalars().first() person = queries.person(db, name)
# Lazy one-time bio enrich, scheduled AFTER the response so the TMDB call is never on the serve path. # Lazy one-time bio enrich, scheduled AFTER the response so the TMDB call is never on the serve path.
# Gated by a per-person cooldown so concurrent/rapid opens (or repeated opens while TMDB is degraded) # Gated by a per-person cooldown so concurrent/rapid opens (or repeated opens while TMDB is degraded)
# can't pile background tasks onto the shared threadpool. # can't pile background tasks onto the shared threadpool.
@@ -193,53 +149,18 @@ def person_detail(
and bool(sysconfig.get_str(db, "tmdb_api_key")) and bool(sysconfig.get_str(db, "tmdb_api_key"))
) )
if enriching and person is not None and _should_schedule_enrich(person.id): if enriching and person is not None and _should_schedule_enrich(person.id):
background_tasks.add_task(_enrich_person_bg, person.id) background_tasks.add_task(service.enrich_person_bg, person.id)
movie_lib_ids, show_lib_ids = content_libs(db) movie_lib_ids, show_lib_ids = content_libs(db)
movies: list[dict] = [] movies: list[dict] = []
if movie_lib_ids: if movie_lib_ids:
items = ( items = queries.filmography(db, "movie", name, movie_lib_ids, lang, _PERSON_SECTION_MAX)
db.execute(
select(PlexItem)
.where(
PlexItem.kind == "movie",
PlexItem.library_id.in_(movie_lib_ids),
or_(
PlexItem.cast_names.contains([name]),
PlexItem.directors.contains([name]),
),
)
.order_by(
PlexItem.year.desc().nullslast(), collated(title_col(PlexItem, lang), lang)
)
.limit(_PERSON_SECTION_MAX)
)
.scalars()
.all()
)
states = rollup.item_state_map(db, user.id, [it.id for it in items]) states = rollup.item_state_map(db, user.id, [it.id for it in items])
movies = [movie_card(it, states.get(it.id), lang=lang) for it in items] movies = [movie_card(it, states.get(it.id), lang=lang) for it in items]
shows: list[dict] = [] shows: list[dict] = []
if show_lib_ids: if show_lib_ids:
show_rows = ( show_rows = queries.filmography(db, "show", name, show_lib_ids, lang, _PERSON_SECTION_MAX)
db.execute(
select(PlexShow)
.where(
PlexShow.library_id.in_(show_lib_ids),
or_(
PlexShow.cast_names.contains([name]),
PlexShow.directors.contains([name]),
),
)
.order_by(
PlexShow.year.desc().nullslast(), collated(title_col(PlexShow, lang), lang)
)
.limit(_PERSON_SECTION_MAX)
)
.scalars()
.all()
)
statuses = rollup.state_map(db, user.id, [s.id for s in show_rows]) statuses = rollup.state_map(db, user.id, [s.id for s in show_rows])
shows = [show_card(sh, statuses.get(sh.id, SHOW_NEW), lang=lang) for sh in show_rows] shows = [show_card(sh, statuses.get(sh.id, SHOW_NEW), lang=lang) for sh in show_rows]
+2 -2
View File
@@ -20,8 +20,8 @@ from app.main import app
from app.models import User from app.models import User
from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, PlexState from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, PlexState
from app.plex.query import PLEX_SORT_KEYS from app.plex.query import PLEX_SORT_KEYS
from app.routes.plex.browse import sort_column from app.plex.library import sort_column
from app.routes.plex.collections import ( from app.plex.collections import (
DEFAULT_MEMBER_SORT, DEFAULT_MEMBER_SORT,
MEMBER_SORT_KEYS, MEMBER_SORT_KEYS,
Member, Member,
+1 -1
View File
@@ -362,7 +362,7 @@ def test_the_person_page_says_a_bio_is_on_its_way(db, monkeypatch, override_dep)
monkeypatch.setattr(state, "_configured_cache", True) monkeypatch.setattr(state, "_configured_cache", True)
override_dep(get_db, db.info["request"]) override_dep(get_db, db.info["request"])
override_dep(current_user, lambda: user) override_dep(current_user, lambda: user)
monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: None) monkeypatch.setattr(people.service, "enrich_person_bg", lambda pid: None)
key = {"value": "key"} key = {"value": "key"}
monkeypatch.setattr(people.sysconfig, "get_str", lambda d, k: key["value"]) monkeypatch.setattr(people.sysconfig, "get_str", lambda d, k: key["value"])
+10 -10
View File
@@ -191,7 +191,7 @@ def test_person_detail_schedules_enrich_off_request_path(db, monkeypatch, overri
db.commit() db.commit()
scheduled: list[int] = [] scheduled: list[int] = []
monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid)) monkeypatch.setattr(people.service, "enrich_person_bg", lambda pid: scheduled.append(pid))
monkeypatch.setattr(people.sysconfig, "get_str", lambda d, k: "key") monkeypatch.setattr(people.sysconfig, "get_str", lambda d, k: "key")
people._enrich_inflight.clear() # ensure the per-person cooldown guard doesn't suppress this open people._enrich_inflight.clear() # ensure the per-person cooldown guard doesn't suppress this open
@@ -204,7 +204,7 @@ def test_person_detail_schedules_enrich_off_request_path(db, monkeypatch, overri
def test_enrich_person_bg_closes_session_and_isolates_errors(monkeypatch): def test_enrich_person_bg_closes_session_and_isolates_errors(monkeypatch):
# The background-task body must open a fresh session, fetch the person, and ALWAYS close it — rolling # The background-task body must open a fresh session, fetch the person, and ALWAYS close it — rolling
# back (never propagating) if enrich_person raises. Pure-unit via a fake session (no DB needed). # back (never propagating) if enrich_person raises. Pure-unit via a fake session (no DB needed).
from app.routes.plex import people from app.plex import service
calls = {"rollback": 0, "close": 0, "enriched": 0} calls = {"rollback": 0, "close": 0, "enriched": 0}
@@ -222,13 +222,13 @@ def test_enrich_person_bg_closes_session_and_isolates_errors(monkeypatch):
calls["close"] += 1 calls["close"] += 1
# Success path: person found → enrich called, session closed, no rollback. # Success path: person found → enrich called, session closed, no rollback.
monkeypatch.setattr(people, "SessionLocal", lambda: _FakeSession(object())) monkeypatch.setattr(service, "SessionLocal", lambda: _FakeSession(object()))
monkeypatch.setattr( monkeypatch.setattr(
people.plex_tmdb, service.plex_tmdb,
"enrich_person", "enrich_person",
lambda db, p: calls.__setitem__("enriched", calls["enriched"] + 1), lambda db, p: calls.__setitem__("enriched", calls["enriched"] + 1),
) )
people._enrich_person_bg(5) service.enrich_person_bg(5)
assert calls == {"rollback": 0, "close": 1, "enriched": 1} assert calls == {"rollback": 0, "close": 1, "enriched": 1}
# Error path: enrich raises → rollback + close, no exception escapes. # Error path: enrich raises → rollback + close, no exception escapes.
@@ -237,14 +237,14 @@ def test_enrich_person_bg_closes_session_and_isolates_errors(monkeypatch):
def _boom(db, p): def _boom(db, p):
raise RuntimeError("enrich blew up") raise RuntimeError("enrich blew up")
monkeypatch.setattr(people.plex_tmdb, "enrich_person", _boom) monkeypatch.setattr(service.plex_tmdb, "enrich_person", _boom)
people._enrich_person_bg(5) # must not raise service.enrich_person_bg(5) # must not raise
assert calls["rollback"] == 1 and calls["close"] == 1 assert calls["rollback"] == 1 and calls["close"] == 1
# Missing person (deleted id): None-guard skips enrich, session still closed. # Missing person (deleted id): None-guard skips enrich, session still closed.
calls.update({"rollback": 0, "close": 0, "enriched": 0}) calls.update({"rollback": 0, "close": 0, "enriched": 0})
monkeypatch.setattr(people, "SessionLocal", lambda: _FakeSession(None)) monkeypatch.setattr(service, "SessionLocal", lambda: _FakeSession(None))
people._enrich_person_bg(9) service.enrich_person_bg(9)
assert calls == {"rollback": 0, "close": 1, "enriched": 0} assert calls == {"rollback": 0, "close": 1, "enriched": 0}
@@ -281,7 +281,7 @@ def test_person_detail_no_enrich_when_already_enriched(db, monkeypatch, override
db.commit() db.commit()
scheduled: list[int] = [] scheduled: list[int] = []
monkeypatch.setattr(people, "_enrich_person_bg", lambda pid: scheduled.append(pid)) monkeypatch.setattr(people.service, "enrich_person_bg", lambda pid: scheduled.append(pid))
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/person/Done P") resp = _get(db, user, monkeypatch, override_dep, "/api/plex/person/Done P")
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
+264
View File
@@ -0,0 +1,264 @@
"""Plex's service and queries below the HTTP layer, one use case each.
The routes are thin callers now, and their own tests stay the contract a client sees. These hold
what each read and write does on its own: that a user's watch state is theirs alone (another
user's state is the same answer as none), that a write leaves the commit to its caller, and the rule
each one carries (an explicit empty key list asks for nothing, a kept collection is editable and
holds its seed). DB-backed (real Postgres).
"""
from datetime import datetime, timezone
from sqlalchemy import select
from app.plex import library, queries, service
from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, PlexState
from app.plex.query import LibraryFilters
NOW = datetime(2024, 6, 1, tzinfo=timezone.utc)
def _lib(db, key="1", kind="movie", **kw):
lib = PlexLibrary(plex_key=key, title=kw.pop("title", f"L{key}"), kind=kind, enabled=True, **kw)
db.add(lib)
db.commit()
return lib
def _movie(db, lib, rk, **kw):
it = PlexItem(rating_key=rk, library_id=lib.id, kind="movie", title=kw.pop("title", rk), **kw)
db.add(it)
db.commit()
return it
def _state(db, user, it, **kw):
st = PlexState(user_id=user.id, item_id=it.id, **kw)
db.add(st)
db.commit()
return st
def _uncommitted(db):
return bool(db.new or db.dirty or db.deleted) or db.in_transaction()
def _page(db, user, **kw):
args = dict(
scope="both",
q=None,
field="all",
sort="title",
sort_dir="asc",
show="all",
lang="en",
offset=0,
limit=40,
f=LibraryFilters(),
)
args.update(kw)
return library.library_page(db, user, **args)
# --- whose a watch state is ----------------------------------------------------------------
def test_a_users_watch_state_is_theirs_alone(db, make_user):
owner, stranger = make_user(), make_user()
lib = _lib(db)
it = _movie(db, lib, "m1")
_state(db, owner, it, status="watched")
assert queries.state(db, owner, it).status == "watched"
assert queries.state(db, stranger, it) is None
assert queries.states(db, stranger, [it.id]) == {}
assert set(queries.states(db, owner, [it.id])) == {it.id}
assert queries.states(db, owner, []) == {}
def test_the_grid_shows_the_callers_state_and_hides_only_what_they_hid(db, make_user):
me, other = make_user(), make_user()
lib = _lib(db)
seen = _movie(db, lib, "seen")
hid = _movie(db, lib, "hid")
_state(db, other, seen, status="watched")
_state(db, other, hid, status="hidden")
_state(db, me, seen, status="new", position_seconds=90)
total, rows, _ = _page(db, me)
assert total == 2
assert {r.rk: (r.status, r.position_seconds) for r in rows}["seen"][1] == 90
assert [r.rk for r in _page(db, me, show="watched")[1]] == []
assert [r.rk for r in _page(db, other, show="watched")[1]] == ["seen"]
assert [r.rk for r in _page(db, other)[1]] == ["seen"] # their hidden one is gone, for them
def test_the_facets_count_what_the_caller_would_see(db, make_user):
me, other = make_user(), make_user()
lib = _lib(db)
_movie(db, lib, "a", genres=["Drama"], year=2001)
hid = _movie(db, lib, "b", genres=["Horror"], year=1990)
_state(db, other, hid, status="hidden")
mine = library.facets(db, me, scope="both", show="all", lang="en", f=LibraryFilters())
theirs = library.facets(db, other, scope="both", show="all", lang="en", f=LibraryFilters())
assert [g["value"] for g in mine["genres"]] == ["Drama", "Horror"]
assert [g["value"] for g in theirs["genres"]] == ["Drama"]
assert (theirs["year_min"], mine["year_min"]) == (2001, 1990)
def test_an_episode_search_joins_the_callers_state(db, make_user):
me, other = make_user(), make_user()
lib = _lib(db, "2", kind="show")
sh = PlexShow(rating_key="s1", library_id=lib.id, title="Show")
db.add(sh)
db.commit()
ep = PlexItem(rating_key="e1", library_id=lib.id, kind="episode", title="Pilot", show_id=sh.id)
db.add(ep)
db.commit()
_state(db, other, ep, status="watched")
_, _, eps = _page(db, me, q="pilot")
assert [(e.rating_key, s, show.rating_key) for e, s, show in eps] == [("e1", None, "s1")]
def test_a_shows_rollup_is_the_callers(db, make_user):
me, other = make_user(), make_user()
lib = _lib(db, "2", kind="show")
sh = PlexShow(rating_key="s1", library_id=lib.id, title="Show")
db.add(sh)
db.commit()
ep = PlexItem(rating_key="e1", library_id=lib.id, kind="episode", title="E", show_id=sh.id)
db.add(ep)
db.commit()
_state(db, other, ep, status="watched", watched_at=NOW)
(row,) = queries.show_rollups(db, me.id, [sh.id])
assert (row.total, row.watched, row.resumed_at) == (1, 0, None)
assert queries.show_rollups(db, me.id, []) == []
# --- libraries, people, a show ---------------------------------------------------------------
def test_the_browsable_libraries_leave_an_overlay_out_and_count_their_titles(db):
en = _lib(db, "1", title="Movies")
_lib(db, "9", title="Filmek", overlay_of=en.id)
_movie(db, en, "m1")
_movie(db, en, "m2")
db.add(PlexItem(rating_key="x", library_id=en.id, kind="episode", title="not a movie"))
db.commit()
assert [(lib.plex_key, n) for lib, n in queries.browsable_libraries(db, "en")] == [("1", 2)]
def test_a_filmography_is_cast_or_directors_and_movies_only(db):
lib = _lib(db)
_movie(db, lib, "acted", cast_names=["Ann"], year=2000)
_movie(db, lib, "directed", directors=["Ann"], year=2010)
_movie(db, lib, "other", cast_names=["Bob"])
db.add(
PlexItem(rating_key="ep", library_id=lib.id, kind="episode", title="E", cast_names=["Ann"])
)
db.commit()
rows = queries.filmography(db, "movie", "Ann", [lib.id], "en", limit=10)
assert [r.rating_key for r in rows] == ["directed", "acted"]
assert queries.filmography(db, "show", "Ann", [lib.id], "en", limit=10) == []
assert queries.cast_count(db, "Ann", [lib.id], []) == 1 # the director credit is not cast
assert queries.cast_count(db, "Ann", [], []) == 0
def test_a_shows_episodes_and_their_keys_are_in_season_order(db):
lib = _lib(db, "2", kind="show")
sh = PlexShow(rating_key="s1", library_id=lib.id, title="Show")
db.add(sh)
db.commit()
for rk, season, ep in (("b", 2, 1), ("a", 1, 2), ("c", 1, 1)):
db.add(
PlexItem(
rating_key=rk,
library_id=lib.id,
kind="episode",
title=rk,
show_id=sh.id,
season_number=season,
episode_number=ep,
)
)
db.commit()
assert [e.rating_key for e in queries.episodes(db, sh)] == ["c", "a", "b"]
assert queries.episode_keys(db, sh.id) == ["c", "a", "b"]
assert queries.show(db, "s1\x00") is None # a NUL names no row
# --- collections -----------------------------------------------------------------------------
def test_a_collection_list_narrows_by_library_name_and_keys(db):
lib = _lib(db)
off = _lib(db, "3", title="Off")
off.enabled = False
db.add_all(
[
PlexCollection(rating_key="c1", library_id=lib.id, title="Bond 100%", child_count=5),
PlexCollection(rating_key="c2", library_id=lib.id, title="Pixar", child_count=9),
PlexCollection(rating_key="c3", library_id=off.id, title="Hidden"),
]
)
db.commit()
keys = lambda rows: [c.rating_key for c in rows] # noqa: E731
assert keys(queries.collections_page(db, None, None, None, "en")) == ["c2", "c1"]
assert keys(queries.collections_page(db, "3", None, None, "en")) == ["c3"]
assert keys(queries.collections_page(db, "404", None, None, "en")) == []
assert keys(queries.collections_page(db, None, "0%", None, "en")) == ["c1"] # % is literal
assert keys(queries.collections_page(db, None, "%", None, "en")) == ["c1"]
assert keys(queries.collections_page(db, None, None, ["c1"], "en")) == ["c1"]
assert keys(queries.collections_page(db, None, None, [], "en")) == [] # asks for none
assert queries.collection_named(db, lib.id, "PIXAR").rating_key == "c2"
assert queries.collection_named(db, off.id, "pixar") is None
def test_a_strip_lists_one_librarys_members_of_a_collection(db):
lib, other = _lib(db), _lib(db, "4")
_movie(db, lib, "in", collection_keys=["c1"], year=2002)
_movie(db, lib, "early", collection_keys=["c1"], year=1999)
_movie(db, other, "elsewhere", collection_keys=["c1"])
_movie(db, lib, "out", collection_keys=["c2"])
rows = queries.library_movies_in(db, lib.id, "c1", "en", limit=10)
assert [r.rating_key for r in rows] == ["early", "in"]
movies, shows = queries.collection_members(db, "c1", "en")
assert ([m.rating_key for m in movies], shows) == (["early", "in", "elsewhere"], [])
# --- writes ----------------------------------------------------------------------------------
def test_a_movie_enrichment_is_stamped_and_left_to_the_caller_to_commit(db):
lib = _lib(db)
it = _movie(db, lib, "m1")
meta = {"Role": [{"tag": "Ann", "role": "Lead"}], "tagline": "Big."}
service.enrich_movie(db, it, meta)
assert it.people_enriched_at is not None
assert "Ann" in (it.cast_names or [])
assert _uncommitted(db)
db.rollback()
db.expire_all()
assert db.get(PlexItem, it.id).people_enriched_at is None
def test_a_collection_kept_after_a_failed_read_is_editable_and_holds_its_seed(db):
lib = _lib(db)
seed = _movie(db, lib, "seed", collection_keys=["old"])
service.keep_created_collection(db, lib, "new", "Mine", "seed")
service.keep_created_collection(db, lib, "new2", "Mine 2", "seed")
db.commit()
db.expire_all()
col = queries.collection(db, "new")
assert (col.editable, col.child_count, col.title) == (True, 1, "Mine")
assert db.get(PlexItem, seed.id).collection_keys == ["old", "new", "new2"]
def test_marking_a_collection_editable_is_left_to_the_caller(db):
lib = _lib(db)
db.add(PlexCollection(rating_key="c1", library_id=lib.id, title="C"))
db.commit()
col = queries.collection(db, "c1")
service.set_collection_editable(col, True)
assert _uncommitted(db)
db.commit()
db.expire_all()
assert db.scalar(select(PlexCollection.editable)) is True
+1 -1
View File
@@ -40,7 +40,7 @@ FUNCTIONS = (ast.FunctionDef, ast.AsyncFunctionDef)
# of them — `{"synced": n}` is an acknowledgement, and gets spread into `{"ok": True, **report}`. # of them — `{"synced": n}` is an acknowledgement, and gets spread into `{"ok": True, **report}`.
SERIALIZERS = { SERIALIZERS = {
"JSONResponse", "JSONResponse",
"_collection_card", "collection_card",
"_playlist_card", "_playlist_card",
"_serialize", "_serialize",
"_serialize_folder", "_serialize_folder",
-5
View File
@@ -110,13 +110,8 @@ UNIT_ROUTES = {
# What each HTTP file still does, by file: the debt a unit's sprint pays off. Exact counts. A core # What each HTTP file still does, by file: the debt a unit's sprint pays off. Exact counts. A core
# route's count is only the unit tables it imports; it is paid by the sprint of the unit it reaches. # route's count is only the unit tables it imports; it is paid by the sprint of the unit it reaches.
ALLOWED = { ALLOWED = {
"plex/_shared.py": 1,
"plex/admin.py": 12, "plex/admin.py": 12,
"plex/browse.py": 93,
"plex/collections.py": 33,
"plex/detail.py": 29,
"plex/discover.py": 44, "plex/discover.py": 44,
"plex/people.py": 20,
"plex/playback.py": 42, "plex/playback.py": 42,
"plex/playlists.py": 80, "plex/playlists.py": 80,
} }
+1 -1
View File
@@ -113,7 +113,7 @@ project checks needs a row.
| CVE gate | HIGH/CRITICAL with a fix available only | `--ignore-unfixed` + `--severity HIGH,CRITICAL`: a finding with no fix cannot be acted on by an upgrade; it shows in the scan output. A fixable one fails the publish, and the only way past it is `.trivyignore.yaml`. | 0 open (E32 S1) | | CVE gate | HIGH/CRITICAL with a fix available only | `--ignore-unfixed` + `--severity HIGH,CRITICAL`: a finding with no fix cannot be acted on by an upgrade; it shows in the scan output. A fixable one fails the publish, and the only way past it is `.trivyignore.yaml`. | 0 open (E32 S1) |
| npm audit | `--omit=dev --audit-level=high` | What ships to the browser, at high or above. The dev toolchain's advisories were burned down by hand in E32 S1 (18 → 0). | 0 | | npm audit | `--omit=dev --audit-level=high` | What ships to the browser, at high or above. The dev toolchain's advisories were burned down by hand in E32 S1 (18 → 0). | 0 |
| knip | `entry` — one file added | Not a narrowing: knip's Vite plugin reads entries from index.html only, so the shared link's page (`watch.html` → `src/watchMain.tsx`) is named here, or the lane reports that whole entry unused. It adds files to the lane, never takes any out. | 1 entry | | knip | `entry` — one file added | Not a narrowing: knip's Vite plugin reads entries from index.html only, so the shared link's page (`watch.html` → `src/watchMain.tsx`) is named here, or the lane reports that whole entry unused. It adds files to the lane, never takes any out. | 1 entry |
| unit fence | the database work the HTTP layer still does, pinned | `backend/tests/test_unit_fences.py`: a unit's route file may not build SQL, call the session's data methods, set a model's attributes or import a unit's tables; a core route may not import a unit's tables. Switched on unit by unit: `ALLOWED` pins each file's count exactly, so a rise fails and a fall fails until the pin is lowered — the list may only shrink. End state: empty, and the pin mechanism goes. A unit in `FENCED` is held below its HTTP layer too: its code reads another unit's tables only along `READS`, writes them never, filters owners through `userscope`, and commits only where `UNIT_COMMITS` names a job's own session. | 826 in 23 files (E42 S0) → 667 in 19 (S1: Capture 0, fenced) → 525 in 14 (S2: Reader 0, fenced) → 354 in 9 (S3: Catalog 0, fenced; core routes 0) | | unit fence | the database work the HTTP layer still does, pinned | `backend/tests/test_unit_fences.py`: a unit's route file may not build SQL, call the session's data methods, set a model's attributes or import a unit's tables; a core route may not import a unit's tables. Switched on unit by unit: `ALLOWED` pins each file's count exactly, so a rise fails and a fall fails until the pin is lowered — the list may only shrink. End state: empty, and the pin mechanism goes. A unit in `FENCED` is held below its HTTP layer too: its code reads another unit's tables only along `READS`, writes them never, filters owners through `userscope`, and commits only where `UNIT_COMMITS` names a job's own session. | 826 in 23 files (E42 S0) → 667 in 19 (S1: Capture 0, fenced) → 525 in 14 (S2: Reader 0, fenced) → 354 in 9 (S3: Catalog 0, fenced; core routes 0) → 178 in 4 (S4a: Plex browse, detail, people, collections 0) |
| React Compiler | the functions it skips, pinned | `components/compilerCoverage.contract.test.ts` — the list may only shrink. End state: only VirtualGrid (TanStack Virtual, an IncompatibleLibrary) remains. | 93 (E32 S1) → 29 (E32 S3) → 1, VirtualGrid (E32 S4) | | React Compiler | the functions it skips, pinned | `components/compilerCoverage.contract.test.ts` — the list may only shrink. End state: only VirtualGrid (TanStack Virtual, an IncompatibleLibrary) remains. | 93 (E32 S1) → 29 (E32 S3) → 1, VirtualGrid (E32 S4) |
## Accepted findings ## Accepted findings