diff --git a/client/src/components/video-upload.tsx b/client/src/components/video-upload.tsx index 30018aa..6d8af7f 100644 --- a/client/src/components/video-upload.tsx +++ b/client/src/components/video-upload.tsx @@ -3,78 +3,108 @@ import { useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; import { useToast } from "@/hooks/use-toast"; 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 { PartNumber: number; 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 - * using presigned multipart URLs, then hands the GUID to the transcode worker. - * Nothing large ever passes through the application server. + * Queue uploader: pick as many files as you like, they go one after another + * straight from the browser into the Hetzner bucket. Parts of the current file + * are sent in parallel, so a single large master still uses the full line. */ export default function VideoUpload() { const { toast } = useToast(); const queryClient = useQueryClient(); const fileRef = useRef(null); - const [file, setFile] = useState(null); - const [title, setTitle] = useState(""); + const [queue, setQueue] = useState([]); + const queueRef = useRef([]); + queueRef.current = queue; const [artist, setArtist] = useState(""); - const [description, setDescription] = useState(""); const [genre, setGenre] = useState("other"); const [contentType, setContentType] = useState("video"); - const [phase, setPhase] = useState("idle"); - const [progress, setProgress] = useState(0); - const [errorMsg, setErrorMsg] = useState(""); + const [running, setRunning] = useState(false); - 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) { - setFile(f); - setPhase("idle"); - setProgress(0); - setErrorMsg(""); - if (f && !title) setTitle(f.name.replace(/\.[^.]+$/, "")); + function addFiles(list: FileList | null) { + if (!list || list.length === 0) return; + const items: QueueItem[] = Array.from(list).map((file, i) => ({ + id: `${Date.now()}-${i}-${file.name}`, + file, + 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 { - // XHR (not fetch) so we could extend this with per-part progress later. + function patch(id: string, changes: Partial) { + 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 { const etag: string = await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("PUT", url, true); xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { 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 { - 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); }); return { PartNumber: partNumber, ETag: etag }; } - async function start() { - if (!file) return; - setErrorMsg(""); - setPhase("starting"); - setProgress(0); - + async function uploadOne(item: QueueItem) { + const { file } = item; let init: any = null; try { + patch(item.id, { status: "uploading", progress: 0, error: undefined }); + const initRes = await apiRequest("POST", "/api/admin/uploads/init", { fileName: file.name, fileSize: file.size, @@ -82,35 +112,46 @@ export default function VideoUpload() { }); init = await initRes.json(); - setPhase("uploading"); - const parts: PartResult[] = []; const total: number = init.partCount; + const parts: PartResult[] = new Array(total); + let completedParts = 0; + let next = 0; - for (let i = 0; i < total; i++) { - const from = i * init.partSize; - const to = Math.min(from + init.partSize, file.size); - const blob = file.slice(from, to); + async function worker() { + while (true) { + const index = next++; + 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", { - key: init.key, - uploadId: init.uploadId, - partNumber: i + 1, - }); - const { url } = await urlRes.json(); + const urlRes = await apiRequest("POST", "/api/admin/uploads/part-url", { + key: init.key, + uploadId: init.uploadId, + partNumber, + }); + const { url } = await urlRes.json(); - parts.push(await uploadPart(url, blob, i + 1)); - setProgress(Math.round(((i + 1) / total) * 100)); + parts[index] = await putPart(url, file.slice(from, to), partNumber); + 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", { guid: init.guid, key: init.key, uploadId: init.uploadId, parts, - title: title.trim(), + title: (queueRef.current.find((q) => q.id === item.id)?.title || item.title).trim() + || file.name.replace(/\.[^.]+$/, ""), artist: artist.trim() || null, - description: description.trim(), + description: "", genre, contentType, isPublic: false, @@ -118,31 +159,40 @@ export default function VideoUpload() { originalFileName: file.name, }); - setPhase("done"); - 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 = ""; + patch(item.id, { status: "done", progress: 100 }); } catch (err: any) { console.error(err); - setPhase("error"); - setErrorMsg(err?.message || "Upload fehlgeschlagen"); + patch(item.id, { status: "error", error: err?.message || "Upload fehlgeschlagen" }); if (init?.key && init?.uploadId) { 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({ - title: "Upload fehlgeschlagen", - description: err?.message || "Unbekannter Fehler", - variant: "destructive", + title: failedNow ? "Upload teilweise abgeschlossen" : "Upload abgeschlossen", + description: failedNow + ? `${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() { - Video hochladen + Videos hochladen + {queue.length > 0 && ( + + {finished}/{queue.length} + + )}
- + pickFile(e.target.files?.[0] || null)} + onChange={(e) => addFiles(e.target.files)} className="bg-white/10 border-white/20 text-white file:text-white" /> - {file && ( -

- {file.name} — {(file.size / 1024 / 1024 / 1024).toFixed(2)} GB -

- )} +

+ Mehrere Dateien mit Strg bzw. Cmd auswählen — oder mehrfach hinzufügen. +

-
+
- - setTitle(e.target.value)} - className="bg-white/10 border-white/20 text-white" - /> -
-
- + setArtist(e.target.value)} className="bg-white/10 border-white/20 text-white" />
-
- -
- @@ -209,7 +249,7 @@ export default function VideoUpload() {
- @@ -222,45 +262,84 @@ export default function VideoUpload() {
-
- -