diff --git a/client/src/components/inventory-panel.tsx b/client/src/components/inventory-panel.tsx new file mode 100644 index 0000000..5c4eb47 --- /dev/null +++ b/client/src/components/inventory-panel.tsx @@ -0,0 +1,106 @@ +import { useQuery } from "@tanstack/react-query"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { apiRequest } from "@/lib/queryClient"; +import { Database, RefreshCw, AlertTriangle, Loader2 } from "lucide-react"; +import { useState } from "react"; + +interface Inventory { + databaseCount: number; + bucketCount: number; + playable: number; + processingCount: number; + missingInBucket: Array<{ id: string; title: string; uploadStatus: string }>; + orphansInBucket: string[]; + pendingJobs: string[]; +} + +/** Side-by-side view of what the database claims and what the bucket actually holds. */ +export default function InventoryPanel() { + const [expanded, setExpanded] = useState(false); + + const { data, isLoading, refetch, isFetching } = useQuery({ + queryKey: ["/api/admin/inventory"], + queryFn: async () => { + const res = await apiRequest("GET", "/api/admin/inventory"); + return res.json(); + }, + refetchInterval: 60_000, + }); + + return ( + + + + + Bestand + + + + + {isLoading || !data ? ( +

Wird geladen…

+ ) : ( + <> +
+ + + + +
+ + {data.missingInBucket.length > 0 && ( +
+

+ + {data.missingInBucket.length} Einträge ohne Dateien im Bucket — diese lassen sich nicht abspielen. +

+ + {expanded && ( +
    + {data.missingInBucket.map((v) => ( +
  • + {v.title} + {v.uploadStatus} +
  • + ))} +
+ )} +
+ )} + + {data.orphansInBucket.length > 0 && ( +

+ {data.orphansInBucket.length} Pakete liegen im Bucket ohne Datenbankeintrag. +

+ )} + + )} +
+
+ ); +} + +function Stat({ label, value }: { label: string; value: number }) { + return ( +
+

{value}

+

{label}

+
+ ); +} diff --git a/client/src/components/video-upload.tsx b/client/src/components/video-upload.tsx new file mode 100644 index 0000000..30018aa --- /dev/null +++ b/client/src/components/video-upload.tsx @@ -0,0 +1,266 @@ +import { useRef, useState } from "react"; +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 { useToast } from "@/hooks/use-toast"; +import { apiRequest } from "@/lib/queryClient"; +import { UploadCloud, Loader2, CheckCircle2 } from "lucide-react"; + +type Phase = "idle" | "starting" | "uploading" | "finishing" | "done" | "error"; + +interface PartResult { + PartNumber: number; + ETag: string; +} + +/** + * 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. + */ +export default function VideoUpload() { + const { toast } = useToast(); + const queryClient = useQueryClient(); + const fileRef = useRef(null); + + const [file, setFile] = useState(null); + const [title, setTitle] = useState(""); + 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 busy = phase === "starting" || phase === "uploading" || phase === "finishing"; + + function pickFile(f: File | null) { + setFile(f); + setPhase("idle"); + setProgress(0); + setErrorMsg(""); + if (f && !title) setTitle(f.name.replace(/\.[^.]+$/, "")); + } + + async function uploadPart(url: string, blob: Blob, partNumber: number): Promise { + // XHR (not fetch) so we could extend this with per-part progress later. + 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`)); + else resolve(tag); + } else { + reject(new Error(`Part ${partNumber} failed with HTTP ${xhr.status}`)); + } + }; + xhr.onerror = () => reject(new Error(`Part ${partNumber}: network error`)); + xhr.send(blob); + }); + return { PartNumber: partNumber, ETag: etag }; + } + + async function start() { + if (!file) return; + setErrorMsg(""); + setPhase("starting"); + setProgress(0); + + let init: any = null; + try { + const initRes = await apiRequest("POST", "/api/admin/uploads/init", { + fileName: file.name, + fileSize: file.size, + contentType: file.type || "video/mp4", + }); + init = await initRes.json(); + + setPhase("uploading"); + const parts: PartResult[] = []; + const total: number = init.partCount; + + 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); + + const urlRes = await apiRequest("POST", "/api/admin/uploads/part-url", { + key: init.key, + uploadId: init.uploadId, + partNumber: i + 1, + }); + const { url } = await urlRes.json(); + + parts.push(await uploadPart(url, blob, i + 1)); + setProgress(Math.round(((i + 1) / total) * 100)); + } + + setPhase("finishing"); + await apiRequest("POST", "/api/admin/uploads/complete", { + guid: init.guid, + key: init.key, + uploadId: init.uploadId, + parts, + title: title.trim(), + artist: artist.trim() || null, + description: description.trim(), + genre, + contentType, + isPublic: false, + fileSize: file.size, + 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 = ""; + } catch (err: any) { + console.error(err); + setPhase("error"); + setErrorMsg(err?.message || "Upload fehlgeschlagen"); + if (init?.key && init?.uploadId) { + apiRequest("POST", "/api/admin/uploads/abort", { key: init.key, uploadId: init.uploadId }).catch(() => {}); + } + toast({ + title: "Upload fehlgeschlagen", + description: err?.message || "Unbekannter Fehler", + variant: "destructive", + }); + } + } + + return ( + + + + + Video hochladen + + + +
+ + pickFile(e.target.files?.[0] || null)} + className="bg-white/10 border-white/20 text-white file:text-white" + /> + {file && ( +

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

+ )} +
+ +
+
+ + 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" + /> +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +