feat(music): 完善歌词与专辑目录整理

This commit is contained in:
jxxghp
2026-08-25 21:02:26 +08:00
parent f28e8b1538
commit 574c50586e
31 changed files with 1228 additions and 105 deletions
+24 -10
View File
@@ -13,8 +13,8 @@
"runtime_to_db": [],
"workflow_to_db": []
},
"edge_count": 6676,
"edge_sha256": "4244dafa5ec5179e2cfbf005dca97f9dde678f9c45e780288bbbbaca36ff4de5",
"edge_count": 6689,
"edge_sha256": "764be9020b4657c344453dc801ba270c925a2b26c94c0fb43c5a9f89953a4e07",
"edges": [
"app -> app.runtime",
"app -> app.runtime.compat",
@@ -3199,11 +3199,11 @@
"app.chain.listenbrainz -> app.domain.context",
"app.chain.listenbrainz -> app.schemas",
"app.chain.listenbrainz -> app.schemas.types",
"app.chain.lrclib -> app.chain",
"app.chain.lrclib -> app.domain",
"app.chain.lrclib -> app.domain.context",
"app.chain.lrclib -> app.domain.meta",
"app.chain.lrclib -> app.domain.meta.metamusic",
"app.chain.lyrics -> app.chain",
"app.chain.lyrics -> app.domain",
"app.chain.lyrics -> app.domain.context",
"app.chain.lyrics -> app.domain.meta",
"app.chain.lyrics -> app.domain.meta.metamusic",
"app.chain.media -> app.application",
"app.chain.media -> app.application.audio",
"app.chain.media -> app.application.configuration",
@@ -3309,7 +3309,7 @@
"app.chain.scraping -> app.application.audio",
"app.chain.scraping -> app.application.configuration",
"app.chain.scraping -> app.chain",
"app.chain.scraping -> app.chain.lrclib",
"app.chain.scraping -> app.chain.lyrics",
"app.chain.scraping -> app.chain.media",
"app.chain.scraping -> app.chain.storage",
"app.chain.scraping -> app.domain",
@@ -4879,6 +4879,19 @@
"app.modules.musicbrainz.music_cache -> app.runtime.settings",
"app.modules.musicbrainz.music_cache -> app.schemas",
"app.modules.musicbrainz.music_cache -> app.schemas.types",
"app.modules.musixmatch -> app.adapters",
"app.modules.musixmatch -> app.adapters.network",
"app.modules.musixmatch -> app.adapters.network.http",
"app.modules.musixmatch -> app.domain",
"app.modules.musixmatch -> app.domain.context",
"app.modules.musixmatch -> app.domain.meta",
"app.modules.musixmatch -> app.domain.meta.metamusic",
"app.modules.musixmatch -> app.modules",
"app.modules.musixmatch -> app.runtime",
"app.modules.musixmatch -> app.runtime.log",
"app.modules.musixmatch -> app.runtime.settings",
"app.modules.musixmatch -> app.schemas",
"app.modules.musixmatch -> app.schemas.types",
"app.modules.navidrome -> app.application",
"app.modules.navidrome -> app.application.mediaserver",
"app.modules.navidrome -> app.domain",
@@ -6693,7 +6706,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 822,
"module_count": 823,
"modules": [
"app",
"app.adapters",
@@ -7050,7 +7063,7 @@
"app.chain.download",
"app.chain.interaction",
"app.chain.listenbrainz",
"app.chain.lrclib",
"app.chain.lyrics",
"app.chain.media",
"app.chain.mediaserver",
"app.chain.message",
@@ -7252,6 +7265,7 @@
"app.modules.lrclib",
"app.modules.musicbrainz",
"app.modules.musicbrainz.music_cache",
"app.modules.musixmatch",
"app.modules.navidrome",
"app.modules.navidrome.navidrome",
"app.modules.plex",
+34 -4
View File
@@ -5299,6 +5299,24 @@
"timeout_policy": "caller_budget",
"version": 1
},
"music_lyrics_candidates": {
"aggregation": "ordered_list_merge",
"error_policy": "isolate_provider",
"execution": "sync_or_async",
"family": "music",
"input_contract": "MusicLyricsRequest",
"plugin_short_circuit": false,
"public_to_plugins": true,
"required_parameters": [
"music"
],
"result_contract": "list[MusicLyrics]",
"result_shape": "list",
"supports_async": true,
"supports_sync": true,
"timeout_policy": "caller_budget",
"version": 1
},
"obtain_images": {
"aggregation": "pipeline_relay",
"error_policy": "isolate_provider",
@@ -6423,10 +6441,10 @@
}
},
"run_module": {
"call_count": 261,
"call_count": 263,
"dynamic_call_count": 0,
"dynamic_calls": [],
"method_count": 211,
"method_count": 212,
"methods": {
"anilist_credits": [
{
@@ -7616,12 +7634,24 @@
],
"music_lyrics": [
{
"caller": "app.chain.lrclib",
"caller": "app.chain.lyrics",
"count": 1,
"mode": "async"
},
{
"caller": "app.chain.lrclib",
"caller": "app.chain.lyrics",
"count": 1,
"mode": "sync"
}
],
"music_lyrics_candidates": [
{
"caller": "app.chain.lyrics",
"count": 1,
"mode": "async"
},
{
"caller": "app.chain.lyrics",
"count": 1,
"mode": "sync"
}
-1
View File
@@ -14,7 +14,6 @@ MUSIC_SOURCE_CHAIN_FILES = (
"acoustid.py",
"douban.py",
"listenbrainz.py",
"lrclib.py",
"musicbrainz.py",
"theaudiodb.py",
)
@@ -506,7 +506,7 @@ from app.runtime.extensions.host_module_adapter import (
registry = build_host_module_registry()
specs = registry.list_specs()
assert len(specs) == 39
assert len(specs) == 40
adapter = HostModuleAdapter()
lifecycle_events = []
@@ -553,7 +553,7 @@ from app.schemas.types import EventType
registry = build_host_module_registry()
specs = registry.list_specs()
assert len(specs) == 39
assert len(specs) == 40
spec_by_id = {spec.id: spec for spec in specs}
events = {spec.id: [] for spec in specs}
@@ -720,7 +720,7 @@ from app.runtime.extensions.host_module_adapter import (
registry = build_host_module_registry()
specs = registry.list_specs()
assert len(specs) == 39
assert len(specs) == 40
configured_specs = tuple(
spec for spec in specs
if spec.activation is ActivationPolicy.WHEN_CONFIGURED
@@ -816,12 +816,12 @@ from app.application.module import configure_module_runtime
configure_module_runtime(lambda: ModuleManager())
manager = ModuleManager()
assert len(manager.list_specs()) == 39
assert len(manager.list_specs()) == 40
assert manager.get_specs() == manager.list_specs()
from app.api.endpoints.system import modulelist
response = modulelist(None)
assert len(response.data["modules"]) == 39
assert len(response.data["modules"]) == 40
heavy_prefixes = (
"lark_oapi",
@@ -930,7 +930,7 @@ from app.runtime.extensions.module_manager import ModuleManager
manager = ModuleManager()
modules = manager.get_modules()
assert len(modules) == len(manager.list_specs()) == 39
assert len(modules) == len(manager.list_specs()) == 40
for spec in manager.list_specs():
implementation = modules[spec.id]
assert implementation.get_name() == spec.metadata["name"]
+159
View File
@@ -0,0 +1,159 @@
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
from mutagen.id3 import SYLT, USLT
from app.application.audio import AudioMetadataHelper
from app.chain.lyrics import LyricsChain
from app.chain.scraping import ScrapingChain
from app.domain.context import MusicInfo, MusicLyrics
from app.domain.meta.metamusic import MetaMusic
from app.schemas.workflow import FileItem
def test_lyricsfile_derives_lrc_plain_language_and_word_quality() -> None:
"""Lyricsfile 应保留原文,并派生播放器兼容内容和逐字同步质量。"""
lyrics = MusicLyrics(
provider="lrclib",
lyricsfile="""
version: '1.0'
metadata:
title: 晴天
artist: 周杰伦
language: zh
lines:
- start_ms: 1250
text: 晴天
words:
- {start_ms: 1250, text: 晴}
- {start_ms: 1500, text: 天}
""",
)
assert lyrics.language == "zh"
assert lyrics.synced_lyrics == "[00:01.25]晴天"
assert lyrics.plain_lyrics == "晴天"
assert lyrics.quality_rank == 4
def test_lyricsfile_rejects_yaml_aliases() -> None:
"""外部 Lyricsfile 不得通过 YAML 锚点别名制造共享或膨胀结构。"""
lyrics = MusicLyrics(
provider="lrclib",
lyricsfile="""
version: '1.0'
metadata: {title: Track, artist: Artist}
base: &line {start: 1000, text: unsafe}
lines: [*line]
""",
)
assert lyrics.content is None
assert lyrics.quality_rank == 0
def test_lyrics_chain_prefers_synced_candidate_over_exact_plain_fallback(monkeypatch) -> None:
"""可信候选中应优先同步质量,再以匹配度和来源优先级打破平局。"""
chain = LyricsChain()
responses = iter([
MusicLyrics(provider="legacy", plain_lyrics="plain", match_score=100),
[MusicLyrics(provider="licensed", synced_lyrics="[00:01]sync", match_score=95)],
])
monkeypatch.setattr(chain, "run_module", lambda *_args, **_kwargs: next(responses))
result = chain.get_music_lyrics(MusicInfo(title="Track", artists=["Artist"]))
assert result is not None
assert result.provider == "licensed"
def test_lyrics_chain_selects_async_candidates(monkeypatch) -> None:
"""通用歌词链异步入口应返回候选中的最优结果。"""
chain = LyricsChain()
run_module = AsyncMock(side_effect=[
MusicLyrics(provider="legacy", plain_lyrics="plain", match_score=100),
[MusicLyrics(provider="lrclib", synced_lyrics="[00:01]sync", match_score=100)],
])
monkeypatch.setattr(chain, "async_run_module", run_module)
result = asyncio.run(chain.async_get_music_lyrics(
MusicInfo(title="Track", artists=["Artist"])
))
assert result is not None
assert result.synced_lyrics == "[00:01]sync"
def test_audio_helper_reads_id3_synced_and_plain_lyrics(monkeypatch, tmp_path) -> None:
"""ID3 SYLT 和 USLT 应转换为本地高置信歌词候选。"""
tags = Mock()
tags.getall.side_effect = lambda name: {
"SYLT": [SYLT(encoding=3, lang="zho", format=2, type=1, text=[("晴天", 1250)])],
"USLT": [USLT(encoding=3, lang="zho", text="晴天")],
}[name]
tags.items.return_value = []
monkeypatch.setattr("app.application.audio.MutagenFile", lambda *_args, **_kwargs: SimpleNamespace(tags=tags))
lyrics = AudioMetadataHelper.read_lyrics(tmp_path / "track.mp3")
assert lyrics is not None
assert lyrics.provider == "embedded"
assert lyrics.synced_lyrics == "[00:01.25]晴天"
assert lyrics.plain_lyrics == "晴天"
def test_scrape_never_downgrades_existing_lrc_to_plain_text(monkeypatch, tmp_path) -> None:
"""即使调用方要求覆盖,也不能用纯文本替换已有同步歌词。"""
chain = object.__new__(ScrapingChain)
chain.storagechain = Mock()
existing = FileItem(storage="local", path=(tmp_path / "track.lrc").as_posix(), type="file")
chain.storagechain.get_file_item.side_effect = lambda storage, path: (
existing if str(path).endswith(".lrc") else None
)
lyrics_chain = Mock()
lyrics_chain.budget_exceeded = False
lyrics_chain.get_music_lyrics.return_value = MusicLyrics(
provider="theaudiodb",
plain_lyrics="plain",
match_score=100,
)
monkeypatch.setattr(AudioMetadataHelper, "read_lyrics", lambda _path: None)
write = Mock(return_value=True)
monkeypatch.setattr(chain, "_write_music_lyrics_sidecar", write)
status = chain._scrape_music_lyrics(
fileitem=FileItem(storage="local", path=(tmp_path / "track.flac").as_posix(), type="file"),
local_path=tmp_path / "track.flac",
scrape_info=MetaMusic(title="Track", artists=["Artist"]),
lyrics_option=SimpleNamespace(is_skip=False, is_upgrade=False),
overwrite=True,
lyrics_chain=lyrics_chain,
album_info=None,
)
assert status == "protected"
write.assert_not_called()
def test_music_lyrics_sidecars_match_only_same_stem_audio() -> None:
"""整理链只关联同目录同主干名歌词,Lyricsfile 双扩展名也应正确剥离。"""
audio = FileItem(storage="local", path="/music/Track.flac", name="Track.flac", type="file", extension="flac")
lrc = FileItem(storage="local", path="/music/Track.lrc", name="Track.lrc", type="file", extension="lrc")
lyricsfile = FileItem(
storage="local",
path="/music/Track.lyricsfile.yaml",
name="Track.lyricsfile.yaml",
type="file",
extension="yaml",
)
other = FileItem(storage="local", path="/music/Notes.txt", name="Notes.txt", type="file", extension="txt")
from app.chain.transfer import TransferChain
transfer_chain = object.__new__(TransferChain)
transfer_chain._subtitle_exts = ()
transfer_chain._audio_exts = (".flac",)
assert transfer_chain._get_related_main_file_key(lrc, [audio]) == ("local", "/music/Track.flac")
assert transfer_chain._get_related_main_file_key(lyricsfile, [audio]) == ("local", "/music/Track.flac")
assert transfer_chain._get_related_main_file_key(other, [audio]) is None
+8 -5
View File
@@ -181,9 +181,9 @@ def test_generic_scrape_dispatches_music_without_entering_video_handlers() -> No
)
def test_default_scraping_config_enables_missing_only_music_lyrics() -> None:
"""新安装和未保存过该字段的用户应默认仅在缺失时下载歌词"""
assert ScrapingConfig.get_default_config()["music_lyrics"] == ScrapingPolicy.MISSINGONLY
def test_default_scraping_config_enables_music_lyrics_quality_upgrade() -> None:
"""新安装和未保存过该字段的用户应默认升级歌词且不允许质量降级"""
assert ScrapingConfig.get_default_config()["music_lyrics"] == ScrapingPolicy.UPGRADE
def test_album_track_match_uses_disc_track_title_and_duration() -> None:
@@ -252,7 +252,7 @@ def test_music_scrape_can_run_lyrics_without_tags_or_cover() -> None:
)
music_chain = Mock()
with patch("app.chain.scraping.LrclibChain", return_value=music_chain):
with patch("app.chain.scraping.LyricsChain", return_value=music_chain):
success, message = chain.scrape_music_metadata(
FileItem(
storage="local",
@@ -265,7 +265,10 @@ def test_music_scrape_can_run_lyrics_without_tags_or_cover() -> None:
)
assert success is True
assert message == "已刮削 1 个音频文件,歌词新增 1 首、已存在 0 首、未匹配 0 首"
assert message == (
"已刮削 1 个音频文件,歌词新增 1 首、升级 0 首、已存在 0 首、"
"防降级保护 0 首、未匹配 0 首"
)
call = chain._scrape_music_file.call_args
assert call.kwargs["write_tags"] is False
assert call.kwargs["with_cover"] is False
+4 -4
View File
@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, Mock
from app.chain.acoustid import AcoustIdChain
from app.chain.douban import DoubanChain
from app.chain.listenbrainz import ListenBrainzChain
from app.chain.lrclib import LrclibChain
from app.chain.lyrics import LyricsChain
from app.chain.musicbrainz import MusicBrainzChain
from app.chain.theaudiodb import TheAudioDbChain
from app.domain.context import MusicAlbumInfo, MusicInfo, MusicLyrics
@@ -95,9 +95,9 @@ def test_acoustid_chain_normalizes_fingerprint_result(monkeypatch) -> None:
assert result == "recording-1"
def test_lrclib_chain_converts_dictionary_result(monkeypatch) -> None:
"""LRCLIB 来源链应把字典结果转换为标准歌词对象。"""
chain = LrclibChain()
def test_lyrics_chain_converts_dictionary_result(monkeypatch) -> None:
"""通用歌词链应把来源返回的字典转换为标准歌词对象。"""
chain = LyricsChain()
monkeypatch.setattr(chain, "run_module", Mock(return_value={
"provider": "lrclib",
"provider_id": "1",
+52
View File
@@ -0,0 +1,52 @@
from app.domain.context import MusicInfo
from app.modules.musixmatch import MusixmatchModule
def _payload(name: str, item: dict) -> dict:
"""构造 Musixmatch 官方 message/body 响应包装。"""
return {
"message": {
"header": {"status_code": 200},
"body": {name: item},
}
}
def test_musixmatch_prefers_authorized_subtitle(monkeypatch) -> None:
"""官方 matcher 返回可用字幕时不应再请求纯文本歌词。"""
module = MusixmatchModule()
calls = []
def request(method, params):
calls.append((method, params))
return _payload("subtitle", {
"subtitle_id": 12,
"subtitle_body": "[00:01.00]Track",
"subtitle_language": "en",
"restricted": 0,
})
monkeypatch.setattr(module, "_request", request)
results = module.music_lyrics_candidates(
MusicInfo(title="Track", artists=["Artist"], duration=180)
)
assert len(results) == 1
assert results[0].synced_lyrics == "[00:01.00]Track"
assert calls[0][0] == "matcher.subtitle.get"
assert calls[0][1]["f_subtitle_length_max_deviation"] == 2
assert len(calls) == 1
def test_musixmatch_restricted_results_are_not_saved(monkeypatch) -> None:
"""授权计划标记 restricted 的字幕和歌词均不得写入本地。"""
module = MusixmatchModule()
responses = iter([
_payload("subtitle", {"restricted": 1, "subtitle_body": "blocked"}),
_payload("lyrics", {"restricted": 1, "lyrics_body": "blocked"}),
])
monkeypatch.setattr(module, "_request", lambda *_args, **_kwargs: next(responses))
assert module.music_lyrics_candidates(
MusicInfo(title="Track", artists=["Artist"])
) == []
+69
View File
@@ -50,6 +50,75 @@ def test_manual_music_transfer_forwards_entity_namespace(monkeypatch):
assert captured["music_type"] == "album"
def test_manual_music_directory_defaults_to_album_namespace(monkeypatch):
"""旧客户端只声明音乐目录时,后端应按整张专辑而不是单曲解释媒体 ID。"""
captured = {}
class FakeTransferChain:
"""记录手动整理调用参数。"""
def manual_transfer(self, **kwargs):
captured.update(kwargs)
return True, ""
monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", FakeTransferChain)
response = manual_transfer(
transer_item=ManualTransferItem(
fileitem=FileItem(
storage="local",
path="/downloads/叶惠美",
name="叶惠美",
type="dir",
),
type_name="音乐",
media_source="musicbrainz",
media_id="977e6978-139d-425c-bb98-6b0c62d1e45e",
),
background=True,
history_query=SimpleNamespace(get=lambda _history_id: None),
_="token",
)
assert response.success is True
assert captured["music_type"] == "album"
def test_manual_music_file_defaults_to_recording_namespace(monkeypatch):
"""旧客户端只声明音乐文件时,后端应继续按单曲解释媒体 ID。"""
captured = {}
class FakeTransferChain:
"""记录手动整理调用参数。"""
def manual_transfer(self, **kwargs):
captured.update(kwargs)
return True, ""
monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", FakeTransferChain)
response = manual_transfer(
transer_item=ManualTransferItem(
fileitem=FileItem(
storage="local",
path="/downloads/晴天.flac",
name="晴天.flac",
type="file",
extension="flac",
),
type_name="音乐",
media_source="musicbrainz",
media_id="recording-1",
),
background=True,
history_query=SimpleNamespace(get=lambda _history_id: None),
_="token",
)
assert response.success is True
assert captured["music_type"] == "recording"
def test_manual_transfer_from_history_preserves_download_context(monkeypatch):
"""复用历史识别信息时应传递原下载上下文。"""
history = SimpleNamespace(