Files
MoviePilot/docs/refactor/media-classification-design.md
2026-09-11 06:29:12 +08:00

48 KiB
Raw Blame History

MoviePilot 多媒体、多数据源自动分类体系设计

  • 状态:V3 首版实现依据
  • 日期:2026-09-02
  • 适用版本:MoviePilot V3
  • 涉及仓库:MoviePilot、MoviePilot-Frontend,后续插件能力文档涉及 MoviePilot-Plugins

1. 背景

MoviePilot 当前自动分类能力已经有后端 API 和前端编辑弹窗,但实现本质上仍是 TheMovieDb 模块内部的专用能力:

  • app/modules/themoviedb/category.py 直接读取和写入 config/category.yaml。
  • 分类配置只有 movie、tv 两个固定根节点。
  • 分类条件直接对应 TMDB 原始字段,例如 genre_ids、original_language、 production_countries、origin_country。
  • app/modules/themoviedb/__init__.py 在构造 TMDB MediaInfo 时直接计算并写入 MediaInfo.category。
  • app/chain/base.py 通过模块方法 media_category、load_category_config、 save_category_config 间接访问 TMDB 分类器。
  • MoviePilot-Frontend/src/components/dialog/CategoryEditDialog.vue 写死电影、电视剧、 TMDB Genre ID、语种和国家选项。

这导致以下问题:

  1. 使用豆瓣、Bangumi、AniList、IMDb、TVDB 或插件数据源识别时,无法得到与 TMDB 等价的自动目录分类。
  2. 音乐虽然已有 MusicBrainz、TheAudioDB、豆瓣音乐以及本地音频标签能力,但没有独立的 自动分类策略。
  3. 来源原始字段与分类规则直接绑定,来源接口变化会使用户规则失效。
  4. 分类逻辑在来源模块内部执行,缓存命中、同步/异步识别、插件识别等路径容易出现行为差异。
  5. 当前 category 字段存在语义冲突:影视使用它表示媒体库分类,音乐使用它表示 Album / Live、流派等来源元数据;整理历史又统一把该字段写成目录分类。
  6. 分类名称同时充当稳定身份和目录路径,重命名分类会影响目录设置、订阅和历史记录。
  7. 当前 UI 只能编辑固定条件,不能表达组合条件、来源范围、兜底规则、规则解释和发布前预览。

本设计将分类能力从 TMDB 模块中拆出,建立一套来源无关、媒体类型可扩展、可解释、可迁移、 可由前端完整配置的统一分类体系。

2. 目标

2.1 功能目标

  1. 支持电影、电视剧、音乐,后续新增媒体类型时不需要重写规则引擎。
  2. 支持所有注册媒体数据源,包括内置来源和插件来源。
  3. 同一条基于标准字段的规则可以跨来源工作,不要求用户理解来源原始 JSON。
  4. 允许规则按媒体类型、数据源、语言、国家、年份、类型、标签、音乐实体和专辑属性等条件匹配。
  5. 支持 AND、OR、NOT、范围、包含、缺失值等明确的组合语义。
  6. 每个媒体只产生一个主目录分类,同时允许附加多个非目录标签。
  7. 支持多级目录分类,并使用稳定分类 ID,目录名称可以安全重命名。
  8. 前端支持新增、复制、排序、启停、校验、测试、影响预览和回滚。
  9. 分类结果可解释:能够回答“命中了哪条规则”“哪些条件通过或失败”“使用了哪些事实”。
  10. 保留现有 category.yaml 用户配置,并提供无损迁移与旧 API 兼容期。

2.2 工程目标

  1. 分类规则求值必须是纯函数,不执行网络、文件或数据库 I/O。
  2. 分类器不直接依赖 TMDB、豆瓣、MusicBrainz 等具体模块。
  3. 同步和异步识别路径使用同一个分类结果收口点。
  4. 媒体身份始终保留原始 media_source + media_id,分类不得隐式转换主身份。
  5. 识别缓存命中后仍按当前策略重新分类,避免策略更新后返回旧分类。
  6. 整理任务进入持久计划后冻结分类结果和策略版本,运行中的任务不受后续策略修改影响。
  7. 用户配置通过 SystemConfigKey 管理,不继续由某个来源模块拥有配置文件。

2.3 非目标

  1. 本设计不替代搜索、订阅使用的资源过滤规则和优先级规则。
  2. 默认不为了分类而调用所有外部数据源,避免额外延迟、配额消耗和不稳定性。
  3. 分类器不允许 UI 直接执行任意 Python 表达式、Jinja、JSONPath 或未受控正则。
  4. 来源原始响应不作为长期规则协议;原始字段只能先投影为已登记的分类事实。

3. 核心设计原则

3.1 分类晚于来源投影,早于目录选择

标准顺序为:

文件名/身份输入
      |
      v
数据源识别或插件识别
      |
      v
统一 MediaInfo / MusicInfo 投影
      |
      v
构造 ClassificationFacts
      |
      v
执行自动分类策略
      |
      v
应用人工覆盖并冻结有效分类
      |
      v
目录选择、下载历史、订阅、整理计划

分类不再由任何具体来源在构造媒体对象时顺便完成。

3.2 多数据源不等于强制转换为 TMDB

当媒体由豆瓣、Bangumi、AniList、MusicBrainz 或插件来源识别时:

  • 保留该来源的 media_source + media_id。
  • 只从该来源已经投影出的标准事实进行分类。
  • 不为得到分类而把身份替换成 TMDB ID。
  • 可选跨源补充只填充缺失事实,不改变主身份,也不覆盖主来源明确提供的值。

3.3 目录分类与来源分类分离

统一采用以下术语:

字段 含义 示例
metadata_category 数据源提供的描述性分类 Album / Live、Rock
library_category 自动分类或人工覆盖后的媒体类型内相对目录分类 现场专辑
classification_labels 不参与目录选择的附加标签 华语、高解析
category 兼容字段,过渡期只映射到 library_category 现场专辑

音乐来源当前写入 category 的专辑类型、流派信息必须迁移到 metadata_category。前端音乐详情和卡片 展示 metadata_category;目录、订阅和整理历史只消费 library_category。

3.4 稳定 ID 与可变路径分离

分类使用不可变 category_id 作为引用,使用可变 path 作为目录快照:

{
  "id": "music.live",
  "media_type": "音乐",
  "name": "现场专辑",
  "path": ["现场专辑"]
}

path 始终相对于媒体类型根目录;是否增加 电影、电视剧、音乐 一级目录仍由目录配置中的 按类型分类开关决定。分类改名只改变 name 或 path,目录配置、订阅和规则仍引用 music.live。

4. 总体架构

                           +------------------------------+
                           | MoviePilot-Frontend          |
                           | 策略编辑器 / 预览 / 影响分析 |
                           +---------------+--------------+
                                           |
                                           v
+---------------------+       +------------+-------------+
| Media Source Catalog|------>| Classification API       |
| 内置来源 + 插件来源 |       | policy/fields/preview    |
+----------+----------+       +------------+-------------+
           |                               |
           v                               v
+----------+-------------------------------+-------------+
| Application: classification                            |
| 策略读取、版本检查、人工覆盖、结果发布、配置迁移       |
+-----------------------------+--------------------------+
                              |
                              v
+-----------------------------+--------------------------+
| Domain: classification                                   |
| ClassificationFacts + Rule AST + 纯求值器 + 命中解释    |
+-----------------------------+--------------------------+
                              ^
                              |
+-----------------------------+--------------------------+
| MediaInfo / MusicInfo / MetaBase / MetaMusic             |
| 来源投影后的标准字段 + 受控扩展事实                      |
+----------------------------------------------------------+

4.1 后端模块归属

建议新增同名包 app/domain/classification/:

文件 责任
model.py 分类、策略、条件树、事实、结果和解释的不可变领域模型
facts.py 从标准媒体对象构造来源无关事实,不读取运行时配置
evaluator.py 纯规则求值、顺序控制和命中解释
validation.py 字段、操作符、路径和不可达规则的纯校验

建议新增 app/application/classification/:

文件 责任
service.py 读取当前策略、分类媒体、解析人工覆盖、返回有效结果
configuration.py 策略发布、版本冲突检查、历史快照和 legacy 迁移
catalog.py 合并内置字段目录与插件来源能力声明
contract.py Application 使用的配置端口和公开 DTO

建议新增 app/startup/composition/classification.py,负责:

  • 使用 SystemConfigService 装配策略仓储。
  • 构造唯一的 MediaClassificationService。
  • 注入媒体链和 API runtime。
  • 在启动时执行一次 legacy 配置迁移。

app/chain/ 只负责在识别和整理工作流中调用已注入的分类服务,不读取配置文件,不实现条件比较。

4.2 前端模块归属

建议用以下组件替换当前单体 CategoryEditDialog.vue:

文件 责任
src/components/classification/ClassificationPolicyDialog.vue 编辑器外壳、加载、保存和发布
ClassificationCategoryList.vue 分类树、排序、启停和路径编辑
ClassificationRuleList.vue 规则列表、复制、拖拽和摘要
ClassificationConditionBuilder.vue 递归条件组编辑器
ClassificationRuleInspector.vue 当前规则详细配置
ClassificationPreviewPanel.vue 单条测试、事实查看和命中解释
ClassificationImpactDialog.vue 发布前批量影响分析
src/composables/useMediaClassification.ts API、草稿、脏状态和能力目录缓存

5. 领域模型

5.1 分类策略

{
  "schema_version": 2,
  "revision": 12,
  "mode": "first_match",
  "categories": [],
  "rules": [],
  "fallbacks": {},
  "field_aliases": {},
  "updated_at": "2026-09-02T12:00:00+08:00"
}

字段说明:

字段 说明
schema_version 配置结构版本,用于后续迁移
revision 每次成功发布递增,用于并发修改检测和任务快照
mode 主分类规则首版固定为 first_match,保留后续策略扩展位置
categories 稳定分类定义
rules 全局有序的主分类规则和附加标签规则
fallbacks 每种媒体类型的兜底分类 ID
field_aliases 可选值别名,例如音乐流派同义词,不改变字段 ID

5.2 分类定义

{
  "id": "tv.anime.jp",
  "media_type": "电视剧",
  "name": "日番",
  "path": ["动漫", "日番"],
  "enabled": true,
  "labels": ["动漫", "日本"]
}

约束:

  • id 使用稳定小写标识,允许小写字母、数字、点、下划线和连字符。
  • path 使用路径段数组,不接受用户直接输入带 / 或 \\ 的完整路径。
  • 单段禁止空值、.、..、控制字符和平台非法文件名。
  • 建议最大 4 级、单段 64 字符、总长度 240 字符。
  • 同一媒体类型下目录路径必须唯一。
  • 删除已被目录、订阅或规则引用的分类前必须先解除引用;普通改名不改变 ID。

5.3 规则定义

{
  "id": "rule.tv.anime.jp",
  "name": "日本动画",
  "kind": "category",
  "enabled": true,
  "priority": 100,
  "media_types": ["电视剧"],
  "sources": [],
  "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",
    "labels": ["动漫"]
  }
}

规则顺序由列表位置决定,priority 是服务端保存后的稳定投影。kind 分为:

  • category:主目录分类规则,必须指定 category_id。
  • label:附加标签规则,只能输出 labels,不得改变主目录分类。

