Codec-aware transcode routing, two-stage queue, request candidate picker, admin reorg

- Requests approval now shows a Radarr/Sonarr candidate picker instead of grabbing the first search result (fixes wrong-title/wrong-year matches)
- approve_request reuses an existing Radarr/Sonarr entry instead of erroring when content is already in the library
- New codec-check queue (step 1) probes source codecs and skips transcoding entirely for already browser-native files; only non-native content reaches the transcode queue (step 2)
- Fixes ffmpeg hard-failing on non-H.264 sources via the h264_mp4toannexb bitstream filter (root cause of the fix-audio-all failures)
- Added Codec Check Worker / Transcode Worker to the services health panel with restart
- Reorganized the admin page into Pipeline/Utilities dropdowns; added the missing Sonarr Import link
- Bumped VERSION 1.0.0 -> 1.1.0
This commit is contained in:
root
2026-07-30 20:41:11 -05:00
parent 671074764e
commit b28a49e8fa
13 changed files with 784 additions and 50 deletions
+2
View File
@@ -19,6 +19,7 @@ import AdminUpload from "./pages/AdminUpload";
import Settings from "./pages/Settings";
import RadarrImport from "./pages/RadarrImport";
import TranscodeQueue from "./pages/TranscodeQueue";
import CodecCheckQueue from "./pages/CodecCheckQueue";
import Shows from "./pages/Shows";
import ShowDetail from "./pages/ShowDetail";
import EpisodePlayer from "./pages/EpisodePlayer";
@@ -105,6 +106,7 @@ function App() {
<Route path="/admin/cleanup" element={<AdminGate><LibraryCleanup /></AdminGate>} />
<Route path="/admin/shows/:id/episodes" element={<AdminGate><AdminShowEpisodes /></AdminGate>} />
<Route path="/admin/queue" element={<AdminGate><TranscodeQueue /></AdminGate>} />
<Route path="/admin/codec-check" element={<AdminGate><CodecCheckQueue /></AdminGate>} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Shell>
+62 -23
View File
@@ -1,9 +1,67 @@
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } 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 {
Trash2, Star, Film, Settings as SettingsIcon, Download, FolderSearch, Wrench, ScanSearch,
Tv, ClipboardList, ChevronDown, Users as UsersIcon,
} from "lucide-react";
import { useNavigate, Link } from "react-router-dom";
// Ordered to match the actual content pipeline: a request comes in, gets pulled into
// Radarr/Sonarr (or uploaded/scanned in directly), gets codec-checked, then transcoded if
// needed — after that it's just in the library. Everything else is a standing utility, not a
// step content passes through, so it lives in its own group.
const PIPELINE_LINKS = [
{ to: "/requests", label: "Review Requests", icon: ClipboardList, testId: "admin-requests-link" },
{ to: "/admin/radarr", label: "Radarr Import", icon: Download, testId: "admin-radarr-link" },
{ to: "/admin/sonarr", label: "Sonarr Import", icon: Tv, testId: "admin-sonarr-link" },
{ to: "/admin/upload", label: "Upload Movie", icon: Film, testId: "admin-upload-link" },
{ to: "/admin/scan", label: "Library Scan", icon: FolderSearch, testId: "admin-scan-link" },
{ to: "/admin/codec-check", label: "Codec Check Queue", icon: ScanSearch, testId: "admin-codec-check-link" },
{ to: "/admin/queue", label: "Transcode Queue", icon: SettingsIcon, testId: "admin-queue-link" },
];
const UTILITY_LINKS = [
{ to: "/admin/cleanup", label: "Library Cleanup", icon: Wrench, testId: "admin-cleanup-link" },
{ to: "/admin/users", label: "Users", icon: UsersIcon, testId: "admin-users-link" },
];
function AdminMenu({ label, links }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
const onClick = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); };
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, []);
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((v) => !v)}
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-menu-${label.toLowerCase()}`}
>
{label} <ChevronDown size={14} strokeWidth={1.5} className={`transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div className="absolute left-0 top-full mt-1 min-w-[220px] bg-[#0F0F0F] border border-[#222] z-20 shadow-lg" data-testid={`admin-menu-${label.toLowerCase()}-panel`}>
{links.map(({ to, label: linkLabel, icon: Icon, testId }) => (
<Link
key={to} to={to} onClick={() => setOpen(false)}
className="flex items-center gap-2 px-4 py-3 text-xs uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-white hover:bg-white/5 border-b border-[#222] last:border-b-0"
data-testid={testId}
>
<Icon size={14} strokeWidth={1.5} /> {linkLabel}
</Link>
))}
</div>
)}
</div>
);
}
export default function Admin() {
const nav = useNavigate();
const [movies, setMovies] = useState([]);
@@ -79,27 +137,8 @@ export default function Admin() {
<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>
<AdminMenu label="Pipeline" links={PIPELINE_LINKS} />
<AdminMenu label="Utilities" links={UTILITY_LINKS} />
</div>
<div className="mt-12 border border-[#222]">
+173
View File
@@ -0,0 +1,173 @@
import { useEffect, useState } from "react";
import api from "../lib/api";
import { toast } from "sonner";
import { RefreshCcw, X, RotateCcw, Pause, Play as PlayIcon, Trash2 } from "lucide-react";
const STATUS_COLORS = {
pending: "text-[#fcd34d]",
running: "text-[#fcd34d]",
done: "text-[#86efac]",
failed: "text-[#fca5a5]",
cancelled: "text-[#8A8A8A]",
superseded: "text-[#8A8A8A]",
};
const CLASSIFICATION_LABEL = {
native: "Native (no transcode)",
audio: "Audio fix needed",
abr: "Full re-encode needed",
};
export default function CodecCheckQueue() {
const [jobs, setJobs] = useState([]);
const [stats, setStats] = useState({ pending: 0, running: 0, done: 0, failed: 0, cancelled: 0, paused: false });
const [runningJob, setRunningJob] = useState(null);
const [loading, setLoading] = useState(false);
const load = async () => {
setLoading(true);
try {
const [j, s] = await Promise.all([api.get("/codec-check/queue"), api.get("/codec-check/queue/stats")]);
setJobs(j.data);
setStats(s.data);
} finally { setLoading(false); }
};
const loadRunning = async () => {
try { const { data } = await api.get("/codec-check/queue/running"); setRunningJob(data); }
catch { /* ignore transient poll failures */ }
};
useEffect(() => { load(); loadRunning(); }, []);
useEffect(() => {
const pending = jobs.some((j) => j.status === "pending");
if (!runningJob && !pending) return;
const t = setInterval(() => { load(); loadRunning(); }, runningJob ? 2000 : 4000);
return () => clearInterval(t);
}, [jobs, runningJob]);
const listedJobs = jobs.filter((j) => j.status !== "running");
const cancel = async (id) => {
try { await api.delete(`/codec-check/queue/${id}`); toast.success("Job cancelled"); load(); }
catch (err) { toast.error(err.response?.data?.detail || "Could not cancel"); }
};
const retry = async (id) => {
try { await api.post(`/codec-check/queue/${id}/retry`); toast.success("Re-queued"); load(); }
catch (err) { toast.error(err.response?.data?.detail || "Could not retry"); }
};
const retryAllFailed = async () => {
try {
const { data } = await api.post("/codec-check/queue/retry-failed");
toast.success(`Re-queued ${data.retried} job(s)`);
load();
} catch (err) { toast.error(err.response?.data?.detail || "Could not retry"); }
};
const togglePause = async () => {
try {
const { data } = await api.post("/codec-check/queue/pause", { paused: !stats.paused });
toast.success(data.paused ? "Queue paused" : "Queue resumed");
load();
} catch { toast.error("Could not toggle pause"); }
};
const clearFinished = async () => {
if (!window.confirm("Remove all done/failed/cancelled jobs from the history?")) return;
try { const { data } = await api.post("/codec-check/queue/clear"); toast.success(`Cleared ${data.deleted}`); load(); }
catch { toast.error("Could not clear"); }
};
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="codec-check-queue-page">
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Background · Step 1 of 2</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Codec Check Queue</h1>
<p className="text-[#8A8A8A] mt-4 max-w-2xl">
Every newly imported movie/episode lands here first. A quick ffprobe decides whether it actually needs
transcoding at all: already-browser-native files (H.264 + AAC/MP3) skip straight to the library with
no transcode job ever created; everything else is handed off to the{" "}
<a href="/admin/queue" className="text-[#D9381E] hover:text-[#ED4B32]">Transcode Queue (step 2)</a>.
</p>
<div className="mt-8 grid grid-cols-2 sm:grid-cols-5 gap-3">
{["pending", "running", "done", "failed", "cancelled"].map((k) => (
<div key={k} className="border border-[#222] p-4" data-testid={`codec-stat-${k}`}>
<div className="text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{k}</div>
<div className={`mt-1 font-display text-3xl font-bold ${STATUS_COLORS[k]}`}>{stats[k] || 0}</div>
</div>
))}
</div>
<div className="mt-6 flex flex-wrap gap-3">
<button onClick={load} disabled={loading} className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-4 py-2 text-xs uppercase tracking-[0.2em] transition-colors disabled:opacity-50" data-testid="codec-queue-refresh">
<RefreshCcw size={14} strokeWidth={1.5} className={loading ? "animate-spin" : ""} /> Refresh
</button>
<button onClick={togglePause} className="flex items-center gap-2 border border-[#222] hover:border-white text-[#8A8A8A] hover:text-white px-4 py-2 text-xs uppercase tracking-[0.2em] transition-colors" data-testid="codec-queue-pause-toggle">
{stats.paused ? <PlayIcon size={14} strokeWidth={1.5} /> : <Pause size={14} strokeWidth={1.5} />}
{stats.paused ? "Resume" : "Pause"}
</button>
<button onClick={retryAllFailed} disabled={!stats.failed && !stats.cancelled} className="flex items-center gap-2 border border-[#222] hover:border-[#D9381E] hover:text-[#D9381E] text-[#8A8A8A] px-4 py-2 text-xs uppercase tracking-[0.2em] transition-colors disabled:opacity-50" data-testid="codec-queue-retry-all">
<RotateCcw size={14} strokeWidth={1.5} /> Retry All Failed
</button>
<button onClick={clearFinished} className="flex items-center gap-2 border border-[#222] hover:border-[#fca5a5] hover:text-[#fca5a5] text-[#8A8A8A] px-4 py-2 text-xs uppercase tracking-[0.2em] transition-colors" data-testid="codec-queue-clear">
<Trash2 size={14} strokeWidth={1.5} /> Clear Finished
</button>
</div>
{runningJob && (
<div className="mt-8 border border-[#D9381E]/40 bg-[#D9381E]/[0.04] p-5" data-testid="codec-now-processing">
<div className="flex items-center gap-2 text-[10px] uppercase tracking-[0.3em] text-[#D9381E]">
<span className="w-1.5 h-1.5 rounded-full bg-[#D9381E] animate-pulse" />
Checking Codec
</div>
<div className="mt-2 text-white text-lg truncate" title={runningJob.movie_title}>
{runningJob.movie_title || runningJob.movie_id.slice(0, 8)}
</div>
<div className="mt-1 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{runningJob.triggered_by}</div>
</div>
)}
<div className="mt-6 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-3">Result</span>
<span className="col-span-1">Trigger</span>
<span className="col-span-2">Status</span>
<span className="col-span-1">Created</span>
<span className="col-span-1 text-right">Actions</span>
</div>
{listedJobs.length === 0 ? (
<div className="p-8 text-center text-[#8A8A8A] text-sm" data-testid="codec-queue-empty">No jobs yet.</div>
) : listedJobs.map((j) => (
<div key={j.id} className="grid grid-cols-12 items-center px-5 py-3 border-b border-[#222] last:border-b-0 hover:bg-[#0F0F0F] transition-colors" data-testid={`codec-queue-row-${j.id}`}>
<div className="col-span-4 text-white truncate" title={j.movie_title}>{j.movie_title || j.movie_id.slice(0, 8)}</div>
<span className="col-span-3 text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">
{j.classification ? CLASSIFICATION_LABEL[j.classification] || j.classification : "—"}
</span>
<span className="col-span-1 text-[10px] uppercase tracking-[0.3em] text-[#8A8A8A]">{j.triggered_by}</span>
<span className={`col-span-2 text-[10px] uppercase tracking-[0.3em] ${STATUS_COLORS[j.status]}`} data-testid={`codec-queue-status-${j.id}`}>
{j.status}
{j.error && j.status === "failed" && <span className="block text-[#fca5a5]/70 normal-case truncate" title={j.error}>{j.error.slice(0, 40)}</span>}
</span>
<span className="col-span-1 text-xs text-[#8A8A8A]">{new Date(j.created_at).toLocaleDateString()}</span>
<div className="col-span-1 flex justify-end gap-2">
{j.status === "pending" && (
<button onClick={() => cancel(j.id)} className="text-[#8A8A8A] hover:text-[#fca5a5]" data-testid={`codec-cancel-${j.id}`} aria-label="Cancel">
<X size={14} strokeWidth={1.5} />
</button>
)}
{(j.status === "failed" || j.status === "cancelled") && (
<button onClick={() => retry(j.id)} className="flex items-center gap-1 text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A] hover:text-[#D9381E]" data-testid={`codec-retry-${j.id}`}>
<RotateCcw size={13} strokeWidth={1.5} /> Retry
</button>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
}
+90 -4
View File
@@ -40,6 +40,80 @@ function LiveStatus({ requestId }) {
);
}
function CandidatePicker({ request, contentType, onClose, onPicked }) {
const [candidates, setCandidates] = useState(null);
const [picking, setPicking] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const { data } = await api.get(`/requests/${request.id}/candidates`, { params: { content_type: contentType } });
if (!cancelled) setCandidates(data);
} catch (err) {
if (!cancelled) {
toast.error(err.response?.data?.detail || "Could not search Radarr/Sonarr");
onClose();
}
}
})();
return () => { cancelled = true; };
}, [request.id, contentType]);
const pick = async (c) => {
setPicking(true);
try {
await onPicked(c);
} finally {
setPicking(false);
}
};
return (
<div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-6" onClick={onClose}>
<div
className="bg-[#0F0F0F] border border-[#222] max-w-2xl w-full max-h-[80vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
data-testid="candidate-picker"
>
<div className="px-5 py-4 border-b border-[#222] flex items-center justify-between">
<h3 className="text-white font-display font-bold">
Pick the right match for "{request.title}"{request.year ? ` (${request.year})` : ""}
</h3>
<button onClick={onClose} className="text-[#8A8A8A] hover:text-white text-xs uppercase tracking-[0.2em]">Close</button>
</div>
{candidates === null ? (
<p className="text-[#8A8A8A] text-sm px-5 py-6">Searching</p>
) : candidates.length === 0 ? (
<p className="text-[#8A8A8A] text-sm px-5 py-6">No results found.</p>
) : (
<div>
{candidates.map((c) => (
<button
key={c.tmdb_id || c.tvdb_id}
onClick={() => pick(c)}
disabled={picking}
className="w-full flex items-start gap-4 px-5 py-4 border-b border-[#222] last:border-b-0 text-left hover:bg-[#1A1A1A] disabled:opacity-50"
data-testid={`candidate-${c.tmdb_id || c.tvdb_id}`}
>
{c.poster_url ? (
<img src={c.poster_url} alt="" className="w-12 h-18 object-cover shrink-0 bg-[#1A1A1A]" />
) : (
<div className="w-12 h-18 shrink-0 bg-[#1A1A1A]" />
)}
<div className="min-w-0">
<p className="text-white">{c.title} {c.year ? <span className="text-[#8A8A8A]">({c.year})</span> : null}</p>
{c.overview && <p className="text-xs text-[#8A8A8A] mt-1 line-clamp-2">{c.overview}</p>}
</div>
</button>
))}
</div>
)}
</div>
</div>
);
}
export default function Requests() {
const { user } = useAuth();
const [mine, setMine] = useState([]);
@@ -48,6 +122,7 @@ export default function Requests() {
const [year, setYear] = useState("");
const [notes, setNotes] = useState("");
const [approving, setApproving] = useState({});
const [picker, setPicker] = useState(null); // { request, contentType }
const load = async () => {
const { data } = await api.get("/requests/mine");
@@ -59,11 +134,14 @@ export default function Requests() {
};
useEffect(() => { load(); }, [user?.is_admin]);
const approve = async (id, contentType) => {
const approveWithCandidate = async (request, contentType, candidate) => {
const id = request.id;
setApproving((p) => ({ ...p, [id]: true }));
try {
const { data } = await api.post(`/requests/${id}/approve`, { content_type: contentType });
const idField = contentType === "movie" ? { tmdb_id: candidate.tmdb_id } : { tvdb_id: candidate.tvdb_id };
const { data } = await api.post(`/requests/${id}/approve`, { content_type: contentType, ...idField });
toast.success(`Searching for "${data.matched_title || data.title}"`);
setPicker(null);
load();
} catch (err) {
toast.error(err.response?.data?.detail || "Could not approve request");
@@ -189,10 +267,10 @@ export default function Requests() {
<div className="flex gap-2 shrink-0">
{r.status === "pending" && (
<>
<button onClick={() => approve(r.id, "movie")} disabled={approving[r.id]}
<button onClick={() => setPicker({ request: r, contentType: "movie" })} disabled={approving[r.id]}
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#93c5fd] hover:text-[#93c5fd] text-[#8A8A8A] px-3 py-2 disabled:opacity-50"
data-testid={`approve-movie-${r.id}`}>Approve (Movie)</button>
<button onClick={() => approve(r.id, "tv")} disabled={approving[r.id]}
<button onClick={() => setPicker({ request: r, contentType: "tv" })} disabled={approving[r.id]}
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#93c5fd] hover:text-[#93c5fd] text-[#8A8A8A] px-3 py-2 disabled:opacity-50"
data-testid={`approve-tv-${r.id}`}>Approve (TV)</button>
</>
@@ -211,6 +289,14 @@ export default function Requests() {
</div>
)}
</div>
{picker && (
<CandidatePicker
request={picker.request}
contentType={picker.contentType}
onClose={() => setPicker(null)}
onPicked={(c) => approveWithCandidate(picker.request, picker.contentType, c)}
/>
)}
</div>
);
}
+4 -2
View File
@@ -92,10 +92,12 @@ export default function TranscodeQueue() {
return (
<div className="min-h-screen bg-[#050505] pt-32 pb-24" data-testid="queue-page">
<div className="px-6 md:px-12 max-w-[1500px] mx-auto">
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Background</span>
<span className="text-xs uppercase tracking-[0.3em] text-[#D9381E]">Background · Step 2 of 2</span>
<h1 className="font-display text-5xl md:text-6xl font-black tracking-tighter text-white mt-3">Transcode Queue</h1>
<p className="text-[#8A8A8A] mt-4 max-w-2xl">
One job runs at a time, moderate CPU priority, capped threads so it can't starve the rest of the stack. Auto-transcode is currently:{" "}
Only files the <a href="/admin/codec-check" className="text-[#D9381E] hover:text-[#ED4B32]">Codec Check Queue (step 1)</a> flagged
as needing work land here already browser-native files skip this queue entirely. One job runs at a time, moderate CPU priority,
capped threads so it can't starve the rest of the stack. Auto-transcode is currently:{" "}
<span className="text-white">{stats.auto_transcode === "off" ? "off" : `auto · ${stats.auto_transcode}`}</span>
{" — "}<a href="/admin/settings" className="text-[#D9381E] hover:text-[#ED4B32]">change in Settings</a>. Failed and cancelled jobs never delete the movie retry re-queues the same file.
</p>