PlexLibrary, PlexShow, PlexSeason, PlexItem, PlexPerson, PlexCollection, PlexState, PlexLink, PlexPlaylist and PlexPlaylistItem leave app/models.py for app/plex/models.py; app.tables registers them. Every import is re-pointed; no schema change.
302 lines
10 KiB
Python
302 lines
10 KiB
Python
"""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 User
|
|
from app.plex.models import PlexCollection, PlexItem, PlexLibrary, PlexShow, PlexState
|
|
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, override_dep, path):
|
|
monkeypatch.setattr(state, "_configured_cache", True)
|
|
override_dep(get_db, db.info["request"])
|
|
override_dep(current_user, lambda: user)
|
|
return TestClient(app).get(path)
|
|
|
|
|
|
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, override_dep):
|
|
"""The order the page had before it grew a control — a franchise reads in release order."""
|
|
user = _seed(db)
|
|
r = _get(db, user, monkeypatch, override_dep, "/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, override_dep):
|
|
user = _seed(db)
|
|
r = _get(
|
|
db, user, monkeypatch, override_dep, "/api/plex/collections/col1?sort=year&sort_dir=desc"
|
|
)
|
|
assert _order(r) == ["Alpha", "Series", "Mike", "Zulu"]
|
|
r = _get(
|
|
db, user, monkeypatch, override_dep, "/api/plex/collections/col1?sort=title&sort_dir=asc"
|
|
)
|
|
assert _order(r) == ["Alpha", "Mike", "Series", "Zulu"]
|
|
r = _get(
|
|
db, user, monkeypatch, override_dep, "/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, override_dep):
|
|
"""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,
|
|
override_dep,
|
|
"/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, override_dep):
|
|
"""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, override_dep, "/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, override_dep):
|
|
"""`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, override_dep, "/api/plex/collections?keys=")
|
|
assert r.status_code == 200
|
|
assert r.json()["collections"] == []
|