mirror of
https://github.com/myronblair/kino-app
synced 2026-07-31 21:12:43 -05:00
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:
@@ -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>
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user