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
+66 -1
View File
@@ -21,7 +21,7 @@ from models import (
Movie, MovieCreate, MovieUpdate,
Subtitle,
WatchlistItem, ProgressUpsert,
RequestCreate, RequestUpdate, MovieRequest,
RequestCreate, RequestUpdate, RequestApprove, MovieRequest,
AppSettings, AppSettingsPublic,
TMDBSearchResult, RadarrMovieDTO,
TranscodeJob, QueuePauseToggle,
@@ -919,6 +919,71 @@ async def update_request(request_id: str, payload: RequestUpdate, user: dict = D
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) ============
@api.get("/settings", response_model=AppSettingsPublic)
async def read_settings(user: dict = Depends(require_admin)):