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:
@@ -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
|
||||
@@ -1,10 +1,11 @@
|
||||
"""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.
|
||||
|
||||
Shared by the live info-page path (routes.plex.browse) and the background rich re-sync (plex.sync) so
|
||||
the two never diverge — the same one-parser rule that keeps `people.py`'s cast handling consistent.
|
||||
`rich_detail` is the pure parser; `apply_rich_detail` writes the rich-ONLY fields onto a movie/show row
|
||||
(genres/directors/studio/content_rating already land from the cheap listing at `_apply_facet_fields`)."""
|
||||
Shared by the live info-page path (`plex.service.enrich_movie` / `enrich_show`) and the background
|
||||
rich re-sync (plex.sync) so the two never diverge — the same one-parser rule that keeps `people.py`'s
|
||||
cast handling consistent. `rich_detail` is the pure parser; `apply_rich_detail` writes the rich-ONLY
|
||||
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.plaintext import plain_text
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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`.
|
||||
# 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.
|
||||
# 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")
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user