From cc38d8bdfac6135cf7da62347b62a8713410782c Mon Sep 17 00:00:00 2001 From: npeter83 Date: Mon, 27 Jul 2026 23:17:12 +0200 Subject: [PATCH] =?UTF-8?q?refactor(api):=20extract=20the=20downloads=20do?= =?UTF-8?q?main=20to=20api/downloads.ts=20(R7=20S3b=C2=B72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The download-center types (DownloadJob/Spec/Profile/EditSpec/ShareLink/... and the admin storage/quota shapes) + all ~30 download methods move into a self-contained api/downloads.ts exporting a `downloadsApi` slice. api.ts spreads the slice into the `api` façade and re-exports the types via `export *`, so the 53 call sites are unchanged. Cleanly separable — the domain's types reference only each other and its methods only touch core's req/getActiveAccount. api.ts 1583 -> 1352 lines; no runtime change. Gate green: typecheck, eslint, prettier, 74 vitest. --- frontend/src/lib/api.ts | 242 +----------------------------- frontend/src/lib/api/downloads.ts | 241 +++++++++++++++++++++++++++++ 2 files changed, 247 insertions(+), 236 deletions(-) create mode 100644 frontend/src/lib/api/downloads.ts diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2f01f17..8de7b2e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -18,6 +18,11 @@ export { setUnauthorizedHandler, }; +// Domain modules split out of this file, re-exported so their types + the `api` façade stay +// importable from "./api". Their method slices are spread into `api` below. +import { downloadsApi } from "./api/downloads"; +export * from "./api/downloads"; + export interface Me { id: number; email: string; @@ -775,139 +780,6 @@ export interface AuditListResult { returned: number; } -// --- download center --- -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; - thumbnail_url: string | null; - expires_at: string | null; -} - -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). -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; - 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; -} - export const api = { // Bootstrap probe: 200 always. Returns the user when signed in, or null when logged out // (the endpoint answers `{authenticated:false}` rather than 401, so the public landing never @@ -1476,107 +1348,5 @@ export const api = { req(`/api/tags/${id}`, { method: "PATCH", body: JSON.stringify(patch) }), deleteTag: (id: number) => req(`/api/tags/${id}`, { method: "DELETE" }), - // --- download center --- - 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) }), - 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`; - }, - - // 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" }), + ...downloadsApi, }; diff --git a/frontend/src/lib/api/downloads.ts b/frontend/src/lib/api/downloads.ts new file mode 100644 index 0000000..3cb220b --- /dev/null +++ b/frontend/src/lib/api/downloads.ts @@ -0,0 +1,241 @@ +// 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, 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; + thumbnail_url: string | null; + expires_at: string | null; +} + +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). +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; + 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; +} + +export const downloadsApi = { + 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) }), + 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`; + }, + + // 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" }), +};