diff --git a/backend/models.py b/backend/models.py index c350274..39d8fe2 100644 --- a/backend/models.py +++ b/backend/models.py @@ -191,6 +191,10 @@ class RequestUpdate(BaseModel): status: str +class RequestApprove(BaseModel): + content_type: str # "movie" | "tv" + + class MovieRequest(BaseModel): model_config = ConfigDict(extra="ignore") id: str = Field(default_factory=lambda: str(uuid.uuid4())) @@ -199,7 +203,12 @@ class MovieRequest(BaseModel): title: str year: Optional[int] = None 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) diff --git a/backend/radarr.py b/backend/radarr.py index dcc13e8..928839e 100644 --- a/backend/radarr.py +++ b/backend/radarr.py @@ -33,6 +33,62 @@ def list_movies(base_url: str, api_key: str) -> List[Dict]: 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: base_url = base_url.rstrip("/") try: diff --git a/backend/server.py b/backend/server.py index 38746bf..6b0b723 100644 --- a/backend/server.py +++ b/backend/server.py @@ -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)): diff --git a/backend/sonarr.py b/backend/sonarr.py index f0802fa..2e96ec0 100644 --- a/backend/sonarr.py +++ b/backend/sonarr.py @@ -14,6 +14,59 @@ def _poster(images): 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: try: r = requests.get(f"{base_url.rstrip('/')}/api/v3/system/status", headers=_h(api_key), timeout=10) diff --git a/frontend/src/pages/Requests.jsx b/frontend/src/pages/Requests.jsx index 84c40c1..8e65b07 100644 --- a/frontend/src/pages/Requests.jsx +++ b/frontend/src/pages/Requests.jsx @@ -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 ( + + {label}{typeof status.progress_pct === "number" ? ` · ${status.progress_pct}%` : ""} + + ); +} + 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() { {r.status} @@ -125,13 +177,26 @@ export default function Requests() { ) : (
{all.map((r) => ( -
-
-

{r.title} {r.year ? ({r.year}) : null}

-

By {r.user_name || "unknown"} · {r.status}

+
+
+

+ {r.matched_title || r.title} {(r.matched_year || r.year) ? ({r.matched_year || r.year}) : null} +

+

By {r.user_name || "unknown"} · {r.status}{r.content_type ? ` · ${r.content_type}` : ""}

{r.notes &&

{r.notes}

} + {r.status === "approved" &&
}
-
+
+ {r.status === "pending" && ( + <> + + + + )}