From 5fa3aa6665a16f4c3866c04e23c55aacdaf38e91 Mon Sep 17 00:00:00 2001 From: Rephl3x Date: Sat, 24 Jan 2026 19:06:40 +1300 Subject: [PATCH 01/89] Route grabs through Sonarr/Radarr only --- backend/app/routers/requests.py | 48 ++------------------------------- 1 file changed, 2 insertions(+), 46 deletions(-) diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index 015ef47..3de01df 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -1619,29 +1619,7 @@ async def action_grab( try: response = await client.grab_release(str(guid), int(indexer_id)) except httpx.HTTPStatusError as exc: - status_code = exc.response.status_code if exc.response is not None else 502 - if status_code == 404 and download_url: - qbit = QBittorrentClient( - runtime.qbittorrent_base_url, - runtime.qbittorrent_username, - runtime.qbittorrent_password, - ) - if not qbit.configured(): - raise HTTPException(status_code=400, detail="qBittorrent not configured") - try: - await qbit.add_torrent_url(str(download_url), category=f"magent-{request_id}") - except httpx.HTTPStatusError as qbit_exc: - raise HTTPException(status_code=502, detail=str(qbit_exc)) from qbit_exc - await asyncio.to_thread( - save_action, - request_id, - "grab", - "Grab release", - "ok", - "Sent to qBittorrent via Prowlarr.", - ) - return {"status": "ok", "message": "Sent to qBittorrent.", "via": "qbittorrent"} - raise HTTPException(status_code=502, detail=str(exc)) from exc + raise HTTPException(status_code=502, detail=f"Sonarr grab failed: {exc}") from exc await asyncio.to_thread( save_action, request_id, "grab", "Grab release", "ok", "Grab sent to Sonarr." ) @@ -1653,29 +1631,7 @@ async def action_grab( try: response = await client.grab_release(str(guid), int(indexer_id)) except httpx.HTTPStatusError as exc: - status_code = exc.response.status_code if exc.response is not None else 502 - if status_code == 404 and download_url: - qbit = QBittorrentClient( - runtime.qbittorrent_base_url, - runtime.qbittorrent_username, - runtime.qbittorrent_password, - ) - if not qbit.configured(): - raise HTTPException(status_code=400, detail="qBittorrent not configured") - try: - await qbit.add_torrent_url(str(download_url), category=f"magent-{request_id}") - except httpx.HTTPStatusError as qbit_exc: - raise HTTPException(status_code=502, detail=str(qbit_exc)) from qbit_exc - await asyncio.to_thread( - save_action, - request_id, - "grab", - "Grab release", - "ok", - "Sent to qBittorrent via Prowlarr.", - ) - return {"status": "ok", "message": "Sent to qBittorrent.", "via": "qbittorrent"} - raise HTTPException(status_code=502, detail=str(exc)) from exc + raise HTTPException(status_code=502, detail=f"Radarr grab failed: {exc}") from exc await asyncio.to_thread( save_action, request_id, "grab", "Grab release", "ok", "Grab sent to Radarr." ) From 18bbcbf6603656687c4065c5ec2a31739b5c738e Mon Sep 17 00:00:00 2001 From: Rephl3x Date: Sat, 24 Jan 2026 19:09:05 +1300 Subject: [PATCH 02/89] Document fix buttons in how-it-works --- frontend/app/how-it-works/page.tsx | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/frontend/app/how-it-works/page.tsx b/frontend/app/how-it-works/page.tsx index f80bbc7..f25cbfc 100644 --- a/frontend/app/how-it-works/page.tsx +++ b/frontend/app/how-it-works/page.tsx @@ -76,12 +76,38 @@ export default function HowItWorksPage() {
-

Why Magent sometimes says “waiting”

+

Why Magent sometimes says "waiting"

If the search helper cannot find a match yet, Magent will say there is nothing to grab. That does not mean it is broken. It usually means the release is not available yet.

+ +
+

Fix buttons and what they do

+
    +
  • + Search for releases checks the torrent sources (via Prowlarr) and shows + you a list you can choose from. +
  • +
  • + Download selected release sends the pick to Sonarr/Radarr so it can + download and import it correctly. +
  • +
  • + Search and auto-download asks Sonarr/Radarr to find the best match and + grab it automatically. +
  • +
  • + Resume download tells qBittorrent to continue a paused download when it + is already there. +
  • +
  • + Add to library queue re-adds the request into Sonarr/Radarr when it is + missing there. +
  • +
+
) } From 3d414b4aeb01ee914ac4727133c601063aee2e15 Mon Sep 17 00:00:00 2001 From: Rephl3x Date: Sat, 24 Jan 2026 19:15:43 +1300 Subject: [PATCH 03/89] Clarify how-it-works steps and fixes --- frontend/app/globals.css | 85 ++++++++++++++++++++++++++++++ frontend/app/how-it-works/page.tsx | 82 +++++++++++++++++++--------- 2 files changed, 141 insertions(+), 26 deletions(-) diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 7156834..a7a71a1 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1496,6 +1496,91 @@ button span { border: 1px solid var(--border); } +.how-step-grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); +} + +.how-step-card { + border-radius: 18px; + padding: 18px; + border: 1px solid var(--border); + background: rgba(255, 255, 255, 0.06); + display: grid; + gap: 10px; + position: relative; + overflow: hidden; +} + +.how-step-card::before { + content: ''; + position: absolute; + inset: 0; + opacity: 0.35; + pointer-events: none; +} + +.step-jellyseerr::before { + background: linear-gradient(135deg, rgba(255, 187, 92, 0.35), transparent 60%); +} + +.step-arr::before { + background: linear-gradient(135deg, rgba(94, 204, 255, 0.35), transparent 60%); +} + +.step-prowlarr::before { + background: linear-gradient(135deg, rgba(120, 255, 189, 0.35), transparent 60%); +} + +.step-qbit::before { + background: linear-gradient(135deg, rgba(255, 133, 200, 0.35), transparent 60%); +} + +.step-jellyfin::before { + background: linear-gradient(135deg, rgba(170, 140, 255, 0.35), transparent 60%); +} + +.step-badge { + width: 38px; + height: 38px; + border-radius: 50%; + display: grid; + place-items: center; + font-weight: 700; + background: rgba(255, 255, 255, 0.12); + border: 1px solid var(--border); + color: var(--ink); +} + +.step-note { + color: var(--ink-muted); + font-size: 14px; +} + +.step-fix-title { + font-size: 13px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-muted); +} + +.step-fix-list { + list-style: none; + display: grid; + gap: 6px; + padding: 0; + margin: 0; +} + +.step-fix-list li { + padding: 8px 10px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.08); + border: 1px solid var(--border); + font-size: 13px; +} + .how-callout { border-left: 4px solid var(--accent); padding: 16px 18px; diff --git a/frontend/app/how-it-works/page.tsx b/frontend/app/how-it-works/page.tsx index f25cbfc..83d2bc0 100644 --- a/frontend/app/how-it-works/page.tsx +++ b/frontend/app/how-it-works/page.tsx @@ -75,6 +75,62 @@ export default function HowItWorksPage() { +
+

Steps and fixes (simple and visual)

+
+
+
1
+

Request sent

+

Jellyseerr holds your request and approval.

+
Fixes you can try
+
    +
  • Add to library queue (if it was approved but never added)
  • +
+
+ +
+
2
+

Added to the library list

+

Sonarr/Radarr decide what quality to get.

+
Fixes you can try
+
    +
  • Search for releases (see options)
  • +
  • Search and auto-download (let it pick for you)
  • +
+
+ +
+
3
+

Searching for sources

+

Prowlarr checks your torrent providers.

+
Fixes you can try
+
    +
  • Search for releases (show a list to choose)
  • +
+
+ +
+
4
+

Downloading the file

+

qBittorrent downloads the selected match.

+
Fixes you can try
+
    +
  • Resume download (only if it already exists there)
  • +
+
+ +
+
5
+

Ready to watch

+

Jellyfin shows it in your library.

+
What to do next
+
    +
  • Open in Jellyfin (watch it)
  • +
+
+
+
+

Why Magent sometimes says "waiting"

@@ -82,32 +138,6 @@ export default function HowItWorksPage() { That does not mean it is broken. It usually means the release is not available yet.

- -
-

Fix buttons and what they do

-
    -
  • - Search for releases checks the torrent sources (via Prowlarr) and shows - you a list you can choose from. -
  • -
  • - Download selected release sends the pick to Sonarr/Radarr so it can - download and import it correctly. -
  • -
  • - Search and auto-download asks Sonarr/Radarr to find the best match and - grab it automatically. -
  • -
  • - Resume download tells qBittorrent to continue a paused download when it - is already there. -
  • -
  • - Add to library queue re-adds the request into Sonarr/Radarr when it is - missing there. -
  • -
-
) } From 030480410b406397f28d0df97d8ca30949452262 Mon Sep 17 00:00:00 2001 From: Rephl3x Date: Sat, 24 Jan 2026 19:21:40 +1300 Subject: [PATCH 04/89] Map Prowlarr releases to Arr indexers for manual grab --- backend/app/clients/radarr.py | 3 +++ backend/app/clients/sonarr.py | 3 +++ backend/app/routers/requests.py | 29 +++++++++++++++++++++++++++++ frontend/app/requests/[id]/page.tsx | 1 + 4 files changed, 36 insertions(+) diff --git a/backend/app/clients/radarr.py b/backend/app/clients/radarr.py index 569ca00..7698b00 100644 --- a/backend/app/clients/radarr.py +++ b/backend/app/clients/radarr.py @@ -21,6 +21,9 @@ class RadarrClient(ApiClient): async def get_queue(self, movie_id: int) -> Optional[Dict[str, Any]]: return await self.get("/api/v3/queue", params={"movieId": movie_id}) + async def get_indexers(self) -> Optional[Dict[str, Any]]: + return await self.get("/api/v3/indexer") + async def search(self, movie_id: int) -> Optional[Dict[str, Any]]: return await self.post("/api/v3/command", payload={"name": "MoviesSearch", "movieIds": [movie_id]}) diff --git a/backend/app/clients/sonarr.py b/backend/app/clients/sonarr.py index 65f1d96..71d0f51 100644 --- a/backend/app/clients/sonarr.py +++ b/backend/app/clients/sonarr.py @@ -18,6 +18,9 @@ class SonarrClient(ApiClient): async def get_queue(self, series_id: int) -> Optional[Dict[str, Any]]: return await self.get("/api/v3/queue", params={"seriesId": series_id}) + async def get_indexers(self) -> Optional[Dict[str, Any]]: + return await self.get("/api/v3/indexer") + async def get_episodes(self, series_id: int) -> Optional[Dict[str, Any]]: return await self.get("/api/v3/episode", params={"seriesId": series_id}) diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index 3de01df..99c08cd 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -1608,6 +1608,7 @@ async def action_grab( guid = payload.get("guid") indexer_id = payload.get("indexerId") download_url = payload.get("downloadUrl") + indexer_name = payload.get("indexerName") or payload.get("indexer") if not guid or not indexer_id: raise HTTPException(status_code=400, detail="Missing guid or indexerId") @@ -1617,6 +1618,20 @@ async def action_grab( if not client.configured(): raise HTTPException(status_code=400, detail="Sonarr not configured") try: + if indexer_name: + indexers = await client.get_indexers() + if isinstance(indexers, list): + matched = next( + ( + item + for item in indexers + if isinstance(item, dict) + and str(item.get("name", "")).lower() == str(indexer_name).lower() + ), + None, + ) + if matched and matched.get("id") is not None: + indexer_id = int(matched["id"]) response = await client.grab_release(str(guid), int(indexer_id)) except httpx.HTTPStatusError as exc: raise HTTPException(status_code=502, detail=f"Sonarr grab failed: {exc}") from exc @@ -1629,6 +1644,20 @@ async def action_grab( if not client.configured(): raise HTTPException(status_code=400, detail="Radarr not configured") try: + if indexer_name: + indexers = await client.get_indexers() + if isinstance(indexers, list): + matched = next( + ( + item + for item in indexers + if isinstance(item, dict) + and str(item.get("name", "")).lower() == str(indexer_name).lower() + ), + None, + ) + if matched and matched.get("id") is not None: + indexer_id = int(matched["id"]) response = await client.grab_release(str(guid), int(indexer_id)) except httpx.HTTPStatusError as exc: raise HTTPException(status_code=502, detail=f"Radarr grab failed: {exc}") from exc diff --git a/frontend/app/requests/[id]/page.tsx b/frontend/app/requests/[id]/page.tsx index 73cc0a1..015932a 100644 --- a/frontend/app/requests/[id]/page.tsx +++ b/frontend/app/requests/[id]/page.tsx @@ -590,6 +590,7 @@ export default function RequestTimelinePage({ params }: { params: { id: string } body: JSON.stringify({ guid: release.guid, indexerId: release.indexerId, + indexerName: release.indexer, downloadUrl: release.downloadUrl, }), } From cf4277d10c02852714923149ae9637448bb5bcb7 Mon Sep 17 00:00:00 2001 From: Rephl3x Date: Sat, 24 Jan 2026 21:48:55 +1300 Subject: [PATCH 05/89] Improve request handling and qBittorrent categories --- backend/app/ai/triage.py | 2 +- backend/app/clients/jellyfin.py | 10 + backend/app/clients/qbittorrent.py | 10 + backend/app/clients/radarr.py | 9 + backend/app/clients/sonarr.py | 9 + backend/app/config.py | 8 + backend/app/db.py | 16 ++ backend/app/routers/admin.py | 26 ++- backend/app/routers/requests.py | 273 ++++++++++++++++++++++++---- backend/app/services/snapshot.py | 45 ++++- frontend/app/admin/SettingsPage.tsx | 33 ++-- frontend/app/how-it-works/page.tsx | 2 +- frontend/app/requests/[id]/page.tsx | 25 ++- 13 files changed, 398 insertions(+), 70 deletions(-) diff --git a/backend/app/ai/triage.py b/backend/app/ai/triage.py index b01dc0c..ae0e33a 100644 --- a/backend/app/ai/triage.py +++ b/backend/app/ai/triage.py @@ -26,7 +26,7 @@ def triage_snapshot(snapshot: Snapshot) -> TriageResult: recommendations.append( TriageRecommendation( action_id="readd_to_arr", - title="Add it to the library queue", + title="Push to Sonarr/Radarr", reason="Sonarr/Radarr has not created the entry for this request.", risk="medium", ) diff --git a/backend/app/clients/jellyfin.py b/backend/app/clients/jellyfin.py index 16ad8a4..f06ee93 100644 --- a/backend/app/clients/jellyfin.py +++ b/backend/app/clients/jellyfin.py @@ -58,3 +58,13 @@ class JellyfinClient(ApiClient): response = await client.get(url, headers=headers) response.raise_for_status() return response.json() + + async def refresh_library(self, recursive: bool = True) -> None: + if not self.base_url or not self.api_key: + return None + url = f"{self.base_url}/Library/Refresh" + headers = {"X-Emby-Token": self.api_key} + params = {"Recursive": "true" if recursive else "false"} + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post(url, headers=headers, params=params) + response.raise_for_status() diff --git a/backend/app/clients/qbittorrent.py b/backend/app/clients/qbittorrent.py index 932c297..fc70d1f 100644 --- a/backend/app/clients/qbittorrent.py +++ b/backend/app/clients/qbittorrent.py @@ -1,5 +1,6 @@ from typing import Any, Dict, Optional import httpx +import logging from .base import ApiClient @@ -8,6 +9,7 @@ class QBittorrentClient(ApiClient): super().__init__(base_url, None) self.username = username self.password = password + self.logger = logging.getLogger(__name__) def configured(self) -> bool: return bool(self.base_url and self.username and self.password) @@ -72,6 +74,14 @@ class QBittorrentClient(ApiClient): raise async def add_torrent_url(self, url: str, category: Optional[str] = None) -> None: + url_host = None + if isinstance(url, str) and "://" in url: + url_host = url.split("://", 1)[-1].split("/", 1)[0] + self.logger.warning( + "qBittorrent add_torrent_url invoked: category=%s host=%s", + category, + url_host or "unknown", + ) data: Dict[str, Any] = {"urls": url} if category: data["category"] = category diff --git a/backend/app/clients/radarr.py b/backend/app/clients/radarr.py index 7698b00..ef5c0d4 100644 --- a/backend/app/clients/radarr.py +++ b/backend/app/clients/radarr.py @@ -46,3 +46,12 @@ class RadarrClient(ApiClient): async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]: return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id}) + + async def push_release(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return await self.post("/api/v3/release/push", payload=payload) + + async def download_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]: + return await self.post( + "/api/v3/command", + payload={"name": "DownloadRelease", "guid": guid, "indexerId": indexer_id}, + ) diff --git a/backend/app/clients/sonarr.py b/backend/app/clients/sonarr.py index 71d0f51..6ad31b6 100644 --- a/backend/app/clients/sonarr.py +++ b/backend/app/clients/sonarr.py @@ -53,3 +53,12 @@ class SonarrClient(ApiClient): async def grab_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]: return await self.post("/api/v3/release", payload={"guid": guid, "indexerId": indexer_id}) + + async def push_release(self, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: + return await self.post("/api/v3/release/push", payload=payload) + + async def download_release(self, guid: str, indexer_id: int) -> Optional[Dict[str, Any]]: + return await self.post( + "/api/v3/command", + payload={"name": "DownloadRelease", "guid": guid, "indexerId": indexer_id}, + ) diff --git a/backend/app/config.py b/backend/app/config.py index 902d658..52367b7 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -70,6 +70,10 @@ class Settings(BaseSettings): sonarr_root_folder: Optional[str] = Field( default=None, validation_alias=AliasChoices("SONARR_ROOT_FOLDER") ) + sonarr_qbittorrent_category: Optional[str] = Field( + default="sonarr", + validation_alias=AliasChoices("SONARR_QBITTORRENT_CATEGORY"), + ) radarr_base_url: Optional[str] = Field( default=None, validation_alias=AliasChoices("RADARR_URL", "RADARR_BASE_URL") @@ -83,6 +87,10 @@ class Settings(BaseSettings): radarr_root_folder: Optional[str] = Field( default=None, validation_alias=AliasChoices("RADARR_ROOT_FOLDER") ) + radarr_qbittorrent_category: Optional[str] = Field( + default="radarr", + validation_alias=AliasChoices("RADARR_QBITTORRENT_CATEGORY"), + ) prowlarr_base_url: Optional[str] = Field( default=None, validation_alias=AliasChoices("PROWLARR_URL", "PROWLARR_BASE_URL") diff --git a/backend/app/db.py b/backend/app/db.py index 72cac98..baef6b8 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -609,6 +609,22 @@ def get_request_cache_count() -> int: return int(row[0] or 0) +def update_request_cache_title( + request_id: int, title: str, year: Optional[int] = None +) -> None: + if not title: + return + with _connect() as conn: + conn.execute( + """ + UPDATE requests_cache + SET title = ?, year = COALESCE(?, year) + WHERE request_id = ? + """, + (title, year, request_id), + ) + + def prune_duplicate_requests_cache() -> int: with _connect() as conn: cursor = conn.execute( diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index a5603f3..47e7813 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -20,6 +20,7 @@ from ..db import ( clear_requests_cache, clear_history, cleanup_history, + update_request_cache_title, ) from ..runtime import get_runtime_settings from ..clients.sonarr import SonarrClient @@ -56,10 +57,12 @@ SETTING_KEYS: List[str] = [ "sonarr_api_key", "sonarr_quality_profile_id", "sonarr_root_folder", + "sonarr_qbittorrent_category", "radarr_base_url", "radarr_api_key", "radarr_quality_profile_id", "radarr_root_folder", + "radarr_qbittorrent_category", "prowlarr_base_url", "prowlarr_api_key", "qbittorrent_base_url", @@ -274,7 +277,28 @@ async def read_logs(lines: int = 200) -> Dict[str, Any]: @router.get("/requests/cache") async def requests_cache(limit: int = 50) -> Dict[str, Any]: - return {"rows": get_request_cache_overview(limit)} + rows = get_request_cache_overview(limit) + missing_titles = [row for row in rows if not row.get("title")] + if missing_titles: + runtime = get_runtime_settings() + client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) + if client.configured(): + for row in missing_titles: + request_id = row.get("request_id") + if not isinstance(request_id, int): + continue + details = await requests_router._get_request_details(client, request_id) + if not isinstance(details, dict): + continue + payload = requests_router._parse_request_payload(details) + title = payload.get("title") + if not title: + continue + row["title"] = title + if payload.get("year"): + row["year"] = payload.get("year") + update_request_cache_title(request_id, title, payload.get("year")) + return {"rows": rows} @router.post("/branding/logo") diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index 99c08cd..63f5472 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -68,6 +68,7 @@ _artwork_prefetch_state: Dict[str, Any] = { "finished_at": None, } _artwork_prefetch_task: Optional[asyncio.Task] = None +_media_endpoint_supported: Optional[bool] = None STATUS_LABELS = { 1: "Waiting for approval", @@ -269,10 +270,17 @@ async def _hydrate_title_from_tmdb( async def _hydrate_media_details(client: JellyseerrClient, media_id: Optional[int]) -> Optional[Dict[str, Any]]: if not media_id: return None + global _media_endpoint_supported + if _media_endpoint_supported is False: + return None try: details = await client.get_media(int(media_id)) - except httpx.HTTPStatusError: + except httpx.HTTPStatusError as exc: + if exc.response is not None and exc.response.status_code == 405: + _media_endpoint_supported = False + logger.info("Jellyseerr media endpoint rejected GET requests; skipping media lookups.") return None + _media_endpoint_supported = True return details if isinstance(details, dict) else None @@ -393,14 +401,23 @@ async def _sync_all_requests(client: JellyseerrClient) -> int: continue payload = _parse_request_payload(item) request_id = payload.get("request_id") + cached_title = None if isinstance(request_id, int): + if not payload.get("title"): + cached = get_request_cache_by_id(request_id) + if cached and cached.get("title"): + cached_title = cached.get("title") if not payload.get("title") or not payload.get("media_id"): logger.debug("Jellyseerr sync hydrate request_id=%s", request_id) details = await _get_request_details(client, request_id) if isinstance(details, dict): payload = _parse_request_payload(details) item = details - if not payload.get("title") and payload.get("media_id"): + if ( + not payload.get("title") + and payload.get("media_id") + and (not payload.get("tmdb_id") or not payload.get("media_type")) + ): media_details = await _hydrate_media_details(client, payload.get("media_id")) if isinstance(media_details, dict): media_title = media_details.get("title") or media_details.get("name") @@ -428,7 +445,7 @@ async def _sync_all_requests(client: JellyseerrClient) -> int: if isinstance(details, dict): item = details payload = _parse_request_payload(details) - if not payload.get("title") and payload.get("tmdb_id"): + if not payload.get("title") and payload.get("tmdb_id") and payload.get("media_type"): hydrated_title, hydrated_year = await _hydrate_title_from_tmdb( client, payload.get("media_type"), payload.get("tmdb_id") ) @@ -436,6 +453,8 @@ async def _sync_all_requests(client: JellyseerrClient) -> int: payload["title"] = hydrated_title if hydrated_year: payload["year"] = hydrated_year + if not payload.get("title") and cached_title: + payload["title"] = cached_title if not isinstance(payload.get("request_id"), int): continue payload_json = json.dumps(item, ensure_ascii=True) @@ -516,6 +535,7 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int: if isinstance(request_id, int): cached = get_request_cache_by_id(request_id) incoming_updated = payload.get("updated_at") + cached_title = cached.get("title") if cached else None if cached and incoming_updated and cached.get("updated_at") == incoming_updated and cached.get("title"): continue if not payload.get("title") or not payload.get("media_id"): @@ -523,7 +543,11 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int: if isinstance(details, dict): payload = _parse_request_payload(details) item = details - if not payload.get("title") and payload.get("media_id"): + if ( + not payload.get("title") + and payload.get("media_id") + and (not payload.get("tmdb_id") or not payload.get("media_type")) + ): media_details = await _hydrate_media_details(client, payload.get("media_id")) if isinstance(media_details, dict): media_title = media_details.get("title") or media_details.get("name") @@ -551,7 +575,7 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int: if isinstance(details, dict): payload = _parse_request_payload(details) item = details - if not payload.get("title") and payload.get("tmdb_id"): + if not payload.get("title") and payload.get("tmdb_id") and payload.get("media_type"): hydrated_title, hydrated_year = await _hydrate_title_from_tmdb( client, payload.get("media_type"), payload.get("tmdb_id") ) @@ -559,6 +583,8 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int: payload["title"] = hydrated_title if hydrated_year: payload["year"] = hydrated_year + if not payload.get("title") and cached_title: + payload["title"] = cached_title if not isinstance(payload.get("request_id"), int): continue payload_json = json.dumps(item, ensure_ascii=True) @@ -999,6 +1025,97 @@ def _normalize_categories(categories: Any) -> List[str]: return names +def _normalize_indexer_name(value: Optional[str]) -> str: + if not isinstance(value, str): + return "" + return "".join(ch for ch in value.lower().strip() if ch.isalnum()) + + +def _resolve_arr_indexer_id( + indexers: Any, indexer_name: Optional[str], indexer_id: Optional[int], service_label: str +) -> Optional[int]: + if not isinstance(indexers, list): + return None + if not indexer_name: + if indexer_id is None: + return None + by_id = next( + (item for item in indexers if isinstance(item, dict) and item.get("id") == indexer_id), + None, + ) + if by_id and by_id.get("id") is not None: + logger.debug("%s indexer id match: %s", service_label, by_id.get("id")) + return int(by_id["id"]) + return None + target = indexer_name.lower().strip() + target_compact = _normalize_indexer_name(indexer_name) + exact = next( + ( + item + for item in indexers + if isinstance(item, dict) + and str(item.get("name", "")).lower().strip() == target + ), + None, + ) + if exact and exact.get("id") is not None: + logger.debug("%s indexer match: '%s' -> %s", service_label, indexer_name, exact.get("id")) + return int(exact["id"]) + compact = next( + ( + item + for item in indexers + if isinstance(item, dict) + and _normalize_indexer_name(str(item.get("name", ""))) == target_compact + ), + None, + ) + if compact and compact.get("id") is not None: + logger.debug("%s indexer compact match: '%s' -> %s", service_label, indexer_name, compact.get("id")) + return int(compact["id"]) + contains = next( + ( + item + for item in indexers + if isinstance(item, dict) + and target in str(item.get("name", "")).lower() + ), + None, + ) + if contains and contains.get("id") is not None: + logger.debug("%s indexer contains match: '%s' -> %s", service_label, indexer_name, contains.get("id")) + return int(contains["id"]) + logger.warning( + "%s indexer not found for name '%s'. Check indexer names in the Arr app.", + service_label, + indexer_name, + ) + return None + + +async def _fallback_qbittorrent_download(download_url: Optional[str], category: str) -> bool: + if not download_url: + return False + runtime = get_runtime_settings() + client = QBittorrentClient( + runtime.qbittorrent_base_url, + runtime.qbittorrent_username, + runtime.qbittorrent_password, + ) + if not client.configured(): + return False + await client.add_torrent_url(download_url, category=category) + return True + + +def _resolve_qbittorrent_category(value: Optional[str], default: str) -> str: + if isinstance(value, str): + cleaned = value.strip() + if cleaned: + return cleaned + return default + + def _filter_prowlarr_results(results: Any, request_type: RequestType) -> List[Dict[str, Any]]: if not isinstance(results, list): return [] @@ -1607,36 +1724,91 @@ async def action_grab( snapshot = await build_snapshot(request_id) guid = payload.get("guid") indexer_id = payload.get("indexerId") - download_url = payload.get("downloadUrl") indexer_name = payload.get("indexerName") or payload.get("indexer") + download_url = payload.get("downloadUrl") + release_title = payload.get("title") + release_size = payload.get("size") + release_protocol = payload.get("protocol") or "torrent" + release_publish = payload.get("publishDate") + release_seeders = payload.get("seeders") + release_leechers = payload.get("leechers") if not guid or not indexer_id: raise HTTPException(status_code=400, detail="Missing guid or indexerId") + logger.info( + "Grab requested: request_id=%s guid=%s indexer_id=%s indexer_name=%s has_download_url=%s has_title=%s", + request_id, + guid, + indexer_id, + indexer_name, + bool(download_url), + bool(release_title), + ) + + push_payload = None + if download_url and release_title: + push_payload = { + "title": release_title, + "downloadUrl": download_url, + "protocol": release_protocol, + "publishDate": release_publish, + "size": release_size, + "indexer": indexer_name, + "guid": guid, + "seeders": release_seeders, + "leechers": release_leechers, + } + runtime = get_runtime_settings() if snapshot.request_type.value == "tv": client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key) if not client.configured(): raise HTTPException(status_code=400, detail="Sonarr not configured") try: - if indexer_name: - indexers = await client.get_indexers() - if isinstance(indexers, list): - matched = next( - ( - item - for item in indexers - if isinstance(item, dict) - and str(item.get("name", "")).lower() == str(indexer_name).lower() - ), - None, - ) - if matched and matched.get("id") is not None: - indexer_id = int(matched["id"]) - response = await client.grab_release(str(guid), int(indexer_id)) + indexers = await client.get_indexers() + resolved_indexer_id = _resolve_arr_indexer_id(indexers, indexer_name, indexer_id, "Sonarr") + response = None + action_message = "Grab sent to Sonarr." + if resolved_indexer_id is not None: + indexer_id = resolved_indexer_id + logger.info("Sonarr grab: attempting DownloadRelease command.") + try: + response = await client.download_release(str(guid), int(indexer_id)) + except httpx.HTTPStatusError as exc: + if exc.response is not None and exc.response.status_code in {404, 405}: + logger.info("Sonarr grab: DownloadRelease unsupported; will try release push.") + response = None + else: + raise + if response is None and push_payload: + logger.info("Sonarr grab: attempting release push.") + try: + response = await client.push_release(push_payload) + except httpx.HTTPStatusError as exc: + if exc.response is not None and exc.response.status_code == 404: + logger.info("Sonarr grab: release push not supported.") + else: + raise + if response is None: + category = _resolve_qbittorrent_category( + runtime.sonarr_qbittorrent_category, "sonarr" + ) + qbittorrent_added = await _fallback_qbittorrent_download(download_url, category) + if qbittorrent_added: + action_message = f"Grab sent to qBittorrent (category {category})." + response = {"qbittorrent": "queued"} + else: + if resolved_indexer_id is None: + detail = "Indexer not found in Sonarr and no release push available." + elif not push_payload: + detail = "Sonarr did not accept the grab request (no release URL available)." + else: + detail = "Sonarr did not accept the grab request (DownloadRelease unsupported)." + raise HTTPException(status_code=400, detail=detail) except httpx.HTTPStatusError as exc: raise HTTPException(status_code=502, detail=f"Sonarr grab failed: {exc}") from exc await asyncio.to_thread( - save_action, request_id, "grab", "Grab release", "ok", "Grab sent to Sonarr." + save_action, request_id, "grab", "Grab release", "ok", action_message ) return {"status": "ok", "response": response} if snapshot.request_type.value == "movie": @@ -1644,25 +1816,50 @@ async def action_grab( if not client.configured(): raise HTTPException(status_code=400, detail="Radarr not configured") try: - if indexer_name: - indexers = await client.get_indexers() - if isinstance(indexers, list): - matched = next( - ( - item - for item in indexers - if isinstance(item, dict) - and str(item.get("name", "")).lower() == str(indexer_name).lower() - ), - None, - ) - if matched and matched.get("id") is not None: - indexer_id = int(matched["id"]) - response = await client.grab_release(str(guid), int(indexer_id)) + indexers = await client.get_indexers() + resolved_indexer_id = _resolve_arr_indexer_id(indexers, indexer_name, indexer_id, "Radarr") + response = None + action_message = "Grab sent to Radarr." + if resolved_indexer_id is not None: + indexer_id = resolved_indexer_id + logger.info("Radarr grab: attempting DownloadRelease command.") + try: + response = await client.download_release(str(guid), int(indexer_id)) + except httpx.HTTPStatusError as exc: + if exc.response is not None and exc.response.status_code in {404, 405}: + logger.info("Radarr grab: DownloadRelease unsupported; will try release push.") + response = None + else: + raise + if response is None and push_payload: + logger.info("Radarr grab: attempting release push.") + try: + response = await client.push_release(push_payload) + except httpx.HTTPStatusError as exc: + if exc.response is not None and exc.response.status_code == 404: + logger.info("Radarr grab: release push not supported.") + else: + raise + if response is None: + category = _resolve_qbittorrent_category( + runtime.radarr_qbittorrent_category, "radarr" + ) + qbittorrent_added = await _fallback_qbittorrent_download(download_url, category) + if qbittorrent_added: + action_message = f"Grab sent to qBittorrent (category {category})." + response = {"qbittorrent": "queued"} + else: + if resolved_indexer_id is None: + detail = "Indexer not found in Radarr and no release push available." + elif not push_payload: + detail = "Radarr did not accept the grab request (no release URL available)." + else: + detail = "Radarr did not accept the grab request (DownloadRelease unsupported)." + raise HTTPException(status_code=400, detail=detail) except httpx.HTTPStatusError as exc: raise HTTPException(status_code=502, detail=f"Radarr grab failed: {exc}") from exc await asyncio.to_thread( - save_action, request_id, "grab", "Grab release", "ok", "Grab sent to Radarr." + save_action, request_id, "grab", "Grab release", "ok", action_message ) return {"status": "ok", "response": response} diff --git a/backend/app/services/snapshot.py b/backend/app/services/snapshot.py index 4c089b1..33c6c00 100644 --- a/backend/app/services/snapshot.py +++ b/backend/app/services/snapshot.py @@ -11,9 +11,14 @@ from ..clients.radarr import RadarrClient from ..clients.prowlarr import ProwlarrClient from ..clients.qbittorrent import QBittorrentClient from ..runtime import get_runtime_settings -from ..db import save_snapshot, get_request_cache_payload +from ..db import save_snapshot, get_request_cache_payload, get_recent_snapshots, get_setting, set_setting from ..models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop +logger = logging.getLogger(__name__) + +JELLYFIN_SCAN_COOLDOWN_SECONDS = 300 +_jellyfin_scan_key = "jellyfin_scan_last_at" + STATUS_LABELS = { 1: "Waiting for approval", @@ -41,6 +46,35 @@ def _pick_first(value: Any) -> Optional[Dict[str, Any]]: return None +async def _maybe_refresh_jellyfin(snapshot: Snapshot) -> None: + if snapshot.state not in {NormalizedState.available, NormalizedState.completed}: + return + runtime = get_runtime_settings() + client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) + if not client.configured(): + return + last_scan = get_setting(_jellyfin_scan_key) + if last_scan: + try: + parsed = datetime.fromisoformat(last_scan.replace("Z", "+00:00")) + if (datetime.now(timezone.utc) - parsed).total_seconds() < JELLYFIN_SCAN_COOLDOWN_SECONDS: + return + except ValueError: + pass + previous = await asyncio.to_thread(get_recent_snapshots, snapshot.request_id, 1) + if previous: + prev_state = previous[0].get("state") + if prev_state in {NormalizedState.available.value, NormalizedState.completed.value}: + return + try: + await client.refresh_library() + except Exception as exc: + logger.warning("Jellyfin library refresh failed: %s", exc) + return + set_setting(_jellyfin_scan_key, datetime.now(timezone.utc).isoformat()) + logger.info("Jellyfin library refresh triggered: request_id=%s", snapshot.request_id) + + def _queue_records(queue: Any) -> List[Dict[str, Any]]: if isinstance(queue, dict): records = queue.get("records") @@ -381,10 +415,6 @@ async def build_snapshot(request_id: str) -> Snapshot: if arr_state is None: arr_state = "unknown" - if arr_state == "missing" and media_status_code in {4}: - arr_state = "available" - elif arr_state == "missing" and media_status_code in {6}: - arr_state = "added" timeline.append(TimelineHop(service="Sonarr/Radarr", status=arr_state, details=arr_details)) @@ -524,7 +554,7 @@ async def build_snapshot(request_id: str) -> Snapshot: snapshot.state_reason = "Waiting for download to start in qBittorrent." elif arr_state == "missing" and derived_approved: snapshot.state = NormalizedState.needs_add - snapshot.state_reason = "Approved, but not added to the library yet." + snapshot.state_reason = "Approved, but not yet added to Sonarr/Radarr." elif arr_state == "searching": snapshot.state = NormalizedState.searching snapshot.state_reason = "Searching for a matching release." @@ -548,7 +578,7 @@ async def build_snapshot(request_id: str) -> Snapshot: actions.append( ActionOption( id="readd_to_arr", - label="Add to the library queue (Sonarr/Radarr)", + label="Push to Sonarr/Radarr", risk="medium", ) ) @@ -604,5 +634,6 @@ async def build_snapshot(request_id: str) -> Snapshot: }, } + await _maybe_refresh_jellyfin(snapshot) await asyncio.to_thread(save_snapshot, snapshot) return snapshot diff --git a/frontend/app/admin/SettingsPage.tsx b/frontend/app/admin/SettingsPage.tsx index 2db337d..05e7f5f 100644 --- a/frontend/app/admin/SettingsPage.tsx +++ b/frontend/app/admin/SettingsPage.tsx @@ -1,6 +1,6 @@ 'use client' -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useRouter } from 'next/navigation' import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' import AdminShell from '../ui/AdminShell' @@ -107,7 +107,7 @@ export default function SettingsPage({ section }: SettingsPageProps) { const [maintenanceStatus, setMaintenanceStatus] = useState(null) const [maintenanceBusy, setMaintenanceBusy] = useState(false) - const loadSettings = async () => { + const loadSettings = useCallback(async () => { const baseUrl = getApiBase() const response = await authFetch(`${baseUrl}/admin/settings`) if (!response.ok) { @@ -139,9 +139,9 @@ export default function SettingsPage({ section }: SettingsPageProps) { } setFormValues(initialValues) setStatus(null) - } + }, [router]) - const loadArtworkPrefetchStatus = async () => { + const loadArtworkPrefetchStatus = useCallback(async () => { try { const baseUrl = getApiBase() const response = await authFetch(`${baseUrl}/admin/requests/artwork/status`) @@ -153,10 +153,9 @@ export default function SettingsPage({ section }: SettingsPageProps) { } catch (err) { console.error(err) } - } + }, []) - - const loadOptions = async (service: 'sonarr' | 'radarr') => { + const loadOptions = useCallback(async (service: 'sonarr' | 'radarr') => { try { const baseUrl = getApiBase() const response = await authFetch(`${baseUrl}/admin/${service}/options`) @@ -185,7 +184,7 @@ export default function SettingsPage({ section }: SettingsPageProps) { setRadarrError('Could not load Radarr options.') } } - } + }, []) useEffect(() => { const load = async () => { @@ -213,7 +212,7 @@ export default function SettingsPage({ section }: SettingsPageProps) { if (section === 'radarr') { void loadOptions('radarr') } - }, [router, section]) + }, [loadArtworkPrefetchStatus, loadOptions, loadSettings, router, section]) const groupedSettings = useMemo(() => { const groups: Record = {} @@ -271,10 +270,12 @@ export default function SettingsPage({ section }: SettingsPageProps) { sonarr_api_key: 'API key for Sonarr.', sonarr_quality_profile_id: 'Quality profile used when adding TV shows.', sonarr_root_folder: 'Root folder where Sonarr stores TV shows.', + sonarr_qbittorrent_category: 'qBittorrent category for manual Sonarr downloads.', radarr_base_url: 'Radarr server URL for movies.', radarr_api_key: 'API key for Radarr.', radarr_quality_profile_id: 'Quality profile used when adding movies.', radarr_root_folder: 'Root folder where Radarr stores movies.', + radarr_qbittorrent_category: 'qBittorrent category for manual Radarr downloads.', prowlarr_base_url: 'Prowlarr server URL for indexer searches.', prowlarr_api_key: 'API key for Prowlarr.', qbittorrent_base_url: 'qBittorrent server URL for download status.', @@ -472,7 +473,7 @@ export default function SettingsPage({ section }: SettingsPageProps) { active = false clearInterval(timer) } - }, [artworkPrefetch?.status]) + }, [artworkPrefetch]) useEffect(() => { if (!artworkPrefetch || artworkPrefetch.status === 'running') { @@ -482,7 +483,7 @@ export default function SettingsPage({ section }: SettingsPageProps) { setArtworkPrefetch(null) }, 5000) return () => clearTimeout(timer) - }, [artworkPrefetch?.status]) + }, [artworkPrefetch]) useEffect(() => { if (!requestsSync || requestsSync.status !== 'running') { @@ -510,7 +511,7 @@ export default function SettingsPage({ section }: SettingsPageProps) { active = false clearInterval(timer) } - }, [requestsSync?.status]) + }, [requestsSync]) useEffect(() => { if (!requestsSync || requestsSync.status === 'running') { @@ -520,9 +521,9 @@ export default function SettingsPage({ section }: SettingsPageProps) { setRequestsSync(null) }, 5000) return () => clearTimeout(timer) - }, [requestsSync?.status]) + }, [requestsSync]) - const loadLogs = async () => { + const loadLogs = useCallback(async () => { setLogsStatus(null) try { const baseUrl = getApiBase() @@ -547,7 +548,7 @@ export default function SettingsPage({ section }: SettingsPageProps) { : 'Could not load logs.' setLogsStatus(message) } - } + }, [logsCount]) useEffect(() => { if (!showLogs) { @@ -558,7 +559,7 @@ export default function SettingsPage({ section }: SettingsPageProps) { void loadLogs() }, 5000) return () => clearInterval(timer) - }, [logsCount, showLogs]) + }, [loadLogs, showLogs]) const loadCache = async () => { setCacheStatus(null) diff --git a/frontend/app/how-it-works/page.tsx b/frontend/app/how-it-works/page.tsx index 83d2bc0..5b131fe 100644 --- a/frontend/app/how-it-works/page.tsx +++ b/frontend/app/how-it-works/page.tsx @@ -132,7 +132,7 @@ export default function HowItWorksPage() {
-

Why Magent sometimes says "waiting"

+

Why Magent sometimes says "waiting"

If the search helper cannot find a match yet, Magent will say there is nothing to grab. That does not mean it is broken. It usually means the release is not available yet. diff --git a/frontend/app/requests/[id]/page.tsx b/frontend/app/requests/[id]/page.tsx index 015932a..f2f7fe2 100644 --- a/frontend/app/requests/[id]/page.tsx +++ b/frontend/app/requests/[id]/page.tsx @@ -1,5 +1,6 @@ 'use client' +import Image from 'next/image' import { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth' @@ -33,6 +34,7 @@ type ReleaseOption = { seeders?: number leechers?: number protocol?: string + publishDate?: string infoUrl?: string downloadUrl?: string } @@ -123,7 +125,7 @@ const friendlyState = (value: string) => { const map: Record = { REQUESTED: 'Waiting for approval', APPROVED: 'Approved and queued', - NEEDS_ADD: 'Needs adding to the library', + NEEDS_ADD: 'Push to Sonarr/Radarr', ADDED_TO_ARR: 'Added to the library queue', SEARCHING: 'Searching for releases', GRABBED: 'Download queued', @@ -155,7 +157,7 @@ const friendlyTimelineStatus = (service: string, status: string) => { } if (service === 'Sonarr/Radarr') { const map: Record = { - missing: 'Not added yet', + missing: 'Push to Sonarr/Radarr', added: 'Added to the library queue', searching: 'Searching for releases', available: 'Ready to watch', @@ -250,7 +252,7 @@ export default function RequestTimelinePage({ params }: { params: { id: string } } load() - }, [params.id]) + }, [params.id, router]) if (loading) { return ( @@ -274,9 +276,11 @@ export default function RequestTimelinePage({ params }: { params: { id: string } const downloadHop = snapshot.timeline.find((hop) => hop.service === 'qBittorrent') const downloadState = downloadHop?.details?.summary ?? downloadHop?.status ?? 'Unknown' const jellyfinAvailable = Boolean(snapshot.raw?.jellyfin?.available) + const arrStageLabel = + snapshot.state === 'NEEDS_ADD' ? 'Push to Sonarr/Radarr' : 'Library queue' const pipelineSteps = [ { key: 'Jellyseerr', label: 'Jellyseerr' }, - { key: 'Sonarr/Radarr', label: 'Library queue' }, + { key: 'Sonarr/Radarr', label: arrStageLabel }, { key: 'Prowlarr', label: 'Search' }, { key: 'qBittorrent', label: 'Download' }, { key: 'Jellyfin', label: 'Jellyfin' }, @@ -308,11 +312,14 @@ export default function RequestTimelinePage({ params }: { params: { id: string }

{resolvedPoster && ( - {`${snapshot.title} )}
@@ -592,6 +599,12 @@ export default function RequestTimelinePage({ params }: { params: { id: string } indexerId: release.indexerId, indexerName: release.indexer, downloadUrl: release.downloadUrl, + title: release.title, + size: release.size, + protocol: release.protocol, + publishDate: release.publishDate, + seeders: release.seeders, + leechers: release.leechers, }), } ) From 38eee2407bed61c5e3deaf34a211f16b643f695f Mon Sep 17 00:00:00 2001 From: Rephl3x Date: Sun, 25 Jan 2026 14:28:16 +1300 Subject: [PATCH 06/89] Add site banner, build number, and changelog --- backend/app/config.py | 15 +++ backend/app/main.py | 2 + backend/app/routers/admin.py | 5 + backend/app/routers/requests.py | 181 ++++++++++---------------- backend/app/routers/site.py | 39 ++++++ backend/app/runtime.py | 1 + frontend/app/admin/SettingsPage.tsx | 74 ++++++++++- frontend/app/admin/[section]/page.tsx | 1 + frontend/app/changelog/page.tsx | 85 ++++++++++++ frontend/app/globals.css | 61 +++++++++ frontend/app/layout.tsx | 2 + frontend/app/requests/[id]/page.tsx | 4 +- frontend/app/ui/AdminSidebar.tsx | 1 + frontend/app/ui/HeaderActions.tsx | 1 + frontend/app/ui/SiteStatus.tsx | 65 +++++++++ 15 files changed, 419 insertions(+), 118 deletions(-) create mode 100644 backend/app/routers/site.py create mode 100644 frontend/app/changelog/page.tsx create mode 100644 frontend/app/ui/SiteStatus.tsx diff --git a/backend/app/config.py b/backend/app/config.py index 52367b7..1d9c4e9 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -38,6 +38,21 @@ class Settings(BaseSettings): artwork_cache_mode: str = Field( default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE") ) + site_build_number: Optional[str] = Field( + default=None, validation_alias=AliasChoices("SITE_BUILD_NUMBER") + ) + site_banner_enabled: bool = Field( + default=False, validation_alias=AliasChoices("SITE_BANNER_ENABLED") + ) + site_banner_message: Optional[str] = Field( + default=None, validation_alias=AliasChoices("SITE_BANNER_MESSAGE") + ) + site_banner_tone: str = Field( + default="info", validation_alias=AliasChoices("SITE_BANNER_TONE") + ) + site_changelog: Optional[str] = Field( + default=None, validation_alias=AliasChoices("SITE_CHANGELOG") + ) jellyseerr_base_url: Optional[str] = Field( default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL") diff --git a/backend/app/main.py b/backend/app/main.py index 4d9a876..6be34b0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -18,6 +18,7 @@ from .routers.images import router as images_router from .routers.branding import router as branding_router from .routers.status import router as status_router from .routers.feedback import router as feedback_router +from .routers.site import router as site_router from .services.jellyfin_sync import run_daily_jellyfin_sync from .logging_config import configure_logging from .runtime import get_runtime_settings @@ -56,3 +57,4 @@ app.include_router(images_router) app.include_router(branding_router) app.include_router(status_router) app.include_router(feedback_router) +app.include_router(site_router) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 47e7813..286216f 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -77,6 +77,11 @@ SETTING_KEYS: List[str] = [ "requests_cleanup_time", "requests_cleanup_days", "requests_data_source", + "site_build_number", + "site_banner_enabled", + "site_banner_message", + "site_banner_tone", + "site_changelog", ] def _normalize_root_folders(folders: Any) -> List[Dict[str, Any]]: diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index 63f5472..c7d26d3 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -1031,6 +1031,57 @@ def _normalize_indexer_name(value: Optional[str]) -> str: return "".join(ch for ch in value.lower().strip() if ch.isalnum()) +def _log_arr_http_error(service_label: str, action: str, exc: httpx.HTTPStatusError) -> None: + if exc.response is None: + logger.warning("%s %s failed: %s", service_label, action, exc) + return + status = exc.response.status_code + body = exc.response.text + if isinstance(body, str): + body = body.strip() + if len(body) > 800: + body = f"{body[:800]}...(truncated)" + logger.warning("%s %s failed: status=%s body=%s", service_label, action, status, body) + + +def _format_rejections(rejections: Any) -> Optional[str]: + if isinstance(rejections, str): + return rejections.strip() or None + if isinstance(rejections, list): + reasons = [] + for item in rejections: + reason = None + if isinstance(item, dict): + reason = ( + item.get("reason") + or item.get("message") + or item.get("errorMessage") + ) + if not reason and item is not None: + reason = str(item) + if isinstance(reason, str) and reason.strip(): + reasons.append(reason.strip()) + if reasons: + return "; ".join(reasons) + return None + + +def _release_push_accepted(response: Any) -> tuple[bool, Optional[str]]: + if not isinstance(response, dict): + return True, None + rejections = response.get("rejections") or response.get("rejectionReasons") + reason = _format_rejections(rejections) + if reason: + return False, reason + if response.get("rejected") is True: + return False, "rejected" + if response.get("downloadAllowed") is False: + return False, "download not allowed" + if response.get("approved") is False: + return False, "not approved" + return True, None + + def _resolve_arr_indexer_id( indexers: Any, indexer_name: Optional[str], indexer_id: Optional[int], service_label: str ) -> Optional[int]: @@ -1745,122 +1796,22 @@ async def action_grab( bool(release_title), ) - push_payload = None - if download_url and release_title: - push_payload = { - "title": release_title, - "downloadUrl": download_url, - "protocol": release_protocol, - "publishDate": release_publish, - "size": release_size, - "indexer": indexer_name, - "guid": guid, - "seeders": release_seeders, - "leechers": release_leechers, - } - runtime = get_runtime_settings() + if not download_url: + raise HTTPException(status_code=400, detail="Missing downloadUrl") if snapshot.request_type.value == "tv": - client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key) - if not client.configured(): - raise HTTPException(status_code=400, detail="Sonarr not configured") - try: - indexers = await client.get_indexers() - resolved_indexer_id = _resolve_arr_indexer_id(indexers, indexer_name, indexer_id, "Sonarr") - response = None - action_message = "Grab sent to Sonarr." - if resolved_indexer_id is not None: - indexer_id = resolved_indexer_id - logger.info("Sonarr grab: attempting DownloadRelease command.") - try: - response = await client.download_release(str(guid), int(indexer_id)) - except httpx.HTTPStatusError as exc: - if exc.response is not None and exc.response.status_code in {404, 405}: - logger.info("Sonarr grab: DownloadRelease unsupported; will try release push.") - response = None - else: - raise - if response is None and push_payload: - logger.info("Sonarr grab: attempting release push.") - try: - response = await client.push_release(push_payload) - except httpx.HTTPStatusError as exc: - if exc.response is not None and exc.response.status_code == 404: - logger.info("Sonarr grab: release push not supported.") - else: - raise - if response is None: - category = _resolve_qbittorrent_category( - runtime.sonarr_qbittorrent_category, "sonarr" - ) - qbittorrent_added = await _fallback_qbittorrent_download(download_url, category) - if qbittorrent_added: - action_message = f"Grab sent to qBittorrent (category {category})." - response = {"qbittorrent": "queued"} - else: - if resolved_indexer_id is None: - detail = "Indexer not found in Sonarr and no release push available." - elif not push_payload: - detail = "Sonarr did not accept the grab request (no release URL available)." - else: - detail = "Sonarr did not accept the grab request (DownloadRelease unsupported)." - raise HTTPException(status_code=400, detail=detail) - except httpx.HTTPStatusError as exc: - raise HTTPException(status_code=502, detail=f"Sonarr grab failed: {exc}") from exc - await asyncio.to_thread( - save_action, request_id, "grab", "Grab release", "ok", action_message - ) - return {"status": "ok", "response": response} + category = _resolve_qbittorrent_category(runtime.sonarr_qbittorrent_category, "sonarr") if snapshot.request_type.value == "movie": - client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key) - if not client.configured(): - raise HTTPException(status_code=400, detail="Radarr not configured") - try: - indexers = await client.get_indexers() - resolved_indexer_id = _resolve_arr_indexer_id(indexers, indexer_name, indexer_id, "Radarr") - response = None - action_message = "Grab sent to Radarr." - if resolved_indexer_id is not None: - indexer_id = resolved_indexer_id - logger.info("Radarr grab: attempting DownloadRelease command.") - try: - response = await client.download_release(str(guid), int(indexer_id)) - except httpx.HTTPStatusError as exc: - if exc.response is not None and exc.response.status_code in {404, 405}: - logger.info("Radarr grab: DownloadRelease unsupported; will try release push.") - response = None - else: - raise - if response is None and push_payload: - logger.info("Radarr grab: attempting release push.") - try: - response = await client.push_release(push_payload) - except httpx.HTTPStatusError as exc: - if exc.response is not None and exc.response.status_code == 404: - logger.info("Radarr grab: release push not supported.") - else: - raise - if response is None: - category = _resolve_qbittorrent_category( - runtime.radarr_qbittorrent_category, "radarr" - ) - qbittorrent_added = await _fallback_qbittorrent_download(download_url, category) - if qbittorrent_added: - action_message = f"Grab sent to qBittorrent (category {category})." - response = {"qbittorrent": "queued"} - else: - if resolved_indexer_id is None: - detail = "Indexer not found in Radarr and no release push available." - elif not push_payload: - detail = "Radarr did not accept the grab request (no release URL available)." - else: - detail = "Radarr did not accept the grab request (DownloadRelease unsupported)." - raise HTTPException(status_code=400, detail=detail) - except httpx.HTTPStatusError as exc: - raise HTTPException(status_code=502, detail=f"Radarr grab failed: {exc}") from exc - await asyncio.to_thread( - save_action, request_id, "grab", "Grab release", "ok", action_message - ) - return {"status": "ok", "response": response} + category = _resolve_qbittorrent_category(runtime.radarr_qbittorrent_category, "radarr") + if snapshot.request_type.value not in {"tv", "movie"}: + raise HTTPException(status_code=400, detail="Unknown request type") + + qbittorrent_added = await _fallback_qbittorrent_download(download_url, category) + if not qbittorrent_added: + raise HTTPException(status_code=400, detail="Failed to add torrent to qBittorrent") + action_message = f"Grab sent to qBittorrent (category {category})." + await asyncio.to_thread( + save_action, request_id, "grab", "Grab release", "ok", action_message + ) + return {"status": "ok", "response": {"qbittorrent": "queued"}} - raise HTTPException(status_code=400, detail="Unknown request type") diff --git a/backend/app/routers/site.py b/backend/app/routers/site.py new file mode 100644 index 0000000..fa99220 --- /dev/null +++ b/backend/app/routers/site.py @@ -0,0 +1,39 @@ +from typing import Any, Dict + +from fastapi import APIRouter, Depends + +from ..auth import get_current_user +from ..runtime import get_runtime_settings + +router = APIRouter(prefix="/site", tags=["site"]) + +_BANNER_TONES = {"info", "warning", "error", "maintenance"} + + +def _build_site_info(include_changelog: bool) -> Dict[str, Any]: + runtime = get_runtime_settings() + banner_message = (runtime.site_banner_message or "").strip() + tone = (runtime.site_banner_tone or "info").strip().lower() + if tone not in _BANNER_TONES: + tone = "info" + info = { + "buildNumber": (runtime.site_build_number or "").strip(), + "banner": { + "enabled": bool(runtime.site_banner_enabled and banner_message), + "message": banner_message, + "tone": tone, + }, + } + if include_changelog: + info["changelog"] = (runtime.site_changelog or "").strip() + return info + + +@router.get("/public") +async def site_public() -> Dict[str, Any]: + return _build_site_info(False) + + +@router.get("/info") +async def site_info(user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]: + return _build_site_info(True) diff --git a/backend/app/runtime.py b/backend/app/runtime.py index 1ad0fc4..e57fdce 100644 --- a/backend/app/runtime.py +++ b/backend/app/runtime.py @@ -12,6 +12,7 @@ _INT_FIELDS = { } _BOOL_FIELDS = { "jellyfin_sync_to_arr", + "site_banner_enabled", } diff --git a/frontend/app/admin/SettingsPage.tsx b/frontend/app/admin/SettingsPage.tsx index 05e7f5f..d09dc12 100644 --- a/frontend/app/admin/SettingsPage.tsx +++ b/frontend/app/admin/SettingsPage.tsx @@ -29,9 +29,12 @@ const SECTION_LABELS: Record = { qbittorrent: 'qBittorrent', log: 'Activity log', requests: 'Request syncing', + site: 'Site', } -const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr']) +const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr', 'site_banner_enabled']) +const TEXTAREA_SETTINGS = new Set(['site_banner_message', 'site_changelog']) +const BANNER_TONES = ['info', 'warning', 'error', 'maintenance'] const SECTION_DESCRIPTIONS: Record = { jellyseerr: 'Connect the request system where users submit content.', @@ -44,6 +47,7 @@ const SECTION_DESCRIPTIONS: Record = { qbittorrent: 'Downloader connection settings.', requests: 'Sync and refresh cadence for requests.', log: 'Activity log for troubleshooting.', + site: 'Sitewide banner, version, and changelog details.', } const SETTINGS_SECTION_MAP: Record = { @@ -58,6 +62,7 @@ const SETTINGS_SECTION_MAP: Record = { cache: null, logs: 'log', maintenance: null, + site: 'site', } const labelFromKey = (key: string) => @@ -78,6 +83,11 @@ const labelFromKey = (key: string) => .replace('jellyfin public url', 'Jellyfin public URL') .replace('jellyfin sync to arr', 'Sync Jellyfin to Sonarr/Radarr') .replace('artwork cache mode', 'Artwork cache mode') + .replace('site build number', 'Build number') + .replace('site banner enabled', 'Sitewide banner enabled') + .replace('site banner message', 'Sitewide banner message') + .replace('site banner tone', 'Sitewide banner tone') + .replace('site changelog', 'Changelog text') type SettingsPageProps = { section: string @@ -290,6 +300,11 @@ export default function SettingsPage({ section }: SettingsPageProps) { requests_data_source: 'Pick where Magent should read requests from.', log_level: 'How much detail is written to the activity log.', log_file: 'Where the activity log is stored.', + site_build_number: 'Version or build identifier shown in the footer.', + site_banner_enabled: 'Enable a sitewide banner for announcements.', + site_banner_message: 'Short banner message for maintenance or updates.', + site_banner_tone: 'Visual tone for the banner.', + site_changelog: 'One update per line for the public changelog.', } const buildSelectOptions = ( @@ -1008,6 +1023,34 @@ export default function SettingsPage({ section }: SettingsPageProps) { ) } + if (setting.key === 'site_banner_tone') { + return ( + + ) + } if ( setting.key === 'requests_full_sync_time' || setting.key === 'requests_cleanup_time' @@ -1086,6 +1129,35 @@ export default function SettingsPage({ section }: SettingsPageProps) { ) } + if (TEXTAREA_SETTINGS.has(setting.key)) { + return ( +