from typing import Any, Dict, List import os from fastapi import APIRouter, HTTPException, Depends, UploadFile, File from ..auth import require_admin from ..config import settings as env_settings from ..db import ( delete_setting, get_all_users, get_request_cache_overview, get_request_cache_missing_titles, get_request_cache_stats, get_settings_overrides, get_user_by_username, set_setting, set_user_blocked, set_user_password, set_user_role, run_integrity_check, vacuum_db, clear_requests_cache, clear_history, cleanup_history, update_request_cache_title, repair_request_cache_titles, ) from ..runtime import get_runtime_settings from ..clients.sonarr import SonarrClient from ..clients.radarr import RadarrClient from ..clients.jellyfin import JellyfinClient from ..clients.jellyseerr import JellyseerrClient from ..services.jellyfin_sync import sync_jellyfin_users import logging from ..logging_config import configure_logging from ..routers import requests as requests_router from ..routers.branding import save_branding_image router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)]) logger = logging.getLogger(__name__) SENSITIVE_KEYS = { "jellyseerr_api_key", "jellyfin_api_key", "sonarr_api_key", "radarr_api_key", "prowlarr_api_key", "qbittorrent_password", } SETTING_KEYS: List[str] = [ "jellyseerr_base_url", "jellyseerr_api_key", "jellyfin_base_url", "jellyfin_api_key", "jellyfin_public_url", "jellyfin_sync_to_arr", "artwork_cache_mode", "sonarr_base_url", "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", "qbittorrent_username", "qbittorrent_password", "log_level", "log_file", "requests_sync_ttl_minutes", "requests_poll_interval_seconds", "requests_delta_sync_interval_minutes", "requests_full_sync_time", "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]]: if not isinstance(folders, list): return [] results = [] for folder in folders: if not isinstance(folder, dict): continue folder_id = folder.get("id") path = folder.get("path") if folder_id is None or path is None: continue results.append({"id": folder_id, "path": path, "label": path}) return results async def _hydrate_cache_titles_from_jellyseerr(limit: int) -> int: runtime = get_runtime_settings() client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) if not client.configured(): return 0 missing = get_request_cache_missing_titles(limit) if not missing: return 0 hydrated = 0 for row in missing: tmdb_id = row.get("tmdb_id") media_type = row.get("media_type") request_id = row.get("request_id") if not tmdb_id or not media_type or not request_id: continue try: title, year = await requests_router._hydrate_title_from_tmdb( client, media_type, tmdb_id ) except Exception: logger.warning( "Requests cache title hydrate failed: request_id=%s tmdb_id=%s", request_id, tmdb_id, ) continue if title: update_request_cache_title(request_id, title, year) hydrated += 1 return hydrated def _normalize_quality_profiles(profiles: Any) -> List[Dict[str, Any]]: if not isinstance(profiles, list): return [] results = [] for profile in profiles: if not isinstance(profile, dict): continue profile_id = profile.get("id") name = profile.get("name") if profile_id is None or name is None: continue results.append({"id": profile_id, "name": name, "label": name}) return results @router.get("/settings") async def list_settings() -> Dict[str, Any]: overrides = get_settings_overrides() results = [] for key in SETTING_KEYS: override_present = key in overrides value = overrides.get(key) if override_present else getattr(env_settings, key) is_set = value is not None and str(value).strip() != "" sensitive = key in SENSITIVE_KEYS results.append( { "key": key, "value": None if sensitive else value, "isSet": is_set, "source": "db" if override_present else ("env" if is_set else "unset"), "sensitive": sensitive, } ) return {"settings": results} @router.put("/settings") async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]: updates = 0 touched_logging = False for key, value in payload.items(): if key not in SETTING_KEYS: raise HTTPException(status_code=400, detail=f"Unknown setting: {key}") if value is None: continue if isinstance(value, str) and value.strip() == "": delete_setting(key) updates += 1 continue set_setting(key, str(value)) updates += 1 if key in {"log_level", "log_file"}: touched_logging = True if touched_logging: runtime = get_runtime_settings() configure_logging(runtime.log_level, runtime.log_file) return {"status": "ok", "updated": updates} @router.get("/sonarr/options") async def sonarr_options() -> Dict[str, Any]: runtime = get_runtime_settings() client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key) if not client.configured(): raise HTTPException(status_code=400, detail="Sonarr not configured") root_folders = await client.get_root_folders() profiles = await client.get_quality_profiles() return { "rootFolders": _normalize_root_folders(root_folders), "qualityProfiles": _normalize_quality_profiles(profiles), } @router.get("/radarr/options") async def radarr_options() -> Dict[str, Any]: runtime = get_runtime_settings() client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key) if not client.configured(): raise HTTPException(status_code=400, detail="Radarr not configured") root_folders = await client.get_root_folders() profiles = await client.get_quality_profiles() return { "rootFolders": _normalize_root_folders(root_folders), "qualityProfiles": _normalize_quality_profiles(profiles), } @router.get("/jellyfin/users") async def jellyfin_users() -> Dict[str, Any]: runtime = get_runtime_settings() client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) if not client.configured(): raise HTTPException(status_code=400, detail="Jellyfin not configured") users = await client.get_users() if not isinstance(users, list): return {"users": []} results = [] for user in users: if not isinstance(user, dict): continue results.append( { "id": user.get("Id"), "name": user.get("Name"), "hasPassword": user.get("HasPassword"), "lastLoginDate": user.get("LastLoginDate"), } ) return {"users": results} @router.post("/jellyfin/users/sync") async def jellyfin_users_sync() -> Dict[str, Any]: imported = await sync_jellyfin_users() return {"status": "ok", "imported": imported} @router.post("/requests/sync") async def requests_sync() -> Dict[str, Any]: runtime = get_runtime_settings() client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) if not client.configured(): raise HTTPException(status_code=400, detail="Jellyseerr not configured") state = await requests_router.start_requests_sync( runtime.jellyseerr_base_url, runtime.jellyseerr_api_key ) logger.info("Admin triggered requests sync: status=%s", state.get("status")) return {"status": "ok", "sync": state} @router.post("/requests/sync/delta") async def requests_sync_delta() -> Dict[str, Any]: runtime = get_runtime_settings() client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) if not client.configured(): raise HTTPException(status_code=400, detail="Jellyseerr not configured") state = await requests_router.start_requests_delta_sync( runtime.jellyseerr_base_url, runtime.jellyseerr_api_key ) logger.info("Admin triggered delta requests sync: status=%s", state.get("status")) return {"status": "ok", "sync": state} @router.post("/requests/artwork/prefetch") async def requests_artwork_prefetch(only_missing: bool = False) -> Dict[str, Any]: runtime = get_runtime_settings() state = await requests_router.start_artwork_prefetch( runtime.jellyseerr_base_url, runtime.jellyseerr_api_key, only_missing=only_missing, ) logger.info("Admin triggered artwork prefetch: status=%s", state.get("status")) return {"status": "ok", "prefetch": state} @router.get("/requests/artwork/status") async def requests_artwork_status() -> Dict[str, Any]: return {"status": "ok", "prefetch": requests_router.get_artwork_prefetch_state()} @router.get("/requests/artwork/summary") async def requests_artwork_summary() -> Dict[str, Any]: runtime = get_runtime_settings() cache_mode = (runtime.artwork_cache_mode or "remote").lower() stats = get_request_cache_stats() if cache_mode != "cache": stats["cache_bytes"] = 0 stats["cache_files"] = 0 stats["missing_artwork"] = 0 summary = { "cache_mode": cache_mode, "cache_bytes": stats.get("cache_bytes", 0), "cache_files": stats.get("cache_files", 0), "missing_artwork": stats.get("missing_artwork", 0), "total_requests": stats.get("total_requests", 0), "updated_at": stats.get("updated_at"), } return {"status": "ok", "summary": summary} @router.get("/requests/sync/status") async def requests_sync_status() -> Dict[str, Any]: return {"status": "ok", "sync": requests_router.get_requests_sync_state()} @router.get("/logs") async def read_logs(lines: int = 200) -> Dict[str, Any]: runtime = get_runtime_settings() log_file = runtime.log_file if not log_file: raise HTTPException(status_code=400, detail="Log file not configured") if not os.path.isabs(log_file): log_file = os.path.join(os.getcwd(), log_file) if not os.path.exists(log_file): raise HTTPException(status_code=404, detail="Log file not found") lines = max(1, min(lines, 1000)) from collections import deque with open(log_file, "r", encoding="utf-8", errors="replace") as handle: tail = deque(handle, maxlen=lines) return {"lines": list(tail)} @router.get("/requests/cache") async def requests_cache(limit: int = 50) -> Dict[str, Any]: repaired = repair_request_cache_titles() if repaired: logger.info("Requests cache titles repaired via settings view: %s", repaired) hydrated = await _hydrate_cache_titles_from_jellyseerr(limit) if hydrated: logger.info("Requests cache titles hydrated via Jellyseerr: %s", hydrated) rows = get_request_cache_overview(limit) return {"rows": rows} @router.post("/branding/logo") async def upload_branding_logo(file: UploadFile = File(...)) -> Dict[str, Any]: return await save_branding_image(file) @router.post("/maintenance/repair") async def repair_database() -> Dict[str, Any]: result = run_integrity_check() vacuum_db() logger.info("Database repair executed: integrity_check=%s", result) return {"status": "ok", "integrity": result} @router.post("/maintenance/flush") async def flush_database() -> Dict[str, Any]: cleared = clear_requests_cache() history = clear_history() delete_setting("requests_sync_last_at") logger.warning("Database flush executed: requests_cache=%s history=%s", cleared, history) return {"status": "ok", "requestsCleared": cleared, "historyCleared": history} @router.post("/maintenance/cleanup") async def cleanup_database(days: int = 90) -> Dict[str, Any]: result = cleanup_history(days) logger.info("Database cleanup executed: days=%s result=%s", days, result) return {"status": "ok", "days": days, "cleared": result} @router.post("/maintenance/logs/clear") async def clear_logs() -> Dict[str, Any]: runtime = get_runtime_settings() log_file = runtime.log_file if not log_file: raise HTTPException(status_code=400, detail="Log file not configured") if not os.path.isabs(log_file): log_file = os.path.join(os.getcwd(), log_file) try: os.makedirs(os.path.dirname(log_file), exist_ok=True) with open(log_file, "w", encoding="utf-8"): pass except OSError as exc: raise HTTPException(status_code=500, detail=str(exc)) from exc logger.info("Log file cleared") return {"status": "ok"} @router.get("/users") async def list_users() -> Dict[str, Any]: users = get_all_users() return {"users": users} @router.post("/users/{username}/block") async def block_user(username: str) -> Dict[str, Any]: set_user_blocked(username, True) return {"status": "ok", "username": username, "blocked": True} @router.post("/users/{username}/unblock") async def unblock_user(username: str) -> Dict[str, Any]: set_user_blocked(username, False) return {"status": "ok", "username": username, "blocked": False} @router.post("/users/{username}/role") async def update_user_role(username: str, payload: Dict[str, Any]) -> Dict[str, Any]: role = payload.get("role") if role not in {"admin", "user"}: raise HTTPException(status_code=400, detail="Invalid role") set_user_role(username, role) return {"status": "ok", "username": username, "role": role} @router.post("/users/{username}/password") async def update_user_password(username: str, payload: Dict[str, Any]) -> Dict[str, Any]: new_password = payload.get("password") if isinstance(payload, dict) else None if not isinstance(new_password, str) or len(new_password.strip()) < 8: raise HTTPException(status_code=400, detail="Password must be at least 8 characters.") user = get_user_by_username(username) if not user: raise HTTPException(status_code=404, detail="User not found") if user.get("auth_provider") != "local": raise HTTPException( status_code=400, detail="Password changes are only available for local users." ) set_user_password(username, new_password.strip()) return {"status": "ok", "username": username}