Multi-file upload queue

Pick many files at once; they upload one after another with per-file progress,
editable titles, retry-by-removal, and 3 parallel 64 MB parts per file.
This commit is contained in:
Claude Agent 2026-08-05 16:51:00 +00:00
parent 711fbdc92c
commit 91472f3a85
No known key found for this signature in database

View File

@ -3,78 +3,108 @@ import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { apiRequest } from "@/lib/queryClient"; import { apiRequest } from "@/lib/queryClient";
import { UploadCloud, Loader2, CheckCircle2 } from "lucide-react"; import { UploadCloud, Loader2, CheckCircle2, X, AlertCircle } from "lucide-react";
type Phase = "idle" | "starting" | "uploading" | "finishing" | "done" | "error"; type ItemStatus = "waiting" | "uploading" | "finishing" | "done" | "error";
interface QueueItem {
id: string;
file: File;
title: string;
status: ItemStatus;
progress: number;
error?: string;
}
interface PartResult { interface PartResult {
PartNumber: number; PartNumber: number;
ETag: string; ETag: string;
} }
// How many 64 MB chunks of the same file travel at once.
const PART_CONCURRENCY = 3;
/** /**
* Uploads the master file straight from the browser into the Hetzner bucket * Queue uploader: pick as many files as you like, they go one after another
* using presigned multipart URLs, then hands the GUID to the transcode worker. * straight from the browser into the Hetzner bucket. Parts of the current file
* Nothing large ever passes through the application server. * are sent in parallel, so a single large master still uses the full line.
*/ */
export default function VideoUpload() { export default function VideoUpload() {
const { toast } = useToast(); const { toast } = useToast();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const fileRef = useRef<HTMLInputElement>(null); const fileRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null); const [queue, setQueue] = useState<QueueItem[]>([]);
const [title, setTitle] = useState(""); const queueRef = useRef<QueueItem[]>([]);
queueRef.current = queue;
const [artist, setArtist] = useState(""); const [artist, setArtist] = useState("");
const [description, setDescription] = useState("");
const [genre, setGenre] = useState("other"); const [genre, setGenre] = useState("other");
const [contentType, setContentType] = useState("video"); const [contentType, setContentType] = useState("video");
const [phase, setPhase] = useState<Phase>("idle"); const [running, setRunning] = useState(false);
const [progress, setProgress] = useState(0);
const [errorMsg, setErrorMsg] = useState("");
const busy = phase === "starting" || phase === "uploading" || phase === "finishing"; const pending = queue.filter((q) => q.status === "waiting").length;
const failed = queue.filter((q) => q.status === "error").length;
const finished = queue.filter((q) => q.status === "done").length;
function pickFile(f: File | null) { function addFiles(list: FileList | null) {
setFile(f); if (!list || list.length === 0) return;
setPhase("idle"); const items: QueueItem[] = Array.from(list).map((file, i) => ({
setProgress(0); id: `${Date.now()}-${i}-${file.name}`,
setErrorMsg(""); file,
if (f && !title) setTitle(f.name.replace(/\.[^.]+$/, "")); title: file.name.replace(/\.[^.]+$/, ""),
status: "waiting",
progress: 0,
}));
queueRef.current = [...queueRef.current, ...items];
setQueue(queueRef.current);
if (fileRef.current) fileRef.current.value = "";
} }
async function uploadPart(url: string, blob: Blob, partNumber: number): Promise<PartResult> { function patch(id: string, changes: Partial<QueueItem>) {
// XHR (not fetch) so we could extend this with per-part progress later. queueRef.current = queueRef.current.map((it) => (it.id === id ? { ...it, ...changes } : it));
setQueue(queueRef.current);
}
function removeItem(id: string) {
queueRef.current = queueRef.current.filter((it) => it.id !== id);
setQueue(queueRef.current);
}
function clearFinished() {
queueRef.current = queueRef.current.filter((it) => it.status !== "done");
setQueue(queueRef.current);
}
async function putPart(url: string, blob: Blob, partNumber: number): Promise<PartResult> {
const etag: string = await new Promise((resolve, reject) => { const etag: string = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open("PUT", url, true); xhr.open("PUT", url, true);
xhr.onload = () => { xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) { if (xhr.status >= 200 && xhr.status < 300) {
const tag = xhr.getResponseHeader("ETag") || xhr.getResponseHeader("etag") || ""; const tag = xhr.getResponseHeader("ETag") || xhr.getResponseHeader("etag") || "";
if (!tag) reject(new Error(`Part ${partNumber}: no ETag returned`)); if (!tag) reject(new Error(`Teil ${partNumber}: kein ETag`));
else resolve(tag); else resolve(tag);
} else { } else {
reject(new Error(`Part ${partNumber} failed with HTTP ${xhr.status}`)); reject(new Error(`Teil ${partNumber}: HTTP ${xhr.status}`));
} }
}; };
xhr.onerror = () => reject(new Error(`Part ${partNumber}: network error`)); xhr.onerror = () => reject(new Error(`Teil ${partNumber}: Netzwerkfehler`));
xhr.send(blob); xhr.send(blob);
}); });
return { PartNumber: partNumber, ETag: etag }; return { PartNumber: partNumber, ETag: etag };
} }
async function start() { async function uploadOne(item: QueueItem) {
if (!file) return; const { file } = item;
setErrorMsg("");
setPhase("starting");
setProgress(0);
let init: any = null; let init: any = null;
try { try {
patch(item.id, { status: "uploading", progress: 0, error: undefined });
const initRes = await apiRequest("POST", "/api/admin/uploads/init", { const initRes = await apiRequest("POST", "/api/admin/uploads/init", {
fileName: file.name, fileName: file.name,
fileSize: file.size, fileSize: file.size,
@ -82,35 +112,46 @@ export default function VideoUpload() {
}); });
init = await initRes.json(); init = await initRes.json();
setPhase("uploading");
const parts: PartResult[] = [];
const total: number = init.partCount; const total: number = init.partCount;
const parts: PartResult[] = new Array(total);
let completedParts = 0;
let next = 0;
for (let i = 0; i < total; i++) { async function worker() {
const from = i * init.partSize; while (true) {
const to = Math.min(from + init.partSize, file.size); const index = next++;
const blob = file.slice(from, to); if (index >= total) return;
const partNumber = index + 1;
const from = index * init.partSize;
const to = Math.min(from + init.partSize, file.size);
const urlRes = await apiRequest("POST", "/api/admin/uploads/part-url", { const urlRes = await apiRequest("POST", "/api/admin/uploads/part-url", {
key: init.key, key: init.key,
uploadId: init.uploadId, uploadId: init.uploadId,
partNumber: i + 1, partNumber,
}); });
const { url } = await urlRes.json(); const { url } = await urlRes.json();
parts.push(await uploadPart(url, blob, i + 1)); parts[index] = await putPart(url, file.slice(from, to), partNumber);
setProgress(Math.round(((i + 1) / total) * 100)); completedParts++;
patch(item.id, { progress: Math.round((completedParts / total) * 100) });
}
} }
setPhase("finishing"); await Promise.all(
Array.from({ length: Math.min(PART_CONCURRENCY, total) }, () => worker()),
);
patch(item.id, { status: "finishing" });
await apiRequest("POST", "/api/admin/uploads/complete", { await apiRequest("POST", "/api/admin/uploads/complete", {
guid: init.guid, guid: init.guid,
key: init.key, key: init.key,
uploadId: init.uploadId, uploadId: init.uploadId,
parts, parts,
title: title.trim(), title: (queueRef.current.find((q) => q.id === item.id)?.title || item.title).trim()
|| file.name.replace(/\.[^.]+$/, ""),
artist: artist.trim() || null, artist: artist.trim() || null,
description: description.trim(), description: "",
genre, genre,
contentType, contentType,
isPublic: false, isPublic: false,
@ -118,31 +159,40 @@ export default function VideoUpload() {
originalFileName: file.name, originalFileName: file.name,
}); });
setPhase("done"); patch(item.id, { status: "done", progress: 100 });
toast({
title: "Upload abgeschlossen",
description: "Das Video wird jetzt umgewandelt und erscheint in Kürze in der Liste.",
});
queryClient.invalidateQueries({ queryKey: ["/api/admin/videos"] });
queryClient.invalidateQueries({ queryKey: ["/api/admin/inventory"] });
setFile(null);
setTitle("");
setArtist("");
setDescription("");
if (fileRef.current) fileRef.current.value = "";
} catch (err: any) { } catch (err: any) {
console.error(err); console.error(err);
setPhase("error"); patch(item.id, { status: "error", error: err?.message || "Upload fehlgeschlagen" });
setErrorMsg(err?.message || "Upload fehlgeschlagen");
if (init?.key && init?.uploadId) { if (init?.key && init?.uploadId) {
apiRequest("POST", "/api/admin/uploads/abort", { key: init.key, uploadId: init.uploadId }).catch(() => {}); apiRequest("POST", "/api/admin/uploads/abort", { key: init.key, uploadId: init.uploadId }).catch(() => {});
} }
}
}
async function startAll() {
if (running) return;
setRunning(true);
try {
// Snapshot the waiting items; files added mid-run go in the next round.
const batch = queueRef.current.filter((it) => it.status === "waiting");
for (const item of batch) {
await uploadOne(item);
}
const doneNow = queueRef.current.filter((it) => it.status === "done").length;
const failedNow = queueRef.current.filter((it) => it.status === "error").length;
toast({ toast({
title: "Upload fehlgeschlagen", title: failedNow ? "Upload teilweise abgeschlossen" : "Upload abgeschlossen",
description: err?.message || "Unbekannter Fehler", description: failedNow
variant: "destructive", ? `${doneNow} fertig, ${failedNow} fehlgeschlagen.`
: `${doneNow} Datei(en) im Bucket — die Umwandlung läuft im Hintergrund.`,
variant: failedNow ? "destructive" : undefined,
}); });
queryClient.invalidateQueries({ queryKey: ["/api/admin/videos"] });
queryClient.invalidateQueries({ queryKey: ["/api/admin/inventory"] });
} finally {
setRunning(false);
} }
} }
@ -151,52 +201,42 @@ export default function VideoUpload() {
<CardHeader> <CardHeader>
<CardTitle className="flex items-center space-x-2"> <CardTitle className="flex items-center space-x-2">
<UploadCloud className="w-5 h-5" /> <UploadCloud className="w-5 h-5" />
<span>Video hochladen</span> <span>Videos hochladen</span>
{queue.length > 0 && (
<Badge variant="secondary" className="ml-2">
{finished}/{queue.length}
</Badge>
)}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div> <div>
<Label className="text-white/90">Datei</Label> <Label className="text-white/90">Dateien (Mehrfachauswahl möglich)</Label>
<Input <Input
ref={fileRef} ref={fileRef}
type="file" type="file"
multiple
accept="video/*,.mkv,.mxf,.mov" accept="video/*,.mkv,.mxf,.mov"
disabled={busy} onChange={(e) => addFiles(e.target.files)}
onChange={(e) => pickFile(e.target.files?.[0] || null)}
className="bg-white/10 border-white/20 text-white file:text-white" className="bg-white/10 border-white/20 text-white file:text-white"
/> />
{file && ( <p className="text-xs text-white/60 mt-1">
<p className="text-xs text-white/70 mt-1"> Mehrere Dateien mit Strg bzw. Cmd auswählen oder mehrfach hinzufügen.
{file.name} {(file.size / 1024 / 1024 / 1024).toFixed(2)} GB </p>
</p>
)}
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div> <div>
<Label className="text-white/90">Titel</Label> <Label className="text-white/90">Interpret (für alle)</Label>
<Input
value={title}
disabled={busy}
onChange={(e) => setTitle(e.target.value)}
className="bg-white/10 border-white/20 text-white"
/>
</div>
<div>
<Label className="text-white/90">Interpret</Label>
<Input <Input
value={artist} value={artist}
disabled={busy}
onChange={(e) => setArtist(e.target.value)} onChange={(e) => setArtist(e.target.value)}
className="bg-white/10 border-white/20 text-white" className="bg-white/10 border-white/20 text-white"
/> />
</div> </div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div>
<Label className="text-white/90">Genre</Label> <Label className="text-white/90">Genre</Label>
<Select value={genre} onValueChange={setGenre} disabled={busy}> <Select value={genre} onValueChange={setGenre}>
<SelectTrigger className="bg-white/10 border-white/20 text-white"> <SelectTrigger className="bg-white/10 border-white/20 text-white">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@ -209,7 +249,7 @@ export default function VideoUpload() {
</div> </div>
<div> <div>
<Label className="text-white/90">Typ</Label> <Label className="text-white/90">Typ</Label>
<Select value={contentType} onValueChange={setContentType} disabled={busy}> <Select value={contentType} onValueChange={setContentType}>
<SelectTrigger className="bg-white/10 border-white/20 text-white"> <SelectTrigger className="bg-white/10 border-white/20 text-white">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@ -222,45 +262,84 @@ export default function VideoUpload() {
</div> </div>
</div> </div>
<div> {queue.length > 0 && (
<Label className="text-white/90">Beschreibung</Label> <div className="space-y-2 max-h-80 overflow-auto rounded border border-white/10 p-2">
<Textarea {queue.map((it) => (
value={description} <div key={it.id} className="rounded bg-white/5 p-2">
disabled={busy} <div className="flex items-center gap-2">
onChange={(e) => setDescription(e.target.value)} <Input
className="bg-white/10 border-white/20 text-white" value={it.title}
rows={3} disabled={it.status !== "waiting"}
/> onChange={(e) => patch(it.id, { title: e.target.value })}
</div> className="h-8 bg-white/10 border-white/20 text-white text-sm"
/>
{phase === "uploading" && ( <span className="text-xs text-white/60 shrink-0 w-20 text-right">
<div> {(it.file.size / 1024 / 1024).toFixed(0)} MB
<div className="h-2 w-full bg-white/20 rounded overflow-hidden"> </span>
<div className="h-full bg-emerald-400 transition-all" style={{ width: `${progress}%` }} /> <StatusChip item={it} />
</div> {it.status === "waiting" && (
<p className="text-xs text-white/70 mt-1">{progress}% in den Bucket übertragen</p> <Button
size="sm"
variant="ghost"
onClick={() => removeItem(it.id)}
className="h-8 px-2 text-white/70 hover:text-white"
>
<X className="w-4 h-4" />
</Button>
)}
</div>
{(it.status === "uploading" || it.status === "finishing") && (
<div className="h-1.5 w-full bg-white/20 rounded overflow-hidden mt-2">
<div className="h-full bg-emerald-400 transition-all" style={{ width: `${it.progress}%` }} />
</div>
)}
{it.status === "error" && <p className="text-xs text-red-300 mt-1">{it.error}</p>}
</div>
))}
</div> </div>
)} )}
{phase === "finishing" && ( <div className="flex gap-2">
<p className="text-sm text-white/80 flex items-center gap-2"> <Button onClick={startAll} disabled={running || pending === 0} className="flex-1">
<Loader2 className="w-4 h-4 animate-spin" /> Upload wird abgeschlossen {running ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <UploadCloud className="w-4 h-4 mr-2" />}
{running ? `Läuft… (${finished}/${queue.length})` : `Hochladen${pending ? ` (${pending})` : ""}`}
</Button>
{finished > 0 && !running && (
<Button variant="outline" onClick={clearFinished} className="text-white border-white/20 hover:bg-white/10">
Fertige entfernen
</Button>
)}
</div>
{failed > 0 && !running && (
<p className="text-xs text-red-300">
{failed} Datei(en) fehlgeschlagen Titel bleibt in der Liste, erneut hinzufügen zum Wiederholen.
</p> </p>
)} )}
{phase === "done" && (
<p className="text-sm text-emerald-300 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4" /> Fertig die Umwandlung läuft im Hintergrund.
</p>
)}
{phase === "error" && <p className="text-sm text-red-300">{errorMsg}</p>}
<Button onClick={start} disabled={!file || busy} className="w-full">
{busy ? <Loader2 className="w-4 h-4 mr-2 animate-spin" /> : <UploadCloud className="w-4 h-4 mr-2" />}
{busy ? "Läuft…" : "Hochladen"}
</Button>
</CardContent> </CardContent>
</Card> </Card>
); );
} }
function StatusChip({ item }: { item: QueueItem }) {
if (item.status === "waiting") return <Badge variant="secondary" className="shrink-0">wartet</Badge>;
if (item.status === "uploading")
return <Badge variant="secondary" className="shrink-0">{item.progress}%</Badge>;
if (item.status === "finishing")
return (
<Badge variant="secondary" className="shrink-0">
<Loader2 className="w-3 h-3 animate-spin" />
</Badge>
);
if (item.status === "done")
return (
<Badge className="shrink-0 bg-emerald-500/80">
<CheckCircle2 className="w-3 h-3" />
</Badge>
);
return (
<Badge variant="destructive" className="shrink-0">
<AlertCircle className="w-3 h-3" />
</Badge>
);
}