48 KiB
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在构造 TMDBMediaInfo时直接计算并写入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、语种和国家选项。
这导致以下问题:
- 使用豆瓣、Bangumi、AniList、IMDb、TVDB 或插件数据源识别时,无法得到与 TMDB 等价的自动目录分类。
- 音乐虽然已有 MusicBrainz、TheAudioDB、豆瓣音乐以及本地音频标签能力,但没有独立的 自动分类策略。
- 来源原始字段与分类规则直接绑定,来源接口变化会使用户规则失效。
- 分类逻辑在来源模块内部执行,缓存命中、同步/异步识别、插件识别等路径容易出现行为差异。
- 当前
category字段存在语义冲突:影视使用它表示媒体库分类,音乐使用它表示Album / Live、流派等来源元数据;整理历史又统一把该字段写成目录分类。 - 分类名称同时充当稳定身份和目录路径,重命名分类会影响目录设置、订阅和历史记录。
- 当前 UI 只能编辑固定条件,不能表达组合条件、来源范围、兜底规则、规则解释和发布前预览。
本设计将分类能力从 TMDB 模块中拆出,建立一套来源无关、媒体类型可扩展、可解释、可迁移、 可由前端完整配置的统一分类体系。
2. 目标
2.1 功能目标
- 支持电影、电视剧、音乐,后续新增媒体类型时不需要重写规则引擎。
- 支持所有注册媒体数据源,包括内置来源和插件来源。
- 同一条基于标准字段的规则可以跨来源工作,不要求用户理解来源原始 JSON。
- 允许规则按媒体类型、数据源、语言、国家、年份、类型、标签、音乐实体和专辑属性等条件匹配。
- 支持
AND、OR、NOT、范围、包含、缺失值等明确的组合语义。 - 每个媒体只产生一个主目录分类,同时允许附加多个非目录标签。
- 支持多级目录分类,并使用稳定分类 ID,目录名称可以安全重命名。
- 前端支持新增、复制、排序、启停、校验、测试、影响预览和回滚。
- 分类结果可解释:能够回答“命中了哪条规则”“哪些条件通过或失败”“使用了哪些事实”。
- 保留现有
category.yaml用户配置,并提供无损迁移与旧 API 兼容期。
2.2 工程目标
- 分类规则求值必须是纯函数,不执行网络、文件或数据库 I/O。
- 分类器不直接依赖 TMDB、豆瓣、MusicBrainz 等具体模块。
- 同步和异步识别路径使用同一个分类结果收口点。
- 媒体身份始终保留原始
media_source + media_id,分类不得隐式转换主身份。 - 识别缓存命中后仍按当前策略重新分类,避免策略更新后返回旧分类。
- 整理任务进入持久计划后冻结分类结果和策略版本,运行中的任务不受后续策略修改影响。
- 用户配置通过
SystemConfigKey管理,不继续由某个来源模块拥有配置文件。
2.3 非目标
- 本设计不替代搜索、订阅使用的资源过滤规则和优先级规则。
- 默认不为了分类而调用所有外部数据源,避免额外延迟、配额消耗和不稳定性。
- 分类器不允许 UI 直接执行任意 Python 表达式、Jinja、JSONPath 或未受控正则。
- 来源原始响应不作为长期规则协议;原始字段只能先投影为已登记的分类事实。
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,不得改变主目录分类。
首版采用以下确定性语义:
- 跳过禁用规则。
- 先检查媒体类型。
sources为空时允许所有来源;非空时只允许列出的来源。- 递归求值
when。 category规则采用首条命中,确定主分类后忽略后续category规则。- 所有命中的
label规则累积标签并稳定去重;命中的主分类规则也可附带标签。 - 没有主分类规则命中时使用该媒体类型的
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 插件扩展事实
插件媒体来源可以声明额外分类字段,但必须满足:
- 字段只能位于
extensions.<media_source>.<field>命名空间。 - 字段描述包括显示名、值类型、允许操作符、适用媒体类型和可选枚举值。
- 插件返回的事实必须是 JSON 标量或标量列表。
- 插件不能覆盖
identity.*、media.*、music.*标准字段。 - 插件未加载或字段缺失时,相关条件按缺失值语义处理。
建议扩展 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 按以下优先级解析:
- 当前请求明确指定的
category_id。 - 订阅、下载历史或人工整理持久化的分类覆盖。
- 用户显式选定目录时,该目录绑定的固定分类。
- 当前策略自动推荐分类。
- 媒体类型默认分类。
来源 metadata_category 永远不自动升级为 library_category。用户确实希望按 Live、Album、Rock
分类时,应通过显式规则引用 music.secondary_types、music.album_type、music.genres。
8. 执行与生命周期
8.1 识别结果收口
在 MediaChain 中新增统一的结果收口方法,例如 _finalize_recognition_result():
- 校验并规范化
media_source + media_id。 - 完成来源到
MediaInfo/MusicInfo的标准投影。 - 构造
ClassificationFacts。 - 执行当前策略并写入
library_category和classification。 - 保留
metadata_category。 - 返回最终对象。
所有同步、异步、按标题、按 ID、缓存命中、插件识别路径必须经过该方法。分类器本身纯同步,异步路径不 维护第二份实现。
8.2 缓存语义
- 来源识别缓存只缓存来源投影结果,不把自动分类结果作为长期真值。
- 每次从识别缓存恢复对象后,使用当前策略重新分类。
- 如需要分类结果缓存,键必须包含
policy_revision和分类事实摘要。 - 修改策略无需清空 TMDB、MusicBrainz 等识别缓存。
8.3 整理任务冻结
整理计划创建时写入:
category_idlibrary_category路径快照classification_rule_idclassification_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:
- Application 定义原子
compare_and_set(expected_revision, state)配置端口。 - DB Adapter 在同一事务中锁定对应 SystemConfig 行、重新读取当前 revision 并完成整体替换。
- revision 不一致时返回
409 Conflict,不覆盖他人修改。 - 校验通过后 revision 加一,旧版本进入有界历史。
- 提交成功后刷新进程内不可变策略引用;进程锁只用于减少同进程竞争,不能替代数据库原子检查。
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/categoryGET /api/v1/media/category/config
兼容规则:
GET /media/category从新策略投影为{媒体类型: [分类路径]}。GET /media/category/config把可表达为 legacy 结构的规则投影回movie/tv;包含音乐或复杂条件时返回 新结构标记,并提示旧客户端只读。- 旧
POST /media/category/config在统一规则编辑器上线后移除;所有新写入只允许通过带expected_revision的版本化分类策略 API。 app.modules.themoviedb.CategoryHelper仅作为旧插件的只读导入兼容符号保留:读取结果投影自活动策略,save()始终拒绝写入;官方 V3 插件改用app.sdk.classification.classify_media()。
11. 前端交互设计
11.1 信息架构
分类策略从“设置 -> 目录”中的“自动分类策略”入口进入,点击后打开全屏、可滚动的编辑窗口;分类编辑器在窗口内工作。 窗口内按职责分为“分类树、规则、检查与发布”三个工作区标签;最后一个工作区再分为“结果预览、影响分析、 版本发布与历史”三个标签。移动端保持同样的分层顺序,不把所有表单压缩到同一屏:
+------------------+------------------+----------------------+
| 分类树 | 规则 | 检查与发布 |
| 电影 / 电视剧 | 有序规则列表 | 结果预览 |
| 音乐 | 条件和输出 | 影响分析 / 版本历史 |
+------------------+------------------+----------------------+
不得继续在每条规则中堆叠固定的 TMDB 表单控件。
11.2 分类树
- 顶部使用电影、电视剧、音乐三个标签页。
- 分类节点显示名称、目录面包屑、启用状态和规则数量。
- 支持新增子分类、复制、拖拽排序、重命名和停用。
- 删除被引用分类时展示引用位置,不允许直接删除。
- 目录路径逐段编辑,前端和后端同时校验非法字符。
- 每种媒体类型明确显示兜底分类,且只能有一个。
11.3 规则列表
- 卡片保持紧凑,展示规则名、来源范围、条件摘要、目标分类和启用状态。
- 拖拽改变首条命中顺序。
- 支持复制规则,复制后生成新稳定 ID。
- 支持只看错误、只看当前来源不可用字段、搜索规则名。
- 兜底分类在规则列表外单独展示;普通规则不允许使用空条件伪装 catch-all。
11.4 条件编辑器
每个条件行使用:
- 字段选择器。
- 操作符选择器。
- 与字段类型匹配的值控件。
- 删除按钮。
字段选择器按“通用、影视、音乐、数据源扩展”分组。值控件由字段目录动态决定:
- 枚举使用可搜索多选框。
- 国家、语言使用标准代码目录和本地化显示名。
- 年份使用数字或范围控件。
- 布尔使用切换项。
- 开放音乐流派和标签使用可创建选项的多选输入。
条件组使用 全部满足、任一满足 分段控件,并允许嵌套一层。复杂度上限由后端返回,首版建议最大深度 3、
每条规则最多 30 个叶子条件。
11.5 来源覆盖提示
选择来源范围后:
native/derived字段正常显示。partial字段显示提示图标和具体说明。- 全部所选来源均
unavailable的字段禁止选择。 - 已保存规则因插件卸载变为不可用时,不删除规则,显示阻断错误并在发布时要求处理。
11.6 单条测试
页面支持两种测试输入:
- 输入关键词搜索并选择一条媒体结果,直接使用结果中的完整媒体信息。
- 兼容调用方提交已经构造好的
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 不存在时执行:
- 读取现有
CONFIG_PATH/category.yaml。 - 按 YAML 顺序转换
movie、tv分类。 - 为每个分类生成稳定 ID;相同名称通过规范化后附加短摘要避免冲突。
genre_ids转换为media.genre_keys;暂时无法映射的 ID 保存为extensions.themoviedb.genre_ids兼容条件并发出提示。- 仅当标准事实与 legacy 真值语义可证明一致时,才把字段提升为
media.*条件;否则使用统一的extensions.themoviedb.*旧比较视图。该视图保留假值缺失、字符串大写、列表逐元素字符串化、production_countries.iso_3166_1和release_date/first_air_date年份截取语义。 original_language、production_countries、origin_country、release_year等字段若因标准化回退、 类型转换或假值处理可能改变旧结果,必须保留为受控 TMDB 扩展事实,不能为了使用标准字段牺牲等价性。- 其它 TMDB 一级字段转换到受控
extensions.themoviedb.*字段;无法登记的字段阻止自动发布,保留 legacy 运行并提示管理员处理。 - 首个空规则分类转换为该媒体类型的全局
fallbacks,其后的 legacy 项在旧实现中本就不可达,迁移时保持禁用并向管理员报告。 - 新策略为电影、电视剧和音乐保留按媒体类型的通用兜底;数据源只能作为规则的筛选条件,不能单独决定默认分类。
尚未配置音乐分类时,追加
Album、Album / Compilation、EP、Single四个常用分类和显式规则。Album / Compilation只是展示名称,目录路径保存为["Album", "Compilation"]两个无空白片段,且精选集规则先于普通专辑规则。 下载历史的资源目录识别归类也消费识别结果中的生效分类路径,因此多级路径会原样映射为下载器保存目录;分类结果不可用时才兼容退回音乐主类型。 - 保存新策略 revision 1,并保留原 YAML 文件只读备份,不再继续写入。已经存在的旧版 revision 1 仅兜底默认策略,只在没有历史且内容与旧默认值完全一致时通过 CAS 升级为 revision 2;任何用户编辑过的策略均保持原样。
- 同一字段的正向枚举合并为
contains_any,排除枚举合并为contains_none;国家、语言等值数量 不应展开为叶子数量。已知和未知 Genre ID 仍分别使用标准字段与受控扩展字段,保持原有 OR 语义。 - 迁移后的策略仍需通过完整发布校验;超出真实条件复杂度或引用约束时不写入新策略,保留旧分类 只读兼容行为和结构化错误诊断,不得让发布校验异常中断宿主启动。
13.2 行为兼容
- 迁移测试必须证明同一批 TMDB fixture 在旧分类器和新分类器下得到完全相同的目录分类。
- 旧配置中的
!值转换为明确的排除操作符。 - 旧逗号字符串转换为数组,后续 API 不再使用逗号编码多值。
- 旧只读 API 将集合逐值投影为逗号项,每个排除值独立添加
!;范围允许展开为等价枚举,不改写原 YAML。 - 迁移后 TMDB 模块不再实例化
CategoryHelper,也不再写MediaInfo.category。
13.3 字段迁移
建议分两个版本完成:
- 第一个版本同时写
library_category和兼容category,读侧优先新字段。 - 前端、目录、订阅、下载、历史、通知全部迁移后,
category只保留序列化兼容属性。 - 音乐
category来源数据迁入metadata_category,音乐 UI 不再读取兼容category展示专辑类型。 - 迁移完成后,前端默认隐藏未被现有规则引用的 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.yamlfixture 迁移前后 TMDB 分类完全一致。
15.4 前端测试
- 字段和操作符完全由后端能力目录驱动。
- 电影、电视剧、音乐规则的新增、复制、排序、删除和保存。
- 来源
partial/unavailable提示。 - 分类被引用时禁止删除。
- revision 冲突时保留本地草稿并提供重新加载/合并选择。
- 预览展示实际值、失败条件和最终目录。
- 预览必须通过搜索选择媒体,不再要求用户手工填写事实;编辑分类弹窗和帮助弹窗在透明主题下保持玻璃表面、边框和遮罩。
- 桌面和窄屏布局不重叠,键盘可完成排序和编辑。
15.5 性能目标
- 200 条规则、每条平均 6 个条件时,单次纯求值 P95 小于 5ms。
- 常规识别路径不因分类新增额外网络请求。
- 影响分析分批执行并限制样本数量,不阻塞 API 事件循环。
- 策略发布后使用不可变快照替换,不在每次分类时解析 JSON。
16. 分阶段实施
阶段 A:语义和领域基础
- 新增
library_category、metadata_category、ClassificationFacts和ClassificationResult。 - 将音乐来源描述性
category迁移到metadata_category。 - 实现纯规则模型、求值器和校验器。
- 建立标准字段和来源能力目录。
阶段 B:配置与兼容
- 新增
MediaClassificationPolicySystemConfig。 - 实现策略服务、revision、历史和回滚。
- 实现
category.yaml自动迁移。 - 保留旧 GET API 投影,统一编辑器上线后移除 legacy 客户端写入口。
阶段 C:识别与整理接入
- 从 TheMovieDb 模块移除分类副作用。
- 统一所有识别结果收口。
- 迁移目录选择、订阅、下载和历史消费者到分类 ID + 路径快照。
- 在 durable transfer 计划中冻结分类结果。
阶段 D:前端规则编辑器
- 用动态字段编辑器替换 TMDB 专用弹窗。
- 支持音乐、来源范围和组合条件。
- 增加单条预览、校验解释和影响分析。
- 目录设置改为保存分类 ID,展示当前路径。
阶段 E:插件和可选跨源补充
- 扩展插件媒体来源注册协议和文档。
- 支持受控扩展事实。
- 根据实际需要增加
enrich_missing,默认仍关闭。 - 移除旧 YAML 写入口和 TMDB 内部 CategoryHelper 依赖,仅保留旧插件只读导入兼容符号。
17. 验收标准
以下条件全部满足才可认为新体系完成:
- 电影、电视剧、音乐都能在同一 UI 中配置自动分类。
- TMDB、豆瓣、Bangumi、AniList、MusicBrainz、TheAudioDB、豆瓣音乐以及插件来源至少各有 一条真实 fixture 通过分类测试。
- 规则引擎和 Application 分类服务不导入具体来源模块。
- 同步、异步、缓存和插件识别分类结果一致。
media_source + media_id在分类前后保持不变。- 音乐来源分类不会直接成为目录分类。
- 分类改名不破坏目录、订阅和规则引用。
- 发布策略前能看到校验结果和近期样本影响。
- 运行中的整理任务在策略更新后仍使用原计划目标。
- 现有
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 秒,低于既有预算。