Serve recent stages from SQLite with configurable background refresh
This commit is contained in:
@@ -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
@@ -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)')
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
Reference in New Issue
Block a user