Add printer video downloads and range selection (#2853)

This commit is contained in:
Sean K
2026-08-22 13:22:56 +02:00
committed by GitHub
parent 937440f956
commit 55cc64c87d
43 changed files with 4098 additions and 236 deletions
+105 -39
View File
@@ -696,6 +696,22 @@ export interface ArchiveDuplicate {
match_type: 'exact' | 'similar'; // 'exact' = hash match, 'similar' = name match
}
export interface ArchivePrinterMediaFile {
name: string;
path: string;
size: number;
mtime: string | null;
kind: 'timelapse' | 'ipcam';
}
export interface ArchivePrinterMedia {
archive_id: number;
printer_id: number | null;
local_timelapse: { name: string; size: number } | null;
remote_files: ArchivePrinterMediaFile[];
warnings: Array<'printer_missing' | 'timelapse_unavailable' | 'ipcam_unavailable' | 'printer_files_forbidden'>;
}
export interface Archive {
id: number;
printer_id: number | null;
@@ -4671,6 +4687,7 @@ export const api = {
path: string;
mtime?: string;
}>;
warnings: Array<'printer_unavailable'>;
}>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`),
getPrinterFileDownloadUrl: (printerId: number, path: string) =>
`${API_BASE}/printers/${printerId}/files/download?path=${encodeURIComponent(path)}`,
@@ -4701,46 +4718,81 @@ export const api = {
}>(`/printers/${printerId}/files/plates?path=${encodeURIComponent(path)}`),
getPrinterFilePlateThumbnail: (printerId: number, plateIndex: number, path: string) =>
withStreamToken(`${API_BASE}/printers/${printerId}/files/plate-thumbnail/${plateIndex}?path=${encodeURIComponent(path)}`),
downloadPrinterFile: async (printerId: number, path: string): Promise<void> => {
const headers: Record<string, string> = {};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
downloadPrinterFilesAsZip: async (
printerId: number,
paths: string[],
sizes: Record<string, number>,
filename = 'printer-files.zip',
asZip = true,
signal?: AbortSignal,
onProgress?: (completed: number, total: number) => void,
): Promise<{ requested: number; successful: number; failed: number }> => {
type JobStatus = {
job_id: string;
state: 'queued' | 'preparing' | 'ready' | 'failed' | 'cancelled';
requested: number;
successful: number;
failed: number;
token: string | null;
message: string | null;
};
let jobId: string | null = null;
try {
if (signal?.aborted) throw new DOMException('Download cancelled', 'AbortError');
let status = await request<JobStatus>(`/printers/${printerId}/files/download-job`, {
method: 'POST',
body: JSON.stringify({ paths, sizes, filename, as_zip: asZip }),
signal,
});
jobId = status.job_id;
let polls = 0;
while (status.state === 'queued' || status.state === 'preparing') {
onProgress?.(status.successful + status.failed, status.requested);
// Small selections finish in the first seconds, so poll quickly there.
// A large one runs for up to half an hour, where half-second polling is
// thousands of requests that each re-check the caller's credentials.
const delay = polls < 10 ? 500 : 2000;
polls += 1;
await new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(new DOMException('Download cancelled', 'AbortError'));
return;
}
const onAbort = () => {
window.clearTimeout(timer);
reject(new DOMException('Download cancelled', 'AbortError'));
};
const timer = window.setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, delay);
signal?.addEventListener('abort', onAbort, { once: true });
});
status = await request<JobStatus>(`/printers/${printerId}/files/download-jobs/${jobId}`, { signal });
}
onProgress?.(status.successful + status.failed, status.requested);
if (status.state !== 'ready' || !status.token) {
throw new Error(status.message || (status.state === 'cancelled'
? 'Download cancelled'
: 'Printer download preparation failed'));
}
const link = document.createElement('a');
link.href = `${API_BASE}/printers/${printerId}/files/dl/${encodeURIComponent(status.token)}/${encodeURIComponent(filename)}`;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
return {
requested: status.requested,
successful: status.successful,
failed: status.failed,
};
} catch (error) {
if (jobId) {
await request(`/printers/${printerId}/files/download-jobs/${jobId}`, { method: 'DELETE' }).catch(() => undefined);
}
throw error;
}
const response = await fetch(
`${API_BASE}/printers/${printerId}/files/download?path=${encodeURIComponent(path)}`,
{ headers }
);
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || `HTTP ${response.status}`);
}
const disposition = response.headers.get('Content-Disposition');
const filename = parseContentDispositionFilename(disposition) || path.split('/').pop() || 'download';
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
},
downloadPrinterFilesAsZip: async (printerId: number, paths: string[]): Promise<Blob> => {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(`${API_BASE}/printers/${printerId}/files/download-zip`, {
method: 'POST',
headers,
body: JSON.stringify({ paths }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || `HTTP ${response.status}`);
}
return response.blob();
},
deletePrinterFile: (printerId: number, path: string) =>
request<{ status: string; path: string }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`, {
@@ -4994,6 +5046,20 @@ export const api = {
getArchiveGcode: (id: number) => `${API_BASE}/archives/${id}/gcode`,
getArchivePlatePreview: (id: number) => withStreamToken(`${API_BASE}/archives/${id}/plate-preview`),
getArchiveTimelapse: (id: number) => withStreamToken(`${API_BASE}/archives/${id}/timelapse?v=${Date.now()}`),
downloadArchiveTimelapse: async (id: number, filename: string): Promise<void> => {
const prepared = await request<{ token: string; filename: string }>(
`/archives/${id}/media-download-token`,
{ method: 'POST' },
);
const link = document.createElement('a');
link.href = `${API_BASE}/archives/${id}/media/dl/${encodeURIComponent(prepared.token)}/${encodeURIComponent(filename)}`;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
},
getArchivePrinterMedia: (id: number) =>
request<ArchivePrinterMedia>(`/archives/${id}/printer-media`),
scanArchiveTimelapse: (id: number) =>
request<{
status: string;