Monitor media before issue repair searches
This commit is contained in:
@@ -141,6 +141,8 @@ def _operation_result_message(
|
|||||||
return "Sonarr removed the existing episode file so it can be replaced."
|
return "Sonarr removed the existing episode file so it can be replaced."
|
||||||
count = len(_result_items(result))
|
count = len(_result_items(result))
|
||||||
return f"Sonarr found {_count_message(count, 'downloaded episode file')}."
|
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":
|
if service == "Sonarr" and "/episode" in normalized_path and normalized_method == "GET":
|
||||||
episodes = _result_items(result)
|
episodes = _result_items(result)
|
||||||
available = sum(1 for item in episodes if isinstance(item, dict) and item.get("hasFile") is True)
|
available = sum(1 for item in episodes if isinstance(item, dict) and item.get("hasFile") is True)
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ class RadarrClient(ApiClient):
|
|||||||
async def search(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
async def search(self, movie_id: int) -> Optional[Dict[str, Any]]:
|
||||||
return await self.post("/api/v3/command", payload={"name": "MoviesSearch", "movieIds": [movie_id]})
|
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]:
|
async def delete_movie_file(self, movie_file_id: int) -> Optional[Any]:
|
||||||
return await self.delete(
|
return await self.delete(
|
||||||
f"/api/v3/moviefile/{movie_file_id}",
|
f"/api/v3/moviefile/{movie_file_id}",
|
||||||
|
|||||||
@@ -57,6 +57,14 @@ class SonarrClient(ApiClient):
|
|||||||
async def search_episodes(self, episode_ids: list[int]) -> Optional[Dict[str, Any]]:
|
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})
|
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]:
|
async def delete_episode_file(self, episode_file_id: int) -> Optional[Any]:
|
||||||
return await self.delete(
|
return await self.delete(
|
||||||
f"/api/v3/episodefile/{episode_file_id}",
|
f"/api/v3/episodefile/{episode_file_id}",
|
||||||
|
|||||||
@@ -1896,8 +1896,8 @@ def _issue_episode_payloads(
|
|||||||
"released": released,
|
"released": released,
|
||||||
"monitored": monitored,
|
"monitored": monitored,
|
||||||
"has_file": has_file,
|
"has_file": has_file,
|
||||||
"missing": released and monitored and not has_file,
|
"missing": released and not has_file,
|
||||||
"best_fit": released and monitored and not has_file,
|
"best_fit": released and not has_file,
|
||||||
"file_id": episode.get("episodeFileId") if has_file else None,
|
"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)
|
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||||
if not radarr.configured():
|
if not radarr.configured():
|
||||||
raise HTTPException(status_code=400, detail="Radarr is not 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.delete_movie_file(file_ids[0])
|
||||||
await radarr.search(movie_id)
|
await radarr.search(movie_id)
|
||||||
elif snapshot.request_type == RequestType.tv:
|
elif snapshot.request_type == RequestType.tv:
|
||||||
@@ -2184,6 +2185,7 @@ async def action_replace_media(
|
|||||||
if not episode_ids:
|
if not episode_ids:
|
||||||
raise HTTPException(status_code=409, detail="Sonarr could not match episodes to this file")
|
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]
|
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:
|
for selected_file_id in file_ids:
|
||||||
await sonarr.delete_episode_file(selected_file_id)
|
await sonarr.delete_episode_file(selected_file_id)
|
||||||
await sonarr.search_episodes(episode_ids)
|
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)
|
radarr = RadarrClient(runtime.radarr_base_url, runtime.radarr_api_key)
|
||||||
if not radarr.configured():
|
if not radarr.configured():
|
||||||
raise HTTPException(status_code=400, detail="Radarr is not configured")
|
raise HTTPException(status_code=400, detail="Radarr is not configured")
|
||||||
|
await radarr.monitor_movie(collector_id, True)
|
||||||
await radarr.search(collector_id)
|
await radarr.search(collector_id)
|
||||||
message = "Radarr started searching for the missing movie."
|
message = "Radarr started searching for the missing movie."
|
||||||
searched_ids: List[int] = []
|
searched_ids: List[int] = []
|
||||||
@@ -2319,14 +2322,17 @@ async def action_search_missing_media(
|
|||||||
item_id
|
item_id
|
||||||
for item_id, episode in episode_map.items()
|
for item_id, episode in episode_map.items()
|
||||||
if _released_episode(episode)
|
if _released_episode(episode)
|
||||||
and episode.get("monitored") is not False
|
|
||||||
and not (
|
and not (
|
||||||
episode.get("hasFile") is True
|
episode.get("hasFile") is True
|
||||||
or (isinstance(episode.get("episodeFileId"), int) and episode.get("episodeFileId") > 0)
|
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:
|
if searched_ids:
|
||||||
|
await sonarr.monitor_episodes(searched_ids, True)
|
||||||
await sonarr.search_episodes(searched_ids)
|
await sonarr.search_episodes(searched_ids)
|
||||||
message = f"Sonarr started searching for {len(searched_ids)} selected missing episode(s)."
|
message = f"Sonarr started searching for {len(searched_ids)} selected missing episode(s)."
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1307,6 +1307,7 @@ class MediaReplacementTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
)
|
)
|
||||||
radarr = SimpleNamespace(
|
radarr = SimpleNamespace(
|
||||||
configured=lambda: True,
|
configured=lambda: True,
|
||||||
|
monitor_movie=AsyncMock(return_value={"id": 44, "monitored": True}),
|
||||||
delete_movie_file=AsyncMock(return_value=None),
|
delete_movie_file=AsyncMock(return_value=None),
|
||||||
search=AsyncMock(return_value={"id": 1}),
|
search=AsyncMock(return_value={"id": 1}),
|
||||||
)
|
)
|
||||||
@@ -1341,6 +1342,7 @@ class MediaReplacementTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result["status"], "ok")
|
self.assertEqual(result["status"], "ok")
|
||||||
|
radarr.monitor_movie.assert_awaited_once_with(44, True)
|
||||||
radarr.delete_movie_file.assert_awaited_once_with(77)
|
radarr.delete_movie_file.assert_awaited_once_with(77)
|
||||||
radarr.search.assert_awaited_once_with(44)
|
radarr.search.assert_awaited_once_with(44)
|
||||||
add_activity.assert_called_once()
|
add_activity.assert_called_once()
|
||||||
@@ -1422,6 +1424,16 @@ class MediaReplacementTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
"episodeFileId": 0,
|
"episodeFileId": 0,
|
||||||
"airDateUtc": "2020-01-08T00:00:00Z",
|
"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(
|
runtime = SimpleNamespace(
|
||||||
@@ -1440,11 +1452,15 @@ class MediaReplacementTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
{"username": "viewer", "role": "user", "auto_search_enabled": True},
|
{"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"])
|
self.assertTrue(result["seasons"][0]["best_fit"])
|
||||||
missing = next(item for item in result["episodes"] if item["id"] == 102)
|
missing = next(item for item in result["episodes"] if item["id"] == 102)
|
||||||
self.assertTrue(missing["missing"])
|
self.assertTrue(missing["missing"])
|
||||||
self.assertTrue(missing["best_fit"])
|
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)
|
collected = next(item for item in result["episodes"] if item["id"] == 101)
|
||||||
self.assertEqual(collected["file_id"], 88)
|
self.assertEqual(collected["file_id"], 88)
|
||||||
self.assertNotIn("file_name", collected)
|
self.assertNotIn("file_name", collected)
|
||||||
@@ -1468,6 +1484,7 @@ class MediaReplacementTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
{"id": 101, "episodeFileId": 88},
|
{"id": 101, "episodeFileId": 88},
|
||||||
{"id": 102, "episodeFileId": 89},
|
{"id": 102, "episodeFileId": 89},
|
||||||
]),
|
]),
|
||||||
|
monitor_episodes=AsyncMock(return_value={"monitored": True}),
|
||||||
delete_episode_file=AsyncMock(return_value=None),
|
delete_episode_file=AsyncMock(return_value=None),
|
||||||
search_episodes=AsyncMock(return_value={"id": 1}),
|
search_episodes=AsyncMock(return_value={"id": 1}),
|
||||||
)
|
)
|
||||||
@@ -1498,9 +1515,87 @@ class MediaReplacementTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(result["file_ids"], [88, 89])
|
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)
|
self.assertEqual(sonarr.delete_episode_file.await_count, 2)
|
||||||
sonarr.search_episodes.assert_awaited_once_with([101, 102])
|
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:
|
async def test_movie_subtitle_issue_starts_bazarr_search_without_replacement(self) -> None:
|
||||||
snapshot = Snapshot(
|
snapshot = Snapshot(
|
||||||
request_id="3914",
|
request_id="3914",
|
||||||
|
|||||||
Reference in New Issue
Block a user