13 Commits

11 changed files with 873 additions and 141 deletions

View File

@@ -1 +1 @@
2601261358 271261539

View File

@@ -179,6 +179,21 @@ def init_db() -> None:
) )
""" """
) )
conn.execute(
"""
CREATE TABLE IF NOT EXISTS artwork_cache_status (
request_id INTEGER PRIMARY KEY,
tmdb_id INTEGER,
media_type TEXT,
poster_path TEXT,
backdrop_path TEXT,
has_tmdb INTEGER NOT NULL DEFAULT 0,
poster_cached INTEGER NOT NULL DEFAULT 0,
backdrop_cached INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL
)
"""
)
conn.execute( conn.execute(
""" """
CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at
@@ -191,6 +206,12 @@ def init_db() -> None:
ON requests_cache (requested_by_norm) ON requests_cache (requested_by_norm)
""" """
) )
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_artwork_cache_status_updated_at
ON artwork_cache_status (updated_at)
"""
)
conn.execute( conn.execute(
""" """
CREATE TABLE IF NOT EXISTS user_activity ( CREATE TABLE IF NOT EXISTS user_activity (
@@ -942,6 +963,120 @@ def get_request_cache_count() -> int:
return int(row[0] or 0) return int(row[0] or 0)
def upsert_artwork_cache_status(
request_id: int,
tmdb_id: Optional[int],
media_type: Optional[str],
poster_path: Optional[str],
backdrop_path: Optional[str],
has_tmdb: bool,
poster_cached: bool,
backdrop_cached: bool,
) -> None:
updated_at = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
conn.execute(
"""
INSERT INTO artwork_cache_status (
request_id,
tmdb_id,
media_type,
poster_path,
backdrop_path,
has_tmdb,
poster_cached,
backdrop_cached,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(request_id) DO UPDATE SET
tmdb_id = excluded.tmdb_id,
media_type = excluded.media_type,
poster_path = excluded.poster_path,
backdrop_path = excluded.backdrop_path,
has_tmdb = excluded.has_tmdb,
poster_cached = excluded.poster_cached,
backdrop_cached = excluded.backdrop_cached,
updated_at = excluded.updated_at
""",
(
request_id,
tmdb_id,
media_type,
poster_path,
backdrop_path,
1 if has_tmdb else 0,
1 if poster_cached else 0,
1 if backdrop_cached else 0,
updated_at,
),
)
def get_artwork_cache_status_count() -> int:
with _connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM artwork_cache_status").fetchone()
return int(row[0] or 0)
def get_artwork_cache_missing_count() -> int:
with _connect() as conn:
row = conn.execute(
"""
SELECT COUNT(*)
FROM artwork_cache_status
WHERE (
(poster_path IS NULL AND has_tmdb = 1)
OR (poster_path IS NOT NULL AND poster_cached = 0)
OR (backdrop_path IS NULL AND has_tmdb = 1)
OR (backdrop_path IS NOT NULL AND backdrop_cached = 0)
)
"""
).fetchone()
return int(row[0] or 0)
def update_artwork_cache_stats(
cache_bytes: Optional[int] = None,
cache_files: Optional[int] = None,
missing_count: Optional[int] = None,
total_requests: Optional[int] = None,
) -> None:
updated_at = datetime.now(timezone.utc).isoformat()
if cache_bytes is not None:
set_setting("artwork_cache_bytes", str(int(cache_bytes)))
if cache_files is not None:
set_setting("artwork_cache_files", str(int(cache_files)))
if missing_count is not None:
set_setting("artwork_cache_missing", str(int(missing_count)))
if total_requests is not None:
set_setting("artwork_cache_total_requests", str(int(total_requests)))
set_setting("artwork_cache_updated_at", updated_at)
def get_artwork_cache_stats() -> Dict[str, Any]:
def _get_int(key: str) -> int:
value = get_setting(key)
if value is None:
return 0
try:
return int(value)
except (TypeError, ValueError):
return 0
return {
"cache_bytes": _get_int("artwork_cache_bytes"),
"cache_files": _get_int("artwork_cache_files"),
"missing_artwork": _get_int("artwork_cache_missing"),
"total_requests": _get_int("artwork_cache_total_requests"),
"updated_at": get_setting("artwork_cache_updated_at"),
}
def get_request_cache_stats() -> Dict[str, Any]:
return get_artwork_cache_stats()
def update_request_cache_title( def update_request_cache_title(
request_id: int, title: str, year: Optional[int] = None request_id: int, title: str, year: Optional[int] = None
) -> None: ) -> None:
@@ -1030,6 +1165,39 @@ def get_request_cache_payloads(limit: int = 200, offset: int = 0) -> list[Dict[s
return results return results
def get_request_cache_payloads_missing(limit: int = 200, offset: int = 0) -> list[Dict[str, Any]]:
limit = max(1, min(limit, 1000))
offset = max(0, offset)
with _connect() as conn:
rows = conn.execute(
"""
SELECT rc.request_id, rc.payload_json
FROM requests_cache rc
JOIN artwork_cache_status acs
ON rc.request_id = acs.request_id
WHERE (
(acs.poster_path IS NULL AND acs.has_tmdb = 1)
OR (acs.poster_path IS NOT NULL AND acs.poster_cached = 0)
OR (acs.backdrop_path IS NULL AND acs.has_tmdb = 1)
OR (acs.backdrop_path IS NOT NULL AND acs.backdrop_cached = 0)
)
ORDER BY rc.request_id ASC
LIMIT ? OFFSET ?
""",
(limit, offset),
).fetchall()
results: list[Dict[str, Any]] = []
for row in rows:
payload = None
if row[1]:
try:
payload = json.loads(row[1])
except json.JSONDecodeError:
payload = None
results.append({"request_id": row[0], "payload": payload})
return results
def get_cached_requests_since(since_iso: str) -> list[Dict[str, Any]]: 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(

View File

@@ -10,6 +10,7 @@ from ..db import (
get_all_users, get_all_users,
get_request_cache_overview, get_request_cache_overview,
get_request_cache_missing_titles, get_request_cache_missing_titles,
get_request_cache_stats,
get_settings_overrides, get_settings_overrides,
get_user_by_username, get_user_by_username,
set_setting, set_setting,
@@ -277,10 +278,12 @@ async def requests_sync_delta() -> Dict[str, Any]:
@router.post("/requests/artwork/prefetch") @router.post("/requests/artwork/prefetch")
async def requests_artwork_prefetch() -> Dict[str, Any]: async def requests_artwork_prefetch(only_missing: bool = False) -> Dict[str, Any]:
runtime = get_runtime_settings() runtime = get_runtime_settings()
state = await requests_router.start_artwork_prefetch( state = await requests_router.start_artwork_prefetch(
runtime.jellyseerr_base_url, runtime.jellyseerr_api_key runtime.jellyseerr_base_url,
runtime.jellyseerr_api_key,
only_missing=only_missing,
) )
logger.info("Admin triggered artwork prefetch: status=%s", state.get("status")) logger.info("Admin triggered artwork prefetch: status=%s", state.get("status"))
return {"status": "ok", "prefetch": state} return {"status": "ok", "prefetch": state}
@@ -290,6 +293,25 @@ async def requests_artwork_prefetch() -> Dict[str, Any]:
async def requests_artwork_status() -> Dict[str, Any]: async def requests_artwork_status() -> Dict[str, Any]:
return {"status": "ok", "prefetch": requests_router.get_artwork_prefetch_state()} return {"status": "ok", "prefetch": requests_router.get_artwork_prefetch_state()}
@router.get("/requests/artwork/summary")
async def requests_artwork_summary() -> Dict[str, Any]:
runtime = get_runtime_settings()
cache_mode = (runtime.artwork_cache_mode or "remote").lower()
stats = get_request_cache_stats()
if cache_mode != "cache":
stats["cache_bytes"] = 0
stats["cache_files"] = 0
stats["missing_artwork"] = 0
summary = {
"cache_mode": cache_mode,
"cache_bytes": stats.get("cache_bytes", 0),
"cache_files": stats.get("cache_files", 0),
"missing_artwork": stats.get("missing_artwork", 0),
"total_requests": stats.get("total_requests", 0),
"updated_at": stats.get("updated_at"),
}
return {"status": "ok", "summary": summary}
@router.get("/requests/sync/status") @router.get("/requests/sync/status")
async def requests_sync_status() -> Dict[str, Any]: async def requests_sync_status() -> Dict[str, Any]:

View File

@@ -1,6 +1,8 @@
import os import os
import re import re
import mimetypes import mimetypes
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException, Response from fastapi import APIRouter, HTTPException, Response
from fastapi.responses import FileResponse, RedirectResponse from fastapi.responses import FileResponse, RedirectResponse
import httpx import httpx
@@ -11,6 +13,7 @@ router = APIRouter(prefix="/images", tags=["images"])
_TMDB_BASE = "https://image.tmdb.org/t/p" _TMDB_BASE = "https://image.tmdb.org/t/p"
_ALLOWED_SIZES = {"w92", "w154", "w185", "w342", "w500", "w780", "original"} _ALLOWED_SIZES = {"w92", "w154", "w185", "w342", "w500", "w780", "original"}
logger = logging.getLogger(__name__)
def _safe_filename(path: str) -> str: def _safe_filename(path: str) -> str:
@@ -19,13 +22,24 @@ def _safe_filename(path: str) -> str:
safe = re.sub(r"[^A-Za-z0-9_.-]", "_", trimmed) safe = re.sub(r"[^A-Za-z0-9_.-]", "_", trimmed)
return safe or "image" return safe or "image"
def tmdb_cache_path(path: str, size: str) -> Optional[str]:
async def cache_tmdb_image(path: str, size: str = "w342") -> bool:
if not path or "://" in path or ".." in path: if not path or "://" in path or ".." in path:
return False return None
if not path.startswith("/"): if not path.startswith("/"):
path = f"/{path}" path = f"/{path}"
if size not in _ALLOWED_SIZES: if size not in _ALLOWED_SIZES:
return None
cache_dir = os.path.join(os.getcwd(), "data", "artwork", "tmdb", size)
return os.path.join(cache_dir, _safe_filename(path))
def is_tmdb_cached(path: str, size: str) -> bool:
file_path = tmdb_cache_path(path, size)
return bool(file_path and os.path.exists(file_path))
async def cache_tmdb_image(path: str, size: str = "w342") -> bool:
if not path or "://" in path or ".." in path:
return False return False
runtime = get_runtime_settings() runtime = get_runtime_settings()
@@ -33,9 +47,10 @@ async def cache_tmdb_image(path: str, size: str = "w342") -> bool:
if cache_mode != "cache": if cache_mode != "cache":
return False return False
cache_dir = os.path.join(os.getcwd(), "data", "artwork", "tmdb", size) file_path = tmdb_cache_path(path, size)
os.makedirs(cache_dir, exist_ok=True) if not file_path:
file_path = os.path.join(cache_dir, _safe_filename(path)) return False
os.makedirs(os.path.dirname(file_path), exist_ok=True)
if os.path.exists(file_path): if os.path.exists(file_path):
return True return True
@@ -64,9 +79,10 @@ async def tmdb_image(path: str, size: str = "w342"):
if cache_mode != "cache": if cache_mode != "cache":
return RedirectResponse(url=url) return RedirectResponse(url=url)
cache_dir = os.path.join(os.getcwd(), "data", "artwork", "tmdb", size) file_path = tmdb_cache_path(path, size)
os.makedirs(cache_dir, exist_ok=True) if not file_path:
file_path = os.path.join(cache_dir, _safe_filename(path)) raise HTTPException(status_code=400, detail="Invalid image path")
os.makedirs(os.path.dirname(file_path), exist_ok=True)
headers = {"Cache-Control": "public, max-age=86400"} headers = {"Cache-Control": "public, max-age=86400"}
if os.path.exists(file_path): if os.path.exists(file_path):
media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg" media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
@@ -77,6 +93,8 @@ async def tmdb_image(path: str, size: str = "w342"):
if os.path.exists(file_path): if os.path.exists(file_path):
media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg" media_type = mimetypes.guess_type(file_path)[0] or "image/jpeg"
return FileResponse(file_path, media_type=media_type, headers=headers) return FileResponse(file_path, media_type=media_type, headers=headers)
raise HTTPException(status_code=502, detail="Image cache failed") logger.warning("TMDB cache miss after fetch: path=%s size=%s", path, size)
except httpx.HTTPError as exc: except (httpx.HTTPError, OSError) as exc:
raise HTTPException(status_code=502, detail=f"Image fetch failed: {exc}") from exc logger.warning("TMDB cache failed: path=%s size=%s error=%s", path, size, exc)
return RedirectResponse(url=url)

View File

@@ -3,6 +3,7 @@ import asyncio
import httpx import httpx
import json import json
import logging import logging
import os
import time import time
from urllib.parse import quote from urllib.parse import quote
from datetime import datetime, timezone, timedelta from datetime import datetime, timezone, timedelta
@@ -17,7 +18,7 @@ from ..clients.prowlarr import ProwlarrClient
from ..ai.triage import triage_snapshot from ..ai.triage import triage_snapshot
from ..auth import get_current_user from ..auth import get_current_user
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
from .images import cache_tmdb_image from .images import cache_tmdb_image, is_tmdb_cached
from ..db import ( from ..db import (
save_action, save_action,
get_recent_actions, get_recent_actions,
@@ -30,11 +31,16 @@ from ..db import (
get_request_cache_last_updated, get_request_cache_last_updated,
get_request_cache_count, get_request_cache_count,
get_request_cache_payloads, get_request_cache_payloads,
get_request_cache_payloads_missing,
repair_request_cache_titles, repair_request_cache_titles,
prune_duplicate_requests_cache, prune_duplicate_requests_cache,
upsert_request_cache, upsert_request_cache,
upsert_artwork_cache_status,
get_artwork_cache_missing_count,
get_artwork_cache_status_count,
get_setting, get_setting,
set_setting, set_setting,
update_artwork_cache_stats,
cleanup_history, cleanup_history,
) )
from ..models import Snapshot, TriageResult, RequestType from ..models import Snapshot, TriageResult, RequestType
@@ -65,6 +71,7 @@ _artwork_prefetch_state: Dict[str, Any] = {
"processed": 0, "processed": 0,
"total": 0, "total": 0,
"message": "", "message": "",
"only_missing": False,
"started_at": None, "started_at": None,
"finished_at": None, "finished_at": None,
} }
@@ -227,6 +234,108 @@ def _extract_artwork_paths(item: Dict[str, Any]) -> tuple[Optional[str], Optiona
backdrop_path = item.get("backdropPath") or item.get("backdrop_path") backdrop_path = item.get("backdropPath") or item.get("backdrop_path")
return poster_path, backdrop_path return poster_path, backdrop_path
def _extract_tmdb_lookup(payload: Dict[str, Any]) -> tuple[Optional[int], Optional[str]]:
media = payload.get("media") or {}
if not isinstance(media, dict):
media = {}
tmdb_id = media.get("tmdbId") or payload.get("tmdbId")
media_type = (
media.get("mediaType")
or payload.get("mediaType")
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
else:
media_type = None
return tmdb_id, media_type
def _artwork_missing_for_payload(payload: Dict[str, Any]) -> bool:
poster_path, backdrop_path = _extract_artwork_paths(payload)
tmdb_id, media_type = _extract_tmdb_lookup(payload)
can_hydrate = bool(tmdb_id and media_type)
if poster_path:
if not is_tmdb_cached(poster_path, "w185") or not is_tmdb_cached(poster_path, "w342"):
return True
elif can_hydrate:
return True
if backdrop_path:
if not is_tmdb_cached(backdrop_path, "w780"):
return True
elif can_hydrate:
return True
return False
def _compute_cached_flags(
poster_path: Optional[str],
backdrop_path: Optional[str],
cache_mode: str,
poster_cached: Optional[bool] = None,
backdrop_cached: Optional[bool] = None,
) -> tuple[bool, bool]:
if cache_mode != "cache":
return True, True
poster = poster_cached
backdrop = backdrop_cached
if poster is None:
poster = bool(poster_path) and is_tmdb_cached(poster_path, "w185") and is_tmdb_cached(
poster_path, "w342"
)
if backdrop is None:
backdrop = bool(backdrop_path) and is_tmdb_cached(backdrop_path, "w780")
return bool(poster), bool(backdrop)
def _upsert_artwork_status(
payload: Dict[str, Any],
cache_mode: str,
poster_cached: Optional[bool] = None,
backdrop_cached: Optional[bool] = None,
) -> None:
parsed = _parse_request_payload(payload)
request_id = parsed.get("request_id")
if not isinstance(request_id, int):
return
tmdb_id, media_type = _extract_tmdb_lookup(payload)
poster_path, backdrop_path = _extract_artwork_paths(payload)
has_tmdb = bool(tmdb_id and media_type)
poster_cached_flag, backdrop_cached_flag = _compute_cached_flags(
poster_path, backdrop_path, cache_mode, poster_cached, backdrop_cached
)
upsert_artwork_cache_status(
request_id=request_id,
tmdb_id=tmdb_id,
media_type=media_type,
poster_path=poster_path,
backdrop_path=backdrop_path,
has_tmdb=has_tmdb,
poster_cached=poster_cached_flag,
backdrop_cached=backdrop_cached_flag,
)
def _collect_artwork_cache_disk_stats() -> tuple[int, int]:
cache_root = os.path.join(os.getcwd(), "data", "artwork")
total_bytes = 0
total_files = 0
if not os.path.isdir(cache_root):
return 0, 0
for root, _, files in os.walk(cache_root):
for name in files:
path = os.path.join(root, name)
try:
total_bytes += os.path.getsize(path)
total_files += 1
except OSError:
continue
return total_bytes, total_files
async def _get_request_details(client: JellyseerrClient, request_id: int) -> Optional[Dict[str, Any]]: async def _get_request_details(client: JellyseerrClient, request_id: int) -> Optional[Dict[str, Any]]:
cache_key = f"request:{request_id}" cache_key = f"request:{request_id}"
@@ -472,6 +581,8 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
updated_at=payload.get("updated_at"), updated_at=payload.get("updated_at"),
payload_json=payload_json, payload_json=payload_json,
) )
if isinstance(item, dict):
_upsert_artwork_status(item, cache_mode)
stored += 1 stored += 1
_sync_state["stored"] = stored _sync_state["stored"] = stored
if len(items) < take: if len(items) < take:
@@ -491,6 +602,11 @@ async def _sync_all_requests(client: JellyseerrClient) -> int:
) )
set_setting(_sync_last_key, datetime.now(timezone.utc).isoformat()) set_setting(_sync_last_key, datetime.now(timezone.utc).isoformat())
_refresh_recent_cache_from_db() _refresh_recent_cache_from_db()
if cache_mode == "cache":
update_artwork_cache_stats(
missing_count=get_artwork_cache_missing_count(),
total_requests=get_request_cache_count(),
)
return stored return stored
@@ -602,6 +718,8 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
updated_at=payload.get("updated_at"), updated_at=payload.get("updated_at"),
payload_json=payload_json, payload_json=payload_json,
) )
if isinstance(item, dict):
_upsert_artwork_status(item, cache_mode)
stored += 1 stored += 1
page_changed = True page_changed = True
_sync_state["stored"] = stored _sync_state["stored"] = stored
@@ -629,10 +747,20 @@ async def _sync_delta_requests(client: JellyseerrClient) -> int:
) )
set_setting(_sync_last_key, datetime.now(timezone.utc).isoformat()) set_setting(_sync_last_key, datetime.now(timezone.utc).isoformat())
_refresh_recent_cache_from_db() _refresh_recent_cache_from_db()
if cache_mode == "cache":
update_artwork_cache_stats(
missing_count=get_artwork_cache_missing_count(),
total_requests=get_request_cache_count(),
)
return stored return stored
async def _prefetch_artwork_cache(client: JellyseerrClient) -> None: async def _prefetch_artwork_cache(
client: JellyseerrClient,
only_missing: bool = False,
total: Optional[int] = None,
use_missing_query: bool = False,
) -> None:
runtime = get_runtime_settings() runtime = get_runtime_settings()
cache_mode = (runtime.artwork_cache_mode or "remote").lower() cache_mode = (runtime.artwork_cache_mode or "remote").lower()
if cache_mode != "cache": if cache_mode != "cache":
@@ -645,74 +773,100 @@ async def _prefetch_artwork_cache(client: JellyseerrClient) -> None:
) )
return return
total = get_request_cache_count() total = total if total is not None else get_request_cache_count()
_artwork_prefetch_state.update( _artwork_prefetch_state.update(
{ {
"status": "running", "status": "running",
"processed": 0, "processed": 0,
"total": total, "total": total,
"message": "Starting artwork prefetch", "message": "Starting missing artwork prefetch"
if only_missing
else "Starting artwork prefetch",
"only_missing": only_missing,
"started_at": datetime.now(timezone.utc).isoformat(), "started_at": datetime.now(timezone.utc).isoformat(),
"finished_at": None, "finished_at": None,
} }
) )
if only_missing and total == 0:
_artwork_prefetch_state.update(
{
"status": "completed",
"processed": 0,
"message": "No missing artwork to cache.",
"finished_at": datetime.now(timezone.utc).isoformat(),
}
)
return
offset = 0 offset = 0
limit = 200 limit = 200
processed = 0 processed = 0
while True: while True:
batch = get_request_cache_payloads(limit=limit, offset=offset) if use_missing_query:
batch = get_request_cache_payloads_missing(limit=limit, offset=offset)
else:
batch = get_request_cache_payloads(limit=limit, offset=offset)
if not batch: if not batch:
break break
for row in batch: for row in batch:
payload = row.get("payload") payload = row.get("payload")
if not isinstance(payload, dict): if not isinstance(payload, dict):
processed += 1 if not only_missing:
processed += 1
continue
if only_missing and not use_missing_query and not _artwork_missing_for_payload(payload):
continue continue
poster_path, backdrop_path = _extract_artwork_paths(payload) poster_path, backdrop_path = _extract_artwork_paths(payload)
if not (poster_path or backdrop_path) and client.configured(): tmdb_id, media_type = _extract_tmdb_lookup(payload)
if (not poster_path or not backdrop_path) and client.configured() and tmdb_id and media_type:
media = payload.get("media") or {} media = payload.get("media") or {}
tmdb_id = media.get("tmdbId") or payload.get("tmdbId") hydrated_poster, hydrated_backdrop = await _hydrate_artwork_from_tmdb(
media_type = media.get("mediaType") or payload.get("type") client, media_type, tmdb_id
if tmdb_id and media_type: )
hydrated_poster, hydrated_backdrop = await _hydrate_artwork_from_tmdb( poster_path = poster_path or hydrated_poster
client, media_type, tmdb_id backdrop_path = backdrop_path or hydrated_backdrop
) if hydrated_poster or hydrated_backdrop:
poster_path = poster_path or hydrated_poster media = dict(media) if isinstance(media, dict) else {}
backdrop_path = backdrop_path or hydrated_backdrop if hydrated_poster:
if hydrated_poster or hydrated_backdrop: media["posterPath"] = hydrated_poster
media = dict(media) if isinstance(media, dict) else {} if hydrated_backdrop:
if hydrated_poster: media["backdropPath"] = hydrated_backdrop
media["posterPath"] = hydrated_poster payload["media"] = media
if hydrated_backdrop: parsed = _parse_request_payload(payload)
media["backdropPath"] = hydrated_backdrop request_id = parsed.get("request_id")
payload["media"] = media if isinstance(request_id, int):
parsed = _parse_request_payload(payload) upsert_request_cache(
request_id = parsed.get("request_id") request_id=request_id,
if isinstance(request_id, int): media_id=parsed.get("media_id"),
upsert_request_cache( media_type=parsed.get("media_type"),
request_id=request_id, status=parsed.get("status"),
media_id=parsed.get("media_id"), title=parsed.get("title"),
media_type=parsed.get("media_type"), year=parsed.get("year"),
status=parsed.get("status"), requested_by=parsed.get("requested_by"),
title=parsed.get("title"), requested_by_norm=parsed.get("requested_by_norm"),
year=parsed.get("year"), created_at=parsed.get("created_at"),
requested_by=parsed.get("requested_by"), updated_at=parsed.get("updated_at"),
requested_by_norm=parsed.get("requested_by_norm"), payload_json=json.dumps(payload, ensure_ascii=True),
created_at=parsed.get("created_at"), )
updated_at=parsed.get("updated_at"), poster_cached_flag = False
payload_json=json.dumps(payload, ensure_ascii=True), backdrop_cached_flag = False
)
if poster_path: if poster_path:
try: try:
await cache_tmdb_image(poster_path, "w185") poster_cached_flag = bool(
await cache_tmdb_image(poster_path, "w342") await cache_tmdb_image(poster_path, "w185")
) and bool(await cache_tmdb_image(poster_path, "w342"))
except httpx.HTTPError: except httpx.HTTPError:
pass poster_cached_flag = False
if backdrop_path: if backdrop_path:
try: try:
await cache_tmdb_image(backdrop_path, "w780") backdrop_cached_flag = bool(await cache_tmdb_image(backdrop_path, "w780"))
except httpx.HTTPError: except httpx.HTTPError:
pass backdrop_cached_flag = False
_upsert_artwork_status(
payload,
cache_mode,
poster_cached=poster_cached_flag if poster_path else None,
backdrop_cached=backdrop_cached_flag if backdrop_path else None,
)
processed += 1 processed += 1
if processed % 25 == 0: if processed % 25 == 0:
_artwork_prefetch_state.update( _artwork_prefetch_state.update(
@@ -720,6 +874,15 @@ async def _prefetch_artwork_cache(client: JellyseerrClient) -> None:
) )
offset += limit offset += limit
total_requests = get_request_cache_count()
missing_count = get_artwork_cache_missing_count()
cache_bytes, cache_files = _collect_artwork_cache_disk_stats()
update_artwork_cache_stats(
cache_bytes=cache_bytes,
cache_files=cache_files,
missing_count=missing_count,
total_requests=total_requests,
)
_artwork_prefetch_state.update( _artwork_prefetch_state.update(
{ {
"status": "completed", "status": "completed",
@@ -730,25 +893,52 @@ async def _prefetch_artwork_cache(client: JellyseerrClient) -> None:
) )
async def start_artwork_prefetch(base_url: Optional[str], api_key: Optional[str]) -> Dict[str, Any]: async def start_artwork_prefetch(
base_url: Optional[str], api_key: Optional[str], only_missing: bool = False
) -> Dict[str, Any]:
global _artwork_prefetch_task global _artwork_prefetch_task
if _artwork_prefetch_task and not _artwork_prefetch_task.done(): if _artwork_prefetch_task and not _artwork_prefetch_task.done():
return dict(_artwork_prefetch_state) return dict(_artwork_prefetch_state)
client = JellyseerrClient(base_url, api_key) client = JellyseerrClient(base_url, api_key)
status_count = get_artwork_cache_status_count()
total_requests = get_request_cache_count()
use_missing_query = only_missing and status_count >= total_requests and total_requests > 0
if only_missing and use_missing_query:
total = get_artwork_cache_missing_count()
else:
total = total_requests
_artwork_prefetch_state.update( _artwork_prefetch_state.update(
{ {
"status": "running", "status": "running",
"processed": 0, "processed": 0,
"total": get_request_cache_count(), "total": total,
"message": "Starting artwork prefetch", "message": "Seeding artwork cache status"
if only_missing and not use_missing_query
else ("Starting missing artwork prefetch" if only_missing else "Starting artwork prefetch"),
"only_missing": only_missing,
"started_at": datetime.now(timezone.utc).isoformat(), "started_at": datetime.now(timezone.utc).isoformat(),
"finished_at": None, "finished_at": None,
} }
) )
if only_missing and total == 0:
_artwork_prefetch_state.update(
{
"status": "completed",
"processed": 0,
"message": "No missing artwork to cache.",
"finished_at": datetime.now(timezone.utc).isoformat(),
}
)
return dict(_artwork_prefetch_state)
async def _runner() -> None: async def _runner() -> None:
try: try:
await _prefetch_artwork_cache(client) await _prefetch_artwork_cache(
client,
only_missing=only_missing,
total=total,
use_missing_query=use_missing_query,
)
except Exception: except Exception:
logger.exception("Artwork prefetch failed") logger.exception("Artwork prefetch failed")
_artwork_prefetch_state.update( _artwork_prefetch_state.update(
@@ -1274,7 +1464,7 @@ async def recent_requests(
rows = _get_recent_from_cache(requested_by, take, skip, since_iso) rows = _get_recent_from_cache(requested_by, 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 = allow_remote allow_artwork_hydrate = client.configured()
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key) jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
jellyfin_cache: Dict[str, bool] = {} jellyfin_cache: Dict[str, bool] = {}

View File

@@ -1,6 +1,6 @@
from typing import Any, Dict from typing import Any, Dict
import httpx import httpx
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException
from ..auth import get_current_user from ..auth import get_current_user
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
@@ -93,3 +93,42 @@ async def services_status() -> Dict[str, Any]:
overall = "degraded" overall = "degraded"
return {"overall": overall, "services": services} return {"overall": overall, "services": services}
@router.post("/services/{service}/test")
async def test_service(service: str) -> Dict[str, Any]:
runtime = get_runtime_settings()
jellyseerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
prowlarr = ProwlarrClient(runtime.prowlarr_base_url, runtime.prowlarr_api_key)
qbittorrent = QBittorrentClient(
runtime.qbittorrent_base_url, runtime.qbittorrent_username, runtime.qbittorrent_password
)
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
service_key = service.strip().lower()
checks = {
"jellyseerr": (
"Jellyseerr",
jellyseerr.configured(),
lambda: jellyseerr.get_recent_requests(take=1, skip=0),
),
"sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status),
"radarr": ("Radarr", radarr.configured(), radarr.get_system_status),
"prowlarr": ("Prowlarr", prowlarr.configured(), prowlarr.get_health),
"qbittorrent": ("qBittorrent", qbittorrent.configured(), qbittorrent.get_app_version),
"jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info),
}
if service_key not in checks:
raise HTTPException(status_code=404, detail="Unknown service")
name, configured, func = checks[service_key]
result = await _check(name, configured, func)
if name == "Prowlarr" and result.get("status") == "up":
health = result.get("detail")
if isinstance(health, list) and health:
result["status"] = "degraded"
result["message"] = "Health warnings"
return result

View File

@@ -11,7 +11,14 @@ from ..clients.radarr import RadarrClient
from ..clients.prowlarr import ProwlarrClient from ..clients.prowlarr import ProwlarrClient
from ..clients.qbittorrent import QBittorrentClient from ..clients.qbittorrent import QBittorrentClient
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
from ..db import save_snapshot, get_request_cache_payload, get_recent_snapshots, get_setting, set_setting from ..db import (
save_snapshot,
get_request_cache_payload,
get_request_cache_by_id,
get_recent_snapshots,
get_setting,
set_setting,
)
from ..models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop from ..models import ActionOption, NormalizedState, RequestType, Snapshot, TimelineHop
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -219,6 +226,19 @@ async def build_snapshot(request_id: str) -> Snapshot:
logging.getLogger(__name__).debug( logging.getLogger(__name__).debug(
"snapshot cache miss: request_id=%s mode=%s", request_id, mode "snapshot cache miss: request_id=%s mode=%s", request_id, mode
) )
if cached_request is not None:
cache_meta = get_request_cache_by_id(int(request_id))
cached_title = cache_meta.get("title") if cache_meta else None
if cached_title and isinstance(cached_request, dict):
media = cached_request.get("media")
if not isinstance(media, dict):
media = {}
cached_request["media"] = media
if not media.get("title") and not media.get("name"):
media["title"] = cached_title
media["name"] = cached_title
if not cached_request.get("title") and not cached_request.get("name"):
cached_request["title"] = cached_title
allow_remote = mode == "always_js" and jellyseerr.configured() allow_remote = mode == "always_js" and jellyseerr.configured()
if not jellyseerr.configured() and not cached_request: if not jellyseerr.configured() and not cached_request:
@@ -259,10 +279,18 @@ async def build_snapshot(request_id: str) -> Snapshot:
jelly_status = jelly_request.get("status", "unknown") jelly_status = jelly_request.get("status", "unknown")
jelly_status_label = _status_label(jelly_status) jelly_status_label = _status_label(jelly_status)
jelly_type = jelly_request.get("type") or "unknown" jelly_type = jelly_request.get("type") or "unknown"
snapshot.title = jelly_request.get("media", {}).get("title", "Unknown")
snapshot.year = jelly_request.get("media", {}).get("year")
snapshot.request_type = RequestType(jelly_type) if jelly_type in {"movie", "tv"} else RequestType.unknown
media = jelly_request.get("media", {}) if isinstance(jelly_request, dict) else {} media = jelly_request.get("media", {}) if isinstance(jelly_request, dict) else {}
if not isinstance(media, dict):
media = {}
snapshot.title = (
media.get("title")
or media.get("name")
or jelly_request.get("title")
or jelly_request.get("name")
or "Unknown"
)
snapshot.year = media.get("year") or jelly_request.get("year")
snapshot.request_type = RequestType(jelly_type) if jelly_type in {"movie", "tv"} else RequestType.unknown
poster_path = None poster_path = None
backdrop_path = None backdrop_path = None
if isinstance(media, dict): if isinstance(media, dict):

View File

@@ -21,14 +21,14 @@ type ServiceOptions = {
const SECTION_LABELS: Record<string, string> = { const SECTION_LABELS: Record<string, string> = {
jellyseerr: 'Jellyseerr', jellyseerr: 'Jellyseerr',
jellyfin: 'Jellyfin', jellyfin: 'Jellyfin',
artwork: 'Artwork', artwork: 'Artwork cache',
cache: 'Cache', cache: 'Cache Control',
sonarr: 'Sonarr', sonarr: 'Sonarr',
radarr: 'Radarr', radarr: 'Radarr',
prowlarr: 'Prowlarr', prowlarr: 'Prowlarr',
qbittorrent: 'qBittorrent', qbittorrent: 'qBittorrent',
log: 'Activity log', log: 'Activity log',
requests: 'Request syncing', requests: 'Request sync',
site: 'Site', site: 'Site',
} }
@@ -39,13 +39,13 @@ const BANNER_TONES = ['info', 'warning', 'error', 'maintenance']
const SECTION_DESCRIPTIONS: Record<string, string> = { const SECTION_DESCRIPTIONS: Record<string, string> = {
jellyseerr: 'Connect the request system where users submit content.', jellyseerr: 'Connect the request system where users submit content.',
jellyfin: 'Control Jellyfin login and availability checks.', jellyfin: 'Control Jellyfin login and availability checks.',
artwork: 'Configure how posters and artwork are loaded.', artwork: 'Cache posters/backdrops and review artwork coverage.',
cache: 'Manage saved request data and offline artwork.', cache: 'Manage saved requests cache and refresh behavior.',
sonarr: 'TV automation settings.', sonarr: 'TV automation settings.',
radarr: 'Movie automation settings.', radarr: 'Movie automation settings.',
prowlarr: 'Indexer search settings.', prowlarr: 'Indexer search settings.',
qbittorrent: 'Downloader connection settings.', qbittorrent: 'Downloader connection settings.',
requests: 'Sync and refresh cadence for requests.', requests: 'Control how often requests are refreshed and cleaned up.',
log: 'Activity log for troubleshooting.', log: 'Activity log for troubleshooting.',
site: 'Sitewide banner, version, and changelog details.', site: 'Sitewide banner, version, and changelog details.',
} }
@@ -53,7 +53,7 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
const SETTINGS_SECTION_MAP: Record<string, string | null> = { const SETTINGS_SECTION_MAP: Record<string, string | null> = {
jellyseerr: 'jellyseerr', jellyseerr: 'jellyseerr',
jellyfin: 'jellyfin', jellyfin: 'jellyfin',
artwork: 'artwork', artwork: null,
sonarr: 'sonarr', sonarr: 'sonarr',
radarr: 'radarr', radarr: 'radarr',
prowlarr: 'prowlarr', prowlarr: 'prowlarr',
@@ -73,13 +73,13 @@ const labelFromKey = (key: string) =>
.replace('quality profile id', 'Quality profile ID') .replace('quality profile id', 'Quality profile ID')
.replace('root folder', 'Root folder') .replace('root folder', 'Root folder')
.replace('qbittorrent', 'qBittorrent') .replace('qbittorrent', 'qBittorrent')
.replace('requests sync ttl minutes', 'Refresh saved requests if older than (minutes)') .replace('requests sync ttl minutes', 'Saved request refresh TTL (minutes)')
.replace('requests poll interval seconds', 'Background refresh check (seconds)') .replace('requests poll interval seconds', 'Full refresh check interval (seconds)')
.replace('requests delta sync interval minutes', 'Check for new or updated requests every (minutes)') .replace('requests delta sync interval minutes', 'Delta sync interval (minutes)')
.replace('requests full sync time', 'Full refresh time (24h)') .replace('requests full sync time', 'Daily full refresh time (24h)')
.replace('requests cleanup time', 'Clean up old history time (24h)') .replace('requests cleanup time', 'Daily history cleanup time (24h)')
.replace('requests cleanup days', 'Remove history older than (days)') .replace('requests cleanup days', 'History retention window (days)')
.replace('requests data source', 'Where requests are loaded from') .replace('requests data source', 'Request source (cache vs Jellyseerr)')
.replace('jellyfin public url', 'Jellyfin public URL') .replace('jellyfin public url', 'Jellyfin public URL')
.replace('jellyfin sync to arr', 'Sync Jellyfin to Sonarr/Radarr') .replace('jellyfin sync to arr', 'Sync Jellyfin to Sonarr/Radarr')
.replace('artwork cache mode', 'Artwork cache mode') .replace('artwork cache mode', 'Artwork cache mode')
@@ -89,6 +89,19 @@ const labelFromKey = (key: string) =>
.replace('site banner tone', 'Sitewide banner tone') .replace('site banner tone', 'Sitewide banner tone')
.replace('site changelog', 'Changelog text') .replace('site changelog', 'Changelog text')
const formatBytes = (value?: number | null) => {
if (!value || value <= 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
let size = value
let unitIndex = 0
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024
unitIndex += 1
}
const decimals = unitIndex === 0 || size >= 10 ? 0 : 1
return `${size.toFixed(decimals)} ${units[unitIndex]}`
}
type SettingsPageProps = { type SettingsPageProps = {
section: string section: string
} }
@@ -112,8 +125,11 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const [cacheRows, setCacheRows] = useState<any[]>([]) const [cacheRows, setCacheRows] = useState<any[]>([])
const [cacheCount, setCacheCount] = useState(50) const [cacheCount, setCacheCount] = useState(50)
const [cacheStatus, setCacheStatus] = useState<string | null>(null) const [cacheStatus, setCacheStatus] = useState<string | null>(null)
const [cacheLoading, setCacheLoading] = useState(false)
const [requestsSync, setRequestsSync] = useState<any | null>(null) const [requestsSync, setRequestsSync] = useState<any | null>(null)
const [artworkPrefetch, setArtworkPrefetch] = useState<any | null>(null) const [artworkPrefetch, setArtworkPrefetch] = useState<any | null>(null)
const [artworkSummary, setArtworkSummary] = useState<any | null>(null)
const [artworkSummaryStatus, setArtworkSummaryStatus] = useState<string | null>(null)
const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null) const [maintenanceStatus, setMaintenanceStatus] = useState<string | null>(null)
const [maintenanceBusy, setMaintenanceBusy] = useState(false) const [maintenanceBusy, setMaintenanceBusy] = useState(false)
@@ -165,6 +181,27 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} }
}, []) }, [])
const loadArtworkSummary = useCallback(async () => {
setArtworkSummaryStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/admin/requests/artwork/summary`)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Artwork summary fetch failed')
}
const data = await response.json()
setArtworkSummary(data?.summary ?? null)
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Could not load artwork stats.'
setArtworkSummaryStatus(message)
}
}, [])
const loadOptions = useCallback(async (service: 'sonarr' | 'radarr') => { const loadOptions = useCallback(async (service: 'sonarr' | 'radarr') => {
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
@@ -204,8 +241,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} }
try { try {
await loadSettings() await loadSettings()
if (section === 'artwork') { if (section === 'cache' || section === 'artwork') {
await loadArtworkPrefetchStatus() await loadArtworkPrefetchStatus()
await loadArtworkSummary()
} }
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@@ -222,7 +260,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
if (section === 'radarr') { if (section === 'radarr') {
void loadOptions('radarr') void loadOptions('radarr')
} }
}, [loadArtworkPrefetchStatus, loadOptions, loadSettings, router, section]) }, [loadArtworkPrefetchStatus, loadArtworkSummary, loadOptions, loadSettings, router, section])
const groupedSettings = useMemo(() => { const groupedSettings = useMemo(() => {
const groups: Record<string, AdminSetting[]> = {} const groups: Record<string, AdminSetting[]> = {}
@@ -237,28 +275,51 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const settingsSection = SETTINGS_SECTION_MAP[section] ?? null const settingsSection = SETTINGS_SECTION_MAP[section] ?? null
const visibleSections = settingsSection ? [settingsSection] : [] const visibleSections = settingsSection ? [settingsSection] : []
const isCacheSection = section === 'cache' const isCacheSection = section === 'cache'
const cacheSettingKeys = new Set([ const cacheSettingKeys = new Set(['requests_sync_ttl_minutes', 'requests_data_source'])
'requests_sync_ttl_minutes', const artworkSettingKeys = new Set(['artwork_cache_mode'])
'requests_data_source', const hiddenSettingKeys = new Set([...cacheSettingKeys, ...artworkSettingKeys])
'artwork_cache_mode', const requestSettingOrder = [
]) 'requests_poll_interval_seconds',
'requests_delta_sync_interval_minutes',
'requests_full_sync_time',
'requests_cleanup_time',
'requests_cleanup_days',
]
const sortByOrder = (items: AdminSetting[], order: string[]) => {
const position = new Map(order.map((key, index) => [key, index]))
return [...items].sort((a, b) => {
const aIndex = position.get(a.key) ?? Number.POSITIVE_INFINITY
const bIndex = position.get(b.key) ?? Number.POSITIVE_INFINITY
if (aIndex !== bIndex) return aIndex - bIndex
return a.key.localeCompare(b.key)
})
}
const cacheSettings = settings.filter((setting) => cacheSettingKeys.has(setting.key)) const cacheSettings = settings.filter((setting) => cacheSettingKeys.has(setting.key))
const artworkSettings = settings.filter((setting) => artworkSettingKeys.has(setting.key))
const settingsSections = isCacheSection const settingsSections = isCacheSection
? [{ key: 'cache', title: 'Cache settings', items: cacheSettings }] ? [
{ key: 'cache', title: 'Cache control', items: cacheSettings },
{ key: 'artwork', title: 'Artwork cache', items: artworkSettings },
]
: visibleSections.map((sectionKey) => ({ : visibleSections.map((sectionKey) => ({
key: sectionKey, key: sectionKey,
title: SECTION_LABELS[sectionKey] ?? sectionKey, title: SECTION_LABELS[sectionKey] ?? sectionKey,
items: items: (() => {
sectionKey === 'requests' || sectionKey === 'artwork' const sectionItems = groupedSettings[sectionKey] ?? []
? (groupedSettings[sectionKey] ?? []).filter( const filtered =
(setting) => !cacheSettingKeys.has(setting.key) sectionKey === 'requests' || sectionKey === 'artwork'
) ? sectionItems.filter((setting) => !hiddenSettingKeys.has(setting.key))
: groupedSettings[sectionKey] ?? [], : sectionItems
if (sectionKey === 'requests') {
return sortByOrder(filtered, requestSettingOrder)
}
return filtered
})(),
})) }))
const showLogs = section === 'logs' const showLogs = section === 'logs'
const showMaintenance = section === 'maintenance' const showMaintenance = section === 'maintenance'
const showRequestsExtras = section === 'requests' const showRequestsExtras = section === 'requests'
const showArtworkExtras = section === 'artwork' const showArtworkExtras = section === 'cache'
const showCacheExtras = section === 'cache' const showCacheExtras = section === 'cache'
const shouldRenderSection = (sectionGroup: { key: string; items?: AdminSetting[] }) => { const shouldRenderSection = (sectionGroup: { key: string; items?: AdminSetting[] }) => {
if (sectionGroup.items && sectionGroup.items.length > 0) return true if (sectionGroup.items && sectionGroup.items.length > 0) return true
@@ -292,10 +353,12 @@ export default function SettingsPage({ section }: SettingsPageProps) {
qbittorrent_username: 'qBittorrent login username.', qbittorrent_username: 'qBittorrent login username.',
qbittorrent_password: 'qBittorrent login password.', qbittorrent_password: 'qBittorrent login password.',
requests_sync_ttl_minutes: 'How long saved requests stay fresh before a refresh is needed.', requests_sync_ttl_minutes: 'How long saved requests stay fresh before a refresh is needed.',
requests_poll_interval_seconds: 'How often the background checker runs.', requests_poll_interval_seconds:
requests_delta_sync_interval_minutes: 'How often we check for new or updated requests.', 'How often Magent checks if a full refresh should run.',
requests_full_sync_time: 'Daily time to refresh the full request list.', requests_delta_sync_interval_minutes:
requests_cleanup_time: 'Daily time to trim old history.', 'How often we poll for new or updated requests.',
requests_full_sync_time: 'Daily time to rebuild the full request cache.',
requests_cleanup_time: 'Daily time to trim old request history.',
requests_cleanup_days: 'History older than this is removed during cleanup.', requests_cleanup_days: 'History older than this is removed during cleanup.',
requests_data_source: requests_data_source:
'Pick where Magent should read requests from. Cache-only avoids Jellyseerr lookups on reads.', 'Pick where Magent should read requests from. Cache-only avoids Jellyseerr lookups on reads.',
@@ -463,6 +526,31 @@ export default function SettingsPage({ section }: SettingsPageProps) {
} }
} }
const prefetchArtworkMissing = async () => {
setArtworkPrefetchStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(
`${baseUrl}/admin/requests/artwork/prefetch?only_missing=1`,
{ method: 'POST' }
)
if (!response.ok) {
const text = await response.text()
throw new Error(text || 'Missing artwork prefetch failed')
}
const data = await response.json()
setArtworkPrefetch(data?.prefetch ?? null)
setArtworkPrefetchStatus('Missing artwork caching started.')
} catch (err) {
console.error(err)
const message =
err instanceof Error && err.message
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Could not cache missing artwork.'
setArtworkPrefetchStatus(message)
}
}
useEffect(() => { useEffect(() => {
if (!artworkPrefetch || artworkPrefetch.status !== 'running') { if (!artworkPrefetch || artworkPrefetch.status !== 'running') {
return return
@@ -480,6 +568,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
setArtworkPrefetch(data?.prefetch ?? null) setArtworkPrefetch(data?.prefetch ?? null)
if (data?.prefetch?.status && data.prefetch.status !== 'running') { if (data?.prefetch?.status && data.prefetch.status !== 'running') {
setArtworkPrefetchStatus(data.prefetch.message || 'Artwork caching complete.') setArtworkPrefetchStatus(data.prefetch.message || 'Artwork caching complete.')
void loadArtworkSummary()
} }
} catch (err) { } catch (err) {
console.error(err) console.error(err)
@@ -489,7 +578,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
active = false active = false
clearInterval(timer) clearInterval(timer)
} }
}, [artworkPrefetch]) }, [artworkPrefetch, loadArtworkSummary])
useEffect(() => { useEffect(() => {
if (!artworkPrefetch || artworkPrefetch.status === 'running') { if (!artworkPrefetch || artworkPrefetch.status === 'running') {
@@ -579,6 +668,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
const loadCache = async () => { const loadCache = async () => {
setCacheStatus(null) setCacheStatus(null)
setCacheLoading(true)
try { try {
const baseUrl = getApiBase() const baseUrl = getApiBase()
const response = await authFetch( const response = await authFetch(
@@ -601,6 +691,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '') ? err.message.replace(/^\\{\"detail\":\"|\"\\}$/g, '')
: 'Could not load cache.' : 'Could not load cache.'
setCacheStatus(message) setCacheStatus(message)
} finally {
setCacheLoading(false)
} }
} }
@@ -715,7 +807,9 @@ export default function SettingsPage({ section }: SettingsPageProps) {
.map((sectionGroup) => ( .map((sectionGroup) => (
<section key={sectionGroup.key} className="admin-section"> <section key={sectionGroup.key} className="admin-section">
<div className="section-header"> <div className="section-header">
<h2>{sectionGroup.key === 'requests' ? 'Sync controls' : sectionGroup.title}</h2> <h2>
{sectionGroup.key === 'requests' ? 'Request sync controls' : sectionGroup.title}
</h2>
{sectionGroup.key === 'sonarr' && ( {sectionGroup.key === 'sonarr' && (
<button type="button" onClick={() => loadOptions('sonarr')}> <button type="button" onClick={() => loadOptions('sonarr')}>
Refresh Sonarr options Refresh Sonarr options
@@ -731,24 +825,33 @@ export default function SettingsPage({ section }: SettingsPageProps) {
Import Jellyfin users Import Jellyfin users
</button> </button>
)} )}
{(showArtworkExtras && sectionGroup.key === 'artwork') || {showArtworkExtras && sectionGroup.key === 'artwork' ? (
(showCacheExtras && sectionGroup.key === 'cache') ? ( <div className="sync-actions">
<button type="button" onClick={prefetchArtwork}> <button type="button" onClick={prefetchArtwork}>
Cache all artwork now Cache all artwork now
</button> </button>
<button
type="button"
className="ghost-button"
onClick={prefetchArtworkMissing}
>
Sync only missing artwork
</button>
</div>
) : null} ) : null}
{showRequestsExtras && sectionGroup.key === 'requests' && ( {showRequestsExtras && sectionGroup.key === 'requests' && (
<div className="sync-actions-block"> <div className="sync-actions-block">
<div className="sync-actions"> <div className="sync-actions">
<button type="button" onClick={syncRequests}> <button type="button" onClick={syncRequests}>
Full refresh (all requests) Run full refresh (rebuild cache)
</button> </button>
<button type="button" className="ghost-button" onClick={syncRequestsDelta}> <button type="button" className="ghost-button" onClick={syncRequestsDelta}>
Quick refresh (delta changes) Run delta sync (recent changes)
</button> </button>
</div> </div>
<div className="meta sync-note"> <div className="meta sync-note">
Full refresh reloads the entire list. Quick refresh only checks recent changes. Full refresh rebuilds the entire cache. Delta sync only checks new or updated
requests.
</div> </div>
</div> </div>
)} )}
@@ -765,17 +868,48 @@ export default function SettingsPage({ section }: SettingsPageProps) {
{sectionGroup.key === 'jellyfin' && jellyfinSyncStatus && ( {sectionGroup.key === 'jellyfin' && jellyfinSyncStatus && (
<div className="status-banner">{jellyfinSyncStatus}</div> <div className="status-banner">{jellyfinSyncStatus}</div>
)} )}
{((showArtworkExtras && sectionGroup.key === 'artwork') || {showArtworkExtras && sectionGroup.key === 'artwork' && artworkPrefetchStatus && (
(showCacheExtras && sectionGroup.key === 'cache')) &&
artworkPrefetchStatus && (
<div className="status-banner">{artworkPrefetchStatus}</div> <div className="status-banner">{artworkPrefetchStatus}</div>
)} )}
{showArtworkExtras && sectionGroup.key === 'artwork' && artworkSummaryStatus && (
<div className="status-banner">{artworkSummaryStatus}</div>
)}
{showArtworkExtras && sectionGroup.key === 'artwork' && (
<div className="summary">
<div className="summary-card">
<strong>Missing artwork</strong>
<p>{artworkSummary?.missing_artwork ?? '--'}</p>
<div className="meta">Requests missing poster/backdrop or cache files.</div>
</div>
<div className="summary-card">
<strong>Artwork cache size</strong>
<p>{formatBytes(artworkSummary?.cache_bytes)}</p>
<div className="meta">
{artworkSummary?.cache_files ?? '--'} cached files
</div>
</div>
<div className="summary-card">
<strong>Total requests</strong>
<p>{artworkSummary?.total_requests ?? '--'}</p>
<div className="meta">Requests currently tracked in cache.</div>
</div>
<div className="summary-card">
<strong>Cache mode</strong>
<p>{artworkSummary?.cache_mode ?? '--'}</p>
<div className="meta">Artwork setting applied to posters/backdrops.</div>
</div>
</div>
)}
{showRequestsExtras && sectionGroup.key === 'requests' && requestsSyncStatus && ( {showRequestsExtras && sectionGroup.key === 'requests' && requestsSyncStatus && (
<div className="status-banner">{requestsSyncStatus}</div> <div className="status-banner">{requestsSyncStatus}</div>
)} )}
{((showArtworkExtras && sectionGroup.key === 'artwork') || {showRequestsExtras && sectionGroup.key === 'requests' && (
(showCacheExtras && sectionGroup.key === 'cache')) && <div className="status-banner">
artworkPrefetch && ( Full refresh checks only decide when to run a full refresh. The delta sync interval
polls for new or updated requests.
</div>
)}
{showArtworkExtras && sectionGroup.key === 'artwork' && artworkPrefetch && (
<div className="sync-progress"> <div className="sync-progress">
<div className="sync-meta"> <div className="sync-meta">
<span>Status: {artworkPrefetch.status}</span> <span>Status: {artworkPrefetch.status}</span>
@@ -1202,7 +1336,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</form> </form>
) : ( ) : (
<div className="status-banner"> <div className="status-banner">
No settings to show here yet. Try the Cache page for artwork and saved-request controls. No settings to show here yet. Try the Cache Control page for artwork and saved-request controls.
</div> </div>
)} )}
{showLogs && ( {showLogs && (
@@ -1248,8 +1382,15 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<option value={200}>200</option> <option value={200}>200</option>
</select> </select>
</label> </label>
<button type="button" onClick={loadCache}> <button type="button" onClick={loadCache} disabled={cacheLoading}>
Load saved requests {cacheLoading ? (
<>
<span className="spinner button-spinner" aria-hidden="true" />
Loading saved requests
</>
) : (
'Load saved requests'
)}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1326,6 +1326,17 @@ button span {
.progress-indeterminate .progress-fill { .progress-indeterminate .progress-fill {
position: absolute; 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; animation: progress-indeterminate 1.6s ease-in-out infinite;
} }
@@ -1404,6 +1415,24 @@ button span {
font-size: 13px; font-size: 13px;
} }
.system-meta {
display: flex;
flex-direction: column;
gap: 2px;
}
.system-test-message {
font-size: 11px;
color: var(--ink-muted);
}
.system-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 8px;
}
.system-dot { .system-dot {
width: 10px; width: 10px;
height: 10px; height: 10px;
@@ -1433,10 +1462,28 @@ button span {
} }
.system-state { .system-state {
margin-left: auto;
color: var(--ink-muted); color: var(--ink-muted);
} }
.system-test {
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.08);
color: var(--ink-muted);
font-size: 11px;
letter-spacing: 0.02em;
}
.system-test:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.16);
}
.system-test:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.pipeline-map { .pipeline-map {
border-radius: 16px; border-radius: 16px;
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -1509,13 +1556,10 @@ button span {
@keyframes progress-indeterminate { @keyframes progress-indeterminate {
0% { 0% {
transform: translateX(-50%); background-position: 200% 0;
}
50% {
transform: translateX(120%);
} }
100% { 100% {
transform: translateX(-50%); background-position: -200% 0;
} }
} }
@@ -1681,6 +1725,16 @@ button span {
animation: spin 0.9s linear infinite; animation: spin 0.9s linear infinite;
} }
.button-spinner {
width: 16px;
height: 16px;
border-width: 2px;
box-shadow: none;
margin-right: 8px;
vertical-align: middle;
display: inline-block;
}
.loading-text { .loading-text {
font-size: 16px; font-size: 16px;
color: var(--ink-muted); color: var(--ink-muted);

View File

@@ -30,6 +30,8 @@ export default function HomePage() {
>(null) >(null)
const [servicesLoading, setServicesLoading] = useState(false) const [servicesLoading, setServicesLoading] = useState(false)
const [servicesError, setServicesError] = useState<string | null>(null) const [servicesError, setServicesError] = useState<string | null>(null)
const [serviceTesting, setServiceTesting] = useState<Record<string, boolean>>({})
const [serviceTestResults, setServiceTestResults] = useState<Record<string, string | null>>({})
const submit = (event: React.FormEvent) => { const submit = (event: React.FormEvent) => {
event.preventDefault() event.preventDefault()
@@ -42,6 +44,61 @@ export default function HomePage() {
void runSearch(trimmed) void runSearch(trimmed)
} }
const toServiceSlug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]/g, '')
const updateServiceStatus = (name: string, status: string, message?: string) => {
setServicesStatus((prev) => {
if (!prev) return prev
return {
...prev,
services: prev.services.map((service) =>
service.name === name ? { ...service, status, message } : service
),
}
})
}
const testService = async (name: string) => {
const slug = toServiceSlug(name)
setServiceTesting((prev) => ({ ...prev, [name]: true }))
setServiceTestResults((prev) => ({ ...prev, [name]: null }))
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/status/services/${slug}/test`, {
method: 'POST',
})
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || `Service test failed: ${response.status}`)
}
const data = await response.json()
const status = data?.status ?? 'unknown'
const message =
data?.message ||
(status === 'up'
? 'API OK'
: status === 'down'
? 'API unreachable'
: status === 'degraded'
? 'Health warnings'
: status === 'not_configured'
? 'Not configured'
: 'Unknown')
setServiceTestResults((prev) => ({ ...prev, [name]: message }))
updateServiceStatus(name, status, data?.message)
} catch (error) {
console.error(error)
setServiceTestResults((prev) => ({ ...prev, [name]: 'Test failed' }))
} finally {
setServiceTesting((prev) => ({ ...prev, [name]: false }))
}
}
useEffect(() => { useEffect(() => {
if (!getToken()) { if (!getToken()) {
router.push('/login') router.push('/login')
@@ -214,21 +271,37 @@ export default function HomePage() {
return order.map((name) => { return order.map((name) => {
const item = items.find((entry) => entry.name === name) const item = items.find((entry) => entry.name === name)
const status = item?.status ?? 'unknown' const status = item?.status ?? 'unknown'
const testing = serviceTesting[name] ?? false
return ( return (
<div key={name} className={`system-item system-${status}`}> <div key={name} className={`system-item system-${status}`}>
<span className="system-dot" /> <span className="system-dot" />
<span className="system-name">{name}</span> <div className="system-meta">
<span className="system-state"> <span className="system-name">{name}</span>
{status === 'up' {serviceTestResults[name] && (
? 'Up' <span className="system-test-message">{serviceTestResults[name]}</span>
: status === 'down' )}
? 'Down' </div>
: status === 'degraded' <div className="system-actions">
? 'Needs attention' <span className="system-state">
: status === 'not_configured' {status === 'up'
? 'Not configured' ? 'Up'
: 'Unknown'} : status === 'down'
</span> ? 'Down'
: status === 'degraded'
? 'Needs attention'
: status === 'not_configured'
? 'Not configured'
: 'Unknown'}
</span>
<button
type="button"
className="system-test"
onClick={() => void testService(name)}
disabled={testing}
>
{testing ? 'Testing...' : 'Test'}
</button>
</div>
</div> </div>
) )
}) })

View File

@@ -17,9 +17,8 @@ const NAV_GROUPS = [
{ {
title: 'Requests', title: 'Requests',
items: [ items: [
{ href: '/admin/requests', label: 'Request syncing' }, { href: '/admin/requests', label: 'Request sync' },
{ href: '/admin/artwork', label: 'Artwork' }, { href: '/admin/cache', label: 'Cache Control' },
{ href: '/admin/cache', label: 'Cache' },
], ],
}, },
{ {