refactor(api): literal-union the closed-value string fields (R7 S3a·1)
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.
This commit is contained in:
@@ -22,7 +22,7 @@ import {
|
|||||||
Youtube,
|
Youtube,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} 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 i18n from "../i18n";
|
||||||
import { notify, resolveVideo } from "../lib/notifications";
|
import { notify, resolveVideo } from "../lib/notifications";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
@@ -70,7 +70,7 @@ const VIEW_ICON: Record<FeedView, LucideIcon> = {
|
|||||||
// (which encode both). One entry per concept; a single arrow flips the direction.
|
// (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.
|
// "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) {
|
switch (show) {
|
||||||
case "hidden":
|
case "hidden":
|
||||||
return status === "hidden";
|
return status === "hidden";
|
||||||
@@ -126,7 +126,7 @@ export default function Feed({
|
|||||||
const sortKeys = channelScoped
|
const sortKeys = channelScoped
|
||||||
? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority")
|
? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority")
|
||||||
: SORT_KEYS;
|
: SORT_KEYS;
|
||||||
const [overrides, setOverrides] = useState<Record<string, string>>({});
|
const [overrides, setOverrides] = useState<Record<string, VideoStatus>>({});
|
||||||
const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({});
|
const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({});
|
||||||
// The open player: which video and where to start (null = resume from saved position).
|
// The open player: which video and where to start (null = resume from saved position).
|
||||||
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
|
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
|
||||||
@@ -241,7 +241,7 @@ export default function Feed({
|
|||||||
const loadedRef = useRef<Video[]>([]);
|
const loadedRef = useRef<Video[]>([]);
|
||||||
|
|
||||||
const onState = useCallback(
|
const onState = useCallback(
|
||||||
(id: string, status: string) => {
|
(id: string, status: VideoStatus) => {
|
||||||
setOverrides((o) => ({ ...o, [id]: status }));
|
setOverrides((o) => ({ ...o, [id]: status }));
|
||||||
// Refetch once the server has the change so other views (e.g. Hidden) are in sync.
|
// 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"
|
// Announce the change only AFTER the server confirms it: a "Marked watched"/"Hidden"
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import Avatar from "./Avatar";
|
|||||||
import AddToPlaylist from "./AddToPlaylist";
|
import AddToPlaylist from "./AddToPlaylist";
|
||||||
import { modalCount } from "./Modal";
|
import { modalCount } from "./Modal";
|
||||||
import DownloadButton from "./DownloadButton";
|
import DownloadButton from "./DownloadButton";
|
||||||
import { api, type Video } from "../lib/api";
|
import { api, type Video, type VideoStatus } from "../lib/api";
|
||||||
import {
|
import {
|
||||||
channelYouTubeUrl,
|
channelYouTubeUrl,
|
||||||
formatDate,
|
formatDate,
|
||||||
@@ -118,7 +118,7 @@ export default function PlayerModal({
|
|||||||
queue?: Video[];
|
queue?: Video[];
|
||||||
startIndex?: number;
|
startIndex?: number;
|
||||||
onClose: () => void;
|
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
|
// 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.
|
// icon next to the name keeps the open-on-YouTube behaviour.
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import Avatar from "./Avatar";
|
|||||||
import AddToPlaylist from "./AddToPlaylist";
|
import AddToPlaylist from "./AddToPlaylist";
|
||||||
import DownloadButton from "./DownloadButton";
|
import DownloadButton from "./DownloadButton";
|
||||||
import clsx from "clsx";
|
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 { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
||||||
import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
|
import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
|
||||||
|
|
||||||
@@ -30,14 +30,14 @@ function Actions({
|
|||||||
dense = false,
|
dense = false,
|
||||||
}: {
|
}: {
|
||||||
video: Video;
|
video: Video;
|
||||||
onState: (id: string, status: string) => void;
|
onState: (id: string, status: VideoStatus) => void;
|
||||||
onResetState?: (id: string) => void;
|
onResetState?: (id: string) => void;
|
||||||
onToggleSave: (id: string, saved: boolean) => void;
|
onToggleSave: (id: string, saved: boolean) => void;
|
||||||
/** Tighter buttons for the small card, whose column bottoms out at 180px. */
|
/** Tighter buttons for the small card, whose column bottoms out at 180px. */
|
||||||
dense?: boolean;
|
dense?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const act = (status: string) => (e: React.MouseEvent) => {
|
const act = (status: VideoStatus) => (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onState(video.id, video.status === status ? "new" : status);
|
onState(video.id, video.status === status ? "new" : status);
|
||||||
@@ -375,7 +375,7 @@ function VideoCard({
|
|||||||
view: FeedView;
|
view: FeedView;
|
||||||
/** Measured width of this card/row, from VirtualFeed. 0 until the first measure. */
|
/** Measured width of this card/row, from VirtualFeed. 0 until the first measure. */
|
||||||
width: number;
|
width: number;
|
||||||
onState: (id: string, status: string) => void;
|
onState: (id: string, status: VideoStatus) => void;
|
||||||
onResetState?: (id: string) => void;
|
onResetState?: (id: string) => void;
|
||||||
onToggleSave: (id: string, saved: boolean) => void;
|
onToggleSave: (id: string, saved: boolean) => void;
|
||||||
onOpen?: (v: Video, startAt?: number | null) => void;
|
onOpen?: (v: Video, startAt?: number | null) => void;
|
||||||
|
|||||||
@@ -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 { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
||||||
import VideoCard from "./VideoCard";
|
import VideoCard from "./VideoCard";
|
||||||
import VirtualGrid from "./VirtualGrid";
|
import VirtualGrid from "./VirtualGrid";
|
||||||
@@ -21,7 +21,7 @@ const ROW_EST: Record<FeedView, number> = {
|
|||||||
export interface VirtualFeedProps {
|
export interface VirtualFeedProps {
|
||||||
items: Video[];
|
items: Video[];
|
||||||
view: FeedView;
|
view: FeedView;
|
||||||
onState: (id: string, status: string) => void;
|
onState: (id: string, status: VideoStatus) => void;
|
||||||
onToggleSave: (id: string, saved: boolean) => void;
|
onToggleSave: (id: string, saved: boolean) => void;
|
||||||
onResetState: (id: string) => void;
|
onResetState: (id: string) => void;
|
||||||
onOpen: (v: Video, startAt?: number | null) => void;
|
onOpen: (v: Video, startAt?: number | null) => void;
|
||||||
|
|||||||
+16
-6
@@ -47,6 +47,11 @@ export interface Tag {
|
|||||||
channel_count: number;
|
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 {
|
export interface Video {
|
||||||
id: string;
|
id: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
@@ -60,7 +65,7 @@ export interface Video {
|
|||||||
view_count: number | null;
|
view_count: number | null;
|
||||||
is_short: boolean;
|
is_short: boolean;
|
||||||
live_status: string;
|
live_status: string;
|
||||||
status: string;
|
status: VideoStatus;
|
||||||
position_seconds: number;
|
position_seconds: number;
|
||||||
saved: boolean; // is the video in the user's built-in Watch later playlist
|
saved: boolean; // is the video in the user's built-in Watch later playlist
|
||||||
watch_url: string;
|
watch_url: string;
|
||||||
@@ -124,11 +129,14 @@ export interface FeedResponse {
|
|||||||
source?: "scrape" | "api";
|
source?: "scrape" | "api";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PlaylistKind = "user" | "watch_later";
|
||||||
|
export type PlaylistSource = "local" | "youtube";
|
||||||
|
|
||||||
export interface Playlist {
|
export interface Playlist {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
kind: string; // "user" | "watch_later"
|
kind: PlaylistKind;
|
||||||
source: string; // "local" | "youtube"
|
source: PlaylistSource;
|
||||||
yt_playlist_id: string | null;
|
yt_playlist_id: string | null;
|
||||||
dirty: boolean; // linked local playlist has edits not yet pushed to YouTube
|
dirty: boolean; // linked local playlist has edits not yet pushed to YouTube
|
||||||
item_count: number;
|
item_count: number;
|
||||||
@@ -140,8 +148,8 @@ export interface Playlist {
|
|||||||
export interface PlaylistDetail {
|
export interface PlaylistDetail {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
kind: string;
|
kind: PlaylistKind;
|
||||||
source: string;
|
source: PlaylistSource;
|
||||||
yt_playlist_id: string | null;
|
yt_playlist_id: string | null;
|
||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
items: Video[];
|
items: Video[];
|
||||||
@@ -1012,6 +1020,8 @@ interface DownloadAsset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type DownloadStatus = "queued" | "running" | "paused" | "done" | "error" | "canceled";
|
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
|
// 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).
|
// 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: string | null; // user override of the channel name
|
||||||
display_uploader_url: string | null; // user override of the channel link
|
display_uploader_url: string | null; // user override of the channel link
|
||||||
extra_links: string[]; // extra reference URLs (beyond source_url)
|
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
|
source_job_id?: number | null; // parent download an edit was cut from
|
||||||
edit_spec?: EditSpec | null;
|
edit_spec?: EditSpec | null;
|
||||||
can_download: boolean;
|
can_download: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user