2 Commits

Author SHA1 Message Date
ab27ebfadf Fix sync progress bar animation 2026-01-26 14:21:18 +13:00
b93b41713a Fix cache title hydration 2026-01-26 14:01:06 +13:00
5 changed files with 145 additions and 9 deletions

View File

@@ -1 +1 @@
2501262041
261261420

View File

@@ -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()

View File

@@ -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}

View File

@@ -1326,6 +1326,17 @@ button span {
.progress-indeterminate .progress-fill {
position: absolute;
width: 100%;
left: 0;
top: 0;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0),
var(--accent-2),
var(--accent-3),
rgba(255, 255, 255, 0)
);
background-size: 200% 100%;
animation: progress-indeterminate 1.6s ease-in-out infinite;
}
@@ -1509,13 +1520,10 @@ button span {
@keyframes progress-indeterminate {
0% {
transform: translateX(-50%);
}
50% {
transform: translateX(120%);
background-position: 200% 0;
}
100% {
transform: translateX(-50%);
background-position: -200% 0;
}
}

View File

@@ -25,6 +25,8 @@ export default function UsersPage() {
const [users, setUsers] = useState<AdminUser[]>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [jellyfinSyncStatus, setJellyfinSyncStatus] = useState<string | null>(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={
<>
<button type="button" onClick={loadUsers}>
Reload list
</button>
<button type="button" onClick={syncJellyfinUsers} disabled={jellyfinSyncBusy}>
{jellyfinSyncBusy ? 'Syncing Jellyfin users...' : 'Sync Jellyfin users'}
</button>
</>
}
>
<section className="admin-section">
{error && <div className="error-banner">{error}</div>}
{jellyfinSyncStatus && <div className="status-banner">{jellyfinSyncStatus}</div>}
{users.length === 0 ? (
<div className="status-banner">No users found yet.</div>
) : (