diff --git a/backend/app/build_info.py b/backend/app/build_info.py index 96f17de..bfa3a3b 100644 --- a/backend/app/build_info.py +++ b/backend/app/build_info.py @@ -1,2 +1,2 @@ -BUILD_NUMBER = "2901262212" +BUILD_NUMBER = "2901262240" CHANGELOG = '2026-01-22\n- Initial commit 2026-01-22|Ignore build artifacts 2026-01-22|Update README 2026-01-22|Update README with Docker-first guide 2026-01-23|Fix cache titles via Jellyseerr media lookup 2026-01-23|Split search actions and improve download options 2026-01-23|Fallback manual grab to qBittorrent 2026-01-23|Hide header actions when signed out 2026-01-23|Add feedback form and webhook 2026-01-23|Fix cache titles and move feedback link 2026-01-23|Show available status on landing when in Jellyfin 2026-01-23|Add default branding assets when missing 2026-01-23|Use bundled branding assets 2026-01-23|Remove password fields from users page 2026-01-23|Add Docker Hub compose override 2026-01-23|Fix backend Dockerfile paths for root context 2026-01-23|Copy public assets into frontend image 2026-01-23|Use backend branding assets for logo and favicon 2026-01-24|Route grabs through Sonarr/Radarr only 2026-01-24|Document fix buttons in how-it-works 2026-01-24|Clarify how-it-works steps and fixes 2026-01-24|Map Prowlarr releases to Arr indexers for manual grab 2026-01-24|Improve request handling and qBittorrent categories 2026-01-25|Add site banner, build number, and changelog 2026-01-25|Automate build number tagging and sync 2026-01-25|Improve mobile header layout 2026-01-25|Move account actions into avatar menu 2026-01-25|Add user stats and activity tracking 2026-01-25|Add Jellyfin login cache and admin-only stats 2026-01-25|Tidy request sync controls 2026-01-25|Seed branding logo from bundled assets 2026-01-25|Serve bundled branding assets by default 2026-01-25|Harden request cache titles and cache-only reads 2026-01-25|Build 2501262041 2026-01-26|Fix cache title hydration 2026-01-26|Fix sync progress bar animation 2026-01-27|Add cache control artwork stats 2026-01-27|Improve cache stats performance (build 271261145) 2026-01-27|Fix backend cache stats import (build 271261149) 2026-01-27|Clarify request sync settings (build 271261159) 2026-01-27|Bump build number to 271261202 2026-01-27|Fix request titles in snapshots (build 271261219) 2026-01-27|Fix snapshot title fallback (build 271261228) 2026-01-27|Add cache load spinner (build 271261238) 2026-01-27|Bump build number (process 2) 271261322 2026-01-27|Add service test buttons (build 271261335) 2026-01-27|Fallback to TMDB when artwork cache fails (build 271261524) 2026-01-27|Hydrate missing artwork from Jellyseerr (build 271261539) 2026-01-29|release: 2901262036 2026-01-29|release: 2901262044 2026-01-29|release: 2901262102 2026-01-29|Hardcode build number in backend 2026-01-29|Bake build number and changelog 2026-01-29|Update full changelog' diff --git a/backend/app/db.py b/backend/app/db.py index f5d81cf..1f4a13b 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -581,7 +581,7 @@ def set_jellyfin_auth_cache(username: str, password: str) -> None: """ UPDATE users SET jellyfin_password_hash = ?, last_jellyfin_auth_at = ? - WHERE username = ? + WHERE username = ? COLLATE NOCASE """, (password_hash, timestamp, username), ) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 2f6fbb7..ce8ef68 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -39,6 +39,14 @@ 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 @@ -235,6 +243,9 @@ async def radarr_options() -> Dict[str, Any]: @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(): @@ -242,18 +253,7 @@ async def jellyfin_users() -> Dict[str, Any]: users = await client.get_users() if not isinstance(users, list): return {"users": []} - results = [] - for user in users: - if not isinstance(user, dict): - continue - results.append( - { - "id": user.get("Id"), - "name": user.get("Name"), - "hasPassword": user.get("HasPassword"), - "lastLoginDate": user.get("LastLoginDate"), - } - ) + results = save_jellyfin_users_cache(users) return {"users": results} @@ -262,24 +262,13 @@ 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]]: +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 @@ -299,6 +288,8 @@ async def _fetch_all_jellyseerr_users(client: JellyseerrClient) -> List[Dict[str if len(batch) < take: break skip += take + if users: + return save_jellyseerr_users_cache(users) return users @router.post("/jellyseerr/users/sync") @@ -307,19 +298,11 @@ async def jellyseerr_users_sync() -> Dict[str, Any]: 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) + 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: 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) + candidate_to_id = build_jellyseerr_candidate_map(jellyseerr_users) updated = 0 skipped = 0 @@ -329,11 +312,7 @@ async def jellyseerr_users_sync() -> Dict[str, Any]: 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 + 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 @@ -356,7 +335,7 @@ async def jellyseerr_users_resync() -> Dict[str, Any]: 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) + jellyseerr_users = await _fetch_all_jellyseerr_users(client, use_cache=False) if not jellyseerr_users: return {"status": "ok", "imported": 0, "cleared": 0} diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index c5b4dcb..f6fe65e 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -22,6 +22,12 @@ from ..clients.jellyfin import JellyfinClient from ..clients.jellyseerr import JellyseerrClient from ..security import create_access_token, verify_password from ..auth import get_current_user +from ..services.user_cache import ( + build_jellyseerr_candidate_map, + get_cached_jellyseerr_users, + match_jellyseerr_user_id, + save_jellyfin_users_cache, +) router = APIRouter(prefix="/auth", tags=["auth"]) @@ -96,6 +102,8 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di client = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) if not client.configured(): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Jellyfin not configured") + jellyseerr_users = get_cached_jellyseerr_users() + candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or []) username = form_data.username password = form_data.password user = get_user_by_username(username) @@ -118,15 +126,20 @@ async def jellyfin_login(form_data: OAuth2PasswordRequestForm = Depends()) -> di try: users = await client.get_users() if isinstance(users, list): - for user in users: - if not isinstance(user, dict): + save_jellyfin_users_cache(users) + for jellyfin_user in users: + if not isinstance(jellyfin_user, dict): continue - name = user.get("Name") + name = jellyfin_user.get("Name") if isinstance(name, str) and name: create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin") except Exception: pass set_jellyfin_auth_cache(username, password) + if user and user.get("jellyseerr_user_id") is None and candidate_map: + matched_id = match_jellyseerr_user_id(username, candidate_map) + if matched_id is not None: + set_user_jellyseerr_id(username, matched_id) token = create_access_token(username, "user") set_last_login(username) return {"access_token": token, "token_type": "bearer", "user": {"username": username, "role": "user"}} diff --git a/backend/app/services/jellyfin_sync.py b/backend/app/services/jellyfin_sync.py index 3cd2ea5..f47ff01 100644 --- a/backend/app/services/jellyfin_sync.py +++ b/backend/app/services/jellyfin_sync.py @@ -3,8 +3,14 @@ import logging from fastapi import HTTPException from ..clients.jellyfin import JellyfinClient -from ..db import create_user_if_missing +from ..db import create_user_if_missing, set_user_jellyseerr_id from ..runtime import get_runtime_settings +from .user_cache import ( + build_jellyseerr_candidate_map, + get_cached_jellyseerr_users, + match_jellyseerr_user_id, + save_jellyfin_users_cache, +) logger = logging.getLogger(__name__) @@ -17,6 +23,9 @@ async def sync_jellyfin_users() -> int: users = await client.get_users() if not isinstance(users, list): return 0 + save_jellyfin_users_cache(users) + jellyseerr_users = get_cached_jellyseerr_users() + candidate_map = build_jellyseerr_candidate_map(jellyseerr_users or []) imported = 0 for user in users: if not isinstance(user, dict): @@ -24,8 +33,18 @@ async def sync_jellyfin_users() -> int: name = user.get("Name") if not name: continue - if create_user_if_missing(name, "jellyfin-user", role="user", auth_provider="jellyfin"): + matched_id = match_jellyseerr_user_id(name, candidate_map) if candidate_map else None + created = create_user_if_missing( + name, + "jellyfin-user", + role="user", + auth_provider="jellyfin", + jellyseerr_user_id=matched_id, + ) + if created: imported += 1 + elif matched_id is not None: + set_user_jellyseerr_id(name, matched_id) return imported diff --git a/backend/app/services/user_cache.py b/backend/app/services/user_cache.py new file mode 100644 index 0000000..a984383 --- /dev/null +++ b/backend/app/services/user_cache.py @@ -0,0 +1,144 @@ +import json +import logging +from datetime import datetime, timezone, timedelta +from typing import Any, Dict, List, Optional + +from ..db import get_setting, set_setting + +logger = logging.getLogger(__name__) + +JELLYSEERR_CACHE_KEY = "jellyseerr_users_cache" +JELLYSEERR_CACHE_AT_KEY = "jellyseerr_users_cached_at" +JELLYFIN_CACHE_KEY = "jellyfin_users_cache" +JELLYFIN_CACHE_AT_KEY = "jellyfin_users_cached_at" + + +def _now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _parse_iso(value: Optional[str]) -> Optional[datetime]: + if not value: + return None + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed + + +def _cache_is_fresh(cached_at: Optional[str], max_age_minutes: int) -> bool: + parsed = _parse_iso(cached_at) + if not parsed: + return False + age = datetime.now(timezone.utc) - parsed + return age <= timedelta(minutes=max_age_minutes) + + +def _load_cached_users( + cache_key: str, cache_at_key: str, max_age_minutes: int +) -> Optional[List[Dict[str, Any]]]: + cached_at = get_setting(cache_at_key) + if not _cache_is_fresh(cached_at, max_age_minutes): + return None + raw = get_setting(cache_key) + if not raw: + return None + try: + data = json.loads(raw) + except (TypeError, json.JSONDecodeError): + return None + if isinstance(data, list): + return [item for item in data if isinstance(item, dict)] + return None + + +def _save_cached_users(cache_key: str, cache_at_key: str, users: List[Dict[str, Any]]) -> None: + payload = json.dumps(users, ensure_ascii=True) + set_setting(cache_key, payload) + set_setting(cache_at_key, _now_iso()) + + +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 list(dict.fromkeys(handles)) + + +def build_jellyseerr_candidate_map(users: List[Dict[str, Any]]) -> Dict[str, int]: + candidate_to_id: Dict[str, int] = {} + for user in users: + if not isinstance(user, dict): + continue + user_id = user.get("id") or user.get("userId") or user.get("Id") + try: + user_id = int(user_id) + except (TypeError, ValueError): + continue + for key in ("username", "email", "displayName", "name"): + for handle in _normalized_handles(user.get(key)): + candidate_to_id.setdefault(handle, user_id) + return candidate_to_id + + +def match_jellyseerr_user_id( + username: str, candidate_map: Dict[str, int] +) -> Optional[int]: + for handle in _normalized_handles(username): + matched = candidate_map.get(handle) + if matched is not None: + return matched + return None + + +def save_jellyseerr_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + normalized: List[Dict[str, Any]] = [] + for user in users: + if not isinstance(user, dict): + continue + normalized.append( + { + "id": user.get("id") or user.get("userId") or user.get("Id"), + "email": user.get("email"), + "username": user.get("username"), + "displayName": user.get("displayName"), + "name": user.get("name"), + } + ) + _save_cached_users(JELLYSEERR_CACHE_KEY, JELLYSEERR_CACHE_AT_KEY, normalized) + logger.debug("Cached Jellyseerr users: %s", len(normalized)) + return normalized + + +def get_cached_jellyseerr_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]: + return _load_cached_users(JELLYSEERR_CACHE_KEY, JELLYSEERR_CACHE_AT_KEY, max_age_minutes) + + +def save_jellyfin_users_cache(users: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + normalized: List[Dict[str, Any]] = [] + for user in users: + if not isinstance(user, dict): + continue + normalized.append( + { + "id": user.get("Id"), + "name": user.get("Name"), + "hasPassword": user.get("HasPassword"), + "lastLoginDate": user.get("LastLoginDate"), + } + ) + _save_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, normalized) + logger.debug("Cached Jellyfin users: %s", len(normalized)) + return normalized + + +def get_cached_jellyfin_users(max_age_minutes: int = 1440) -> Optional[List[Dict[str, Any]]]: + return _load_cached_users(JELLYFIN_CACHE_KEY, JELLYFIN_CACHE_AT_KEY, max_age_minutes) diff --git a/frontend/package.json b/frontend/package.json index e5e419e..5819a96 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "magent-frontend", "private": true, - "version": "2901262212", + "version": "2901262240", "scripts": { "dev": "next dev", "build": "next build",