Files
siftlode/frontend/src/lib/api/downloads.ts
T
peter fb8fb980e5 feat(files): a file that is not media opens in a viewer — an image, a PDF, text as text
- GET /api/files/{id}/preview: an allow-list on the stored extension (downloads/preview.py) picks
  image / PDF / text; served inline under `CSP: sandbox; frame-ancestors 'self'`, the type from the
  table; HTML and SVG arrive as plain text; a text is its first 1 MiB (X-Preview-Truncated), sent
  as UTF-8 whatever it was written in (BOM, UTF-8, else cp1250); a NUL means binary: 415
- the list says which preview each file gets (asset.preview); FE/BE contract on kinds and header
- the viewer (shared dialog shell): ← / → through the list's such files in its order, Save, open in
  a tab, the file's menu; one stage height for every kind; a no-preview card offers the file
- the name, the card's picture and the menu's Preview open it; an image up to 8 MB is its own
  thumbnail
2026-10-12 15:28:31 +02:00

334 lines
14 KiB
TypeScript

// 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<ClipRequest, "display_name"> | 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/<token>" — 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<DownloadProfile[]> => req("/api/downloads/profiles"),
createDownloadProfile: (p: { name: string; spec: DownloadSpec }): Promise<DownloadProfile> =>
req("/api/downloads/profiles", { method: "POST", body: JSON.stringify(p) }),
updateDownloadProfile: (
id: number,
patch: { name?: string; spec?: DownloadSpec },
): Promise<DownloadProfile> =>
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<DownloadJob> => req("/api/downloads", { method: "POST", body: JSON.stringify(body) }),
enqueueEdit: (body: {
source_job_id: number;
edit_spec: EditSpec;
display_name?: string;
}): Promise<DownloadJob> =>
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<DownloadJob> =>
req("/api/downloads/clip", { method: "POST", body: JSON.stringify(body) }),
downloadStoryboard: (id: number): Promise<StoryboardMeta> =>
req(`/api/downloads/${id}/storyboard`),
downloads: (): Promise<QueueJob[]> => req("/api/downloads"),
downloadIndex: (): Promise<Record<string, DownloadStatus>> => req("/api/downloads/index"),
pauseDownload: (id: number): Promise<DownloadJob> =>
req(`/api/downloads/${id}/pause`, { method: "POST" }, { idempotent: true }),
resumeDownload: (id: number): Promise<DownloadJob> =>
req(`/api/downloads/${id}/resume`, { method: "POST" }, { idempotent: true }),
cancelDownload: (id: number): Promise<DownloadJob> =>
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<DownloadJob> =>
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<ShareRecipient[]> => req("/api/downloads/recipients"),
// Public watch links (share by link).
downloadLinks: (jobId: number): Promise<ShareLink[]> => req(`/api/downloads/${jobId}/links`),
createDownloadLink: (
jobId: number,
body: { allow_download?: boolean; expires_days?: number | null; password?: string },
): Promise<ShareLink> =>
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<ShareLink> =>
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 <img>/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 <img> 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<DownloadJob[]> => req("/api/admin/downloads"),
adminDownloadStorage: (): Promise<AdminStorage> => req("/api/admin/downloads/storage"),
adminGetDownloadQuota: (userId: number): Promise<AdminDownloadQuota> =>
req(`/api/admin/downloads/quota/${userId}`),
adminSetDownloadQuota: (
userId: number,
patch: Partial<
Pick<AdminDownloadQuota, "max_bytes" | "max_concurrent" | "max_jobs" | "unlimited">
>,
): Promise<AdminDownloadQuota> =>
req(`/api/admin/downloads/quota/${userId}`, { method: "PUT", body: JSON.stringify(patch) }),
adminResetDownloadQuota: (userId: number): Promise<AdminDownloadQuota> =>
req(`/api/admin/downloads/quota/${userId}`, { method: "DELETE" }),
};