Serve recent stages from SQLite with configurable background refresh
Magent CI/CD / verify (push) Successful in 1m51s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped

This commit is contained in:
2026-09-13 16:18:30 +12:00
parent dcd8082c8d
commit d7e5c75cb1
8 changed files with 115 additions and 24 deletions
+1
View File
@@ -67,6 +67,7 @@ class Settings(BaseSettings):
requests_sync_ttl_minutes: int = Field( requests_sync_ttl_minutes: int = Field(
default=1440, validation_alias=AliasChoices("REQUESTS_SYNC_TTL_MINUTES") default=1440, validation_alias=AliasChoices("REQUESTS_SYNC_TTL_MINUTES")
) )
requests_stage_refresh_minutes: int = Field(default=15, ge=1, le=1440, validation_alias=AliasChoices("REQUESTS_STAGE_REFRESH_MINUTES"))
requests_poll_interval_seconds: int = Field( requests_poll_interval_seconds: int = Field(
default=300, validation_alias=AliasChoices("REQUESTS_POLL_INTERVAL_SECONDS") default=300, validation_alias=AliasChoices("REQUESTS_POLL_INTERVAL_SECONDS")
) )
+16 -1
View File
@@ -187,6 +187,7 @@ def _has_secure_bootstrap_admin_credentials() -> bool:
def init_db() -> None: def init_db() -> None:
with _connect() as conn: with _connect() as conn:
conn.execute("CREATE TABLE IF NOT EXISTS request_stage_cache (request_id INTEGER PRIMARY KEY, source_updated TEXT, ready INTEGER NOT NULL, checked_at REAL NOT NULL)")
conn.execute("""CREATE TABLE IF NOT EXISTS user_duplicate_repairs ( conn.execute("""CREATE TABLE IF NOT EXISTS user_duplicate_repairs (
id INTEGER PRIMARY KEY AUTOINCREMENT, kept_user_id INTEGER NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT, kept_user_id INTEGER NOT NULL,
archive_json TEXT NOT NULL, repaired_by TEXT NOT NULL, repaired_at TEXT NOT NULL)""") archive_json TEXT NOT NULL, repaired_by TEXT NOT NULL, repaired_at TEXT NOT NULL)""")
@@ -855,6 +856,7 @@ def save_action(
) -> None: ) -> None:
created_at = datetime.now(timezone.utc).isoformat() created_at = datetime.now(timezone.utc).isoformat()
with _connect() as conn: with _connect() as conn:
conn.execute('UPDATE request_stage_cache SET checked_at = 0 WHERE request_id = ?', (request_id,))
conn.execute( conn.execute(
""" """
INSERT INTO actions (request_id, action_id, label, status, message, created_at) INSERT INTO actions (request_id, action_id, label, status, message, created_at)
@@ -2879,7 +2881,7 @@ def get_cached_requests_since(since_iso: str) -> list[Dict[str, Any]]:
rows = conn.execute( rows = conn.execute(
""" """
SELECT request_id, media_id, media_type, status, title, year, requested_by, SELECT request_id, media_id, media_type, status, title, year, requested_by,
requested_by_norm, requested_by_id, created_at requested_by_norm, requested_by_id, created_at, updated_at
FROM requests_cache FROM requests_cache
WHERE created_at >= ? WHERE created_at >= ?
ORDER BY created_at DESC, request_id DESC ORDER BY created_at DESC, request_id DESC
@@ -2900,6 +2902,7 @@ def get_cached_requests_since(since_iso: str) -> list[Dict[str, Any]]:
"requested_by_norm": row[7], "requested_by_norm": row[7],
"requested_by_id": row[8], "requested_by_id": row[8],
"created_at": row[9], "created_at": row[9],
"updated_at": row[10],
} }
) )
return results return results
@@ -4027,3 +4030,15 @@ def cleanup_history(days: int) -> Dict[str, int]:
(cutoff,), (cutoff,),
).rowcount ).rowcount
return {"actions": actions, "snapshots": snapshots} return {"actions": actions, "snapshots": snapshots}
def get_request_stage_cache():
with _connect() as conn:
return {row[0]: {'source_updated': row[1], 'ready': bool(row[2]), 'checked_at': row[3]}
for row in conn.execute('SELECT request_id, source_updated, ready, checked_at FROM request_stage_cache')}
def save_request_stage_cache(rows):
with _connect() as conn:
conn.executemany('INSERT OR REPLACE INTO request_stage_cache (request_id, source_updated, ready, checked_at) VALUES (?, ?, ?, ?)', rows)
conn.execute('DELETE FROM request_stage_cache WHERE request_id NOT IN (SELECT request_id FROM requests_cache)')
+2
View File
@@ -13,6 +13,7 @@ from .db import has_admin_user, init_db
from .routers.requests import ( from .routers.requests import (
router as requests_router, router as requests_router,
startup_warmup_requests_cache, startup_warmup_requests_cache,
run_local_request_stage_loop,
run_requests_delta_loop, run_requests_delta_loop,
run_daily_requests_full_sync, run_daily_requests_full_sync,
run_daily_db_cleanup, run_daily_db_cleanup,
@@ -267,6 +268,7 @@ async def startup() -> None:
return return
_launch_background_task("jellyfin-sync", run_daily_jellyfin_sync) _launch_background_task("jellyfin-sync", run_daily_jellyfin_sync)
_launch_background_task("requests-warmup", startup_warmup_requests_cache) _launch_background_task("requests-warmup", startup_warmup_requests_cache)
_launch_background_task("request-local-stages", run_local_request_stage_loop)
_launch_background_task("requests-delta-loop", run_requests_delta_loop) _launch_background_task("requests-delta-loop", run_requests_delta_loop)
_launch_background_task("requests-full-sync", run_daily_requests_full_sync) _launch_background_task("requests-full-sync", run_daily_requests_full_sync)
_launch_background_task("db-cleanup", run_daily_db_cleanup) _launch_background_task("db-cleanup", run_daily_db_cleanup)
+9
View File
@@ -248,6 +248,7 @@ SETTING_KEYS: List[str] = [
"log_background_sync_level", "log_background_sync_level",
"requests_sync_ttl_minutes", "requests_sync_ttl_minutes",
"requests_poll_interval_seconds", "requests_poll_interval_seconds",
"requests_stage_refresh_minutes",
"requests_delta_sync_interval_minutes", "requests_delta_sync_interval_minutes",
"requests_full_sync_time", "requests_full_sync_time",
"requests_cleanup_time", "requests_cleanup_time",
@@ -683,6 +684,14 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
changed_keys.append(key) changed_keys.append(key)
continue continue
value_to_store = str(value).strip() if isinstance(value, str) else str(value) value_to_store = str(value).strip() if isinstance(value, str) else str(value)
if key == "requests_stage_refresh_minutes":
try:
interval = int(value_to_store)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Local stage refresh must be a whole number from 1 to 1440 minutes") from exc
if not 1 <= interval <= 1440:
raise HTTPException(status_code=400, detail="Local stage refresh must be from 1 to 1440 minutes")
value_to_store = str(interval)
if key == "issue_confirmation_contact_attempts": if key == "issue_confirmation_contact_attempts":
try: try:
attempts = int(value_to_store) attempts = int(value_to_store)
+47 -19
View File
@@ -23,6 +23,8 @@ from ..auth import get_current_user
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
from .images import cache_tmdb_image, is_tmdb_cached from .images import cache_tmdb_image, is_tmdb_cached
from ..db import ( from ..db import (
get_request_stage_cache,
save_request_stage_cache,
add_portal_item_activity, add_portal_item_activity,
get_portal_item, get_portal_item,
save_action, save_action,
@@ -157,6 +159,7 @@ async def _request_is_available_in_jellyfin(
media_type: Optional[str], media_type: Optional[str],
request_payload: Optional[Dict[str, Any]], request_payload: Optional[Dict[str, Any]],
availability_cache: Dict[str, bool], availability_cache: Dict[str, bool],
raise_errors: bool = False,
) -> bool: ) -> bool:
if not jellyfin.configured() or not title: if not jellyfin.configured() or not title:
return False return False
@@ -168,6 +171,8 @@ async def _request_is_available_in_jellyfin(
try: try:
search = await jellyfin.search_items(title, types, limit=50) search = await jellyfin.search_items(title, types, limit=50)
except Exception: except Exception:
if raise_errors:
raise
availability_cache[cache_key] = False availability_cache[cache_key] = False
return False return False
if isinstance(search, dict): if isinstance(search, dict):
@@ -2741,15 +2746,8 @@ async def recent_requests(
runtime = get_runtime_settings() runtime = get_runtime_settings()
client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) client = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
mode = (runtime.requests_data_source or "prefer_cache").lower() mode = (runtime.requests_data_source or "prefer_cache").lower()
allow_remote = mode == "always_js" # Browsing is always local. Synchronization is owned by background workers.
if allow_remote: allow_remote = False
if not client.configured():
raise HTTPException(status_code=400, detail="Seerr not configured")
try:
await _ensure_requests_cache(client)
except httpx.HTTPStatusError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
username_norm = _normalize_username(user.get("username", "")) username_norm = _normalize_username(user.get("username", ""))
requested_by_id = user.get("jellyseerr_user_id") requested_by_id = user.get("jellyseerr_user_id")
requested_by = None if user.get("role") == "admin" else username_norm requested_by = None if user.get("role") == "admin" else username_norm
@@ -2773,7 +2771,8 @@ async def recent_requests(
matched = 0 matched = 0
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 = client.configured() allow_artwork_hydrate = False
stage_cache = await asyncio.to_thread(get_request_stage_cache)
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] = {}
results = [] results = []
@@ -2787,7 +2786,7 @@ async def recent_requests(
) )
year = row.get("year") year = row.get("year")
details = None details = None
if row.get("request_id") and mode != "always_js": if row.get("request_id"):
cached_payload = get_request_cache_payload(int(row["request_id"])) cached_payload = get_request_cache_payload(int(row["request_id"]))
if isinstance(cached_payload, dict): if isinstance(cached_payload, dict):
details = cached_payload details = cached_payload
@@ -2874,14 +2873,8 @@ async def recent_requests(
status = 5 status = 5
status_label = "Repair in progress" status_label = "Repair in progress"
elif status_label in {"Working on it", "Ready to watch", "Partially ready"}: elif status_label in {"Working on it", "Ready to watch", "Partially ready"}:
is_available = await _request_is_available_in_jellyfin( saved = stage_cache.get(row.get("request_id")) or {}
jellyfin, is_available = bool(saved.get("ready")) and saved.get("source_updated") == row.get("updated_at")
title,
year,
row.get("media_type"),
details if isinstance(details, dict) else None,
jellyfin_cache,
)
status_label = _status_label_with_jellyfin(status, is_available) status_label = _status_label_with_jellyfin(status, is_available)
if status_label == STATUS_LABELS[4]: if status_label == STATUS_LABELS[4]:
status = 4 status = 4
@@ -3755,3 +3748,38 @@ async def action_grab(
save_action, request_id, "grab", "Download selected release", "failed", failure_message save_action, request_id, "grab", "Download selected release", "failed", failure_message
) )
raise HTTPException(status_code=502, detail=failure_message) raise HTTPException(status_code=502, detail=failure_message)
async def refresh_local_request_stages():
runtime = get_runtime_settings()
jellyfin = JellyfinClient(runtime.jellyfin_base_url, runtime.jellyfin_api_key)
if not jellyfin.configured():
return
rows = await asyncio.to_thread(get_cached_requests_since, (datetime.now(timezone.utc) - timedelta(days=RECENT_CACHE_MAX_DAYS)).isoformat())
saved = await asyncio.to_thread(get_request_stage_cache)
interval = max(1, min(1440, int(runtime.requests_stage_refresh_minutes))) * 60
now = time.time()
due = [row for row in rows if row.get('status') == 5 and
(now - (saved.get(row['request_id']) or {}).get('checked_at', 0) >= interval or
(saved.get(row['request_id']) or {}).get('source_updated') != row.get('updated_at'))]
semaphore = asyncio.Semaphore(4)
async def check(row):
async with semaphore:
payload = await asyncio.to_thread(get_request_cache_payload, row['request_id'])
try:
ready = await _request_is_available_in_jellyfin(jellyfin, row.get('title'), row.get('year'), row.get('media_type'), payload, {}, raise_errors=True)
return (row['request_id'], row.get('updated_at'), int(ready), time.time())
except Exception:
logger.warning('Local request stage refresh failed request_id=%s', row['request_id'])
return None
checked = await asyncio.gather(*(check(row) for row in due))
await asyncio.to_thread(save_request_stage_cache, [row for row in checked if row is not None])
async def run_local_request_stage_loop():
while True:
try:
await refresh_local_request_stages()
except Exception:
logger.exception('Local request stage refresh failed')
await asyncio.sleep(30)
+1
View File
@@ -17,6 +17,7 @@ _INT_FIELDS = {
"log_file_backup_count", "log_file_backup_count",
"requests_sync_ttl_minutes", "requests_sync_ttl_minutes",
"requests_poll_interval_seconds", "requests_poll_interval_seconds",
"requests_stage_refresh_minutes",
"requests_delta_sync_interval_minutes", "requests_delta_sync_interval_minutes",
"requests_cleanup_days", "requests_cleanup_days",
"issue_confirmation_contact_attempts", "issue_confirmation_contact_attempts",
+35 -3
View File
@@ -1,6 +1,6 @@
import unittest import unittest
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, AsyncMock, patch
from contextlib import ExitStack from contextlib import ExitStack
from backend.app.routers import requests from backend.app.routers import requests
@@ -14,11 +14,11 @@ class RecentStageTests(unittest.IsolatedAsyncioTestCase):
requested_by_id=10) for i, status in [(1,5),(2,5),(3,4),(4,6),(5,5),(6,2),(7,1),(8,3)]] requested_by_id=10) for i, status in [(1,5),(2,5),(3,4),(4,6),(5,5),(6,2),(7,1),(8,3)]]
async def available(client, title, *args): return title in {'1','3','4','5'} async def available(client, title, *args): return title in {'1','3','4','5'}
with ExitStack() as stack: with ExitStack() as stack:
for name, value in [('get_runtime_settings', runtime), ('_recent_cache_stale', False), for name, value in [('get_runtime_settings', runtime), ('get_request_stage_cache', {1:{'ready':True},3:{'ready':True},4:{'ready':True},5:{'ready':True}}), ('_recent_cache_stale', False),
('active_repair_request_ids', {'5'}), ('get_request_cache_payload', None)]: ('active_repair_request_ids', {'5'}), ('get_request_cache_payload', None)]:
stack.enter_context(patch.object(requests, name, return_value=value)) stack.enter_context(patch.object(requests, name, return_value=value))
stack.enter_context(patch.dict(requests._recent_cache, {'items':rows})) stack.enter_context(patch.dict(requests._recent_cache, {'items':rows}))
stack.enter_context(patch.object(requests, '_request_is_available_in_jellyfin', new=available)) stack.enter_context(patch.object(requests, '_request_is_available_in_jellyfin', new=AsyncMock(side_effect=AssertionError('Recent requests must not call Jellyfin'))))
user={'role':'user','username':'viewer','jellyseerr_user_id':10} user={'role':'user','username':'viewer','jellyseerr_user_id':10}
expected={'working':[2,5], 'ready':[1,3], 'partial':[4], 'approved':[6], expected={'working':[2,5], 'ready':[1,3], 'partial':[4], 'approved':[6],
'pending':[7], 'declined':[8], 'in_progress':[2,4,5,6]} 'pending':[7], 'declined':[8], 'in_progress':[2,4,5,6]}
@@ -31,3 +31,35 @@ class RecentStageTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(result['results'][0]['status'],4) self.assertEqual(result['results'][0]['status'],4)
result=await requests.recent_requests(take=20,skip=0,days=0,stage='all',user={**user,'jellyseerr_user_id':99}) result=await requests.recent_requests(take=20,skip=0,days=0,stage='all',user={**user,'jellyseerr_user_id':99})
self.assertEqual(result['results'],[]) self.assertEqual(result['results'],[])
async def test_background_refresh_skips_fresh_rows_and_preserves_failures(self):
import time
rows=[{'request_id':i,'status':5,'title':str(i),'updated_at':'v1'} for i in [1,2,3]]
runtime=SimpleNamespace(jellyfin_base_url='http://jellyfin',jellyfin_api_key='test',requests_stage_refresh_minutes=15)
with ExitStack() as stack:
stack.enter_context(patch.object(requests,'get_runtime_settings',return_value=runtime))
stack.enter_context(patch.object(requests,'get_cached_requests_since',return_value=rows))
stack.enter_context(patch.object(requests,'get_request_stage_cache',return_value={1:{'source_updated':'v1','checked_at':time.time()}}))
stack.enter_context(patch.object(requests,'get_request_cache_payload',return_value={}))
check=stack.enter_context(patch.object(requests,'_request_is_available_in_jellyfin',new=AsyncMock(side_effect=[True,RuntimeError('offline')])) )
save=stack.enter_context(patch.object(requests,'save_request_stage_cache'))
await requests.refresh_local_request_stages()
self.assertEqual(check.await_count,2)
written=save.call_args.args[0]
self.assertEqual(len(written),1)
self.assertEqual(written[0][:3],(2,'v1',1))
from backend.tests.test_backend_quality import TempDatabaseMixin
from backend.app import db
class StagePersistenceTests(TempDatabaseMixin, unittest.TestCase):
def test_saved_stages_survive_initialization_and_actions_mark_due(self):
with db._connect() as conn:
conn.execute("INSERT INTO requests_cache (request_id, payload_json) VALUES (42, '{}')")
db.save_request_stage_cache([(42,'v1',1,12345)])
db.init_db()
self.assertTrue(db.get_request_stage_cache()[42]['ready'])
db.save_action('42','search_releases','Search','ok')
self.assertEqual(db.get_request_stage_cache()[42]['checked_at'],0)
self.assertTrue(db.get_request_stage_cache()[42]['ready'])
+4 -1
View File
@@ -82,6 +82,7 @@ const NUMBER_SETTINGS = new Set([
'log_file_backup_count', 'log_file_backup_count',
'requests_sync_ttl_minutes', 'requests_sync_ttl_minutes',
'requests_poll_interval_seconds', 'requests_poll_interval_seconds',
'requests_stage_refresh_minutes',
'requests_delta_sync_interval_minutes', 'requests_delta_sync_interval_minutes',
'requests_cleanup_days', 'requests_cleanup_days',
'issue_confirmation_contact_attempts', 'issue_confirmation_contact_attempts',
@@ -386,7 +387,7 @@ const STANDARD_SECTION_GROUPS: Record<
key: 'requests-advanced', key: 'requests-advanced',
title: 'Advanced scheduling', title: 'Advanced scheduling',
description: 'How frequently the background worker checks whether a full refresh is due.', description: 'How frequently the background worker checks whether a full refresh is due.',
keys: ['requests_poll_interval_seconds'], keys: ['requests_poll_interval_seconds', 'requests_stage_refresh_minutes'],
}, },
{ {
key: 'requests-sync', key: 'requests-sync',
@@ -505,6 +506,7 @@ const SETTING_LABEL_OVERRIDES: Record<string, string> = {
qbittorrent_username: 'Web UI username', qbittorrent_username: 'Web UI username',
qbittorrent_password: 'Web UI password', qbittorrent_password: 'Web UI password',
requests_sync_ttl_minutes: 'Request cache freshness (minutes)', requests_sync_ttl_minutes: 'Request cache freshness (minutes)',
requests_stage_refresh_minutes: 'Local stage refresh (minutes)',
requests_poll_interval_seconds: 'Full-sync eligibility check (seconds)', requests_poll_interval_seconds: 'Full-sync eligibility check (seconds)',
requests_delta_sync_interval_minutes: 'Recent-change sync interval (minutes)', requests_delta_sync_interval_minutes: 'Recent-change sync interval (minutes)',
requests_full_sync_time: 'Daily full-sync time', requests_full_sync_time: 'Daily full-sync time',
@@ -1027,6 +1029,7 @@ export default function SettingsPage({ section }: SettingsPageProps) {
'qBittorrent server URL for download status (FQDN or IP). Scheme is optional.', 'qBittorrent server URL for download status (FQDN or IP). Scheme is optional.',
qbittorrent_username: 'qBittorrent login username.', qbittorrent_username: 'qBittorrent login username.',
qbittorrent_password: 'qBittorrent login password.', qbittorrent_password: 'qBittorrent login password.',
requests_stage_refresh_minutes: 'Refresh saved request stages in the background. Default: 15 minutes; range: 1 to 1440. Shorter intervals increase Jellyfin traffic and server load. Recent requests always load from the local database.',
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: requests_poll_interval_seconds:
'How often Magent checks if a full refresh should run.', 'How often Magent checks if a full refresh should run.',