diff --git a/app/config.py b/app/config.py index fc146b6..1f7698e 100644 --- a/app/config.py +++ b/app/config.py @@ -96,6 +96,11 @@ "JELLYSEERR_URL": "", "JELLYSEERR_API_KEY": "", }, + "SEERR": { + "SEERR_ENABLED": False, + "SEERR_URL": "", + "SEERR_API_KEY": "", + }, "WEBHOOK": { "WEBHOOK_ENABLED": False, "WEBHOOK_SECRET": "", diff --git a/app/routers/integrations.py b/app/routers/integrations.py index 5f522a7..760a50c 100644 --- a/app/routers/integrations.py +++ b/app/routers/integrations.py @@ -1,10 +1,14 @@ """ -Radarr / Overseerr / Jellyseerr / Webhook / Watchtower routes. +Radarr / Overseerr / Jellyseerr / Seerr / Webhook / Watchtower routes. GET /api/radarr/profiles + GET /api/radarr/rootfolders + GET /api/radarr/library POST /api/radarr/add + POST /api/radarr/search GET /api/radarr/status POST /api/overseerr/add POST /api/jellyseerr/add + POST /api/seerr/add POST /api/webhook POST /api/watchtower/update """ @@ -68,7 +72,14 @@ def _radarr_post( return {"ok": True} -_radarr_status_cache: dict = {"data": None, "ts": 0.0} +_radarr_status_cache: dict = {"data": None, "ts": 0.0} +_radarr_library_cache: dict = {"data": None, "ts": 0.0} +_RADARR_LIB_TTL = 300 # 5 minutes + + +def _invalidate_radarr_library_cache(): + """Call after a successful Radarr add so the library set refreshes promptly.""" + _radarr_library_cache["ts"] = 0.0 # -------------------------------------------------- @@ -176,7 +187,82 @@ def radarr_add(payload: dict = Body(...), instance: str = Query(default="primary section = cfg.get("RADARR", {}) if not section.get("RADARR_ENABLED"): return {"ok": False, "error": "Radarr disabled"} - return _radarr_post(section, "RADARR", tmdb_id, title, quality_override, root_override) + result = _radarr_post(section, "RADARR", tmdb_id, title, quality_override, root_override) + if result.get("ok"): + _invalidate_radarr_library_cache() + return result + + +@router.get("/api/radarr/library") +def radarr_library(): + """Return the set of TMDB IDs for all movies in Radarr (primary). Cached 5 min.""" + now = time.time() + if _radarr_library_cache["data"] and now - _radarr_library_cache["ts"] < _RADARR_LIB_TTL: + return _radarr_library_cache["data"] + + cfg = load_config() + radarr = cfg.get("RADARR", {}) + if not radarr.get("RADARR_ENABLED"): + return {"ok": True, "tmdb_ids": []} + + url = str(radarr.get("RADARR_URL", "")).rstrip("/") + key = str(radarr.get("RADARR_API_KEY", "")).strip() + if not url or urlparse(url).scheme not in ("http", "https"): + return {"ok": True, "tmdb_ids": []} + + try: + r = requests.get(f"{url}/api/v3/movie", headers={"X-Api-Key": key}, timeout=20) + if r.status_code == 200: + tmdb_ids = [int(m["tmdbId"]) for m in r.json() if m.get("tmdbId")] + result = {"ok": True, "tmdb_ids": tmdb_ids} + else: + result = {"ok": True, "tmdb_ids": []} + except requests.exceptions.RequestException as e: + log.warning(f"Radarr library fetch failed: {e}") + result = {"ok": True, "tmdb_ids": []} + + _radarr_library_cache.update({"data": result, "ts": now}) + return result + + +@router.post("/api/radarr/search") +def radarr_search(payload: dict = Body(...)): + """Trigger a MoviesSearch command in Radarr for an already-monitored movie.""" + cfg = load_config() + radarr = cfg.get("RADARR", {}) + if not radarr.get("RADARR_ENABLED"): + return {"ok": False, "error": "Radarr not enabled"} + + tmdb_id = _parse_tmdb_id(payload.get("tmdb")) + if tmdb_id is None: + return {"ok": False, "error": "Invalid TMDB ID"} + + url = str(radarr.get("RADARR_URL", "")).rstrip("/") + key = str(radarr.get("RADARR_API_KEY", "")).strip() + headers = {"X-Api-Key": key} + + try: + # Resolve Radarr's internal movie ID from TMDB ID + r = requests.get( + f"{url}/api/v3/movie", params={"tmdbId": tmdb_id}, + headers=headers, timeout=10, + ) + movies = r.json() if r.status_code == 200 else [] + if not movies: + return {"ok": False, "error": "Movie not found in Radarr"} + radarr_id = movies[0]["id"] + + # Kick off a search + r2 = requests.post( + f"{url}/api/v3/command", + json={"name": "MoviesSearch", "movieIds": [radarr_id]}, + headers=headers, timeout=10, + ) + if r2.status_code not in (200, 201): + return {"ok": False, "error": r2.text} + return {"ok": True} + except requests.exceptions.RequestException as e: + return {"ok": False, "error": str(e)} @router.get("/api/radarr/status") @@ -288,6 +374,35 @@ def jellyseerr_add(payload: dict = Body(...)): return {"ok": True} +# -------------------------------------------------- +# Seerr (unified Overseerr + Jellyseerr successor) +# -------------------------------------------------- + +@router.post("/api/seerr/add") +def seerr_add(payload: dict = Body(...)): + cfg = load_config().get("SEERR", {}) + if not cfg.get("SEERR_ENABLED"): + return {"ok": False, "error": "Seerr disabled"} + tmdb_id = _parse_tmdb_id(payload.get("tmdb")) + if tmdb_id is None: + return {"ok": False, "error": "Invalid TMDB ID"} + api_key = cfg.get("SEERR_API_KEY", "").strip() + if not api_key: + return {"ok": False, "error": "Seerr API key not configured"} + headers = {"X-Api-Key": api_key, "Content-Type": "application/json"} + try: + r = requests.post( + f"{cfg['SEERR_URL'].rstrip('/')}/api/v1/request", + json={"mediaType": "movie", "mediaId": tmdb_id}, + headers=headers, timeout=20, + ) + except requests.exceptions.RequestException as e: + return {"ok": False, "error": str(e)} + if r.status_code not in (200, 201): + return {"ok": False, "error": r.text} + return {"ok": True} + + # -------------------------------------------------- # Webhook # -------------------------------------------------- diff --git a/e2e/tests/streaming.spec.js b/e2e/tests/streaming.spec.js index c001207..3172d40 100644 --- a/e2e/tests/streaming.spec.js +++ b/e2e/tests/streaming.spec.js @@ -151,7 +151,8 @@ test.describe('Streaming availability in movie modal', () => { const section = page.locator('#streamingSection') await expect(section).not.toBeEmpty({ timeout: 5000 }) - const link = section.locator('a[href*="justwatch.com"]') + // Provider logos are also wrapped in JustWatch links — target the text link specifically + const link = section.locator('a[href*="justwatch.com"]:has-text("JustWatch")') await expect(link).toBeVisible() await expect(link).toHaveAttribute('target', '_blank') }) diff --git a/static/index.html b/static/index.html index f9f5f26..dd2ef97 100644 --- a/static/index.html +++ b/static/index.html @@ -902,7 +902,7 @@ } #clipboardBtn:hover { background: var(--border); color: var(--gold); } - /* Overseerr / Jellyseerr / Trailer buttons */ + /* Overseerr / Jellyseerr / Seerr / Trailer buttons */ .btn-overseerr { background: rgba(59,130,246,.12); border-color: rgba(59,130,246,.35); color: #60a5fa; @@ -913,6 +913,11 @@ color: #c084fc; } .btn-jellyseerr:hover { background: rgba(168,85,247,.22); } + .btn-seerr { + background: rgba(20,184,166,.12); border-color: rgba(20,184,166,.35); + color: #2dd4bf; + } + .btn-seerr:hover { background: rgba(20,184,166,.22); } .btn-trailer { background: rgba(239,68,68,.12); border-color: rgba(239,68,68,.35); color: #f87171; @@ -1300,6 +1305,7 @@ + diff --git a/static/js/app.js b/static/js/app.js index 4477e88..c124813 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -366,8 +366,10 @@ async function boot(){ } } catch(e) {} - if (CONFIGURED) await loadResults() - else { setStatus("Setup required"); render() } + if (CONFIGURED) { + await loadResults() + _fetchRadarrLibrary() // background fetch — no await, updates cards on next render + } else { setStatus("Setup required"); render() } } async function logout() { diff --git a/static/js/cards.js b/static/js/cards.js index e59ba24..c2f7b0e 100644 --- a/static/js/cards.js +++ b/static/js/cards.js @@ -9,8 +9,11 @@ function posterCard(m, extraTag = "") { const tmdb = m.tmdb const safeName = (m.title || "").replace(/'/g, "\\'").replace(/"/g, """) + const _inRadarr = _radarrLibTmdbIds?.has(tmdb) const radarrBtn = CONFIG?.RADARR?.RADARR_ENABLED - ? `` + ? (_inRadarr + ? `` + : ``) : "" const radarr4kBtn = CONFIG?.RADARR_4K?.RADARR_4K_ENABLED ? `` @@ -25,6 +28,11 @@ function posterCard(m, extraTag = "") { ? `` : ``) : "" + const seerrBtn = CONFIG?.SEERR?.SEERR_ENABLED + ? (seerrRequested?.has(tmdb) + ? `` + : ``) + : "" // Encode movie data on the button so add/remove toggles can update DATA without extra API calls const movieData = JSON.stringify({tmdb:m.tmdb,title:m.title,year:m.year,poster:m.poster,rating:m.rating,wishlist:m.wishlist}).replace(/"/g,'"') @@ -61,7 +69,7 @@ function posterCard(m, extraTag = "") {
` } @@ -173,8 +181,11 @@ function suggestionCard(m) { box-shadow:0 1px 4px rgba(0,0,0,.5)">⚡${score}` : "" + const _inRadarr2 = _radarrLibTmdbIds?.has(tmdb) const radarrBtn = CONFIG?.RADARR?.RADARR_ENABLED - ? `` + ? (_inRadarr2 + ? `` + : ``) : "" const radarr4kBtn = CONFIG?.RADARR_4K?.RADARR_4K_ENABLED ? `` @@ -189,6 +200,11 @@ function suggestionCard(m) { ? `` : ``) : "" + const seerrBtn2 = CONFIG?.SEERR?.SEERR_ENABLED + ? (seerrRequested?.has(tmdb) + ? `` + : ``) + : "" const movieData = JSON.stringify({tmdb:m.tmdb,title:m.title,year:m.year,poster:m.poster,rating:m.rating,wishlist:m.wishlist}).replace(/"/g,'"') const wBtn = m.wishlist @@ -224,7 +240,7 @@ function suggestionCard(m) { ` } diff --git a/static/js/config.js b/static/js/config.js index 9f8a4a5..e60ea02 100644 --- a/static/js/config.js +++ b/static/js/config.js @@ -307,6 +307,7 @@ function renderConfig(){ const tmdb = cfg.TMDB ||{} const stm = cfg.STREAMING ||{} const radarr= cfg.RADARR ||{} + const seerr = cfg.SEERR ||{} const r4k = cfg.RADARR_4K ||{} const cls = cfg.CLASSICS ||{} const act = cfg.ACTOR_HITS ||{} @@ -496,19 +497,27 @@ function renderConfig(){