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
This commit is contained in:
jxxghp
2026-10-13 23:48:10 +08:00
parent 64e970215b
commit b82bbcb420
9 changed files with 233 additions and 12 deletions

View File

@@ -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

View File

@@ -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,
)
)

View File

@@ -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

View File

@@ -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、数据库依赖或外部传输能力;删除路径未知时电影历史查询默认返回空。

View File

@@ -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 等基线均为零 |

View File

@@ -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*

View File

@@ -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",

View File

@@ -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 缺少远端身份时,按「类型 + 季 + 目标路径前缀」查询。

View File

@@ -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 "",
) == []