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