首版采用以下确定性语义:

  1. 跳过禁用规则。
  2. 先检查媒体类型。
  3. sources 为空时允许所有来源;非空时只允许列出的来源。
  4. 递归求值 when。
  5. category 规则采用首条命中,确定主分类后忽略后续 category 规则。
  6. 所有命中的 label 规则累积标签并稳定去重;命中的主分类规则也可附带标签。
  7. 没有主分类规则命中时使用该媒体类型的 fallbacks。

5.4 条件树

条件树只有两种节点:条件组和叶子条件。

{
  "any": [
    {"field": "media.language", "operator": "in", "value": ["zh", "yue"]},
    {
      "all": [
        {"field": "media.countries", "operator": "contains_any", "value": ["CN", "HK", "TW"]},
        {"field": "media.year", "operator": "between", "value": [2000, 2099]}
      ]
    }
  ]
}

支持的组:

  • all:全部子项为真。
  • any:任一子项为真。
  • not:仅包含一个子项并取反。

操作符由字段类型决定:

类型 操作符
字符串 equals、not_equals、in、not_in、contains、starts_with、ends_with
数字/年份 equals、not_equals、gt、gte、lt、lte、between
字符串列表 contains_any、contains_all、contains_none
布尔 is_true、is_false
任意字段 exists、not_exists

缺失值规则必须固定:

  • 除 not_exists 外,字段缺失一律判定为不匹配。
  • not_in、not_equals、contains_none 不得把缺失值当成命中。
  • 用户要匹配缺失值时必须显式使用 not_exists。

这可避免来源字段不足时意外落入排除型规则。

5.5 分类事实

规则不直接读取 tmdb_info、douban_info、bangumi_info、anilist_info、 MusicInfo.raw_data。求值输入统一为 ClassificationFacts:

{
  "identity": {
    "media_source": "musicbrainz",
    "media_id": "..."
  },
  "media": {
    "type": "音乐",
    "title": "示例专辑",
    "year": 2024,
    "language": null,
    "countries": ["JP"],
    "genre_keys": ["rock"],
    "genre_names": ["Rock"],
    "adult": false
  },
  "music": {
    "entity_type": "album",
    "album_type": "Album",
    "secondary_types": ["Live"],
    "tags": ["j-rock"],
    "artists": ["示例艺术家"]
  },
  "extensions": {}
}

建议的标准字段目录:

字段 ID 类型 适用类型 说明
identity.media_source string 全部 当前主身份来源
media.type enum 全部 电影、电视剧、音乐
media.year integer 全部 标准发行或首播年份
media.language string 全部 ISO 639 标准语言代码
media.countries list 全部 ISO 3166 国家/地区代码
media.genre_keys list 全部 MoviePilot 规范化类型键
media.genre_names list 全部 来源投影后的类型名称
media.adult boolean 影视 成人内容标记
media.runtime integer 影视 分钟
media.content_rating string 影视 内容分级
media.companies list 影视 出品公司或工作室名称
media.networks list 电视剧 电视台或平台名称
music.entity_type enum 音乐 recording、album、artist
music.album_type string 音乐 Album、EP、Single 等
music.secondary_types list 音乐 Live、Compilation、Soundtrack 等
music.genres list 音乐 标准化音乐流派名称
music.tags list 音乐 来源标签
music.artist_country string 音乐 艺术家国家/地区
music.release_status string 音乐 Official、Bootleg 等

media.genre_keys 用于跨来源通用规则,值采用 MoviePilot 稳定词表,例如 animation、documentary、reality、talk、kids、music。来源投影负责把 TMDB 数字 ID、 豆瓣类型名、Bangumi 标签、AniList Genre 等转换为这些规范键;无法规范化的值仍保留在 genre_names 或音乐 genres/tags 中。

在界面中,这些内容统一称为“媒体信息”。“统一风格”只是为了让不同数据源的同一种风格可以使用 同一条规则匹配,普通用户不需要理解内部键名;搜索并选择媒体后,页面会同时展示来源返回的风格名称 和用于规则匹配的统一风格。

5.6 插件扩展事实

插件媒体来源可以声明额外分类字段,但必须满足:

  1. 字段只能位于 extensions.<media_source>.<field> 命名空间。
  2. 字段描述包括显示名、值类型、允许操作符、适用媒体类型和可选枚举值。
  3. 插件返回的事实必须是 JSON 标量或标量列表。
  4. 插件不能覆盖 identity.*、media.*、music.* 标准字段。
  5. 插件未加载或字段缺失时,相关条件按缺失值语义处理。

建议扩展 MediaSourceInfo:

{
  "name": "Example Source",
  "media_source": "example.source",
  "media_types": ["电影", "电视剧"],
  "classification_fields": [
    {
      "id": "extensions.example.source.region_group",
      "label": "来源地区组",
      "value_type": "string",
      "operators": ["equals", "in"]
    }
  ]
}

插件识别结果通过受控的 classification_facts 字段提供对应值。键必须使用完整 extensions.<media_source>.<field> 字段 ID;宿主按结果自身的 media_source、媒体类型、当前启用插件注册表和 字段值类型验证后,转换为分类器内部的来源局部事实。无效字段按稳定诊断码记录并忽略,不得改变 media_source + media_id,也不得导致媒体识别失败。

插件通过稳定入口 app.sdk.classification 检测 MEDIA_SOURCE_CLASSIFICATION_PROTOCOL_VERSION >= 1,并可从该入口导入 MediaSourceInfo、 ClassificationFieldDefinition 和 ClassificationFactValue。旧宿主或未声明字段的旧插件保持原有 get_media_source() 与识别返回值;不会隐式获得扩展事实写入权限。插件禁用、停止、卸载、重载或启动失败时, 运行时立即撤销对应运行实例拥有的字段注册,已保存规则保留但重新校验为字段不可用。

6. 多数据源能力模型

