Direct-to-bucket video upload + transcode pipeline
- server/videoUpload.ts: S3 multipart presign helpers, bucket inventory listing
- routes: /api/admin/uploads/{init,part-url,complete,abort}, /api/admin/inventory,
/api/internal/transcode-done (worker callback, bearer INTERNAL_TOKEN)
- admin UI: browser-to-bucket uploader with progress, bucket-vs-database panel
Originals land in folx-tv/_incoming/{guid}/, the worker on the remotion box
packages them to HLS and reports back.
This commit is contained in:
@@ -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<Inventory>({
|
||||
queryKey: ["/api/admin/inventory"],
|
||||
queryFn: async () => {
|
||||
const res = await apiRequest("GET", "/api/admin/inventory");
|
||||
return res.json();
|
||||
},
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<Card className="bg-white/10 border-white/20 text-white mb-6">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Database className="w-5 h-5" />
|
||||
<span>Bestand</span>
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="text-white border-white/20 hover:bg-white/10"
|
||||
>
|
||||
{isFetching ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading || !data ? (
|
||||
<p className="text-white/70 text-sm">Wird geladen…</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<Stat label="In der Datenbank" value={data.databaseCount} />
|
||||
<Stat label="Im Bucket" value={data.bucketCount} />
|
||||
<Stat label="Abspielbar" value={data.playable} />
|
||||
<Stat label="In Umwandlung" value={data.processingCount} />
|
||||
</div>
|
||||
|
||||
{data.missingInBucket.length > 0 && (
|
||||
<div className="rounded border border-amber-400/40 bg-amber-400/10 p-3">
|
||||
<p className="flex items-center gap-2 text-sm text-amber-200">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
{data.missingInBucket.length} Einträge ohne Dateien im Bucket — diese lassen sich nicht abspielen.
|
||||
</p>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-amber-200 px-0 h-auto"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? "Liste ausblenden" : "Liste anzeigen"}
|
||||
</Button>
|
||||
{expanded && (
|
||||
<ul className="mt-2 max-h-64 overflow-auto text-xs space-y-1">
|
||||
{data.missingInBucket.map((v) => (
|
||||
<li key={v.id} className="flex justify-between gap-2">
|
||||
<span className="truncate">{v.title}</span>
|
||||
<Badge variant="secondary" className="shrink-0">{v.uploadStatus}</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.orphansInBucket.length > 0 && (
|
||||
<p className="mt-3 text-xs text-white/60">
|
||||
{data.orphansInBucket.length} Pakete liegen im Bucket ohne Datenbankeintrag.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded bg-white/5 p-3">
|
||||
<p className="text-2xl font-semibold">{value}</p>
|
||||
<p className="text-xs text-white/70">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
|
||||
const [file, setFile] = useState<File | null>(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<Phase>("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<PartResult> {
|
||||
// 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 (
|
||||
<Card className="bg-white/10 border-white/20 text-white mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<UploadCloud className="w-5 h-5" />
|
||||
<span>Video hochladen</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label className="text-white/90">Datei</Label>
|
||||
<Input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="video/*,.mkv,.mxf,.mov"
|
||||
disabled={busy}
|
||||
onChange={(e) => pickFile(e.target.files?.[0] || null)}
|
||||
className="bg-white/10 border-white/20 text-white file:text-white"
|
||||
/>
|
||||
{file && (
|
||||
<p className="text-xs text-white/70 mt-1">
|
||||
{file.name} — {(file.size / 1024 / 1024 / 1024).toFixed(2)} GB
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-white/90">Titel</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
|
||||
value={artist}
|
||||
disabled={busy}
|
||||
onChange={(e) => setArtist(e.target.value)}
|
||||
className="bg-white/10 border-white/20 text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-white/90">Genre</Label>
|
||||
<Select value={genre} onValueChange={setGenre} disabled={busy}>
|
||||
<SelectTrigger className="bg-white/10 border-white/20 text-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["volksmusik", "schlager", "pop", "rock", "country", "instrumental", "dance", "other"].map((g) => (
|
||||
<SelectItem key={g} value={g}>{g}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-white/90">Typ</Label>
|
||||
<Select value={contentType} onValueChange={setContentType} disabled={busy}>
|
||||
<SelectTrigger className="bg-white/10 border-white/20 text-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["video", "oddaja", "music_video", "documentary", "live"].map((c) => (
|
||||
<SelectItem key={c} value={c}>{c}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-white/90">Beschreibung</Label>
|
||||
<Textarea
|
||||
value={description}
|
||||
disabled={busy}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="bg-white/10 border-white/20 text-white"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{phase === "uploading" && (
|
||||
<div>
|
||||
<div className="h-2 w-full bg-white/20 rounded overflow-hidden">
|
||||
<div className="h-full bg-emerald-400 transition-all" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<p className="text-xs text-white/70 mt-1">{progress}% in den Bucket übertragen</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === "finishing" && (
|
||||
<p className="text-sm text-white/80 flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" /> Upload wird abgeschlossen…
|
||||
</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>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,8 @@ import { apiRequest } from "@/lib/queryClient";
|
||||
import type { Video } from "@shared/schema";
|
||||
import { Shield, Edit, Upload, Search, Filter, Save, X, Sparkles, Loader2 } from "lucide-react";
|
||||
import HeaderAd from "@/components/HeaderAd";
|
||||
import VideoUpload from "@/components/video-upload";
|
||||
import InventoryPanel from "@/components/inventory-panel";
|
||||
|
||||
export default function AdminPage() {
|
||||
const { user, isLoading: authLoading, isAuthenticated, isAdmin } = useAuth();
|
||||
@@ -98,6 +100,8 @@ export default function AdminPage() {
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-3">
|
||||
<VideoUpload />
|
||||
<InventoryPanel />
|
||||
<VideoManagement search={search} onEditVideo={setSelectedVideo} onOpenDialog={setEditDialogOpen} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user