mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-31 12:24:39 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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、数据库依赖或外部传输能力;删除路径未知时电影历史查询默认返回空。
|
||||
|
||||
@@ -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 等基线均为零 |
|
||||
|
||||
@@ -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*
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 缺少远端身份时,按「类型 + 季 + 目标路径前缀」查询。
|
||||
|
||||
122
tests/test_jellyfin_webhook.py
Normal file
122
tests/test_jellyfin_webhook.py
Normal 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 "",
|
||||
) == []
|
||||
Reference in New Issue
Block a user