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:
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
"""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
|
||||||
|
|||||||
@@ -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`.
|
# 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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user