From a8dc6e99732c1b139b350992737f81b4b3c53537 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sat, 15 Aug 2026 10:56:50 +0800 Subject: [PATCH] =?UTF-8?q?fix(servarr):=20Seerr=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=89=A7=E9=9B=86=E8=AE=A2=E9=98=85=E7=BC=BA=20tmdbId=20?= =?UTF-8?q?=E6=88=96=E7=A9=BA=E5=AD=A3=E5=88=97=E8=A1=A8=E6=97=B6=E6=97=A0?= =?UTF-8?q?=E6=B3=95=E5=88=9B=E5=BB=BA=E8=AE=A2=E9=98=85=20(#6315)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seerr 的 Sonarr 兼容请求体只携带 tvdbId、不携带 tmdbId,且季列表由 lookup 返回的季列表构造;lookup 季列表为空时请求体季列表为空,旧逻辑 直接返回成功但不创建任何订阅,导致 Seerr 显示请求正常而 MP 未收到订阅。 - 新增剧集订阅时按 tvdbId 补全媒体身份,识别失败返回 500 而非静默成功 - 请求体季列表为空时按已识别季集兜底,识别不到季默认第 1 季 - lookup 在 TVDB 无可用季信息时按 TMDB 季集兜底返回季列表 - 新增回归测试覆盖上述场景 --- app/api/servarr.py | 72 ++++++++++++-- tests/test_servarr_series_add.py | 165 +++++++++++++++++++++++++++++++ 2 files changed, 227 insertions(+), 10 deletions(-) create mode 100644 tests/test_servarr_series_add.py diff --git a/app/api/servarr.py b/app/api/servarr.py index dc7abb51c..8c3dadb29 100644 --- a/app/api/servarr.py +++ b/app/api/servarr.py @@ -1,4 +1,4 @@ -from typing import List, Annotated +from typing import List, Optional, Annotated from fastapi import APIRouter, HTTPException, Depends from sqlalchemy.ext.asyncio import AsyncSession @@ -9,6 +9,7 @@ from app.api.response import ERROR_RESPONSES from app.chain.media import MediaChain from app.chain.subscribe import SubscribeChain from app.chain.tvdb import TvdbChain +from app.domain.context import MediaInfo from app.domain.metainfo import MetaInfo from app.application.security.access import verify_apikey from app.db import get_db, get_async_db @@ -31,6 +32,27 @@ def _subscribe_tmdb_id(subscribe: Subscribe) -> int | None: return None +def _resolve_series_media( + tvdbid: Optional[int] = None, + title: Optional[str] = None, + year: Optional[str | int] = None, +) -> Optional[MediaInfo]: + """按 TVDB ID 或标题解析剧集媒体信息,用于补全 Seerr 请求体中缺失的媒体身份。""" + meta = None + if tvdbid: + tvdbinfo = MediaChain().tvdb_info(tvdbid=tvdbid) + if tvdbinfo and tvdbinfo.get("name"): + meta = MetaInfo(tvdbinfo.get("name")) + if not meta and title: + meta = MetaInfo(title) + if not meta: + return None + meta.type = MediaType.TV + if year: + meta.year = year + return MediaChain().recognize_by_meta(meta, obtain_images=False) + + @arr_router.get( "/system/status", summary="系统状态", @@ -571,8 +593,6 @@ def arr_series_lookup( """ 查询Sonarr剧集 term: `tvdb:${id}` title """ - # 季信息 - seas: List[int] = [] # tvdbid 列表 tvdbids: List[int] = [] # 获取TVDBID @@ -599,8 +619,7 @@ def arr_series_lookup( and season["number"] > 0 ] ) - if sea_num: - seas = list(range(1, int(sea_num) + 1)) + seas = list(range(1, int(sea_num) + 1)) if sea_num else [] # 根据TVDB查询媒体信息 meta = MetaInfo(tvdbinfo.get("name")) @@ -611,6 +630,9 @@ def arr_series_lookup( ) if not mediainfo: continue + # TVDB 未提供可用季信息时,按 TMDB 季集兜底,避免 Seerr 请求体季列表为空 + if not seas and mediainfo.seasons: + seas = [season for season in mediainfo.seasons if season > 0] # 查询是否存在 exists = MediaChain().media_exists(mediainfo) if exists: @@ -722,14 +744,46 @@ async def arr_add_series( """ 新增Sonarr剧集订阅 """ + # Seerr 的请求体只携带 tvdbId、不携带 tmdbId,缺失时按 TVDB 信息补全媒体身份; + # 请求体季列表由 lookup 返回的季列表构造,lookup 季列表为空时请求体也会为空,此时按识别季集兜底 + mediainfo = None + if not tv.tmdbId or not tv.seasons: + mediainfo = _resolve_series_media( + tvdbid=tv.tvdbId, title=tv.title, year=tv.year + ) + if not tv.tmdbId: + if not mediainfo: + raise HTTPException(status_code=500, detail="添加订阅失败:未识别到媒体信息") + tv.tmdbId = mediainfo.tmdb_id + if mediainfo: + if not tv.title: + tv.title = mediainfo.title + if not tv.year: + tv.year = mediainfo.year + # 提取请求季与监控标记,排除特别季 + seasons = [ + (season.seasonNumber, season.monitored) + for season in tv.seasons + if season.seasonNumber + ] + if not seasons: + # 请求体未携带季信息时,订阅已识别的全部季,识别不到季则默认第 1 季 + fallback_seasons = ( + [season for season in (mediainfo.seasons or {}) if season > 0] + if mediainfo + else [] + ) + seasons = [(season, True) for season in (fallback_seasons or [1])] # 检查订阅是否存在 left_seasons = [] - for season in tv.seasons: + for season, monitored in seasons: + if not monitored: + continue subscribe = await Subscribe.async_exists( db, media_source=MediaSource.TMDB.value, media_id=str(tv.tmdbId), - season=season.seasonNumber, + season=season, ) if subscribe: continue @@ -741,12 +795,10 @@ async def arr_add_series( sid = 0 message = "" for season in left_seasons: - if not season.monitored: - continue sid, message = await SubscribeChain().async_add( title=tv.title, year=tv.year, - season=season.seasonNumber, + season=season, media_source=MediaSource.TMDB, media_id=str(tv.tmdbId), mtype=MediaType.TV, diff --git a/tests/test_servarr_series_add.py b/tests/test_servarr_series_add.py new file mode 100644 index 000000000..234a0f52f --- /dev/null +++ b/tests/test_servarr_series_add.py @@ -0,0 +1,165 @@ +""" +Seerr 的 Sonarr 兼容端点回归测试。 + +Seerr 提交剧集请求时,POST /api/v3/series 请求体不携带 tmdbId、只携带 tvdbId, +且季列表由 lookup 接口返回的季列表构造;lookup 未返回季信息时请求体季列表为空。 +本测试保证新增剧集订阅在以上两种情况下都能正常创建订阅,且不会静默成功。 +""" +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import HTTPException + +from app.api.servarr import arr_add_series, arr_series_lookup +from app.schemas import SonarrSeason, SonarrSeries +from app.schemas.types import MediaSource, MediaType + +_TVDB_ID = 454898 +_TMDB_ID = 236534 + + +def _fake_mediainfo(tmdb_id=_TMDB_ID, seasons=None): + """构造最小可用的媒体识别结果。""" + return SimpleNamespace( + tmdb_id=tmdb_id, + title="Tales of Herding Gods", + year=2024, + imdb_id=None, + seasons=seasons or {1: [1, 2, 3]}, + get_poster_image=lambda: None, + ) + + +def _series(tmdb_id=None, seasons=None): + """构造 Seerr 风格的剧集请求体,默认不携带 tmdbId。""" + return SonarrSeries( + id=None, + title="Tales of Herding Gods", + tvdbId=_TVDB_ID, + tmdbId=tmdb_id, + year=2024, + seasons=seasons or [], + ) + + +def _run_add(tv): + """直接调用新增剧集订阅处理函数。""" + return asyncio.run(arr_add_series(tv=tv, _="api-token", db=object())) + + +def _patch_chains(mediainfo=None, exists=None, add_result=(123, "")): + """统一 patch 媒体链、订阅链与订阅查询。""" + media_chain = MagicMock() + media_chain.tvdb_info.return_value = { + "name": "Tales of Herding Gods", + "seasons": [{"type": {"id": "default"}, "number": 1}], + "defaultSeasonType": "default", + } + media_chain.recognize_by_meta.return_value = mediainfo + subscribe_chain = MagicMock() + subscribe_chain.async_add = AsyncMock(return_value=add_result) + return patch( + "app.api.servarr.MediaChain", + return_value=media_chain, + ), patch( + "app.api.servarr.SubscribeChain", + return_value=subscribe_chain, + ), patch( + "app.api.servarr.Subscribe.async_exists", + new=AsyncMock(return_value=exists), + ), subscribe_chain + + +def test_add_series_without_tmdbid_resolves_identity_via_tvdbid(): + """Seerr 请求体不携带 tmdbId 时,应按 tvdbId 补全媒体身份并创建订阅。""" + tv = _series(seasons=[SonarrSeason(seasonNumber=1, monitored=True)]) + media_patch, chain_patch, exists_patch, subscribe_chain = _patch_chains( + mediainfo=_fake_mediainfo() + ) + with media_patch, chain_patch, exists_patch: + result = _run_add(tv) + + assert result.id == 123 + subscribe_chain.async_add.assert_awaited_once_with( + title="Tales of Herding Gods", + year=2024, + season=1, + media_source=MediaSource.TMDB, + media_id=str(_TMDB_ID), + mtype=MediaType.TV, + username="Seerr", + ) + + +def test_add_series_with_empty_seasons_falls_back_to_all_seasons(): + """请求体季列表为空时不应静默成功,应兜底订阅已识别的全部季。""" + tv = _series() + media_patch, chain_patch, exists_patch, subscribe_chain = _patch_chains( + mediainfo=_fake_mediainfo(seasons={1: [1, 2, 3], 2: [1]}) + ) + subscribe_chain.async_add = AsyncMock(side_effect=[(100, ""), (101, "")]) + with media_patch, chain_patch, exists_patch: + result = _run_add(tv) + + assert result.id == 101 + assert subscribe_chain.async_add.await_count == 2 + assert subscribe_chain.async_add.await_args_list[0].kwargs["season"] == 1 + assert subscribe_chain.async_add.await_args_list[1].kwargs["season"] == 2 + + +def test_add_series_already_subscribed_returns_existing(): + """全部请求季已存在订阅时,返回已有标识且不重复创建。""" + tv = _series( + tmdb_id=_TMDB_ID, + seasons=[SonarrSeason(seasonNumber=1, monitored=True)], + ) + media_patch, chain_patch, exists_patch, subscribe_chain = _patch_chains( + exists=SimpleNamespace(id=9) + ) + with media_patch, chain_patch, exists_patch: + result = _run_add(tv) + + assert result.id == 1 + subscribe_chain.async_add.assert_not_awaited() + + +def test_add_series_identity_resolution_failure_returns_500(): + """媒体身份补全失败时返回 500,避免 Seerr 误判请求已成功。""" + tv = _series() + media_patch, chain_patch, exists_patch, _ = _patch_chains(mediainfo=None) + with media_patch, chain_patch, exists_patch: + with pytest.raises(HTTPException) as excinfo: + _run_add(tv) + + assert excinfo.value.status_code == 500 + + +def test_series_lookup_falls_back_to_tmdb_seasons(): + """TVDB 未提供可用季信息时,lookup 应按 TMDB 季集兜底返回季列表。""" + media_chain = MagicMock() + media_chain.tvdb_info.return_value = { + "name": "Tales of Herding Gods", + "seasons": [], + "defaultSeasonType": "default", + } + media_chain.recognize_by_meta.return_value = _fake_mediainfo( + seasons={1: [1, 2, 3], 2: [1]} + ) + media_chain.media_exists.return_value = False + with patch( + "app.api.servarr.TvdbChain", + return_value=MagicMock(get_tvdbid_by_name=MagicMock(return_value=[_TVDB_ID])), + ), patch( + "app.api.servarr.MediaChain", + return_value=media_chain, + ), patch( + "app.api.servarr.Subscribe.list_by_media_identity", + return_value=[], + ): + result = arr_series_lookup(term=f"tvdb:{_TVDB_ID}", _="api-token", db=object()) + + assert len(result) == 1 + assert [season.seasonNumber for season in result[0].seasons] == [1, 2] + assert all(not season.monitored for season in result[0].seasons) \ No newline at end of file