mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-21 10:15:04 +02:00
Add printer video downloads and range selection (#2853)
This commit is contained in:
+105
-39
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user