fix(music): apply simplified metadata to manual transfers

Normalize album details and final music template fields while preserving local edition evidence, multi-artist credits and source objects. Refs #6870.
This commit is contained in:
jxxghp
2026-10-04 08:47:15 +08:00
parent 6e295d5d2f
commit 754da97d97
10 changed files with 173 additions and 22 deletions

View File

@@ -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,

View File

@@ -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),

View File

@@ -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

View File

@@ -567,6 +567,12 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch
标题、副标题的实际解析,不用目标媒体回填证据;`title_aliases`、`album_aliases`、
`artist_aliases` 分别保留同一实体的可信别名及展示转简体前的原文。
`MUSIC_METADATA_TO_SIMPLIFIED` 同样适用于同步/异步专辑详情及其逐曲文本,
也适用于音乐整理最终命名:完整本地标签、手选专辑发行版和手选录音均按开关转换。
转换不改写原始音频标签、来源缓存、歌词或媒体身份;关闭开关保留原文。
录音身份不等同于专辑发行身份,单曲仍保留已有的本地专辑字段优先级;
多艺人署名完整保留,不自动截取第一位艺人。
音乐元数据、单曲和专辑模型还分别保留 `composers`、`conductors`、`orchestras` 人名列表,
以及 `performers` 乐器/声部到人名列表的映射(未指定乐器使用 `performer` 键)。
这些字段不会混入主艺人或艺人别名;空字段在简化卡片中省略,完整模型与旧缓存兼容。

View File

@@ -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 |

View File

@@ -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.

View File

@@ -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

View File

@@ -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",

View File

@@ -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:

View File

@@ -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