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 = "") {
${escHtml(m.title||"Untitled")}
-
${wBtn}${radarrBtn}${radarr4kBtn}${overseerrBtn}${jellyseerrBtn}${ignoreBtn}
+
${wBtn}${radarrBtn}${radarr4kBtn}${overseerrBtn}${jellyseerrBtn}${seerrBtn}${ignoreBtn}
` } @@ -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) {
${escHtml(m.title||"Untitled")}
-
${wBtn}${radarrBtn}${radarr4kBtn}${overseerrBtn}${jellyseerrBtn}${ignoreBtn}
+
${wBtn}${radarrBtn}${radarr4kBtn}${overseerrBtn}${jellyseerrBtn}${seerrBtn2}${ignoreBtn}
` } 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(){
- ${sec('Overseerr (optional)', svcBadge('OVERSEERR','#F59E0B','#000'))} + ${sec('Seerr (optional)', svcBadge('SEERR','#14b8a6'))} + ${check("cfg_seerr_enabled", "Enabled", seerr.SEERR_ENABLED)} + ${field("cfg_seerr_url", "Seerr URL", seerr.SEERR_URL ||"")} + ${field("cfg_seerr_key", "API Key", seerr.SEERR_API_KEY||"", "secret")} + ${hint("Unified successor to Overseerr & Jellyseerr. API key found in Seerr → Settings → General.")} +
+ +
+ ${sec('Overseerr (legacy)', svcBadge('OVERSEERR','#F59E0B','#000'))} ${check("cfg_ovs_enabled", "Enabled", ovs.OVERSEERR_ENABLED)} ${field("cfg_ovs_url", "Overseerr URL", ovs.OVERSEERR_URL ||"")} ${field("cfg_ovs_key", "API Key", ovs.OVERSEERR_API_KEY||"", "secret")} - ${hint("Point to your Overseerr instance. API key found in Overseerr → Settings → General.")} + ${hint("⚠️ Legacy — no longer maintained upstream. Consider migrating to Seerr.")}
- ${sec('Jellyseerr (optional)', svcBadge('JELLYSEERR','#29B4E8'))} + ${sec('Jellyseerr (legacy)', svcBadge('JELLYSEERR','#29B4E8'))} ${check("cfg_jss_enabled", "Enabled", jss.JELLYSEERR_ENABLED)} ${field("cfg_jss_url", "Jellyseerr URL", jss.JELLYSEERR_URL ||"")} ${field("cfg_jss_key", "API Key", jss.JELLYSEERR_API_KEY||"", "secret")} - ${hint("Same API format as Overseerr. API key found in Jellyseerr → Settings → General.")} + ${hint("⚠️ Legacy — no longer maintained upstream. Consider migrating to Seerr.")}
@@ -616,6 +625,11 @@ async function saveConfig(){ RADARR_4K_QUALITY_PROFILE_ID:vi("cfg_r4k_quality"), RADARR_4K_SEARCH_ON_ADD: vc("cfg_r4k_search"), }, + SEERR:{ + SEERR_ENABLED: vc("cfg_seerr_enabled"), + SEERR_URL: v("cfg_seerr_url"), + SEERR_API_KEY: v("cfg_seerr_key"), + }, OVERSEERR:{ OVERSEERR_ENABLED: vc("cfg_ovs_enabled"), OVERSEERR_URL: v("cfg_ovs_url"), diff --git a/static/js/modal.js b/static/js/modal.js index 8e7f2e2..9b131ba 100644 --- a/static/js/modal.js +++ b/static/js/modal.js @@ -117,6 +117,9 @@ async function openMovieModal(tmdb, fallback = {}) { const jellyseerrBtn = CONFIG?.JELLYSEERR?.JELLYSEERR_ENABLED ? `` : "" + const seerrBtn = CONFIG?.SEERR?.SEERR_ENABLED + ? `` + : "" const trailerBtn = md.trailer_key ? `` : "" @@ -150,6 +153,7 @@ async function openMovieModal(tmdb, fallback = {}) { ${radarr4kBtn} ${overseerrBtn} ${jellyseerrBtn} + ${seerrBtn} ${trailerBtn} { const logos = providers.slice(0, 6).map(p => p.logo - ? `${escHtml(p.name)}` + ? ` + ${escHtml(p.name)} + ` : `${escHtml(p.name)}` ).join("") return `${escHtml(TYPE_LABEL[type]||type)}:${logos}` diff --git a/static/js/mutations.js b/static/js/mutations.js index 265f2ed..4e8e2cc 100644 --- a/static/js/mutations.js +++ b/static/js/mutations.js @@ -76,9 +76,11 @@ function updateBatchBar() { bar.classList.add("visible") const ovsBtn = document.getElementById("batchOverseerr") const jssBtn = document.getElementById("batchJellyseerr") + const srrBtn = document.getElementById("batchSeerr") const wlBtn = document.getElementById("batchWishlist") if (ovsBtn) ovsBtn.style.display = CONFIG?.OVERSEERR?.OVERSEERR_ENABLED ? "" : "none" if (jssBtn) jssBtn.style.display = CONFIG?.JELLYSEERR?.JELLYSEERR_ENABLED ? "" : "none" + if (srrBtn) srrBtn.style.display = CONFIG?.SEERR?.SEERR_ENABLED ? "" : "none" // On Wishlist tab: swap "Add to Wishlist" → "Remove from Wishlist" if (wlBtn) { if (ACTIVE_TAB === "wishlist") { @@ -208,6 +210,43 @@ async function batchAddToJellyseerr() { clearSelection() } +async function batchAddToSeerr() { + if (!CONFIG?.SEERR?.SEERR_ENABLED) { toast("Seerr not enabled", "error"); return } + let ok = 0, fail = 0 + for (const [tmdb] of _selected) { + const res = await api("/api/seerr/add", "POST", { tmdb }) + res.ok ? ok++ : fail++ + } + toast(`Seerr: ${ok} requested${fail ? `, ${fail} failed` : ""}`, ok ? "success" : "error") + clearSelection() +} + +/* ── Radarr library (Search vs Add) ────────────────────────── */ + +/** Set of TMDB IDs currently in Radarr — null while still loading. */ +let _radarrLibTmdbIds = null + +async function _fetchRadarrLibrary() { + if (!CONFIG?.RADARR?.RADARR_ENABLED) { _radarrLibTmdbIds = new Set(); return } + try { + const res = await api("/api/radarr/library") + _radarrLibTmdbIds = res.ok ? new Set(res.tmdb_ids) : new Set() + } catch { _radarrLibTmdbIds = new Set() } +} + +async function searchInRadarr(tmdb, title, btn) { + btn.disabled = true; btn.textContent = "…" + const res = await api("/api/radarr/search", "POST", { tmdb, title }) + if (res.ok) { + btn.textContent = "✓ Searching" + btn.style.color = "var(--green)" + toast(`${title} — search triggered in Radarr`, "success") + } else { + btn.textContent = "⟳ Search"; btn.disabled = false + toast(`Radarr search: ${res.error || "unknown error"}`, "error") + } +} + /* ── In-memory DATA helpers ─────────────────────────────────── */ /** @@ -506,6 +545,7 @@ function _makeSeerrStore(key) { } const overseerrRequested = _makeSeerrStore("cp-overseerr-requested") const jellyseerrRequested = _makeSeerrStore("cp-jellyseerr-requested") +const seerrRequested = _makeSeerrStore("cp-seerr-requested") async function addToOverseerr(tmdb, title, btn){ btn.disabled = true; btn.textContent = "…" @@ -539,6 +579,22 @@ async function addToJellyseerr(tmdb, title, btn){ } } +async function addToSeerr(tmdb, title, btn){ + btn.disabled = true; btn.textContent = "…" + const res = await api("/api/seerr/add","POST",{tmdb,title}) + if (res.ok){ + seerrRequested.add(tmdb) + btn.textContent = "✓ Requested" + btn.className = "btn-sm" + btn.style.color = "var(--green)" + btn.disabled = true + toast(`${title} → Seerr`,"success") + } else { + btn.textContent = "✗"; btn.disabled = false + toast(`Seerr: ${res.error||"unknown error"}`,"error") + } +} + /* ── Ignore-group actions ───────────────────────────────────── */ async function ignoreFranchise(name, btn){