Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@
"JELLYSEERR_URL": "",
"JELLYSEERR_API_KEY": "",
},
"SEERR": {
"SEERR_ENABLED": False,
"SEERR_URL": "",
"SEERR_API_KEY": "",
},
"WEBHOOK": {
"WEBHOOK_ENABLED": False,
"WEBHOOK_SECRET": "",
Expand Down
121 changes: 118 additions & 3 deletions app/routers/integrations.py
Original file line number Diff line number Diff line change
@@ -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
"""
Expand Down Expand Up @@ -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


# --------------------------------------------------
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
# --------------------------------------------------
Expand Down
3 changes: 2 additions & 1 deletion e2e/tests/streaming.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
})
Expand Down
8 changes: 7 additions & 1 deletion static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -1300,6 +1305,7 @@
<button class="btn-sm btn-radarr" onclick="batchAddToRadarr()" id="batchRadarr">+ Radarr</button>
<button class="btn-sm btn-overseerr" onclick="batchAddToOverseerr()" id="batchOverseerr" style="display:none">→ Overseerr</button>
<button class="btn-sm btn-jellyseerr" onclick="batchAddToJellyseerr()" id="batchJellyseerr" style="display:none">→ Jellyseerr</button>
<button class="btn-sm btn-seerr" onclick="batchAddToSeerr()" id="batchSeerr" style="display:none">→ Seerr</button>
<button class="btn-sm btn-wishlist" onclick="batchWishlistAction()" id="batchWishlist">☆ Wishlist</button>
<button class="btn-sm btn-ignore" onclick="batchIgnoreMovies()" id="batchIgnore">🚫 Ignore all</button>
<button class="btn-sm" onclick="clearSelection()" style="background:var(--bg2);color:var(--text2)">✕ Clear</button>
Expand Down
6 changes: 4 additions & 2 deletions static/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
24 changes: 20 additions & 4 deletions static/js/cards.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ function posterCard(m, extraTag = "") {
const tmdb = m.tmdb
const safeName = (m.title || "").replace(/'/g, "\\'").replace(/"/g, "&quot;")

const _inRadarr = _radarrLibTmdbIds?.has(tmdb)
const radarrBtn = CONFIG?.RADARR?.RADARR_ENABLED
? `<button class="btn-sm btn-radarr" onclick="event.stopPropagation();addToRadarr(${tmdb},'${safeName}',this)">+ Radarr</button>`
? (_inRadarr
? `<button class="btn-sm btn-radarr" onclick="event.stopPropagation();searchInRadarr(${tmdb},'${safeName}',this)">⟳ Search</button>`
: `<button class="btn-sm btn-radarr" onclick="event.stopPropagation();addToRadarr(${tmdb},'${safeName}',this)">+ Radarr</button>`)
: ""
const radarr4kBtn = CONFIG?.RADARR_4K?.RADARR_4K_ENABLED
? `<button class="btn-sm btn-radarr" style="opacity:.75" onclick="event.stopPropagation();addToRadarr4k(${tmdb},'${safeName}',this)">+ 4K</button>`
Expand All @@ -25,6 +28,11 @@ function posterCard(m, extraTag = "") {
? `<button class="btn-sm" style="color:var(--green)" disabled>✓ Requested</button>`
: `<button class="btn-sm btn-jellyseerr" onclick="event.stopPropagation();addToJellyseerr(${tmdb},'${safeName}',this)">→ JS</button>`)
: ""
const seerrBtn = CONFIG?.SEERR?.SEERR_ENABLED
? (seerrRequested?.has(tmdb)
? `<button class="btn-sm" style="color:var(--green)" disabled>✓ Requested</button>`
: `<button class="btn-sm btn-seerr" onclick="event.stopPropagation();addToSeerr(${tmdb},'${safeName}',this)">→ Seerr</button>`)
: ""

// 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,'&quot;')
Expand Down Expand Up @@ -61,7 +69,7 @@ function posterCard(m, extraTag = "") {
</div>
<div class="pc-overlay">
<div class="pc-overlay-title">${escHtml(m.title||"Untitled")}</div>
<div class="pc-overlay-actions">${wBtn}${radarrBtn}${radarr4kBtn}${overseerrBtn}${jellyseerrBtn}${ignoreBtn}</div>
<div class="pc-overlay-actions">${wBtn}${radarrBtn}${radarr4kBtn}${overseerrBtn}${jellyseerrBtn}${seerrBtn}${ignoreBtn}</div>
</div>
</div>`
}
Expand Down Expand Up @@ -173,8 +181,11 @@ function suggestionCard(m) {
box-shadow:0 1px 4px rgba(0,0,0,.5)">⚡${score}</div>`
: ""

const _inRadarr2 = _radarrLibTmdbIds?.has(tmdb)
const radarrBtn = CONFIG?.RADARR?.RADARR_ENABLED
? `<button class="btn-sm btn-radarr" onclick="event.stopPropagation();addToRadarr(${tmdb},'${safeName}',this)">+ Radarr</button>`
? (_inRadarr2
? `<button class="btn-sm btn-radarr" onclick="event.stopPropagation();searchInRadarr(${tmdb},'${safeName}',this)">⟳ Search</button>`
: `<button class="btn-sm btn-radarr" onclick="event.stopPropagation();addToRadarr(${tmdb},'${safeName}',this)">+ Radarr</button>`)
: ""
const radarr4kBtn = CONFIG?.RADARR_4K?.RADARR_4K_ENABLED
? `<button class="btn-sm btn-radarr" style="opacity:.75" onclick="event.stopPropagation();addToRadarr4k(${tmdb},'${safeName}',this)">+ 4K</button>`
Expand All @@ -189,6 +200,11 @@ function suggestionCard(m) {
? `<button class="btn-sm" style="color:var(--green)" disabled>✓ Requested</button>`
: `<button class="btn-sm btn-jellyseerr" onclick="event.stopPropagation();addToJellyseerr(${tmdb},'${safeName}',this)">→ JS</button>`)
: ""
const seerrBtn2 = CONFIG?.SEERR?.SEERR_ENABLED
? (seerrRequested?.has(tmdb)
? `<button class="btn-sm" style="color:var(--green)" disabled>✓ Requested</button>`
: `<button class="btn-sm btn-seerr" onclick="event.stopPropagation();addToSeerr(${tmdb},'${safeName}',this)">→ Seerr</button>`)
: ""

const movieData = JSON.stringify({tmdb:m.tmdb,title:m.title,year:m.year,poster:m.poster,rating:m.rating,wishlist:m.wishlist}).replace(/"/g,'&quot;')
const wBtn = m.wishlist
Expand Down Expand Up @@ -224,7 +240,7 @@ function suggestionCard(m) {
</div>
<div class="pc-overlay">
<div class="pc-overlay-title">${escHtml(m.title||"Untitled")}</div>
<div class="pc-overlay-actions">${wBtn}${radarrBtn}${radarr4kBtn}${overseerrBtn}${jellyseerrBtn}${ignoreBtn}</div>
<div class="pc-overlay-actions">${wBtn}${radarrBtn}${radarr4kBtn}${overseerrBtn}${jellyseerrBtn}${seerrBtn2}${ignoreBtn}</div>
</div>
</div>`
}
Expand Down
22 changes: 18 additions & 4 deletions static/js/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||{}
Expand Down Expand Up @@ -496,19 +497,27 @@ function renderConfig(){
</div>

<div class="form-section">
${sec('Overseerr <span style="font-size:.75rem;font-weight:400;color:var(--text3)">(optional)</span>', svcBadge('OVERSEERR','#F59E0B','#000'))}
${sec('Seerr <span style="font-size:.75rem;font-weight:400;color:var(--text3)">(optional)</span>', 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 &amp; Jellyseerr. API key found in Seerr → Settings → General.")}
</div>

<div class="form-section">
${sec('Overseerr <span style="font-size:.75rem;font-weight:400;color:var(--text3)">(legacy)</span>', 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.")}
</div>

<div class="form-section">
${sec('Jellyseerr <span style="font-size:.75rem;font-weight:400;color:var(--text3)">(optional)</span>', svcBadge('JELLYSEERR','#29B4E8'))}
${sec('Jellyseerr <span style="font-size:.75rem;font-weight:400;color:var(--text3)">(legacy)</span>', 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.")}
</div>

<div class="form-section">
Expand Down Expand Up @@ -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"),
Expand Down
14 changes: 12 additions & 2 deletions static/js/modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ async function openMovieModal(tmdb, fallback = {}) {
const jellyseerrBtn = CONFIG?.JELLYSEERR?.JELLYSEERR_ENABLED
? `<button class="btn-sm btn-jellyseerr" onclick="addToJellyseerr(${tmdb},'${safeTitle}',this)">→ Jellyseerr</button>`
: ""
const seerrBtn = CONFIG?.SEERR?.SEERR_ENABLED
? `<button class="btn-sm btn-seerr" onclick="addToSeerr(${tmdb},'${safeTitle}',this)">→ Seerr</button>`
: ""
const trailerBtn = md.trailer_key
? `<button class="btn-sm btn-trailer" onclick="toggleModalTrailer('${md.trailer_key}')">▶ Trailer</button>`
: ""
Expand Down Expand Up @@ -150,6 +153,7 @@ async function openMovieModal(tmdb, fallback = {}) {
${radarr4kBtn}
${overseerrBtn}
${jellyseerrBtn}
${seerrBtn}
${trailerBtn}
<a href="${md.tmdb_url || `https://www.themoviedb.org/movie/${tmdb}`}"
target="_blank" rel="noopener"
Expand Down Expand Up @@ -181,11 +185,17 @@ async function _loadStreamingProviders(tmdb) {
;(byType[p.type] = byType[p.type] || []).push(p)
}

const jwLink = res.link || ""
const sections = Object.entries(byType).map(([type, providers]) => {
const logos = providers.slice(0, 6).map(p =>
p.logo
? `<img src="${p.logo}" title="${escHtml(p.name)}" alt="${escHtml(p.name)}"
style="width:32px;height:32px;border-radius:6px;object-fit:cover" loading="lazy"/>`
? `<a href="${jwLink||"https://www.justwatch.com"}" target="_blank" rel="noopener"
title="${escHtml(p.name)}" style="display:inline-block;line-height:0">
<img src="${p.logo}" alt="${escHtml(p.name)}"
style="width:32px;height:32px;border-radius:6px;object-fit:cover;
transition:opacity .15s" loading="lazy"
onmouseover="this.style.opacity='.75'" onmouseout="this.style.opacity='1'"/>
</a>`
: `<span style="font-size:.72rem;color:var(--text2)">${escHtml(p.name)}</span>`
).join("")
return `<span style="font-size:.72rem;color:var(--text3);margin-right:.4rem">${escHtml(TYPE_LABEL[type]||type)}:</span>${logos}`
Expand Down
Loading
Loading