The clip panel and the editor get a Video / GIF choice with the GIF's size, the size it is expected to come out at, and what that size means (no moving link preview over 15 MB, refused over 30 MB). A range longer than a GIF may be leaves Make unavailable with its reason. The editor makes one GIF per kept segment; Join says why it is unavailable instead of going dead. The share page shows a picture as itself with its Download, and a picture in a folder opens above the list.
344 lines
14 KiB
TypeScript
344 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`/`beacon` from core.
|
|
import { beacon, req } 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 };
|
|
/** Make it a GIF, this long side in pixels (`lib/gif` GIF_SIDES); left out, an mp4. */
|
|
gif_side?: 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 }[];
|
|
// In the picture's size as the browser reports it, which `frame_w` x `frame_h` says: the server
|
|
// scales them to the file's own pixels (a video's stored and shown sizes can differ).
|
|
crop?: { x: number; y: number; w: number; h: number; frame_w?: number; frame_h?: number };
|
|
accurate?: boolean;
|
|
/** Make it a GIF (a single trim with an end, `lib/gif` GIF_MAX_S long at most): a GIF is always
|
|
* re-encoded, so `accurate` does not apply. */
|
|
gif?: { side: number };
|
|
}
|
|
|
|
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; // when it was asked for
|
|
ready_at?: string | null; // when it became done — the library's "Added"
|
|
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 listings carry it (the
|
|
// Files library's and the queue's) — 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;
|
|
// Null when the server cannot show this link's address again (no encrypted copy its key reads): the
|
|
// link still opens for whoever has it; to share it anew, make a new link.
|
|
token: string | null;
|
|
url: string | null; // path like "/watch/<token>" or "/file/<token>" — prepend window.location.origin to share
|
|
allow_download: boolean;
|
|
has_password: boolean;
|
|
expires_at: string | null;
|
|
view_count: number;
|
|
created_at: string | null;
|
|
}
|
|
|
|
// What a new link is made with; an omitted `allow_download` takes the server's default (true).
|
|
export interface NewShareLink {
|
|
allow_download?: boolean;
|
|
expires_days?: number | null;
|
|
password?: string;
|
|
}
|
|
|
|
// 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: NewShareLink): 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) }),
|
|
// A link to one of the user's own folders: it opens whatever is in the folder when it is opened.
|
|
folderLinks: (folderId: number): Promise<ShareLink[]> =>
|
|
req(`/api/downloads/folders/${folderId}/links`),
|
|
createFolderLink: (folderId: number, body: NewShareLink): Promise<ShareLink> =>
|
|
req(`/api/downloads/folders/${folderId}/links`, {
|
|
method: "POST",
|
|
body: JSON.stringify(body),
|
|
}),
|
|
revokeDownloadLink: (linkId: number): Promise<{ ok: true; 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).
|
|
storyboardImageUrl: (id: number): string => `/api/downloads/${id}/storyboard.jpg`,
|
|
// The locally-generated poster (an <img> src).
|
|
posterImageUrl: (id: number): string => `/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" }),
|
|
};
|