feat(plex): the collection page sorts, and a linked collection has a name

Two halves of the same report ("Collectionben nem működik a rendezés?"). The pasted browse URL
sorts correctly — that part was working. What did not:

The Collection INFO page had no sort control at all and a hardcoded release-year-ascending order.
It now shows the browse grid's Sort control, with the browse grid's vocabulary, because a second
sort vocabulary on a neighbouring surface is exactly the kind of drift this repo keeps paying for:
`PLEX_SORT_KEYS` is now one tuple in `plex/query.py`, and a test pins BOTH implementations to it —
browse's key→column map (extracted from the handler to be reachable) and the collection page's
key→member map. A key added to the vocabulary without teaching one of them fails the gate instead
of putting a dead option in the menu.

Ordering a collection cannot be a single ORDER BY: its two legs are different tables, so the merged
member list is sorted in Python. Members with no value for the key sink to the END in both
directions (a movie with no rating is not "the worst rated" — the browse grid's `nullslast()`), and
ties always resolve title-ascending, so flipping the direction reverses the ranking rather than the
alphabet inside it. `lastwatched` needed the show leg's `resumed_at`, so the page now takes ONE
rollup pass that yields both the badge status and the recency instead of the status alone — without
it that menu entry would have ordered every show as never-watched. The default stays release-year
ascending: a franchise reads in release order, and that is what the page did before.

The other half: a shared Plex link carries the collection filter as bare rating_keys, so a link
opened cold had no name for them — the sidebar token rendered `17045` and the chip could only say
"Collection". `GET /api/plex/collections` takes a `keys=` CSV now, and a display-only hook resolves
just the missing ids. It deliberately does NOT write them into `collectionTitles`: that map is
hashed into the Plex query keys, so storing a resolved label there would split the cache from the
identical state and refetch the grid for a piece of text.

Verified in the browser on localdev against the real Plex library: the movie collection's menu
offers Length and the show collection's does not (the same scope rule as browse); rating-ascending
reorders 8.1/7.4/7.2/6.6/6.5 correctly; `lastwatched` on a show collection brings the in-progress
shows up; the choice survives a reload (it is a server pref, on its own key so opening a collection
never re-sorts the library behind it); and the reported URL now reads "Pirates of the Caribbean
Collection" in both the sidebar and the chip.

12 pytest + 8 vitest, mutation-checked against 14 mutations (direction ignored, nulls first, no
stable tiebreak, unknown key untouched, wrong default, show recency dropped, empty `keys=` falling
through to everything, a key missing from either sort map, every id refetched, ids unsorted,
fetched titles unmerged, the chip ignoring them, the hook's result never reaching the builder) —
each red on exactly the test that names it.
This commit is contained in:
2026-09-10 22:05:39 +02:00
parent 782362b808
commit 889d2acf52
11 changed files with 709 additions and 58 deletions
+16
View File
@@ -14,6 +14,22 @@ from app.models import PlexItem, PlexLibrary
TS_CONFIG = "public.unaccent_simple"
# The ONE Plex sort vocabulary, shared by every surface that offers the Sort control (the browse grid
# and the collection info page) and mirrored by the frontend's `plexSort.ts`. It lives here rather than
# inside a router because the two surfaces order DIFFERENT things — browse orders a union subquery in
# SQL, a collection orders its already-loaded member list in Python — and a key that one of them cannot
# honour would put an option in the UI that silently does nothing. Tests pin both implementations to
# this tuple, so adding a key here fails the gate until every surface can order by it.
PLEX_SORT_KEYS: tuple[str, ...] = (
"added",
"lastwatched",
"release",
"year",
"rating",
"duration",
"title",
)
# --- Language-aware column pickers (E5 S6) ----------------------------------
#
# The HU-overlay twin (E5 S5) stores Hungarian metadata in `*_hu` columns on the SAME EN row. When
+17 -8
View File
@@ -54,6 +54,22 @@ from app.utils import to_tsquery_str
router = APIRouter(prefix="/api/plex", tags=["plex"])
def sort_column(u, sort: str):
"""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."""
return {
"added": u.c.added_at,
"title": func.lower(u.c.title),
"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")
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
@@ -221,14 +237,7 @@ def unified_library(
if tsq is not None:
order = [u.c.rank.desc()]
else:
col = {
"title": func.lower(u.c.title),
"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)
col = sort_column(u, sort)
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()
+131 -13
View File
@@ -1,5 +1,9 @@
"""Plex collections: browse mirrored collections + the admin manual-collection editor."""
from collections.abc import Callable
from datetime import date, datetime
from typing import Any, NamedTuple
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import func, select
@@ -12,6 +16,7 @@ from app.models import (
PlexItem,
PlexLibrary,
PlexShow,
PlexState,
User,
)
from app.plex import rollup
@@ -83,13 +88,20 @@ def _collection_card(c: PlexCollection) -> dict:
def collections(
library: str | None = None,
q: str | None = None,
keys: str | None = None,
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Mirrored collections as cards (most-populated first), optionally name-filtered by `q`. With a
`library` plex_key → that one library's collections (used by the collection editor). WITHOUT one →
the UNION across all enabled libraries (the unified-scope sidebar picker, which has no single
library selected)."""
library selected).
`keys` (CSV of rating_keys) narrows to just those. It exists because a Plex link carries the
collection FILTER as bare rating_keys — nothing in the URL says what "17045" is called — so the
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
~200 KB."""
if library is not None:
lib = (
db.execute(select(PlexLibrary).where(PlexLibrary.plex_key == str(library)))
@@ -113,6 +125,13 @@ def collections(
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(
@@ -149,14 +168,69 @@ def collections(
}
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) -> 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."""
key = MEMBER_SORT_KEYS.get(sort, MEMBER_SORT_KEYS[DEFAULT_MEMBER_SORT])
ordered = sorted(members, key=lambda m: m.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}")
def collection_detail(
rating_key: str,
sort: str = DEFAULT_MEMBER_SORT,
sort_dir: str = "asc",
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""One collection's hero data + its members (movies + shows), ordered by release year then title —
the Collection info page. Read-only here; edits go through the admin mutation endpoints below."""
"""One collection's hero data + its members (movies + shows) in the requested order — the
Collection info page. The sort vocabulary is the browse grid's (`PLEX_SORT_KEYS`) so the same Sort
control means the same thing on both surfaces; the default is release year ascending, which is
what this page did before it had a control. Read-only here; edits go through the admin mutation
endpoints below."""
col = by_rating_key(db, PlexCollection, rating_key)
if col is None:
raise HTTPException(status_code=404, detail="Unknown collection")
@@ -179,19 +253,63 @@ def collection_detail(
.all()
)
mstate = rollup.item_state_map(db, user.id, [m.id for m in movies])
sstate = rollup.state_map(db, user.id, [s.id for s in shows])
# Merge the two type-homogeneous lists into ONE member list ordered by (year, title).
tagged = [(m.year, (m.title or "").lower(), "movie", m) for m in movies] + [
(s.year, (s.title or "").lower(), "show", s) for s in shows
# 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
# cheaper `rollup.state_map` gives only the status, which would leave `lastwatched` unorderable on
# a show collection while the control still offered it.
show_rows = (
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_status(r.total, r.watched, r.inprog) 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:
# Same definition as the browse grid's: when it was last resumed, else when it was marked
# watched (coalesce(progress_updated_at, watched_at)).
return (st.progress_updated_at or st.watched_at) if st else None
# Merge the two type-homogeneous lists into ONE member list, then order it.
members = [
Member(
kind="movie",
obj=m,
title=(m.title or "").lower(),
year=m.year,
rating=m.rating,
added=m.added_at,
release=m.originally_available_at,
duration=m.duration_s,
last_watched=_movie_last_watched(mstate.get(m.id)),
)
for m in movies
] + [
Member(
kind="show",
obj=sh,
title=(sh.title or "").lower(),
year=sh.year,
rating=sh.rating,
added=sh.added_at,
release=sh.originally_available_at,
duration=None,
last_watched=sresumed.get(sh.id),
)
for sh in shows
]
tagged.sort(key=lambda t: (t[0] is None, t[0] or 0, t[1]))
members = sort_members(members, sort, sort_dir)
items = [
movie_card(o, mstate.get(o.id))
if kind == "movie"
else show_card(o, sstate.get(o.id, "new"))
for _, _, kind, o in tagged
movie_card(m.obj, mstate.get(m.obj.id))
if m.kind == "movie"
else show_card(m.obj, sstate.get(m.obj.id, "new"))
for m in members
]
years = [y for y, _, _, _ in tagged if y 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]
return {
**_collection_card(col),
+291
View File
@@ -0,0 +1,291 @@
"""The Collection info page's member ordering (`GET /api/plex/collections/{rk}?sort=&sort_dir=`) and
the `keys=` lookup that gives the sidebar token / filter chip a title instead of a raw rating_key.
Reported from daily use: "Collectionben nem működik a rendezés". The browse grid sorts fine — the
collection page had no control at all and a hardcoded year-ascending order. Adding the control means
the SAME vocabulary must actually work here, hence the pin against `PLEX_SORT_KEYS`.
Pure-logic tests run everywhere; the endpoint tests use the `db` fixture (DB lane).
"""
from datetime import date, datetime, timedelta, timezone
from fastapi.testclient import TestClient
from sqlalchemy import literal_column
import app.state as state
from app.auth import current_user
from app.db import get_db
from app.main import app
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, PlexState, User
from app.plex.query import PLEX_SORT_KEYS
from app.routes.plex.browse import sort_column
from app.routes.plex.collections import (
DEFAULT_MEMBER_SORT,
MEMBER_SORT_KEYS,
Member,
sort_members,
)
NOW = datetime(2024, 6, 1, tzinfo=timezone.utc)
def _m(title: str, **over) -> Member:
base = dict(
kind="movie",
obj=None,
title=title,
year=None,
rating=None,
added=None,
release=None,
duration=None,
last_watched=None,
)
base.update(over)
return Member(**base) # type: ignore[arg-type]
def _titles(members: list[Member]) -> list[str]:
return [m.title for m in members]
# --- the vocabulary is ONE vocabulary ---------------------------------------------------------
def test_every_sort_key_is_orderable_on_a_collection():
"""The collection page shows the same Sort control as the browse grid, so a key it cannot order
would be an option that silently does nothing."""
assert set(MEMBER_SORT_KEYS) == set(PLEX_SORT_KEYS)
def test_every_sort_key_is_orderable_on_the_browse_grid():
"""The other half of the same pin: browse falls back to `added_at` for an unknown key, so a key
missing from its map would order by date while the control claimed otherwise."""
class _Cols:
title = literal_column("title")
year = literal_column("year")
rating = literal_column("rating")
duration_s = literal_column("duration_s")
rel = literal_column("rel")
last_watched = literal_column("last_watched")
added_at = literal_column("added_at")
class _U:
c = _Cols()
u = _U()
fallback = u.c.added_at
unhandled = [k for k in PLEX_SORT_KEYS if k != "added" and sort_column(u, k) is fallback]
assert unhandled == []
# …and the fallback itself is still what an unknown key gets.
assert sort_column(u, "no-such-key") is fallback
# --- ordering behaviour -----------------------------------------------------------------------
def test_orders_by_the_requested_key_in_both_directions():
members = [_m("b", year=2006), _m("c", year=2017), _m("a", year=2003)]
assert _titles(sort_members(members, "year", "asc")) == ["a", "b", "c"]
assert _titles(sort_members(members, "year", "desc")) == ["c", "b", "a"]
def test_members_without_a_value_sink_to_the_end_in_both_directions():
"""A movie with no rating is not "the worst rated" — nullslast, like the browse grid."""
members = [_m("rated-low", rating=6.5), _m("unrated"), _m("rated-high", rating=8.1)]
assert _titles(sort_members(members, "rating", "desc")) == [
"rated-high",
"rated-low",
"unrated",
]
assert _titles(sort_members(members, "rating", "asc")) == ["rated-low", "rated-high", "unrated"]
def test_ties_stay_title_ascending_when_the_direction_flips():
"""Flipping the direction must reverse the RANKING, not the alphabet inside a tie."""
members = [_m("zulu", year=2003), _m("alpha", year=2003), _m("mike", year=2017)]
assert _titles(sort_members(members, "year", "asc")) == ["alpha", "zulu", "mike"]
assert _titles(sort_members(members, "year", "desc")) == ["mike", "alpha", "zulu"]
def test_an_unknown_key_falls_back_to_the_pages_default():
members = [_m("b", year=2006), _m("a", year=2017)]
assert _titles(sort_members(members, "no-such-key", "asc")) == _titles(
sort_members(members, DEFAULT_MEMBER_SORT, "asc")
)
def test_a_show_has_no_duration_and_therefore_sinks_on_that_key():
members = [
_m("short movie", duration=3600),
_m("a show", kind="show", duration=None),
_m("long movie", duration=9000),
]
assert _titles(sort_members(members, "duration", "desc")) == [
"long movie",
"short movie",
"a show",
]
# --- the endpoint (DB lane) -------------------------------------------------------------------
def _seed(db):
lib_m = PlexLibrary(plex_key="1", title="Movies", kind="movie", enabled=True)
lib_s = PlexLibrary(plex_key="2", title="Shows", kind="show", enabled=True)
db.add_all([lib_m, lib_s])
db.flush()
col = PlexCollection(rating_key="col1", library_id=lib_m.id, title="Franchise", child_count=3)
other = PlexCollection(rating_key="col2", library_id=lib_m.id, title="Elsewhere", child_count=1)
db.add_all([col, other])
db.flush()
# Titles deliberately alphabetical-opposite to the years, so a year sort can't accidentally pass
# by falling through to the title tiebreak.
old = PlexItem(
rating_key="mo_old",
library_id=lib_m.id,
kind="movie",
title="Zulu",
year=2003,
rating=8.1,
duration_s=8000,
added_at=NOW - timedelta(days=1),
originally_available_at=date(2003, 7, 9),
collection_keys=["col1"],
)
mid = PlexItem(
rating_key="mo_mid",
library_id=lib_m.id,
kind="movie",
title="Mike",
year=2006,
rating=7.4,
duration_s=9000,
added_at=NOW - timedelta(days=3),
originally_available_at=date(2006, 7, 7),
collection_keys=["col1"],
)
new = PlexItem(
rating_key="mo_new",
library_id=lib_m.id,
kind="movie",
title="Alpha",
year=2017,
rating=6.5,
duration_s=7000,
added_at=NOW,
originally_available_at=date(2017, 5, 26),
collection_keys=["col1"],
)
# A show member with a resumed episode: `lastwatched` must work on this leg too, not just movies.
show = PlexShow(
rating_key="sh1",
library_id=lib_s.id,
title="Series",
year=2010,
child_count=1,
collection_keys=["col1"],
)
db.add_all([old, mid, new, show])
db.flush()
ep = PlexItem(
rating_key="sh1_e1",
library_id=lib_s.id,
kind="episode",
title="E1",
show_id=show.id,
season_number=1,
episode_number=1,
)
db.add(ep)
db.flush()
user = User(email="colsort@example.com")
db.add(user)
db.flush()
db.add_all(
[
# The show's episode is the most recently touched thing in the collection…
PlexState(
user_id=user.id,
item_id=ep.id,
status="new",
position_seconds=60,
progress_updated_at=NOW,
),
# …and one movie was watched earlier.
PlexState(
user_id=user.id,
item_id=old.id,
status="watched",
watched_at=NOW - timedelta(days=2),
),
]
)
db.commit()
return user
def _get(db, user, monkeypatch, path):
monkeypatch.setattr(state, "_configured_cache", True)
app.dependency_overrides[get_db] = lambda: db
app.dependency_overrides[current_user] = lambda: user
try:
return TestClient(app).get(path)
finally:
app.dependency_overrides.clear()
def _order(r) -> list[str]:
return [i["title"] for i in r.json()["items"]]
def test_collection_detail_defaults_to_release_year_ascending(db, monkeypatch):
"""The order the page had before it grew a control — a franchise reads in release order."""
user = _seed(db)
r = _get(db, user, monkeypatch, "/api/plex/collections/col1")
assert r.status_code == 200
assert _order(r) == ["Zulu", "Mike", "Series", "Alpha"]
def test_collection_detail_honours_sort_and_direction(db, monkeypatch):
user = _seed(db)
r = _get(db, user, monkeypatch, "/api/plex/collections/col1?sort=year&sort_dir=desc")
assert _order(r) == ["Alpha", "Series", "Mike", "Zulu"]
r = _get(db, user, monkeypatch, "/api/plex/collections/col1?sort=title&sort_dir=asc")
assert _order(r) == ["Alpha", "Mike", "Series", "Zulu"]
r = _get(db, user, monkeypatch, "/api/plex/collections/col1?sort=rating&sort_dir=desc")
# The show has no rating, so it sinks — it is not "rated 0".
assert _order(r) == ["Zulu", "Mike", "Alpha", "Series"]
def test_lastwatched_ranks_a_shows_episodes_alongside_the_movies(db, monkeypatch):
"""The show leg needs the rollup's `resumed_at`; with only a status map this key would order
every show as never-watched."""
user = _seed(db)
r = _get(db, user, monkeypatch, "/api/plex/collections/col1?sort=lastwatched&sort_dir=desc")
# Series (episode resumed NOW) > Zulu (watched 2 days ago) > the two never-touched, title-asc.
assert _order(r) == ["Series", "Zulu", "Alpha", "Mike"]
def test_collections_list_resolves_titles_by_key(db, monkeypatch):
"""What a shared Plex link needs: the URL carries bare rating_keys, so the sidebar token and the
filter chip have nothing to show but `17045` until these are resolved."""
user = _seed(db)
r = _get(db, user, monkeypatch, "/api/plex/collections?keys=col1")
assert r.status_code == 200
assert [c["title"] for c in r.json()["collections"]] == ["Franchise"]
def test_an_empty_keys_param_asks_for_nothing(db, monkeypatch):
"""`keys=` with nothing in it must not fall through to "every collection" — that would be a
200 KB answer to a question about zero ids."""
user = _seed(db)
r = _get(db, user, monkeypatch, "/api/plex/collections?keys=")
assert r.status_code == 200
assert r.json()["collections"] == []