Wire Requests approval into Radarr/Sonarr with live download status

Admin can now Approve a pending request as Movie or TV, which looks up
the title via Radarr/Sonarr's own lookup API, adds the best match with
search-on-add enabled, and tracks the resulting radarr_id/sonarr_id on
the request. A live status line (searching/queued/downloading/downloaded,
polled every 15s) shows next to each approved request.
This commit is contained in:
Myron Blair
2026-07-26 20:55:22 -05:00
parent 1891fd0b7c
commit 3d2b7a4122
5 changed files with 256 additions and 8 deletions
+71 -6
View File
@@ -3,6 +3,43 @@ import api from "../lib/api";
import { useAuth } from "../lib/auth";
import { toast } from "sonner";
const STATUS_LABEL = {
searching: "Searching…",
queued: "Queued",
downloading: "Downloading",
"partially downloaded": "Partially downloaded",
downloaded: "Downloaded",
completed: "Downloaded",
delay: "Waiting",
unknown: "",
};
function LiveStatus({ requestId }) {
const [status, setStatus] = useState(null);
useEffect(() => {
let cancelled = false;
const poll = async () => {
try {
const { data } = await api.get(`/requests/${requestId}/status`);
if (!cancelled) setStatus(data);
} catch { /* transient — next poll will retry */ }
};
poll();
const t = setInterval(poll, 15000);
return () => { cancelled = true; clearInterval(t); };
}, [requestId]);
if (!status) return null;
const label = STATUS_LABEL[status.state] || status.state;
if (!label) return null;
return (
<span className="text-[10px] uppercase tracking-[0.2em] text-[#8A8A8A]">
{label}{typeof status.progress_pct === "number" ? ` · ${status.progress_pct}%` : ""}
</span>
);
}
export default function Requests() {
const { user } = useAuth();
const [mine, setMine] = useState([]);
@@ -10,6 +47,7 @@ export default function Requests() {
const [title, setTitle] = useState("");
const [year, setYear] = useState("");
const [notes, setNotes] = useState("");
const [approving, setApproving] = useState({});
const load = async () => {
const { data } = await api.get("/requests/mine");
@@ -21,6 +59,19 @@ export default function Requests() {
};
useEffect(() => { load(); }, [user?.is_admin]);
const approve = async (id, contentType) => {
setApproving((p) => ({ ...p, [id]: true }));
try {
const { data } = await api.post(`/requests/${id}/approve`, { content_type: contentType });
toast.success(`Searching for "${data.matched_title || data.title}"`);
load();
} catch (err) {
toast.error(err.response?.data?.detail || "Could not approve request");
} finally {
setApproving((p) => ({ ...p, [id]: false }));
}
};
const submit = async (e) => {
e.preventDefault();
if (!title.trim()) return;
@@ -107,7 +158,8 @@ export default function Requests() {
</div>
<span className={`text-[10px] uppercase tracking-[0.3em] px-2 py-1 ${
r.status === "fulfilled" ? "text-[#86efac]" :
r.status === "rejected" ? "text-[#fca5a5]" : "text-[#fcd34d]"
r.status === "rejected" ? "text-[#fca5a5]" :
r.status === "approved" ? "text-[#93c5fd]" : "text-[#fcd34d]"
}`}>
{r.status}
</span>
@@ -125,13 +177,26 @@ export default function Requests() {
) : (
<div className="border border-[#222]">
{all.map((r) => (
<div key={r.id} className="flex items-center justify-between px-5 py-4 border-b border-[#222] last:border-b-0" data-testid={`admin-request-${r.id}`}>
<div>
<p className="text-white">{r.title} {r.year ? <span className="text-[#8A8A8A]">({r.year})</span> : null}</p>
<p className="text-xs text-[#8A8A8A] mt-1">By {r.user_name || "unknown"} · {r.status}</p>
<div key={r.id} className="flex items-center justify-between gap-4 px-5 py-4 border-b border-[#222] last:border-b-0" data-testid={`admin-request-${r.id}`}>
<div className="min-w-0">
<p className="text-white truncate">
{r.matched_title || r.title} {(r.matched_year || r.year) ? <span className="text-[#8A8A8A]">({r.matched_year || r.year})</span> : null}
</p>
<p className="text-xs text-[#8A8A8A] mt-1">By {r.user_name || "unknown"} · {r.status}{r.content_type ? ` · ${r.content_type}` : ""}</p>
{r.notes && <p className="text-xs text-[#666] mt-1">{r.notes}</p>}
{r.status === "approved" && <div className="mt-1"><LiveStatus requestId={r.id} /></div>}
</div>
<div className="flex gap-2">
<div className="flex gap-2 shrink-0">
{r.status === "pending" && (
<>
<button onClick={() => approve(r.id, "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]}
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>
</>
)}
<button onClick={() => updateStatus(r.id, "fulfilled")}
className="text-[10px] uppercase tracking-[0.2em] border border-[#222] hover:border-[#86efac] hover:text-[#86efac] text-[#8A8A8A] px-3 py-2"
data-testid={`fulfill-${r.id}`}>Fulfilled</button>