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 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 发布语义。"""
+30 -1
View File
@@ -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(
@@ -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. 迁移后的策略仍需通过完整发布校验;超出真实条件复杂度或引用约束时不写入新策略,保留旧分类
+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
`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
+1
View File
@@ -207,6 +207,7 @@ V3 前端仍然基于 Vue 3、Vuetify 3 和 Vite,并不是推倒重写。因
- 搜索、订阅、探索、推荐、整理、缓存和历史页面支持音乐实体。
- 新增数据库备份管理面板。
- 目录设置新增“自动分类策略”入口,打开全屏窗口后可编辑电影、电视剧、音乐分类,预览命中过程并查看发布影响。
- 默认音乐分类可识别专辑、精选集、EP 和单曲;精选集显示为 `Album / Compilation`,实际按 `Album/Compilation` 两级无空格目录整理。
- 插件市场支持虚拟分身、来源绑定和换源。
- 新增首次初始化页面,移除原来体量较大的全功能设置向导。
- AI 助手支持全屏显示和受保护操作交互。
+37
View File
@@ -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"),
[
+150 -4
View File
@@ -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",
],
}