From 6391fbfd8149f2db440241cc45733de0ebf3896e Mon Sep 17 00:00:00 2001 From: Zak Bearman Date: Tue, 1 Sep 2026 20:50:16 +1200 Subject: [PATCH] Monitor media before issue repair searches --- backend/app/clients/base.py | 2 + backend/app/clients/radarr.py | 9 +++ backend/app/clients/sonarr.py | 8 +++ backend/app/routers/requests.py | 14 ++-- backend/tests/test_backend_quality.py | 97 ++++++++++++++++++++++++++- 5 files changed, 125 insertions(+), 5 deletions(-) diff --git a/backend/app/clients/base.py b/backend/app/clients/base.py index de032ad..beb0eea 100644 --- a/backend/app/clients/base.py +++ b/backend/app/clients/base.py @@ -141,6 +141,8 @@ def _operation_result_message( return "Sonarr removed the existing episode file so it can be replaced." count = len(_result_items(result)) return f"Sonarr found {_count_message(count, 'downloaded episode file')}." + if service == "Sonarr" and normalized_path.endswith("/episode/monitor") and normalized_method == "PUT": + return "Sonarr marked the selected episodes as wanted." if service == "Sonarr" and "/episode" in normalized_path and normalized_method == "GET": episodes = _result_items(result) available = sum(1 for item in episodes if isinstance(item, dict) and item.get("hasFile") is True) diff --git a/backend/app/clients/radarr.py b/backend/app/clients/radarr.py index 141b0d8..d8f41b2 100644 --- a/backend/app/clients/radarr.py +++ b/backend/app/clients/radarr.py @@ -39,6 +39,15 @@ class RadarrClient(ApiClient): async def search(self, movie_id: int) -> Optional[Dict[str, Any]]: return await self.post("/api/v3/command", payload={"name": "MoviesSearch", "movieIds": [movie_id]}) + async def monitor_movie( + self, movie_id: int, monitored: bool = True + ) -> Optional[Dict[str, Any]]: + movie = await self.get_movie(movie_id) + if not isinstance(movie, dict): + raise ValueError("Radarr did not return the movie before updating its monitored state") + movie["monitored"] = monitored + return await self.update_movie(movie) + async def delete_movie_file(self, movie_file_id: int) -> Optional[Any]: return await self.delete( f"/api/v3/moviefile/{movie_file_id}", diff --git a/backend/app/clients/sonarr.py b/backend/app/clients/sonarr.py index 5f5a6d9..2dbdaf2 100644 --- a/backend/app/clients/sonarr.py +++ b/backend/app/clients/sonarr.py @@ -57,6 +57,14 @@ class SonarrClient(ApiClient): async def search_episodes(self, episode_ids: list[int]) -> Optional[Dict[str, Any]]: return await self.post("/api/v3/command", payload={"name": "EpisodeSearch", "episodeIds": episode_ids}) + async def monitor_episodes( + self, episode_ids: list[int], monitored: bool = True + ) -> Optional[Dict[str, Any]]: + return await self.put( + "/api/v3/episode/monitor", + payload={"episodeIds": episode_ids, "monitored": monitored}, + ) + async def delete_episode_file(self, episode_file_id: int) -> Optional[Any]: return await self.delete( f"/api/v3/episodefile/{episode_file_id}", diff --git a/backend/app/routers/requests.py b/backend/app/routers/requests.py index 764ced8..64c791c 100644 --- a/backend/app/routers/requests.py +++ b/backend/app/routers/requests.py @@ -1896,8 +1896,8 @@ def _issue_episode_payloads( "released": released, "monitored": monitored, "has_file": has_file, - "missing": released and monitored and not has_file, - "best_fit": released and monitored and not has_file, + "missing": released and not has_file, + "best_fit": released and not has_file, "file_id": episode.get("episodeFileId") if has_file else None, } ) @@ -2154,6 +2154,7 @@ async def action_replace_media( radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key) if not radarr.configured(): raise HTTPException(status_code=400, detail="Radarr is not configured") + await radarr.monitor_movie(movie_id, True) await radarr.delete_movie_file(file_ids[0]) await radarr.search(movie_id) elif snapshot.request_type == RequestType.tv: @@ -2184,6 +2185,7 @@ async def action_replace_media( if not episode_ids: raise HTTPException(status_code=409, detail="Sonarr could not match episodes to this file") target_names = [_replacement_file_name(file_data) for file_data in selected_files] + await sonarr.monitor_episodes(episode_ids, True) for selected_file_id in file_ids: await sonarr.delete_episode_file(selected_file_id) await sonarr.search_episodes(episode_ids) @@ -2295,6 +2297,7 @@ async def action_search_missing_media( radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key) if not radarr.configured(): raise HTTPException(status_code=400, detail="Radarr is not configured") + await radarr.monitor_movie(collector_id, True) await radarr.search(collector_id) message = "Radarr started searching for the missing movie." searched_ids: List[int] = [] @@ -2319,14 +2322,17 @@ async def action_search_missing_media( item_id for item_id, episode in episode_map.items() if _released_episode(episode) - and episode.get("monitored") is not False and not ( episode.get("hasFile") is True or (isinstance(episode.get("episodeFileId"), int) and episode.get("episodeFileId") > 0) ) - and (not season_numbers or episode.get("seasonNumber") in season_numbers) + and ( + (season_numbers and episode.get("seasonNumber") in season_numbers) + or (not season_numbers and episode.get("monitored") is not False) + ) ] if searched_ids: + await sonarr.monitor_episodes(searched_ids, True) await sonarr.search_episodes(searched_ids) message = f"Sonarr started searching for {len(searched_ids)} selected missing episode(s)." else: diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index 791d38e..e130551 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -1307,6 +1307,7 @@ class MediaReplacementTests(unittest.IsolatedAsyncioTestCase): ) radarr = SimpleNamespace( configured=lambda: True, + monitor_movie=AsyncMock(return_value={"id": 44, "monitored": True}), delete_movie_file=AsyncMock(return_value=None), search=AsyncMock(return_value={"id": 1}), ) @@ -1341,6 +1342,7 @@ class MediaReplacementTests(unittest.IsolatedAsyncioTestCase): ) self.assertEqual(result["status"], "ok") + radarr.monitor_movie.assert_awaited_once_with(44, True) radarr.delete_movie_file.assert_awaited_once_with(77) radarr.search.assert_awaited_once_with(44) add_activity.assert_called_once() @@ -1422,6 +1424,16 @@ class MediaReplacementTests(unittest.IsolatedAsyncioTestCase): "episodeFileId": 0, "airDateUtc": "2020-01-08T00:00:00Z", }, + { + "id": 103, + "seasonNumber": 1, + "episodeNumber": 3, + "title": "Missing and unmonitored", + "monitored": False, + "hasFile": False, + "episodeFileId": 0, + "airDateUtc": "2020-01-15T00:00:00Z", + }, ]), ) runtime = SimpleNamespace( @@ -1440,11 +1452,15 @@ class MediaReplacementTests(unittest.IsolatedAsyncioTestCase): {"username": "viewer", "role": "user", "auto_search_enabled": True}, ) - self.assertEqual(result["seasons"][0]["missing_count"], 1) + self.assertEqual(result["seasons"][0]["missing_count"], 2) self.assertTrue(result["seasons"][0]["best_fit"]) missing = next(item for item in result["episodes"] if item["id"] == 102) self.assertTrue(missing["missing"]) self.assertTrue(missing["best_fit"]) + unmonitored = next(item for item in result["episodes"] if item["id"] == 103) + self.assertFalse(unmonitored["monitored"]) + self.assertTrue(unmonitored["missing"]) + self.assertTrue(unmonitored["best_fit"]) collected = next(item for item in result["episodes"] if item["id"] == 101) self.assertEqual(collected["file_id"], 88) self.assertNotIn("file_name", collected) @@ -1468,6 +1484,7 @@ class MediaReplacementTests(unittest.IsolatedAsyncioTestCase): {"id": 101, "episodeFileId": 88}, {"id": 102, "episodeFileId": 89}, ]), + monitor_episodes=AsyncMock(return_value={"monitored": True}), delete_episode_file=AsyncMock(return_value=None), search_episodes=AsyncMock(return_value={"id": 1}), ) @@ -1498,9 +1515,87 @@ class MediaReplacementTests(unittest.IsolatedAsyncioTestCase): ) self.assertEqual(result["file_ids"], [88, 89]) + sonarr.monitor_episodes.assert_awaited_once_with([101, 102], True) self.assertEqual(sonarr.delete_episode_file.await_count, 2) sonarr.search_episodes.assert_awaited_once_with([101, 102]) + async def test_missing_episode_search_monitors_explicit_unmonitored_episode(self) -> None: + snapshot = Snapshot( + request_id="113", + title="Family Guy", + request_type=RequestType.tv, + state=NormalizedState.available, + raw={"arr": {"item": {"id": 540}}}, + ) + sonarr = SimpleNamespace( + configured=lambda: True, + get_episodes=AsyncMock(return_value=[{ + "id": 36899, + "seasonNumber": 5, + "episodeNumber": 9, + "monitored": False, + "hasFile": False, + "episodeFileId": 0, + "airDateUtc": "2006-12-17T00:00:00Z", + }]), + monitor_episodes=AsyncMock(return_value={"monitored": True}), + search_episodes=AsyncMock(return_value={"id": 9001}), + search=AsyncMock(return_value={"id": 9002}), + ) + runtime = SimpleNamespace( + sonarr_base_url="http://sonarr", + sonarr_api_key="secret", + ) + with ( + patch.object(requests_router, "get_runtime_settings", return_value=runtime), + patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)), + patch.object(requests_router, "SonarrClient", return_value=sonarr), + patch.object(requests_router, "save_action"), + ): + result = await requests_router.action_search_missing_media( + "113", + {"episode_ids": [36899], "season_numbers": [5]}, + {"username": "admin", "role": "admin", "auto_search_enabled": True}, + ) + + self.assertEqual(result["episode_ids"], [36899]) + sonarr.monitor_episodes.assert_awaited_once_with([36899], True) + sonarr.search_episodes.assert_awaited_once_with([36899]) + sonarr.search.assert_not_awaited() + + async def test_missing_movie_search_monitors_movie_before_search(self) -> None: + snapshot = Snapshot( + request_id="3914", + title="Missing Movie", + request_type=RequestType.movie, + state=NormalizedState.available, + raw={"arr": {"item": {"id": 44}}}, + ) + radarr = SimpleNamespace( + configured=lambda: True, + monitor_movie=AsyncMock(return_value={"id": 44, "monitored": True}), + search=AsyncMock(return_value={"id": 9003}), + ) + runtime = SimpleNamespace( + radarr_base_url="http://radarr", + radarr_api_key="secret", + ) + with ( + patch.object(requests_router, "get_runtime_settings", return_value=runtime), + patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)), + patch.object(requests_router, "RadarrClient", return_value=radarr), + patch.object(requests_router, "save_action"), + ): + result = await requests_router.action_search_missing_media( + "3914", + {"episode_ids": [], "season_numbers": []}, + {"username": "admin", "role": "admin", "auto_search_enabled": True}, + ) + + self.assertEqual(result["status"], "ok") + radarr.monitor_movie.assert_awaited_once_with(44, True) + radarr.search.assert_awaited_once_with(44) + async def test_movie_subtitle_issue_starts_bazarr_search_without_replacement(self) -> None: snapshot = Snapshot( request_id="3914",