diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index 3f40659..9fcf72e 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -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) +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") async def action_recheck(request_id: str, user: Dict[str, str] = Depends(get_current_user)) -> dict: 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) _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") - message = f"Recheck complete. {status_label}." + message = ("Monitoring restored. " if restored else "") + f"Recheck complete. {status_label}." await asyncio.to_thread( save_action, 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": []} missing_ids = [identity for season in season_numbers for identity in missing_by_season[season]] 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 semaphore = asyncio.Semaphore(3) 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): message += ' Duplicate results are combined; up to 200 ranked releases are shown.' 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) return {'status': 'ok', 'collector': collector, 'qualityFiltered': False, 'message': message, 'outcome': 'matches' if approved else 'attention', 'rejectionReasons': rejection_reasons, diff --git a/backend/tests/test_manual_selection.py b/backend/tests/test_manual_selection.py index 3856fcd..0a292ad 100644 --- a/backend/tests/test_manual_selection.py +++ b/backend/tests/test_manual_selection.py @@ -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}}}) 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'}) - second = await requests.action_search('42', {'username':'viewer','role':'user'}, offset=20) - self.assertEqual(first['nextOffset'],20); self.assertIsNone(second['nextOffset']) - self.assertEqual(sonarr.search_episode_releases.await_count,25) + second = await requests.action_search('42', {'username':'viewer','role':'user'}, offset=24) + self.assertEqual(first['nextOffset'],3); self.assertIsNone(second['nextOffset']) + self.assertEqual(sonarr.search_episode_releases.await_count,4) self.assertLessEqual(peak,3) self.assertEqual(first['totalMissingEpisodes'],25) diff --git a/backend/tests/test_recheck_monitoring.py b/backend/tests/test_recheck_monitoring.py new file mode 100644 index 0000000..5f7ebf8 --- /dev/null +++ b/backend/tests/test_recheck_monitoring.py @@ -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}) diff --git a/frontend/next.config.js b/frontend/next.config.js index c5e687e..04535d5 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -2,6 +2,7 @@ const backendUrl = process.env.BACKEND_INTERNAL_URL || 'http://backend:8000' /** @type {import('next').NextConfig} */ const nextConfig = { + experimental: { proxyTimeout: 180000 }, async rewrites() { return [ {