// Download-center domain: the yt-dlp job lifecycle, per-user profiles, share links/recipients, the // phase-2 editor, storyboards, and the admin storage/quota views. Self-contained — its types only // reference each other, and its methods only touch the shared `req`/`getActiveAccount` from core. import { beacon, req, getActiveAccount } from "./core"; export interface DownloadSpec { mode: "av" | "v" | "a"; // audio+video | video-only | audio-only max_height: number | null; container: string | null; audio_format: string | null; vcodec: string | null; embed_subs: boolean; embed_chapters: boolean; embed_thumbnail: boolean; sponsorblock: boolean; } export interface DownloadProfile { id: number; name: string; spec: DownloadSpec; is_builtin: boolean; sort_order: number; } interface DownloadAsset { id: number; status: string; title: string | null; uploader: string | null; uploader_url: string | null; // yt-dlp channel/uploader page URL (auto; null for e.g. reddit) upload_date: string | null; duration_s: number | null; size_bytes: number | null; width: number | null; height: number | null; container: string | null; // "video" | "audio" | "file" — the stored discriminator that gates library row actions: media // (video/audio) gets play/edit; a generic uploaded file is download+share only, no player. Null // while still in flight (set at finalize). See backend downloads/mediakind.py. media_kind: "video" | "audio" | "file" | null; // What the library's viewer shows of a generic file (backend downloads/preview.py): an image, a PDF // in the browser's own viewer, plain text; null = no preview (media plays instead). preview?: FilePreviewKind | null; thumbnail_url: string | null; } export type FilePreviewKind = "image" | "pdf" | "text"; export type DownloadStatus = "queued" | "running" | "paused" | "done" | "error" | "canceled"; // "download" (default), "edit" (a per-user trim/crop derivative cut from another job), "upload" // (a locally-uploaded file) or "clip" (a marked range of a YouTube video or a Plex item, cut straight // from the source). Only a download has a re-fetchable source; re-download refuses the rest. export type DownloadJobKind = "download" | "edit" | "upload" | "clip"; // An ad-hoc clip (backend downloads/clip): a marked range of a YouTube video or a Plex item. export interface ClipRequest { source: "youtube" | "plex"; ref: string; start_s: number; end_s: number; audio_track?: number; /** A crop frame: fractions of the box it was drawn in, and that box's shape (downloads/clip). */ crop?: { x: number; y: number; w: number; h: number; aspect: number }; display_name?: string; } // 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). export interface EditSpec { trim?: { start_s: number; end_s?: number }; segments?: { start_s: number; end_s: number }[]; crop?: { x: number; y: number; w: number; h: number }; accurate?: boolean; } export interface StoryboardMeta { available: boolean; cols?: number; rows?: number; count?: number; interval_s?: number; url?: string; } export interface DownloadJob { id: number; status: DownloadStatus; progress: number; speed_bps: number | null; eta_s: number | null; phase: string | null; error: string | null; // raw yt-dlp/ffmpeg text (technical detail) error_code: string | null; // failure category, see backend downloads.errors.classify error_detail: string | null; // host-specific failure-catalog entry, see downloads.errors.diagnose queue_pos: number; created_at: string | null; source_kind: string; source_ref: string; source_url: string | null; // clean "downloaded from" URL (null for edit clips) poster_url: string | null; // locally-generated poster, fallback when the source had no thumbnail profile_id: number | null; spec: DownloadSpec; display_name: string | null; 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?: DownloadJobKind; // see DownloadJobKind; absent reads as "download" source_job_id?: number | null; // parent download an edit was cut from // An edit's recipe, or a clip's spec (its request without the name) — by job_kind. edit_spec?: EditSpec | Omit | null; can_download: boolean; // The Files library (backend downloads/folders): its folder, whether the user keeps it past the // folder's retention, and when it goes (null = never). folder_id: number | null; kept: boolean; expires_at: string | null; asset: DownloadAsset | null; shared?: boolean; // Whose it is — only the admin Storage tab's listing of every user's downloads carries them. user_id?: number; user_email?: string; // Where the viewer stopped in it (whole seconds; 0 = from the start). Only the two library listings // (`downloads`, `downloadsShared`) carry it — a job a mutation returns does not. position_seconds?: number; } // A row of the queue (the tray's poll): what is in flight or failed, and what finished a moment ago, // with where it came from as the library names it. export interface QueueJob extends DownloadJob { source_key: string; } export interface ShareRecipient { id: number; email: string; display_name: string | null; } export interface ShareLink { id: number; token: string; url: string; // path like "/watch/" — prepend window.location.origin to share allow_download: boolean; has_password: boolean; expires_at: string | null; view_count: number; created_at: string | null; } // The width of every user-supplied display name on a job, mirroring the backend's DISPLAY_TEXT_MAX // (backend/app/models.py) so an input can stop the user rather than letting the server truncate in // silence. The two are held together by `downloads.contract.test.ts` — a number living in two // languages is exactly the drift that CLAUDE.md's comment-vs-code rule is about. export const DISPLAY_NAME_MAX = 255; /** One user's row on the admin Storage tab: their footprint against their own limit, and how many * files their Files lists. */ export interface AdminStorageUser { user_id: number; email: string | null; footprint_bytes: number; files: number; max_bytes: number; unlimited: boolean; } export interface AdminStorage { files_on_disk: number; total_bytes: number; total_assets: number; total_cap_bytes: number; per_user: AdminStorageUser[]; } export interface AdminDownloadQuota { user_id: number; custom: boolean; max_bytes: number; max_concurrent: number; max_jobs: number; unlimited: boolean; } // Per-user download authentication (cookies). Write-only: the API never returns the stored blob, // only which supported hosts are configured + when. See backend downloads.credentials. export interface DownloadCredentialHost { host_key: string; label: string; configured: boolean; updated_at: string | null; } export const downloadsApi = { downloadCredentials: (): Promise<{ hosts: DownloadCredentialHost[] }> => req("/api/downloads/credentials"), setDownloadCredential: ( hostKey: string, cookies: string, ): Promise<{ ok: true; hosts: DownloadCredentialHost[] }> => req(`/api/downloads/credentials/${encodeURIComponent(hostKey)}`, { method: "PUT", body: JSON.stringify({ cookies }), }), deleteDownloadCredential: ( hostKey: string, ): Promise<{ ok: true; hosts: DownloadCredentialHost[] }> => req(`/api/downloads/credentials/${encodeURIComponent(hostKey)}`, { method: "DELETE" }), downloadProfiles: (): Promise => req("/api/downloads/profiles"), createDownloadProfile: (p: { name: string; spec: DownloadSpec }): Promise => req("/api/downloads/profiles", { method: "POST", body: JSON.stringify(p) }), updateDownloadProfile: ( id: number, patch: { name?: string; spec?: DownloadSpec }, ): Promise => req(`/api/downloads/profiles/${id}`, { method: "PATCH", body: JSON.stringify(patch) }), deleteDownloadProfile: (id: number) => req(`/api/downloads/profiles/${id}`, { method: "DELETE" }), enqueueDownload: (body: { source: string; profile_id?: number; spec?: DownloadSpec; display_name?: string; }): Promise => req("/api/downloads", { method: "POST", body: JSON.stringify(body) }), enqueueEdit: (body: { source_job_id: number; edit_spec: EditSpec; display_name?: string; }): Promise => req("/api/downloads/edit", { method: "POST", body: JSON.stringify(body) }), // An ad-hoc clip marked in a player: a YouTube video's or a Plex item's range, cut into a new // download of its own (`audio_track`: the Plex audio track that was playing). enqueueClip: (body: ClipRequest): Promise => req("/api/downloads/clip", { method: "POST", body: JSON.stringify(body) }), downloadStoryboard: (id: number): Promise => req(`/api/downloads/${id}/storyboard`), downloads: (): Promise => req("/api/downloads"), downloadIndex: (): Promise> => req("/api/downloads/index"), pauseDownload: (id: number): Promise => req(`/api/downloads/${id}/pause`, { method: "POST" }, { idempotent: true }), resumeDownload: (id: number): Promise => req(`/api/downloads/${id}/resume`, { method: "POST" }, { idempotent: true }), cancelDownload: (id: number): Promise => req(`/api/downloads/${id}/cancel`, { method: "POST" }, { idempotent: true }), // Force a fresh re-download of a finished/failed job — keeps the job (and its share links), // deletes the old file, and rebuilds it under the current format/naming rules. redownloadDownload: (id: number): Promise => req(`/api/downloads/${id}/redownload`, { method: "POST" }, { idempotent: true }), shareDownload: (id: number, email: string): Promise<{ shared_with: string }> => req(`/api/downloads/${id}/share`, { method: "POST", body: JSON.stringify({ email }) }), unshareDownload: (id: number, email: string) => req(`/api/downloads/${id}/share/${encodeURIComponent(email)}`, { method: "DELETE" }), // Registered users this user can share with (for the internal picker). shareRecipients: (): Promise => req("/api/downloads/recipients"), // Public watch links (share by link). downloadLinks: (jobId: number): Promise => req(`/api/downloads/${jobId}/links`), createDownloadLink: ( jobId: number, body: { allow_download?: boolean; expires_days?: number | null; password?: string }, ): Promise => req(`/api/downloads/${jobId}/links`, { method: "POST", body: JSON.stringify(body) }), updateDownloadLink: ( linkId: number, patch: { allow_download?: boolean; expires_days?: number | null; password?: string }, ): Promise => req(`/api/downloads/links/${linkId}`, { method: "PATCH", body: JSON.stringify(patch) }), revokeDownloadLink: (linkId: number): Promise<{ deleted: number }> => req(`/api/downloads/links/${linkId}`, { method: "DELETE" }), // The library player's resume checkpoint. A position too early or too close to the end clears it. saveDownloadProgress: ( id: number, positionSeconds: number, durationSeconds: number, ): Promise<{ ok: true; job_id: number; position_seconds: number }> => req( `/api/downloads/${id}/progress`, { method: "POST", body: JSON.stringify({ position_seconds: Math.floor(positionSeconds), duration_seconds: Math.floor(durationSeconds), }), }, { idempotent: true }, ), // The same, as a keepalive POST that survives the page going away (a reload, a closed tab). saveDownloadProgressBeacon: ( id: number, positionSeconds: number, durationSeconds: number, onSettled?: () => void, ): void => beacon( `/api/downloads/${id}/progress`, { position_seconds: Math.floor(positionSeconds), duration_seconds: Math.floor(durationSeconds), }, onSettled, ), // Filmstrip sprite (an /background src → direct nav, so it needs the ?account= wallet gate). storyboardImageUrl: (id: number): string => { const a = getActiveAccount(); return a != null ? `/api/downloads/${id}/storyboard.jpg?account=${a}` : `/api/downloads/${id}/storyboard.jpg`; }, // The locally-generated poster (an src → direct nav, so same ?account= wallet gate). Without // it the poster resolves to the session-default account and 404s for a per-tab account's download — // which reads as a blank Library thumbnail once the poster is the card's primary image. posterImageUrl: (id: number): string => { const a = getActiveAccount(); return a != null ? `/api/downloads/${id}/poster.jpg?account=${a}` : `/api/downloads/${id}/poster.jpg`; }, // admin adminDownloads: (): Promise => req("/api/admin/downloads"), adminDownloadStorage: (): Promise => req("/api/admin/downloads/storage"), adminGetDownloadQuota: (userId: number): Promise => req(`/api/admin/downloads/quota/${userId}`), adminSetDownloadQuota: ( userId: number, patch: Partial< Pick >, ): Promise => req(`/api/admin/downloads/quota/${userId}`, { method: "PUT", body: JSON.stringify(patch) }), adminResetDownloadQuota: (userId: number): Promise => req(`/api/admin/downloads/quota/${userId}`, { method: "DELETE" }), };