diff --git a/backend/app/feedquery.py b/backend/app/feedquery.py index c432917c..ec78d644 100644 --- a/backend/app/feedquery.py +++ b/backend/app/feedquery.py @@ -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 diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index e694f15c..2d6f3635 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -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": , "count": }]}, + newest year first.""" + return {"years": feed_years(db, user, **params)} + + class VideoStateIn(BaseModel): status: str diff --git a/backend/tests/test_feed_views.py b/backend/tests/test_feed_views.py index deaa3cd6..9ac5a603 100644 --- a/backend/tests/test_feed_views.py +++ b/backend/tests/test_feed_views.py @@ -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, diff --git a/frontend/src/components/DateFilterSelect.tsx b/frontend/src/components/DateFilterSelect.tsx index 9df1cd33..f4fe6587 100644 --- a/frontend/src/components/DateFilterSelect.tsx +++ b/frontend/src/components/DateFilterSelect.tsx @@ -151,6 +151,7 @@ export default function DateFilterSelect({ ...filters, dateFrom: e.target.value || undefined, maxAgeDays: undefined, + year: undefined, }) } className={dateInputCls} @@ -169,6 +170,7 @@ export default function DateFilterSelect({ ...filters, dateTo: e.target.value || undefined, maxAgeDays: undefined, + year: undefined, }) } className={dateInputCls} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 9f5b5442..efe3baa2 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -15,6 +15,7 @@ import { type PanelItem } from "./PanelGroups"; import { type MediaSourceKind } from "../lib/mediaSource"; import ChannelPicker from "./ChannelPicker"; import DateFilterSelect from "./DateFilterSelect"; +import YearFilter from "./YearFilter"; import Combobox, { type ComboboxOption } from "./Combobox"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; import { useChannelsActions } from "./ChannelsProvider"; @@ -145,6 +146,7 @@ export default function Sidebar({ const available: Record = { date: true, + year: true, channel: true, language: languages.length > 0, topic: topics.length > 0, @@ -159,6 +161,8 @@ export default function Sidebar({ return ( ); + case "year": + return ; case "language": return ( void; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [sortByCount, setSortByCount] = useState(false); + + const facetFilters: FeedFilters = { + ...filters, + year: undefined, + maxAgeDays: undefined, + dateFrom: undefined, + dateTo: undefined, + }; + const yearsQuery = useQuery({ + queryKey: qk.feedYears(facetFilters), + queryFn: () => api.feedYears(facetFilters), + staleTime: 30_000, + placeholderData: keepPreviousData, + }); + const years = yearsQuery.data?.years ?? []; + const selected = filters.year; + + // A year pick and the Upload-date control are mutually exclusive publish-date narrowings. + const pick = (year?: number) => + setFilters({ ...filters, year, maxAgeDays: undefined, dateFrom: undefined, dateTo: undefined }); + + // Quick chips = the most recent years, plus the selected year if it falls outside that window (so the + // active pick is always visible), re-sorted newest-first. + const quick = useMemo(() => { + const top = years.slice(0, QUICK); + if (selected != null && !top.some((y) => y.year === selected)) { + const sel = years.find((y) => y.year === selected) ?? { year: selected, count: 0 }; + return [...top, sel].sort((a, b) => b.year - a.year); + } + return top; + }, [years, selected]); + + const sorted = useMemo( + () => + [...years].sort((a, b) => + sortByCount ? b.count - a.count || b.year - a.year : b.year - a.year, + ), + [years, sortByCount], + ); + + const pop = useAnchoredPopover({ + open, + onOpenChange: setOpen, + mode: "menu", + placement: "bottom-start", + }); + + // Honest states: a first-load / errored fetch must not read as "no years" (an empty chip row). + if (years.length === 0) { + if (yearsQuery.isLoading) { + return ( +

+ {t("common.loading")} +

+ ); + } + if (yearsQuery.isError) { + return ( +

+ {t("common.loadError")} +

+ ); + } + return

{t("sidebar.year.none")}

; + } + + return ( +
+ {quick.map((y) => { + const on = selected === y.year; + return ( + + ); + })} + + {years.length > quick.length && ( + <> + + + +
+ {t("sidebar.year.sortBy")} +
+ {( + [ + ["sortYear", false], + ["sortCount", true], + ] as const + ).map(([key, v]) => ( + + ))} +
+
+
+ {sorted.map((y) => { + const on = selected === y.year; + return ( + { + pick(on ? undefined : y.year); + setOpen(false); + }} + label={String(y.year)} + className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${ + on ? "text-fg" : "text-muted" + }`} + > + + {y.year} + {y.count} + + ); + })} +
+
+ + )} +
+ ); +} diff --git a/frontend/src/i18n/locales/en/sidebar.json b/frontend/src/i18n/locales/en/sidebar.json index db1b8e3c..b524d0c5 100644 --- a/frontend/src/i18n/locales/en/sidebar.json +++ b/frontend/src/i18n/locales/en/sidebar.json @@ -38,7 +38,16 @@ "language": "Language", "topic": "Topic", "tags": "Your tags", - "channel": "Channel" + "channel": "Channel", + "year": "Year" + }, + "year": { + "more": "More…", + "moreLabel": "All years", + "none": "No years yet", + "sortBy": "Sort", + "sortYear": "Year", + "sortCount": "Count" }, "show": { "unwatched": "Unwatched", diff --git a/frontend/src/i18n/locales/hu/sidebar.json b/frontend/src/i18n/locales/hu/sidebar.json index 4fe76126..622df4ef 100644 --- a/frontend/src/i18n/locales/hu/sidebar.json +++ b/frontend/src/i18n/locales/hu/sidebar.json @@ -38,7 +38,16 @@ "language": "Nyelv", "topic": "Téma", "tags": "Saját címkék", - "channel": "Csatorna" + "channel": "Csatorna", + "year": "Év" + }, + "year": { + "more": "Több…", + "moreLabel": "Összes év", + "none": "Még nincs év", + "sortBy": "Rendezés", + "sortYear": "Év", + "sortCount": "Darab" }, "show": { "unwatched": "Nem nézett", diff --git a/frontend/src/lib/api/feed.ts b/frontend/src/lib/api/feed.ts index a690d498..8ff19306 100644 --- a/frontend/src/lib/api/feed.ts +++ b/frontend/src/lib/api/feed.ts @@ -81,6 +81,16 @@ export interface FeedFilters { maxDuration?: number; dateFrom?: string; dateTo?: string; + /** Filter-by-year (item 8): a single calendar year, expressed on the wire as a whole-year + * published_after/before range. Mutually exclusive with maxAgeDays/dateFrom/dateTo (all + * publish-date narrowings) — the setters clear the others, and `filterParams` lets year win. */ + year?: number; +} + +/** One year's video count for the year control's chips + "more…" dialog (item 8 / E8·S2). */ +export interface YearCount { + year: number; + count: number; } // The filter half of the query (everything except paging/sort), shared by the feed and @@ -100,9 +110,16 @@ function filterParams(f: FeedFilters): URLSearchParams { p.set("include_live", String(f.includeLive)); p.set("show", f.show); f.channelIds.forEach((id) => p.append("channel_ids", id)); - if (f.maxAgeDays) p.set("max_age_days", String(f.maxAgeDays)); - if (f.dateFrom) p.set("published_after", f.dateFrom); - if (f.dateTo) p.set("published_before", f.dateTo); + // Year (item 8) is a whole-year publish-date range and wins over the maxAge/custom-range controls + // (they're mutually exclusive publish-date narrowings — the setters enforce it, this guards the wire). + if (f.year) { + p.set("published_after", `${f.year}-01-01`); + p.set("published_before", `${f.year}-12-31`); + } else { + if (f.maxAgeDays) p.set("max_age_days", String(f.maxAgeDays)); + if (f.dateFrom) p.set("published_after", f.dateFrom); + if (f.dateTo) p.set("published_before", f.dateTo); + } if (f.minDuration != null) p.set("min_duration", String(f.minDuration)); if (f.maxDuration != null) p.set("max_duration", String(f.maxDuration)); // Only relevant in Library scope; the backend ignores it for "my". Default = hide search. @@ -148,6 +165,10 @@ export const feedApi = { // the separate feedCount + facets the feed and sidebar used to fire on every filter change (PERF-13). feedSummary: (f: FeedFilters): Promise<{ count: number; facets: Record }> => req(`/api/feed/summary?${filterParams(f).toString()}`), + // Per-year video counts for the year control (item 8 / E8·S2). The backend drops the publish-date + // narrowing, so callers key this on the date-stripped filters (picking a year mustn't refetch it). + feedYears: (f: FeedFilters): Promise<{ years: YearCount[] }> => + req(`/api/feed/years?${filterParams(f).toString()}`), // idempotent: setting a state / saving a progress checkpoint is safe to replay, so these // recover from a transient gateway/keepalive 502 instead of surfacing it (see req()). setState: (id: string, status: string) => diff --git a/frontend/src/lib/dateFilter.test.ts b/frontend/src/lib/dateFilter.test.ts index 3d8bb58c..666348e4 100644 --- a/frontend/src/lib/dateFilter.test.ts +++ b/frontend/src/lib/dateFilter.test.ts @@ -87,4 +87,11 @@ describe("selectDateChip", () => { const change = selectDateChip("30", base({ q: "hello", tags: [1, 2] })); expect(change.filters).toMatchObject({ q: "hello", tags: [1, 2], maxAgeDays: 30 }); }); + + it("clears a selected year on any date pick (they're mutually exclusive)", () => { + // preset, custom, and 'any' all drop the year — it's the same publish-date narrowing. + expect(selectDateChip("30", base({ year: 2024 })).filters.year).toBeUndefined(); + expect(selectDateChip("custom", base({ year: 2024 })).filters.year).toBeUndefined(); + expect(selectDateChip("any", base({ year: 2024 })).filters.year).toBeUndefined(); + }); }); diff --git a/frontend/src/lib/dateFilter.ts b/frontend/src/lib/dateFilter.ts index ebec8287..1f0684b7 100644 --- a/frontend/src/lib/dateFilter.ts +++ b/frontend/src/lib/dateFilter.ts @@ -32,18 +32,26 @@ export function selectDateChip( key: string, filters: FeedFilters, ): { filters: FeedFilters; customOpen: boolean } { + // A date pick and the year control are mutually exclusive publish-date narrowings, so any date + // selection clears the year (and vice versa, in YearFilter). if (key === "custom") { - return { filters: { ...filters, maxAgeDays: undefined }, customOpen: true }; + return { filters: { ...filters, maxAgeDays: undefined, year: undefined }, customOpen: true }; } if (key === "any" || key === "") { return { - filters: { ...filters, maxAgeDays: undefined, dateFrom: undefined, dateTo: undefined }, + filters: { + ...filters, + maxAgeDays: undefined, + dateFrom: undefined, + dateTo: undefined, + year: undefined, + }, customOpen: false, }; } const days = Number(key); return { - filters: { ...filters, maxAgeDays: days, dateFrom: undefined, dateTo: undefined }, + filters: { ...filters, maxAgeDays: days, dateFrom: undefined, dateTo: undefined, year: undefined }, customOpen: false, }; } diff --git a/frontend/src/lib/panelLayout.ts b/frontend/src/lib/panelLayout.ts index 88eebc04..f1f66c73 100644 --- a/frontend/src/lib/panelLayout.ts +++ b/frontend/src/lib/panelLayout.ts @@ -16,7 +16,7 @@ export interface PanelLayout { // Unknown/stale ids are dropped and any missing (e.g. a group added in a later version) are // appended by normalizeLayout, so nothing silently disappears. const PANEL_ORDER: Record = { - feed: ["date", "channel", "tags", "language", "topic"], + feed: ["date", "year", "channel", "tags", "language", "topic"], plex: [ "playlists", "watch", diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 356d5910..703c322c 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -29,6 +29,8 @@ export const qk = { filters !== undefined ? (["facets", filters] as const) : (["facets"] as const), feedSummary: (filters?: FeedFilters) => filters !== undefined ? (["feed-summary", filters] as const) : (["feed-summary"] as const), + feedYears: (filters?: FeedFilters) => + filters !== undefined ? (["feed-years", filters] as const) : (["feed-years"] as const), ytSearch: (q?: string | null, count?: number) => q !== undefined ? (["yt-search", q, count] as const) : (["yt-search"] as const), videoDetail: (id: string) => ["video-detail", id] as const, diff --git a/frontend/src/lib/urlState.dom.test.ts b/frontend/src/lib/urlState.dom.test.ts index a89b5911..051f1a9c 100644 --- a/frontend/src/lib/urlState.dom.test.ts +++ b/frontend/src/lib/urlState.dom.test.ts @@ -141,6 +141,15 @@ describe("filterParamUpdates", () => { expect(back.tagModes).toEqual({ language: "or", topic: "and", other: "or" }); }); + it("round-trips the year filter; emitted only when set", () => { + expect(filtersToParams({ ...DEFAULTS, year: 2024 }).get("year")).toBe("2024"); + expect(filtersToParams({ ...DEFAULTS, year: undefined }).has("year")).toBe(false); + expect(paramsToFilters(new URLSearchParams("year=2024"), DEFAULTS).year).toBe(2024); + // Junk / absent → undefined (off), never a NaN year. + expect(paramsToFilters(new URLSearchParams("year=nope"), DEFAULTS).year).toBeUndefined(); + expect(paramsToFilters(new URLSearchParams(""), DEFAULTS).year).toBeUndefined(); + }); + it("round-trips the 'No tag' (untagged) flag; emitted only when on", () => { expect(filtersToParams({ ...DEFAULTS, untagged: true }).get("notag")).toBe("1"); expect(filtersToParams({ ...DEFAULTS, untagged: false }).has("notag")).toBe(false); diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index 5541c9c4..c5a3801f 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -25,6 +25,7 @@ const KEYS = [ "maxage", "from", "to", + "year", "mindur", "maxdur", ] as const; @@ -50,6 +51,7 @@ export function filtersToParams(f: FeedFilters): URLSearchParams { if (f.maxAgeDays) p.set("maxage", String(f.maxAgeDays)); if (f.dateFrom) p.set("from", f.dateFrom); if (f.dateTo) p.set("to", f.dateTo); + if (f.year) p.set("year", String(f.year)); if (f.minDuration != null) p.set("mindur", String(f.minDuration)); if (f.maxDuration != null) p.set("maxdur", String(f.maxDuration)); return p; @@ -102,6 +104,7 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee f.maxAgeDays = num(params.get("maxage")); f.dateFrom = params.get("from") || undefined; f.dateTo = params.get("to") || undefined; + f.year = num(params.get("year")); f.minDuration = num(params.get("mindur")); f.maxDuration = num(params.get("maxdur")); return f; diff --git a/frontend/src/lib/useActiveFilters.ts b/frontend/src/lib/useActiveFilters.ts index 956f2bdd..e246dd8e 100644 --- a/frontend/src/lib/useActiveFilters.ts +++ b/frontend/src/lib/useActiveFilters.ts @@ -120,6 +120,14 @@ export function useActiveFilters( }); } + if (filters.year) { + out.push({ + key: "year", + label: String(filters.year), + remove: () => setFilters({ ...filters, year: undefined }), + }); + } + if (filters.maxAgeDays || filters.dateFrom || filters.dateTo) { const label = filters.maxAgeDays ? t("feed.chips.maxAge", { count: filters.maxAgeDays })