# 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 分类晚于来源投影,早于目录选择 标准顺序为: ```text 文件名/身份输入 | 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` 作为目录快照: ```json { "id": "music.live", "media_type": "音乐", "name": "现场专辑", "path": ["现场专辑"] } ``` `path` 始终相对于媒体类型根目录;是否增加 `电影`、`电视剧`、`音乐` 一级目录仍由目录配置中的 按类型分类开关决定。分类改名只改变 `name` 或 `path`,目录配置、订阅和规则仍引用 `music.live`。 ## 4. 总体架构 ```text +------------------------------+ | 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 分类策略 ```json { "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 分类定义 ```json { "id": "tv.anime.jp", "media_type": "电视剧", "name": "日番", "path": ["动漫", "日番"], "enabled": true, "labels": ["动漫", "日本"] } ``` 约束: - `id` 使用稳定小写标识,允许小写字母、数字、点、下划线和连字符。 - `path` 使用路径段数组,不接受用户直接输入带 `/` 或 `\\` 的完整路径。 - 单段禁止空值、`.`、`..`、控制字符和平台非法文件名。 - 建议最大 4 级、单段 64 字符、总长度 240 字符。 - 同一媒体类型下目录路径必须唯一。 - 删除已被目录、订阅或规则引用的分类前必须先解除引用;普通改名不改变 ID。 ### 5.3 规则定义 ```json { "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 条件树 条件树只有两种节点:条件组和叶子条件。 ```json { "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`: ```json { "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..` 命名空间。 2. 字段描述包括显示名、值类型、允许操作符、适用媒体类型和可选枚举值。 3. 插件返回的事实必须是 JSON 标量或标量列表。 4. 插件不能覆盖 `identity.*`、`media.*`、`music.*` 标准字段。 5. 插件未加载或字段缺失时,相关条件按缺失值语义处理。 建议扩展 `MediaSourceInfo`: ```json { "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..` 字段 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` 返回: ```json { "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 分类结果 ```json { "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`,值为版本化策略状态包: ```json { "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 预览响应 ```json { "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 信息架构 分类策略从“设置 -> 目录”中的“自动分类策略”入口进入,点击后打开全屏、可滚动的编辑窗口;分类编辑器在窗口内工作。 窗口内按职责分为“分类树、规则、检查与发布”三个工作区标签;最后一个工作区再分为“结果预览、影响分析、 版本发布与历史”三个标签。移动端保持同样的分层顺序,不把所有表单压缩到同一屏: ```text +------------------+------------------+----------------------+ | 分类树 | 规则 | 检查与发布 | | 电影 / 电视剧 | 有序规则列表 | 结果预览 | | 音乐 | 条件和输出 | 影响分析 / 版本历史 | +------------------+------------------+----------------------+ ``` 不得继续在每条规则中堆叠固定的 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 电影动画 ```json { "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 日本动画剧集 ```json { "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 现场音乐专辑 ```json { "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 影视原声 ```json { "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 来源专用扩展条件 ```json { "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 秒,低于既有预算。