6.1 来源能力目录

后端必须成为字段能力的唯一事实来源,前端不再硬编码 TMDB Genre、国家和语言字段。

GET /api/v1/media/classification/fields 返回:

{
  "fields": [
    {
      "id": "media.countries",
      "label": "国家/地区",
      "value_type": "string_list",
      "operators": ["contains_any", "contains_all", "contains_none", "exists", "not_exists"],
      "media_types": ["电影", "电视剧", "音乐"],
      "options": [],
      "source_support": {
        "themoviedb": "native",
        "douban": "derived",
        "anilist": "native",
        "musicbrainz": "partial"
      }
    }
  ]
}

支持等级:

值 含义
native 来源直接提供标准值
derived 可从来源其它字段稳定推导
partial 只有部分对象或详情级响应提供
extension 插件声明的扩展字段
unavailable 来源无法提供

UI 根据用户选择的媒体类型和来源过滤字段,并显示覆盖提示。规则仍允许保存 partial 字段,但必须提示 “字段缺失时本规则不会命中”。

6.2 可选跨源补充

首版默认使用 primary_only:只使用当前识别结果和本地解析事实。选择 enrich_missing 后, 分类执行服务和影响分析会在需要时补充缺失的标准媒体信息:

  • 只请求当前策略实际引用且当前缺失的标准事实。
  • 使用独立可选模块方法 get_media_classification_facts。
  • 通过外部 ID 或明确映射查找同一媒体,不改变主 media_source + media_id。
  • 单来源失败隔离,设置总超时、并发上限和 TTL 缓存。
  • 结果记录每个事实的提供来源,预览中可查看。
  • 整理主流程在补充失败时继续使用已有事实,不把分类补充变成整理硬依赖。

7. 分类结果与覆盖规则

7.1 分类结果

{
  "recommended": {
    "category_id": "music.live",
    "category_path": ["现场专辑"],
    "rule_id": "rule.music.live"
  },
  "effective": {
    "category_id": "music.live",
    "category_path": ["现场专辑"],
    "source": "automatic"
  },
  "labels": ["现场"],
  "policy_revision": 12,
  "state": "complete"
}

state 可为:

  • complete:使用详情级事实完成求值。
  • partial:存在规则所需字段缺失,但仍得到其它规则或兜底结果。
  • not_evaluated:列表摘要等路径没有执行分类。
  • invalid_policy:策略不可用,回退兼容行为并记录错误。

7.2 有效分类优先级

最终 effective 按以下优先级解析:

  1. 当前请求明确指定的 category_id。
  2. 订阅、下载历史或人工整理持久化的分类覆盖。
  3. 用户显式选定目录时,该目录绑定的固定分类。
  4. 当前策略自动推荐分类。
  5. 媒体类型默认分类。

来源 metadata_category 永远不自动升级为 library_category。用户确实希望按 Live、Album、Rock 分类时,应通过显式规则引用 music.secondary_types、music.album_type、music.genres。

8. 执行与生命周期

8.1 识别结果收口

在 MediaChain 中新增统一的结果收口方法,例如 _finalize_recognition_result():

  1. 校验并规范化 media_source + media_id。
  2. 完成来源到 MediaInfo / MusicInfo 的标准投影。
  3. 构造 ClassificationFacts。
  4. 执行当前策略并写入 library_category 和 classification。
  5. 保留 metadata_category。
  6. 返回最终对象。

所有同步、异步、按标题、按 ID、缓存命中、插件识别路径必须经过该方法。分类器本身纯同步,异步路径不 维护第二份实现。

8.2 缓存语义

  • 来源识别缓存只缓存来源投影结果,不把自动分类结果作为长期真值。
  • 每次从识别缓存恢复对象后,使用当前策略重新分类。
  • 如需要分类结果缓存,键必须包含 policy_revision 和分类事实摘要。
  • 修改策略无需清空 TMDB、MusicBrainz 等识别缓存。

8.3 整理任务冻结

整理计划创建时写入:

  • category_id
  • library_category 路径快照
  • classification_rule_id
  • classification_policy_revision
  • 覆盖来源 automatic/manual/subscription/directory

任务进入 durable transfer 计划后不得重新读取当前分类策略。重启恢复继续使用计划中的快照,避免策略修改 导致同一个任务目标目录变化。

8.4 订阅与历史

  • Subscribe.media_category 兼容字段继续保存路径快照。
  • 新增稳定的 media_category_id,人工选择时优先保存 ID。
  • 自动分类不需要在订阅创建时永久锁死;没有人工覆盖的订阅在每次取得完整媒体信息时使用当前策略。
  • 下载历史、整理历史和订阅历史保存实际执行时的分类 ID、路径和策略版本。
  • 分类解释可保存在现有结构化 note 中,避免为完整条件明细增加大量列;常用查询字段使用正式列。

9. 配置持久化与版本

9.1 存储方式

新增 SystemConfigKey.MediaClassificationPolicy,值为版本化策略状态包:

{
  "active": {"schema_version": 2, "revision": 12, "categories": [], "rules": []},
  "history": [
    {"schema_version": 2, "revision": 11, "categories": [], "rules": []}
  ]
}

API 常规读取只返回 active,历史接口按需读取 history。选择该存储方式的理由:

  • 分类策略是管理员可编辑的运行时业务配置。
  • 不需要为分类和规则各建一张高频查询表。
  • 策略可以作为一个整体原子校验、替换和发布。
  • 与现有目录、过滤规则等配置管理方式一致。

同一个配置值中保留最近 10 个已发布快照,避免活跃策略和历史分别写入两个 key 造成非原子状态。

9.2 发布并发控制

