diff --git a/app/application/classification/configuration.py b/app/application/classification/configuration.py index 9ab4bc5a3..a5da94dad 100644 --- a/app/application/classification/configuration.py +++ b/app/application/classification/configuration.py @@ -6,7 +6,7 @@ import threading from collections.abc import Callable, Iterable from datetime import datetime, timezone from functools import partial -from typing import cast +from typing import Union, cast from app.application.classification.contract import ( ClassificationPolicyConflictError, @@ -19,14 +19,29 @@ from app.domain.classification.fields import merge_field_definitions from app.domain.classification.validation import ClassificationPolicyValidator from app.schemas.category import ( ClassificationCategory, + ClassificationCondition, ClassificationFieldDefinition, + ClassificationOperator, ClassificationPolicy, ClassificationPolicyState, + ClassificationRule, + ClassificationTarget, ClassificationValidationResult, ) CLASSIFICATION_POLICY_HISTORY_LIMIT = 10 +_DEFAULT_MUSIC_CATEGORIES = ( + ("music.album", "Album", ("Album",)), + ( + "music.compilation", + "Album / Compilation", + ("Album", "Compilation"), + ), + ("music.ep", "EP", ("EP",)), + ("music.single", "Single", ("Single",)), +) + class ClassificationPolicyNotInitializedError(RuntimeError): """表示分类策略服务尚未加载或初始化活动快照。""" @@ -45,8 +60,8 @@ class ClassificationPolicyValidationError(ValueError): super().__init__("分类策略校验失败") -def build_default_classification_policy() -> ClassificationPolicy: - """构造电影、电视剧和音乐均有稳定兜底分类的初始草稿。""" +def _build_uncategorized_classification_policy() -> ClassificationPolicy: + """构造旧版仅包含媒体类型兜底分类的初始草稿。""" categories = [ ClassificationCategory( id="movie.uncategorized", @@ -77,6 +92,106 @@ def build_default_classification_policy() -> ClassificationPolicy: ) +def with_default_music_classification(policy: ClassificationPolicy) -> ClassificationPolicy: + """为尚未配置音乐分类的策略追加安全、结构化的常用专辑分类。""" + music_categories = [ + item for item in policy.categories if item.media_type == "音乐" + ] + music_rules = [item for item in policy.rules if "音乐" in item.media_types] + if music_rules or any(item.id != "music.uncategorized" for item in music_categories): + return cast(ClassificationPolicy, policy.model_copy(deep=True)) + + categories = [ + *(item.model_copy(deep=True) for item in policy.categories), + *( + ClassificationCategory( + id=category_id, + media_type="音乐", + name=name, + path=list(path), + ) + for category_id, name, path in _DEFAULT_MUSIC_CATEGORIES + ), + ] + priority = max((item.priority for item in policy.rules), default=-1) + 1 + rules = [*(item.model_copy(deep=True) for item in policy.rules)] + + def append_rule( + *, + rule_id: str, + name: str, + field: str, + operator: ClassificationOperator, + value: Union[str, list[str]], + category_id: str, + ) -> None: + nonlocal priority + rules.append( + ClassificationRule( + id=rule_id, + name=name, + kind="category", + priority=priority, + media_types=["音乐"], + when=ClassificationCondition( + field=field, + operator=operator, + value=value, + ), + target=ClassificationTarget(category_id=category_id), + ) + ) + priority += 1 + + # 精选集同时具有 Album 主类型,必须在普通 Album 之前匹配。 + append_rule( + rule_id="music.compilation.default", + name="音乐精选集", + field="music.secondary_types", + operator="contains_any", + value=["Compilation"], + category_id="music.compilation", + ) + for album_type, suffix, category_id in ( + ("EP", "ep", "music.ep"), + ("Single", "single", "music.single"), + ("Album", "album", "music.album"), + ): + append_rule( + rule_id=f"music.{suffix}.default", + name=f"音乐{album_type}", + field="music.album_type", + operator="equals", + value=album_type, + category_id=category_id, + ) + return cast( + ClassificationPolicy, + policy.model_copy( + deep=True, + update={"categories": categories, "rules": rules}, + ), + ) + + +def is_untouched_legacy_default_policy(state: ClassificationPolicyState) -> bool: + """判断状态是否为旧版本自动创建且从未编辑的 revision 1 默认策略。""" + if state.active.revision != 1 or state.history: + return False + normalized = state.active.model_copy( + deep=True, + update={"revision": 0, "updated_at": None}, + ) + return bool(normalized == _build_uncategorized_classification_policy()) + + +def build_default_classification_policy() -> ClassificationPolicy: + """构造带稳定兜底和常用音乐专辑分类的初始草稿。""" + return with_default_music_classification( + _build_uncategorized_classification_policy() + ) + + class ClassificationPolicyConfigurationService: """维护分类策略的进程内完整快照和数据库 CAS 发布语义。""" diff --git a/app/startup/composition/classification.py b/app/startup/composition/classification.py index c0144d5b7..b984f6c92 100644 --- a/app/startup/composition/classification.py +++ b/app/startup/composition/classification.py @@ -14,8 +14,11 @@ from pydantic import ValidationError from app.application.classification.configuration import ( ClassificationPolicyConfigurationService, ClassificationPolicyValidationError, + is_untouched_legacy_default_policy, + with_default_music_classification, ) from app.application.classification.contract import ( + ClassificationPolicyConflictError, ClassificationPolicyStateCorruptError, ) from app.application.classification.execution import ( @@ -104,6 +107,7 @@ async def compose_classification( values = system_config.all() policy_key = SystemConfigKey.MediaClassificationPolicy.value stored_value = values.get(policy_key) + stored_state: ClassificationPolicyState | None = None extra_fields: tuple[ClassificationFieldDefinition, ...] = () existing_issue: tuple[ClassificationValidationIssue, ...] = () if policy_key in values: @@ -155,6 +159,29 @@ async def compose_classification( ClassificationRuntime(service, diagnostics=(issue,)), migrated=False, ) + if stored_state is not None and is_untouched_legacy_default_policy( + stored_state + ): + try: + await service.async_publish( + with_default_music_classification(stored_state.active), + expected_revision=stored_state.active.revision, + ) + except ClassificationPolicyConflictError: + # 多进程同时启动时由首个成功 CAS 的进程完成升级,其余进程刷新事实源。 + await service.async_reload() + except ClassificationPolicyValidationError as error: + logger.error("默认音乐分类策略升级未通过校验,保留原策略") + return finish( + ClassificationRuntime(service, diagnostics=tuple(error.result.issues)), + migrated=False, + ) + else: + logger.info("已为未编辑的默认分类策略补充常用音乐分类 revision 2") + return finish( + ClassificationRuntime(service), + migrated=True, + ) return finish( ClassificationRuntime(service), migrated=False, @@ -198,7 +225,9 @@ async def compose_classification( service.register_extra_fields(migration.extra_fields) try: - await service.async_initialize(migration.policy) + await service.async_initialize( + with_default_music_classification(migration.policy) + ) except ClassificationPolicyValidationError as error: logger.error("旧分类策略未通过发布校验,继续保留 legacy 只读兼容行为") return finish( diff --git a/docs/architecture/media-classification-design.md b/docs/architecture/media-classification-design.md index 70910d23a..c682335ea 100644 --- a/docs/architecture/media-classification-design.md +++ b/docs/architecture/media-classification-design.md @@ -932,8 +932,11 @@ API 常规读取只返回 `active`,历史接口按需读取 `history`。选择 7. 其它 TMDB 一级字段转换到受控 `extensions.themoviedb.*` 字段;无法登记的字段阻止自动发布,保留 legacy 运行并提示管理员处理。 8. 首个空规则分类转换为该媒体类型的全局 `fallbacks`,其后的 legacy 项在旧实现中本就不可达,迁移时保持禁用并向管理员报告。 -9. 新策略只按媒体类型配置通用兜底;数据源只能作为规则的筛选条件,不能单独决定默认分类。 -10. 保存新策略 revision 1,并保留原 YAML 文件只读备份,不再继续写入。 +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. 迁移后的策略仍需通过完整发布校验;超出真实条件复杂度或引用约束时不写入新策略,保留旧分类 diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 7373f11c3..e644edc87 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -449,7 +449,11 @@ moving classification semantics into the endpoint. `app/startup/composition/clas owner allowed to decide whether the one-time YAML migration runs: an existing `MediaClassificationPolicy` always wins, while invalid legacy input leaves the new runtime unavailable with structured diagnostics instead of publishing a -partial policy. The +partial policy. It may CAS-upgrade only the exact, history-free revision-1 legacy +default by adding explicit music rules for `Album`, `Album/Compilation`, `EP`, and +`Single`; user-edited policies always win. The display label +`Album / Compilation` maps to the two path segments `Album` and `Compilation`, so +spaces around the separator never become directory-name suffixes. The `app/db/adapters/classification.py` implementation stores `active + history` in the single `SystemConfigKey.MediaClassificationPolicy` value, verifies revision inside a short row-lock transaction and publishes the shared SystemConfig diff --git a/docs/v2-to-v3-overview.md b/docs/v2-to-v3-overview.md index 081cae557..493793f7a 100644 --- a/docs/v2-to-v3-overview.md +++ b/docs/v2-to-v3-overview.md @@ -207,6 +207,7 @@ V3 前端仍然基于 Vue 3、Vuetify 3 和 Vite,并不是推倒重写。因 - 搜索、订阅、探索、推荐、整理、缓存和历史页面支持音乐实体。 - 新增数据库备份管理面板。 - 目录设置新增“自动分类策略”入口,打开全屏窗口后可编辑电影、电视剧、音乐分类,预览命中过程并查看发布影响。 +- 默认音乐分类可识别专辑、精选集、EP 和单曲;精选集显示为 `Album / Compilation`,实际按 `Album/Compilation` 两级无空格目录整理。 - 插件市场支持虚拟分身、来源绑定和换源。 - 新增首次初始化页面,移除原来体量较大的全功能设置向导。 - AI 助手支持全屏显示和受保护操作交互。 diff --git a/tests/test_media_classification.py b/tests/test_media_classification.py index ef423e1a9..66fb3d9be 100644 --- a/tests/test_media_classification.py +++ b/tests/test_media_classification.py @@ -9,6 +9,9 @@ from typing import Any, Callable import pytest +from app.application.classification.configuration import ( + build_default_classification_policy, +) from app.domain.classification.evaluator import ClassificationEvaluator from app.domain.classification.fields import get_standard_classification_fields from app.domain.classification.validation import ClassificationPolicyValidator @@ -185,6 +188,40 @@ def _leaf(field: str, operator: str, value: Any = _MISSING) -> dict[str, Any]: return condition +@pytest.mark.parametrize( # type: ignore[misc] + ("album_type", "secondary_types", "category_id", "category_path"), + [ + ("Album", ["Compilation"], "music.compilation", ["Album", "Compilation"]), + ("EP", [], "music.ep", ["EP"]), + ("Single", [], "music.single", ["Single"]), + ("Album", [], "music.album", ["Album"]), + ], +) +def test_default_music_policy_uses_structured_album_categories( + album_type: str, + secondary_types: list[str], + category_id: str, + category_path: list[str], +) -> None: + """默认音乐规则应优先识别精选集,并生成不带空白的安全路径段。""" + policy = build_default_classification_policy().model_copy(update={"revision": 1}) + result = _evaluate( + policy, + _facts( + media_type="音乐", + media_source="musicbrainz", + values={ + "music.album_type": album_type, + "music.secondary_types": secondary_types, + }, + ), + ) + + assert result.result.effective is not None + assert result.result.effective.category_id == category_id + assert result.result.effective.category_path == category_path + + @pytest.mark.parametrize( # type: ignore[misc] ("condition", "fact_values"), [ diff --git a/tests/test_media_classification_startup.py b/tests/test_media_classification_startup.py index a1ce12f83..6df4f5b4b 100644 --- a/tests/test_media_classification_startup.py +++ b/tests/test_media_classification_startup.py @@ -92,6 +92,23 @@ def _published_default_state() -> ClassificationPolicyState: return ClassificationPolicyState(active=policy) +def _published_legacy_default_state() -> ClassificationPolicyState: + """构造旧版本仅含三个未分类兜底的 revision 1 状态。""" + policy = build_default_classification_policy() + policy.categories = [ + category + for category in policy.categories + if category.id + in { + "movie.uncategorized", + "tv.uncategorized", + "music.uncategorized", + } + ] + policy.rules = [] + return ClassificationPolicyState(active=policy.model_copy(update={"revision": 1})) + + @pytest.mark.asyncio # type: ignore[misc] async def test_existing_policy_never_reads_legacy_yaml( monkeypatch: pytest.MonkeyPatch, @@ -125,6 +142,121 @@ async def test_existing_policy_never_reads_legacy_yaml( assert store.write_count == 0 +@pytest.mark.asyncio # type: ignore[misc] +async def test_untouched_legacy_default_policy_gains_music_rules_once( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """旧版未编辑默认策略应幂等升级,用户已发布的其它策略不得被误判。""" + state = _published_legacy_default_state() + store = _MemoryPolicyStore(state) + system_config = _SystemConfig( + { + SystemConfigKey.MediaClassificationPolicy.value: state.model_dump( + mode="json" + ) + } + ) + monkeypatch.setattr( + classification_composition, + "SystemConfigClassificationPolicyStore", + lambda *_args: store, + ) + + composition = await classification_composition.compose_classification( + executor=cast(Any, _InlineExecutor()), + settings=cast(Any, SimpleNamespace(CONFIG_PATH=tmp_path)), + system_config=cast(Any, system_config), + ) + + policy = composition.runtime.require_policy() + assert composition.migrated is True + assert policy.revision == 2 + assert [ + category.path + for category in policy.categories + if category.media_type == "音乐" + ] == [ + ["未分类"], + ["Album"], + ["Album", "Compilation"], + ["EP"], + ["Single"], + ] + assert [rule.id for rule in policy.rules] == [ + "music.compilation.default", + "music.ep.default", + "music.single.default", + "music.album.default", + ] + assert store.write_count == 1 + + assert store.state is not None + system_config.publish_many( + { + SystemConfigKey.MediaClassificationPolicy: store.state.model_dump( + mode="json" + ) + } + ) + reloaded = await classification_composition.compose_classification( + executor=cast(Any, _InlineExecutor()), + settings=cast(Any, SimpleNamespace(CONFIG_PATH=tmp_path)), + system_config=cast(Any, system_config), + ) + + assert reloaded.migrated is False + assert reloaded.runtime.require_policy() == policy + assert store.write_count == 1 + + +@pytest.mark.asyncio # type: ignore[misc] +async def test_edited_legacy_default_policy_is_not_automatically_changed( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """即使仍是 revision 1,用户改过的默认策略也不得被启动升级覆盖。""" + state = _published_legacy_default_state() + categories = [ + category.model_copy( + deep=True, + update={"name": "自定义音乐", "path": ["自定义音乐"]}, + ) + if category.id == "music.uncategorized" + else category.model_copy(deep=True) + for category in state.active.categories + ] + state = ClassificationPolicyState( + active=state.active.model_copy( + deep=True, + update={"categories": categories}, + ) + ) + store = _MemoryPolicyStore(state) + system_config = _SystemConfig( + { + SystemConfigKey.MediaClassificationPolicy.value: state.model_dump( + mode="json" + ) + } + ) + monkeypatch.setattr( + classification_composition, + "SystemConfigClassificationPolicyStore", + lambda *_args: store, + ) + + composition = await classification_composition.compose_classification( + executor=cast(Any, _InlineExecutor()), + settings=cast(Any, SimpleNamespace(CONFIG_PATH=tmp_path)), + system_config=cast(Any, system_config), + ) + + assert composition.migrated is False + assert composition.runtime.require_policy() == state.active + assert store.write_count == 0 + + @pytest.mark.asyncio # type: ignore[misc] async def test_absent_policy_migrates_yaml_once_without_rewriting_file( monkeypatch: pytest.MonkeyPatch, @@ -181,8 +313,10 @@ async def test_large_legacy_enumerations_migrate_and_reload_without_losing_value assert composition.migrated is True policy = composition.runtime.require_policy() assert policy.revision == 1 - assert len(policy.rules) == 2 - for rule in policy.rules: + legacy_rules = [rule for rule in policy.rules if "音乐" not in rule.media_types] + assert len(policy.rules) == 6 + assert len(legacy_rules) == 2 + for rule in legacy_rules: assert isinstance(rule.when, ClassificationConditionGroup) assert rule.when.all is not None and len(rule.when.all) == 2 assert store.state is not None @@ -268,7 +402,13 @@ def test_runtime_compat_projection_is_read_only_and_includes_music_categories() categories = runtime.media_categories().root - assert categories["音乐"] == ["未分类"] + assert categories["音乐"] == [ + "未分类", + "Album", + "Album / Compilation", + "EP", + "Single", + ] assert runtime.legacy_config().movie == {} assert store.write_count == 1 @@ -296,7 +436,13 @@ async def test_legacy_category_get_endpoints_use_classification_runtime_only( assert categories.root == { "电影": ["未分类"], "电视剧": ["未分类"], - "音乐": ["未分类"], + "音乐": [ + "未分类", + "Album", + "Album / Compilation", + "EP", + "Single", + ], }