// Feed, video watch-state/progress, and live YouTube search. `filterParams`/`feedQuery` translate // the shared FeedFilters into the query string the feed + facet endpoints both consume; they stay // module-local (no consumer names them). Only touches core's req/beacon. import { req, beacon } from "./core"; import { TAG_CATEGORIES, type TagModes } from "./tagModes"; // Per-user watch status stored server-side (VALID_STATES in routes/feed.py). "in_progress" and // "unwatched" are NOT statuses — they're derived feed filters (status "new" + a resume position), // so they never appear on the wire. export type VideoStatus = "new" | "watched" | "hidden"; export interface Video { id: string; title: string | null; channel_id: string; channel_title: string | null; channel_thumbnail: string | null; channel_url: string; published_at: string | null; thumbnail_url: string | null; duration_seconds: number | null; view_count: number | null; is_short: boolean; live_status: string; status: VideoStatus; position_seconds: number; saved: boolean; // is the video in the user's built-in Watch later playlist watch_url: string; } /** Whether a video has anything its reset would clear: pristine = never opened, the default status * and no resume position. The card's reset button and the card menu's reset both ask this. */ export function isResettable(v: Pick): boolean { return v.status !== "new" || v.position_seconds > 0; } export interface VideoDetail { id: string; description: string | null; like_count: number | null; in_db: boolean; channel_id: string | null; channel_title: string | null; published_at: string | null; view_count: number | null; duration_seconds: number | null; } /** The stored answer of `POST /api/videos/{id}/progress` — the row as it now stands. */ export interface StoredProgress { video_id: string; position_seconds: number; } export interface FeedResponse { items: Video[]; has_more: boolean; // Opaque keyset cursor for the next page, or null when this is the last page. next_cursor: string | null; // Only set by the live YouTube search: "scrape" (InnerTube, no API quota) or "api" // (search.list, 100 units/page) — lets the UI drop the quota warning in scrape mode. source?: "scrape" | "api"; // NOTE: /api/feed also returns an echoed `limit`, but /api/search/youtube does not and nothing // in the app reads it, so it's deliberately omitted rather than typed required (a lie for search) // or optional (dead surface). Re-add if a consumer ever needs it. } export interface FeedFilters { tags: number[]; /** Per-category tag match (language / topic / other each independently any/all). */ tagModes: TagModes; q: string; sort: string; // Re-roll token for the "shuffle" sort; bumped by the reshuffle button so the // feed re-queries with a fresh order. Ignored by every other sort. seed?: number; // "my" = only your subscriptions (default); "all" = the whole shared catalog. scope: "my" | "all"; includeNormal: boolean; includeShorts: boolean; includeLive: boolean; show: string; // Library (scope "all") only — provenance filter: "organic" (default) hides search- // discovered videos, "all" mixes them in, "search" shows only them. Ignored for "my". librarySource?: "organic" | "all" | "search" | "plex"; /** "No tag" filter (item B): keep only channels the user has NOT personally tagged in the channel * manager (no user-owned ChannelTag). Auto/system tags don't count. */ untagged?: boolean; /** OR-ed together: a video from ANY of these passes, and every other filter still applies. */ channelIds: string[]; maxAgeDays?: number; minDuration?: number; 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. */ export interface YearCount { year: number; count: number; } // The filter half of the query (everything except paging/sort), shared by the feed and // the facet-count endpoint so both see exactly the same filter context. function filterParams(f: FeedFilters): URLSearchParams { const p = new URLSearchParams(); f.tags.forEach((t) => p.append("tags", String(t))); // Only emit the categories set to "and"; the backend defaults each to "or". for (const cat of TAG_CATEGORIES) { if (f.tagModes[cat] === "and") p.set(`tag_mode_${cat}`, "and"); } if (f.untagged) p.set("untagged", "true"); if (f.q) p.set("q", f.q); p.set("scope", f.scope); p.set("show_normal", String(f.includeNormal)); p.set("include_shorts", String(f.includeShorts)); p.set("include_live", String(f.includeLive)); p.set("show", f.show); f.channelIds.forEach((id) => p.append("channel_ids", id)); // 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. p.set("library_source", f.librarySource ?? "organic"); return p; } function feedQuery(f: FeedFilters, cursor: string | null, limit: number): string { const p = filterParams(f); p.set("sort", f.sort); if (f.sort === "shuffle" && f.seed) p.set("seed", String(f.seed)); if (cursor) p.set("cursor", cursor); p.set("limit", String(limit)); return p.toString(); } export const feedApi = { feed: (f: FeedFilters, cursor: string | null, limit: number): Promise => req(`/api/feed?${feedQuery(f, cursor, limit)}`), // Live YouTube search: results are materialised into the catalog and returned as feed cards. // `quiet` so the YT-search panel can render quota/limit errors (incl. 429) inline instead of // popping the global modal. `cursor` is the next-page token (an InnerTube continuation token // in scrape mode, or a Data API pageToken in api mode); the response's `source` says which. searchYoutube: (q: string, cursor: string | null, limit?: number): Promise => { const p = new URLSearchParams({ q }); if (cursor) p.set("cursor", cursor); if (limit) p.set("limit", String(limit)); return req(`/api/search/youtube?${p.toString()}`, {}, { quiet: true }); }, // Discard a set of live-search results "as if never added" (drops your search-finds + deletes // the now-orphaned, un-kept videos). Returns how many videos were removed. clearSearch: (videoIds: string[]): Promise<{ removed: number }> => req("/api/search/clear", { method: "POST", body: JSON.stringify({ video_ids: videoIds }) }), // 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 }> => req(`/api/feed/summary?${filterParams(f).toString()}`), // Per-year video counts for the year control. 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) => req( `/api/videos/${id}/state`, { method: "POST", body: JSON.stringify({ status }) }, { idempotent: true }, ), clearState: (id: string) => req(`/api/videos/${id}/state`, { method: "DELETE" }, { idempotent: true }), // Returns the position the server actually STORED — which is not always what we sent: a // trivially-early or near-finished checkpoint is cleared to 0 (see set_video_progress). Callers // that mirror the position into a cache must write this answer, never their own reading, so the // client can never disagree with the row. saveProgress: ( id: string, positionSeconds: number, durationSeconds: number, ): Promise => req( `/api/videos/${id}/progress`, { method: "POST", body: JSON.stringify({ position_seconds: Math.floor(positionSeconds), duration_seconds: Math.floor(durationSeconds), }), }, { idempotent: true }, ), // Fire-and-forget YT progress save that survives page unload (F5/close/navigate). saveProgressBeacon: (id: string, positionSeconds: number, durationSeconds: number): void => beacon(`/api/videos/${encodeURIComponent(id)}/progress`, { position_seconds: Math.floor(positionSeconds), duration_seconds: Math.floor(durationSeconds), }), videoDetail: (id: string): Promise => req(`/api/videos/${id}`), };