diff --git a/app/application/messaging/message.py b/app/application/messaging/message.py index 71e69c467..a011f3139 100644 --- a/app/application/messaging/message.py +++ b/app/application/messaging/message.py @@ -15,13 +15,14 @@ from typing import Any, Callable, Dict, List, Literal, Optional, Protocol, Union from jinja2 import Template -from app.application.configuration import get_configured_system_config +from app.application.configuration import get_chain_runtime_config_snapshot, get_configured_system_config from app.domain.context import MediaInfo, MusicInfo, TorrentInfo from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.foundation import size as size_tools from app.foundation.crypto import HashUtils from app.foundation.singleton import Singleton, SingletonClass +from app.foundation.text import convert as zhconv_convert from app.runtime.cache import TTLCache from app.runtime.log import logger from app.runtime.stop import runtime_stop_state @@ -129,6 +130,7 @@ class TemplateContextBuilder: 每次调用都新建本地 ``context`` 字典,依次填充各业务来源和当前实例的 主机名后返回过滤掉 None 值的副本,调用之间互不影响。 + 音乐在字段取值完成后统一按开关转简体,避免原始标签覆盖在线转换结果。 :param meta: 媒体元数据 :param mediainfo: 识别的媒体信息 @@ -148,6 +150,8 @@ class TemplateContextBuilder: self._add_transfer_info(context, transferinfo) self._add_torrent_info(context, torrentinfo) self._add_file_info(context, file_extension) + if isinstance(meta, MetaMusic) or isinstance(mediainfo, MusicInfo): + self._simplify_music_context(context) context.update(kwargs, instance_name=socket.gethostname()) if include_raw_objects: @@ -156,6 +160,18 @@ class TemplateContextBuilder: # 移除空值 return {k: v for k, v in context.items() if v is not None} + @staticmethod + def _simplify_music_context(context: Dict[str, Any]) -> None: + """只转换最终音乐展示字段,保留原始文件名、标签、媒体身份和取值优先级。""" + if not get_chain_runtime_config_snapshot().music_metadata_to_simplified: + return + for field_name in ("title", "name", "artist", "album", "album_artist", "title_year", "version"): + value = context.get(field_name) + if isinstance(value, str): + context[field_name] = zhconv_convert(value, "zh-hans") + if "artists" in context: + context["artists"] = [zhconv_convert(artist, "zh-hans") for artist in context["artists"]] + @classmethod def _add_media_info( cls, diff --git a/app/chain/media/catalog.py b/app/chain/media/catalog.py index cd952a405..74f3a9885 100644 --- a/app/chain/media/catalog.py +++ b/app/chain/media/catalog.py @@ -1,12 +1,12 @@ """音乐来源、目录搜索与详情路由 owner。""" from copy import deepcopy -from typing import Any, Iterable, Optional, cast +from typing import Any, Iterable, Optional, Union, cast from app.application.configuration import get_chain_runtime_config_snapshot from app.application.music.catalog import MusicCatalogService from app.chain.douban import DoubanChain -from app.chain.media.contract import _MediaOwnerBase +from app.chain.media.contract import _MediaOwnerBase, _MusicMetadataT from app.chain.musicbrainz import MusicBrainzChain, MusicMetadataSourceChain from app.chain.theaudiodb import TheAudioDbChain from app.domain.context import ( @@ -38,7 +38,7 @@ class MediaCatalogOwner(_MediaOwnerBase): @classmethod def _music_artist_aliases( cls, - info: MusicInfo, + info: Union[MusicInfo, MusicAlbumInfo], artist_index: int, ) -> list[str]: """按艺术家身份提取别名,避免多位艺术家的别名相互串用。""" @@ -96,7 +96,7 @@ class MediaCatalogOwner(_MediaOwnerBase): return str(zhconv_convert(name, "zh-hans")) @classmethod - def _simplify_music_artist_names(cls, info: MusicInfo) -> list[str]: + def _simplify_music_artist_names(cls, info: Union[MusicInfo, MusicAlbumInfo]) -> list[str]: """逐位转换艺术家名称,保留多艺术家结果的身份边界。""" return [ cls._simplify_music_artist_name( @@ -238,8 +238,8 @@ class MediaCatalogOwner(_MediaOwnerBase): return cls._simplify_recognized_music_info(result) @classmethod - def _simplify_recognized_music_info(cls, info: MusicInfo) -> MusicInfo: - """按开关转换标准音乐文本字段,并优先使用可信中文艺术家别名。""" + def _simplify_recognized_music_info(cls, info: _MusicMetadataT) -> _MusicMetadataT: + """按开关转换音乐及专辑曲目文本,保留来源缓存、原文别名和身份。""" if not get_chain_runtime_config_snapshot().music_metadata_to_simplified: return info updates: dict[str, Any] = {} @@ -264,9 +264,14 @@ class MediaCatalogOwner(_MediaOwnerBase): ) if converted_items != value: updates[field_name] = converted_items + if isinstance(info, MusicAlbumInfo): + tracks = [cls._simplify_recognized_music_info(track) for track in info.tracks] + if any(converted is not original for converted, original in zip(tracks, info.tracks)): + updates["tracks"] = tracks if not updates: return info - simplified = deepcopy(info) + simplified: _MusicMetadataT = deepcopy(info) + alias_field: str for field_name, alias_field in ( ("title", "title_aliases"), ("album", "album_aliases"), @@ -279,7 +284,7 @@ class MediaCatalogOwner(_MediaOwnerBase): if field_name in ("album_artist", "names") and info.music_type != MUSIC_ENTITY_ALBUM: continue original = getattr(info, field_name) - originals = original if isinstance(original, list) else [original] + originals: list[Any] = original if isinstance(original, list) else [original] setattr(simplified, alias_field, list(dict.fromkeys([ *(getattr(simplified, alias_field, None) or []), *originals, ]))) @@ -372,7 +377,7 @@ class MediaCatalogOwner(_MediaOwnerBase): music_release_scripts: Optional[list[str]] = None, musicbrainz_release_id: Optional[str] = None, ) -> Optional[MusicAlbumInfo]: - """按音乐来源和原生 ID 同步获取专辑详情。""" + """按音乐来源和原生 ID 同步获取专辑详情,按开关转换专辑及曲目文本。""" source, normalized_id = resolve_media_identity(media_source=media_source, media_id=media_id) if not source or not normalized_id: return None @@ -387,6 +392,8 @@ class MediaCatalogOwner(_MediaOwnerBase): if music_release_scripts is not None: preference_kwargs["music_release_scripts"] = music_release_scripts result = chain.get_music_album(normalized_id, **preference_kwargs) if chain else None + if result: + result = self._simplify_recognized_music_info(result) return cast( Optional[MusicAlbumInfo], self._finalize_recognition_result(result), @@ -400,7 +407,7 @@ class MediaCatalogOwner(_MediaOwnerBase): music_release_scripts: Optional[list[str]] = None, musicbrainz_release_id: Optional[str] = None, ) -> Optional[MusicAlbumInfo]: - """按音乐来源和原生 ID 异步获取专辑详情。""" + """按音乐来源和原生 ID 异步获取专辑详情,按开关转换专辑及曲目文本。""" source, normalized_id = resolve_media_identity(media_source=media_source, media_id=media_id) if not source or not normalized_id: return None @@ -415,6 +422,8 @@ class MediaCatalogOwner(_MediaOwnerBase): if music_release_scripts is not None: preference_kwargs["music_release_scripts"] = music_release_scripts result = await chain.async_get_music_album(normalized_id, **preference_kwargs) if chain else None + if result: + result = self._simplify_recognized_music_info(result) return cast( Optional[MusicAlbumInfo], await self._async_finalize_recognition_result(result), diff --git a/app/chain/media/contract.py b/app/chain/media/contract.py index bc68dbb1e..5656126a7 100644 --- a/app/chain/media/contract.py +++ b/app/chain/media/contract.py @@ -18,6 +18,7 @@ from app.schemas.types import ( _RecognitionCallback = Callable[[], Optional[MediaInfo]] _AsyncRecognitionCallback = Callable[[], Awaitable[Optional[MediaInfo]]] +_MusicMetadataT = TypeVar("_MusicMetadataT", MusicInfo, MusicAlbumInfo) _ClassificationSubjectT = TypeVar( "_ClassificationSubjectT", MediaInfo, @@ -159,8 +160,8 @@ if TYPE_CHECKING: ... @classmethod - def _simplify_recognized_music_info(cls, info: MusicInfo) -> MusicInfo: - """按配置返回简体化音乐信息。""" + def _simplify_recognized_music_info(cls, info: _MusicMetadataT) -> _MusicMetadataT: + """按配置返回简体化音乐信息,专辑详情包含逐曲转换。""" ... @classmethod diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 0c8a544e7..66cdc0c3c 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -567,6 +567,12 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch 标题、副标题的实际解析,不用目标媒体回填证据;`title_aliases`、`album_aliases`、 `artist_aliases` 分别保留同一实体的可信别名及展示转简体前的原文。 +`MUSIC_METADATA_TO_SIMPLIFIED` 同样适用于同步/异步专辑详情及其逐曲文本, +也适用于音乐整理最终命名:完整本地标签、手选专辑发行版和手选录音均按开关转换。 +转换不改写原始音频标签、来源缓存、歌词或媒体身份;关闭开关保留原文。 +录音身份不等同于专辑发行身份,单曲仍保留已有的本地专辑字段优先级; +多艺人署名完整保留,不自动截取第一位艺人。 + 音乐元数据、单曲和专辑模型还分别保留 `composers`、`conductors`、`orchestras` 人名列表, 以及 `performers` 乐器/声部到人名列表的映射(未指定乐器使用 `performer` 键)。 这些字段不会混入主艺人或艺人别名;空字段在简化卡片中省略,完整模型与旧缓存兼容。 diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 62962b349..54f823a6d 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -66,6 +66,7 @@ to make the directory tree look symmetrical. | `app/application/download/` | Download task querying/control and selection use cases; `failures.py` owns the frozen failure-cooldown write/query DTOs and persistence Port | | `app/application/history/` | History use cases and persistence contracts; DownloadHistory and TransferHistory own deeply frozen DTOs plus typed query/write/staging ports | | `app/application/music/` | 多来源音乐目录编排;`observation.py` 沿用站点搜索的调用观察模式,隔离一次音乐识别的结果、候选摘要与请求/等待预算,不持有来源客户端、不改变旧模块返回合同;`recognition.py` 通过注入来源回调串行回退、汇总诊断,并按领域候选计划有界补充真实Artist身份别名,子来源共享祖先预算,`catalog.py` 声明目录查询所需的最小来源 Port | +| `app/application/messaging/message.py` | 模板上下文按既有来源优先级选定音乐字段后,使用配置快照和 `app.foundation.text.convert` 应用简体开关;不调用识别 Chain、不改写标签或来源缓存,也不替换录音所属发行和多艺人署名 | | `app/application/chain/` | Injectable Chain runtime capabilities: `context.py` owns the typed runtime and persistence dependency aggregate, and `events.py` owns durable event write contracts plus replayable payload conversion | | `app/application/agent.py` | Agent orchestration facade and typed `AgentDataContext`; startup injects one explicit data context into the manager, memory, tool and scheduler owners without a process-wide persistence locator | | `app/application/invocation.py` | Frozen Agent write-call identity, claim and receipt contracts; the injected repository provides atomic claim, fenced settlement and unresolved-state reads, while `db/adapters/invocation.py` owns short transactions and cold-start recovery is invoked by startup | diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index 780a0c385..323149046 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -107,6 +107,10 @@ paths. Cross-share moves copy on the server before deleting the source and are not atomic; cross-share hard links are unsupported. Update saved directory and downloader mappings when switching path modes. +For music metadata and transfer naming, follow the simplification and album-edition +precedence contract in [api/music.md](api/music.md); text conversion does not change +source identities or reduce multi-artist credits to a single artist. + ## Overall Workflow 1. Select the exact `operation_id` from the category index below. diff --git a/skills/moviepilot-api/api/music.md b/skills/moviepilot-api/api/music.md index a7e9f7307..f75d51aef 100644 --- a/skills/moviepilot-api/api/music.md +++ b/skills/moviepilot-api/api/music.md @@ -188,6 +188,7 @@ online confirmation; album corrections must preserve the returned source-file sc ### `music.album.get` `GET /api/v1/music/album/{album_id}`; policy effect: `safe_read`. Purpose: Read one album's details, tracks, releases, and aligned artist names and IDs. +- Album and track text follows `MUSIC_METADATA_TO_SIMPLIFIED`, matching recognition and final transfer naming. Original aliases, source data, lyrics, and IDs remain unchanged. Recording selection does not override local album-edition evidence, and multi-artist credits remain complete. - `path_params`: `album_id*` (string): Source-native album ID returned by music search, exploration, or artist-album browsing. - `query`: `media_source` (MediaSource): Metadata source identifier. Preserve the exact value returned with media_id.; `musicbrainz_release_id` (string|null): Optional exact MusicBrainz Release UUID belonging to the selected album Release Group; never use it as media_id. - `body`: none diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 2d2065a23..207f20a64 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -941,8 +941,8 @@ "runtime_only": true } }, - "edge_count": 9299, - "edge_sha256": "c87fbb377412559c30d21b4c44c2906085143fa58a9e645855348d72f0b056a6", + "edge_count": 9300, + "edge_sha256": "8f47b7d4f63b473306a741bd791077c4d88986061a3f182ded3ab7389c709d2d", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -3717,6 +3717,7 @@ "app.application.messaging.message -> app.foundation.crypto", "app.application.messaging.message -> app.foundation.singleton", "app.application.messaging.message -> app.foundation.size", + "app.application.messaging.message -> app.foundation.text", "app.application.messaging.message -> app.runtime", "app.application.messaging.message -> app.runtime.cache", "app.application.messaging.message -> app.runtime.log", diff --git a/tests/test_music_local_transfer.py b/tests/test_music_local_transfer.py index e393c23d6..bb32bb71c 100644 --- a/tests/test_music_local_transfer.py +++ b/tests/test_music_local_transfer.py @@ -15,7 +15,7 @@ from app.chain.media import MediaChain from app.domain.meta.metamusic import MetaMusic from app.domain.music import music_tags_are_usable from app.modules.filemanager.transhandler import TransHandler -from app.runtime.config import ConfigModel +from app.runtime.config import ConfigModel, settings from app.schemas.category import CategoryConfig, ClassificationPolicy from app.schemas.file import FileItem from app.schemas.system import TransferDirectoryConf @@ -33,8 +33,8 @@ def _tagged_files(directory: Path) -> list[Path]: shutil.copyfile(Path(__file__).parent / "fixtures/audio/silence.flac", path) audio = FLAC(path) audio.update({ - "title": [title], "artist": ["周杰伦"], "album": ["叶惠美"], - "albumartist": ["周杰伦"], "date": ["2003"], + "title": [title], "artist": ["周杰倫"], "album": ["葉惠美"], + "albumartist": ["周杰倫"], "date": ["2003"], "tracknumber": ["1"], "tracktotal": ["1"], "discnumber": [str(disc)], "disctotal": ["2"], }) @@ -57,8 +57,10 @@ def _classification_service(): @pytest.mark.parametrize("mtype", [None, MediaType.MUSIC]) -def test_complete_tags_preview_offline_and_preserve_custom_category(tmp_path, monkeypatch, mtype): - """自动与显式音乐批次均以真实标签完成目标命名,双碟不碰撞且源内容不变。""" +@pytest.mark.parametrize("simplified", [False, True]) +def test_complete_tags_preview_offline_and_preserve_custom_category(tmp_path, monkeypatch, mtype, simplified): + """真实标签离线整理遵守简体开关和自定义分类,双碟不碰撞且源内容不变。""" + monkeypatch.setattr(settings, "MUSIC_METADATA_TO_SIMPLIFIED", simplified) source = tmp_path / "2022-wrong-name" paths = _tagged_files(source) hashes = [hashlib.sha256(path.read_bytes()).digest() for path in paths] @@ -114,9 +116,10 @@ def test_complete_tags_preview_offline_and_preserve_custom_category(tmp_path, mo assert all(not item.music.online_confirmed and item.source_storage == "local" for item in projected.items) assert [item.music.disc_number for item in projected.items] == [1, 2] assert [item.source_item.path for item in projected.items] == [str(path) for path in paths] + artist, album = ("周杰伦", "叶惠美") if simplified else ("周杰倫", "葉惠美") assert destinations == [ - "收藏/周杰伦/叶惠美 (2003)/Disc 1/01 - 晴天.flac", - "收藏/周杰伦/叶惠美 (2003)/Disc 2/01 - 以父之名.flac", + f"收藏/{artist}/{album} (2003)/Disc 1/01 - 晴天.flac", + f"收藏/{artist}/{album} (2003)/Disc 2/01 - 以父之名.flac", ] assert [hashlib.sha256(path.read_bytes()).digest() for path in paths] == hashes for operation in online: diff --git a/tests/test_music_metadata_simplification.py b/tests/test_music_metadata_simplification.py new file mode 100644 index 000000000..e4178075d --- /dev/null +++ b/tests/test_music_metadata_simplification.py @@ -0,0 +1,109 @@ +"""音乐专辑详情与整理命名共享简体开关,并保留原始来源证据。""" + +import asyncio +from copy import deepcopy +from unittest.mock import AsyncMock, Mock + +import pytest +from jinja2 import Template + +from app.application.messaging.message import TemplateContextBuilder +from app.chain.media import MediaChain +from app.domain.context import MusicAlbumInfo, MusicInfo +from app.domain.meta.metamusic import MetaMusic +from app.runtime.config import ConfigModel, settings + + +@pytest.mark.parametrize("asynchronous", [False, True]) +@pytest.mark.parametrize("simplified", [False, True]) +@pytest.mark.parametrize("album_title", ["歲月", "Album"]) +def test_album_details_simplify_tracks_without_mutating_source( + monkeypatch, asynchronous, simplified, album_title, +): + """同步、异步详情均转换曲目;仅曲目含繁体时也不能污染来源缓存。""" + source = MusicAlbumInfo( + media_source="musicbrainz", media_id="album-1", title=album_title, + artists=["優里"] if album_title != "Album" else ["Artist"], + artist_ids=["artist-1"], release_date="2026-09-02", + musicbrainz_release_id="release-1", + tracks=[MusicInfo( + media_source="musicbrainz", media_id="recording-1", + title="後來", artists=["優里", "UMI"], artist_ids=["artist-1", "artist-2"], + album="歲月", album_artist="優里 / UMI", album_id="album-1", + lyrics="後來的歌詞", track_number=1, disc_number=1, + musicbrainz_release_id="release-1", musicbrainz_release_track_id="track-1", + raw_data={"title": "後來", "artist-credit": [{"name": "優里"}]}, + )], + raw_data={"title": album_title}, + ) + original = deepcopy(source) + provider = Mock() + provider.get_music_album.return_value = source + provider.async_get_music_album = AsyncMock(return_value=source) + chain = MediaChain() + monkeypatch.setattr(chain, "_music_source_chain", Mock(return_value=provider)) + monkeypatch.setattr(settings, "MUSIC_METADATA_TO_SIMPLIFIED", simplified) + + kwargs = {"media_source": "musicbrainz", "media_id": "album-1", + "musicbrainz_release_id": "release-1"} + result = (asyncio.run(chain.async_get_music_album(**kwargs)) if asynchronous + else chain.get_music_album(**kwargs)) + + assert result.title == ("岁月" if simplified and album_title != "Album" else album_title) + assert result.artists == (["优里"] if simplified and album_title != "Album" else original.artists) + assert result.artist_ids == original.artist_ids + assert result.musicbrainz_release_id == "release-1" + track = result.tracks[0] + assert track.title == ("后来" if simplified else "後來") + assert track.artists == (["优里", "UMI"] if simplified else ["優里", "UMI"]) + assert track.album == ("岁月" if simplified else "歲月") + assert track.album_artist == ("优里 / UMI" if simplified else "優里 / UMI") + assert (track.media_id, track.album_id, track.artist_ids) == ("recording-1", "album-1", ["artist-1", "artist-2"]) + assert (track.musicbrainz_release_id, track.musicbrainz_release_track_id) == ("release-1", "track-1") + assert (track.track_number, track.disc_number) == (1, 1) + assert track.lyrics == original.tracks[0].lyrics + assert track.raw_data == original.tracks[0].raw_data + assert source == original + if simplified: + assert result is not source + assert "後來" in track.title_aliases + if album_title != "Album": + assert "歲月" in result.title_aliases + assert "優里" in result.artist_aliases + + +@pytest.mark.parametrize("music_type", ["local", "recording", "album"]) +@pytest.mark.parametrize("simplified", [False, True]) +def test_music_naming_converts_selected_fields_without_changing_identity( + monkeypatch, music_type, simplified, +): + """本地标签、录音与专辑命名同样转简体,录音身份不替换本地发行专辑。""" + meta = MetaMusic( + title="後來", artists=["優里", "UMI"], album="歲月", + album_artist="優里", year=2026, track_number=1, disc_number=2, total_discs=2, + ) + info = MusicInfo.from_meta(meta) + if music_type != "local": + info.media_source, info.media_id, info.music_type = "musicbrainz", "selected-1", music_type + info.album, info.album_artist, info.year = "精選", "合輯藝人", 2020 + original_meta, original_info = deepcopy(meta.to_dict()), deepcopy(info) + monkeypatch.setattr(settings, "MUSIC_METADATA_TO_SIMPLIFIED", simplified) + + context = TemplateContextBuilder().build( + meta=meta, mediainfo=info, file_extension=".flac", include_raw_objects=False, + ) + path = Template(ConfigModel().MUSIC_RENAME_FORMAT).render(context) + + expected_artist = "合辑艺人" if music_type == "album" else "优里" + expected_album = "精选" if music_type == "album" else "岁月" + if not simplified: + expected_artist = "合輯藝人" if music_type == "album" else "優里" + expected_album = "精選" if music_type == "album" else "歲月" + expected_title = "后来" if simplified else "後來" + expected_year = 2020 if music_type == "album" else 2026 + assert path == f"{expected_artist}/{expected_album} ({expected_year})/Disc 2/01 - {expected_title}.flac" + assert context["artists"] == (["优里", "UMI"] if simplified else ["優里", "UMI"]) + assert context["artist"] == ("优里 / UMI" if simplified else "優里 / UMI") + assert context["title_year"] == f"{expected_title} ({expected_year})" + assert meta.to_dict() == original_meta + assert info == original_info