381 lines
13 KiB
Python
381 lines
13 KiB
Python
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_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
|
|
|
|
|
|
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() -> 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]:
|
|
repaired = repair_request_cache_titles()
|
|
if repaired:
|
|
logger.info("Requests cache titles repaired via settings view: %s", repaired)
|
|
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}
|