mirror of
https://github.com/myronblair/kino-app
synced 2026-07-31 13:02:29 -05:00
b28a49e8fa
- 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
110 lines
4.9 KiB
Python
110 lines
4.9 KiB
Python
"""Radarr v3 API client. https://radarr.video/docs/api/"""
|
|
import requests
|
|
from typing import List, Dict, Optional
|
|
|
|
|
|
def _h(api_key: str) -> dict:
|
|
return {"X-Api-Key": api_key, "Content-Type": "application/json"}
|
|
|
|
|
|
def list_movies(base_url: str, api_key: str) -> List[Dict]:
|
|
base_url = base_url.rstrip("/")
|
|
r = requests.get(f"{base_url}/api/v3/movie", headers=_h(api_key), timeout=20)
|
|
r.raise_for_status()
|
|
data = r.json() or []
|
|
out = []
|
|
for m in data:
|
|
poster = ""
|
|
for img in m.get("images", []) or []:
|
|
if img.get("coverType") == "poster":
|
|
poster = img.get("remoteUrl") or img.get("url") or ""
|
|
break
|
|
movie_file = m.get("movieFile") or {}
|
|
out.append({
|
|
"radarr_id": m["id"],
|
|
"title": m.get("title") or "",
|
|
"year": m.get("year"),
|
|
"overview": m.get("overview") or "",
|
|
"has_file": bool(m.get("hasFile")),
|
|
"file_path": movie_file.get("path") if movie_file else None,
|
|
"poster_url": poster,
|
|
"tmdb_id": m.get("tmdbId"),
|
|
})
|
|
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.
|
|
If the movie is already in Radarr's library, Radarr rejects the add with a 400
|
|
MovieExistsValidator — in that case, reuse the existing movie and just kick off a search
|
|
instead of surfacing the add as a failure."""
|
|
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)
|
|
if r.status_code == 400 and any(e.get("errorCode") == "MovieExistsValidator" for e in (r.json() or [])):
|
|
existing = requests.get(f"{base_url}/api/v3/movie", params={"tmdbId": tmdb_id}, headers=h, timeout=15).json()
|
|
if not existing:
|
|
raise RuntimeError("Radarr reported movie already exists but it could not be found")
|
|
movie = existing[0]
|
|
requests.put(f"{base_url}/api/v3/movie/{movie['id']}", json={**movie, "monitored": True}, headers=h, timeout=15)
|
|
requests.post(f"{base_url}/api/v3/command", json={"name": "MoviesSearch", "movieIds": [movie["id"]]}, headers=h, timeout=15)
|
|
return {"radarr_id": movie["id"], "title": movie.get("title"), "year": movie.get("year")}
|
|
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:
|
|
r = requests.get(f"{base_url}/api/v3/system/status", headers=_h(api_key), timeout=10)
|
|
return r.ok
|
|
except Exception:
|
|
return False
|