现象

升级到 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):

json
{
  "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 的标签, 都会被归一化成 animationdocumentarykidsreality 这些稳定键, 所以同一条规则能跨数据源生效 —— 这是 V3 的设计亮点:

纯文本
文件名输入 → 数据源识别 → 统一 MediaInfo 投影 → 构造分类事实 → 规则求值(首条命中)
          → 应用人工覆盖并冻结结果 → 目录选择

category.yaml 的唯一作用是:V3 首次启动、且数据库里还没有策略时,被迁移一次。

这句话就是整个事故的根源。


二、排查:用 API 把现场扒清楚

V3 的分类 API 很完整,排查基本不用登服务器。先说认证(这里有个坑):

bash
# ❌ 这样会 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 表示没有):

bash
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. 读当前生效策略 —— 当场破案

bash
curl -s "http://host:3000/api/v1/media/classification/policy?apikey=$TOKEN" | python3 -m json.tool
json
{
  "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 的整理历史记录了分类是怎么来的,这是最硬的一条证据:

json
{
  "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):

bash
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):

python
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 拉下来一看, 里面有这么一段:

yaml
# 配置动漫的分类策略
anime:
  国漫:
    origin_country: 'CN,TW,HK'
  日番:
    origin_country: 'JP'

V2 和 V3 都只认 movietv 两个一级键。 这个 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"],更稳。

生成 + 校验 + 干跑 + 发布

bash
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 头。