// 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, uploadReq } from "./core"; import type { RunStatus } from "./admin"; 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(); } // The import's job (`scheduler.TAKEOUT_IMPORT`, held to it by backendIds.contract.test): its notice // reaches a user who may not see the Scheduler, so it opens their own import instead. export const TAKEOUT_JOB = "takeout_import"; // A Google Takeout file, uploaded: which one it was, the videos read from it, and its entries left // out (an ad, a view of something that is not a video, a row that could not be read). export interface TakeoutUpload { ok: true; kind: TakeoutKind; videos: number; entries: number; ads: number; other: number; malformed: number; } // The two files (`reader.models.TakeoutKind`) and a kept video's outcome (`TakeoutOutcome`): watched // / kept (the user's own state here stood) / short for the history; added / present / missing for // Watch later. backendIds.contract.test holds both lists to the backend's enums. export const TAKEOUT_KINDS = ["watched", "watch_later"] as const; export const TAKEOUT_OUTCOMES = [ "watched", "kept", "short", "added", "present", "missing", ] as const; export type TakeoutKind = (typeof TAKEOUT_KINDS)[number]; // Each outcome's count, and "waiting" for the videos the import could not settle yet. export type TakeoutCounts = Partial>; export interface TakeoutStatus { /** An import is queued and its run not started: `run` is still the one before it. */ queued: boolean; /** The largest file the upload takes. */ max_bytes: number; run: { status: RunStatus; started_at: string | null; finished_at: string | null; result: Record | null; failure: { failure: string; [key: string]: unknown } | null; } | null; watched?: TakeoutCounts; watch_later?: TakeoutCounts; } // A channel the user's Takeout history shows them watching often but not following // (`GET /api/takeout/channels`): `watched` counts its videos of five minutes or more they watched. export interface WatchedChannel { id: string; title: string | null; handle: string | null; thumbnail_url: string | null; subscriber_count: number | null; video_count: number | null; description: string | null; watched: number; last_watched_at: string | null; } export interface WatchedChannels { /** Whether a watch history was uploaded at all ("nothing to suggest" vs "nothing to go on"). */ imported: boolean; /** How many more channels may join once the import has asked YouTube for their videos' lengths. */ pending: number; channels: WatchedChannel[]; } /** A channel's counted videos, ready to play, last watched first; `gone`: those YouTube lost. */ export interface WatchedChannelVideos { ok: true; items: (Video & { takeout_watched_at: string | null })[]; gone: number; } 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}`), // One file of a Google Takeout export, its bytes as they are; the server tells the two apart. takeoutUpload: (file: File): Promise => uploadReq("/api/takeout", file), takeoutStatus: (): Promise => req("/api/takeout"), watchedChannels: (): Promise => req("/api/takeout/channels"), watchedChannelVideos: (id: string): Promise => req(`/api/takeout/channels/${encodeURIComponent(id)}/videos`, { method: "POST" }), dismissWatchedChannel: (id: string) => req(`/api/takeout/channels/${encodeURIComponent(id)}/dismissal`, { method: "PUT" }), restoreWatchedChannel: (id: string) => req(`/api/takeout/channels/${encodeURIComponent(id)}/dismissal`, { method: "DELETE" }), };