Files
kino-app/frontend/src/pages/Admin.jsx
T
Myron Blair b74d8a2652 Add fast audio-only transcode mode; fixes no-sound from AC3/DTS/EAC3
Root cause of "movies have no volume control / can't hear anything":
0 of 200 movies had finished transcoding, so every movie streamed its
raw source file — and BDRip/AVI sources very commonly carry AC3/DTS/EAC3
audio, which no browser can decode natively. Browsers respond by hiding
the volume control entirely when they can't find a playable audio track,
which is exactly the symptom reported.

New "audio" transcode quality: video is stream-copied untouched, only
the audio track is re-encoded to AAC — dramatically faster than a full
ABR pass since no video re-encoding happens. Added:
- transcode_audio_fix() in transcode.py
- "audio" as a valid quality value everywhere quick/abr were validated
- POST /transcode/queue/fix-audio-all: cancels every still-pending job
  and replaces it with the fast audio fix for the same content (running
  jobs are left alone), so a library-wide backlog of slow ABR jobs can
  be swapped for something that actually restores sound soon
- Per-movie "Audio" button in Admin, "Fix Audio (Fast, All)" button on
  the Transcode Queue page

Note: this doesn't help HEVC/x265 sources, whose video (not just audio)
isn't natively browser-playable either — those still need a full ABR
re-encode to H.264.
2026-07-26 23:31:23 -05:00

184 lines
10 KiB
React

