Files
Magent/backend/app/routers/admin.py
2026-01-29 20:38:37 +13:00

601 lines
21 KiB
Python

from typing import Any, Dict, List, Optional
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_id,
get_user_by_username,
get_user_request_stats,
create_user_if_missing,
set_user_jellyseerr_id,
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,
delete_non_admin_users,
)
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_username(value: str) -> str:
normalized = value.strip().lower()
if "@" in normalized:
normalized = normalized.split("@", 1)[0]
return normalized
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}
def _normalized_handles(value: Any) -> List[str]:
if not isinstance(value, str):
return []
normalized = value.strip().lower()
if not normalized:
return []
handles = [normalized]
if "@" in normalized:
handles.append(normalized.split("@", 1)[0])
return handles
def _extract_user_candidates(user: Dict[str, Any]) -> List[str]:
candidates: List[str] = []
for key in ("username", "email", "displayName", "name"):
candidates.extend(_normalized_handles(user.get(key)))
return list(dict.fromkeys(candidates))
async def _fetch_all_jellyseerr_users(client: JellyseerrClient) -> List[Dict[str, Any]]:
users: List[Dict[str, Any]] = []
take = 100
skip = 0
while True:
payload = await client.get_users(take=take, skip=skip)
if not payload:
break
if isinstance(payload, list):
batch = payload
elif isinstance(payload, dict):
batch = payload.get("results") or payload.get("users") or payload.get("data") or payload.get("items")
else:
batch = None
if not isinstance(batch, list) or not batch:
break
users.extend([user for user in batch if isinstance(user, dict)])
if len(batch) < take:
break
skip += take
return users
@router.post("/jellyseerr/users/sync")
async def jellyseerr_users_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")
jellyseerr_users = await _fetch_all_jellyseerr_users(client)
if not jellyseerr_users:
return {"status": "ok", "matched": 0, "skipped": 0, "total": 0}
candidate_to_id: Dict[str, int] = {}
for user in jellyseerr_users:
user_id = user.get("id") or user.get("userId") or user.get("Id")
try:
user_id = int(user_id)
except (TypeError, ValueError):
continue
for candidate in _extract_user_candidates(user):
candidate_to_id.setdefault(candidate, user_id)
updated = 0
skipped = 0
users = get_all_users()
for user in users:
if user.get("jellyseerr_user_id") is not None:
skipped += 1
continue
username = user.get("username") or ""
matched_id = None
for handle in _normalized_handles(username):
matched_id = candidate_to_id.get(handle)
if matched_id is not None:
break
if matched_id is not None:
set_user_jellyseerr_id(username, matched_id)
updated += 1
else:
skipped += 1
return {"status": "ok", "matched": updated, "skipped": skipped, "total": len(users)}
def _pick_jellyseerr_username(user: Dict[str, Any]) -> Optional[str]:
for key in ("email", "username", "displayName", "name"):
value = user.get(key)
if isinstance(value, str) and value.strip():
return value.strip()
return None
@router.post("/jellyseerr/users/resync")
async def jellyseerr_users_resync() -> 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")
jellyseerr_users = await _fetch_all_jellyseerr_users(client)
if not jellyseerr_users:
return {"status": "ok", "imported": 0, "cleared": 0}
cleared = delete_non_admin_users()
imported = 0
for user in jellyseerr_users:
user_id = user.get("id") or user.get("userId") or user.get("Id")
try:
user_id = int(user_id)
except (TypeError, ValueError):
continue
username = _pick_jellyseerr_username(user)
if not username:
continue
created = create_user_if_missing(
username,
"jellyseerr-user",
role="user",
auth_provider="jellyseerr",
jellyseerr_user_id=user_id,
)
if created:
imported += 1
else:
set_user_jellyseerr_id(username, user_id)
return {"status": "ok", "imported": imported, "cleared": cleared}
@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 = [user for user in get_all_users() if user.get("role") == "admin" or user.get("auth_provider") == "jellyseerr"]
return {"users": users}
@router.get("/users/summary")
async def list_users_summary() -> Dict[str, Any]:
users = [user for user in get_all_users() if user.get("role") == "admin" or user.get("auth_provider") == "jellyseerr"]
results: list[Dict[str, Any]] = []
for user in users:
username = user.get("username") or ""
username_norm = _normalize_username(username) if username else ""
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
results.append({**user, "stats": stats})
return {"users": results}
@router.get("/users/{username}")
async def get_user_summary(username: str) -> Dict[str, Any]:
user = get_user_by_username(username)
if not user:
raise HTTPException(status_code=404, detail="User not found")
username_norm = _normalize_username(user.get("username") or "")
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
return {"user": user, "stats": stats}
@router.get("/users/id/{user_id}")
async def get_user_summary_by_id(user_id: int) -> Dict[str, Any]:
user = get_user_by_id(user_id)
if not user:
raise HTTPException(status_code=404, detail="User not found")
username_norm = _normalize_username(user.get("username") or "")
stats = get_user_request_stats(username_norm, user.get("jellyseerr_user_id"))
return {"user": user, "stats": stats}
@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}