mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 08:57:09 +08:00
fix(servarr): Seerr 新增剧集订阅缺 tmdbId 或空季列表时无法创建订阅 (#6315)
Seerr 的 Sonarr 兼容请求体只携带 tvdbId、不携带 tmdbId,且季列表由 lookup 返回的季列表构造;lookup 季列表为空时请求体季列表为空,旧逻辑 直接返回成功但不创建任何订阅,导致 Seerr 显示请求正常而 MP 未收到订阅。 - 新增剧集订阅时按 tvdbId 补全媒体身份,识别失败返回 500 而非静默成功 - 请求体季列表为空时按已识别季集兜底,识别不到季默认第 1 季 - lookup 在 TVDB 无可用季信息时按 TMDB 季集兜底返回季列表 - 新增回归测试覆盖上述场景
This commit is contained in:
+62
-10
@@ -1,4 +1,4 @@
|
|||||||
from typing import List, Annotated
|
from typing import List, Optional, Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.media import MediaChain
|
||||||
from app.chain.subscribe import SubscribeChain
|
from app.chain.subscribe import SubscribeChain
|
||||||
from app.chain.tvdb import TvdbChain
|
from app.chain.tvdb import TvdbChain
|
||||||
|
from app.domain.context import MediaInfo
|
||||||
from app.domain.metainfo import MetaInfo
|
from app.domain.metainfo import MetaInfo
|
||||||
from app.application.security.access import verify_apikey
|
from app.application.security.access import verify_apikey
|
||||||
from app.db import get_db, get_async_db
|
from app.db import get_db, get_async_db
|
||||||
@@ -31,6 +32,27 @@ def _subscribe_tmdb_id(subscribe: Subscribe) -> int | None:
|
|||||||
return 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(
|
@arr_router.get(
|
||||||
"/system/status",
|
"/system/status",
|
||||||
summary="系统状态",
|
summary="系统状态",
|
||||||
@@ -571,8 +593,6 @@ def arr_series_lookup(
|
|||||||
"""
|
"""
|
||||||
查询Sonarr剧集 term: `tvdb:${id}` title
|
查询Sonarr剧集 term: `tvdb:${id}` title
|
||||||
"""
|
"""
|
||||||
# 季信息
|
|
||||||
seas: List[int] = []
|
|
||||||
# tvdbid 列表
|
# tvdbid 列表
|
||||||
tvdbids: List[int] = []
|
tvdbids: List[int] = []
|
||||||
# 获取TVDBID
|
# 获取TVDBID
|
||||||
@@ -599,8 +619,7 @@ def arr_series_lookup(
|
|||||||
and season["number"] > 0
|
and season["number"] > 0
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
if sea_num:
|
seas = list(range(1, int(sea_num) + 1)) if sea_num else []
|
||||||
seas = list(range(1, int(sea_num) + 1))
|
|
||||||
|
|
||||||
# 根据TVDB查询媒体信息
|
# 根据TVDB查询媒体信息
|
||||||
meta = MetaInfo(tvdbinfo.get("name"))
|
meta = MetaInfo(tvdbinfo.get("name"))
|
||||||
@@ -611,6 +630,9 @@ def arr_series_lookup(
|
|||||||
)
|
)
|
||||||
if not mediainfo:
|
if not mediainfo:
|
||||||
continue
|
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)
|
exists = MediaChain().media_exists(mediainfo)
|
||||||
if exists:
|
if exists:
|
||||||
@@ -722,14 +744,46 @@ async def arr_add_series(
|
|||||||
"""
|
"""
|
||||||
新增Sonarr剧集订阅
|
新增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 = []
|
left_seasons = []
|
||||||
for season in tv.seasons:
|
for season, monitored in seasons:
|
||||||
|
if not monitored:
|
||||||
|
continue
|
||||||
subscribe = await Subscribe.async_exists(
|
subscribe = await Subscribe.async_exists(
|
||||||
db,
|
db,
|
||||||
media_source=MediaSource.TMDB.value,
|
media_source=MediaSource.TMDB.value,
|
||||||
media_id=str(tv.tmdbId),
|
media_id=str(tv.tmdbId),
|
||||||
season=season.seasonNumber,
|
season=season,
|
||||||
)
|
)
|
||||||
if subscribe:
|
if subscribe:
|
||||||
continue
|
continue
|
||||||
@@ -741,12 +795,10 @@ async def arr_add_series(
|
|||||||
sid = 0
|
sid = 0
|
||||||
message = ""
|
message = ""
|
||||||
for season in left_seasons:
|
for season in left_seasons:
|
||||||
if not season.monitored:
|
|
||||||
continue
|
|
||||||
sid, message = await SubscribeChain().async_add(
|
sid, message = await SubscribeChain().async_add(
|
||||||
title=tv.title,
|
title=tv.title,
|
||||||
year=tv.year,
|
year=tv.year,
|
||||||
season=season.seasonNumber,
|
season=season,
|
||||||
media_source=MediaSource.TMDB,
|
media_source=MediaSource.TMDB,
|
||||||
media_id=str(tv.tmdbId),
|
media_id=str(tv.tmdbId),
|
||||||
mtype=MediaType.TV,
|
mtype=MediaType.TV,
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user