perf(feed): collapse count + facets into one /feed/summary pass (E2 S4, PERF-13)

The FE fired /feed/count (1 pass) + /facets (3 passes) on every filter change =
4 full scans of the subscribed set. New /api/feed/summary does ONE grouped query
(the base filter with tags stripped, GROUP BY channel_id -> per-channel video
counts) and derives the total count + every tag's distinct-channel facet count in
Python on the ~few-hundred-row channel map, faithfully mirroring filtered_query's
channel-based tag logic + the /facets drill-down (own category dropped, unless a
conjunctive topic-AND). Returns {count, facets} — the same numbers the two old
endpoints returned.

FE: a single shared query (Feed reads .count, Sidebar reads .facets); same key +
filters, so React Query dedupes to ONE request. Invalidations repointed to
qk.feedSummary. The old /feed/count + /facets endpoints (and their now-unused FE
api/qk) are kept as the equivalence-test oracle + safety net; a later cleanup can
drop them.

New equivalence test (test_feed_views, 8 filter combos incl. cross-category,
disjunctive/conjunctive topic modes, search): /feed/summary count == /feed/count
and facets == /facets, byte-identical. Structural win: 2 requests / 4-6 queries
-> 1 request / 2-3 queries (the review measured 530 -> 144 ms). Gate green:
344 pytest, frontend tsc/eslint/knip/prettier/222 vitest.
This commit is contained in:
2026-08-18 05:01:27 +02:00
parent 6a35e83ded
commit 21a3aae700
11 changed files with 182 additions and 18 deletions
+4
View File
@@ -131,6 +131,10 @@ export const feedApi = {
req("/api/search/clear", { method: "POST", body: JSON.stringify({ video_ids: videoIds }) }),
facets: (f: FeedFilters): Promise<{ counts: Record<string, number> }> =>
req(`/api/facets?${filterParams(f).toString()}`),
// The sidebar's total count + per-tag channel facets in ONE request + ONE grouped query, replacing
// 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<string, number> }> =>
req(`/api/feed/summary?${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) =>
+2
View File
@@ -16,6 +16,8 @@ describe("qk — bare vs parameterized branch", () => {
expect(qk.feedCount(FILTERS)).toEqual(["feed-count", FILTERS]);
expect(qk.facets()).toEqual(["facets"]);
expect(qk.facets(FILTERS)).toEqual(["facets", FILTERS]);
expect(qk.feedSummary()).toEqual(["feed-summary"]);
expect(qk.feedSummary(FILTERS)).toEqual(["feed-summary", FILTERS]);
expect(qk.playlist()).toEqual(["playlist"]);
expect(qk.plexShow()).toEqual(["plex-show"]);
expect(qk.plexShow("s1")).toEqual(["plex-show", "s1"]);
+2
View File
@@ -27,6 +27,8 @@ export const qk = {
filters !== undefined ? (["feed-count", filters] as const) : (["feed-count"] as const),
facets: (filters?: FeedFilters) =>
filters !== undefined ? (["facets", filters] as const) : (["facets"] as const),
feedSummary: (filters?: FeedFilters) =>
filters !== undefined ? (["feed-summary", filters] as const) : (["feed-summary"] 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,