From d6bf78b63a6948e3f3ddaa34db1f611783a38206 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Fri, 14 Aug 2026 21:02:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E9=9F=B3=E4=B9=90?= =?UTF-8?q?=E8=AF=86=E5=88=AB=E4=BF=A1=E6=81=AF=E8=BD=AC=E7=AE=80=E4=BD=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/chain/media.py | 66 ++++++++++++++++-- app/runtime/config.py | 2 + docs/cli.md | 2 + tests/test_music_recognize_routing.py | 96 +++++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 7 deletions(-) diff --git a/app/chain/media.py b/app/chain/media.py index 390c23e47..db7762a33 100644 --- a/app/chain/media.py +++ b/app/chain/media.py @@ -43,6 +43,7 @@ from app.domain.media import ( resolve_media_identity, ) from app.foundation.singleton import Singleton +from app.foundation.text import convert as zhconv_convert from app.domain.string import StringUtils recognize_lock = Lock() @@ -58,6 +59,15 @@ class MediaChain(ChainBase, metaclass=Singleton): _album_dir_cache: dict[str, tuple[tuple[str, ...], dict[str, MusicInfo]]] = {} _album_dir_cache_max = 128 _album_match_min_files = 2 + _music_simplified_text_fields = ( + "title", + "album", + "album_artist", + "album_type", + "version", + "category", + ) + _music_simplified_list_fields = ("artists", "genres", "names") @staticmethod def _music_source_chain( @@ -206,7 +216,49 @@ class MediaChain(ChainBase, metaclass=Singleton): or str(result.media_id or "") != media_id ): return None - return result + return MediaChain._simplify_recognized_music_info(result) + + @classmethod + def _simplify_recognized_music_info(cls, info: MusicInfo) -> MusicInfo: + """按开关转换标准音乐文本字段,并避免修改来源模块的缓存对象。""" + if not settings.MUSIC_METADATA_TO_SIMPLIFIED: + return info + updates: dict[str, Any] = {} + for field_name in cls._music_simplified_text_fields: + value = getattr(info, field_name, None) + if isinstance(value, str): + converted = zhconv_convert(value, "zh-hans") + if converted != value: + updates[field_name] = converted + for field_name in cls._music_simplified_list_fields: + value = getattr(info, field_name, None) + if isinstance(value, list): + converted = [ + zhconv_convert(item, "zh-hans") if isinstance(item, str) else item + for item in value + ] + if converted != value: + updates[field_name] = converted + if not updates: + return info + simplified = deepcopy(info) + for field_name, value in updates.items(): + setattr(simplified, field_name, value) + return simplified + + @classmethod + def _simplify_recognized_music_mapping( + cls, + matched: dict[str, MusicInfo], + ) -> dict[str, MusicInfo]: + """转换目录识别结果,同时让缓存始终保留来源返回的原始文本。""" + simplified = { + path: cls._simplify_recognized_music_info(info) + for path, info in matched.items() + } + if all(simplified[path] is info for path, info in matched.items()): + return matched + return simplified def recognize_music_from_source( self, @@ -1193,12 +1245,12 @@ class MediaChain(ChainBase, metaclass=Singleton): signature = self._album_directory_signature(directory, files) cached = self._album_dir_cache.get(key) if cached and cached[0] == signature: - return cached[1] + return self._simplify_recognized_music_mapping(cached[1]) matched = self._match_music_album_directory(directory, files) if len(self._album_dir_cache) >= self._album_dir_cache_max: self._album_dir_cache.clear() self._album_dir_cache[key] = signature, matched - return matched + return self._simplify_recognized_music_mapping(matched) async def async_recognize_music_album_directory( self, @@ -1215,12 +1267,12 @@ class MediaChain(ChainBase, metaclass=Singleton): signature = self._album_directory_signature(directory, files) cached = self._album_dir_cache.get(key) if cached and cached[0] == signature: - return cached[1] + return self._simplify_recognized_music_mapping(cached[1]) matched = await self._async_match_music_album_directory(directory, files) if len(self._album_dir_cache) >= self._album_dir_cache_max: self._album_dir_cache.clear() self._album_dir_cache[key] = signature, matched - return matched + return self._simplify_recognized_music_mapping(matched) def recognize_music_by_path( self, @@ -1259,7 +1311,7 @@ class MediaChain(ChainBase, metaclass=Singleton): matched = self._music_album_dir_fallback(path) if matched: result = self._merge_music_audio_quality(matched, meta) - return meta, result + return meta, self._simplify_recognized_music_info(result) async def async_recognize_music_by_path( self, @@ -1304,7 +1356,7 @@ class MediaChain(ChainBase, metaclass=Singleton): matched = await self._async_music_album_dir_fallback(path) if matched: result = self._merge_music_audio_quality(matched, meta) - return meta, result + return meta, self._simplify_recognized_music_info(result) def _is_music_path_request(self, path: str, media_source: Optional[MediaSource]) -> bool: """路径识别请求是否属于音乐:音频后缀文件或显式指定音乐数据源。""" diff --git a/app/runtime/config.py b/app/runtime/config.py index 7728cf23c..00f807a2f 100644 --- a/app/runtime/config.py +++ b/app/runtime/config.py @@ -240,6 +240,8 @@ class ConfigModel(BaseModel): MUSIC_COVER_PROXY: str = "" # AcoustID 应用 API Key,用于查询本地音频的 Chromaprint 指纹 ACOUSTID_API_KEY: str = "b1auxfOzAg" + # 是否将识别到的音乐标题、艺术家、专辑等标准元数据转换为简体中文 + MUSIC_METADATA_TO_SIMPLIFIED: bool = True # TheAudioDB API Key,默认使用官方公开的免费 V1 Key,可通过环境变量覆盖 THEAUDIODB_API_KEY: str = "123" diff --git a/docs/cli.md b/docs/cli.md index 72ddca69b..326f61528 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -442,6 +442,7 @@ moviepilot config set PORT 3001 moviepilot config set NGINX_PORT 3000 moviepilot config set API_TOKEN your-token-here moviepilot config set ACOUSTID_API_KEY your-acoustid-client-key +moviepilot config set MUSIC_METADATA_TO_SIMPLIFIED true ``` 查看所有可配置项: @@ -460,6 +461,7 @@ moviepilot config describe API_TOKEN --show-secrets - `config list` 显示当前配置值 - `config keys` 显示配置项名称、类型和默认值 - `ACOUSTID_API_KEY` 内置可用默认值,也可在前端“高级设置 - 媒体”或配置命令中覆盖;本地安装需要系统可执行路径中存在 Chromaprint `fpcalc`,官方 Docker 镜像已内置 +- `MUSIC_METADATA_TO_SIMPLIFIED` 默认开启;开启后会将识别结果中的曲名、艺术家、专辑和分类等标准音乐元数据转换为简体中文,不转换歌词与来源原始响应 - `config describe` 显示单个配置项的类型、默认值和当前值 ## Tool 命令 diff --git a/tests/test_music_recognize_routing.py b/tests/test_music_recognize_routing.py index 0317c49b6..8004e52dc 100644 --- a/tests/test_music_recognize_routing.py +++ b/tests/test_music_recognize_routing.py @@ -17,6 +17,7 @@ from app.modules.bangumi import BangumiModule from app.modules.musicbrainz import MusicBrainzModule from app.modules.theaudiodb import TheAudioDbModule from app.modules.themoviedb import TheMovieDbModule +from app.runtime.config import ConfigModel from app.schemas.types import MediaSource, MediaType @@ -341,6 +342,101 @@ def test_media_chain_rejects_cross_entity_detail_result(monkeypatch): assert source_chain.recognize_music.call_args.kwargs["music_type"] == MUSIC_ENTITY_ALBUM +def test_music_metadata_simplified_conversion_defaults_to_enabled(): + """音乐识别结果转简体开关应默认开启。""" + assert ConfigModel.model_fields["MUSIC_METADATA_TO_SIMPLIFIED"].default is True + + +def test_media_chain_converts_recognized_music_metadata_without_mutating_source(monkeypatch): + """开启开关时应转换标准音乐字段,并保留模块缓存对象和歌词原文。""" + source_info = MusicInfo( + media_source="musicbrainz", + media_id="recording-1", + title="後來的我們", + artists=["張學友"], + album="歲月如歌", + album_artist="張學友", + category="華語流行", + genres=["華語"], + names=["後來的我們", "歲月如歌"], + lyrics="後來的我們", + ) + source_chain = Mock() + source_chain.recognize_music.return_value = source_info + chain = MediaChain() + monkeypatch.setattr(chain, "_music_source_chain", Mock(return_value=source_chain)) + monkeypatch.setattr("app.chain.media.settings.MUSIC_METADATA_TO_SIMPLIFIED", True) + + result = chain.recognize_music_from_source( + media_source="musicbrainz", + media_id="recording-1", + ) + + assert result is not source_info + assert result.title == "后来的我们" + assert result.artists == ["张学友"] + assert result.album == "岁月如歌" + assert result.album_artist == "张学友" + assert result.category == "华语流行" + assert result.genres == ["华语"] + assert result.names == ["后来的我们", "岁月如歌"] + assert result.lyrics == "後來的我們" + assert source_info.title == "後來的我們" + assert source_info.artists == ["張學友"] + + +def test_media_chain_preserves_original_music_metadata_when_conversion_disabled(monkeypatch): + """关闭开关时识别结果应保持来源提供的原始繁简写法。""" + source_info = MusicInfo( + media_source="musicbrainz", + media_id="recording-1", + title="後來的我們", + artists=["張學友"], + ) + source_chain = Mock() + source_chain.recognize_music.return_value = source_info + chain = MediaChain() + monkeypatch.setattr(chain, "_music_source_chain", Mock(return_value=source_chain)) + monkeypatch.setattr("app.chain.media.settings.MUSIC_METADATA_TO_SIMPLIFIED", False) + + result = chain.recognize_music_from_source( + media_source="musicbrainz", + media_id="recording-1", + ) + + assert result is source_info + assert result.title == "後來的我們" + assert result.artists == ["張學友"] + + +def test_music_path_fallback_converts_local_tag_metadata(monkeypatch): + """远端未命中时,本地标签生成的音乐信息也应按开关转换为简体。""" + meta = MetaMusic( + title="後來的我們", + artists=["張學友"], + album="歲月如歌", + ) + chain = MediaChain() + monkeypatch.setattr( + "app.chain.media.AudioMetadataHelper.read_evidence", + Mock(return_value=(meta, meta, MetaMusic())), + ) + monkeypatch.setattr( + AcoustIdChain, + "identify_music_by_fingerprint", + Mock(return_value=None), + ) + monkeypatch.setattr(chain, "recognize_media", Mock(return_value=None)) + monkeypatch.setattr("app.chain.media.settings.MUSIC_METADATA_TO_SIMPLIFIED", True) + + _, result = chain.recognize_music_by_path("track.flac") + + assert result.title == "后来的我们" + assert result.artists == ["张学友"] + assert result.album == "岁月如歌" + assert meta.title == "後來的我們" + + def test_media_chain_rejects_replaced_explicit_identity(monkeypatch): """显式 ID 识别不得用标题搜索得到的另一 ID 替换请求目标。""" chain = MediaChain()