From d42c2172867bf4f9eec84daac1bba05408cddccd Mon Sep 17 00:00:00 2001 From: YuHoYe Date: Thu, 8 Oct 2026 20:22:02 +0800 Subject: [PATCH 1/2] fix(subscribe): convert douban/bangumi identity to TMDB on subscribe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 识别源为 TMDB 时,手动订阅电视剧或电影若传入豆瓣、Bangumi 等非 TMDB 身份, 订阅链现在会先通过 convert_media_identity 换成 TMDB 整剧身份,再按 TMDB 识别, 与豆瓣想看、豆瓣榜单插件的订阅口径保持一致。 非 TMDB 来源把每一季当作独立条目,标题和年份都是季级的。此前手动订阅只拆出季号、 仍以季条目作为主身份,下载记录与整理随之按季标题生成独立剧集目录,媒体库里出现 多部同名剧。转换失败时沿用原身份,识别源不是 TMDB 时不做转换。 --- app/chain/subscribe/create.py | 101 +++++++++++++++++++++- tests/test_subscribe_chain.py | 155 ++++++++++++++++++++++++++++++++++ 2 files changed, 255 insertions(+), 1 deletion(-) diff --git a/app/chain/subscribe/create.py b/app/chain/subscribe/create.py index 875f2b78c..2c18373fb 100644 --- a/app/chain/subscribe/create.py +++ b/app/chain/subscribe/create.py @@ -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,101 @@ 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) + if not identity: + return + source, media_id = identity + tmdbinfo = MediaChain().convert_media_identity( + target_source=MediaSource.TMDB, + media_source=source, + media_id=media_id, + mtype=context.mediainfo.type, + season=context.season, + ) + converted = None + if tmdbinfo and tmdbinfo.get("id"): + converted = MediaChain().recognize_media( + meta=context.metainfo, + mtype=context.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) + if not identity: + return + source, media_id = identity + tmdbinfo = await MediaChain().async_convert_media_identity( + target_source=MediaSource.TMDB, + media_source=source, + media_id=media_id, + mtype=context.mediainfo.type, + season=context.season, + ) + converted = None + if tmdbinfo and tmdbinfo.get("id"): + converted = await MediaChain().async_recognize_media( + meta=context.metainfo, + mtype=context.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 +313,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 +342,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( diff --git a/tests/test_subscribe_chain.py b/tests/test_subscribe_chain.py index a0835b2eb..572effe0a 100644 --- a/tests/test_subscribe_chain.py +++ b/tests/test_subscribe_chain.py @@ -576,6 +576,7 @@ def _load_subscribe_chain_class(): search_owner, ), "add_subscribe": (create_owner,), + "get_runtime_setting": (create_owner,), }, ) @@ -3717,6 +3718,160 @@ 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): + """按请求来源分发识别结果,并记录身份转换调用,供订阅创建的转换用例复用。""" + 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): + calls["recognize"].append(kwargs) + if kwargs.get("media_source") == MediaSource.TMDB: + return tmdb + 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): + """以豆瓣季条目创建电视剧订阅,返回落库字段和媒体链调用记录。""" + added = [] + eventmanager, _ = self._event_manager(9) + chain = SubscribeChain() + chain.obtain_images = lambda **_kwargs: None + + def _add_subscribe(**kwargs): + added.append(kwargs) + return 51, None + + media_patch, calls = self._conversion_media_chain(module, tmdbinfo, allow_convert=allow_convert) + with ( + patch.object(module, "add_subscribe", _add_subscribe), + patch.object(module, "eventmanager", eventmanager), + patch.object(module, "get_runtime_setting", lambda _key, _default=None: recognize_source), + media_patch, + ): + sid, err_msg = chain.add( + title="绝望写手 第三季", + year="2024", + mtype=MediaType.TV, + media_source="douban", + media_id="35932853", + message=False, + ) + 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"] == [] + + 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 = { From 35d5cac84f9cc2ed6bbed3773e4a23bebe7748c1 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 8 Oct 2026 21:22:17 +0800 Subject: [PATCH 2/2] fix(subscribe): validate TMDB conversion and sync architecture facts --- app/chain/subscribe/create.py | 14 +-- docs/architecture-overview.md | 4 +- docs/refactor/optimization-checklist.md | 2 +- .../architecture/dependency-baseline.json | 6 +- tests/test_subscribe_chain.py | 96 ++++++++++++++++++- 5 files changed, 106 insertions(+), 16 deletions(-) diff --git a/app/chain/subscribe/create.py b/app/chain/subscribe/create.py index 2c18373fb..26ce58e14 100644 --- a/app/chain/subscribe/create.py +++ b/app/chain/subscribe/create.py @@ -240,21 +240,22 @@ class SubscribeCreateOwner(_SubscribeOwnerBase): 下载记录与整理会按季标题生成独立剧集目录,媒体库里就会出现多部同名剧。 """ identity = self._SubscribeChain__tmdb_conversion_identity(context) - if not identity: + 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=context.mediainfo.type, + mtype=mediainfo.type, season=context.season, ) converted = None if tmdbinfo and tmdbinfo.get("id"): converted = MediaChain().recognize_media( meta=context.metainfo, - mtype=context.mediainfo.type, + mtype=mediainfo.type, media_source=MediaSource.TMDB, media_id=str(tmdbinfo.get("id")), episode_group=context.episode_group, @@ -268,21 +269,22 @@ class SubscribeCreateOwner(_SubscribeOwnerBase): ) -> None: """异步版本的 TMDB 身份转换,与同步入口共享判定和替换规则。""" identity = self._SubscribeChain__tmdb_conversion_identity(context) - if not identity: + 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=context.mediainfo.type, + mtype=mediainfo.type, season=context.season, ) converted = None if tmdbinfo and tmdbinfo.get("id"): converted = await MediaChain().async_recognize_media( meta=context.metainfo, - mtype=context.mediainfo.type, + mtype=mediainfo.type, media_source=MediaSource.TMDB, media_id=str(tmdbinfo.get("id")), episode_group=context.episode_group, diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index e032cb716..2aee5cf87 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -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` 再执行快照测试及一次 diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index 31df27a94..25f11996e 100644 --- a/docs/refactor/optimization-checklist.md +++ b/docs/refactor/optimization-checklist.md @@ -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 等基线均为零 | diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 0d94bd171..2eda43eff 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -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", diff --git a/tests/test_subscribe_chain.py b/tests/test_subscribe_chain.py index 572effe0a..61fd437ac 100644 --- a/tests/test_subscribe_chain.py +++ b/tests/test_subscribe_chain.py @@ -576,6 +576,7 @@ def _load_subscribe_chain_class(): search_owner, ), "add_subscribe": (create_owner,), + "async_add_subscribe": (create_owner,), "get_runtime_setting": (create_owner,), }, ) @@ -2921,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)]}, @@ -2942,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 @@ -2954,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 @@ -3729,27 +3739,31 @@ class TestSubscribeProgressConsolidation: 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): + 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 + 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( @@ -3761,32 +3775,43 @@ class TestSubscribeProgressConsolidation: ) return patch.object(module, "MediaChain", return_value=media_chain), calls - def _add_douban_season(self, module, SubscribeChain, tmdbinfo, recognize_source="themoviedb", allow_convert=True): + 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 - media_patch, calls = self._conversion_media_chain(module, tmdbinfo, allow_convert=allow_convert) + 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, ): - sid, err_msg = chain.add( + 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 @@ -3837,6 +3862,67 @@ class TestSubscribeProgressConsolidation: 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()