mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
feat(music): 完善歌词与专辑目录整理
This commit is contained in:
+24
-10
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"])
|
||||
) == []
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user