更新请求必须携带客户端读取到的 revision:

  1. Application 定义原子 compare_and_set(expected_revision, state) 配置端口。
  2. DB Adapter 在同一事务中锁定对应 SystemConfig 行、重新读取当前 revision 并完成整体替换。
  3. revision 不一致时返回 409 Conflict,不覆盖他人修改。
  4. 校验通过后 revision 加一,旧版本进入有界历史。
  5. 提交成功后刷新进程内不可变策略引用;进程锁只用于减少同进程竞争,不能替代数据库原子检查。

9.3 回滚

回滚不是覆盖旧 revision,而是选择历史快照内容并发布为新 revision。这样审计、运行中整理任务和缓存键 都保持单调版本语义。

10. API 设计

10.1 新 API

方法 路径 权限 用途
GET /api/v1/media/classification/policy 登录用户 读取当前策略和 revision
PUT /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 超级管理员 对近期历史样本比较旧/新策略
GET /api/v1/media/classification/history 超级管理员 查询可回滚版本
POST /api/v1/media/classification/rollback/{revision} 超级管理员 以历史内容发布新版本

字段目录响应同时返回服务端规则限制。枚举型字段的 options 使用稳定的 {value, label} 对象,allow_custom_values 明确表示前端是否允许目录外输入;前端不得根据字段名硬编码 输入控件或限制值。预览请求使用带 kind 判别字段的输入联合体,当前支持:

  • {"kind": "media", "media": {...}}:前端搜索并选择的完整媒体信息。
  • {"kind": "facts", "facts": {...}}:保留给旧调用方和集成测试的标准分类信息。

发布、回滚和影响分析都携带 expected_revision。revision 不一致时返回标准 Response 包装的 409,data 为 {expected_revision, current_revision};领域校验失败返回标准 Response 包装的 422,data 为完整 ClassificationValidationResult。客户端收到任一响应都必须保留本地草稿。

10.2 预览响应

{
  "facts": {},
  "result": {},
  "trace": [
    {
      "rule_id": "rule.music.live",
      "matched": true,
      "conditions": [
        {
          "path": "when.all[0]",
          "field": "music.secondary_types",
          "operator": "contains_any",
          "expected": ["Live"],
          "actual": ["Live"],
          "matched": true
        }
      ]
    }
  ],
  "warnings": [
    {
      "code": "missing_fact",
      "message": "分类事实缺少规则所需字段",
      "field": "music.secondary_types",
      "source": "musicbrainz"
    }
  ]
}

解释信息只在测试、名称识别调试和显式预览中完整返回;普通搜索和整理对象只保留规则 ID、revision 和结果, 避免扩大常规响应。

10.3 旧 API 兼容

兼容期只保留只读投影:

  • GET /api/v1/media/category
  • GET /api/v1/media/category/config

兼容规则:

  1. GET /media/category 从新策略投影为 {媒体类型: [分类路径]}。
  2. GET /media/category/config 把可表达为 legacy 结构的规则投影回 movie/tv;包含音乐或复杂条件时返回 新结构标记,并提示旧客户端只读。
  3. 旧 POST /media/category/config 在统一规则编辑器上线后移除;所有新写入只允许通过带 expected_revision 的版本化分类策略 API。
  4. app.modules.themoviedb.CategoryHelper 仅作为旧插件的只读导入兼容符号保留:读取结果投影自活动策略, save() 始终拒绝写入;官方 V3 插件改用 app.sdk.classification.classify_media()。

11. 前端交互设计

11.1 信息架构

分类策略从“设置 -> 目录”中的“自动分类策略”入口进入,点击后打开全屏、可滚动的编辑窗口;分类编辑器在窗口内工作。 窗口内按职责分为“分类树、规则、检查与发布”三个工作区标签;最后一个工作区再分为“结果预览、影响分析、 版本发布与历史”三个标签。移动端保持同样的分层顺序,不把所有表单压缩到同一屏:

+------------------+------------------+----------------------+
| 分类树           | 规则             | 检查与发布           |
| 电影 / 电视剧    | 有序规则列表     | 结果预览             |
| 音乐             | 条件和输出       | 影响分析 / 版本历史  |
+------------------+------------------+----------------------+

不得继续在每条规则中堆叠固定的 TMDB 表单控件。

11.2 分类树

  • 顶部使用电影、电视剧、音乐三个标签页。
  • 分类节点显示名称、目录面包屑、启用状态和规则数量。
  • 支持新增子分类、复制、拖拽排序、重命名和停用。
  • 删除被引用分类时展示引用位置,不允许直接删除。
  • 目录路径逐段编辑,前端和后端同时校验非法字符。
  • 每种媒体类型明确显示兜底分类,且只能有一个。

11.3 规则列表

  • 卡片保持紧凑,展示规则名、来源范围、条件摘要、目标分类和启用状态。
  • 拖拽改变首条命中顺序。
  • 支持复制规则,复制后生成新稳定 ID。
  • 支持只看错误、只看当前来源不可用字段、搜索规则名。
  • 兜底分类在规则列表外单独展示;普通规则不允许使用空条件伪装 catch-all。

11.4 条件编辑器

每个条件行使用:

  1. 字段选择器。
  2. 操作符选择器。
  3. 与字段类型匹配的值控件。
  4. 删除按钮。

字段选择器按“通用、影视、音乐、数据源扩展”分组。值控件由字段目录动态决定:

  • 枚举使用可搜索多选框。
  • 国家、语言使用标准代码目录和本地化显示名。
  • 年份使用数字或范围控件。
  • 布尔使用切换项。
  • 开放音乐流派和标签使用可创建选项的多选输入。

条件组使用 全部满足、任一满足 分段控件,并允许嵌套一层。复杂度上限由后端返回,首版建议最大深度 3、 每条规则最多 30 个叶子条件。

11.5 来源覆盖提示

