diff --git a/.build_number b/.build_number index 4cdf183..21f3afc 100644 --- a/.build_number +++ b/.build_number @@ -1 +1 @@ -271261539 +0202261541 diff --git a/backend/app/build_info.py b/backend/app/build_info.py index cbd26fa..1116c8f 100644 --- a/backend/app/build_info.py +++ b/backend/app/build_info.py @@ -1,2 +1,2 @@ -BUILD_NUMBER = "3001262148" +BUILD_NUMBER = "0202261541" CHANGELOG = '2026-01-22\\n- Initial commit\\n- Ignore build artifacts\\n- Update README\\n- Update README with Docker-first guide\\n\\n2026-01-23\\n- Fix cache titles via Jellyseerr media lookup\\n- Split search actions and improve download options\\n- Fallback manual grab to qBittorrent\\n- Hide header actions when signed out\\n- Add feedback form and webhook\\n- Fix cache titles and move feedback link\\n- Show available status on landing when in Jellyfin\\n- Add default branding assets when missing\\n- Use bundled branding assets\\n- Remove password fields from users page\\n- Add Docker Hub compose override\\n- Fix backend Dockerfile paths for root context\\n- Copy public assets into frontend image\\n- Use backend branding assets for logo and favicon\\n\\n2026-01-24\\n- Route grabs through Sonarr/Radarr only\\n- Document fix buttons in how-it-works\\n- Clarify how-it-works steps and fixes\\n- Map Prowlarr releases to Arr indexers for manual grab\\n- Improve request handling and qBittorrent categories\\n\\n2026-01-25\\n- Add site banner, build number, and changelog\\n- Automate build number tagging and sync\\n- Improve mobile header layout\\n- Move account actions into avatar menu\\n- Add user stats and activity tracking\\n- Add Jellyfin login cache and admin-only stats\\n- Tidy request sync controls\\n- Seed branding logo from bundled assets\\n- Serve bundled branding assets by default\\n- Harden request cache titles and cache-only reads\\n- Build 2501262041\\n\\n2026-01-26\\n- Fix cache title hydration\\n- Fix sync progress bar animation\\n\\n2026-01-27\\n- Add cache control artwork stats\\n- Improve cache stats performance (build 271261145)\\n- Fix backend cache stats import (build 271261149)\\n- Clarify request sync settings (build 271261159)\\n- Bump build number to 271261202\\n- Fix request titles in snapshots (build 271261219)\\n- Fix snapshot title fallback (build 271261228)\\n- Add cache load spinner (build 271261238)\\n- Bump build number (process 2) 271261322\\n- Add service test buttons (build 271261335)\\n- Fallback to TMDB when artwork cache fails (build 271261524)\\n- Hydrate missing artwork from Jellyseerr (build 271261539)\\n\\n2026-01-29\\n- release: 2901262036\\n- release: 2901262044\\n- release: 2901262102\\n- Hardcode build number in backend\\n- Bake build number and changelog\\n- Update full changelog\\n- Tidy full changelog\\n- Build 2901262240: cache users\n\n2026-01-30\n- Merge backend and frontend into one container' diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index ce8ef68..2d5ebee 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -1,6 +1,8 @@ from typing import Any, Dict, List, Optional from datetime import datetime, timedelta, timezone +import ipaddress import os +from urllib.parse import urlparse, urlunparse from fastapi import APIRouter, HTTPException, Depends, UploadFile, File @@ -64,6 +66,16 @@ SENSITIVE_KEYS = { "qbittorrent_password", } +URL_SETTING_KEYS = { + "jellyseerr_base_url", + "jellyfin_base_url", + "jellyfin_public_url", + "sonarr_base_url", + "radarr_base_url", + "prowlarr_base_url", + "qbittorrent_base_url", +} + SETTING_KEYS: List[str] = [ "jellyseerr_base_url", "jellyseerr_api_key", @@ -107,6 +119,49 @@ def _normalize_username(value: str) -> str: normalized = normalized.split("@", 1)[0] return normalized + +def _is_ip_host(host: str) -> bool: + try: + ipaddress.ip_address(host) + return True + except ValueError: + return False + + +def _normalize_service_url(value: str) -> str: + raw = value.strip() + if not raw: + raise ValueError("URL cannot be empty.") + + candidate = raw + if "://" not in candidate: + authority = candidate.split("/", 1)[0].strip() + if authority.startswith("["): + closing = authority.find("]") + host = authority[1:closing] if closing > 0 else authority.strip("[]") + else: + host = authority.split(":", 1)[0] + host = host.strip().lower() + default_scheme = "http" if host in {"localhost"} or _is_ip_host(host) or "." not in host else "https" + candidate = f"{default_scheme}://{candidate}" + + parsed = urlparse(candidate) + if parsed.scheme not in {"http", "https"}: + raise ValueError("URL must use http:// or https://.") + if not parsed.netloc: + raise ValueError("URL must include a host.") + if parsed.query or parsed.fragment: + raise ValueError("URL must not include query params or fragments.") + if not parsed.hostname: + raise ValueError("URL must include a valid host.") + + normalized_path = parsed.path.rstrip("/") + normalized = parsed._replace(path=normalized_path, params="", query="", fragment="") + result = urlunparse(normalized).rstrip("/") + if not result: + raise ValueError("URL is invalid.") + return result + def _normalize_root_folders(folders: Any) -> List[Dict[str, Any]]: if not isinstance(folders, list): return [] @@ -203,7 +258,14 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]: delete_setting(key) updates += 1 continue - set_setting(key, str(value)) + value_to_store = str(value).strip() if isinstance(value, str) else str(value) + if key in URL_SETTING_KEYS and value_to_store: + try: + value_to_store = _normalize_service_url(value_to_store) + except ValueError as exc: + friendly_key = key.replace("_", " ") + raise HTTPException(status_code=400, detail=f"{friendly_key}: {exc}") from exc + set_setting(key, value_to_store) updates += 1 if key in {"log_level", "log_file"}: touched_logging = True diff --git a/frontend/app/admin/SettingsPage.tsx b/frontend/app/admin/SettingsPage.tsx index 9a22372..319e61e 100644 --- a/frontend/app/admin/SettingsPage.tsx +++ b/frontend/app/admin/SettingsPage.tsx @@ -34,6 +34,15 @@ const SECTION_LABELS: Record = { const BOOL_SETTINGS = new Set(['jellyfin_sync_to_arr', 'site_banner_enabled']) const TEXTAREA_SETTINGS = new Set(['site_banner_message', 'site_changelog']) +const URL_SETTINGS = new Set([ + 'jellyseerr_base_url', + 'jellyfin_base_url', + 'jellyfin_public_url', + 'sonarr_base_url', + 'radarr_base_url', + 'prowlarr_base_url', + 'qbittorrent_base_url', +]) const BANNER_TONES = ['info', 'warning', 'error', 'maintenance'] const SECTION_DESCRIPTIONS: Record = { @@ -330,26 +339,31 @@ export default function SettingsPage({ section }: SettingsPageProps) { } const settingDescriptions: Record = { - jellyseerr_base_url: 'Base URL for your Jellyseerr server.', + jellyseerr_base_url: + 'Base URL for your Jellyseerr server (FQDN or IP). Scheme is optional.', jellyseerr_api_key: 'API key used to read requests and status.', - jellyfin_base_url: 'Local Jellyfin server URL for logins and lookups.', + jellyfin_base_url: + 'Jellyfin server URL for logins and lookups (FQDN or IP). Scheme is optional.', jellyfin_api_key: 'Admin API key for syncing users and availability.', - jellyfin_public_url: 'Public Jellyfin URL used for the “Open in Jellyfin” button.', + jellyfin_public_url: + 'Public Jellyfin URL for the “Open in Jellyfin” button (FQDN or IP).', jellyfin_sync_to_arr: 'Auto-add items to Sonarr/Radarr when they already exist in Jellyfin.', artwork_cache_mode: 'Choose whether posters are cached locally or loaded from the web.', - sonarr_base_url: 'Sonarr server URL for TV tracking.', + sonarr_base_url: 'Sonarr server URL for TV tracking (FQDN or IP). Scheme is optional.', 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_base_url: 'Radarr server URL for movies (FQDN or IP). Scheme is optional.', 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_base_url: + 'Prowlarr server URL for indexer searches (FQDN or IP). Scheme is optional.', prowlarr_api_key: 'API key for Prowlarr.', - qbittorrent_base_url: 'qBittorrent server URL for download status.', + qbittorrent_base_url: + 'qBittorrent server URL for download status (FQDN or IP). Scheme is optional.', qbittorrent_username: 'qBittorrent login username.', qbittorrent_password: 'qBittorrent login password.', requests_sync_ttl_minutes: 'How long saved requests stay fresh before a refresh is needed.', @@ -371,6 +385,16 @@ export default function SettingsPage({ section }: SettingsPageProps) { site_changelog: 'One update per line for the public changelog.', } + const settingPlaceholders: Record = { + jellyseerr_base_url: 'https://requests.example.com or 10.30.1.81:5055', + jellyfin_base_url: 'https://jelly.example.com or 10.40.0.80:8096', + jellyfin_public_url: 'https://jelly.example.com', + sonarr_base_url: 'https://sonarr.example.com or 10.30.1.81:8989', + radarr_base_url: 'https://radarr.example.com or 10.30.1.81:7878', + prowlarr_base_url: 'https://prowlarr.example.com or 10.30.1.81:9696', + qbittorrent_base_url: 'https://qb.example.com or 10.30.1.81:8080', + } + const buildSelectOptions = ( currentValue: string, options: { id: number; label: string; path?: string }[], @@ -982,6 +1006,10 @@ export default function SettingsPage({ section }: SettingsPageProps) { const isRadarrProfile = setting.key === 'radarr_quality_profile_id' const isRadarrRoot = setting.key === 'radarr_root_folder' const isBoolSetting = BOOL_SETTINGS.has(setting.key) + const isUrlSetting = URL_SETTINGS.has(setting.key) + const inputPlaceholder = setting.sensitive && setting.isSet + ? 'Configured (enter to replace)' + : settingPlaceholders[setting.key] ?? '' if (isBoolSetting) { return (