6 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
ceb8c1c9eb Build 2501262041 2026-01-25 20:43:14 +13:00
86ca3bdeb2 Harden request cache titles and cache-only reads 2026-01-25 19:38:31 +13:00
22f90b7e07 Serve bundled branding assets by default 2026-01-25 18:20:30 +13:00
57a4883931 Seed branding logo from bundled assets 2026-01-25 18:01:54 +13:00
11 changed files with 341 additions and 95 deletions

View File

@@ -1 +1 @@
251260452
261261420

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -24,6 +24,92 @@ def _connect() -> sqlite3.Connection:
return sqlite3.connect(_db_path())
def _normalize_title_value(title: Optional[str]) -> Optional[str]:
if not isinstance(title, str):
return None
trimmed = title.strip()
return trimmed if trimmed else None
def _normalize_year_value(year: Optional[Any]) -> Optional[int]:
if isinstance(year, int):
return year
if isinstance(year, str):
trimmed = year.strip()
if trimmed.isdigit():
return int(trimmed)
return None
def _is_placeholder_title(title: Optional[str], request_id: Optional[int]) -> bool:
if not isinstance(title, str):
return True
normalized = title.strip().lower()
if not normalized:
return True
if normalized == "untitled":
return True
if request_id and normalized == f"request {request_id}":
return True
return False
def _extract_title_year_from_payload(payload_json: Optional[str]) -> tuple[Optional[str], Optional[int]]:
if not payload_json:
return None, None
try:
payload = json.loads(payload_json)
except json.JSONDecodeError:
return None, None
if not isinstance(payload, dict):
return None, None
media = payload.get("media") or {}
title = None
year = None
if isinstance(media, dict):
title = media.get("title") or media.get("name")
year = media.get("year")
if not title:
title = payload.get("title") or payload.get("name")
if year is None:
year = payload.get("year")
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(
@@ -603,7 +689,34 @@ def upsert_request_cache(
updated_at: Optional[str],
payload_json: str,
) -> None:
normalized_title = _normalize_title_value(title)
normalized_year = _normalize_year_value(year)
derived_title = None
derived_year = None
if not normalized_title or normalized_year is None:
derived_title, derived_year = _extract_title_year_from_payload(payload_json)
if _is_placeholder_title(normalized_title, request_id):
normalized_title = None
if derived_title and not normalized_title:
normalized_title = derived_title
if normalized_year is None and derived_year is not None:
normalized_year = derived_year
with _connect() as conn:
existing_title = None
existing_year = None
if normalized_title is None or normalized_year is None:
row = conn.execute(
"SELECT title, year FROM requests_cache WHERE request_id = ?",
(request_id,),
).fetchone()
if row:
existing_title, existing_year = row[0], row[1]
if _is_placeholder_title(existing_title, request_id):
existing_title = None
if normalized_title is None and existing_title:
normalized_title = existing_title
if normalized_year is None and existing_year is not None:
normalized_year = existing_year
conn.execute(
"""
INSERT INTO requests_cache (
@@ -637,8 +750,8 @@ def upsert_request_cache(
media_id,
media_type,
status,
title,
year,
normalized_title,
normalized_year,
requested_by,
requested_by_norm,
created_at,
@@ -741,22 +854,11 @@ def get_cached_requests(
title = row[4]
year = row[5]
if (not title or not year) and row[8]:
try:
payload = json.loads(row[8])
if isinstance(payload, dict):
media = payload.get("media") or {}
derived_title, derived_year = _extract_title_year_from_payload(row[8])
if not title:
title = (
(media.get("title") if isinstance(media, dict) else None)
or (media.get("name") if isinstance(media, dict) else None)
or payload.get("title")
or payload.get("name")
)
title = derived_title
if not year:
year = media.get("year") if isinstance(media, dict) else None
year = year or payload.get("year")
except json.JSONDecodeError:
pass
year = derived_year
results.append(
{
"request_id": row[0],
@@ -788,18 +890,8 @@ def get_request_cache_overview(limit: int = 50) -> list[Dict[str, Any]]:
for row in rows:
title = row[4]
if not title and row[9]:
try:
payload = json.loads(row[9])
if isinstance(payload, dict):
media = payload.get("media") or {}
title = (
(media.get("title") if isinstance(media, dict) else None)
or (media.get("name") if isinstance(media, dict) else None)
or payload.get("title")
or payload.get("name")
)
except json.JSONDecodeError:
title = row[4]
derived_title, _ = _extract_title_year_from_payload(row[9])
title = derived_title or row[4]
results.append(
{
"request_id": row[0],
@@ -816,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()
@@ -825,7 +945,9 @@ def get_request_cache_count() -> int:
def update_request_cache_title(
request_id: int, title: str, year: Optional[int] = None
) -> None:
if not title:
normalized_title = _normalize_title_value(title)
normalized_year = _normalize_year_value(year)
if not normalized_title:
return
with _connect() as conn:
conn.execute(
@@ -834,10 +956,38 @@ def update_request_cache_title(
SET title = ?, year = COALESCE(?, year)
WHERE request_id = ?
""",
(title, year, request_id),
(normalized_title, normalized_year, request_id),
)
def repair_request_cache_titles() -> int:
updated = 0
with _connect() as conn:
rows = conn.execute(
"""
SELECT request_id, title, year, payload_json
FROM requests_cache
"""
).fetchall()
for row in rows:
request_id, title, year, payload_json = row
if not _is_placeholder_title(title, request_id):
continue
derived_title, derived_year = _extract_title_year_from_payload(payload_json)
if not derived_title:
continue
conn.execute(
"""
UPDATE requests_cache
SET title = ?, year = COALESCE(?, year)
WHERE request_id = ?
""",
(derived_title, derived_year, request_id),
)
updated += 1
return updated
def prune_duplicate_requests_cache() -> int:
with _connect() as conn:
cursor = conn.execute(

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,
@@ -21,6 +22,7 @@ from ..db import (
clear_history,
cleanup_history,
update_request_cache_title,
repair_request_cache_titles,
)
from ..runtime import get_runtime_settings
from ..clients.sonarr import SonarrClient
@@ -99,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 []
@@ -282,27 +316,13 @@ async def read_logs(lines: int = 200) -> Dict[str, Any]:
@router.get("/requests/cache")
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)
missing_titles = [row for row in rows if not row.get("title")]
if missing_titles:
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if client.configured():
for row in missing_titles:
request_id = row.get("request_id")
if not isinstance(request_id, int):
continue
details = await requests_router._get_request_details(client, request_id)
if not isinstance(details, dict):
continue
payload = requests_router._parse_request_payload(details)
title = payload.get("title")
if not title:
continue
row["title"] = title
if payload.get("year"):
row["year"] = payload.get("year")
update_request_cache_title(request_id, title, payload.get("year"))
return {"rows": rows}

View File

@@ -11,6 +11,10 @@ router = APIRouter(prefix="/branding", tags=["branding"])
_BRANDING_DIR = os.path.join(os.getcwd(), "data", "branding")
_LOGO_PATH = os.path.join(_BRANDING_DIR, "logo.png")
_FAVICON_PATH = os.path.join(_BRANDING_DIR, "favicon.ico")
_BUNDLED_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "assets", "branding"))
_BUNDLED_LOGO_PATH = os.path.join(_BUNDLED_DIR, "logo.png")
_BUNDLED_FAVICON_PATH = os.path.join(_BUNDLED_DIR, "favicon.ico")
_BRANDING_SOURCE = os.getenv("BRANDING_SOURCE", "bundled").lower()
def _ensure_branding_dir() -> None:
@@ -41,6 +45,18 @@ def _ensure_default_branding() -> None:
if os.path.exists(_LOGO_PATH) and os.path.exists(_FAVICON_PATH):
return
_ensure_branding_dir()
if not os.path.exists(_LOGO_PATH) and os.path.exists(_BUNDLED_LOGO_PATH):
try:
with open(_BUNDLED_LOGO_PATH, "rb") as source, open(_LOGO_PATH, "wb") as target:
target.write(source.read())
except OSError:
pass
if not os.path.exists(_FAVICON_PATH) and os.path.exists(_BUNDLED_FAVICON_PATH):
try:
with open(_BUNDLED_FAVICON_PATH, "rb") as source, open(_FAVICON_PATH, "wb") as target:
target.write(source.read())
except OSError:
pass
if not os.path.exists(_LOGO_PATH):
image = Image.new("RGBA", (300, 300), (12, 18, 28, 255))
draw = ImageDraw.Draw(image)
@@ -65,24 +81,32 @@ def _ensure_default_branding() -> None:
favicon.save(_FAVICON_PATH, format="ICO")
def _resolve_branding_paths() -> tuple[str, str]:
if _BRANDING_SOURCE == "data":
_ensure_default_branding()
return _LOGO_PATH, _FAVICON_PATH
if os.path.exists(_BUNDLED_LOGO_PATH) and os.path.exists(_BUNDLED_FAVICON_PATH):
return _BUNDLED_LOGO_PATH, _BUNDLED_FAVICON_PATH
_ensure_default_branding()
return _LOGO_PATH, _FAVICON_PATH
@router.get("/logo.png")
async def branding_logo() -> FileResponse:
if not os.path.exists(_LOGO_PATH):
_ensure_default_branding()
if not os.path.exists(_LOGO_PATH):
logo_path, _ = _resolve_branding_paths()
if not os.path.exists(logo_path):
raise HTTPException(status_code=404, detail="Logo not found")
headers = {"Cache-Control": "public, max-age=300"}
return FileResponse(_LOGO_PATH, media_type="image/png", headers=headers)
headers = {"Cache-Control": "no-store"}
return FileResponse(logo_path, media_type="image/png", headers=headers)
@router.get("/favicon.ico")
async def branding_favicon() -> FileResponse:
if not os.path.exists(_FAVICON_PATH):
_ensure_default_branding()
if not os.path.exists(_FAVICON_PATH):
_, favicon_path = _resolve_branding_paths()
if not os.path.exists(favicon_path):
raise HTTPException(status_code=404, detail="Favicon not found")
headers = {"Cache-Control": "public, max-age=300"}
return FileResponse(_FAVICON_PATH, media_type="image/x-icon", headers=headers)
headers = {"Cache-Control": "no-store"}
return FileResponse(favicon_path, media_type="image/x-icon", headers=headers)
async def save_branding_image(file: UploadFile) -> Dict[str, Any]:

