feat: add seasons from request details
This commit is contained in:
@@ -273,8 +273,14 @@ def _user_can_use_search_auto(user: Dict[str, Any]) -> bool:
|
||||
|
||||
|
||||
def _filter_snapshot_for_user(snapshot: Snapshot, user: Dict[str, Any]) -> Snapshot:
|
||||
if not _user_can_use_search_auto(user):
|
||||
can_add_seasons = _user_can_use_search_auto(user)
|
||||
if not can_add_seasons:
|
||||
snapshot.actions = [action for action in snapshot.actions if action.id != "search_auto"]
|
||||
pipeline = snapshot.presentation.get("pipeline")
|
||||
if isinstance(pipeline, list):
|
||||
for stage in pipeline:
|
||||
if isinstance(stage, dict) and stage.get("id") == "library":
|
||||
stage["canAddSeasons"] = can_add_seasons
|
||||
if user.get("role") != "admin":
|
||||
# The standard request view is intentionally collaborative, but service payloads can
|
||||
# contain requester identities, internal URLs, download hashes and diagnostic errors.
|
||||
@@ -2474,6 +2480,137 @@ async def action_search_missing_media(
|
||||
return {"status": "ok", "message": message, "episode_ids": searched_ids}
|
||||
|
||||
|
||||
@router.post("/{request_id}/actions/add-seasons")
|
||||
async def action_add_seasons(
|
||||
request_id: str,
|
||||
payload: Dict[str, Any],
|
||||
user: Dict[str, str] = Depends(get_current_user),
|
||||
) -> Dict[str, Any]:
|
||||
if not request_id.isdigit():
|
||||
raise HTTPException(status_code=400, detail="Invalid request id")
|
||||
if not _user_can_use_search_auto(user):
|
||||
raise HTTPException(status_code=403, detail="Adding seasons is disabled for this user")
|
||||
season_numbers = _positive_id_list(
|
||||
payload.get("season_numbers"), field="season_numbers", maximum=100
|
||||
)
|
||||
if not season_numbers:
|
||||
raise HTTPException(status_code=400, detail="Choose at least one season")
|
||||
|
||||
runtime = get_runtime_settings()
|
||||
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
|
||||
if seerr.configured():
|
||||
await _ensure_request_access(seerr, int(request_id), user)
|
||||
snapshot = await build_snapshot(request_id)
|
||||
if snapshot.request_type != RequestType.tv:
|
||||
raise HTTPException(status_code=400, detail="Additional seasons are only available for TV requests")
|
||||
arr_item = snapshot.raw.get("arr", {}).get("item")
|
||||
if not isinstance(arr_item, dict) or not isinstance(arr_item.get("id"), int):
|
||||
raise HTTPException(status_code=404, detail="Series not found in Sonarr")
|
||||
|
||||
sonarr = SonarrClient(runtime.sonarr_base_url, runtime.sonarr_api_key)
|
||||
if not sonarr.configured():
|
||||
raise HTTPException(status_code=400, detail="Sonarr is not configured")
|
||||
series_id = int(arr_item["id"])
|
||||
label = "Add seasons"
|
||||
try:
|
||||
series = await sonarr.get_series(series_id)
|
||||
if not isinstance(series, dict) or not isinstance(series.get("seasons"), list):
|
||||
raise HTTPException(status_code=502, detail="Sonarr did not return the series seasons")
|
||||
known_seasons = {
|
||||
season.get("seasonNumber")
|
||||
for season in series["seasons"]
|
||||
if isinstance(season, dict)
|
||||
and isinstance(season.get("seasonNumber"), int)
|
||||
and season.get("seasonNumber") > 0
|
||||
}
|
||||
if any(season_number not in known_seasons for season_number in season_numbers):
|
||||
raise HTTPException(status_code=409, detail="One or more selected seasons are no longer available in Sonarr")
|
||||
|
||||
updated_seasons = [
|
||||
{**season, "monitored": True}
|
||||
if isinstance(season, dict) and season.get("seasonNumber") in season_numbers
|
||||
else season
|
||||
for season in series["seasons"]
|
||||
]
|
||||
if series.get("monitored") is not True or updated_seasons != series["seasons"]:
|
||||
await sonarr.update_series({**series, "monitored": True, "seasons": updated_seasons})
|
||||
|
||||
episodes = await sonarr.get_episodes(series_id)
|
||||
if not isinstance(episodes, list):
|
||||
raise HTTPException(status_code=502, detail="Sonarr did not return an episode list")
|
||||
selected_episodes = [
|
||||
episode for episode in episodes
|
||||
if isinstance(episode, dict)
|
||||
and episode.get("seasonNumber") in season_numbers
|
||||
and isinstance(episode.get("id"), int)
|
||||
]
|
||||
episode_ids = [int(episode["id"]) for episode in selected_episodes]
|
||||
if episode_ids:
|
||||
await sonarr.monitor_episodes(episode_ids, True)
|
||||
search_ids = [
|
||||
int(episode["id"])
|
||||
for episode in selected_episodes
|
||||
if _released_episode(episode)
|
||||
and episode.get("hasFile") is not True
|
||||
and not (
|
||||
isinstance(episode.get("episodeFileId"), int)
|
||||
and episode.get("episodeFileId") > 0
|
||||
)
|
||||
]
|
||||
if search_ids:
|
||||
await sonarr.search_episodes(search_ids)
|
||||
|
||||
verified_series = await sonarr.get_series(series_id)
|
||||
verified_seasons = verified_series.get("seasons") if isinstance(verified_series, dict) else []
|
||||
verified_season_map = {
|
||||
season.get("seasonNumber"): season.get("monitored")
|
||||
for season in verified_seasons
|
||||
if isinstance(season, dict) and isinstance(season.get("seasonNumber"), int)
|
||||
}
|
||||
if (
|
||||
not isinstance(verified_series, dict)
|
||||
or verified_series.get("monitored") is not True
|
||||
or any(verified_season_map.get(number) is not True for number in season_numbers)
|
||||
):
|
||||
raise HTTPException(status_code=502, detail="Sonarr did not enable monitoring for every selected season")
|
||||
if episode_ids:
|
||||
verified_episodes = await sonarr.get_episodes(series_id)
|
||||
if not isinstance(verified_episodes, list) or any(
|
||||
isinstance(episode, dict)
|
||||
and episode.get("id") in episode_ids
|
||||
and episode.get("monitored") is not True
|
||||
for episode in verified_episodes
|
||||
):
|
||||
raise HTTPException(status_code=502, detail="Sonarr did not enable monitoring for every selected episode")
|
||||
except HTTPException as exc:
|
||||
detail = f"The seasons could not be added: {exc.detail}"
|
||||
await asyncio.to_thread(save_action, request_id, "add_seasons", label, "failed", detail)
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("add seasons failed request_id=%s", request_id)
|
||||
detail = "Sonarr could not add the selected seasons."
|
||||
await asyncio.to_thread(save_action, request_id, "add_seasons", label, "failed", detail)
|
||||
raise HTTPException(status_code=502, detail=detail) from exc
|
||||
|
||||
season_label = ", ".join(str(number) for number in season_numbers)
|
||||
message = f"Season{'s' if len(season_numbers) != 1 else ''} {season_label} added to Sonarr."
|
||||
if search_ids:
|
||||
message += f" Searching for {len(search_ids)} released missing episode{'s' if len(search_ids) != 1 else ''}."
|
||||
elif episode_ids:
|
||||
message += " All known episodes are already collected or have not aired yet."
|
||||
else:
|
||||
message += " New episodes will be monitored when Sonarr discovers them."
|
||||
await asyncio.to_thread(save_action, request_id, "add_seasons", label, "ok", message)
|
||||
fresh_snapshot = _filter_snapshot_for_user(await build_snapshot(request_id), user)
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": message,
|
||||
"season_numbers": season_numbers,
|
||||
"searched_episode_count": len(search_ids),
|
||||
"snapshot": fresh_snapshot,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{request_id}/actions/repair-subtitles")
|
||||
async def action_repair_subtitles(
|
||||
request_id: str,
|
||||
|
||||
@@ -369,6 +369,38 @@ def _episode_availability(episodes: Any) -> Dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _unmonitored_season_options(series: Any, episodes: Any) -> List[Dict[str, int]]:
|
||||
"""Describe regular Sonarr seasons that can be added to an existing request."""
|
||||
if not isinstance(series, dict) or not isinstance(series.get("seasons"), list):
|
||||
return []
|
||||
episode_rows = [episode for episode in episodes if isinstance(episode, dict)] if isinstance(episodes, list) else []
|
||||
options: List[Dict[str, int]] = []
|
||||
for season in series["seasons"]:
|
||||
if not isinstance(season, dict) or season.get("monitored") is not False:
|
||||
continue
|
||||
season_number = season.get("seasonNumber")
|
||||
if not isinstance(season_number, int) or season_number <= 0:
|
||||
continue
|
||||
matching = [episode for episode in episode_rows if episode.get("seasonNumber") == season_number]
|
||||
statistics = season.get("statistics") if isinstance(season.get("statistics"), dict) else {}
|
||||
episode_count = statistics.get("totalEpisodeCount")
|
||||
if not isinstance(episode_count, int):
|
||||
episode_count = statistics.get("episodeCount")
|
||||
if not isinstance(episode_count, int):
|
||||
episode_count = len(matching)
|
||||
available = statistics.get("episodeFileCount")
|
||||
if not isinstance(available, int):
|
||||
available = sum(1 for episode in matching if episode.get("hasFile") is True)
|
||||
options.append(
|
||||
{
|
||||
"seasonNumber": season_number,
|
||||
"episodeCount": max(0, episode_count),
|
||||
"available": max(0, available),
|
||||
}
|
||||
)
|
||||
return sorted(options, key=lambda item: item["seasonNumber"])
|
||||
|
||||
|
||||
def _summarize_qbit(torrents: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
if not torrents:
|
||||
return {"state": "idle", "message": "0 active downloads."}
|
||||
@@ -939,6 +971,7 @@ def _build_presentation(
|
||||
"missing": missing,
|
||||
"total": total,
|
||||
"seasons": availability.get("seasons") or [],
|
||||
"unmonitoredSeasons": arr_details.get("unmonitoredSeasons") or [],
|
||||
"missingEpisodes": arr_details.get("missingEpisodes") or {},
|
||||
},
|
||||
{
|
||||
@@ -1240,6 +1273,7 @@ async def build_snapshot(request_id: str) -> Snapshot:
|
||||
"state": await read_search_status(sonarr, RequestType.tv, series_id, episodes)
|
||||
}
|
||||
arr_details["availability"] = _episode_availability(episodes)
|
||||
arr_details["unmonitoredSeasons"] = _unmonitored_season_options(arr_item, episodes)
|
||||
counts = arr_details["availability"]
|
||||
arr_state = "available" if counts.get("total", 0) > 0 and not counts.get("missing") else "added"
|
||||
missing_by_season = _missing_episode_numbers_by_season(episodes)
|
||||
|
||||
@@ -39,6 +39,7 @@ from backend.app.services.snapshot import (
|
||||
_build_repair_activity,
|
||||
_episode_availability,
|
||||
_torrent_progress,
|
||||
_unmonitored_season_options,
|
||||
)
|
||||
|
||||
|
||||
@@ -567,6 +568,34 @@ class RequestPresentationTests(unittest.TestCase):
|
||||
self.assertEqual(availability["missing"], 1)
|
||||
self.assertEqual(availability["total"], 2)
|
||||
|
||||
def test_unmonitored_seasons_are_offered_separately_from_collection_progress(self) -> None:
|
||||
series = {
|
||||
"seasons": [
|
||||
{"seasonNumber": 0, "monitored": False},
|
||||
{"seasonNumber": 7, "monitored": True},
|
||||
{
|
||||
"seasonNumber": 8,
|
||||
"monitored": False,
|
||||
"statistics": {"episodeCount": 16, "episodeFileCount": 2},
|
||||
},
|
||||
{"seasonNumber": 9, "monitored": False},
|
||||
]
|
||||
}
|
||||
episodes = [
|
||||
{"seasonNumber": 9, "episodeNumber": 1, "hasFile": True},
|
||||
{"seasonNumber": 9, "episodeNumber": 2, "hasFile": False},
|
||||
]
|
||||
|
||||
options = _unmonitored_season_options(series, episodes)
|
||||
|
||||
self.assertEqual(
|
||||
options,
|
||||
[
|
||||
{"seasonNumber": 8, "episodeCount": 16, "available": 2},
|
||||
{"seasonNumber": 9, "episodeCount": 2, "available": 1},
|
||||
],
|
||||
)
|
||||
|
||||
def test_presentation_hides_download_without_download_evidence(self) -> None:
|
||||
snapshot = Snapshot(
|
||||
request_id="3909",
|
||||
@@ -1892,6 +1921,103 @@ class MediaReplacementTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase)
|
||||
sonarr.search_episodes.assert_awaited_once_with([36899])
|
||||
sonarr.search.assert_not_awaited()
|
||||
|
||||
async def test_add_seasons_monitors_series_and_searches_released_missing_episodes(self) -> None:
|
||||
snapshot = Snapshot(
|
||||
request_id="3580",
|
||||
title="Suits",
|
||||
request_type=RequestType.tv,
|
||||
state=NormalizedState.available,
|
||||
raw={"arr": {"item": {"id": 540}}},
|
||||
)
|
||||
refreshed = Snapshot(
|
||||
request_id="3580",
|
||||
title="Suits",
|
||||
request_type=RequestType.tv,
|
||||
state=NormalizedState.importing,
|
||||
presentation={"pipeline": [{"id": "library", "unmonitoredSeasons": []}]},
|
||||
)
|
||||
original_series = {
|
||||
"id": 540,
|
||||
"monitored": True,
|
||||
"qualityProfileId": 7,
|
||||
"seasons": [
|
||||
{"seasonNumber": 7, "monitored": True},
|
||||
{"seasonNumber": 8, "monitored": False},
|
||||
{"seasonNumber": 9, "monitored": False},
|
||||
],
|
||||
}
|
||||
updated_series = {
|
||||
**original_series,
|
||||
"seasons": [
|
||||
{"seasonNumber": 7, "monitored": True},
|
||||
{"seasonNumber": 8, "monitored": True},
|
||||
{"seasonNumber": 9, "monitored": True},
|
||||
],
|
||||
}
|
||||
episodes = [
|
||||
{
|
||||
"id": 801,
|
||||
"seasonNumber": 8,
|
||||
"episodeNumber": 1,
|
||||
"monitored": False,
|
||||
"hasFile": False,
|
||||
"airDateUtc": "2018-07-18T00:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": 802,
|
||||
"seasonNumber": 8,
|
||||
"episodeNumber": 2,
|
||||
"monitored": False,
|
||||
"hasFile": True,
|
||||
"episodeFileId": 88,
|
||||
},
|
||||
{
|
||||
"id": 901,
|
||||
"seasonNumber": 9,
|
||||
"episodeNumber": 1,
|
||||
"monitored": False,
|
||||
"hasFile": False,
|
||||
"airDateUtc": "2019-07-17T00:00:00Z",
|
||||
},
|
||||
]
|
||||
verified_episodes = [{**episode, "monitored": True} for episode in episodes]
|
||||
sonarr = SimpleNamespace(
|
||||
configured=lambda: True,
|
||||
get_series=AsyncMock(side_effect=[original_series, updated_series]),
|
||||
update_series=AsyncMock(return_value=updated_series),
|
||||
get_episodes=AsyncMock(side_effect=[episodes, verified_episodes]),
|
||||
monitor_episodes=AsyncMock(return_value={"monitored": True}),
|
||||
search_episodes=AsyncMock(return_value={"id": 9001}),
|
||||
)
|
||||
runtime = SimpleNamespace(
|
||||
jellyseerr_base_url=None,
|
||||
jellyseerr_api_key=None,
|
||||
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(side_effect=[snapshot, refreshed]),
|
||||
),
|
||||
patch.object(requests_router, "SonarrClient", return_value=sonarr),
|
||||
patch.object(requests_router, "save_action"),
|
||||
):
|
||||
result = await requests_router.action_add_seasons(
|
||||
"3580",
|
||||
{"season_numbers": [8, 9]},
|
||||
{"username": "viewer", "role": "user", "auto_search_enabled": True},
|
||||
)
|
||||
|
||||
self.assertEqual(result["season_numbers"], [8, 9])
|
||||
self.assertEqual(result["searched_episode_count"], 2)
|
||||
self.assertTrue(result["snapshot"].presentation["pipeline"][0]["canAddSeasons"])
|
||||
sonarr.update_series.assert_awaited_once_with(updated_series)
|
||||
sonarr.monitor_episodes.assert_awaited_once_with([801, 802, 901], True)
|
||||
sonarr.search_episodes.assert_awaited_once_with([801, 901])
|
||||
|
||||
async def test_missing_movie_search_monitors_movie_before_search(self) -> None:
|
||||
snapshot = Snapshot(
|
||||
request_id="3914",
|
||||
|
||||
Reference in New Issue
Block a user