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

@@ -47,6 +47,7 @@ def get_current_user(token: str = Depends(oauth2_scheme), request: Request = Non
"username": user["username"], "username": user["username"],
"role": user["role"], "role": user["role"],
"auth_provider": user.get("auth_provider", "local"), "auth_provider": user.get("auth_provider", "local"),
"jellyseerr_user_id": user.get("jellyseerr_user_id"),
} }

View File

@@ -35,3 +35,12 @@ class JellyseerrClient(ApiClient):
"page": page, "page": page,
}, },
) )
async def get_users(self, take: int = 50, skip: int = 0) -> Optional[Dict[str, Any]]:
return await self.get(
"/api/v1/user",
params={
"take": take,
"skip": skip,
},
)

View File

@@ -145,6 +145,7 @@ def init_db() -> None:
password_hash TEXT NOT NULL, password_hash TEXT NOT NULL,
role TEXT NOT NULL, role TEXT NOT NULL,
auth_provider TEXT NOT NULL DEFAULT 'local', auth_provider TEXT NOT NULL DEFAULT 'local',
jellyseerr_user_id INTEGER,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
last_login_at TEXT, last_login_at TEXT,
is_blocked INTEGER NOT NULL DEFAULT 0, is_blocked INTEGER NOT NULL DEFAULT 0,
@@ -173,6 +174,7 @@ def init_db() -> None:
year INTEGER, year INTEGER,
requested_by TEXT, requested_by TEXT,
requested_by_norm TEXT, requested_by_norm TEXT,
requested_by_id INTEGER,
created_at TEXT, created_at TEXT,
updated_at TEXT, updated_at TEXT,
payload_json TEXT NOT NULL payload_json TEXT NOT NULL
@@ -258,6 +260,23 @@ def init_db() -> None:
conn.execute("ALTER TABLE users ADD COLUMN last_jellyfin_auth_at TEXT") conn.execute("ALTER TABLE users ADD COLUMN last_jellyfin_auth_at TEXT")
except sqlite3.OperationalError: except sqlite3.OperationalError:
pass pass
try:
conn.execute("ALTER TABLE users ADD COLUMN jellyseerr_user_id INTEGER")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE requests_cache ADD COLUMN requested_by_id INTEGER")
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id
ON requests_cache (requested_by_id)
"""
)
except sqlite3.OperationalError:
pass
_backfill_auth_providers() _backfill_auth_providers()
ensure_admin_user() ensure_admin_user()
@@ -361,31 +380,41 @@ def ensure_admin_user() -> None:
create_user(settings.admin_username, settings.admin_password, role="admin") create_user(settings.admin_username, settings.admin_password, role="admin")
def create_user(username: str, password: str, role: str = "user", auth_provider: str = "local") -> None: def create_user(
username: str,
password: str,
role: str = "user",
auth_provider: str = "local",
jellyseerr_user_id: Optional[int] = None,
) -> None:
created_at = datetime.now(timezone.utc).isoformat() created_at = datetime.now(timezone.utc).isoformat()
password_hash = hash_password(password) password_hash = hash_password(password)
with _connect() as conn: with _connect() as conn:
conn.execute( conn.execute(
""" """
INSERT INTO users (username, password_hash, role, auth_provider, created_at) INSERT INTO users (username, password_hash, role, auth_provider, jellyseerr_user_id, created_at)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
""", """,
(username, password_hash, role, auth_provider, created_at), (username, password_hash, role, auth_provider, jellyseerr_user_id, created_at),
) )
def create_user_if_missing( def create_user_if_missing(
username: str, password: str, role: str = "user", auth_provider: str = "local" username: str,
password: str,
role: str = "user",
auth_provider: str = "local",
jellyseerr_user_id: Optional[int] = None,
) -> bool: ) -> bool:
created_at = datetime.now(timezone.utc).isoformat() created_at = datetime.now(timezone.utc).isoformat()
password_hash = hash_password(password) password_hash = hash_password(password)
with _connect() as conn: with _connect() as conn:
cursor = conn.execute( cursor = conn.execute(
""" """
INSERT OR IGNORE INTO users (username, password_hash, role, auth_provider, created_at) INSERT OR IGNORE INTO users (username, password_hash, role, auth_provider, jellyseerr_user_id, created_at)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
""", """,
(username, password_hash, role, auth_provider, created_at), (username, password_hash, role, auth_provider, jellyseerr_user_id, created_at),
) )
return cursor.rowcount > 0 return cursor.rowcount > 0
@@ -394,10 +423,10 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
with _connect() as conn: with _connect() as conn:
row = conn.execute( row = conn.execute(
""" """
SELECT id, username, password_hash, role, auth_provider, created_at, last_login_at, SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
is_blocked, jellyfin_password_hash, last_jellyfin_auth_at created_at, last_login_at, is_blocked, jellyfin_password_hash, last_jellyfin_auth_at
FROM users FROM users
WHERE username = ? WHERE username = ? COLLATE NOCASE
""", """,
(username,), (username,),
).fetchone() ).fetchone()
@@ -409,19 +438,47 @@ def get_user_by_username(username: str) -> Optional[Dict[str, Any]]:
"password_hash": row[2], "password_hash": row[2],
"role": row[3], "role": row[3],
"auth_provider": row[4], "auth_provider": row[4],
"created_at": row[5], "jellyseerr_user_id": row[5],
"last_login_at": row[6], "created_at": row[6],
"is_blocked": bool(row[7]), "last_login_at": row[7],
"jellyfin_password_hash": row[8], "is_blocked": bool(row[8]),
"last_jellyfin_auth_at": row[9], "jellyfin_password_hash": row[9],
"last_jellyfin_auth_at": row[10],
} }
def get_user_by_id(user_id: int) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT id, username, password_hash, role, auth_provider, jellyseerr_user_id,
created_at, last_login_at, is_blocked, jellyfin_password_hash, last_jellyfin_auth_at
FROM users
WHERE id = ?
""",
(user_id,),
).fetchone()
if not row:
return None
return {
"id": row[0],
"username": row[1],
"password_hash": row[2],
"role": row[3],
"auth_provider": row[4],
"jellyseerr_user_id": row[5],
"created_at": row[6],
"last_login_at": row[7],
"is_blocked": bool(row[8]),
"jellyfin_password_hash": row[9],
"last_jellyfin_auth_at": row[10],
}
def get_all_users() -> list[Dict[str, Any]]: def get_all_users() -> list[Dict[str, Any]]:
with _connect() as conn: with _connect() as conn:
rows = conn.execute( rows = conn.execute(
""" """
SELECT id, username, role, auth_provider, created_at, last_login_at, is_blocked SELECT id, username, role, auth_provider, jellyseerr_user_id, created_at, last_login_at, is_blocked
FROM users FROM users
ORDER BY username COLLATE NOCASE ORDER BY username COLLATE NOCASE
""" """
@@ -434,14 +491,35 @@ def get_all_users() -> list[Dict[str, Any]]:
"username": row[1], "username": row[1],
"role": row[2], "role": row[2],
"auth_provider": row[3], "auth_provider": row[3],
"created_at": row[4], "jellyseerr_user_id": row[4],
"last_login_at": row[5], "created_at": row[5],
"is_blocked": bool(row[6]), "last_login_at": row[6],
"is_blocked": bool(row[7]),
} }
) )
return results return results
def delete_non_admin_users() -> int:
with _connect() as conn:
cursor = conn.execute(
"""
DELETE FROM users WHERE role != 'admin'
"""
)
return cursor.rowcount
def set_user_jellyseerr_id(username: str, jellyseerr_user_id: Optional[int]) -> None:
with _connect() as conn:
conn.execute(
"""
UPDATE users SET jellyseerr_user_id = ? WHERE username = ?
""",
(jellyseerr_user_id, username),
)
def set_last_login(username: str) -> None: def set_last_login(username: str) -> None:
timestamp = datetime.now(timezone.utc).isoformat() timestamp = datetime.now(timezone.utc).isoformat()
with _connect() as conn: with _connect() as conn:
@@ -614,8 +692,8 @@ def get_user_activity_summary(username: str) -> Dict[str, Any]:
} }
def get_user_request_stats(username_norm: str) -> Dict[str, Any]: def get_user_request_stats(username_norm: str, requested_by_id: Optional[int] = None) -> Dict[str, Any]:
if not username_norm: if requested_by_id is None:
return { return {
"total": 0, "total": 0,
"ready": 0, "ready": 0,
@@ -632,26 +710,26 @@ def get_user_request_stats(username_norm: str) -> Dict[str, Any]:
""" """
SELECT COUNT(*) SELECT COUNT(*)
FROM requests_cache FROM requests_cache
WHERE requested_by_norm = ? WHERE requested_by_id = ?
""", """,
(username_norm,), (requested_by_id,),
).fetchone() ).fetchone()
status_rows = conn.execute( status_rows = conn.execute(
""" """
SELECT status, COUNT(*) SELECT status, COUNT(*)
FROM requests_cache FROM requests_cache
WHERE requested_by_norm = ? WHERE requested_by_id = ?
GROUP BY status GROUP BY status
""", """,
(username_norm,), (requested_by_id,),
).fetchall() ).fetchall()
last_row = conn.execute( last_row = conn.execute(
""" """
SELECT MAX(created_at) SELECT MAX(created_at)
FROM requests_cache FROM requests_cache
WHERE requested_by_norm = ? WHERE requested_by_id = ?
""", """,
(username_norm,), (requested_by_id,),
).fetchone() ).fetchone()
counts = {int(row[0]): int(row[1]) for row in status_rows if row[0] is not None} counts = {int(row[0]): int(row[1]) for row in status_rows if row[0] is not None}
pending = counts.get(1, 0) pending = counts.get(1, 0)
@@ -706,6 +784,7 @@ def upsert_request_cache(
year: Optional[int], year: Optional[int],
requested_by: Optional[str], requested_by: Optional[str],
requested_by_norm: Optional[str], requested_by_norm: Optional[str],
requested_by_id: Optional[int],
created_at: Optional[str], created_at: Optional[str],
updated_at: Optional[str], updated_at: Optional[str],
payload_json: str, payload_json: str,
@@ -749,11 +828,12 @@ def upsert_request_cache(
year, year,
requested_by, requested_by,
requested_by_norm, requested_by_norm,
requested_by_id,
created_at, created_at,
updated_at, updated_at,
payload_json payload_json
) )
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(request_id) DO UPDATE SET ON CONFLICT(request_id) DO UPDATE SET
media_id = excluded.media_id, media_id = excluded.media_id,
media_type = excluded.media_type, media_type = excluded.media_type,
@@ -762,6 +842,7 @@ def upsert_request_cache(
year = excluded.year, year = excluded.year,
requested_by = excluded.requested_by, requested_by = excluded.requested_by,
requested_by_norm = excluded.requested_by_norm, requested_by_norm = excluded.requested_by_norm,
requested_by_id = excluded.requested_by_id,
created_at = excluded.created_at, created_at = excluded.created_at,
updated_at = excluded.updated_at, updated_at = excluded.updated_at,
payload_json = excluded.payload_json payload_json = excluded.payload_json
@@ -775,6 +856,7 @@ def upsert_request_cache(
normalized_year, normalized_year,
requested_by, requested_by,
requested_by_norm, requested_by_norm,
requested_by_id,
created_at, created_at,
updated_at, updated_at,
payload_json, payload_json,
@@ -844,15 +926,20 @@ def get_cached_requests(
limit: int, limit: int,
offset: int, offset: int,
requested_by_norm: Optional[str] = None, requested_by_norm: Optional[str] = None,
requested_by_id: Optional[int] = None,
since_iso: Optional[str] = None, since_iso: Optional[str] = None,
) -> list[Dict[str, Any]]: ) -> list[Dict[str, Any]]:
query = """ query = """
SELECT request_id, media_id, media_type, status, title, year, requested_by, created_at, payload_json SELECT request_id, media_id, media_type, status, title, year, requested_by,
requested_by_norm, requested_by_id, created_at, payload_json
FROM requests_cache FROM requests_cache
""" """
params: list[Any] = [] params: list[Any] = []
conditions = [] conditions = []
if requested_by_norm: if requested_by_id is not None:
conditions.append("requested_by_id = ?")
params.append(requested_by_id)
elif requested_by_norm:
conditions.append("requested_by_norm = ?") conditions.append("requested_by_norm = ?")
params.append(requested_by_norm) params.append(requested_by_norm)
if since_iso: if since_iso:
@@ -865,17 +952,19 @@ def get_cached_requests(
with _connect() as conn: with _connect() as conn:
rows = conn.execute(query, tuple(params)).fetchall() rows = conn.execute(query, tuple(params)).fetchall()
logger.debug( logger.debug(
"requests_cache list: count=%s requested_by_norm=%s since_iso=%s", "requests_cache list: count=%s requested_by_norm=%s requested_by_id=%s since_iso=%s",
len(rows), len(rows),
requested_by_norm, requested_by_norm,
requested_by_id,
since_iso, since_iso,
) )
results: list[Dict[str, Any]] = [] results: list[Dict[str, Any]] = []
for row in rows: for row in rows:
title = row[4] title = row[4]
year = row[5] year = row[5]
if (not title or not year) and row[8]: payload_json = row[10]
derived_title, derived_year = _extract_title_year_from_payload(row[8]) if (not title or not year) and payload_json:
derived_title, derived_year = _extract_title_year_from_payload(payload_json)
if not title: if not title:
title = derived_title title = derived_title
if not year: if not year:
@@ -889,7 +978,9 @@ def get_cached_requests(
"title": title, "title": title,
"year": year, "year": year,
"requested_by": row[6], "requested_by": row[6],
"created_at": row[7], "requested_by_norm": row[7],
"requested_by_id": row[8],
"created_at": row[9],
} }
) )
return results return results
@@ -900,7 +991,8 @@ def get_request_cache_overview(limit: int = 50) -> list[Dict[str, Any]]:
with _connect() as conn: with _connect() as conn:
rows = conn.execute( rows = conn.execute(
""" """
SELECT request_id, media_id, media_type, status, title, year, requested_by, created_at, updated_at, payload_json SELECT request_id, media_id, media_type, status, title, year, requested_by,
requested_by_norm, requested_by_id, created_at, updated_at, payload_json
FROM requests_cache FROM requests_cache
ORDER BY updated_at DESC, request_id DESC ORDER BY updated_at DESC, request_id DESC
LIMIT ? LIMIT ?
@@ -910,8 +1002,8 @@ def get_request_cache_overview(limit: int = 50) -> list[Dict[str, Any]]:
results: list[Dict[str, Any]] = [] results: list[Dict[str, Any]] = []
for row in rows: for row in rows:
title = row[4] title = row[4]
if not title and row[9]: if not title and row[11]:
derived_title, _ = _extract_title_year_from_payload(row[9]) derived_title, _ = _extract_title_year_from_payload(row[11])
title = derived_title or row[4] title = derived_title or row[4]
results.append( results.append(
{ {
@@ -922,8 +1014,10 @@ def get_request_cache_overview(limit: int = 50) -> list[Dict[str, Any]]:
"title": title, "title": title,
"year": row[5], "year": row[5],
"requested_by": row[6], "requested_by": row[6],
"created_at": row[7], "requested_by_norm": row[7],
"updated_at": row[8], "requested_by_id": row[8],
"created_at": row[9],
"updated_at": row[10],
} }
) )
return results return results
@@ -1202,7 +1296,8 @@ def get_cached_requests_since(since_iso: str) -> list[Dict[str, Any]]:
with _connect() as conn: with _connect() as conn:
rows = conn.execute( rows = conn.execute(
""" """
SELECT request_id, media_id, media_type, status, title, year, requested_by, requested_by_norm, created_at SELECT request_id, media_id, media_type, status, title, year, requested_by,
requested_by_norm, requested_by_id, created_at
FROM requests_cache FROM requests_cache
WHERE created_at >= ? WHERE created_at >= ?
ORDER BY created_at DESC, request_id DESC ORDER BY created_at DESC, request_id DESC
@@ -1221,14 +1316,17 @@ def get_cached_requests_since(since_iso: str) -> list[Dict[str, Any]]:
"year": row[5], "year": row[5],
"requested_by": row[6], "requested_by": row[6],
"requested_by_norm": row[7], "requested_by_norm": row[7],
"created_at": row[8], "requested_by_id": row[8],
"created_at": row[9],
} }
) )
return results return results
def get_cached_request_by_media_id( def get_cached_request_by_media_id(
media_id: int, requested_by_norm: Optional[str] = None media_id: int,
requested_by_norm: Optional[str] = None,
requested_by_id: Optional[int] = None,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
query = """ query = """
SELECT request_id, status SELECT request_id, status
@@ -1236,7 +1334,10 @@ def get_cached_request_by_media_id(
WHERE media_id = ? WHERE media_id = ?
""" """
params: list[Any] = [media_id] params: list[Any] = [media_id]
if requested_by_norm: if requested_by_id is not None:
query += " AND requested_by_id = ?"
params.append(requested_by_id)
elif requested_by_norm:
query += " AND requested_by_norm = ?" query += " AND requested_by_norm = ?"
params.append(requested_by_norm) params.append(requested_by_norm)
query += " ORDER BY created_at DESC, request_id DESC LIMIT 1" query += " ORDER BY created_at DESC, request_id DESC LIMIT 1"

View File

@@ -1,4 +1,4 @@
from typing import Any, Dict, List from typing import Any, Dict, List, Optional
import os import os
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
@@ -12,7 +12,11 @@ from ..db import (
get_request_cache_missing_titles, get_request_cache_missing_titles,
get_request_cache_stats, get_request_cache_stats,
get_settings_overrides, get_settings_overrides,
get_user_by_id,
get_user_by_username, get_user_by_username,
get_user_request_stats,
create_user_if_missing,
set_user_jellyseerr_id,
set_setting, set_setting,
set_user_blocked, set_user_blocked,
set_user_password, set_user_password,
@@ -24,6 +28,7 @@ from ..db import (
cleanup_history, cleanup_history,
update_request_cache_title, update_request_cache_title,
repair_request_cache_titles, repair_request_cache_titles,
delete_non_admin_users,
) )
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
from ..clients.sonarr import SonarrClient from ..clients.sonarr import SonarrClient
@@ -87,6 +92,12 @@ SETTING_KEYS: List[str] = [
"site_changelog", "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]]: def _normalize_root_folders(folders: Any) -> List[Dict[str, Any]]:
if not isinstance(folders, list): if not isinstance(folders, list):
return [] return []
@@ -250,6 +261,127 @@ async def jellyfin_users_sync() -> Dict[str, Any]:
imported = await sync_jellyfin_users() imported = await sync_jellyfin_users()
return {"status": "ok", "imported": imported} 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") @router.post("/requests/sync")
async def requests_sync() -> Dict[str, Any]: async def requests_sync() -> Dict[str, Any]:
@@ -397,9 +529,39 @@ async def clear_logs() -> Dict[str, Any]:
@router.get("/users") @router.get("/users")
async def list_users() -> Dict[str, Any]: 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} 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") @router.post("/users/{username}/block")
async def block_user(username: str) -> Dict[str, Any]: async def block_user(username: str) -> Dict[str, Any]:

View File

@@ -10,6 +10,7 @@ from ..db import (
get_user_by_username, get_user_by_username,
set_user_password, set_user_password,
set_jellyfin_auth_cache, set_jellyfin_auth_cache,
set_user_jellyseerr_id,
get_user_activity, get_user_activity,
get_user_activity_summary, get_user_activity_summary,
get_user_request_stats, get_user_request_stats,
@@ -26,7 +27,10 @@ router = APIRouter(prefix="/auth", tags=["auth"])
def _normalize_username(value: str) -> str: def _normalize_username(value: str) -> str:
return value.strip().lower() normalized = value.strip().lower()
if "@" in normalized:
normalized = normalized.split("@", 1)[0]
return normalized
def _is_recent_jellyfin_auth(last_auth_at: str) -> bool: def _is_recent_jellyfin_auth(last_auth_at: str) -> bool:
@@ -53,6 +57,22 @@ def _has_valid_jellyfin_cache(user: dict, password: str) -> bool:
return False return False
return _is_recent_jellyfin_auth(last_auth_at) return _is_recent_jellyfin_auth(last_auth_at)
def _extract_jellyseerr_user_id(response: dict) -> int | None:
if not isinstance(response, dict):
return None
candidate = response
if isinstance(response.get("user"), dict):
candidate = response.get("user")
for key in ("id", "userId", "Id"):
value = candidate.get(key) if isinstance(candidate, dict) else None
if value is None:
continue
try:
return int(value)
except (TypeError, ValueError):
continue
return None
@router.post("/login") @router.post("/login")
async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict: async def login(form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
@@ -125,10 +145,19 @@ async def jellyseerr_login(form_data: OAuth2PasswordRequestForm = Depends()) ->
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if not isinstance(response, dict): if not isinstance(response, dict):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyseerr credentials") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyseerr credentials")
create_user_if_missing(form_data.username, "jellyseerr-user", role="user", auth_provider="jellyseerr") jellyseerr_user_id = _extract_jellyseerr_user_id(response)
create_user_if_missing(
form_data.username,
"jellyseerr-user",
role="user",
auth_provider="jellyseerr",
jellyseerr_user_id=jellyseerr_user_id,
)
user = get_user_by_username(form_data.username) user = get_user_by_username(form_data.username)
if user and user.get("is_blocked"): if user and user.get("is_blocked"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User is blocked")
if jellyseerr_user_id is not None:
set_user_jellyseerr_id(form_data.username, jellyseerr_user_id)
token = create_access_token(form_data.username, "user") token = create_access_token(form_data.username, "user")
set_last_login(form_data.username) set_last_login(form_data.username)
return {"access_token": token, "token_type": "bearer", "user": {"username": form_data.username, "role": "user"}} return {"access_token": token, "token_type": "bearer", "user": {"username": form_data.username, "role": "user"}}
@@ -143,7 +172,7 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict:
async def profile(current_user: dict = Depends(get_current_user)) -> dict: async def profile(current_user: dict = Depends(get_current_user)) -> dict:
username = current_user.get("username") or "" username = current_user.get("username") or ""
username_norm = _normalize_username(username) if username else "" username_norm = _normalize_username(username) if username else ""
stats = get_user_request_stats(username_norm) stats = get_user_request_stats(username_norm, current_user.get("jellyseerr_user_id"))
global_total = get_global_request_total() global_total = get_global_request_total()
share = (stats.get("total", 0) / global_total) if global_total else 0 share = (stats.get("total", 0) / global_total) if global_total else 0
activity_summary = get_user_activity_summary(username) if username else {} activity_summary = get_user_activity_summary(username) if username else {}

View File

@@ -113,6 +113,10 @@ def _normalize_username(value: Any) -> Optional[str]:
if not isinstance(value, str): if not isinstance(value, str):
return None return None
normalized = value.strip().lower() normalized = value.strip().lower()
if not normalized:
return None
if "@" in normalized:
normalized = normalized.split("@", 1)[0]
return normalized if normalized else None return normalized if normalized else None
@@ -164,6 +168,21 @@ def _normalize_requested_by(request_data: Any) -> Optional[str]:
normalized = normalized.split("@", 1)[0] normalized = normalized.split("@", 1)[0]
return normalized return normalized
def _extract_requested_by_id(request_data: Any) -> Optional[int]:
if not isinstance(request_data, dict):
return None
requested_by = request_data.get("requestedBy") or request_data.get("requestedByUser")
if isinstance(requested_by, dict):
for key in ("id", "userId", "Id"):
value = requested_by.get(key)
if value is None:
continue
try:
return int(value)
except (TypeError, ValueError):
continue
return None
def _format_upstream_error(service: str, exc: httpx.HTTPStatusError) -> str: def _format_upstream_error(service: str, exc: httpx.HTTPStatusError) -> str:
response = exc.response response = exc.response
@@ -206,6 +225,7 @@ def _parse_request_payload(item: Dict[str, Any]) -> Dict[str, Any]:
updated_at = item.get("updatedAt") or created_at updated_at = item.get("updatedAt") or created_at
requested_by = _request_display_name(item) requested_by = _request_display_name(item)
requested_by_norm = _normalize_requested_by(item) requested_by_norm = _normalize_requested_by(item)
requested_by_id = _extract_requested_by_id(item)
return { return {
"request_id": item.get("id"), "request_id": item.get("id"),
"media_id": media_id, "media_id": media_id,
@@ -216,6 +236,7 @@ def _parse_request_payload(item: Dict[str, Any]) -> Dict[str, Any]:
"year": year, "year": year,
"requested_by": requested_by, "requested_by": requested_by,
"requested_by_norm": requested_by_norm, "requested_by_norm": requested_by_norm,
"requested_by_id": requested_by_id,
"created_at": created_at, "created_at": created_at,
"updated_at": updated_at, "updated_at": updated_at,
} }
@@ -577,6 +598,7 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
year=payload.get("year"), year=payload.get("year"),
requested_by=payload.get("requested_by"), requested_by=payload.get("requested_by"),
requested_by_norm=payload.get("requested_by_norm"), requested_by_norm=payload.get("requested_by_norm"),
requested_by_id=payload.get("requested_by_id"),
created_at=payload.get("created_at"), created_at=payload.get("created_at"),
updated_at=payload.get("updated_at"), updated_at=payload.get("updated_at"),
payload_json=payload_json, payload_json=payload_json,
@@ -714,6 +736,7 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
year=payload.get("year"), year=payload.get("year"),
requested_by=payload.get("requested_by"), requested_by=payload.get("requested_by"),
requested_by_norm=payload.get("requested_by_norm"), requested_by_norm=payload.get("requested_by_norm"),
requested_by_id=payload.get("requested_by_id"),
created_at=payload.get("created_at"), created_at=payload.get("created_at"),
updated_at=payload.get("updated_at"), updated_at=payload.get("updated_at"),
payload_json=payload_json, payload_json=payload_json,
@@ -843,6 +866,7 @@ async def _prefetch_artwork_cache(
year=parsed.get("year"), year=parsed.get("year"),
requested_by=parsed.get("requested_by"), requested_by=parsed.get("requested_by"),
requested_by_norm=parsed.get("requested_by_norm"), requested_by_norm=parsed.get("requested_by_norm"),
requested_by_id=parsed.get("requested_by_id"),
created_at=parsed.get("created_at"), created_at=parsed.get("created_at"),
updated_at=parsed.get("updated_at"), updated_at=parsed.get("updated_at"),
payload_json=json.dumps(payload, ensure_ascii=True), payload_json=json.dumps(payload, ensure_ascii=True),
@@ -985,19 +1009,39 @@ def _recent_cache_stale() -> bool:
return (datetime.now(timezone.utc) - parsed).total_seconds() > RECENT_CACHE_TTL_SECONDS return (datetime.now(timezone.utc) - parsed).total_seconds() > RECENT_CACHE_TTL_SECONDS
def _parse_iso_datetime(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed
def _get_recent_from_cache( def _get_recent_from_cache(
requested_by_norm: Optional[str], requested_by_norm: Optional[str],
requested_by_id: Optional[int],
limit: int, limit: int,
offset: int, offset: int,
since_iso: Optional[str], since_iso: Optional[str],
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
items = _recent_cache.get("items") or [] items = _recent_cache.get("items") or []
results = [] results = []
since_dt = _parse_iso_datetime(since_iso)
for item in items: for item in items:
if requested_by_norm and item.get("requested_by_norm") != requested_by_norm: if requested_by_id is not None:
continue if item.get("requested_by_id") != requested_by_id:
if since_iso and item.get("created_at") and item["created_at"] < since_iso: continue
elif requested_by_norm and item.get("requested_by_norm") != requested_by_norm:
continue continue
if since_dt:
candidate = item.get("created_at") or item.get("updated_at")
item_dt = _parse_iso_datetime(candidate)
if not item_dt or item_dt < since_dt:
continue
results.append(item) results.append(item)
return results[offset : offset + limit] return results[offset : offset + limit]
@@ -1455,13 +1499,15 @@ async def recent_requests(
raise HTTPException(status_code=502, detail=str(exc)) from exc raise HTTPException(status_code=502, detail=str(exc)) from exc
username_norm = _normalize_username(user.get("username", "")) username_norm = _normalize_username(user.get("username", ""))
requested_by_id = user.get("jellyseerr_user_id")
requested_by = None if user.get("role") == "admin" else username_norm requested_by = None if user.get("role") == "admin" else username_norm
requested_by_id = None if user.get("role") == "admin" else requested_by_id
since_iso = None since_iso = None
if days > 0: if days > 0:
since_iso = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat() since_iso = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
if _recent_cache_stale(): if _recent_cache_stale():
_refresh_recent_cache_from_db() _refresh_recent_cache_from_db()
rows = _get_recent_from_cache(requested_by, take, skip, since_iso) rows = _get_recent_from_cache(requested_by, requested_by_id, take, skip, since_iso)
cache_mode = (runtime.artwork_cache_mode or "remote").lower() cache_mode = (runtime.artwork_cache_mode or "remote").lower()
allow_title_hydrate = False allow_title_hydrate = False
allow_artwork_hydrate = client.configured() allow_artwork_hydrate = client.configured()
@@ -1537,6 +1583,7 @@ async def recent_requests(
year=year or payload.get("year"), year=year or payload.get("year"),
requested_by=payload.get("requested_by"), requested_by=payload.get("requested_by"),
requested_by_norm=payload.get("requested_by_norm"), requested_by_norm=payload.get("requested_by_norm"),
requested_by_id=payload.get("requested_by_id"),
created_at=payload.get("created_at"), created_at=payload.get("created_at"),
updated_at=payload.get("updated_at"), updated_at=payload.get("updated_at"),
payload_json=json.dumps(details, ensure_ascii=True), payload_json=json.dumps(details, ensure_ascii=True),
@@ -1584,6 +1631,7 @@ async def recent_requests(
year=payload.get("year"), year=payload.get("year"),
requested_by=payload.get("requested_by"), requested_by=payload.get("requested_by"),
requested_by_norm=payload.get("requested_by_norm"), requested_by_norm=payload.get("requested_by_norm"),
requested_by_id=payload.get("requested_by_id"),
created_at=payload.get("created_at"), created_at=payload.get("created_at"),
updated_at=payload.get("updated_at"), updated_at=payload.get("updated_at"),
payload_json=json.dumps(details, ensure_ascii=True), payload_json=json.dumps(details, ensure_ascii=True),
@@ -1656,8 +1704,14 @@ async def search_requests(
status_label = _status_label(status) status_label = _status_label(status)
elif isinstance(media_info_id, int): elif isinstance(media_info_id, int):
username_norm = _normalize_username(user.get("username", "")) username_norm = _normalize_username(user.get("username", ""))
requested_by_id = user.get("jellyseerr_user_id")
requested_by = None if user.get("role") == "admin" else username_norm requested_by = None if user.get("role") == "admin" else username_norm
cached = get_cached_request_by_media_id(media_info_id, requested_by_norm=requested_by) requested_by_id = None if user.get("role") == "admin" else requested_by_id
cached = get_cached_request_by_media_id(
media_info_id,
requested_by_norm=requested_by,
requested_by_id=requested_by_id,
)
if cached: if cached:
request_id = cached.get("request_id") request_id = cached.get("request_id")
status = cached.get("status") status = cached.get("status")

View File

@@ -1084,6 +1084,118 @@ button span {
line-height: 1.4; line-height: 1.4;
} }
.user-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 16px;
}
.user-grid-card {
display: grid;
gap: 14px;
padding: 16px;
border-radius: 16px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.04);
color: var(--ink);
text-decoration: none;
transition: border-color 0.2s ease, transform 0.2s ease;
}
.user-grid-card:hover {
border-color: rgba(59, 130, 246, 0.5);
transform: translateY(-2px);
}
.user-grid-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
}
.user-grid-meta {
display: block;
font-size: 12px;
color: var(--ink-muted);
}
.user-grid-pill {
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
border: 1px solid rgba(59, 130, 246, 0.4);
color: var(--ink);
background: rgba(59, 130, 246, 0.2);
}
.user-grid-pill.is-blocked {
border-color: rgba(255, 82, 82, 0.5);
background: rgba(255, 82, 82, 0.2);
}
.user-grid-stats {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.user-grid-stats .label {
font-size: 12px;
color: var(--ink-muted);
display: block;
}
.user-grid-stats .value {
font-size: 16px;
font-weight: 600;
}
.user-grid-footer {
display: grid;
gap: 6px;
}
.user-detail-card {
display: grid;
gap: 16px;
padding: 18px;
border-radius: 18px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.04);
}
.user-detail-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.user-detail-meta {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.user-detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
}
.user-detail-grid .label {
font-size: 12px;
color: var(--ink-muted);
display: block;
}
.user-detail-grid .value {
font-size: 18px;
font-weight: 600;
}
.label-row { .label-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;

View File

@@ -312,11 +312,12 @@ export default function HomePage() {
<h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2> <h2>{role === 'admin' ? 'All requests' : 'My recent requests'}</h2>
{authReady && ( {authReady && (
<label className="recent-filter"> <label className="recent-filter">
<span>Show last</span> <span>Show</span>
<select <select
value={recentDays} value={recentDays}
onChange={(event) => setRecentDays(Number(event.target.value))} onChange={(event) => setRecentDays(Number(event.target.value))}
> >
<option value={0}>All</option>
<option value={30}>30 days</option> <option value={30}>30 days</option>
<option value={60}>60 days</option> <option value={60}>60 days</option>
<option value={90}>90 days</option> <option value={90}>90 days</option>

View File

@@ -0,0 +1,234 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../../lib/auth'
import AdminShell from '../../ui/AdminShell'
type UserStats = {
total: number
ready: number
pending: number
approved: number
working: number
partial: number
declined: number
in_progress: number
last_request_at?: string | null
}
type AdminUser = {
id?: number
username: string
role: string
auth_provider?: string | null
last_login_at?: string | null
is_blocked?: boolean
jellyseerr_user_id?: number | null
}
const formatDateTime = (value?: string | null) => {
if (!value) return 'Never'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
const normalizeStats = (stats: any): UserStats => ({
total: Number(stats?.total ?? 0),
ready: Number(stats?.ready ?? 0),
pending: Number(stats?.pending ?? 0),
approved: Number(stats?.approved ?? 0),
working: Number(stats?.working ?? 0),
partial: Number(stats?.partial ?? 0),
declined: Number(stats?.declined ?? 0),
in_progress: Number(stats?.in_progress ?? 0),
last_request_at: stats?.last_request_at ?? null,
})
export default function UserDetailPage() {
const params = useParams()
const router = useRouter()
const idParam = Array.isArray(params?.id) ? params.id[0] : params?.id
const [user, setUser] = useState<AdminUser | null>(null)
const [stats, setStats] = useState<UserStats | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const loadUser = async () => {
if (!idParam) return
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/id/${encodeURIComponent(idParam)}`
)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (response.status === 403) {
router.push('/')
return
}
if (response.status === 404) {
setError('User not found.')
return
}
throw new Error('Could not load user.')
}
const data = await response.json()
setUser(data?.user ?? null)
setStats(normalizeStats(data?.stats))
setError(null)
} catch (err) {
console.error(err)
setError('Could not load user.')
} finally {
setLoading(false)
}
}
const toggleUserBlock = async (blocked: boolean) => {
if (!user) return
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/${blocked ? 'block' : 'unblock'}`,
{ method: 'POST' }
)
if (!response.ok) {
throw new Error('Update failed')
}
await loadUser()
} catch (err) {
console.error(err)
setError('Could not update user access.')
}
}
const updateUserRole = async (role: string) => {
if (!user) return
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/users/${encodeURIComponent(user.username)}/role`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role }),
}
)
if (!response.ok) {
throw new Error('Update failed')
}
await loadUser()
} catch (err) {
console.error(err)
setError('Could not update user role.')
}
}
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
}
void loadUser()
}, [router, idParam])
if (loading) {
return <main className="card">Loading user...</main>
}
return (
<AdminShell
title={user?.username || 'User'}
subtitle="User overview and request stats."
actions={
<button type="button" onClick={() => router.push('/users')}>
Back to users
</button>
}
>
<section className="admin-section">
{error && <div className="error-banner">{error}</div>}
{!user ? (
<div className="status-banner">No user data found.</div>
) : (
<>
<div className="user-detail-card">
<div className="user-detail-header">
<div>
<strong>{user.username}</strong>
<div className="user-detail-meta">
<span className="meta">Jellyseerr ID: {user.jellyseerr_user_id ?? user.id ?? 'Unknown'}</span>
<span className="meta">Role: {user.role}</span>
<span className="meta">Login type: {user.auth_provider || 'local'}</span>
<span className="meta">Last login: {formatDateTime(user.last_login_at)}</span>
</div>
</div>
<div className="user-actions">
<label className="toggle">
<input
type="checkbox"
checked={user.role === 'admin'}
onChange={(event) => updateUserRole(event.target.checked ? 'admin' : 'user')}
/>
<span>Make admin</span>
</label>
<button
type="button"
className="ghost-button"
onClick={() => toggleUserBlock(!user.is_blocked)}
>
{user.is_blocked ? 'Allow access' : 'Block access'}
</button>
</div>
</div>
<div className="user-detail-grid">
<div>
<span className="label">Total</span>
<span className="value">{stats?.total ?? 0}</span>
</div>
<div>
<span className="label">Ready</span>
<span className="value">{stats?.ready ?? 0}</span>
</div>
<div>
<span className="label">Pending</span>
<span className="value">{stats?.pending ?? 0}</span>
</div>
<div>
<span className="label">Approved</span>
<span className="value">{stats?.approved ?? 0}</span>
</div>
<div>
<span className="label">Working</span>
<span className="value">{stats?.working ?? 0}</span>
</div>
<div>
<span className="label">Partial</span>
<span className="value">{stats?.partial ?? 0}</span>
</div>
<div>
<span className="label">Declined</span>
<span className="value">{stats?.declined ?? 0}</span>
</div>
<div>
<span className="label">In progress</span>
<span className="value">{stats?.in_progress ?? 0}</span>
</div>
<div>
<span className="label">Last request</span>
<span className="value">{formatDateTime(stats?.last_request_at)}</span>
</div>
</div>
</div>
</>
)}
</section>
</AdminShell>
)
}

View File

@@ -2,15 +2,30 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth' import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import AdminShell from '../ui/AdminShell' import AdminShell from '../ui/AdminShell'
type AdminUser = { type AdminUser = {
id: number
username: string username: string
role: string role: string
authProvider?: string | null authProvider?: string | null
lastLoginAt?: string | null lastLoginAt?: string | null
isBlocked?: boolean isBlocked?: boolean
stats?: UserStats
}
type UserStats = {
total: number
ready: number
pending: number
approved: number
working: number
partial: number
declined: number
in_progress: number
last_request_at?: string | null
} }
const formatLastLogin = (value?: string | null) => { const formatLastLogin = (value?: string | null) => {
@@ -20,18 +35,50 @@ const formatLastLogin = (value?: string | null) => {
return date.toLocaleString() return date.toLocaleString()
} }
const formatLastRequest = (value?: string | null) => {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.valueOf())) return value
return date.toLocaleString()
}
const emptyStats: UserStats = {
total: 0,
ready: 0,
pending: 0,
approved: 0,
working: 0,
partial: 0,
declined: 0,
in_progress: 0,
last_request_at: null,
}
const normalizeStats = (stats: any): UserStats => ({
total: Number(stats?.total ?? 0),
ready: Number(stats?.ready ?? 0),
pending: Number(stats?.pending ?? 0),
approved: Number(stats?.approved ?? 0),
working: Number(stats?.working ?? 0),
partial: Number(stats?.partial ?? 0),
declined: Number(stats?.declined ?? 0),
in_progress: Number(stats?.in_progress ?? 0),
last_request_at: stats?.last_request_at ?? null,
})
export default function UsersPage() { export default function UsersPage() {
const router = useRouter() const router = useRouter()
const [users, setUsers] = useState<AdminUser[]>([]) const [users, setUsers] = useState<AdminUser[]>([])
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [jellyfinSyncStatus, setJellyfinSyncStatus] = useState<string | null>(null) const [jellyseerrSyncStatus, setJellyseerrSyncStatus] = useState<string | null>(null)
const [jellyfinSyncBusy, setJellyfinSyncBusy] = useState(false) const [jellyseerrSyncBusy, setJellyseerrSyncBusy] = useState(false)
const [jellyseerrResyncBusy, setJellyseerrResyncBusy] = useState(false)
const loadUsers = async () => { const loadUsers = async () => {
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/users`) const response = await authFetch(`${baseUrl}/admin/users/summary`)
if (!response.ok) { if (!response.ok) {
if (response.status === 401) { if (response.status === 401) {
clearToken() clearToken()
@@ -53,6 +100,8 @@ export default function UsersPage() {
authProvider: user.auth_provider ?? 'local', authProvider: user.auth_provider ?? 'local',
lastLoginAt: user.last_login_at ?? null, lastLoginAt: user.last_login_at ?? null,
isBlocked: Boolean(user.is_blocked), isBlocked: Boolean(user.is_blocked),
id: Number(user.id ?? 0),
stats: normalizeStats(user.stats ?? emptyStats),
})) }))
) )
} else { } else {
@@ -105,12 +154,12 @@ export default function UsersPage() {
} }
} }
const syncJellyfinUsers = async () => { const syncJellyseerrUsers = async () => {
setJellyfinSyncStatus(null) setJellyseerrSyncStatus(null)
setJellyfinSyncBusy(true) setJellyseerrSyncBusy(true)
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/jellyfin/users/sync`, { const response = await authFetch(`${baseUrl}/admin/jellyseerr/users/sync`, {
method: 'POST', method: 'POST',
}) })
if (!response.ok) { if (!response.ok) {
@@ -118,13 +167,44 @@ export default function UsersPage() {
throw new Error(text || 'Sync failed') throw new Error(text || 'Sync failed')
} }
const data = await response.json() const data = await response.json()
setJellyfinSyncStatus(`Synced ${data?.imported ?? 0} Jellyfin users.`) setJellyseerrSyncStatus(
`Matched ${data?.matched ?? 0} users. Skipped ${data?.skipped ?? 0}.`
)
await loadUsers() await loadUsers()
} catch (err) { } catch (err) {
console.error(err) console.error(err)
setJellyfinSyncStatus('Could not sync Jellyfin users.') setJellyseerrSyncStatus('Could not sync Jellyseerr users.')
} finally { } finally {
setJellyfinSyncBusy(false) setJellyseerrSyncBusy(false)
}
}
const resyncJellyseerrUsers = async () => {
const confirmed = window.confirm(
'This will remove all non-admin users and re-import from Jellyseerr. Continue?'
)
if (!confirmed) return
setJellyseerrSyncStatus(null)
setJellyseerrResyncBusy(true)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/jellyseerr/users/resync`, {
method: 'POST',
})
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Resync failed')
}
const data = await response.json()
setJellyseerrSyncStatus(
`Re-imported ${data?.imported ?? 0} users. Cleared ${data?.cleared ?? 0}.`
)
await loadUsers()
} catch (err) {
console.error(err)
setJellyseerrSyncStatus('Could not resync Jellyseerr users.')
} finally {
setJellyseerrResyncBusy(false)
} }
} }
@@ -149,49 +229,63 @@ export default function UsersPage() {
<button type="button" onClick={loadUsers}> <button type="button" onClick={loadUsers}>
Reload list Reload list
</button> </button>
<button type="button" onClick={syncJellyfinUsers} disabled={jellyfinSyncBusy}> <button type="button" onClick={syncJellyseerrUsers} disabled={jellyseerrSyncBusy}>
{jellyfinSyncBusy ? 'Syncing Jellyfin users...' : 'Sync Jellyfin users'} {jellyseerrSyncBusy ? 'Syncing Jellyseerr users...' : 'Sync Jellyseerr users'}
</button>
<button type="button" onClick={resyncJellyseerrUsers} disabled={jellyseerrResyncBusy}>
{jellyseerrResyncBusy ? 'Resyncing Jellyseerr users...' : 'Resync Jellyseerr users'}
</button> </button>
</> </>
} }
> >
<section className="admin-section"> <section className="admin-section">
{error && <div className="error-banner">{error}</div>} {error && <div className="error-banner">{error}</div>}
{jellyfinSyncStatus && <div className="status-banner">{jellyfinSyncStatus}</div>} {jellyseerrSyncStatus && <div className="status-banner">{jellyseerrSyncStatus}</div>}
{users.length === 0 ? ( {users.length === 0 ? (
<div className="status-banner">No users found yet.</div> <div className="status-banner">No users found yet.</div>
) : ( ) : (
<div className="admin-grid"> <div className="user-grid">
{users.map((user) => ( {users.map((user) => (
<div key={user.username} className="summary-card user-card"> <Link
<div> key={user.username}
<strong>{user.username}</strong> className="user-grid-card"
<div className="user-meta"> href={`/users/${user.id}`}
<span className="meta">Role: {user.role}</span> >
<span className="meta">Login type: {user.authProvider || 'local'}</span> <div className="user-grid-header">
<span className="meta">Last login: {formatLastLogin(user.lastLoginAt)}</span> <div>
<strong>{user.username}</strong>
<span className="user-grid-meta">{user.role}</span>
</div>
<span className={`user-grid-pill ${user.isBlocked ? 'is-blocked' : ''}`}>
{user.isBlocked ? 'Blocked' : 'Active'}
</span>
</div>
<div className="user-grid-stats">
<div>
<span className="label">Total</span>
<span className="value">{user.stats?.total ?? 0}</span>
</div>
<div>
<span className="label">Ready</span>
<span className="value">{user.stats?.ready ?? 0}</span>
</div>
<div>
<span className="label">Pending</span>
<span className="value">{user.stats?.pending ?? 0}</span>
</div>
<div>
<span className="label">In progress</span>
<span className="value">{user.stats?.in_progress ?? 0}</span>
</div> </div>
</div> </div>
<div className="user-actions"> <div className="user-grid-footer">
<label className="toggle"> <span className="meta">Login: {user.authProvider || 'local'}</span>
<input <span className="meta">Last login: {formatLastLogin(user.lastLoginAt)}</span>
type="checkbox" <span className="meta">
checked={user.role === 'admin'} Last request: {formatLastRequest(user.stats?.last_request_at)}
onChange={(event) => </span>
updateUserRole(user.username, event.target.checked ? 'admin' : 'user')
}
/>
<span>Make admin</span>
</label>
<button
type="button"
className="ghost-button"
onClick={() => toggleUserBlock(user.username, !user.isBlocked)}
>
{user.isBlocked ? 'Allow access' : 'Block access'}
</button>
</div> </div>
</div> </Link>
))} ))}
</div> </div>
)} )}

View File

@@ -1,7 +1,7 @@
{ {
"name": "magent-frontend", "name": "magent-frontend",
"private": true, "private": true,
"version": "0.1.0", "version": "2901262036",
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"build": "next build", "build": "next build",