The code cited its plan instead of saying why: `E28 review J4`, `(R6 S3b)`, `S1 justified…`,
`deep-review round 5`. That plan lives in the private ops repo, so for every reader of this one the
citation was noise, and a sentence whose subject was the plan step ("round 2 showed…") hid its
lesson behind an id. Each one is gone and the "why" kept: a bare citation is deleted, a sentence
carrying a lesson is reworded around it ("a later attempt showed…").
Measured (`scripts/audit_metrics.py`, product code): 652 lines in 270 files carried an epic code by
the definition the next commit holds (507 / 241 by the old, two-digit-only one) -> 0. Across all
code, tests, scripts and configs: ~1100 epic-code lines plus ~320 sprint/round ids.
Behaviour unchanged, checked mechanically: every changed .py file has the same AST once docstrings
are blanked; css/sh/toml/yml/json files are identical once comments are stripped; the only
non-comment changes in TS/JS are test titles, one description string in a contract table, and five
ESLint messages, whose text is all the gates snapshot diff holds. Not touched: Markdown (docs and
the changelog keep their history), Alembic migrations, TV episode notation (`S2 · E5`), DESIGN.md
ids (`C1`, `B4`, `S3h`), tracker ids (`SEC-16`, `DX-17`).
One stale claim fixed on the way: test_hls_reaper's docstring said the player checkpoints "every
10 s whether or not it is playing", which plex/stream.py calls wrong; it now says what the code does.
214 lines
9.6 KiB
TypeScript
214 lines
9.6 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 } 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<Video, "status" | "position_seconds">): 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<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 });
|
|
},
|
|
// 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 },
|
|
),
|
|
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<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}`),
|
|
};
|