Restore monitoring on recheck and prevent release-search proxy timeouts
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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})
|
||||||
@@ -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 [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user