// 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 { req, uploadReq, 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; thumbnail_url: string | null; expires_at: string | null; } export type DownloadStatus = "queued" | "running" | "paused" | "done" | "error" | "canceled"; // "download" (default), "edit" (a per-user trim/crop derivative cut from another job), or "upload" // (a locally-uploaded file — no re-fetchable source, so re-download is rejected backend-side). // Local (not exported): consumers compare `job.job_kind` against literals, never name the type. type DownloadJobKind = "download" | "edit" | "upload"; // 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; // "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; expired: boolean; asset: DownloadAsset | null; shared?: boolean; user_email?: 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; } export interface DownloadUsage { footprint_bytes: number; active_jobs: number; running_jobs: number; max_bytes: number; max_concurrent: number; max_jobs: number; unlimited: boolean; } export interface AdminStorage { ready_files: number; total_bytes: number; total_assets: number; total_cap_bytes: number; per_user: { user_id: number; email: string | null; footprint_bytes: number }[]; } 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) }), // Ingest a local file into the library (E7 S5). Multipart via XHR so the dialog can show upload // progress; any file type is accepted (media gets the full function set, a generic file is // download-only). Returns the already-`done` job the ingest creates. uploadDownload: ( file: File, opts: { displayName?: string; onProgress?: (pct: number) => void; signal?: AbortSignal } = {}, ): Promise => { const form = new FormData(); form.append("file", file); if (opts.displayName) form.append("display_name", opts.displayName); return uploadReq("/api/downloads/upload", form, { onProgress: opts.onProgress, signal: opts.signal, }); }, enqueueEdit: (body: { source_job_id: number; edit_spec: EditSpec; display_name?: string; }): Promise => req("/api/downloads/edit", { method: "POST", body: JSON.stringify(body) }), downloadStoryboard: (id: number): Promise => req(`/api/downloads/${id}/storyboard`), downloads: (): Promise => req("/api/downloads"), downloadsShared: (): Promise => req("/api/downloads/shared"), // Remove a shared-with-me item from your list (deletes only your share grant, not the file). removeSharedDownload: (jobId: number): Promise<{ removed: number }> => req(`/api/downloads/shared/${jobId}`, { method: "DELETE" }), downloadUsage: (): Promise => req("/api/downloads/usage"), 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 }), deleteDownload: (id: number) => req(`/api/downloads/${id}`, { method: "DELETE" }), // Update a download's display metadata (title / channel name+link / extra reference URLs). updateDownloadMeta: ( id: number, meta: { display_name?: string; display_uploader?: string; display_uploader_url?: string; extra_links?: string[]; }, ): Promise => req(`/api/downloads/${id}`, { method: "PATCH", body: JSON.stringify(meta) }), 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" }), // A plain navigation can't send the X-Siftlode-Account header, so pass the active // account via ?account= (same wallet-gated selection the WebSocket uses) — otherwise the // download resolves to the session-default account and 404s for a per-tab account's file. downloadFileUrl: (id: number): string => { const a = getActiveAccount(); return a != null ? `/api/downloads/${id}/file?account=${a}` : `/api/downloads/${id}/file`; }, // 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" }), };