feat(filters): pick one year from a decade grid, in the feed and in Plex

A shared YearPicker: the recent years as chips with their counts, and a
More dialog that lays every year out by decade (sortable by count), so a
ninety-year library fits on one screen. The feed's scrolling popover gives
way to it; the Plex rail gets it above its range fields, a pick setting
both ends. The Plex facets now count titles per year, the year filter left
out.
This commit is contained in:
2026-11-14 15:08:50 +01:00
parent 94f9ffdb1c
commit 1dff809268
10 changed files with 338 additions and 161 deletions
+14 -11
View File
@@ -242,6 +242,7 @@ def facets(db: Session, user: User, *, scope: str, show: str, lang: str, f: Libr
"content_ratings": [],
"studios": [],
"playable": [],
"years": [],
"year_min": None,
"year_max": None,
"rating_max": None,
@@ -302,7 +303,7 @@ def facets(db: Session, user: User, *, scope: str, show: str, lang: str, f: Libr
cr_counts: dict[str, int] = {}
studio_counts: dict[str, int] = {}
playable_counts: dict[str, int] = {}
years: list[int] = []
year_counts: dict[int, int] = {}
ratings: list[float] = []
durs: list[int] = []
@@ -383,21 +384,22 @@ def facets(db: Session, user: User, *, scope: str, show: str, lang: str, f: Libr
)
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(
# Titles per year — exclude the year filter, so a picked year never empties the list it was
# picked from. The year bounds are read from the same counts.
yq = (
wsq(
select(func.min(model.year), func.max(model.year)),
select(model.year, func.count()),
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])
.where(model.year.isnot(None))
.group_by(model.year)
)
for y, c in db.execute(yq).all():
year_counts[y] = year_counts.get(y, 0) + c
# 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(
@@ -465,8 +467,9 @@ def facets(db: Session, user: User, *, scope: str, show: str, lang: str, f: Libr
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,
"years": [{"year": y, "count": c} for y, c in sorted(year_counts.items(), reverse=True)],
"year_min": min(year_counts) if year_counts else None,
"year_max": max(year_counts) if year_counts 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,
+22
View File
@@ -105,6 +105,28 @@ def test_the_facets_count_what_the_caller_would_see(db, make_user):
assert (theirs["year_min"], mine["year_min"]) == (2001, 1990)
def test_the_facets_count_titles_per_year_without_the_year_filter(db, make_user):
# The year picker lists every year with its count. Like each facet it drops its own filter (a
# picked year must not empty the list it was picked from) and keeps the others; movies and shows
# of one year add up, newest first.
me = make_user()
movies, shows = _lib(db), _lib(db, "2", kind="show")
_movie(db, movies, "a", genres=["Drama"], year=2001)
_movie(db, movies, "b", genres=["Drama"], year=2001)
_movie(db, movies, "c", genres=["Drama"], year=1990)
_movie(db, movies, "d", genres=["Horror"], year=1985)
_movie(db, movies, "e", genres=["Drama"]) # no year: not a year to pick
db.add(PlexShow(rating_key="s1", library_id=shows.id, title="S", genres=["Drama"], year=1990))
db.commit()
f = LibraryFilters(genres="Drama", year_min=2001, year_max=2001)
got = library.facets(db, me, scope="both", show="all", lang="en", f=f)
assert got["years"] == [{"year": 2001, "count": 2}, {"year": 1990, "count": 2}]
assert (got["year_min"], got["year_max"]) == (1990, 2001)
movie_only = library.facets(db, me, scope="movie", show="all", lang="en", f=f)
assert movie_only["years"] == [{"year": 2001, "count": 2}, {"year": 1990, "count": 1}]
def test_an_episode_search_joins_the_callers_state(db, make_user):
me, other = make_user(), make_user()
lib = _lib(db, "2", kind="show")