import { useEffect, useState } from "react";
import api from "../lib/api";
import { toast } from "sonner";
import { Trash2, Star, Film, Settings as SettingsIcon, Download, RefreshCcw, FolderSearch, Wrench } from "lucide-react";
import { useNavigate, Link } from "react-router-dom";
export default function Admin() {
const nav = useNavigate();
const [movies, setMovies] = useState([]);
const [shows, setShows] = useState([]);
const [transcoding, setTranscoding] = useState({});
const load = async () => {
const [{ data: m }, { data: s }] = await Promise.all([
api.get("/movies"),
api.get("/shows").catch(() => ({ data: [] })),
]);
setMovies(m); setShows(s);
};
useEffect(() => { load(); }, []);
// Poll for transcoding status
useEffect(() => {
const inProgress = movies.some((m) => m.hls_status === "running" || m.hls_status === "pending");
if (!inProgress) return;
const t = setInterval(load, 3000);
return () => clearInterval(t);
}, [movies]);
const remove = async (id) => {
if (!window.confirm("Remove this movie permanently?")) return;
await api.delete(`/movies/${id}`);
toast.success("Removed");
load();
};
const toggleFeatured = async (m) => {
await api.patch(`/movies/${m.id}`, { featured: !m.featured });
load();
};
const startTranscode = async (m, quality = "quick") => {
setTranscoding({ ...transcoding, [m.id]: true });
try {
await api.post(`/movies/${m.id}/transcode`, { quality });
toast.success(`${quality === "abr" ? "ABR" : "Quick"} transcode started`);
load();
} catch (err) {
toast.error(err.response?.data?.detail || "Could not start transcode");
} finally {
setTranscoding({ ...transcoding, [m.id]: false });
}
};
const hlsLabel = (m) => {
if (m.hls_status === "done") {
return m.hls_path === "master.m3u8" ? "ABR" : "Quick";
}
return null;
};
const hlsStatusBadge = (m) => {
if (!m.hls_status) return null;
const colors = {
pending: "text-[#fcd34d]",
running: "text-[#fcd34d]",
done: "text-[#86efac]",
failed: "text-[#fca5a5]",
};
const variant = m.hls_status === "done" ? hlsLabel(m) : "";
return <span className={`text-[9px] uppercase tracking-[0.2em] ${colors[m.hls_status] || "text-[#8A8A8A]"}`}>HLS {m.hls_status}{variant ? ` · ${variant}` : ""}</span>;
};
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="admin-page">
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Library management</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Admin</h1>
<p className="text-[#8A8A8A] mt-4">Manage your library, requests, integrations.</p>
<div className="mt-10 flex flex-wrap gap-3">
<Link to="/admin/upload" className="flex items-center gap-2 bg-[#D9381E] hover:bg-[#ED4B32] text-white px-5 py-2 text-xs uppercase tracking-[0.2em]" data-testid="admin-upload-link">
<Film size={14} strokeWidth={1.5} /> Upload Movie
</Link>
<Link to="/admin/radarr" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-radarr-link">
<Download size={14} strokeWidth={1.5} /> Radarr Import
</Link>
<Link to="/admin/scan" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-scan-link">
<FolderSearch size={14} strokeWidth={1.5} /> Library Scan
</Link>
<Link to="/admin/cleanup" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-cleanup-link">
<Wrench size={14} strokeWidth={1.5} /> Library Cleanup
</Link>
<Link to="/admin/queue" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-queue-link">
<SettingsIcon size={14} strokeWidth={1.5} /> Queue
</Link>
<Link to="/admin/users" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10" data-testid="admin-users-link">
<SettingsIcon size={14} strokeWidth={1.5} /> Users
</Link>
<Link to="/requests" className="flex items-center gap-2 bg-white/10 hover:bg-white/20 text-white px-5 py-2 text-xs uppercase tracking-[0.2em] border border-white/10">
Review Requests
</Link>
</div>
<div className="mt-12 border border-[#222]">
<div className="grid grid-cols-12 px-5 py-3 border-b border-[#222] text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">
<span className="col-span-4">Title</span>
<span className="col-span-1">Year</span>
<span className="col-span-1">Rating</span>
<span className="col-span-2">Source</span>
<span className="col-span-2">HLS</span>
<span className="col-span-2 text-right">Actions</span>
</div>
{movies.map((m) => (
<div key={m.id} className="grid grid-cols-12 items-center px-5 py-4 border-b border-[#222] last:border-b-0 hover:bg-[#0F0F0F] transition-colors" data-testid={`admin-movie-row-${m.id}`}>
<div className="col-span-4 flex items-center gap-3 min-w-0">
<img src={m.poster_url} alt="" className="w-10 h-14 object-cover" />
<span className="text-white truncate">{m.title}</span>
</div>
<span className="col-span-1 text-[#8A8A8A] text-sm">{m.year}</span>
<span className="col-span-1 text-[#8A8A8A] text-sm">{m.rating}</span>
<span className="col-span-2 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{m.storage_type}</span>
<span className="col-span-2">{hlsStatusBadge(m)}</span>
<div className="col-span-2 flex justify-end gap-2">
{(m.storage_type === "local" || m.storage_type === "radarr" || m.storage_type === "scan") && m.hls_status !== "running" && m.hls_status !== "pending" && (
<div className="flex gap-1">
<button onClick={() => startTranscode(m, "quick")} disabled={transcoding[m.id]}
title="Quick transcode (stream-copy, single bitrate)"
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-2 py-1 disabled:opacity-50"
data-testid={`transcode-quick-${m.id}`}>Quick</button>
<button onClick={() => startTranscode(m, "audio")} disabled={transcoding[m.id]}
title="Fix audio only (video untouched, audio re-encoded to AAC) — fast, fixes no-sound from AC3/DTS/EAC3"
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#86efac] hover:text-[#86efac] text-[#8A8A8A] px-2 py-1 disabled:opacity-50"
data-testid={`transcode-audio-${m.id}`}>Audio</button>
<button onClick={() => startTranscode(m, "abr")} disabled={transcoding[m.id]}
title="Adaptive bitrate (re-encodes to 360/480/720/1080p — slow)"
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-2 py-1 disabled:opacity-50"
data-testid={`transcode-abr-${m.id}`}>ABR</button>
</div>
)}
<button onClick={() => toggleFeatured(m)} className={`${m.featured ? "text-[#D9381E]" : "text-[#8A8A8A]"} hover:text-[#ED4B32] transition-colors`} aria-label="Feature" data-testid={`feature-${m.id}`}>
<Star size={16} strokeWidth={1.5} fill={m.featured ? "#D9381E" : "none"} />
</button>
<button onClick={() => remove(m.id)} className="text-[#8A8A8A] hover:text-[#fca5a5] transition-colors" aria-label="Delete" data-testid={`delete-${m.id}`}>
<Trash2 size={16} strokeWidth={1.5} />
</button>
</div>
</div>
))}
</div>
{shows.length > 0 && (
<div className="mt-12">
<h2 className="font-display text-2xl font-bold tracking-tight text-white mb-4">Series</h2>
<div className="border border-[#222]">
<div className="grid grid-cols-12 px-5 py-3 border-b border-[#222] text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">
<span className="col-span-6">Title</span>
<span className="col-span-2">Year</span>
<span className="col-span-2">Episodes</span>
<span className="col-span-2 text-right">Actions</span>
</div>
{shows.map((s) => (
<div key={s.id} className="grid grid-cols-12 items-center px-5 py-4 border-b border-[#222] last:border-b-0 hover:bg-[#0F0F0F] transition-colors" data-testid={`admin-show-row-${s.id}`}>
<div className="col-span-6 flex items-center gap-3 min-w-0">
<img src={s.poster_url} alt="" className="w-10 h-14 object-cover" />
<span className="text-white truncate">{s.title}</span>
</div>
<span className="col-span-2 text-[#8A8A8A] text-sm">{s.year}</span>
<span className="col-span-2 text-[#8A8A8A] text-sm">{s.season_count}s · {s.episode_count}e</span>
<div className="col-span-2 flex justify-end">
<a href={`/admin/shows/${s.id}/episodes`} className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-3 py-1 transition-colors" data-testid={`manage-episodes-${s.id}`}>
Manage Episodes
</a>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}