mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 00:46:57 +08:00
feat: 支持音乐识别信息转简体
This commit is contained in:
+59
-7
@@ -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:
|
||||
"""路径识别请求是否属于音乐:音频后缀文件或显式指定音乐数据源。"""
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 命令
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user