选择来源范围后:

  • native/derived 字段正常显示。
  • partial 字段显示提示图标和具体说明。
  • 全部所选来源均 unavailable 的字段禁止选择。
  • 已保存规则因插件卸载变为不可用时,不删除规则,显示阻断错误并在发布时要求处理。

11.6 单条测试

页面支持两种测试输入:

  1. 输入关键词搜索并选择一条媒体结果,直接使用结果中的完整媒体信息。
  2. 兼容调用方提交已经构造好的 ClassificationFacts;页面不要求用户手工填写来源编号或字段值。

测试面板显示:

  • 标准分类事实。
  • 自动推荐分类和最终有效分类。
  • 命中规则。
  • 每个条件的实际值和结果。
  • 缺失字段与来源覆盖警告。
  • 最终目录预览。

11.7 发布前影响分析

点击“发布”前可用草稿策略对最近 N 条下载和成功整理历史执行只读比较:

  • 分类未变化数量。
  • 分类发生变化数量。
  • 从有分类变为兜底或未分类数量。
  • 按媒体类型和数据源分组的变化明细。
  • 不执行文件移动,不修改历史和订阅。

变化较大时由用户二次确认,但不使用模糊的“可能有风险”提示,应明确列出影响数量和示例。

首期后端使用最近下载历史与成功整理历史合并抽样,按媒体身份去重,最多比较 200 条、返回 50 条 变化示例。历史表只保存身份、类型、标题和年份,因此服务端会按记录中的来源和编号重新读取完整媒体详情; 单条读取失败会计入“无法获取详情”,不会被误算为没有变化。scanned/skipped/unresolved/truncated 和按来源、类型分组统计随响应返回;整个过程不移动文件、不修改订阅或历史,且对详情读取设置固定并发上限。

11.8 无障碍与移动端

  • 拖拽排序必须同时提供“上移/下移”菜单操作。
  • 所有图标按钮提供 tooltip 和可访问名称。
  • 移动端使用“分类 -> 规则 -> 条件 -> 预览”的分层导航,不把三栏压缩到同一屏。
  • 错误与警告不能只通过颜色表达。

12. 示例策略

12.1 电影动画

{
  "kind": "category",
  "media_types": ["电影"],
  "sources": [],
  "when": {
    "all": [
      {"field": "media.genre_keys", "operator": "contains_any", "value": ["animation"]}
    ]
  },
  "target": {"category_id": "movie.animation"}
}

同一规则可匹配 TMDB 的 Genre ID 16、豆瓣类型名“动画”、Bangumi/AniList 对应类型投影,不在规则里写 任何来源专用字段。

12.2 日本动画剧集

{
  "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"}
}

12.3 现场音乐专辑

{
  "kind": "category",
  "media_types": ["音乐"],
  "when": {
    "all": [
      {"field": "music.entity_type", "operator": "in", "value": ["album", "recording"]},
      {"field": "music.secondary_types", "operator": "contains_any", "value": ["Live"]}
    ]
  },
  "target": {
    "category_id": "music.live",
    "labels": ["现场"]
  }
}

12.4 影视原声

{
  "kind": "category",
  "media_types": ["音乐"],
  "when": {
    "any": [
      {"field": "music.secondary_types", "operator": "contains_any", "value": ["Soundtrack"]},
      {"field": "music.tags", "operator": "contains_any", "value": ["soundtrack", "ost"]}
    ]
  },
  "target": {"category_id": "music.soundtrack"}
}

12.5 来源专用扩展条件

{
  "kind": "category",
  "media_types": ["电影"],
  "sources": ["example.source"],
  "when": {
    "all": [
      {
        "field": "extensions.example.source.region_group",
        "operator": "equals",
        "value": "east_asia"
      }
    ]
  },
  "target": {"category_id": "movie.east_asia"}
}

13. Legacy 迁移方案

13.1 自动迁移

启动时仅在 MediaClassificationPolicy 不存在时执行:

  1. 读取现有 CONFIG_PATH/category.yaml。
  2. 按 YAML 顺序转换 movie、tv 分类。
  3. 为每个分类生成稳定 ID;相同名称通过规范化后附加短摘要避免冲突。
  4. genre_ids 转换为 media.genre_keys;暂时无法映射的 ID 保存为 extensions.themoviedb.genre_ids 兼容条件并发出提示。
  5. 仅当标准事实与 legacy 真值语义可证明一致时,才把字段提升为 media.* 条件;否则使用统一的 extensions.themoviedb.* 旧比较视图。该视图保留假值缺失、字符串大写、列表逐元素字符串化、 production_countries.iso_3166_1 和 release_date/first_air_date 年份截取语义。
  6. original_language、production_countries、origin_country、release_year 等字段若因标准化回退、 类型转换或假值处理可能改变旧结果,必须保留为受控 TMDB 扩展事实,不能为了使用标准字段牺牲等价性。
  7. 其它 TMDB 一级字段转换到受控 extensions.themoviedb.* 字段;无法登记的字段阻止自动发布,保留 legacy 运行并提示管理员处理。
  8. 首个空规则分类转换为该媒体类型的全局 fallbacks,其后的 legacy 项在旧实现中本就不可达,迁移时保持禁用并向管理员报告。
  9. 新策略为电影、电视剧和音乐保留按媒体类型的通用兜底;数据源只能作为规则的筛选条件,不能单独决定默认分类。 尚未配置音乐分类时,追加 Album、Album / Compilation、EP、Single 四个常用分类和显式规则。 Album / Compilation 只是展示名称,目录路径保存为 ["Album", "Compilation"] 两个无空白片段,且精选集规则先于普通专辑规则。 下载历史的资源目录识别归类也消费识别结果中的生效分类路径,因此多级路径会原样映射为下载器保存目录;分类结果不可用时才兼容退回音乐主类型。
  10. 保存新策略 revision 1,并保留原 YAML 文件只读备份,不再继续写入。已经存在的旧版 revision 1 仅兜底默认策略,只在没有历史且内容与旧默认值完全一致时通过 CAS 升级为 revision 2;任何用户编辑过的策略均保持原样。
  11. 同一字段的正向枚举合并为 contains_any,排除枚举合并为 contains_none;国家、语言等值数量 不应展开为叶子数量。已知和未知 Genre ID 仍分别使用标准字段与受控扩展字段,保持原有 OR 语义。
  12. 迁移后的策略仍需通过完整发布校验;超出真实条件复杂度或引用约束时不写入新策略,保留旧分类 只读兼容行为和结构化错误诊断,不得让发布校验异常中断宿主启动。

