feat(feed): filter by year — per-year count facet + year control (E8·S2)

Add a Year filter to the feed rail: quick chips for recent years plus a
'More…' popover listing all years with per-year video counts, sortable by
year or count. Selecting a year narrows the feed to that whole calendar
year (a published_after/before range applied in the api layer) and is
mutually exclusive with the Upload-date control.

Backend: new /api/feed/years endpoint + feed_years() — one grouped query
that honours every filter EXCEPT the publish-date narrowing (dropped), so
the year list stays a live drill-down and switching years never zeroes the
others. FE keys the list on the date-stripped filters, so picking a year
re-queries the feed but not the list.

Wires year through FeedFilters, the filterParams/URL (?year) round-trip,
the active-filter chip row, and the panel layout. i18n HU+EN.

Tests: feed_years count query (date narrowing ignored, other filters
honoured); dateFilter year-clearing; urlState year round-trip.
This commit is contained in:
2026-08-25 02:55:12 +02:00
parent 2c843e549b
commit edcea87ea5
16 changed files with 373 additions and 9 deletions
+20
View File
@@ -437,6 +437,26 @@ def feed_summary(db: Session, user: User, **params) -> dict:
return {"count": count, "facets": facets}
def feed_years(db: Session, user: User, **params) -> list[dict]:
"""Per-year video counts for the current filter context (item 8 / E8·S2): powers the year control's
quick chips + the "more…" per-year-counts dialog. Every OTHER filter (tags, channels, content type,
search, watch state, scope) still applies, but the publish-date narrowing itself is DROPPED —
published_after/before and max_age_days — so the year list is the full set of years available and
switching years stays a live drill-down (the same principle as the tag facets dropping their own
category). One grouped query, newest year first."""
base, _rank = filtered_query(
db,
user,
**{**params, "published_after": None, "published_before": None, "max_age_days": None},
)
pub = base.with_only_columns(Video.published_at.label("pub")).subquery()
year = func.extract("year", pub.c.pub)
rows = db.execute(
select(year.label("y"), func.count()).group_by(year).order_by(year.desc())
).all()
return [{"year": int(y), "count": n} for y, n in rows if y is not None]
# --- Keyset (cursor) pagination ---------------------------------------------
#
# Each sort is described as an ordered list of key columns (expression + direction
+14
View File
@@ -14,6 +14,7 @@ from app.feedquery import (
decode_cursor,
encode_cursor,
feed_summary,
feed_years,
filtered_query,
keyset_predicate,
order_by,
@@ -206,6 +207,19 @@ def get_feed_summary(
return feed_summary(db, user, **params)
@router.get("/feed/years")
def get_feed_years(
params: dict = Depends(_feed_params),
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Per-year video counts for the year control (item 8 / E8·S2): the quick recent-year chips and the
"more…" per-year-counts dialog. Every filter EXCEPT the publish-date narrowing applies, so picking a
year re-queries the feed but not this list. Response: {"years": [{"year": <int>, "count": <int>}]},
newest year first."""
return {"years": feed_years(db, user, **params)}
class VideoStateIn(BaseModel):
status: str
+52
View File
@@ -201,6 +201,58 @@ def test_untagged_filter_keeps_only_channels_the_user_has_not_tagged(db, as_user
assert _feed_ids(client, "show=all&untagged=true") == {"chSysv", "chNonev"}
def test_feed_years_counts_ignore_the_date_narrowing_but_honour_other_filters(
db, as_user, make_user
):
"""The year facet (item 8 / E8·S2): `/api/feed/years` returns a per-year video count, newest first,
with the publish-date narrowing DROPPED (so switching years stays a drill-down) but every other
filter still applied (here: a channel pick)."""
user = make_user(email="years@example.com")
chans = {c: Channel(id=c, title=c, subscriber_count=10) for c in ("chX", "chY")}
db.add_all(chans.values())
db.flush()
# chX: 2 videos in 2024, 1 in 2022; chY: 1 in 2023 (should drop once we scope to chX).
spread = {
"chX": [(2024, 1), (2024, 2), (2022, 5)],
"chY": [(2023, 3)],
}
for cid, dates in spread.items():
db.add(Subscription(user_id=user.id, channel_id=cid, priority=0, hidden=False))
for i, (yr, day) in enumerate(dates):
db.add(
Video(
id=f"{cid}v{i}",
channel_id=cid,
published_at=datetime(yr, 1, day, tzinfo=timezone.utc),
title=f"{cid} clip {i}",
is_short=False,
live_status="none",
via_search=False,
via_explore=False,
)
)
db.commit()
client = as_user(user)
# All channels: every year present, counted, newest first.
resp = client.get("/api/feed/years?show=all")
assert resp.status_code == 200, resp.text
assert resp.json()["years"] == [
{"year": 2024, "count": 2},
{"year": 2023, "count": 1},
{"year": 2022, "count": 1},
]
# A channel filter narrows the year list; a date narrowing is ignored (the point of the facet — so
# a selected year never zeroes out the others). Scoping to chX drops 2023, keeps 2024 (×2) + 2022.
resp = client.get("/api/feed/years?show=all&channel_ids=chX&published_after=2024-01-01")
assert resp.status_code == 200, resp.text
assert resp.json()["years"] == [
{"year": 2024, "count": 2},
{"year": 2022, "count": 1},
]
@pytest.mark.parametrize("case", _SUMMARY_CASES)
def test_feed_summary_matches_count_plus_facets(db, as_user, make_user, case):
# /feed/summary must return byte-identical numbers to the old /feed/count + /facets it replaces,