View File

@@ -30,6 +30,7 @@ from ..db import (
get_request_cache_last_updated,
get_request_cache_count,
get_request_cache_payloads,
repair_request_cache_titles,
prune_duplicate_requests_cache,
upsert_request_cache,
get_setting,
@@ -814,13 +815,14 @@ def _get_recent_from_cache(
async def startup_warmup_requests_cache() -> None:
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
if not client.configured():
return
if client.configured():
try:
await _ensure_requests_cache(client)
except httpx.HTTPError as exc:
logger.warning("Requests warmup skipped: %s", exc)
return
repaired = repair_request_cache_titles()
if repaired:
logger.info("Requests cache titles repaired: %s", repaired)
_refresh_recent_cache_from_db()
@@ -968,7 +970,10 @@ async def _ensure_request_access(
runtime = get_runtime_settings()
mode = (runtime.requests_data_source or "prefer_cache").lower()
cached = get_request_cache_payload(request_id)
if mode != "always_js" and cached is not None:
if mode != "always_js":
if cached is None:
logger.debug("access cache miss: request_id=%s mode=%s", request_id, mode)
raise HTTPException(status_code=404, detail="Request not found in cache")
logger.debug("access cache hit: request_id=%s mode=%s", request_id, mode)
if _request_matches_user(cached, user.get("username", "")):
return
@@ -1249,9 +1254,11 @@ async def recent_requests(
) -> dict:
runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
mode = (runtime.requests_data_source or "prefer_cache").lower()
allow_remote = mode == "always_js"
if allow_remote:
if not client.configured():
raise HTTPException(status_code=400, detail="Jellyseerr not configured")
try:
await _ensure_requests_cache(client)
except httpx.HTTPStatusError as exc:
@@ -1266,10 +1273,8 @@ async def recent_requests(
_refresh_recent_cache_from_db()
rows = _get_recent_from_cache(requested_by, take, skip, since_iso)
cache_mode = (runtime.artwork_cache_mode or "remote").lower()
mode = (runtime.requests_data_source or "prefer_cache").lower()
allow_remote = mode == "always_js"
allow_title_hydrate = mode == "prefer_cache"
allow_artwork_hydrate = allow_remote or allow_title_hydrate
allow_title_hydrate = False
allow_artwork_hydrate = allow_remote
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
jellyfin_cache: Dict[str, bool] = {}
@@ -1814,4 +1819,3 @@ async def action_grab(
save_action, request_id, "grab", "Grab release", "ok", action_message
)
return {"status": "ok", "response": {"qbittorrent": "queued"}}

View File

@@ -220,6 +220,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
"snapshot cache miss: request_id=%s mode=%s", request_id, mode
)
allow_remote = mode == "always_js" and jellyseerr.configured()
if not jellyseerr.configured() and not cached_request:
timeline.append(TimelineHop(service="Jellyseerr", status="not_configured"))
timeline.append(TimelineHop(service="Sonarr/Radarr", status="not_configured"))
@@ -227,9 +228,15 @@ async def build_snapshot(request_id: str) -> Snapshot:
timeline.append(TimelineHop(service="qBittorrent", status="not_configured"))
snapshot.timeline = timeline
return snapshot
if cached_request is None and not allow_remote:
timeline.append(TimelineHop(service="Jellyseerr", status="cache_miss"))
snapshot.timeline = timeline
snapshot.state = NormalizedState.unknown
snapshot.state_reason = "Request not found in cache"
return snapshot
jelly_request = cached_request
if (jelly_request is None or mode == "always_js") and jellyseerr.configured():
if allow_remote and (jelly_request is None or mode == "always_js"):
try:
jelly_request = await jellyseerr.get_request(request_id)
logging.getLogger(__name__).debug(
@@ -262,7 +269,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
poster_path = media.get("posterPath") or media.get("poster_path")
backdrop_path = media.get("backdropPath") or media.get("backdrop_path")
if snapshot.title in {None, "", "Unknown"} and jellyseerr.configured():
if snapshot.title in {None, "", "Unknown"} and allow_remote:
tmdb_id = jelly_request.get("media", {}).get("tmdbId")
if tmdb_id:
try:

View File

@@ -297,7 +297,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
requests_full_sync_time: 'Daily time to refresh the full request list.',
requests_cleanup_time: 'Daily time to trim old history.',
requests_cleanup_days: 'History older than this is removed during cleanup.',
requests_data_source: 'Pick where Magent should read requests from.',
requests_data_source:
'Pick where Magent should read requests from. Cache-only avoids Jellyseerr lookups on reads.',
log_level: 'How much detail is written to the activity log.',
log_file: 'Where the activity log is stored.',
site_build_number: 'Build number shown in the account menu (auto-set from releases).',
@@ -1129,7 +1130,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}
>
<option value="always_js">Always use Jellyseerr (slower)</option>
<option value="prefer_cache">Use saved requests first (faster)</option>
<option value="prefer_cache">
Use saved requests only (fastest)
</option>
</select>
</label>
)

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