现象
升级到 MoviePilot V3 之后,下载完成的文件整理进媒体库,结果全部落在:
/vol1/1000/媒体库/电影/未分类/海洋奇缘:启航 (2026)/海洋奇缘:启航 (2026) - 1080p.mkv
而我明明在 category.yaml 里配了「动画电影 / 华语电影 / 外语电影」「动漫 / 国产剧 / 欧美剧 / 日韩剧」,
升级前一直用得好好的,整理历史里「华语电影」「动漫」「国产剧」这些目录都在。
第一反应是配置被覆盖了,于是把 category.yaml 重新改了一遍 —— 毫无反应。
环境
- MoviePilot v3.0.0(内网部署,nginx 反代
:3000→ 后端:3001) CONFIG_DIR=/config,数据库 PostgreSQL- 媒体库
/vol1/1000/媒体库/,两个目录配置都开了「媒体库类别子目录」
一、先搞清楚 V3 的分类机制变了什么
不想看源码的可以直接记结论:V3 把分类从「读一个 yaml 文件」换成了「数据库里的一份版本化策略」。
V3 的新规则长这样(条件树 + 稳定分类 ID):
{
"id": "rule.tv.anime.jp",
"kind": "category",
"media_types": ["电视剧"],
"when": {
"all": [
{ "field": "media.genre_keys", "operator": "contains_any", "value": ["animation"] },
{ "field": "media.countries", "operator": "contains_any", "value": ["JP"] }
]
},
"target": { "category_id": "tv.anime.jp" }
}
关键点是 media.genre_keys 这类分类事实:TMDB 的数字 genre id、豆瓣的类型名、Bangumi 的标签,
都会被归一化成 animation、documentary、kids、reality 这些稳定键,
所以同一条规则能跨数据源生效 —— 这是 V3 的设计亮点:
文件名输入 → 数据源识别 → 统一 MediaInfo 投影 → 构造分类事实 → 规则求值(首条命中)
→ 应用人工覆盖并冻结结果 → 目录选择
而 category.yaml 的唯一作用是:V3 首次启动、且数据库里还没有策略时,被迁移一次。
这句话就是整个事故的根源。
二、排查:用 API 把现场扒清楚
V3 的分类 API 很完整,排查基本不用登服务器。先说认证(这里有个坑):
# ❌ 这样会 401 "token校验不通过"
curl -H "Authorization: Bearer $API_TOKEN" http://host:3000/api/v1/...
# ✅ API 令牌要走这两种方式之一
curl "http://host:3000/api/v1/...?apikey=$API_TOKEN"
curl -H "X-API-Key: $API_TOKEN" http://host:3000/api/v1/...
Authorization: Bearer 是给登录后的 JWT 用的,跟你在「设置 → 系统 → API 令牌」里生成的那个令牌不是一回事。
1. 确认版本
不用登录,看路由存在性就能判版本(401 表示路由存在,404 表示没有):
curl -s -o /dev/null -w "%{http_code}\n" http://host:3000/api/v1/media/classification/policy
# 401 → 路由存在 → 是 V3
# 404 → 没有这个路由 → 是 V2
前端 JS 里也能翻到版本串:/assets/index-*.js 里搜 v3.。
2. 读当前生效策略 —— 当场破案
curl -s "http://host:3000/api/v1/media/classification/policy?apikey=$TOKEN" | python3 -m json.tool
{
"schema_version": 2,
"revision": 1,
"mode": "first_match",
"categories": [
{ "id": "movie.uncategorized", "media_type": "电影", "path": ["未分类"] },
{ "id": "tv.uncategorized", "media_type": "电视剧", "path": ["未分类"] },
{ "id": "music.uncategorized", "media_type": "音乐", "path": ["未分类"] },
{ "id": "music.album", "media_type": "音乐", "path": ["Album"] },
... 音乐默认四个 ...
],
"rules": [ ...只有 4 条音乐规则... ],
"fallbacks": { "电影": "movie.uncategorized", "电视剧": "tv.uncategorized", "音乐": "music.uncategorized" }
}
电影和电视剧一条规则都没有,只有三个「未分类」兜底。
这七个分类 = V3 的出厂默认(三个兜底 + 音乐的 Album/Compilation/EP/Single)。 也就是说:我配的分类从来没进过策略。
再看策略历史,items 是空的 —— 说明这个 revision 1 是启动时初始化出来的,
不是发布出来的,也不是迁移出来的。
GET /api/v1/media/classification/history
{"active_revision": 1, "items": []}
3. 目录配置没问题
顺手确认一下不是目录配置的锅:
GET /api/v1/system/setting/Directories
「媒体库类别子目录」是开的。所以不是开关问题,是分类本身算不出来。
4. 整理历史里自带证据
V3 的整理历史记录了分类是怎么来的,这是最硬的一条证据:
{
"title": "海洋奇缘:启航",
"type": "电影",
"category": "未分类",
"media_category_id": "movie.uncategorized",
"classification_rule_id": null,
"classification_policy_revision": 1,
"classification_source": "fallback"
}
classification_source: "fallback" —— 没有任何规则命中,直接用了兜底分类。
往前翻 199 条旧记录,这几个字段全是 null,分类却是「动漫」「国产剧」「华语电影」——
那都是旧版本写的。这个字段的有无,正好把新旧版本切开。
5. 还原时间线
不需要登服务器,用 POST /api/v1/storage/list 能列容器内的目录(带 mtime):
curl -s -X POST "http://host:3000/api/v1/storage/list?apikey=$TOKEN" \
-H "Content-Type: application/json" \
-d '{"storage":"local","path":"/config","type":"dir","name":"config"}'
再加上策略的 updated_at、整理历史的时间,时间线就出来了:
07-29 ~ 09-10 19:01 旧版本整理正常,分类是 动漫/国产剧/华语电影/日韩剧
09-10 19:33:20 /config/cookies 目录创建 ← V3.0.0 首次启动
09-10 19:33:32 策略被写成「仅兜底」的 revision 1
09-10 21:08:05 V3 第一条整理 → fallback → 进「未分类」
09-10 21:39:04 我把 category.yaml 写回 /config(5289 字节)
因果清楚了:19:33 V3 首次启动时,/config/category.yaml 是空的(或不存在),
于是 V3 按"没有旧配置"处理,async_initialize() 直接落了一个只有兜底分类的空策略。
等我 21:39 把 yaml 写回去时,数据库里已经有策略了 ——
而 V3 的逻辑是「有策略就 reload,永远不再读 yaml」。所以之后怎么改都没反应。
对应源码(app/startup/composition/classification.py):
if policy_key in values: # 数据库里已有策略
... # 直接 reload,压根不看 category.yaml
return finish(..., migrated=False)
legacy_path = Path(settings.CONFIG_PATH) / "category.yaml"
if not await executor.run(legacy_path.exists):
await service.async_initialize() # ← 我走的是这条:空策略
return finish(..., migrated=False)
6. 顺带挖出一个隐藏雷
用 POST /api/v1/storage/download 把 /config/category.yaml 拉下来一看,
里面有这么一段:
# 配置动漫的分类策略
anime:
国漫:
origin_country: 'CN,TW,HK'
日番:
origin_country: 'JP'
V2 和 V3 都只认 movie 和 tv 两个一级键。 这个 anime: 段:
- 在 V2 里:直接被忽略(所以整理历史里只有「动漫」,从没出现过「国漫」「日番」)
- 在 V3 里:迁移时会产生 error 级诊断
unsupported_legacy_media_type,让整份配置作废
也就是说,就算我把数据库策略清掉、重启让它重新迁移,只要这个 anime: 还在,结果还是空策略。
三、修复:把 V2 的 yaml 翻译成 V3 策略
路线选了「直接用 V3 策略 API 重建」,而不是「清库重启让它重新迁移」—— 后者是拿退役路径兜底,治标不治本。
字段对照(翻译时的关键)
我的 yaml 里「华语电影」用的是 original_language: 'zh,cn,bo,za',
翻译时直接换成标准字段 media.language in ["zh","cn","bo","za"],更稳。
生成 + 校验 + 干跑 + 发布
export MP_TOKEN=你的API令牌
python3 mp-build-policy.py # 生成策略 JSON,并调 /validate 校验(不落库)
python3 mp-preview-policy.py # 用真实媒体事实干跑 9 个用例
python3 mp-publish-policy.py # dry-run,看 diff
python3 mp-publish-policy.py --apply # PUT /policy 发布
发布前 V3 会做 CAS 校验(expected_revision),所以不用担心并发覆盖。
/validate 返回 valid=true(只有几条 partial_field_support 警告,提示某些字段在部分数据源不可得,属正常)。
干跑结果,9/9 命中:
✓ 海洋奇缘:启航(美/英配动画电影) → 动画电影
✓ 不成功穿越指南(华语真人电影) → 华语电影
✓ 外语真人电影(英语) → 外语电影
✓ 仙逆(国产动画剧集) → 动漫/国漫
✓ BLACK TORCH(日本动画剧集) → 动漫/日番
✓ 某真人国产剧 → 国产剧
✓ 某美剧 → 欧美剧
✓ 某韩剧 → 日韩剧
✓ 某综艺 → 综艺
关于 anime: 那段:V3 没有 anime 媒体类型,我把它合并成电视剧下的多级分类
动漫/国漫、动漫/日番,顺序排在 动漫 之前(V3 支持 path 数组,最多 4 级)。
这样既保留了原意,又符合 V3 模型。
发布结果
已发布:revision=2,18 分类 / 15 规则
再用已生效策略干跑一遍确认:
海洋奇缘:启航 → 动画电影 [movie.animation.default]
不成功穿越指南 → 华语电影 [movie.chinese.default]
仙逆 → 动漫/国漫 [tv.anime.cn.default]
重案六组:消失的警号 → 国产剧 [tv.cn.default]
策略历史里保留了 revision 1,不满意可以一条命令回滚:
POST /api/v1/media/classification/rollback/1
附:这次用到的 API
GET /api/v1/media/classification/policy 当前生效策略
GET /api/v1/media/classification/fields 可用字段目录(写规则前先看看有哪些字段)
POST /api/v1/media/classification/validate 校验草稿(不落库)
POST /api/v1/media/classification/preview 干跑,返回命中解释
POST /api/v1/media/classification/impact 发布前的批量影响分析
PUT /api/v1/media/classification/policy 发布(带 CAS revision)
GET /api/v1/media/classification/history 策略历史
POST /api/v1/media/classification/rollback/{revision} 回滚
GET /api/v1/system/setting/Directories 整理目录配置
GET /api/v1/history/transfer 整理历史(含分类来源 trace)
POST /api/v1/storage/list 列容器内目录(含 mtime)
POST /api/v1/storage/download 取回容器内的文件
GET /api/v1/system/env 运行时环境(CONFIG_DIR、端口、DB 类型等)
认证统一用 ?apikey=<API_TOKEN> 或 X-API-Key 头。
评论