fix(classification): add safe music category defaults

This commit is contained in:
yanjunpu
2026-09-08 02:24:49 +08:00
parent f0198c22e1
commit 78b678a5df
7 changed files with 346 additions and 11 deletions
+118 -3
View File
@@ -6,7 +6,7 @@ import threading
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from datetime import datetime, timezone from datetime import datetime, timezone
from functools import partial from functools import partial
from typing import cast from typing import Union, cast
from app.application.classification.contract import ( from app.application.classification.contract import (
ClassificationPolicyConflictError, ClassificationPolicyConflictError,
@@ -19,14 +19,29 @@ from app.domain.classification.fields import merge_field_definitions
from app.domain.classification.validation import ClassificationPolicyValidator from app.domain.classification.validation import ClassificationPolicyValidator
from app.schemas.category import ( from app.schemas.category import (
ClassificationCategory, ClassificationCategory,
ClassificationCondition,
ClassificationFieldDefinition, ClassificationFieldDefinition,
ClassificationOperator,
ClassificationPolicy, ClassificationPolicy,
ClassificationPolicyState, ClassificationPolicyState,
ClassificationRule,
ClassificationTarget,
ClassificationValidationResult, ClassificationValidationResult,
) )
CLASSIFICATION_POLICY_HISTORY_LIMIT = 10 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): class ClassificationPolicyNotInitializedError(RuntimeError):
"""表示分类策略服务尚未加载或初始化活动快照。""" """表示分类策略服务尚未加载或初始化活动快照。"""
@@ -45,8 +60,8 @@ class ClassificationPolicyValidationError(ValueError):
super().__init__("分类策略校验失败") super().__init__("分类策略校验失败")
def build_default_classification_policy() -> ClassificationPolicy: def _build_uncategorized_classification_policy() -> ClassificationPolicy:
"""构造电影、电视剧和音乐均有稳定兜底分类的初始草稿。""" """构造旧版仅包含媒体类型兜底分类的初始草稿。"""
categories = [ categories = [
ClassificationCategory( ClassificationCategory(
id="movie.uncategorized", 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: class ClassificationPolicyConfigurationService:
"""维护分类策略的进程内完整快照和数据库 CAS 发布语义。""" """维护分类策略的进程内完整快照和数据库 CAS 发布语义。"""
+30 -1
View File
@@ -14,8 +14,11 @@ from pydantic import ValidationError
from app.application.classification.configuration import ( from app.application.classification.configuration import (
ClassificationPolicyConfigurationService, ClassificationPolicyConfigurationService,
ClassificationPolicyValidationError, ClassificationPolicyValidationError,
is_untouched_legacy_default_policy,
with_default_music_classification,
) )
from app.application.classification.contract import ( from app.application.classification.contract import (
ClassificationPolicyConflictError,
ClassificationPolicyStateCorruptError, ClassificationPolicyStateCorruptError,
) )
from app.application.classification.execution import ( from app.application.classification.execution import (
@@ -104,6 +107,7 @@ async def compose_classification(
values = system_config.all() values = system_config.all()
policy_key = SystemConfigKey.MediaClassificationPolicy.value policy_key = SystemConfigKey.MediaClassificationPolicy.value
stored_value = values.get(policy_key) stored_value = values.get(policy_key)
stored_state: ClassificationPolicyState | None = None
extra_fields: tuple[ClassificationFieldDefinition, ...] = () extra_fields: tuple[ClassificationFieldDefinition, ...] = ()
existing_issue: tuple[ClassificationValidationIssue, ...] = () existing_issue: tuple[ClassificationValidationIssue, ...] = ()
if policy_key in values: if policy_key in values:
@@ -155,6 +159,29 @@ async def compose_classification(
ClassificationRuntime(service, diagnostics=(issue,)), ClassificationRuntime(service, diagnostics=(issue,)),
migrated=False, 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( return finish(
ClassificationRuntime(service), ClassificationRuntime(service),
migrated=False, migrated=False,
@@ -198,7 +225,9 @@ async def compose_classification(
service.register_extra_fields(migration.extra_fields) service.register_extra_fields(migration.extra_fields)
try: try:
await service.async_initialize(migration.policy) await service.async_initialize(
with_default_music_classification(migration.policy)
)
except ClassificationPolicyValidationError as error: except ClassificationPolicyValidationError as error:
logger.error("旧分类策略未通过发布校验,继续保留 legacy 只读兼容行为") logger.error("旧分类策略未通过发布校验,继续保留 legacy 只读兼容行为")
return finish( return finish(
@@ -932,8 +932,11 @@ API 常规读取只返回 `active`,历史接口按需读取 `history`。选择
7. 其它 TMDB 一级字段转换到受控 `extensions.themoviedb.*` 字段;无法登记的字段阻止自动发布,保留 7. 其它 TMDB 一级字段转换到受控 `extensions.themoviedb.*` 字段;无法登记的字段阻止自动发布,保留
legacy 运行并提示管理员处理。 legacy 运行并提示管理员处理。
8. 首个空规则分类转换为该媒体类型的全局 `fallbacks`,其后的 legacy 项在旧实现中本就不可达,迁移时保持禁用并向管理员报告。 8. 首个空规则分类转换为该媒体类型的全局 `fallbacks`,其后的 legacy 项在旧实现中本就不可达,迁移时保持禁用并向管理员报告。
9. 新策略按媒体类型配置通用兜底;数据源只能作为规则的筛选条件,不能单独决定默认分类。 9. 新策略为电影、电视剧和音乐保留按媒体类型通用兜底;数据源只能作为规则的筛选条件,不能单独决定默认分类。
10. 保存新策略 revision 1,并保留原 YAML 文件只读备份,不再继续写入 尚未配置音乐分类时,追加 `Album``Album / Compilation``EP``Single` 四个常用分类和显式规则
`Album / Compilation` 只是展示名称,目录路径保存为 `["Album", "Compilation"]` 两个无空白片段,且精选集规则先于普通专辑规则。
10. 保存新策略 revision 1,并保留原 YAML 文件只读备份,不再继续写入。已经存在的旧版 revision 1
仅兜底默认策略,只在没有历史且内容与旧默认值完全一致时通过 CAS 升级为 revision 2;任何用户编辑过的策略均保持原样。
11. 同一字段的正向枚举合并为 `contains_any`,排除枚举合并为 `contains_none`;国家、语言等值数量 11. 同一字段的正向枚举合并为 `contains_any`,排除枚举合并为 `contains_none`;国家、语言等值数量
不应展开为叶子数量。已知和未知 Genre ID 仍分别使用标准字段与受控扩展字段,保持原有 OR 语义。 不应展开为叶子数量。已知和未知 Genre ID 仍分别使用标准字段与受控扩展字段,保持原有 OR 语义。
12. 迁移后的策略仍需通过完整发布校验;超出真实条件复杂度或引用约束时不写入新策略,保留旧分类 12. 迁移后的策略仍需通过完整发布校验;超出真实条件复杂度或引用约束时不写入新策略,保留旧分类
+5 -1
View File
@@ -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 owner allowed to decide whether the one-time YAML migration runs: an existing
`MediaClassificationPolicy` always wins, while invalid legacy input leaves the `MediaClassificationPolicy` always wins, while invalid legacy input leaves the
new runtime unavailable with structured diagnostics instead of publishing a 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 `app/db/adapters/classification.py` implementation stores `active + history` in
the single `SystemConfigKey.MediaClassificationPolicy` value, verifies revision the single `SystemConfigKey.MediaClassificationPolicy` value, verifies revision
inside a short row-lock transaction and publishes the shared SystemConfig inside a short row-lock transaction and publishes the shared SystemConfig
+1
View File
@@ -207,6 +207,7 @@ V3 前端仍然基于 Vue 3、Vuetify 3 和 Vite,并不是推倒重写。因
- 搜索、订阅、探索、推荐、整理、缓存和历史页面支持音乐实体。 - 搜索、订阅、探索、推荐、整理、缓存和历史页面支持音乐实体。
- 新增数据库备份管理面板。 - 新增数据库备份管理面板。
- 目录设置新增“自动分类策略”入口,打开全屏窗口后可编辑电影、电视剧、音乐分类,预览命中过程并查看发布影响。 - 目录设置新增“自动分类策略”入口,打开全屏窗口后可编辑电影、电视剧、音乐分类,预览命中过程并查看发布影响。
- 默认音乐分类可识别专辑、精选集、EP 和单曲;精选集显示为 `Album / Compilation`,实际按 `Album/Compilation` 两级无空格目录整理。
- 插件市场支持虚拟分身、来源绑定和换源。 - 插件市场支持虚拟分身、来源绑定和换源。
- 新增首次初始化页面,移除原来体量较大的全功能设置向导。 - 新增首次初始化页面,移除原来体量较大的全功能设置向导。
- AI 助手支持全屏显示和受保护操作交互。 - AI 助手支持全屏显示和受保护操作交互。
+37
View File
@@ -9,6 +9,9 @@ from typing import Any, Callable
import pytest import pytest
from app.application.classification.configuration import (
build_default_classification_policy,
)
from app.domain.classification.evaluator import ClassificationEvaluator from app.domain.classification.evaluator import ClassificationEvaluator
from app.domain.classification.fields import get_standard_classification_fields from app.domain.classification.fields import get_standard_classification_fields
from app.domain.classification.validation import ClassificationPolicyValidator 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 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] @pytest.mark.parametrize( # type: ignore[misc]
("condition", "fact_values"), ("condition", "fact_values"),
[ [
+150 -4
View File
@@ -92,6 +92,23 @@ def _published_default_state() -> ClassificationPolicyState:
return ClassificationPolicyState(active=policy) 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] @pytest.mark.asyncio # type: ignore[misc]
async def test_existing_policy_never_reads_legacy_yaml( async def test_existing_policy_never_reads_legacy_yaml(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
@@ -125,6 +142,121 @@ async def test_existing_policy_never_reads_legacy_yaml(
assert store.write_count == 0 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] @pytest.mark.asyncio # type: ignore[misc]
async def test_absent_policy_migrates_yaml_once_without_rewriting_file( async def test_absent_policy_migrates_yaml_once_without_rewriting_file(
monkeypatch: pytest.MonkeyPatch, monkeypatch: pytest.MonkeyPatch,
@@ -181,8 +313,10 @@ async def test_large_legacy_enumerations_migrate_and_reload_without_losing_value
assert composition.migrated is True assert composition.migrated is True
policy = composition.runtime.require_policy() policy = composition.runtime.require_policy()
assert policy.revision == 1 assert policy.revision == 1
assert len(policy.rules) == 2 legacy_rules = [rule for rule in policy.rules if "音乐" not in rule.media_types]
for rule in policy.rules: assert len(policy.rules) == 6
assert len(legacy_rules) == 2
for rule in legacy_rules:
assert isinstance(rule.when, ClassificationConditionGroup) assert isinstance(rule.when, ClassificationConditionGroup)
assert rule.when.all is not None and len(rule.when.all) == 2 assert rule.when.all is not None and len(rule.when.all) == 2
assert store.state is not None 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 categories = runtime.media_categories().root
assert categories["音乐"] == ["未分类"] assert categories["音乐"] == [
"未分类",
"Album",
"Album / Compilation",
"EP",
"Single",
]
assert runtime.legacy_config().movie == {} assert runtime.legacy_config().movie == {}
assert store.write_count == 1 assert store.write_count == 1
@@ -296,7 +436,13 @@ async def test_legacy_category_get_endpoints_use_classification_runtime_only(
assert categories.root == { assert categories.root == {
"电影": ["未分类"], "电影": ["未分类"],
"电视剧": ["未分类"], "电视剧": ["未分类"],
"音乐": ["未分类"], "音乐": [
"未分类",
"Album",
"Album / Compilation",
"EP",
"Single",
],
} }