13.2 行为兼容

  • 迁移测试必须证明同一批 TMDB fixture 在旧分类器和新分类器下得到完全相同的目录分类。
  • 旧配置中的 !值 转换为明确的排除操作符。
  • 旧逗号字符串转换为数组,后续 API 不再使用逗号编码多值。
  • 旧只读 API 将集合逐值投影为逗号项,每个排除值独立添加 !;范围允许展开为等价枚举,不改写原 YAML。
  • 迁移后 TMDB 模块不再实例化 CategoryHelper,也不再写 MediaInfo.category。

13.3 字段迁移

建议分两个版本完成:

  1. 第一个版本同时写 library_category 和兼容 category,读侧优先新字段。
  2. 前端、目录、订阅、下载、历史、通知全部迁移后,category 只保留序列化兼容属性。
  3. 音乐 category 来源数据迁入 metadata_category,音乐 UI 不再读取兼容 category 展示专辑类型。
  4. 迁移完成后,前端默认隐藏未被现有规则引用的 TMDB 旧字段;仍被旧规则引用的字段会以“旧规则”分组只读显示, 直到用户把规则改为新的统一字段。

14. 校验规则

服务端发布前必须拒绝:

  • 重复分类 ID、规则 ID、同媒体类型重复路径。
  • 不存在的目标分类或跨媒体类型目标。
  • 空分类名称、非法路径段、目录穿越片段。
  • 未登记字段或字段不支持的操作符。
  • 条件值类型错误、范围起止颠倒、空 in 列表。
  • 递归深度、规则数或条件数超过上限。
  • 普通规则条件为空;兜底分类必须通过 fallbacks 表达。
  • 同一媒体类型没有可用兜底分类。
  • 插件扩展字段的命名空间与来源不一致。
  • 规则引用已卸载插件且没有兼容处理。

服务端应警告但允许发布:

  • 某字段仅被部分来源支持。
  • 两条规则可能有重叠,但顺序可以明确决定结果。
  • 分类路径发生变化,可能影响未来整理目标。
  • 当前策略没有覆盖某个已注册但未启用的数据源。

15. 测试方案

15.1 后端单元测试

  • 条件操作符和缺失值三态语义。
  • all/any/not 嵌套、最大深度和短路行为。
  • 首条命中、兜底、标签累积和人工覆盖优先级。
  • 分类 ID、路径和引用完整性校验。
  • 策略 revision 冲突和回滚单调递增。
  • 分类事实构造不修改原始 MediaInfo / MusicInfo。

15.2 来源契约测试

至少覆盖:

  • TMDB:Genre ID、语言、电影出品国、电视剧原产国、年份。
  • 豆瓣:类型名、国家、年份。
  • Bangumi:标签、平台、工作室、年份。
  • AniList:Genre、国家、格式、年份。
  • IMDb、TVDB:当前统一字段投影可用部分。
  • MusicBrainz:专辑主类型、副类型、流派、标签、发行状态。
  • TheAudioDB:流派、风格、专辑和艺术家信息。
  • 豆瓣音乐:专辑、艺术家、流派和发行信息的可用部分。
  • 一个同时支持影视和音乐的插件来源。

来源能力声明必须与实际 fixture 投影一致,避免 UI 显示支持但运行时永远缺失。

15.3 工作流回归测试

  • 同步和异步识别得到相同分类。
  • 按标题、按 ID、缓存命中和插件识别都经过分类收口。
  • 音乐 metadata_category 不会被当成媒体库分类。
  • 目录按分类 ID 匹配,改名后引用不失效。
  • 多级分类路径安全生成。
  • 订阅人工分类覆盖自动分类。
  • 下载历史和整理历史保存实际分类快照。
  • durable transfer 恢复使用计划中的 revision 和路径,不受策略更新影响。
  • legacy category.yaml fixture 迁移前后 TMDB 分类完全一致。

15.4 前端测试

  • 字段和操作符完全由后端能力目录驱动。
  • 电影、电视剧、音乐规则的新增、复制、排序、删除和保存。
  • 来源 partial/unavailable 提示。
  • 分类被引用时禁止删除。
  • revision 冲突时保留本地草稿并提供重新加载/合并选择。
  • 预览展示实际值、失败条件和最终目录。
  • 预览必须通过搜索选择媒体,不再要求用户手工填写事实;编辑分类弹窗和帮助弹窗在透明主题下保持玻璃表面、边框和遮罩。
  • 桌面和窄屏布局不重叠,键盘可完成排序和编辑。

15.5 性能目标

  • 200 条规则、每条平均 6 个条件时,单次纯求值 P95 小于 5ms。
  • 常规识别路径不因分类新增额外网络请求。
  • 影响分析分批执行并限制样本数量,不阻塞 API 事件循环。
  • 策略发布后使用不可变快照替换,不在每次分类时解析 JSON。

16. 分阶段实施

阶段 A:语义和领域基础

  1. 新增 library_category、metadata_category、ClassificationFacts 和 ClassificationResult。
  2. 将音乐来源描述性 category 迁移到 metadata_category。
  3. 实现纯规则模型、求值器和校验器。
  4. 建立标准字段和来源能力目录。

