Restore monitoring on recheck and prevent release-search proxy timeouts
Magent CI/CD / verify (push) Successful in 2m1s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Skipped

This commit is contained in:
2026-09-13 19:47:36 +12:00
parent b3b83bda4f
commit ce756c1a65
4 changed files with 91 additions and 7 deletions
+60 -4
View File
@@ -2578,6 +2578,54 @@ async def get_snapshot(request_id: str, user: Dict[str, str] = Depends(get_curre
return _filter_snapshot_for_user(snapshot, user) return _filter_snapshot_for_user(snapshot, user)
async def _restore_request_monitoring(snapshot: Snapshot, request: dict) -> bool:
# Pending or declined requests must not gain collection access via Recheck.
if request.get('status') != 2:
return False
item = (snapshot.raw.get('arr') or {}).get('item')
if not isinstance(item, dict) or not isinstance(item.get('id'), int):
return False
runtime = get_runtime_settings()
if snapshot.request_type == RequestType.movie:
client = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
fresh = await client.get_movie(item['id'])
if not isinstance(fresh, dict):
raise ValueError('Radarr did not return the requested movie')
if fresh.get('monitored') is True:
return False
await client.update_movie({**fresh, 'monitored': True})
verified = await client.get_movie(item['id'])
if not isinstance(verified, dict) or verified.get('monitored') is not True:
raise ValueError('Radarr did not enable monitoring')
return True
client = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
fresh = await client.get_series(item['id'])
if not isinstance(fresh, dict):
raise ValueError('Sonarr did not return the requested series')
requested = {season['seasonNumber'] for season in (request.get('seasons') or [])
if isinstance(season, dict) and isinstance(season.get('seasonNumber'), int)}
seasons = [{**season, 'monitored': True} if season.get('seasonNumber') in requested else season
for season in (fresh.get('seasons') or [])]
changed = fresh.get('monitored') is not True or seasons != fresh.get('seasons', [])
if changed:
await client.update_series({**fresh, 'monitored': True, 'seasons': seasons})
episodes = await client.get_episodes(item['id']) if requested else []
ids = [episode['id'] for episode in episodes if episode.get('seasonNumber') in requested
and episode.get('monitored') is not True and isinstance(episode.get('id'), int)]
if ids:
await client.monitor_episodes(ids, True)
verified_episodes = await client.get_episodes(item['id'])
if any(e.get('id') in ids and e.get('monitored') is not True for e in verified_episodes):
raise ValueError('Sonarr did not enable episode monitoring')
if changed:
verified = await client.get_series(item['id'])
if not isinstance(verified, dict) or verified.get('monitored') is not True or any(
season.get('seasonNumber') in requested and season.get('monitored') is not True
for season in verified.get('seasons', [])):
raise ValueError('Sonarr did not enable monitoring')
return changed or bool(ids)
@router.post("/{request_id}/actions/recheck") @router.post("/{request_id}/actions/recheck")
async def action_recheck(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict: async def action_recheck(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict:
if not request_id.isdigit(): if not request_id.isdigit():
@@ -2634,9 +2682,17 @@ async def action_recheck(request_id: str, user: Dict[str, str] = Depends(get_cur
_cache_set(f"request:{request_id}", fresh_request) _cache_set(f"request:{request_id}", fresh_request)
_refresh_recent_cache_from_db() _refresh_recent_cache_from_db()
snapshot = _filter_snapshot_for_user(await build_snapshot(request_id), user) snapshot = await build_snapshot(request_id)
try:
restored = await _restore_request_monitoring(snapshot, fresh_request)
except Exception as exc:
logger.warning('Recheck monitoring failed request_id=%s: %s', request_id, exc)
raise HTTPException(502, 'Could not restore monitoring in Sonarr/Radarr. Please try Recheck again.') from exc
if restored:
snapshot = await build_snapshot(request_id)
snapshot = _filter_snapshot_for_user(snapshot, user)
status_label = str((snapshot.presentation.get("status") or {}).get("label") or "Status updated") status_label = str((snapshot.presentation.get("status") or {}).get("label") or "Status updated")
message = f"Recheck complete. {status_label}." message = ("Monitoring restored. " if restored else "") + f"Recheck complete. {status_label}."
await asyncio.to_thread( await asyncio.to_thread(
save_action, save_action,
request_id, request_id,
@@ -3340,7 +3396,7 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
return {"status": "ok", "message": message, "collector": collector, "releases": []} return {"status": "ok", "message": message, "collector": collector, "releases": []}
missing_ids = [identity for season in season_numbers for identity in missing_by_season[season]] missing_ids = [identity for season in season_numbers for identity in missing_by_season[season]]
total_missing = len(missing_ids) total_missing = len(missing_ids)
batch = missing_ids[offset:offset + 20] batch = missing_ids[offset:offset + 3]
next_offset = offset + len(batch) if offset + len(batch) < total_missing else None next_offset = offset + len(batch) if offset + len(batch) < total_missing else None
semaphore = asyncio.Semaphore(3) semaphore = asyncio.Semaphore(3)
async def search_episode(identity): async def search_episode(identity):
@@ -3406,7 +3462,7 @@ async def action_search(request_id: str, user: Dict[str, str] = Depends(get_curr
if len(results) > len(releases): if len(results) > len(releases):
message += ' Duplicate results are combined; up to 200 ranked releases are shown.' message += ' Duplicate results are combined; up to 200 ranked releases are shown.'
if total_missing: if total_missing:
message += f' Searched {min(20, max(0, total_missing - offset))} of {total_missing} missing monitored episodes.' message += f' Searched {min(3, max(0, total_missing - offset))} of {total_missing} missing monitored episodes.'
await asyncio.to_thread(save_action, request_id, 'search_releases', 'Search and choose a download', 'ok', message) await asyncio.to_thread(save_action, request_id, 'search_releases', 'Search and choose a download', 'ok', message)
return {'status': 'ok', 'collector': collector, 'qualityFiltered': False, 'message': message, return {'status': 'ok', 'collector': collector, 'qualityFiltered': False, 'message': message,
'outcome': 'matches' if approved else 'attention', 'rejectionReasons': rejection_reasons, 'outcome': 'matches' if approved else 'attention', 'rejectionReasons': rejection_reasons,
+3 -3
View File
@@ -75,9 +75,9 @@ class ManualEpisodeSearchTests(unittest.IsolatedAsyncioTestCase):
snapshot = Snapshot(request_id='42',request_type=RequestType.tv,title='Example',raw={'arr':{'item':{'id':55,'qualityProfileId':9}}}) snapshot = Snapshot(request_id='42',request_type=RequestType.tv,title='Example',raw={'arr':{'item':{'id':55,'qualityProfileId':9}}})
with patch.object(requests, 'get_runtime_settings', return_value=runtime), patch.object(requests, 'build_snapshot', new=AsyncMock(return_value=snapshot)), patch.object(requests,'SonarrClient',return_value=sonarr), patch.object(requests,'save_action'): with patch.object(requests, 'get_runtime_settings', return_value=runtime), patch.object(requests, 'build_snapshot', new=AsyncMock(return_value=snapshot)), patch.object(requests,'SonarrClient',return_value=sonarr), patch.object(requests,'save_action'):
first = await requests.action_search('42', {'username':'viewer','role':'user'}) first = await requests.action_search('42', {'username':'viewer','role':'user'})
second = await requests.action_search('42', {'username':'viewer','role':'user'}, offset=20) second = await requests.action_search('42', {'username':'viewer','role':'user'}, offset=24)
self.assertEqual(first['nextOffset'],20); self.assertIsNone(second['nextOffset']) self.assertEqual(first['nextOffset'],3); self.assertIsNone(second['nextOffset'])
self.assertEqual(sonarr.search_episode_releases.await_count,25) self.assertEqual(sonarr.search_episode_releases.await_count,4)
self.assertLessEqual(peak,3) self.assertLessEqual(peak,3)
self.assertEqual(first['totalMissingEpisodes'],25) self.assertEqual(first['totalMissingEpisodes'],25)
+27
View File
@@ -0,0 +1,27 @@
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from backend.app.routers import requests
from backend.app.models import Snapshot, RequestType
class RecheckMonitoringTests(unittest.IsolatedAsyncioTestCase):
async def test_series_restores_only_requested_seasons_and_episodes(self):
original={'id':10,'monitored':False,'qualityProfileId':7,'seasons':[{'seasonNumber':1,'monitored':False},{'seasonNumber':2,'monitored':False}]}
restored={**original,'monitored':True,'seasons':[{'seasonNumber':1,'monitored':True},{'seasonNumber':2,'monitored':False}]}
client=SimpleNamespace(get_series=AsyncMock(side_effect=[original,restored]),update_series=AsyncMock(),get_episodes=AsyncMock(side_effect=[[{'id':1,'seasonNumber':1,'monitored':False},{'id':2,'seasonNumber':2,'monitored':False}],[{'id':1,'seasonNumber':1,'monitored':True},{'id':2,'seasonNumber':2,'monitored':False}]]),monitor_episodes=AsyncMock())
snapshot=Snapshot(request_id='42',title='Test',request_type=RequestType.tv,raw={'arr':{'item':{'id':10}}})
runtime=SimpleNamespace(sonarr_base_url='http://sonarr',sonarr_api_key='test')
with patch.object(requests,'get_runtime_settings',return_value=runtime),patch.object(requests,'SonarrClient',return_value=client):
self.assertTrue(await requests._restore_request_monitoring(snapshot,{'status':2,'seasons':[{'seasonNumber':1}]}))
client.update_series.assert_awaited_once_with(restored)
client.monitor_episodes.assert_awaited_once_with([1],True)
async def test_movie_monitoring_preserves_profile_and_pending_is_noop(self):
movie={'id':10,'monitored':False,'qualityProfileId':7}
client=SimpleNamespace(get_movie=AsyncMock(side_effect=[movie,{**movie,'monitored':True}]),update_movie=AsyncMock())
snapshot=Snapshot(request_id='42',title='Test',request_type=RequestType.movie,raw={'arr':{'item':{'id':10}}})
runtime=SimpleNamespace(radarr_base_url='http://radarr',radarr_api_key='test')
with patch.object(requests,'get_runtime_settings',return_value=runtime),patch.object(requests,'RadarrClient',return_value=client):
self.assertFalse(await requests._restore_request_monitoring(snapshot,{'status':1}))
self.assertTrue(await requests._restore_request_monitoring(snapshot,{'status':2}))
client.update_movie.assert_awaited_once_with({**movie,'monitored':True})
+1
View File
@@ -2,6 +2,7 @@ const backendUrl = process.env.BACKEND_INTERNAL_URL || 'http://backend:8000'
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
experimental: { proxyTimeout: 180000 },
async rewrites() { async rewrites() {
return [ return [
{ {