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
+10 -1
View File
@@ -191,6 +191,10 @@ class RequestUpdate(BaseModel):
status: str status: str
class RequestApprove(BaseModel):
content_type: str # "movie" | "tv"
class MovieRequest(BaseModel): class MovieRequest(BaseModel):
model_config = ConfigDict(extra="ignore") model_config = ConfigDict(extra="ignore")
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()))
@@ -199,7 +203,12 @@ class MovieRequest(BaseModel):
title: str title: str
year: Optional[int] = None year: Optional[int] = None
notes: str = "" notes: str = ""
status: str = "pending" status: str = "pending" # pending | approved | fulfilled | rejected
content_type: Optional[str] = None # movie | tv, set once approved
radarr_id: Optional[int] = None
sonarr_id: Optional[int] = None
matched_title: Optional[str] = None
matched_year: Optional[int] = None
created_at: str = Field(default_factory=_now_iso) created_at: str = Field(default_factory=_now_iso)
+56
View File
@@ -33,6 +33,62 @@ def list_movies(base_url: str, api_key: str) -> List[Dict]:
return out return out
def search_movie(base_url: str, api_key: str, term: str) -> List[Dict]:
"""Radarr's own title lookup (proxies TMDB) — used to resolve a free-text request to a real movie."""
base_url = base_url.rstrip("/")
r = requests.get(f"{base_url}/api/v3/movie/lookup", params={"term": term}, headers=_h(api_key), timeout=20)
r.raise_for_status()
out = []
for m in r.json() or []:
poster = ""
for img in m.get("images", []) or []:
if img.get("coverType") == "poster":
poster = img.get("remoteUrl") or img.get("url") or ""
break
out.append({
"tmdb_id": m.get("tmdbId"), "title": m.get("title") or "",
"year": m.get("year"), "overview": m.get("overview") or "", "poster_url": poster,
})
return out
def add_movie(base_url: str, api_key: str, tmdb_id: int, title: str, year: Optional[int]) -> Dict:
"""Adds a movie by tmdb_id with monitoring + an immediate search enabled, using the first
configured quality profile and root folder — same defaults Radarr's own UI would pre-fill."""
base_url = base_url.rstrip("/")
h = _h(api_key)
profiles = requests.get(f"{base_url}/api/v3/qualityprofile", headers=h, timeout=15).json()
roots = requests.get(f"{base_url}/api/v3/rootfolder", headers=h, timeout=15).json()
if not profiles or not roots:
raise RuntimeError("Radarr has no quality profile or root folder configured")
payload = {
"title": title, "tmdbId": tmdb_id, "year": year or 0,
"qualityProfileId": profiles[0]["id"], "rootFolderPath": roots[0]["path"],
"monitored": True, "addOptions": {"searchForMovie": True},
}
r = requests.post(f"{base_url}/api/v3/movie", json=payload, headers=h, timeout=20)
r.raise_for_status()
added = r.json()
return {"radarr_id": added["id"], "title": added.get("title"), "year": added.get("year")}
def movie_status(base_url: str, api_key: str, radarr_id: int) -> Dict:
base_url = base_url.rstrip("/")
h = _h(api_key)
m = requests.get(f"{base_url}/api/v3/movie/{radarr_id}", headers=h, timeout=15).json()
if m.get("hasFile"):
return {"state": "downloaded"}
q = requests.get(f"{base_url}/api/v3/queue", params={"movieIds": radarr_id}, headers=h, timeout=15).json()
items = q.get("records") if isinstance(q, dict) else q
if items:
it = items[0]
size = it.get("size") or 0
sizeleft = it.get("sizeleft") or 0
pct = round(100 * (1 - (sizeleft / size)), 1) if size else 0
return {"state": (it.get("status") or "queued").lower(), "progress_pct": pct}
return {"state": "searching"}
def test_connection(base_url: str, api_key: str) -> bool: def test_connection(base_url: str, api_key: str) -> bool:
base_url = base_url.rstrip("/") base_url = base_url.rstrip("/")
try: try:
+66 -1
View File
@@ -21,7 +21,7 @@ from models import (
Movie, MovieCreate, MovieUpdate, Movie, MovieCreate, MovieUpdate,
Subtitle, Subtitle,
WatchlistItem, ProgressUpsert, WatchlistItem, ProgressUpsert,
RequestCreate, RequestUpdate, MovieRequest, RequestCreate, RequestUpdate, RequestApprove, MovieRequest,
AppSettings, AppSettingsPublic, AppSettings, AppSettingsPublic,
TMDBSearchResult, RadarrMovieDTO, TMDBSearchResult, RadarrMovieDTO,
TranscodeJob, QueuePauseToggle, TranscodeJob, QueuePauseToggle,
@@ -919,6 +919,71 @@ async def update_request(request_id: str, payload: RequestUpdate, user: dict = D
return res return res
@api.post("/requests/{request_id}/approve", response_model=MovieRequest)
async def approve_request(request_id: str, payload: RequestApprove, user: dict = Depends(require_admin)):
"""Looks up the request's title in Radarr/Sonarr, adds the best match with an immediate
search enabled, and marks the request approved. Content actually lands via the normal
Radarr/Sonarr download pipeline this only kicks that off."""
req = await db.requests.find_one({"id": request_id}, {"_id": 0})
if not req: raise HTTPException(status_code=404, detail="Request not found")
if payload.content_type not in ("movie", "tv"):
raise HTTPException(status_code=400, detail="content_type must be movie or tv")
s = await get_settings()
if payload.content_type == "movie":
if not s.get("radarr_url") or not s.get("radarr_api_key"):
raise HTTPException(status_code=400, detail="Radarr not configured")
try:
candidates = await asyncio.to_thread(radarr_client.search_movie, s["radarr_url"], s["radarr_api_key"], req["title"])
except Exception as e:
raise HTTPException(status_code=502, detail=f"Radarr lookup error: {e}")
match = next((c for c in candidates if req.get("year") and c.get("year") == req["year"]), candidates[0] if candidates else None)
if not match:
raise HTTPException(status_code=404, detail="No match found in Radarr")
try:
added = await asyncio.to_thread(radarr_client.add_movie, s["radarr_url"], s["radarr_api_key"], match["tmdb_id"], match["title"], match.get("year"))
except Exception as e:
raise HTTPException(status_code=502, detail=f"Radarr add error: {e}")
updates = {"status": "approved", "content_type": "movie", "radarr_id": added["radarr_id"],
"matched_title": added.get("title"), "matched_year": added.get("year")}
else:
if not s.get("sonarr_url") or not s.get("sonarr_api_key"):
raise HTTPException(status_code=400, detail="Sonarr not configured")
try:
candidates = await asyncio.to_thread(sonarr_client.search_series, s["sonarr_url"], s["sonarr_api_key"], req["title"])
except Exception as e:
raise HTTPException(status_code=502, detail=f"Sonarr lookup error: {e}")
match = next((c for c in candidates if req.get("year") and c.get("year") == req["year"]), candidates[0] if candidates else None)
if not match:
raise HTTPException(status_code=404, detail="No match found in Sonarr")
try:
added = await asyncio.to_thread(sonarr_client.add_series, s["sonarr_url"], s["sonarr_api_key"], match["tvdb_id"], match["title"], match.get("year"))
except Exception as e:
raise HTTPException(status_code=502, detail=f"Sonarr add error: {e}")
updates = {"status": "approved", "content_type": "tv", "sonarr_id": added["sonarr_id"],
"matched_title": added.get("title"), "matched_year": added.get("year")}
res = await db.requests.find_one_and_update(
{"id": request_id}, {"$set": updates}, projection={"_id": 0}, return_document=True,
)
return res
@api.get("/requests/{request_id}/status")
async def request_download_status(request_id: str, user: dict = Depends(require_admin)):
req = await db.requests.find_one({"id": request_id}, {"_id": 0})
if not req: raise HTTPException(status_code=404, detail="Request not found")
s = await get_settings()
try:
if req.get("radarr_id") and s.get("radarr_url") and s.get("radarr_api_key"):
return await asyncio.to_thread(radarr_client.movie_status, s["radarr_url"], s["radarr_api_key"], req["radarr_id"])
if req.get("sonarr_id") and s.get("sonarr_url") and s.get("sonarr_api_key"):
return await asyncio.to_thread(sonarr_client.series_status, s["sonarr_url"], s["sonarr_api_key"], req["sonarr_id"])
except Exception as e:
return {"state": "unknown", "error": str(e)}
return {"state": req.get("status", "pending")}
# ============ SETTINGS (admin) ============ # ============ SETTINGS (admin) ============
@api.get("/settings", response_model=AppSettingsPublic) @api.get("/settings", response_model=AppSettingsPublic)
async def read_settings(user: dict = Depends(require_admin)): async def read_settings(user: dict = Depends(require_admin)):
+53
View File
@@ -14,6 +14,59 @@ def _poster(images):
return "" return ""
def search_series(base_url: str, api_key: str, term: str) -> List[Dict]:
"""Sonarr's own title lookup (proxies TVDB) — used to resolve a free-text request to a real series."""
r = requests.get(f"{base_url.rstrip('/')}/api/v3/series/lookup", params={"term": term}, headers=_h(api_key), timeout=20)
r.raise_for_status()
out = []
for s in r.json() or []:
out.append({
"tvdb_id": s.get("tvdbId"), "tmdb_id": s.get("tmdbId"), "title": s.get("title") or "",
"year": s.get("year"), "overview": s.get("overview") or "", "poster_url": _poster(s.get("images")),
})
return out
def add_series(base_url: str, api_key: str, tvdb_id: int, title: str, year) -> Dict:
"""Adds a series by tvdb_id with monitoring + an immediate missing-episode search enabled."""
base_url = base_url.rstrip("/")
h = _h(api_key)
profiles = requests.get(f"{base_url}/api/v3/qualityprofile", headers=h, timeout=15).json()
roots = requests.get(f"{base_url}/api/v3/rootfolder", headers=h, timeout=15).json()
if not profiles or not roots:
raise RuntimeError("Sonarr has no quality profile or root folder configured")
payload = {
"title": title, "tvdbId": tvdb_id, "year": year or 0,
"qualityProfileId": profiles[0]["id"], "rootFolderPath": roots[0]["path"],
"seasonFolder": True, "monitored": True,
"addOptions": {"searchForMissingEpisodes": True, "monitor": "all"},
}
r = requests.post(f"{base_url}/api/v3/series", json=payload, headers=h, timeout=20)
r.raise_for_status()
added = r.json()
return {"sonarr_id": added["id"], "title": added.get("title"), "year": added.get("year")}
def series_status(base_url: str, api_key: str, sonarr_id: int) -> Dict:
base_url = base_url.rstrip("/")
h = _h(api_key)
s = requests.get(f"{base_url}/api/v3/series/{sonarr_id}", headers=h, timeout=15).json()
stats = s.get("statistics") or {}
if stats.get("episodeFileCount", 0) > 0 and stats.get("episodeFileCount") >= stats.get("episodeCount", 1):
return {"state": "downloaded"}
q = requests.get(f"{base_url}/api/v3/queue", params={"seriesId": sonarr_id, "includeSeries": False}, headers=h, timeout=15).json()
items = q.get("records") if isinstance(q, dict) else q
if items:
it = items[0]
size = it.get("size") or 0
sizeleft = it.get("sizeleft") or 0
pct = round(100 * (1 - (sizeleft / size)), 1) if size else 0
return {"state": (it.get("status") or "queued").lower(), "progress_pct": pct}
if stats.get("episodeFileCount", 0) > 0:
return {"state": "partially downloaded"}
return {"state": "searching"}
def test_connection(base_url: str, api_key: str) -> bool: def test_connection(base_url: str, api_key: str) -> bool:
try: try:
r = requests.get(f"{base_url.rstrip('/')}/api/v3/system/status", headers=_h(api_key), timeout=10) r = requests.get(f"{base_url.rstrip('/')}/api/v3/system/status", headers=_h(api_key), timeout=10)
+71 -6
View File
@@ -3,6 +3,43 @@ import api from "../lib/api";
import { useAuth } from "../lib/auth"; import { useAuth } from "../lib/auth";
import { toast } from "sonner"; 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() { export default function Requests() {
const { user } = useAuth(); const { user } = useAuth();
const [mine, setMine] = useState([]); const [mine, setMine] = useState([]);
@@ -10,6 +47,7 @@ export default function Requests() {
const [title, setTitle] = useState(""); const [title, setTitle] = useState("");
const [year, setYear] = useState(""); const [year, setYear] = useState("");
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [approving, setApproving] = useState({});
const load = async () => { const load = async () => {
const { data } = await api.get("/requests/mine"); const { data } = await api.get("/requests/mine");
@@ -21,6 +59,19 @@ export default function Requests() {
}; };
useEffect(() => { load(); }, [user?.is_admin]); 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) => { const submit = async (e) => {
e.preventDefault(); e.preventDefault();
if (!title.trim()) return; if (!title.trim()) return;
@@ -107,7 +158,8 @@ export default function Requests() {
</div> </div>
<span className={`text-[10px] uppercase tracking-[0.3em] px-2 py-1 ${ <span className={`text-[10px] uppercase tracking-[0.3em] px-2 py-1 ${
r.status === "fulfilled" ? "text-[#86efac]" : 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} {r.status}
</span> </span>
@@ -125,13 +177,26 @@ export default function Requests() {
) : ( ) : (
<div className="border border-[#222]"> <div className="border border-[#222]">
{all.map((r) => ( {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 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> <div className="min-w-0">
<p className="text-white">{r.title} {r.year ? <span className="text-[#8A8A8A]">({r.year})</span> : null}</p> <p className="text-white truncate">
<p className="text-xs text-[#8A8A8A] mt-1">By {r.user_name || "unknown"} · {r.status}</p> {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.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>
<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")} <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" 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> data-testid={`fulfill-${r.id}`}>Fulfilled</button>