阶段 B:配置与兼容

  1. 新增 MediaClassificationPolicy SystemConfig。
  2. 实现策略服务、revision、历史和回滚。
  3. 实现 category.yaml 自动迁移。
  4. 保留旧 GET API 投影,统一编辑器上线后移除 legacy 客户端写入口。

阶段 C:识别与整理接入

  1. 从 TheMovieDb 模块移除分类副作用。
  2. 统一所有识别结果收口。
  3. 迁移目录选择、订阅、下载和历史消费者到分类 ID + 路径快照。
  4. 在 durable transfer 计划中冻结分类结果。

阶段 D:前端规则编辑器

  1. 用动态字段编辑器替换 TMDB 专用弹窗。
  2. 支持音乐、来源范围和组合条件。
  3. 增加单条预览、校验解释和影响分析。
  4. 目录设置改为保存分类 ID,展示当前路径。

阶段 E:插件和可选跨源补充

  1. 扩展插件媒体来源注册协议和文档。
  2. 支持受控扩展事实。
  3. 根据实际需要增加 enrich_missing,默认仍关闭。
  4. 移除旧 YAML 写入口和 TMDB 内部 CategoryHelper 依赖,仅保留旧插件只读导入兼容符号。

17. 验收标准

以下条件全部满足才可认为新体系完成:

  1. 电影、电视剧、音乐都能在同一 UI 中配置自动分类。
  2. TMDB、豆瓣、Bangumi、AniList、MusicBrainz、TheAudioDB、豆瓣音乐以及插件来源至少各有 一条真实 fixture 通过分类测试。
  3. 规则引擎和 Application 分类服务不导入具体来源模块。
  4. 同步、异步、缓存和插件识别分类结果一致。
  5. media_source + media_id 在分类前后保持不变。
  6. 音乐来源分类不会直接成为目录分类。
  7. 分类改名不破坏目录、订阅和规则引用。
  8. 发布策略前能看到校验结果和近期样本影响。
  9. 运行中的整理任务在策略更新后仍使用原计划目标。
  10. 现有 category.yaml 可以自动迁移,TMDB 旧规则行为无回归。

17.1 落地验收证据

验收项 自动化证据 状态
统一 UI 支持电影、电视剧、音乐 前端 AccountSettingClassification、分类树、规则与条件构建器测试 通过
内置与插件来源 fixture test_media_classification_source_contracts.py;官方 LunaTVSource CMS 分类集成测试 通过
分类层不导入具体来源 test_media_classification_architecture.py 静态扫描 Domain/Application 分类包 通过
同步、异步、缓存、插件结果一致 test_media_classification_execution.py、test_media_classification_plugin_extensions.py、LunaTVSource 同异步测试 通过
分类前后身份不变 分类事实、执行服务、插件事实与缓存往返测试 通过
音乐来源分类不成为目录分类 分类事实、执行服务和官方插件历史快照测试 通过
改名不破坏稳定引用 配置服务、目录解析和订阅引用测试 通过
发布前校验与影响分析 分类 API 测试;前端发布控制、影响面板和 revision 冲突测试 通过
Durable plan 冻结原目标 test_media_classification_transfer_checkpoint.py 的 planned replay 与 provider promotion 测试 通过
YAML 自动迁移且旧规则等价 test_media_classification_startup.py 与 test_media_classification_legacy.py 通过

最终验收于 2026-09-03 完成:后端 8149 passed, 9 skipped;前端 249 个测试文件、2475 项测试通过;官方插件仓 CI 55 项、V3 580 项、兼容 V2 39 项通过。宿主与插件架构基线、严格 mypy、Ruff/mypy 低水位、事件、并发、异步阻塞、复杂度、schema 导出、Agent API 审计、Alembic 唯一 head 和启动性能门禁全部通过。

18. 推荐决策摘要

决策项 推荐方案
分类所有权 独立 Domain + Application 能力,不属于任何数据源模块
配置存储 SystemConfigKey.MediaClassificationPolicy 版本化整体存储
求值模式 有序首条命中,一个主分类,可附加多个标签
多来源规则 优先使用标准事实;来源特有字段使用受控命名空间
音乐字段 metadata_category 与 library_category 强制分离
分类引用 稳定 category_id + 可变目录路径快照
前端表单 后端字段目录驱动的条件构建器,不硬编码 TMDB 字段
默认跨源行为 primary_only,可选 enrich_missing 后续提供
可解释性 保存 rule ID/revision,预览按条件返回 trace
兼容策略 自动迁移 YAML;旧 GET 保留只读投影,旧 POST 在统一编辑器上线后移除

分类配置的字典与命名约定

  • domain/classification/vocabulary.py 拥有离线词表,供事实归一化、字段目录与旧字段展示复用;不访问数据源、配置或数据库。
  • 国家、语言和规范风格使用可搜索的中文名称与稳定值;来源原始字段使用按来源区分的开放候选,不将示例目录当作封闭枚举。
  • 分类名称、分类路径和用途分开表达。规则命中后“归入分类”,全部未命中才使用“默认分类”;两者引用同一份分类定义。
  • 旧迁移中的国家条件仍读取原字段,新增条件使用标准字段。迁移兼容提示不代表错误,也不自动改变既有规则语义。
  • 新迁移只创建实际需要的默认未分类目录。早期生成的 未分类/通用 在 UI 标为“备用未分类”,保留 ID、路径、目录引用和历史配置。

共享离线词表新增一个纯领域模块,启动导入模块数按实际依赖变化更新:app.startup.lifecycle 539 → 540、app.factory 551 → 552、app.main 553 → 554;保留原有耗时预算、采样和生命周期约束。三次本地采样中位数约 1.2 秒,低于既有预算。