release: 2901262036

This commit is contained in:
2026-01-29 20:38:37 +13:00
parent 3493bf715e
commit fb65d646f2
11 changed files with 893 additions and 96 deletions

View File

@@ -1,4 +1,4 @@
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
import os
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
@@ -12,7 +12,11 @@ from ..db import (
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,
@@ -24,6 +28,7 @@ from ..db import (
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
@@ -87,6 +92,12 @@ SETTING_KEYS: List[str] = [
"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 []
@@ -250,6 +261,127 @@ 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]:
@@ -397,9 +529,39 @@ async def clear_logs() -> Dict[str, Any]:
@router.get("/users")
async def list_users() -> Dict[str, Any]:
users = get_all_users()
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]: