diff --git a/.build_number b/.build_number index 81a88e3..ff9993b 100644 --- a/.build_number +++ b/.build_number @@ -1 +1 @@ -2501262041 +2601261358 diff --git a/backend/app/db.py b/backend/app/db.py index 225014b..0a25e20 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -76,6 +76,40 @@ def _extract_title_year_from_payload(payload_json: Optional[str]) -> tuple[Optio return _normalize_title_value(title), _normalize_year_value(year) +def _extract_tmdb_from_payload(payload_json: Optional[str]) -> tuple[Optional[int], Optional[str]]: + if not payload_json: + return None, None + try: + payload = json.loads(payload_json) + except (TypeError, json.JSONDecodeError): + return None, None + if not isinstance(payload, dict): + return None, None + media = payload.get("media") or {} + if not isinstance(media, dict): + media = {} + tmdb_id = ( + media.get("tmdbId") + or payload.get("tmdbId") + or payload.get("tmdb_id") + or media.get("externalServiceId") + or payload.get("externalServiceId") + ) + media_type = ( + media.get("mediaType") + or payload.get("mediaType") + or payload.get("media_type") + or payload.get("type") + ) + try: + tmdb_id = int(tmdb_id) if tmdb_id is not None else None + except (TypeError, ValueError): + tmdb_id = None + if isinstance(media_type, str): + media_type = media_type.strip().lower() or None + return tmdb_id, media_type + + def init_db() -> None: with _connect() as conn: conn.execute( @@ -874,6 +908,34 @@ def get_request_cache_overview(limit: int = 50) -> list[Dict[str, Any]]: return results +def get_request_cache_missing_titles(limit: int = 200) -> list[Dict[str, Any]]: + limit = max(1, min(limit, 500)) + with _connect() as conn: + rows = conn.execute( + """ + SELECT request_id, payload_json + FROM requests_cache + WHERE title IS NULL OR TRIM(title) = '' OR LOWER(title) = 'untitled' + ORDER BY updated_at DESC, request_id DESC + LIMIT ? + """, + (limit,), + ).fetchall() + results: list[Dict[str, Any]] = [] + for row in rows: + payload_json = row[1] + tmdb_id, media_type = _extract_tmdb_from_payload(payload_json) + results.append( + { + "request_id": row[0], + "payload_json": payload_json, + "tmdb_id": tmdb_id, + "media_type": media_type, + } + ) + return results + + def get_request_cache_count() -> int: with _connect() as conn: row = conn.execute("SELECT COUNT(*) FROM requests_cache").fetchone() diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 0d2779e..c5e9abf 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -9,6 +9,7 @@ from ..db import ( delete_setting, get_all_users, get_request_cache_overview, + get_request_cache_missing_titles, get_settings_overrides, get_user_by_username, set_setting, @@ -100,6 +101,38 @@ def _normalize_root_folders(folders: Any) -> List[Dict[str, Any]]: 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 [] @@ -286,6 +319,9 @@ 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} diff --git a/frontend/app/users/page.tsx b/frontend/app/users/page.tsx index 12bfcbe..75e299a 100644 --- a/frontend/app/users/page.tsx +++ b/frontend/app/users/page.tsx @@ -25,6 +25,8 @@ export default function UsersPage() { const [users, setUsers] = useState([]) const [error, setError] = useState(null) const [loading, setLoading] = useState(true) + const [jellyfinSyncStatus, setJellyfinSyncStatus] = useState(null) + const [jellyfinSyncBusy, setJellyfinSyncBusy] = useState(false) const loadUsers = async () => { try { @@ -103,6 +105,28 @@ export default function UsersPage() { } } + const syncJellyfinUsers = async () => { + setJellyfinSyncStatus(null) + setJellyfinSyncBusy(true) + try { + const baseUrl = getApiBase() + const response = await authFetch(`${baseUrl}/admin/jellyfin/users/sync`, { + method: 'POST', + }) + if (!response.ok) { + const text = await response.text() + throw new Error(text || 'Sync failed') + } + const data = await response.json() + setJellyfinSyncStatus(`Synced ${data?.imported ?? 0} Jellyfin users.`) + await loadUsers() + } catch (err) { + console.error(err) + setJellyfinSyncStatus('Could not sync Jellyfin users.') + } finally { + setJellyfinSyncBusy(false) + } + } useEffect(() => { if (!getToken()) { @@ -121,13 +145,19 @@ export default function UsersPage() { title="Users" subtitle="Manage who can use Magent." actions={ - + <> + + + } >
{error &&
{error}
} + {jellyfinSyncStatus &&
{jellyfinSyncStatus}
} {users.length === 0 ? (
No users found yet.
) : (