1140 lines
42 KiB
Python
1140 lines
42 KiB
Python
from typing import Any, Dict, List, Optional
|
|
from datetime import datetime, timedelta, timezone
|
|
import ipaddress
|
|
import os
|
|
import secrets
|
|
import sqlite3
|
|
import string
|
|
from urllib.parse import urlparse, urlunparse
|
|
|
|
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
|
|
|
|
from ..auth import require_admin, get_current_user
|
|
from ..config import settings as env_settings
|
|
from ..db import (
|
|
delete_setting,
|
|
get_all_users,
|
|
get_cached_requests,
|
|
get_cached_requests_count,
|
|
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_auto_search_enabled,
|
|
set_auto_search_enabled_for_non_admin_users,
|
|
set_user_profile_id,
|
|
set_user_expires_at,
|
|
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,
|
|
list_user_profiles,
|
|
get_user_profile,
|
|
create_user_profile,
|
|
update_user_profile,
|
|
delete_user_profile,
|
|
list_signup_invites,
|
|
get_signup_invite_by_id,
|
|
create_signup_invite,
|
|
update_signup_invite,
|
|
delete_signup_invite,
|
|
)
|
|
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.user_cache import (
|
|
build_jellyseerr_candidate_map,
|
|
get_cached_jellyfin_users,
|
|
get_cached_jellyseerr_users,
|
|
match_jellyseerr_user_id,
|
|
save_jellyfin_users_cache,
|
|
save_jellyseerr_users_cache,
|
|
)
|
|
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",
|
|
}
|
|
|
|
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",
|
|
"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_banner_enabled",
|
|
"site_banner_message",
|
|
"site_banner_tone",
|
|
]
|
|
|
|
def _normalize_username(value: str) -> str:
|
|
normalized = value.strip().lower()
|
|
if "@" in normalized:
|
|
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 []
|
|
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
|
|
|
|
|
|
def _normalize_optional_text(value: Any) -> Optional[str]:
|
|
if value is None:
|
|
return None
|
|
if not isinstance(value, str):
|
|
value = str(value)
|
|
trimmed = value.strip()
|
|
return trimmed if trimmed else None
|
|
|
|
|
|
def _parse_optional_positive_int(value: Any, field_name: str) -> Optional[int]:
|
|
if value is None or value == "":
|
|
return None
|
|
try:
|
|
parsed = int(value)
|
|
except (TypeError, ValueError) as exc:
|
|
raise HTTPException(status_code=400, detail=f"{field_name} must be a number") from exc
|
|
if parsed <= 0:
|
|
raise HTTPException(status_code=400, detail=f"{field_name} must be greater than 0")
|
|
return parsed
|
|
|
|
|
|
def _parse_optional_profile_id(value: Any) -> Optional[int]:
|
|
if value is None or value == "":
|
|
return None
|
|
try:
|
|
parsed = int(value)
|
|
except (TypeError, ValueError) as exc:
|
|
raise HTTPException(status_code=400, detail="profile_id must be a number") from exc
|
|
if parsed <= 0:
|
|
raise HTTPException(status_code=400, detail="profile_id must be greater than 0")
|
|
profile = get_user_profile(parsed)
|
|
if not profile:
|
|
raise HTTPException(status_code=404, detail="Profile not found")
|
|
return parsed
|
|
|
|
|
|
def _parse_optional_expires_at(value: Any) -> Optional[str]:
|
|
if value is None or value == "":
|
|
return None
|
|
if not isinstance(value, str):
|
|
raise HTTPException(status_code=400, detail="expires_at must be an ISO datetime string")
|
|
candidate = value.strip()
|
|
if not candidate:
|
|
return None
|
|
try:
|
|
parsed = datetime.fromisoformat(candidate.replace("Z", "+00:00"))
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail="expires_at must be a valid ISO datetime") from exc
|
|
if parsed.tzinfo is None:
|
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
return parsed.isoformat()
|
|
|
|
|
|
def _normalize_invite_code(value: Optional[str]) -> str:
|
|
raw = (value or "").strip().upper()
|
|
filtered = "".join(ch for ch in raw if ch.isalnum())
|
|
if len(filtered) < 6:
|
|
raise HTTPException(status_code=400, detail="Invite code must be at least 6 letters/numbers.")
|
|
return filtered
|
|
|
|
|
|
def _generate_invite_code(length: int = 12) -> str:
|
|
alphabet = string.ascii_uppercase + string.digits
|
|
return "".join(secrets.choice(alphabet) for _ in range(length))
|
|
|
|
|
|
def _normalize_role_or_none(value: Any) -> Optional[str]:
|
|
if value is None:
|
|
return None
|
|
if not isinstance(value, str):
|
|
value = str(value)
|
|
role = value.strip().lower()
|
|
if not role:
|
|
return None
|
|
if role not in {"user", "admin"}:
|
|
raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'")
|
|
return role
|
|
|
|
|
|
def _calculate_profile_expiry(profile: Dict[str, Any]) -> Optional[str]:
|
|
expires_days = profile.get("account_expires_days")
|
|
if isinstance(expires_days, int) and expires_days > 0:
|
|
return (datetime.now(timezone.utc) + timedelta(days=expires_days)).isoformat()
|
|
return None
|
|
|
|
|
|
def _apply_profile_defaults_to_user(username: str, profile: Dict[str, Any]) -> Dict[str, Any]:
|
|
set_user_profile_id(username, int(profile["id"]))
|
|
role = profile.get("role") or "user"
|
|
if role in {"user", "admin"}:
|
|
set_user_role(username, role)
|
|
set_user_auto_search_enabled(username, bool(profile.get("auto_search_enabled", True)))
|
|
set_user_expires_at(username, _calculate_profile_expiry(profile))
|
|
refreshed = get_user_by_username(username)
|
|
if not refreshed:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
return refreshed
|
|
|
|
|
|
@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
|
|
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
|
|
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]:
|
|
cached = get_cached_jellyfin_users()
|
|
if cached is not None:
|
|
return {"users": cached}
|
|
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 = save_jellyfin_users_cache(users)
|
|
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}
|
|
|
|
async def _fetch_all_jellyseerr_users(
|
|
client: JellyseerrClient, use_cache: bool = True
|
|
) -> List[Dict[str, Any]]:
|
|
if use_cache:
|
|
cached = get_cached_jellyseerr_users()
|
|
if cached is not None:
|
|
return cached
|
|
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
|
|
if users:
|
|
return save_jellyseerr_users_cache(users)
|
|
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, use_cache=False)
|
|
if not jellyseerr_users:
|
|
return {"status": "ok", "matched": 0, "skipped": 0, "total": 0}
|
|
|
|
candidate_to_id = build_jellyseerr_candidate_map(jellyseerr_users)
|
|
|
|
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 = match_jellyseerr_user_id(username, candidate_to_id)
|
|
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, use_cache=False)
|
|
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.get("/requests/all")
|
|
async def requests_all(
|
|
take: int = 50,
|
|
skip: int = 0,
|
|
days: Optional[int] = None,
|
|
user: Dict[str, str] = Depends(get_current_user),
|
|
) -> Dict[str, Any]:
|
|
if user.get("role") != "admin":
|
|
raise HTTPException(status_code=403, detail="Forbidden")
|
|
take = max(1, min(int(take or 50), 200))
|
|
skip = max(0, int(skip or 0))
|
|
since_iso = None
|
|
if days is not None and int(days) > 0:
|
|
since_iso = (datetime.now(timezone.utc) - timedelta(days=int(days))).isoformat()
|
|
rows = get_cached_requests(limit=take, offset=skip, since_iso=since_iso)
|
|
total = get_cached_requests_count(since_iso=since_iso)
|
|
results = []
|
|
for row in rows:
|
|
status = row.get("status")
|
|
results.append(
|
|
{
|
|
"id": row.get("request_id"),
|
|
"title": row.get("title"),
|
|
"year": row.get("year"),
|
|
"type": row.get("media_type"),
|
|
"status": status,
|
|
"statusLabel": requests_router._status_label(status),
|
|
"requestedBy": row.get("requested_by"),
|
|
"createdAt": row.get("created_at"),
|
|
}
|
|
)
|
|
return {"results": results, "total": total, "take": take, "skip": skip}
|
|
|
|
|
|
@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.get("/users/summary")
|
|
async def list_users_summary() -> Dict[str, Any]:
|
|
users = get_all_users()
|
|
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}/auto-search")
|
|
async def update_user_auto_search(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
enabled = payload.get("enabled") if isinstance(payload, dict) else None
|
|
if not isinstance(enabled, bool):
|
|
raise HTTPException(status_code=400, detail="enabled must be true or false")
|
|
user = get_user_by_username(username)
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
set_user_auto_search_enabled(username, enabled)
|
|
return {"status": "ok", "username": username, "auto_search_enabled": enabled}
|
|
|
|
|
|
@router.post("/users/{username}/profile")
|
|
async def update_user_profile_assignment(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
user = get_user_by_username(username)
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
if not isinstance(payload, dict):
|
|
raise HTTPException(status_code=400, detail="Invalid payload")
|
|
profile_id = payload.get("profile_id")
|
|
if profile_id in (None, ""):
|
|
set_user_profile_id(username, None)
|
|
refreshed = get_user_by_username(username)
|
|
return {"status": "ok", "user": refreshed}
|
|
try:
|
|
parsed_profile_id = int(profile_id)
|
|
except (TypeError, ValueError) as exc:
|
|
raise HTTPException(status_code=400, detail="profile_id must be a number") from exc
|
|
profile = get_user_profile(parsed_profile_id)
|
|
if not profile:
|
|
raise HTTPException(status_code=404, detail="Profile not found")
|
|
if not profile.get("is_active", True):
|
|
raise HTTPException(status_code=400, detail="Profile is disabled")
|
|
refreshed = _apply_profile_defaults_to_user(username, profile)
|
|
return {"status": "ok", "user": refreshed, "applied_profile_id": parsed_profile_id}
|
|
|
|
|
|
@router.post("/users/{username}/expiry")
|
|
async def update_user_expiry(username: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
user = get_user_by_username(username)
|
|
if not user:
|
|
raise HTTPException(status_code=404, detail="User not found")
|
|
if not isinstance(payload, dict):
|
|
raise HTTPException(status_code=400, detail="Invalid payload")
|
|
clear = payload.get("clear")
|
|
if clear is True:
|
|
set_user_expires_at(username, None)
|
|
refreshed = get_user_by_username(username)
|
|
return {"status": "ok", "user": refreshed}
|
|
if "days" in payload and payload.get("days") not in (None, ""):
|
|
days = _parse_optional_positive_int(payload.get("days"), "days")
|
|
expires_at = None
|
|
if days is not None:
|
|
expires_at = (datetime.now(timezone.utc) + timedelta(days=days)).isoformat()
|
|
set_user_expires_at(username, expires_at)
|
|
refreshed = get_user_by_username(username)
|
|
return {"status": "ok", "user": refreshed}
|
|
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
|
|
set_user_expires_at(username, expires_at)
|
|
refreshed = get_user_by_username(username)
|
|
return {"status": "ok", "user": refreshed}
|
|
|
|
|
|
@router.post("/users/auto-search/bulk")
|
|
async def update_users_auto_search_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
enabled = payload.get("enabled") if isinstance(payload, dict) else None
|
|
if not isinstance(enabled, bool):
|
|
raise HTTPException(status_code=400, detail="enabled must be true or false")
|
|
updated = set_auto_search_enabled_for_non_admin_users(enabled)
|
|
return {
|
|
"status": "ok",
|
|
"enabled": enabled,
|
|
"updated": updated,
|
|
"scope": "non-admin-users",
|
|
}
|
|
|
|
|
|
@router.post("/users/profile/bulk")
|
|
async def update_users_profile_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
if not isinstance(payload, dict):
|
|
raise HTTPException(status_code=400, detail="Invalid payload")
|
|
scope = str(payload.get("scope") or "non-admin-users").strip().lower()
|
|
if scope not in {"non-admin-users", "all-users"}:
|
|
raise HTTPException(status_code=400, detail="Invalid scope")
|
|
profile_id_value = payload.get("profile_id")
|
|
if profile_id_value in (None, ""):
|
|
users = get_all_users()
|
|
updated = 0
|
|
for user in users:
|
|
if scope == "non-admin-users" and user.get("role") == "admin":
|
|
continue
|
|
set_user_profile_id(user["username"], None)
|
|
updated += 1
|
|
return {"status": "ok", "updated": updated, "scope": scope, "profile_id": None}
|
|
try:
|
|
profile_id = int(profile_id_value)
|
|
except (TypeError, ValueError) as exc:
|
|
raise HTTPException(status_code=400, detail="profile_id must be a number") from exc
|
|
profile = get_user_profile(profile_id)
|
|
if not profile:
|
|
raise HTTPException(status_code=404, detail="Profile not found")
|
|
if not profile.get("is_active", True):
|
|
raise HTTPException(status_code=400, detail="Profile is disabled")
|
|
users = get_all_users()
|
|
updated = 0
|
|
for user in users:
|
|
if scope == "non-admin-users" and user.get("role") == "admin":
|
|
continue
|
|
_apply_profile_defaults_to_user(user["username"], profile)
|
|
updated += 1
|
|
return {"status": "ok", "updated": updated, "scope": scope, "profile_id": profile_id}
|
|
|
|
|
|
@router.post("/users/expiry/bulk")
|
|
async def update_users_expiry_bulk(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
if not isinstance(payload, dict):
|
|
raise HTTPException(status_code=400, detail="Invalid payload")
|
|
scope = str(payload.get("scope") or "non-admin-users").strip().lower()
|
|
if scope not in {"non-admin-users", "all-users"}:
|
|
raise HTTPException(status_code=400, detail="Invalid scope")
|
|
clear = payload.get("clear")
|
|
expires_at: Optional[str] = None
|
|
if clear is True:
|
|
expires_at = None
|
|
elif "days" in payload and payload.get("days") not in (None, ""):
|
|
days = _parse_optional_positive_int(payload.get("days"), "days")
|
|
expires_at = (datetime.now(timezone.utc) + timedelta(days=int(days or 0))).isoformat() if days else None
|
|
else:
|
|
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
|
|
users = get_all_users()
|
|
updated = 0
|
|
for user in users:
|
|
if scope == "non-admin-users" and user.get("role") == "admin":
|
|
continue
|
|
set_user_expires_at(user["username"], expires_at)
|
|
updated += 1
|
|
return {"status": "ok", "updated": updated, "scope": scope, "expires_at": expires_at}
|
|
|
|
|
|
@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.get("/profiles")
|
|
async def get_profiles() -> Dict[str, Any]:
|
|
profiles = list_user_profiles()
|
|
users = get_all_users()
|
|
invites = list_signup_invites()
|
|
user_counts: Dict[int, int] = {}
|
|
invite_counts: Dict[int, int] = {}
|
|
for user in users:
|
|
profile_id = user.get("profile_id")
|
|
if isinstance(profile_id, int):
|
|
user_counts[profile_id] = user_counts.get(profile_id, 0) + 1
|
|
for invite in invites:
|
|
profile_id = invite.get("profile_id")
|
|
if isinstance(profile_id, int):
|
|
invite_counts[profile_id] = invite_counts.get(profile_id, 0) + 1
|
|
enriched = []
|
|
for profile in profiles:
|
|
pid = int(profile["id"])
|
|
enriched.append(
|
|
{
|
|
**profile,
|
|
"assigned_users": user_counts.get(pid, 0),
|
|
"assigned_invites": invite_counts.get(pid, 0),
|
|
}
|
|
)
|
|
return {"profiles": enriched}
|
|
|
|
|
|
@router.post("/profiles")
|
|
async def create_profile(payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
if not isinstance(payload, dict):
|
|
raise HTTPException(status_code=400, detail="Invalid payload")
|
|
name = _normalize_optional_text(payload.get("name"))
|
|
if not name:
|
|
raise HTTPException(status_code=400, detail="Profile name is required")
|
|
role = _normalize_role_or_none(payload.get("role")) or "user"
|
|
auto_search_enabled = payload.get("auto_search_enabled")
|
|
if auto_search_enabled is None:
|
|
auto_search_enabled = True
|
|
if not isinstance(auto_search_enabled, bool):
|
|
raise HTTPException(status_code=400, detail="auto_search_enabled must be true or false")
|
|
is_active = payload.get("is_active")
|
|
if is_active is None:
|
|
is_active = True
|
|
if not isinstance(is_active, bool):
|
|
raise HTTPException(status_code=400, detail="is_active must be true or false")
|
|
account_expires_days = _parse_optional_positive_int(
|
|
payload.get("account_expires_days"), "account_expires_days"
|
|
)
|
|
try:
|
|
profile = create_user_profile(
|
|
name=name,
|
|
description=_normalize_optional_text(payload.get("description")),
|
|
role=role,
|
|
auto_search_enabled=auto_search_enabled,
|
|
account_expires_days=account_expires_days,
|
|
is_active=is_active,
|
|
)
|
|
except sqlite3.IntegrityError as exc:
|
|
raise HTTPException(status_code=409, detail="A profile with that name already exists") from exc
|
|
return {"status": "ok", "profile": profile}
|
|
|
|
|
|
@router.put("/profiles/{profile_id}")
|
|
async def edit_profile(profile_id: int, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
if not isinstance(payload, dict):
|
|
raise HTTPException(status_code=400, detail="Invalid payload")
|
|
existing = get_user_profile(profile_id)
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail="Profile not found")
|
|
name = _normalize_optional_text(payload.get("name"))
|
|
if not name:
|
|
raise HTTPException(status_code=400, detail="Profile name is required")
|
|
role = _normalize_role_or_none(payload.get("role")) or "user"
|
|
auto_search_enabled = payload.get("auto_search_enabled")
|
|
if not isinstance(auto_search_enabled, bool):
|
|
raise HTTPException(status_code=400, detail="auto_search_enabled must be true or false")
|
|
is_active = payload.get("is_active")
|
|
if not isinstance(is_active, bool):
|
|
raise HTTPException(status_code=400, detail="is_active must be true or false")
|
|
account_expires_days = _parse_optional_positive_int(
|
|
payload.get("account_expires_days"), "account_expires_days"
|
|
)
|
|
try:
|
|
profile = update_user_profile(
|
|
profile_id,
|
|
name=name,
|
|
description=_normalize_optional_text(payload.get("description")),
|
|
role=role,
|
|
auto_search_enabled=auto_search_enabled,
|
|
account_expires_days=account_expires_days,
|
|
is_active=is_active,
|
|
)
|
|
except sqlite3.IntegrityError as exc:
|
|
raise HTTPException(status_code=409, detail="A profile with that name already exists") from exc
|
|
if not profile:
|
|
raise HTTPException(status_code=404, detail="Profile not found")
|
|
return {"status": "ok", "profile": profile}
|
|
|
|
|
|
@router.delete("/profiles/{profile_id}")
|
|
async def remove_profile(profile_id: int) -> Dict[str, Any]:
|
|
try:
|
|
deleted = delete_user_profile(profile_id)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="Profile not found")
|
|
return {"status": "ok", "deleted": True, "profile_id": profile_id}
|
|
|
|
|
|
@router.get("/invites")
|
|
async def get_invites() -> Dict[str, Any]:
|
|
invites = list_signup_invites()
|
|
profiles = {profile["id"]: profile for profile in list_user_profiles()}
|
|
results = []
|
|
for invite in invites:
|
|
profile = profiles.get(invite.get("profile_id"))
|
|
results.append(
|
|
{
|
|
**invite,
|
|
"profile": (
|
|
{
|
|
"id": profile.get("id"),
|
|
"name": profile.get("name"),
|
|
}
|
|
if profile
|
|
else None
|
|
),
|
|
}
|
|
)
|
|
return {"invites": results}
|
|
|
|
|
|
@router.post("/invites")
|
|
async def create_invite(payload: Dict[str, Any], current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]:
|
|
if not isinstance(payload, dict):
|
|
raise HTTPException(status_code=400, detail="Invalid payload")
|
|
raw_code = _normalize_optional_text(payload.get("code"))
|
|
code = _normalize_invite_code(raw_code) if raw_code else _generate_invite_code()
|
|
profile_id = _parse_optional_profile_id(payload.get("profile_id"))
|
|
enabled = payload.get("enabled")
|
|
if enabled is None:
|
|
enabled = True
|
|
if not isinstance(enabled, bool):
|
|
raise HTTPException(status_code=400, detail="enabled must be true or false")
|
|
role = _normalize_role_or_none(payload.get("role"))
|
|
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
|
|
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
|
|
try:
|
|
invite = create_signup_invite(
|
|
code=code,
|
|
label=_normalize_optional_text(payload.get("label")),
|
|
description=_normalize_optional_text(payload.get("description")),
|
|
profile_id=profile_id,
|
|
role=role,
|
|
max_uses=max_uses,
|
|
enabled=enabled,
|
|
expires_at=expires_at,
|
|
created_by=current_user.get("username"),
|
|
)
|
|
except sqlite3.IntegrityError as exc:
|
|
raise HTTPException(status_code=409, detail="An invite with that code already exists") from exc
|
|
return {"status": "ok", "invite": invite}
|
|
|
|
|
|
@router.put("/invites/{invite_id}")
|
|
async def edit_invite(invite_id: int, payload: Dict[str, Any]) -> Dict[str, Any]:
|
|
if not isinstance(payload, dict):
|
|
raise HTTPException(status_code=400, detail="Invalid payload")
|
|
existing = get_signup_invite_by_id(invite_id)
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail="Invite not found")
|
|
code = _normalize_invite_code(_normalize_optional_text(payload.get("code")) or existing["code"])
|
|
profile_id = _parse_optional_profile_id(payload.get("profile_id"))
|
|
enabled = payload.get("enabled")
|
|
if not isinstance(enabled, bool):
|
|
raise HTTPException(status_code=400, detail="enabled must be true or false")
|
|
role = _normalize_role_or_none(payload.get("role"))
|
|
max_uses = _parse_optional_positive_int(payload.get("max_uses"), "max_uses")
|
|
expires_at = _parse_optional_expires_at(payload.get("expires_at"))
|
|
try:
|
|
invite = update_signup_invite(
|
|
invite_id,
|
|
code=code,
|
|
label=_normalize_optional_text(payload.get("label")),
|
|
description=_normalize_optional_text(payload.get("description")),
|
|
profile_id=profile_id,
|
|
role=role,
|
|
max_uses=max_uses,
|
|
enabled=enabled,
|
|
expires_at=expires_at,
|
|
)
|
|
except sqlite3.IntegrityError as exc:
|
|
raise HTTPException(status_code=409, detail="An invite with that code already exists") from exc
|
|
if not invite:
|
|
raise HTTPException(status_code=404, detail="Invite not found")
|
|
return {"status": "ok", "invite": invite}
|
|
|
|
|
|
@router.delete("/invites/{invite_id}")
|
|
async def remove_invite(invite_id: int) -> Dict[str, Any]:
|
|
deleted = delete_signup_invite(invite_id)
|
|
if not deleted:
|
|
raise HTTPException(status_code=404, detail="Invite not found")
|
|
return {"status": "ok", "deleted": True, "invite_id": invite_id}
|