refactor(api): extract the downloads domain to api/downloads.ts (R7 S3b·2)
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.
This commit is contained in:
+6
-236
@@ -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/<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;
|
||||
}
|
||||
|
||||
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<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) }),
|
||||
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`;
|
||||
},
|
||||
|
||||
// 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" }),
|
||||
...downloadsApi,
|
||||
};
|
||||
|
||||
@@ -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/<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;
|
||||
}
|
||||
|
||||
export const downloadsApi = {
|
||||
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) }),
|
||||
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`;
|
||||
},
|
||||
|
||||
// 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" }),
|
||||
};
|
||||
Reference in New Issue
Block a user