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