From b82bbcb42013d44a480fc28b7c821dc6f790ba43 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Tue, 13 Oct 2026 23:48:10 +0800 Subject: [PATCH] fix(webhook)!: guard unknown movie paths in transfer history Recover Jellyfin deletion paths from webhook payloads or bounded server-scoped caches. Avoid querying deleted items and preserve TV season/series lookup behavior. BREAKING CHANGE: Movie and unspecified-type history queries without a destination now return no records unless match_all_versions=True is explicitly requested. Refs #6988 --- app/db/models/transferhistory.py | 8 +- app/db/oper/transferhistory.py | 8 +- app/modules/jellyfin/jellyfin.py | 32 ++++- docs/architecture-overview.md | 8 +- docs/refactor/optimization-checklist.md | 2 +- docs/rules/09-external-response.md | 10 ++ .../architecture/dependency-baseline.json | 5 +- tests/test_db_transferhistory_queries.py | 50 ++++++- tests/test_jellyfin_webhook.py | 122 ++++++++++++++++++ 9 files changed, 233 insertions(+), 12 deletions(-) create mode 100644 tests/test_jellyfin_webhook.py diff --git a/app/db/models/transferhistory.py b/app/db/models/transferhistory.py index 7ebadf334..00a7ba6a4 100644 --- a/app/db/models/transferhistory.py +++ b/app/db/models/transferhistory.py @@ -621,9 +621,13 @@ class TransferHistory(Base): episode: Optional[str] = None, media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, - dest: Optional[str] = None): + dest: Optional[str] = None, *, match_all_versions: bool = False): """ 按媒体身份、季集或标题年份查询整理记录。 + + 电影及未指定类型的查询默认要求非空目标路径;路径未知不能代表全部版本。 + 仅明确需要按身份或标题年份全量查询时传入 match_all_versions=True。 + 电视剧整季、整剧查询保留既有语义,单集仍精确匹配目标路径。 """ if media_source and media_id and mtype: statement = select(cls).where(cls.media_source == str(media_source), @@ -650,6 +654,8 @@ class TransferHistory(Base): elif dest: # 电影:没有季集,用目标路径区分不同版本 statement = statement.where(cls.dest == dest) + elif mtype != MediaType.TV.value and not match_all_versions: + return [] return list(db.execute(statement).scalars().all()) @classmethod diff --git a/app/db/oper/transferhistory.py b/app/db/oper/transferhistory.py index 9800a482d..0a187e012 100644 --- a/app/db/oper/transferhistory.py +++ b/app/db/oper/transferhistory.py @@ -373,9 +373,12 @@ class TransferHistoryOper(DbOper): def get_by(self, title: Optional[str] = None, year: Optional[str] = None, mtype: Optional[str] = None, season: Optional[str] = None, episode: Optional[str] = None, media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, - dest: Optional[str] = None) -> List[TransferHistory]: + dest: Optional[str] = None, *, match_all_versions: bool = False) -> List[TransferHistory]: """ - 按类型、标题、年份、季集查询转移记录 + 按类型、标题、年份、季集查询转移记录。 + + 电影或未指定类型且目标路径为空时默认返回空;显式传入 + match_all_versions=True 才允许全量匹配,不能用于路径未知的删除事件。 """ return self._execute_sync_query( lambda session: TransferHistory.list_by( @@ -388,6 +391,7 @@ class TransferHistoryOper(DbOper): episode=episode, media_source=media_source, media_id=media_id, + match_all_versions=match_all_versions, ) ) diff --git a/app/modules/jellyfin/jellyfin.py b/app/modules/jellyfin/jellyfin.py index 08fb49bdc..eea991500 100644 --- a/app/modules/jellyfin/jellyfin.py +++ b/app/modules/jellyfin/jellyfin.py @@ -7,6 +7,7 @@ from requests import Response from app.adapters.network.http import RequestUtils from app.application.mediaserver import MediaServerIdentityHelper, format_emby_family_item from app.foundation.url import UrlUtils +from app.runtime.cache import MemoryBackend from app.runtime.log import logger from app.runtime.settings import get_runtime_setting from app.schemas.dashboard import Statistic as _SchemaStatistic @@ -26,6 +27,9 @@ class Jellyfin: _playhost: Optional[str] = None _sync_libraries: List[str] = [] user: Optional[Union[str, int]] = None + # 删除后无法反查条目;缓存按服务器隔离,并限制容量和存活时间。 + _webhook_paths = MemoryBackend(maxsize=10000, ttl=30 * 24 * 60 * 60) + _webhook_path_region = "jellyfin.webhook.paths" def __init__(self, host: Optional[str] = None, apikey: Optional[str] = None, play_host: Optional[str] = None, sync_libraries: list = None, **kwargs): @@ -683,6 +687,29 @@ class Jellyfin: return None return None + def _get_webhook_item_path(self, message: dict[str, Any]) -> Optional[str]: + """优先读取负载路径;删除事件仅使用已知路径,不反查已删除条目。 + + 入库及播放等非删除事件刷新缓存,删除时消费对应条目缓存。 + 缓存仅在进程内有效;重启、过期或未观察过的条目可能仍无路径。 + """ + item_id = message.get("ItemId") + path = message.get("Path") + path = path if isinstance(path, str) and path.strip() else None + if not item_id: + return path + key = f"{self._host}:{item_id}" + if message.get("NotificationType") == "ItemDeleted": + cached_path = self._webhook_paths.consume(key, region=self._webhook_path_region) + path = path or cached_path + if not path: + logger.warning(f"Jellyfin 删除条目 {item_id} 的路径未知,不能据此定位文件版本") + return path + path = path or self.get_item_path_by_id(item_id) + if path: + self._webhook_paths.set(key, path, region=self._webhook_path_region) + return path + def generate_image_link(self, item_id: str, image_type: str, host_type: bool) -> Optional[str]: """ 根据ItemId和imageType查询本地对应图片 @@ -881,14 +908,13 @@ class Jellyfin: eventItem.percentage = playback_position_ticks / runtime_ticks * 100 # 获取消息图片 - if eventItem.item_id: + if eventItem.item_id and eventType != "ItemDeleted": # 根据返回的item_id去调用媒体服务器获取 eventItem.image_url = self.get_remote_image_by_id( item_id=eventItem.item_id, image_type="Backdrop" ) - # jellyfin 的 webhook 不含 item_path,需要单独获取 - eventItem.item_path = self.get_item_path_by_id(eventItem.item_id) + eventItem.item_path = self._get_webhook_item_path(message) eventItem.json_object = message diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 068586852..56911021c 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -763,7 +763,7 @@ flowchart LR | 指标 | 当前值 | |---|---:| | Python 模块 | 1119 | -| 内部导入边 | 9,538 | +| 内部导入边 | 9,539 | | 非平凡 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 | -成功通知聚合复用既有 owner 和领域/模板模型,新增 14 条合法内部依赖边(含父包展开),未新增模块或 SCC。整理失败反馈由 `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,538;订阅独立日志复用运行时上下文、系统日志端口与资源鉴权,新增 19 条合法依赖边(含父包展开),未新增模块或 SCC;插件实例校验复用 `app.domain.plugin` 的物理身份规则,兼容下划线 ID,新增 2 条 Schema 到 Domain 的合法依赖边(含父包展开);种子下载限流窗口由 `app.domain.torrent` 定义,失败冷却 owner 复用同一语义,新增 1 条合法 Chain 到 Domain 的导入边;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条模块导入边;资源过滤动作 `app.workflow.actions.filter_torrents` 与动作包共用的领域还原函数同样把 Schema 资源还原为领域对象后再交给过滤链,新增 6 条模块导入边。音乐来源链与识别缓存复用领域身份核验,音乐交互包未新增 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` 为 587、`app.factory` 为 600、`app.main` 为 602。专辑订阅入库对账新增 `app.chain.subscribe.music` 一个职责 owner;按注入的下载/整理历史 Port 读取事实,复用音频标签/CUE 读取和领域覆盖规则,依赖图增加 18 条合法边,不引入跨层数据库或 Adapter 直连。订阅业务活动新增 `app.application.subscription.activity` 及对应 DB Model、Oper、Adapter 四个模块,三入口与功能前提交对照均只增加这四个模块;性能基线仅同步结构计数,保留原有历史耗时采样和预算。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。 +成功通知聚合复用既有 owner 和领域/模板模型,新增 14 条合法内部依赖边(含父包展开),未新增模块或 SCC。整理失败反馈由 `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,539;订阅独立日志复用运行时上下文、系统日志端口与资源鉴权,新增 19 条合法依赖边(含父包展开),未新增模块或 SCC;插件实例校验复用 `app.domain.plugin` 的物理身份规则,兼容下划线 ID,新增 2 条 Schema 到 Domain 的合法依赖边(含父包展开);种子下载限流窗口由 `app.domain.torrent` 定义,失败冷却 owner 复用同一语义,新增 1 条合法 Chain 到 Domain 的导入边;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条模块导入边;资源过滤动作 `app.workflow.actions.filter_torrents` 与动作包共用的领域还原函数同样把 Schema 资源还原为领域对象后再交给过滤链,新增 6 条模块导入边。音乐来源链与识别缓存复用领域身份核验,音乐交互包未新增 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` 为 587、`app.factory` 为 600、`app.main` 为 602。专辑订阅入库对账新增 `app.chain.subscribe.music` 一个职责 owner;按注入的下载/整理历史 Port 读取事实,复用音频标签/CUE 读取和领域覆盖规则,依赖图增加 18 条合法边,不引入跨层数据库或 Adapter 直连。订阅业务活动新增 `app.application.subscription.activity` 及对应 DB Model、Oper、Adapter 四个模块,三入口与功能前提交对照均只增加这四个模块;性能基线仅同步结构计数,保留原有历史耗时采样和预算。音乐调用观察 `app.application.music.observation` 新增一个轻量模块,记录来源结果及请求预算;AcoustID及路径识别复用此上下文传递有界指纹候选,保留旧单ID模块合同。站点图片域名快照新增 `app.application.security.image`,各启动入口增加一个模块;性能基线只同步模块数量,原有耗时预算、历史采样和生命周期资源约束保持有效。 架构专项验证分为两个 CI 投影:`Check event semantic policy` 先运行依赖、Adapter、出口和 Event 语义门禁,`Check host architecture snapshot` 再执行快照测试及一次 @@ -847,3 +847,7 @@ Agent Hermes 执行纠偏新增5个宿主模块、19条内部依赖边,当前1 智能分页新增冷导入模块 `app.application.search.session`、`app.chain.search.manual`、`app.chain.search.scan` 和 `app.domain.search`,三入口分别增加4个模块。性能 fixture 仅同步结构数量,保留原有耗时样本、预算及生命周期资源约束。下载提交沿用原有流程;未新增越层依赖或 SCC。 订阅业务活动新增 Application 事实和 Port、DB Model/Oper/Adapter 四个模块,增加 50 条合法内部依赖边(含父包展开)。新 API 保持登录用户归属隔离;后台活动采用短事务观察,完成历史通过独立标识关联,未增加 SCC 或 Application/Chain 到具体 Adapter/数据库的直连。 + +Jellyfin webhook 路径恢复复用 `app.runtime.cache.MemoryBackend` 的有界内存缓存和原子消费, +新增 1 条合法 Module 到 Runtime 的导入边。缓存按服务器与条目 ID 隔离, +未新增模块、SCC、数据库依赖或外部传输能力;删除路径未知时电影历史查询默认返回空。 diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index 1ab8b011d..8d132693e 100644 --- a/docs/refactor/optimization-checklist.md +++ b/docs/refactor/optimization-checklist.md @@ -100,7 +100,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 1119 / 9,538 | `dependency-baseline.json` 当前快照;成功通知聚合复用既有 owner 和领域/模板模型,新增 14 条合法内部依赖边(含父包展开),未新增模块或 SCC。专辑订阅入库对账新增 1 个 Chain owner 和 18 条合法依赖边(含父包展开),仍经注入 Port 读取历史并通过既有 mutation 写入;订阅业务活动新增 4 个模块和 50 条合法内部依赖边(含父包展开),Application 仅定义事实与持久化 Port,DB Adapter/Oper 实现存取,组合根注入,未增加 Application/Chain 到具体 Adapter 或数据库的直连;插件实例校验复用 `app.domain.plugin` 的物理身份规则,兼容下划线 ID,新增 2 条合法依赖边(含父包展开);种子失败冷却复用 `app.domain.torrent` 的限流语义,新增 1 条合法导入边;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条合法模块导入边,资源过滤动作复用同一还原再新增 6 条;LLM 独占连接关闭复用宿主线程池;歌词字形转换复用 `app.foundation.text`;订阅创建使用 `app.domain.context.MediaInfo` 和运行时配置端口,将非 TMDB 影视身份投影为 TMDB 身份 | +| 宿主 Python 模块 / 内部依赖边 | 1119 / 9,539 | `dependency-baseline.json` 当前快照;成功通知聚合复用既有 owner 和领域/模板模型,新增 14 条合法内部依赖边(含父包展开),未新增模块或 SCC。专辑订阅入库对账新增 1 个 Chain owner 和 18 条合法依赖边(含父包展开),仍经注入 Port 读取历史并通过既有 mutation 写入;订阅业务活动新增 4 个模块和 50 条合法内部依赖边(含父包展开),Application 仅定义事实与持久化 Port,DB Adapter/Oper 实现存取,组合根注入,未增加 Application/Chain 到具体 Adapter 或数据库的直连;插件实例校验复用 `app.domain.plugin` 的物理身份规则,兼容下划线 ID,新增 2 条合法依赖边(含父包展开);种子失败冷却复用 `app.domain.torrent` 的限流语义,新增 1 条合法导入边;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档;消息音乐交互由 `app.chain.music_interaction` 包中的音乐解析/展示与订阅协调模块处理;已整理下载的站点字幕经 `app.chain.transfer` 自动整理队列完成命名与归档;网络连通性检测按 `app.application.nettest` 归档;工作流添加下载动作由 `app.workflow.actions.add_download` 转换工作流 Schema 为下载链领域上下文,新增 5 条合法模块导入边,资源过滤动作复用同一还原再新增 6 条;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/docs/rules/09-external-response.md b/docs/rules/09-external-response.md index bedc5e3dc..3a0b6261a 100644 --- a/docs/rules/09-external-response.md +++ b/docs/rules/09-external-response.md @@ -184,4 +184,14 @@ Webhook payloads arrive at `app/api/endpoints/webhook.py` and are dispatched via Do not add webhook-specific business logic directly in the endpoint. The endpoint parses the payload and fires the event; the chain handles the response. +Jellyfin 的条目删除后无法通过播放接口反查路径。适配器优先使用 webhook 的 `Path`; +非删除事件查到的路径会按服务器和条目 ID 缓存在进程内(最多 10000 条、有效期 30 天), +删除事件消费缓存,不请求已删除条目的路径或图片。重启、过期或从未观察过的条目仍可能 +没有路径,此时告警并保留 `item_path=None`,消费方不得据此推断同片其他副本。 + +插件兼容接口 `TransferHistoryOper.get_by` 及底层 `TransferHistory.list_by` 对电影和 +未指定类型的查询要求非空 `dest`,空路径默认返回空列表。确实需要按身份或标题年份查询 +全部版本时,调用方须显式传入 `match_all_versions=True`;路径未知的删除事件不得使用 +该参数。电视剧整剧、整季及单集路径精确匹配继续沿用原有查询语义。 + *Last Updated: 2026-08-14* diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 3ce6a734f..6dd63d18e 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -941,8 +941,8 @@ "runtime_only": true } }, - "edge_count": 9538, - "edge_sha256": "b7b224c7a66e3a2d169cffdad65ef7b54dc425af11b1ccb12b6bc1ceebf9108b", + "edge_count": 9539, + "edge_sha256": "101edfcffe1b9525bc69515f84c493d499fcd5cb4349de8f6f3f8542bed40f40", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -7829,6 +7829,7 @@ "app.modules.jellyfin.jellyfin -> app.foundation", "app.modules.jellyfin.jellyfin -> app.foundation.url", "app.modules.jellyfin.jellyfin -> app.runtime", + "app.modules.jellyfin.jellyfin -> app.runtime.cache", "app.modules.jellyfin.jellyfin -> app.runtime.log", "app.modules.jellyfin.jellyfin -> app.runtime.settings", "app.modules.jellyfin.jellyfin -> app.schemas", diff --git a/tests/test_db_transferhistory_queries.py b/tests/test_db_transferhistory_queries.py index 511eb803a..57c99695f 100644 --- a/tests/test_db_transferhistory_queries.py +++ b/tests/test_db_transferhistory_queries.py @@ -299,12 +299,60 @@ def test_list_by_falls_back_to_title_and_year(db): seasons="S01")) assert [h.title for h in TransferHistory.list_by( - db.session, title="回退标题", year="2020")] == ["回退标题"] + db.session, title="回退标题", year="2020", match_all_versions=True)] == ["回退标题"] assert [h.title for h in TransferHistory.list_by( db.session, title="回退标题", year="2020", season="S01")] == ["回退标题"] assert TransferHistory.list_by(db.session, title="回退标题", year="1999") == [] +@pytest.mark.parametrize("dest", [None, ""]) +@pytest.mark.parametrize("use_identity", [True, False]) +def test_list_by_unknown_movie_path_does_not_match_other_copies(db, dest, use_identity): + """电影路径未知时不能按媒体身份或标题年份命中仍在使用的副本。""" + db.add(_hist("同一电影", mtype=MediaType.MOVIE.value, dest="/media/active.mkv")) + criteria = {"media_source": MediaSource.TMDB, "media_id": "6001"} if use_identity else { + "title": "同一电影", "year": "2026", + } + + assert TransferHistory.list_by( + db.session, mtype=MediaType.MOVIE.value, dest=dest, **criteria, + ) == [] + + +def test_list_by_all_movie_versions_requires_explicit_opt_in(db): + """明确查询全部版本时才可省略电影目标路径,给出路径后仍须精确匹配。""" + db.add(_hist("4K", mtype=MediaType.MOVIE.value, dest="/media/4k.mkv"), + _hist("1080", mtype=MediaType.MOVIE.value, dest="/media/1080.mkv")) + criteria = {"mtype": MediaType.MOVIE.value, "media_source": MediaSource.TMDB, + "media_id": "6001", "match_all_versions": True} + + assert {h.title for h in TransferHistory.list_by(db.session, **criteria)} == {"4K", "1080"} + assert [h.title for h in TransferHistory.list_by( + db.session, dest="/media/4k.mkv", **criteria, + )] == ["4K"] + + +def test_list_by_title_without_type_or_path_is_not_full_match(db): + """缺少类型的标题年份回退查询同样需要显式允许全部版本。""" + db.add(_hist("同一电影", mtype=MediaType.MOVIE.value)) + + assert TransferHistory.list_by(db.session, title="同一电影", year="2026") == [] + + +def test_transferhistory_oper_preserves_movie_path_guard_and_opt_in(db): + """插件兼容 Oper 应传递路径保护和显式全版本参数。""" + from app.db.oper.transferhistory import TransferHistoryOper + + db.add(_hist("同一电影", mtype=MediaType.MOVIE.value, dest="/media/active.mkv")) + oper = TransferHistoryOper(db.session) + criteria = {"mtype": MediaType.MOVIE.value, "media_source": MediaSource.TMDB, + "media_id": "6001"} + + assert oper.get_by(dest="", **criteria) == [] + assert oper.get_by(dest="/media/deleted.mkv", **criteria) == [] + assert [h.title for h in oper.get_by(match_all_versions=True, **criteria)] == ["同一电影"] + + def test_list_by_supports_type_season_and_dest_prefix(db): """ 媒体服务器 webhook 缺少远端身份时,按「类型 + 季 + 目标路径前缀」查询。 diff --git a/tests/test_jellyfin_webhook.py b/tests/test_jellyfin_webhook.py new file mode 100644 index 000000000..80ae09bf6 --- /dev/null +++ b/tests/test_jellyfin_webhook.py @@ -0,0 +1,122 @@ +"""Jellyfin 删除事件的路径恢复及整理历史定位回归。""" + +import json +from unittest.mock import Mock + +import pytest + +from app.db.models.transferhistory import TransferHistory +from app.db.oper.transferhistory import TransferHistoryOper +from app.modules.jellyfin.jellyfin import Jellyfin +from app.runtime.cache import MemoryBackend +from app.schemas.types import MediaSource, MediaType + + +@pytest.fixture +def client(monkeypatch): + """隔离路径缓存并禁止媒体服务器真实请求。""" + monkeypatch.setattr(MemoryBackend, "_region_caches", {}) + server = Jellyfin.__new__(Jellyfin) + server._host = "http://jellyfin.local/" + monkeypatch.setattr(server, "get_item_path_by_id", Mock(return_value="/media/deleted.mkv")) + monkeypatch.setattr(server, "get_remote_image_by_id", Mock(return_value="image")) + return server + + +def _payload(event="ItemDeleted", **kwargs): + """构造同一电影条目的 webhook 负载。""" + return json.dumps({ + "NotificationType": event, "ItemId": "deleted-copy", "ItemType": "Movie", + "Name": "签约幽会", "Year": 2017, "Provider_tmdb": "468205", **kwargs, + }) + + +def test_deleted_item_uses_payload_path_without_remote_lookup(client): + """删除负载已有路径时无需访问已消失条目的图片或播放接口。""" + event = client.get_webhook_message(_payload(Path="/media/payload.mkv")) + + assert event.item_path == "/media/payload.mkv" + client.get_item_path_by_id.assert_not_called() + client.get_remote_image_by_id.assert_not_called() + + +@pytest.mark.parametrize("event_type", ["ItemAdded", "PlaybackStart", "PlaybackStop"]) +def test_deleted_item_recovers_observed_path_and_consumes_cache(client, event_type): + """入库和播放查询到的路径可用于删除事件,消费后不再复用陈旧路径。""" + first = client.get_webhook_message(_payload(event_type)) + deleted = client.get_webhook_message(_payload()) + duplicate = client.get_webhook_message(_payload()) + + assert first.item_path == deleted.item_path == "/media/deleted.mkv" + assert duplicate.item_path is None + client.get_item_path_by_id.assert_called_once_with("deleted-copy") + client.get_remote_image_by_id.assert_called_once() + + +def test_payload_path_overrides_and_invalidates_cached_path(client): + """删除负载路径优先于旧缓存,删除后清理缓存防止重用。""" + client.get_webhook_message(_payload("ItemAdded")) + deleted = client.get_webhook_message(_payload(Path="/media/new.mkv")) + + assert deleted.item_path == "/media/new.mkv" + assert client.get_webhook_message(_payload()).item_path is None + + +@pytest.mark.parametrize("path", [None, "", " ", 42]) +def test_unknown_deleted_path_is_reported_without_remote_lookup(client, monkeypatch, path): + """未观察过的删除条目保留路径未知并告警,不能反查或猜测同片路径。""" + warning = Mock() + monkeypatch.setattr("app.modules.jellyfin.jellyfin.logger.warning", warning) + + event = client.get_webhook_message(_payload(Path=path)) + + assert event.item_path is None + warning.assert_called_once() + client.get_item_path_by_id.assert_not_called() + + +def test_webhook_path_cache_isolates_servers_and_items(client): + """不同服务器或不同条目不得复用同一电影的缓存路径。""" + client.get_webhook_message(_payload("ItemAdded")) + assert client.get_webhook_message(_payload(ItemId="other-copy")).item_path is None + client._host = "http://other-jellyfin.local/" + assert client.get_webhook_message(_payload()).item_path is None + client._host = "http://jellyfin.local/" + assert client.get_webhook_message(_payload()).item_path == "/media/deleted.mkv" + + +def test_non_deleted_payload_path_refreshes_cache_without_playback_lookup(client): + """非删除负载自带路径时直接缓存,播放接口失败不影响已知路径。""" + client.get_webhook_message(_payload("ItemAdded", Path="/media/payload.mkv")) + + assert client.get_webhook_message(_payload()).item_path == "/media/payload.mkv" + client.get_item_path_by_id.assert_not_called() + + +def test_failed_lookup_does_not_erase_observed_path(client): + """一次播放查询失败不能擦除之前观察到的有效路径。""" + client.get_webhook_message(_payload("ItemAdded")) + client.get_item_path_by_id.return_value = None + client.get_webhook_message(_payload("PlaybackStart")) + + assert client.get_webhook_message(_payload()).item_path == "/media/deleted.mkv" + + +@pytest.mark.parametrize("observed", [True, False]) +def test_deleted_copy_does_not_match_active_transfer_history(client, db, observed): + """复现只有存活副本有整理记录的场景:已删副本或未知路径均匹配零条。""" + db.watermark(TransferHistory) + db.add(TransferHistory( + title="签约幽会", year="2017", type=MediaType.MOVIE.value, + media_source=str(MediaSource.TMDB), media_id="468205", src="/downloads/source.mkv", + dest="/media/active.mkv", src_storage="local", dest_storage="local", + )) + if observed: + client.get_webhook_message(_payload("ItemAdded")) + client.get_item_path_by_id.return_value = None + event = client.get_webhook_message(_payload()) + + assert TransferHistoryOper(db.session).get_by( + mtype=MediaType.MOVIE.value, media_source=event.media_source, + media_id=event.media_id, dest=event.item_path or "", + ) == []