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:
@@ -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"] == []
|
||||
Reference in New Issue
Block a user