Merge pull request #6918 from YuHoYe/fix/subscribe-convert-identity-to-tmdb

fix(subscribe): convert douban/bangumi identity to TMDB on subscribe
This commit is contained in:
jxxghp
2026-10-08 21:28:31 +08:00
committed by GitHub
5 changed files with 350 additions and 6 deletions

View File

@@ -14,11 +14,13 @@ from app.chain.media import MediaChain
from app.chain.subscribe.context import _SubscribeCreateContext, _SubscribePostCommitContext
from app.chain.subscribe.contract import _SubscribeOwnerBase
from app.chain.subscribe.identity import media_recognize_kwargs
from app.domain.context import MediaInfo
from app.domain.meta.metamusic import MetaMusic
from app.domain.metainfo import MetaInfo
from app.runtime.log import logger
from app.runtime.settings import get_runtime_setting
from app.schemas.common import JsonData
from app.schemas.media import resolve_media_identity
from app.schemas.media import normalize_media_source, resolve_media_identity
from app.schemas.types import (
MediaSource,
MediaType,
@@ -193,6 +195,103 @@ class SubscribeCreateOwner(_SubscribeOwnerBase):
if context.season is None:
context.season = meta.begin_season
@staticmethod
def _SubscribeChain__tmdb_conversion_identity(
context: _SubscribeCreateContext,
) -> Optional[Tuple[MediaSource, str]]:
"""识别源为 TMDB 时,返回需要换成 TMDB 整剧身份的影视来源身份;其余情况返回空。"""
mediainfo = context.mediainfo
if not mediainfo or mediainfo.type not in (MediaType.MOVIE, MediaType.TV):
return None
source, media_id = resolve_media_identity(media=mediainfo)
if not source or not media_id or source == MediaSource.TMDB:
return None
if normalize_media_source(get_runtime_setting("RECOGNIZE_SOURCE", None)) != MediaSource.TMDB:
return None
return source, media_id
@staticmethod
def _SubscribeChain__apply_converted_subscribe_media(
context: _SubscribeCreateContext,
source: MediaSource,
media_id: str,
tmdbinfo: Optional[Dict[str, Any]],
converted: Optional[MediaInfo],
) -> None:
"""转换成功才替换订阅目标;豆瓣季条目的季号来自标题或投影结果,整剧身份本身不带季。"""
tmdb_id = tmdbinfo.get("id") if tmdbinfo else None
if not tmdb_id or not converted:
logger.warning(
f"未能将 {source}:{media_id} 转换为 TMDB 身份,订阅沿用原身份:"
f"{context.mediainfo.title_year if context.mediainfo else context.title}"
)
return
if context.season is None and converted.type == MediaType.TV:
projected_season = tmdbinfo.get("season") if tmdbinfo else None
if isinstance(projected_season, int) and projected_season > 0:
context.season = projected_season
logger.info(f"订阅身份已由 {source}:{media_id} 转换为 TMDB {tmdb_id}:{converted.title_year}")
context.mediainfo = converted
def _SubscribeChain__convert_subscribe_media_to_tmdb(self, context: _SubscribeCreateContext) -> None:
"""同步把豆瓣、Bangumi 等影视身份换成 TMDB,和豆瓣想看、豆瓣榜单的订阅口径一致。
非 TMDB 来源把每一季当成独立条目,标题和年份都是季级的;若直接作为订阅主身份,
下载记录与整理会按季标题生成独立剧集目录,媒体库里就会出现多部同名剧。
"""
identity = self._SubscribeChain__tmdb_conversion_identity(context)
mediainfo = context.mediainfo
if not identity or mediainfo is None:
return
source, media_id = identity
tmdbinfo = MediaChain().convert_media_identity(
target_source=MediaSource.TMDB,
media_source=source,
media_id=media_id,
mtype=mediainfo.type,
season=context.season,
)
converted = None
if tmdbinfo and tmdbinfo.get("id"):
converted = MediaChain().recognize_media(
meta=context.metainfo,
mtype=mediainfo.type,
media_source=MediaSource.TMDB,
media_id=str(tmdbinfo.get("id")),
episode_group=context.episode_group,
cache=False,
)
self._SubscribeChain__apply_converted_subscribe_media(context, source, media_id, tmdbinfo, converted)
async def _SubscribeChain__async_convert_subscribe_media_to_tmdb(
self,
context: _SubscribeCreateContext,
) -> None:
"""异步版本的 TMDB 身份转换,与同步入口共享判定和替换规则。"""
identity = self._SubscribeChain__tmdb_conversion_identity(context)
mediainfo = context.mediainfo
if not identity or mediainfo is None:
return
source, media_id = identity
tmdbinfo = await MediaChain().async_convert_media_identity(
target_source=MediaSource.TMDB,
media_source=source,
media_id=media_id,
mtype=mediainfo.type,
season=context.season,
)
converted = None
if tmdbinfo and tmdbinfo.get("id"):
converted = await MediaChain().async_recognize_media(
meta=context.metainfo,
mtype=mediainfo.type,
media_source=MediaSource.TMDB,
media_id=str(tmdbinfo.get("id")),
episode_group=context.episode_group,
cache=False,
)
self._SubscribeChain__apply_converted_subscribe_media(context, source, media_id, tmdbinfo, converted)
def _SubscribeChain__recognize_subscribe_media(self, context: _SubscribeCreateContext) -> Optional[str]:
"""同步识别订阅目标;显式身份失败时禁止按标题换成另一个媒体。"""
if context.media_source and context.media_id:
@@ -216,6 +315,7 @@ class SubscribeCreateOwner(_SubscribeOwnerBase):
)
if context.mtype == MediaType.MUSIC and context.mediainfo and not context.mediainfo.media_source:
context.mediainfo = None
self._SubscribeChain__convert_subscribe_media_to_tmdb(context)
return self._SubscribeChain__validate_recognized_subscribe_media(context)
async def _SubscribeChain__async_recognize_subscribe_media(
@@ -244,6 +344,7 @@ class SubscribeCreateOwner(_SubscribeOwnerBase):
)
if context.mtype == MediaType.MUSIC and context.mediainfo and not context.mediainfo.media_source:
context.mediainfo = None
await self._SubscribeChain__async_convert_subscribe_media_to_tmdb(context)
return self._SubscribeChain__validate_recognized_subscribe_media(context)
def _SubscribeChain__validate_recognized_subscribe_media(

View File

@@ -763,7 +763,7 @@ flowchart LR
| 指标 | 当前值 |
|---|---:|
| Python 模块 | 1113 |
| 内部导入边 | 9,413 |
| 内部导入边 | 9,415 |
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
| Direct egress | 49(债务已清零,49 条精确 containment) |
@@ -773,7 +773,7 @@ flowchart LR
| Model/Oper 自动事务与自建 Session | 0 |
| 组合根外 `SystemConfigOper()` | 0 |
整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责,预览证据由 `app.application.transfer.projection` 只读投影;宿主依赖边现为 9,413,音乐来源链与识别缓存复用领域身份核验,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。Web Push 协议(aes128gcm 加密、VAPID 签名与经 `RequestUtils` 投递)由网络适配器 `app.adapters.network.webpush` 直接基于 http-ece 与 py-vapid 实现,消息端点与 `WebPushModule` 共用,宿主不再导入 pywebpush 及其连带的 aiohttp;该依赖同样暂时保留给插件使用。飞书渠道改用模块内 `app.modules.feishu.openapi` OpenAPI 客户端(经 `RequestUtils` 出站)与网络适配器 `app.adapters.network.feishu` 事件长连接(websocket-client 同步收发 pbbp2 帧),宿主不再导入 lark-oapi 及其上万个生成模块,长连接也不再依赖 SDK 的模块级事件循环;插件经 `app.sdk.feishu` 复用同一长连接,lark-oapi 暂时保留给尚未迁移的插件使用。API 文档路由由 `app.adapters.web.docs` 按 `API_DOCS_ENABLE` 逐请求开放,默认关闭,未开放时不生成文档。当前 `app.startup.lifecycle` 为 582、`app.factory` 为 595、`app.main` 为 597。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。
整理失败反馈由 `app.application.transfer.feedback` 集中投影;已整理下载的站点字幕复用 `app.chain.transfer` 自动整理队列;网络连通性检测按 `app.application.nettest` 归档,并覆盖 HTTP 与 WebSocket 探测。未启用智能助手的消息音乐交互由 `app.chain.music_interaction` 包中的解析展示与订阅协调模块处理;音乐标签准入与批次共识由 `app.chain.transfer.music` 负责,预览证据由 `app.application.transfer.projection` 只读投影;宿主依赖边现为 9,415,音乐来源链与识别缓存复用领域身份核验,音乐交互包未新增 SCC,插件一次性任务 owner `app.scheduler.oncejob` 带来 1 个模块和 9 条依赖边。Agent 持久回执新增 Application 端口及 DB Model/Oper/Adapter 四个冷导入模块。缓存组合根只在启动时选用 Redis 缓存才导入 Redis 适配器,依赖 langchain_core 的对话记忆模型归属 `app.agent.memory`,文件缓存且未启用智能体的实例不再加载 redis 与 langchain_core。Telegram 渠道改用模块内 `app.modules.telegram.botapi` 精简 Bot API 客户端,经 `RequestUtils` 出站,宿主不再导入 pyTelegramBotAPI,启用 Telegram 的实例也不会因该 SDK 加载 redis;该依赖暂时保留给插件使用。Web Push 协议(aes128gcm 加密、VAPID 签名与经 `RequestUtils` 投递)由网络适配器 `app.adapters.network.webpush` 直接基于 http-ece 与 py-vapid 实现,消息端点与 `WebPushModule` 共用,宿主不再导入 pywebpush 及其连带的 aiohttp;该依赖同样暂时保留给插件使用。飞书渠道改用模块内 `app.modules.feishu.openapi` OpenAPI 客户端(经 `RequestUtils` 出站)与网络适配器 `app.adapters.network.feishu` 事件长连接(websocket-client 同步收发 pbbp2 帧),宿主不再导入 lark-oapi 及其上万个生成模块,长连接也不再依赖 SDK 的模块级事件循环;插件经 `app.sdk.feishu` 复用同一长连接,lark-oapi 暂时保留给尚未迁移的插件使用。API 文档路由由 `app.adapters.web.docs` 按 `API_DOCS_ENABLE` 逐请求开放,默认关闭,未开放时不生成文档。当前 `app.startup.lifecycle` 为 582、`app.factory` 为 595、`app.main` 为 597。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。
架构专项验证分为两个 CI 投影:`Check event semantic policy` 先运行依赖、Adapter、出口和 Event
语义门禁,`Check host architecture snapshot` 再执行快照测试及一次

View File

@@ -98,7 +98,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
| 指标 | 当前值 | 解释 |
|---|---:|---|
| 宿主 Python 模块 / 内部依赖边 | 1113 / 9,413 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档;LLM 独占连接关闭复用宿主线程池;歌词字形转换复用 `app.foundation.text` |
| 宿主 Python 模块 / 内部依赖边 | 1113 / 9,415 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档;LLM 独占连接关闭复用宿主线程池;歌词字形转换复用 `app.foundation.text`;订阅创建使用 `app.domain.context.MediaInfo` 和运行时配置端口,将非 TMDB 影视身份投影为 TMDB 身份 |
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |

View File

@@ -941,8 +941,8 @@
"runtime_only": true
}
},
"edge_count": 9413,
"edge_sha256": "17537238149bf3be2a7807e8ed6cf498227cf5e7265f7862b5cec3e327c6a748",
"edge_count": 9415,
"edge_sha256": "cf827acc39adbf6e1080ce8c09922c286c03423990dd331d90df30d9b59097e4",
"edges": [
"app -> app.foundation",
"app -> app.foundation.environment",
@@ -5339,11 +5339,13 @@
"app.chain.subscribe.create -> app.chain.subscribe.contract",
"app.chain.subscribe.create -> app.chain.subscribe.identity",
"app.chain.subscribe.create -> app.domain",
"app.chain.subscribe.create -> app.domain.context",
"app.chain.subscribe.create -> app.domain.meta",
"app.chain.subscribe.create -> app.domain.meta.metamusic",
"app.chain.subscribe.create -> app.domain.metainfo",
"app.chain.subscribe.create -> app.runtime",
"app.chain.subscribe.create -> app.runtime.log",
"app.chain.subscribe.create -> app.runtime.settings",
"app.chain.subscribe.create -> app.schemas",
"app.chain.subscribe.create -> app.schemas.common",
"app.chain.subscribe.create -> app.schemas.media",

View File

@@ -576,6 +576,8 @@ def _load_subscribe_chain_class():
search_owner,
),
"add_subscribe": (create_owner,),
"async_add_subscribe": (create_owner,),
"get_runtime_setting": (create_owner,),
},
)
@@ -2920,7 +2922,10 @@ class TestSubscribeProgressEntrypoint:
class TestSubscribeProgressConsolidation:
"""验证订阅创建、季集刷新及下载进度汇总的字段一致性。"""
def _mediainfo(self, total_episode=5):
"""构造具有统一影视身份和季集信息的识别结果。"""
return SimpleNamespace(
type=MediaType.TV,
seasons={1: [object() for _ in range(total_episode)]},
@@ -2941,9 +2946,11 @@ class TestSubscribeProgressConsolidation:
@staticmethod
def _event_manager(total_episode=None, *, updated=True):
"""记录订阅事件,并模拟外部集数刷新结果。"""
captured = []
def _apply(event_type, event_data):
"""仅在集数刷新事件中填充扩展结果。"""
captured.append((event_type, event_data))
if hasattr(event_data, "current_total_episode"):
event_data.updated = updated
@@ -2953,10 +2960,14 @@ class TestSubscribeProgressConsolidation:
return SimpleNamespace(event_data=event_data)
class _EventManager:
"""提供同步和异步一致的隔离事件入口。"""
def send_event(self, event_type, event_data):
"""记录同步事件并返回刷新结果。"""
return _apply(event_type, event_data)
async def async_send_event(self, event_type, event_data):
"""记录异步事件并返回刷新结果。"""
return _apply(event_type, event_data)
return _EventManager(), captured
@@ -3717,6 +3728,236 @@ class TestSubscribeProgressConsolidation:
assert added[-1]["total_episode"] == 10
assert added[-1]["lack_episode"] == 10
def _identity_mediainfo(self, media_source, media_id, title, year, seasons=(1,), total_episode=9):
"""按来源构造识别结果,季集结构与 TMDB 整剧或豆瓣季条目保持一致。"""
mediainfo = self._mediainfo(total_episode=total_episode)
mediainfo.media_source = media_source
mediainfo.media_id = media_id
mediainfo.title = title
mediainfo.year = year
mediainfo.title_year = f"{title} ({year})"
mediainfo.seasons = {number: [object() for _ in range(total_episode)] for number in seasons}
return mediainfo
def _conversion_media_chain(self, module, tmdbinfo, *, allow_convert=True, tmdb_available=True):
"""按请求来源分发识别结果,并记录身份转换调用,供订阅创建的转换用例复用。"""
douban = self._identity_mediainfo("douban", "35932853", "绝望写手 第三季", "2024", seasons=(1,))
tmdb = self._identity_mediainfo("themoviedb", "124101", "绝望写手", "2021", seasons=(1, 2, 3, 4, 5))
calls = {"convert": [], "recognize": []}
def _recognize(**kwargs):
"""根据目标来源返回原身份或 TMDB 二次识别结果。"""
calls["recognize"].append(kwargs)
if kwargs.get("media_source") == MediaSource.TMDB:
return tmdb if tmdb_available else None
return douban
def _convert(**kwargs):
"""记录身份投影参数,禁止无需转换的目标触发调用。"""
assert allow_convert, "recognize source is not TMDB, identity must not be converted"
calls["convert"].append(kwargs)
return tmdbinfo
async def _async_recognize(**kwargs):
"""以相同规则模拟异步识别。"""
return _recognize(**kwargs)
async def _async_convert(**kwargs):
"""以相同规则模拟异步身份投影。"""
return _convert(**kwargs)
media_chain = SimpleNamespace(
recognize_media=_recognize,
async_recognize_media=_async_recognize,
convert_media_identity=_convert,
async_convert_media_identity=_async_convert,
supplement_media_info=lambda mediainfo: mediainfo,
)
return patch.object(module, "MediaChain", return_value=media_chain), calls
def _add_douban_season(
self, module, SubscribeChain, tmdbinfo, recognize_source="themoviedb", allow_convert=True,
*, tmdb_available=True, season=None, async_mode=False,
):
"""以豆瓣季条目创建电视剧订阅,返回落库字段和媒体链调用记录。"""
added = []
eventmanager, _ = self._event_manager(9)
chain = SubscribeChain()
chain.obtain_images = lambda **_kwargs: None
chain.async_obtain_images = AsyncMock(return_value=None)
def _add_subscribe(**kwargs):
"""记录最终订阅写入字段。"""
added.append(kwargs)
return 51, None
async_add_subscribe = AsyncMock(side_effect=_add_subscribe)
media_patch, calls = self._conversion_media_chain(
module, tmdbinfo, allow_convert=allow_convert, tmdb_available=tmdb_available,
)
with (
patch.object(module, "add_subscribe", _add_subscribe),
patch.object(module, "async_add_subscribe", async_add_subscribe),
patch.object(module, "eventmanager", eventmanager),
patch.object(module, "get_runtime_setting", lambda _key, _default=None: recognize_source),
media_patch,
):
kwargs = dict(
title="绝望写手 第三季",
year="2024",
mtype=MediaType.TV,
media_source="douban",
media_id="35932853",
season=season,
message=False,
)
sid, err_msg = asyncio.run(chain.async_add(**kwargs)) if async_mode else chain.add(**kwargs)
assert sid == 51
assert err_msg is None
return added[-1], calls
def test_add_converts_douban_season_identity_to_tmdb_series(self):
"""识别源为 TMDB 时,豆瓣季条目订阅必须落为 TMDB 整剧身份并保留季号。"""
module, SubscribeChain = _load_subscribe_chain_class()
added, calls = self._add_douban_season(module, SubscribeChain, {"id": 124101, "media_type": "tv", "season": 3})
assert added["media_source"] == "themoviedb"
assert added["media_id"] == "124101"
assert added["season"] == 3
assert added["mediainfo"].title == "绝望写手"
assert added["total_episode"] == 9
assert calls["convert"][0]["media_source"] == MediaSource.Douban
assert calls["convert"][0]["media_id"] == "35932853"
assert calls["convert"][0]["season"] == 3
assert calls["recognize"][-1]["media_source"] == MediaSource.TMDB
assert calls["recognize"][-1]["media_id"] == "124101"
def test_add_keeps_douban_identity_when_tmdb_conversion_fails(self):
"""转换失败时沿用原身份,保持历史行为而不是拒绝订阅。"""
module, SubscribeChain = _load_subscribe_chain_class()
added, calls = self._add_douban_season(module, SubscribeChain, None)
assert added["media_source"] == "douban"
assert added["media_id"] == "35932853"
assert added["season"] == 3
assert added["mediainfo"].title == "绝望写手"
assert len(calls["convert"]) == 1
assert all(call.get("media_source") != MediaSource.TMDB for call in calls["recognize"])
def test_add_skips_tmdb_conversion_when_recognize_source_is_not_tmdb(self):
"""识别源不是 TMDB 时沿用用户选定的来源身份,不触发转换。"""
module, SubscribeChain = _load_subscribe_chain_class()
added, calls = self._add_douban_season(
module,
SubscribeChain,
{"id": 124101},
recognize_source="douban",
allow_convert=False,
)
assert added["media_source"] == "douban"
assert added["media_id"] == "35932853"
assert calls["convert"] == []
@pytest.mark.parametrize("async_mode", [False, True])
def test_add_keeps_original_identity_when_converted_tmdb_cannot_be_recognized(self, async_mode):
"""TMDB 投影命中但二次识别失败时,同步和异步创建都保留原身份及季号。"""
module, SubscribeChain = _load_subscribe_chain_class()
added, calls = self._add_douban_season(
module, SubscribeChain, {"id": 124101, "season": 3},
tmdb_available=False, async_mode=async_mode,
)
assert added["media_source"] == "douban"
assert added["media_id"] == "35932853"
assert added["season"] == 3
assert len(calls["convert"]) == 1
assert calls["recognize"][-1]["media_source"] == MediaSource.TMDB
@pytest.mark.parametrize("async_mode", [False, True])
def test_add_preserves_explicit_season_during_tmdb_conversion(self, async_mode):
"""显式季号优先于标题和投影季号,配置的 TMDB 别名也应触发转换。"""
module, SubscribeChain = _load_subscribe_chain_class()
added, calls = self._add_douban_season(
module, SubscribeChain, {"id": 124101, "season": 3},
recognize_source="tmdb", season=4, async_mode=async_mode,
)
assert added["media_source"] == "themoviedb"
assert added["media_id"] == "124101"
assert added["season"] == 4
assert calls["convert"][0]["season"] == 4
assert calls["recognize"][-1]["mtype"] == MediaType.TV
assert calls["recognize"][-1]["cache"] is False
@pytest.mark.parametrize("async_mode", [False, True])
@pytest.mark.parametrize("target", ["missing", "tmdb", "music"])
def test_tmdb_conversion_skips_missing_tmdb_and_music_targets(self, target, async_mode):
"""无识别结果、已有 TMDB 身份及音乐目标均不得调用影视身份转换。"""
module, SubscribeChain = _load_subscribe_chain_class()
chain = SubscribeChain()
mediainfo = None
if target != "missing":
mediainfo = self._identity_mediainfo(
"themoviedb" if target == "tmdb" else "musicbrainz", "124101", "标题", "2024",
)
if target == "music":
mediainfo.type = MediaType.MUSIC
context = SimpleNamespace(mediainfo=mediainfo)
media_patch, calls = self._conversion_media_chain(module, {"id": 124101}, allow_convert=False)
with (
patch.object(module, "get_runtime_setting", lambda _key, _default=None: "themoviedb"),
media_patch,
):
if async_mode:
asyncio.run(chain._SubscribeChain__async_convert_subscribe_media_to_tmdb(context))
else:
chain._SubscribeChain__convert_subscribe_media_to_tmdb(context)
assert context.mediainfo is mediainfo
assert calls == {"convert": [], "recognize": []}
def test_async_convert_subscribe_media_to_tmdb_replaces_identity_and_fills_season(self):
"""异步入口同样换成 TMDB 身份;季号缺失时采用投影结果给出的季。"""
module, SubscribeChain = _load_subscribe_chain_class()
chain = SubscribeChain()
context, error = chain._SubscribeChain__build_subscribe_create_context(
"绝望写手",
"2024",
MediaType.TV,
None,
None,
None,
None,
None,
None,
False,
False,
"douban",
"35932853",
{},
)
assert error is None
context.mediainfo = self._identity_mediainfo("douban", "35932853", "绝望写手", "2024")
media_patch, calls = self._conversion_media_chain(module, {"id": 124101, "season": 3})
with (
patch.object(module, "get_runtime_setting", lambda _key, _default=None: "themoviedb"),
media_patch,
):
asyncio.run(chain._SubscribeChain__async_convert_subscribe_media_to_tmdb(context))
assert context.mediainfo.media_source == "themoviedb"
assert context.mediainfo.media_id == "124101"
assert context.season == 3
assert calls["convert"][0]["season"] is None
def test_completed_episode_uses_schema_function_directly_for_best_version(self):
module, SubscribeChain = _load_subscribe_chain_class()
values = {