Files
siftlode/frontend/src/lib/api/feed.ts
T

336 lines
14 KiB
TypeScript

// 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 a resume point, the one thing "Clear watch progress" clears. Its status is no
* part of it: a hidden video stays hidden, a watched one watched. */
export function hasResumePoint(v: Pick<Video, "position_seconds">): boolean {
return v.position_seconds > 0;
}
/** A video's related videos, in the locale YouTube fills the list in: on the video's topic, or with
* what trends in Hungary (the server's `RELATED_REGIONS`). */
export type RelatedRegion = "topical" | "hu";
/** A row of a related list: a feed row that also says whether the user follows its channel. */
export interface RelatedVideo extends Video {
followed: boolean;
}
export interface RelatedResponse {
items: RelatedVideo[];
has_more: boolean;
next_cursor: string | null;
}
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.
}
/** The feed's view and order with nothing chosen: what a bookmark of the plain feed names, and what
* the active-filter chips read "nothing applied" against. */
export const FEED_DEFAULT_SHOW = "unwatched";
export const FEED_DEFAULT_SORT = "newest";
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<Record<(typeof TAKEOUT_OUTCOMES)[number] | "waiting", number>>;
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<string, unknown> | 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<FeedResponse> =>
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<FeedResponse> => {
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 });
},
// The videos YouTube shows beside this one, without what the user has watched or hidden. `quiet`
// for the same reason as the search: the list renders its refusal (the day's cap, YouTube) inline.
related: (
videoId: string,
region: RelatedRegion,
cursor: string | null,
): Promise<RelatedResponse> => {
const p = new URLSearchParams({ region });
if (cursor) p.set("cursor", cursor);
return req(
`/api/search/related/${encodeURIComponent(videoId)}?${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<string, number> }> =>
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 },
),
clearProgress: (id: string) =>
req(`/api/videos/${id}/progress`, { 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<StoredProgress> =>
req<StoredProgress>(
`/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<VideoDetail> => req(`/api/videos/${id}`),
/** One catalog video as the feed lists it (this user's state with it): what a player opened from an
* address starts from. */
videoRow: (id: string): Promise<Video> => req(`/api/videos/${encodeURIComponent(id)}/row`),
// One file of a Google Takeout export, its bytes as they are; the server tells the two apart.
takeoutUpload: (file: File): Promise<TakeoutUpload> => uploadReq("/api/takeout", file),
takeoutStatus: (): Promise<TakeoutStatus> => req("/api/takeout"),
watchedChannels: (): Promise<WatchedChannels> => req("/api/takeout/channels"),
watchedChannelVideos: (id: string): Promise<WatchedChannelVideos> =>
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" }),
};