feat: 支持音乐识别信息转简体

This commit is contained in:
jxxghp
2026-08-14 21:02:33 +08:00
parent c580181049
commit d6bf78b63a
4 changed files with 159 additions and 7 deletions
+59 -7
View File
@@ -43,6 +43,7 @@ from app.domain.media import (
resolve_media_identity, resolve_media_identity,
) )
from app.foundation.singleton import Singleton from app.foundation.singleton import Singleton
from app.foundation.text import convert as zhconv_convert
from app.domain.string import StringUtils from app.domain.string import StringUtils
recognize_lock = Lock() 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: dict[str, tuple[tuple[str, ...], dict[str, MusicInfo]]] = {}
_album_dir_cache_max = 128 _album_dir_cache_max = 128
_album_match_min_files = 2 _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 @staticmethod
def _music_source_chain( def _music_source_chain(
@@ -206,7 +216,49 @@ class MediaChain(ChainBase, metaclass=Singleton):
or str(result.media_id or "") != media_id or str(result.media_id or "") != media_id
): ):
return None 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( def recognize_music_from_source(
self, self,
@@ -1193,12 +1245,12 @@ class MediaChain(ChainBase, metaclass=Singleton):
signature = self._album_directory_signature(directory, files) signature = self._album_directory_signature(directory, files)
cached = self._album_dir_cache.get(key) cached = self._album_dir_cache.get(key)
if cached and cached[0] == signature: 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) matched = self._match_music_album_directory(directory, files)
if len(self._album_dir_cache) >= self._album_dir_cache_max: if len(self._album_dir_cache) >= self._album_dir_cache_max:
self._album_dir_cache.clear() self._album_dir_cache.clear()
self._album_dir_cache[key] = signature, matched self._album_dir_cache[key] = signature, matched
return matched return self._simplify_recognized_music_mapping(matched)
async def async_recognize_music_album_directory( async def async_recognize_music_album_directory(
self, self,
@@ -1215,12 +1267,12 @@ class MediaChain(ChainBase, metaclass=Singleton):
signature = self._album_directory_signature(directory, files) signature = self._album_directory_signature(directory, files)
cached = self._album_dir_cache.get(key) cached = self._album_dir_cache.get(key)
if cached and cached[0] == signature: 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) matched = await self._async_match_music_album_directory(directory, files)
if len(self._album_dir_cache) >= self._album_dir_cache_max: if len(self._album_dir_cache) >= self._album_dir_cache_max:
self._album_dir_cache.clear() self._album_dir_cache.clear()
self._album_dir_cache[key] = signature, matched self._album_dir_cache[key] = signature, matched
return matched return self._simplify_recognized_music_mapping(matched)
def recognize_music_by_path( def recognize_music_by_path(
self, self,
@@ -1259,7 +1311,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
matched = self._music_album_dir_fallback(path) matched = self._music_album_dir_fallback(path)
if matched: if matched:
result = self._merge_music_audio_quality(matched, meta) 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( async def async_recognize_music_by_path(
self, self,
@@ -1304,7 +1356,7 @@ class MediaChain(ChainBase, metaclass=Singleton):
matched = await self._async_music_album_dir_fallback(path) matched = await self._async_music_album_dir_fallback(path)
if matched: if matched:
result = self._merge_music_audio_quality(matched, meta) 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: def _is_music_path_request(self, path: str, media_source: Optional[MediaSource]) -> bool:
"""路径识别请求是否属于音乐:音频后缀文件或显式指定音乐数据源。""" """路径识别请求是否属于音乐:音频后缀文件或显式指定音乐数据源。"""
+2
View File
@@ -240,6 +240,8 @@ class ConfigModel(BaseModel):
MUSIC_COVER_PROXY: str = "" MUSIC_COVER_PROXY: str = ""
# AcoustID 应用 API Key,用于查询本地音频的 Chromaprint 指纹 # AcoustID 应用 API Key,用于查询本地音频的 Chromaprint 指纹
ACOUSTID_API_KEY: str = "b1auxfOzAg" ACOUSTID_API_KEY: str = "b1auxfOzAg"
# 是否将识别到的音乐标题、艺术家、专辑等标准元数据转换为简体中文
MUSIC_METADATA_TO_SIMPLIFIED: bool = True
# TheAudioDB API Key,默认使用官方公开的免费 V1 Key,可通过环境变量覆盖 # TheAudioDB API Key,默认使用官方公开的免费 V1 Key,可通过环境变量覆盖
THEAUDIODB_API_KEY: str = "123" THEAUDIODB_API_KEY: str = "123"
+2
View File
@@ -442,6 +442,7 @@ moviepilot config set PORT 3001
moviepilot config set NGINX_PORT 3000 moviepilot config set NGINX_PORT 3000
moviepilot config set API_TOKEN your-token-here moviepilot config set API_TOKEN your-token-here
moviepilot config set ACOUSTID_API_KEY your-acoustid-client-key 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 list` 显示当前配置值
- `config keys` 显示配置项名称、类型和默认值 - `config keys` 显示配置项名称、类型和默认值
- `ACOUSTID_API_KEY` 内置可用默认值,也可在前端“高级设置 - 媒体”或配置命令中覆盖;本地安装需要系统可执行路径中存在 Chromaprint `fpcalc`,官方 Docker 镜像已内置 - `ACOUSTID_API_KEY` 内置可用默认值,也可在前端“高级设置 - 媒体”或配置命令中覆盖;本地安装需要系统可执行路径中存在 Chromaprint `fpcalc`,官方 Docker 镜像已内置
- `MUSIC_METADATA_TO_SIMPLIFIED` 默认开启;开启后会将识别结果中的曲名、艺术家、专辑和分类等标准音乐元数据转换为简体中文,不转换歌词与来源原始响应
- `config describe` 显示单个配置项的类型、默认值和当前值 - `config describe` 显示单个配置项的类型、默认值和当前值
## Tool 命令 ## Tool 命令
+96
View File
@@ -17,6 +17,7 @@ from app.modules.bangumi import BangumiModule
from app.modules.musicbrainz import MusicBrainzModule from app.modules.musicbrainz import MusicBrainzModule
from app.modules.theaudiodb import TheAudioDbModule from app.modules.theaudiodb import TheAudioDbModule
from app.modules.themoviedb import TheMovieDbModule from app.modules.themoviedb import TheMovieDbModule
from app.runtime.config import ConfigModel
from app.schemas.types import MediaSource, MediaType 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 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): def test_media_chain_rejects_replaced_explicit_identity(monkeypatch):
"""显式 ID 识别不得用标题搜索得到的另一 ID 替换请求目标。""" """显式 ID 识别不得用标题搜索得到的另一 ID 替换请求目标。"""
chain = MediaChain() chain = MediaChain()