from typing import Any, Dict, List from datetime import datetime, timedelta, timezone import secrets 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_invite_profile, list_invite_profiles, create_invite_profile, create_invite, list_invites, disable_invite, delete_invite, delete_user, get_all_contacts, get_request_cache_overview, save_announcement, 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, ) 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 from ..services.jellyseerr_sync import sync_jellyseerr_users import logging from ..logging_config import configure_logging from ..routers import requests as requests_router from ..routers.branding import save_branding_image from ..services.notifications import send_notification 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", "smtp_password", "hcaptcha_secret_key", "recaptcha_secret_key", "turnstile_secret_key", "telegram_bot_token", "matrix_password", "matrix_access_token", "pushover_token", "pushover_user_key", "pushbullet_token", "gotify_token", } 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", "radarr_base_url", "radarr_api_key", "radarr_quality_profile_id", "radarr_root_folder", "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", "invites_enabled", "invites_require_captcha", "invite_default_profile_id", "signup_allow_referrals", "referral_default_uses", "password_min_length", "password_require_upper", "password_require_lower", "password_require_number", "password_require_symbol", "password_reset_enabled", "captcha_provider", "hcaptcha_site_key", "hcaptcha_secret_key", "recaptcha_site_key", "recaptcha_secret_key", "turnstile_site_key", "turnstile_secret_key", "smtp_host", "smtp_port", "smtp_user", "smtp_password", "smtp_from", "smtp_tls", "smtp_starttls", "notify_email_enabled", "notify_discord_enabled", "notify_telegram_enabled", "notify_matrix_enabled", "notify_pushover_enabled", "notify_pushbullet_enabled", "notify_gotify_enabled", "notify_ntfy_enabled", "telegram_bot_token", "telegram_chat_id", "matrix_homeserver", "matrix_user", "matrix_password", "matrix_access_token", "matrix_room_id", "pushover_token", "pushover_user_key", "pushbullet_token", "gotify_url", "gotify_token", "ntfy_url", "ntfy_topic", "expiry_default_days", "expiry_default_action", "expiry_warning_days", "expiry_check_interval_minutes", "jellyseerr_sync_users", "jellyseerr_sync_interval_minutes", ] 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 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("/jellyseerr/users/sync") async def jellyseerr_users_sync() -> Dict[str, Any]: imported = await sync_jellyseerr_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() -> Dict[str, Any]: runtime = get_runtime_settings() state = await requests_router.start_artwork_prefetch( runtime.jellyseerr_base_url, runtime.jellyseerr_api_key ) 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/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]: return {"rows": get_request_cache_overview(limit)} @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} @router.post("/users/bulk") async def bulk_user_action(payload: Dict[str, Any]) -> Dict[str, Any]: action = str(payload.get("action") or "").strip().lower() usernames = payload.get("usernames") if not isinstance(usernames, list) or not usernames: raise HTTPException(status_code=400, detail="User list required") if action not in {"block", "unblock", "delete", "role"}: raise HTTPException(status_code=400, detail="Invalid action") updated = 0 for username in usernames: if not isinstance(username, str) or not username.strip(): continue name = username.strip() if action == "block": set_user_blocked(name, True) elif action == "unblock": set_user_blocked(name, False) elif action == "delete": delete_user(name) elif action == "role": role = str(payload.get("role") or "").strip().lower() if role not in {"admin", "user"}: raise HTTPException(status_code=400, detail="Invalid role") set_user_role(name, role) updated += 1 return {"status": "ok", "updated": updated} @router.get("/invite-profiles") async def invite_profiles() -> Dict[str, Any]: return {"profiles": list_invite_profiles()} @router.post("/invite-profiles") async def create_profile( payload: Dict[str, Any], user: Dict[str, Any] = Depends(require_admin) ) -> Dict[str, Any]: name = str(payload.get("name") or "").strip() if not name: raise HTTPException(status_code=400, detail="Profile name required") profile_id = create_invite_profile( name=name, description=str(payload.get("description") or "").strip() or None, max_uses=payload.get("max_uses"), expires_in_days=payload.get("expires_in_days"), require_captcha=bool(payload.get("require_captcha")), password_rules=payload.get("password_rules") if isinstance(payload.get("password_rules"), dict) else None, allow_referrals=bool(payload.get("allow_referrals")), referral_uses=payload.get("referral_uses"), user_expiry_days=payload.get("user_expiry_days"), user_expiry_action=str(payload.get("user_expiry_action") or "").strip() or None, ) return {"status": "ok", "id": profile_id} @router.get("/invites") async def list_invites_endpoint(limit: int = 200) -> Dict[str, Any]: return {"invites": list_invites(limit)} @router.post("/invites") async def create_invite_endpoint( payload: Dict[str, Any], user: Dict[str, Any] = Depends(require_admin) ) -> Dict[str, Any]: runtime = get_runtime_settings() profile_id = payload.get("profile_id") profile = None if profile_id is not None: try: profile = get_invite_profile(int(profile_id)) except (TypeError, ValueError): profile = None expires_in_days = payload.get("expires_in_days") or (profile.get("expires_in_days") if profile else None) expires_at = None if expires_in_days: try: expires_at = ( datetime.now(timezone.utc) + timedelta(days=float(expires_in_days)) ).isoformat() except (TypeError, ValueError): expires_at = None require_captcha = bool(payload.get("require_captcha")) if not require_captcha and profile: require_captcha = bool(profile.get("require_captcha")) if not require_captcha: require_captcha = runtime.invites_require_captcha password_rules = payload.get("password_rules") if not isinstance(password_rules, dict): password_rules = profile.get("password_rules") if profile else None allow_referrals = bool(payload.get("allow_referrals")) if not allow_referrals and profile: allow_referrals = bool(profile.get("allow_referrals")) user_expiry_days = payload.get("user_expiry_days") or (profile.get("user_expiry_days") if profile else None) user_expiry_action = payload.get("user_expiry_action") or (profile.get("user_expiry_action") if profile else None) code = secrets.token_urlsafe(8) create_invite( code=code, created_by=user.get("username"), profile_id=int(profile_id) if profile_id is not None else None, expires_at=expires_at, max_uses=payload.get("max_uses") or (profile.get("max_uses") if profile else None), require_captcha=require_captcha, password_rules=password_rules if isinstance(password_rules, dict) else None, allow_referrals=allow_referrals, referral_uses=payload.get("referral_uses") or (profile.get("referral_uses") if profile else None), user_expiry_days=user_expiry_days, user_expiry_action=str(user_expiry_action) if user_expiry_action else None, is_referral=bool(payload.get("is_referral")), ) return {"status": "ok", "code": code} @router.post("/invites/{code}/disable") async def disable_invite_endpoint(code: str) -> Dict[str, Any]: disable_invite(code) return {"status": "ok", "code": code, "disabled": True} @router.delete("/invites/{code}") async def delete_invite_endpoint(code: str) -> Dict[str, Any]: delete_invite(code) return {"status": "ok", "code": code, "deleted": True} @router.post("/announcements") async def send_announcement( payload: Dict[str, Any], user: Dict[str, Any] = Depends(require_admin) ) -> Dict[str, Any]: subject = str(payload.get("subject") or "").strip() body = str(payload.get("body") or "").strip() channels = payload.get("channels") if isinstance(payload.get("channels"), list) else [] if not subject or not body: raise HTTPException(status_code=400, detail="Subject and message required") results: Dict[str, Any] = {} email_count = 0 email_failed = 0 if "email" in [str(c).lower() for c in channels]: for contact in get_all_contacts(): email = contact.get("email") if not email: continue outcome = await send_notification(subject, body, channels=["email"], email=email) if outcome.get("email") == "sent": email_count += 1 else: email_failed += 1 results["email"] = {"sent": email_count, "failed": email_failed} other_channels = [c for c in channels if str(c).lower() != "email"] if other_channels: results.update(await send_notification(subject, body, channels=other_channels)) save_announcement(user.get("username"), subject, body, ",".join(channels)) return {"status": "ok", "results": results}