From 38eee2407bed61c5e3deaf34a211f16b643f695f Mon Sep 17 00:00:00 2001 From: Rephl3x Date: Sun, 25 Jan 2026 14:28:16 +1300 Subject: [PATCH] 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 ( +