A failed Facebook download read "Download failed": yt-dlp words the login wall (18+ reel, closed group) as "Cannot parse data", which no category matched. Measured on dev with the owner's cookie before writing a rule — the same text is the login wall without a cookie and a refused cookie with one. - errors.diagnose(msg, url, cookie_attached) tries a host-scoped catalog first (all-of markers, cookie state, link fragment) and falls back to the category. First entries: facebook.login_wall, facebook.cookie_rejected, facebook.group_post_video. The module doc is the recipe for the next entry. - download_jobs.error_detail (0084) + backfill of the stored Facebook parse failures that were unknown. - The pipeline's failure branch passes the link and whether the cookie went with it; resume and redownload clear code and detail (redownload never cleared error_code). - A job meeting an already-failed asset got the text but no code, so it read "Download failed" where the first job said why. It is diagnosed again, with the cookie state from credentials.is_authed_asset (now shared with the cookie gate); the redacted network text classifies back to network. - FE: failCopyKeys gives the row and the failure window one lookup — catalog copy, else the category. Copy in EN and HU; a contract test pins every backend entry to both locales.
298 lines
12 KiB
TypeScript
298 lines
12 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 { 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/<token>" — 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<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) }),
|
|
// 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<DownloadJob> => {
|
|
const form = new FormData();
|
|
form.append("file", file);
|
|
if (opts.displayName) form.append("display_name", opts.displayName);
|
|
return uploadReq<DownloadJob>("/api/downloads/upload", form, {
|
|
onProgress: opts.onProgress,
|
|
signal: opts.signal,
|
|
});
|
|
},
|
|
enqueueEdit: (body: {
|
|
source_job_id: number;
|
|
edit_spec: EditSpec;
|
|
display_name?: string;
|
|
}): Promise<DownloadJob> =>
|
|
req("/api/downloads/edit", { method: "POST", body: JSON.stringify(body) }),
|
|
downloadStoryboard: (id: number): Promise<StoryboardMeta> =>
|
|
req(`/api/downloads/${id}/storyboard`),
|
|
downloads: (): Promise<DownloadJob[]> => req("/api/downloads"),
|
|
downloadsShared: (): Promise<DownloadJob[]> => 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<DownloadUsage> => req("/api/downloads/usage"),
|
|
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 }),
|
|
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<DownloadJob> =>
|
|
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<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" }),
|
|
// A plain <a> 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 <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" }),
|
|
};
|