From 5de6be8fded4e7a9ba795498e79b9e2c5ca311a1 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 27 Jul 2026 22:40:11 +0200 Subject: [PATCH] =?UTF-8?q?refactor(api):=20literal-union=20the=20closed-v?= =?UTF-8?q?alue=20string=20fields=20(R7=20S3a=C2=B71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Video.status, Playlist.kind/source (+ PlaylistDetail), DownloadJob.job_kind were bare `string`; type them as named unions verified against the backend (VALID_STATES={new,watched,hidden}; kind user|watch_later; source local| youtube; job_kind download|edit). VideoStatus then propagates through the optimistic-override map, onState, and the VideoCard/VirtualFeed/PlayerModal props — tsc now rejects a status typo (e.g. a stale "in_progress", which is a derived filter, never a stored status) at every call site. --- frontend/src/components/Feed.tsx | 8 ++++---- frontend/src/components/PlayerModal.tsx | 4 ++-- frontend/src/components/VideoCard.tsx | 8 ++++---- frontend/src/components/VirtualFeed.tsx | 4 ++-- frontend/src/lib/api.ts | 22 ++++++++++++++++------ 5 files changed, 28 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index e8e7ff9..4573e1c 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -22,7 +22,7 @@ import { Youtube, type LucideIcon, } from "lucide-react"; -import { api, HttpError, type FeedFilters, type Video } from "../lib/api"; +import { api, HttpError, type FeedFilters, type Video, type VideoStatus } from "../lib/api"; import i18n from "../i18n"; import { notify, resolveVideo } from "../lib/notifications"; import { useDebounced } from "../lib/useDebounced"; @@ -70,7 +70,7 @@ const VIEW_ICON: Record = { // (which encode both). One entry per concept; a single arrow flips the direction. // "relevance" (full-text search ranking) and "shuffle" are directionless single-string sorts. -function matchesView(status: string, show: string): boolean { +function matchesView(status: VideoStatus, show: string): boolean { switch (show) { case "hidden": return status === "hidden"; @@ -126,7 +126,7 @@ export default function Feed({ const sortKeys = channelScoped ? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority") : SORT_KEYS; - const [overrides, setOverrides] = useState>({}); + const [overrides, setOverrides] = useState>({}); const [savedOverrides, setSavedOverrides] = useState>({}); // The open player: which video and where to start (null = resume from saved position). const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>( @@ -241,7 +241,7 @@ export default function Feed({ const loadedRef = useRef([]); const onState = useCallback( - (id: string, status: string) => { + (id: string, status: VideoStatus) => { setOverrides((o) => ({ ...o, [id]: status })); // Refetch once the server has the change so other views (e.g. Hidden) are in sync. // Announce the change only AFTER the server confirms it: a "Marked watched"/"Hidden" diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index a9f023b..e49f550 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -29,7 +29,7 @@ import Avatar from "./Avatar"; import AddToPlaylist from "./AddToPlaylist"; import { modalCount } from "./Modal"; import DownloadButton from "./DownloadButton"; -import { api, type Video } from "../lib/api"; +import { api, type Video, type VideoStatus } from "../lib/api"; import { channelYouTubeUrl, formatDate, @@ -118,7 +118,7 @@ export default function PlayerModal({ queue?: Video[]; startIndex?: number; onClose: () => void; - onState: (id: string, status: string) => void; + onState: (id: string, status: VideoStatus) => void; // Open the active video's channel page in-app (closes the player first). The small external // icon next to the name keeps the open-on-YouTube behaviour. }) { diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index fdd185b..8e1500e 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -18,7 +18,7 @@ import Avatar from "./Avatar"; import AddToPlaylist from "./AddToPlaylist"; import DownloadButton from "./DownloadButton"; import clsx from "clsx"; -import type { Video } from "../lib/api"; +import type { Video, VideoStatus } from "../lib/api"; import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView"; import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format"; @@ -30,14 +30,14 @@ function Actions({ dense = false, }: { video: Video; - onState: (id: string, status: string) => void; + onState: (id: string, status: VideoStatus) => void; onResetState?: (id: string) => void; onToggleSave: (id: string, saved: boolean) => void; /** Tighter buttons for the small card, whose column bottoms out at 180px. */ dense?: boolean; }) { const { t } = useTranslation(); - const act = (status: string) => (e: React.MouseEvent) => { + const act = (status: VideoStatus) => (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); onState(video.id, video.status === status ? "new" : status); @@ -375,7 +375,7 @@ function VideoCard({ view: FeedView; /** Measured width of this card/row, from VirtualFeed. 0 until the first measure. */ width: number; - onState: (id: string, status: string) => void; + onState: (id: string, status: VideoStatus) => void; onResetState?: (id: string) => void; onToggleSave: (id: string, saved: boolean) => void; onOpen?: (v: Video, startAt?: number | null) => void; diff --git a/frontend/src/components/VirtualFeed.tsx b/frontend/src/components/VirtualFeed.tsx index 0410c45..5268ea3 100644 --- a/frontend/src/components/VirtualFeed.tsx +++ b/frontend/src/components/VirtualFeed.tsx @@ -1,4 +1,4 @@ -import type { Video } from "../lib/api"; +import type { Video, VideoStatus } from "../lib/api"; import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView"; import VideoCard from "./VideoCard"; import VirtualGrid from "./VirtualGrid"; @@ -21,7 +21,7 @@ const ROW_EST: Record = { export interface VirtualFeedProps { items: Video[]; view: FeedView; - onState: (id: string, status: string) => void; + onState: (id: string, status: VideoStatus) => void; onToggleSave: (id: string, saved: boolean) => void; onResetState: (id: string) => void; onOpen: (v: Video, startAt?: number | null) => void; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 497bbd6..66b9895 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -47,6 +47,11 @@ export interface Tag { channel_count: number; } +// 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; @@ -60,7 +65,7 @@ export interface Video { view_count: number | null; is_short: boolean; live_status: string; - status: string; + status: VideoStatus; position_seconds: number; saved: boolean; // is the video in the user's built-in Watch later playlist watch_url: string; @@ -124,11 +129,14 @@ export interface FeedResponse { source?: "scrape" | "api"; } +export type PlaylistKind = "user" | "watch_later"; +export type PlaylistSource = "local" | "youtube"; + export interface Playlist { id: number; name: string; - kind: string; // "user" | "watch_later" - source: string; // "local" | "youtube" + kind: PlaylistKind; + source: PlaylistSource; yt_playlist_id: string | null; dirty: boolean; // linked local playlist has edits not yet pushed to YouTube item_count: number; @@ -140,8 +148,8 @@ export interface Playlist { export interface PlaylistDetail { id: number; name: string; - kind: string; - source: string; + kind: PlaylistKind; + source: PlaylistSource; yt_playlist_id: string | null; dirty: boolean; items: Video[]; @@ -1012,6 +1020,8 @@ interface DownloadAsset { } export type DownloadStatus = "queued" | "running" | "paused" | "done" | "error" | "canceled"; +// "download" (default) or "edit" (a per-user trim/crop derivative cut from another job). +export type DownloadJobKind = "download" | "edit"; // Phase-2 editor recipe. A single `trim` (one output file) or a `segments` cut-list joined into // one file. `accurate` re-encodes for a frame-accurate cut (a crop always re-encodes). @@ -1051,7 +1061,7 @@ export interface DownloadJob { display_uploader: string | null; // user override of the channel name display_uploader_url: string | null; // user override of the channel link extra_links: string[]; // extra reference URLs (beyond source_url) - job_kind?: string; // "download" (default) | "edit" + job_kind?: DownloadJobKind; // "download" (default) | "edit" (a per-user trim/crop derivative) source_job_id?: number | null; // parent download an edit was cut from edit_spec?: EditSpec | null; can_download: boolean;