From 82c0b4d09a9b55cee04dab13ba2a498506cdefd9 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Sun, 6 Sep 2026 08:21:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(music):=20=E6=81=A2=E5=A4=8D=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=E5=8E=86=E5=8F=B2=E4=B8=AD=E7=BC=BA=E5=A4=B1=E7=9A=84?= =?UTF-8?q?=E5=B7=B2=E9=80=89=E9=9F=B3=E4=B9=90=E5=85=83=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/chain/transfer/filter.py | 18 ++++++++ tests/test_music_download.py | 77 ++++++++++++++++++++++++++++++++ tests/test_music_transfer.py | 85 ++++++++++++++++++++++++++++++++---- 3 files changed, 171 insertions(+), 9 deletions(-) diff --git a/app/chain/transfer/filter.py b/app/chain/transfer/filter.py index a1d148a08..771a91a17 100644 --- a/app/chain/transfer/filter.py +++ b/app/chain/transfer/filter.py @@ -256,6 +256,8 @@ class FileFilterMixin(_TransferOwnerBase): ) -> tuple[Optional[MetaMusic], Optional[MusicInfo]]: """从下载历史恢复音乐上下文,并用当前音频标签覆盖曲目级字段。 + 种子未提供的语义字段由历史中已选媒体补缺;实体类型和来源身份始终 + 沿用已选媒体,不根据专辑名或文件曲名在单曲与专辑之间转换。 多音轨批次误带单曲身份时只保留文件自身标签,避免把同一 recording 身份传播到整张专辑;调用方随后可使用目录级证据重新匹配专辑。 """ @@ -283,6 +285,22 @@ class FileFilterMixin(_TransferOwnerBase): return file_meta, None file_meta = deepcopy(saved_meta) + # 新旧历史都可能仅在 media 中保留已选专辑;先补缺,再沿用文件标签的 + # 覆盖规则。音质不从目标媒体补写,曲名仍取当前文件,避免混淆资源证据。 + for field_name in ( + "artists", + "album", + "album_artist", + "year", + "disc_number", + "track_number", + "total_tracks", + "version", + "isrc", + ): + saved_value = getattr(saved_info, field_name, None) + if getattr(file_meta, field_name, None) in (None, "", []) and saved_value not in (None, "", []): + setattr(file_meta, field_name, deepcopy(saved_value)) file_meta.org_string = file_path.name # 曲目标题始终优先使用当前文件自身的标签(缺失时回退为文件名), # 防止整包目录继续沿用订阅/下载标题(单曲名、专辑名等)导致所有文件重名。 diff --git a/tests/test_music_download.py b/tests/test_music_download.py index a0e9c3415..f7620f0ae 100644 --- a/tests/test_music_download.py +++ b/tests/test_music_download.py @@ -1,10 +1,21 @@ +import json +from pathlib import Path +from types import SimpleNamespace from unittest.mock import Mock, patch +import pytest +from jinja2 import Template + import app.chain.download.submission as download_submission from app.api.endpoints.download import add, download +from app.application.audio import AudioMetadataHelper +from app.application.messaging.message import TemplateHelper from app.chain.download import DownloadChain +from app.chain.media import MediaChain +from app.chain.transfer.facade import TransferChain from app.domain.context import MUSIC_ENTITY_ALBUM, Context, MusicInfo from app.domain.meta.metamusic import MetaMusic +from app.runtime.config import settings from app.schemas.context import TorrentInfo from app.schemas.mediaserver import ExistMediaInfo from app.schemas.music import MusicInfo as MusicInfoSchema @@ -174,6 +185,72 @@ def test_download_endpoint_builds_music_context(): assert context.meta_info.media_id is None +@pytest.mark.parametrize("music_type", ["recording", "album"]) +def test_download_history_restores_selected_music_album(music_type, monkeypatch): + """手动下载保留独立种子证据,历史恢复仍能为无标签音频补齐已选专辑。""" + selected = _music_info() + selected.music_type = music_type + selected.album_artist = "周杰伦" + if music_type == "album": + selected.title = selected.album + selected.media_id = "release-group-1" + torrent_title = f"周杰伦 - {selected.title} FLAC" + chain = Mock() + chain.download_single.return_value = "hash-1" + monkeypatch.setattr("app.api.endpoints.download.DownloadChain", lambda: chain) + + response = download( + media_in=MusicInfoSchema(**selected.to_dict()), + torrent_in=TorrentInfo( + title=torrent_title, + enclosure="https://example.com/download?id=2", + category="音乐", + ), + downloader="qb", + save_path=None, + current_user=SimpleNamespace(name="admin"), + ) + + assert response.success is True + context = chain.download_single.call_args.kwargs["context"] + assert context.meta_info.album is None + assert context.meta_info.media_id is None + assert context.meta_info.org_string == torrent_title + assert context.media_info.music_type == music_type + note = json.loads(json.dumps(DownloadChain._build_download_note( + "Manual", context.media_info, context.meta_info, + ))) + assert note["music"]["meta"]["album"] is None + assert note["music"]["media"]["album"] == "叶惠美" + assert note["music"]["media"]["music_type"] == music_type + audio_path = Path("/03 - 晴天.flac") + file_meta = AudioMetadataHelper.read_filename(audio_path) + assert file_meta.album is None + monkeypatch.setattr(MediaChain, "read_path_meta", Mock(return_value=file_meta)) + + restored_meta, restored_info = TransferChain._restore_music_download_context( + SimpleNamespace(note=note), audio_path, + ) + + assert restored_meta.album == restored_info.album == "叶惠美" + assert restored_meta.year == restored_info.year == 2003 + assert restored_meta.title == restored_info.title == "晴天" + assert restored_meta.track_number == restored_info.track_number == 3 + assert restored_info.music_type == music_type + assert restored_info.media_source == selected.media_source + assert restored_info.media_id == selected.media_id + naming_context = TemplateHelper().builder.build( + meta=restored_meta, + mediainfo=restored_info, + file_extension=".flac", + include_raw_objects=False, + ) + assert Template(settings.MUSIC_RENAME_FORMAT).render(naming_context) == ( + "周杰伦/叶惠美 (2003)/03 - 晴天.flac" + ) + assert note["music"]["meta"]["album"] is None + + def test_manual_album_without_id_uses_subtitle_evidence(): """没有 ID 的人工专辑下载保留实体意图,并使用副标题中的真实艺人识别。""" media_chain = Mock() diff --git a/tests/test_music_transfer.py b/tests/test_music_transfer.py index bc9813030..2f872e738 100644 --- a/tests/test_music_transfer.py +++ b/tests/test_music_transfer.py @@ -1,7 +1,9 @@ +from copy import deepcopy from pathlib import Path from types import SimpleNamespace from unittest.mock import Mock +import pytest from jinja2 import Template from app.application.history import DownloadHistorySnapshot @@ -248,7 +250,8 @@ def test_restore_music_context_from_download_history(): assert restored_info.album == "Random Access Memories" -def test_restore_music_context_discards_shared_recording_identity(tmp_path, monkeypatch): +@pytest.mark.parametrize("file_album", ["Local Album", None]) +def test_restore_music_context_discards_shared_recording_identity(tmp_path, monkeypatch, file_album): """多音轨批次不得把下载记录中的单曲身份恢复到每个音频文件。""" meta, info = _music_context() history = DownloadHistorySnapshot( @@ -270,9 +273,9 @@ def test_restore_music_context_discards_shared_recording_identity(tmp_path, monk org_string=audio_file.name, title="Give Life Back to Music", artists=["Daft Punk"], - album="Local Album", - album_artist="Daft Punk", - year=2020, + album=file_album, + album_artist="Daft Punk" if file_album else None, + year=2020 if file_album else None, track_number=1, ) monkeypatch.setattr(MediaChain, "read_path_meta", Mock(return_value=file_meta)) @@ -285,8 +288,8 @@ def test_restore_music_context_discards_shared_recording_identity(tmp_path, monk assert restored_meta is not None assert restored_meta.title == "Give Life Back to Music" - assert restored_meta.album == "Local Album" - assert restored_meta.year == 2020 + assert restored_meta.album == file_meta.album + assert restored_meta.year == file_meta.year assert restored_meta.media_source is None assert restored_meta.media_id is None assert restored_info is None @@ -307,7 +310,10 @@ def test_download_history_music_type_falls_back_to_versioned_note(): assert TransferChain._download_history_music_type(history) == "album" -def test_restore_album_context_keeps_album_identity_and_track_specific_tags(tmp_path, monkeypatch): +@pytest.mark.parametrize("resource_meta_only", [False, True]) +def test_restore_album_context_keeps_album_identity_and_track_specific_tags( + tmp_path, monkeypatch, resource_meta_only, +): """整专整理应保留选中的专辑身份,同时使用每个文件自己的曲名、艺术家和曲序。""" album = MusicInfo( media_source="musicbrainz", @@ -320,7 +326,10 @@ def test_restore_album_context_keeps_album_identity_and_track_specific_tags(tmp_ year=2003, total_tracks=11, ) - meta = MetaMusic.from_music_info(album) + meta = ( + MetaMusic.parse_resource("周杰伦 - 叶惠美 FLAC") + if resource_meta_only else MetaMusic.from_music_info(album) + ) history = SimpleNamespace(note={ "music": { "version": 1, @@ -348,7 +357,9 @@ def test_restore_album_context_keeps_album_identity_and_track_specific_tags(tmp_ ), ) - restored_meta, restored_info = TransferChain._restore_music_download_context(history, audio_file) + restored_meta, restored_info = TransferChain._restore_music_download_context( + history, audio_file, discard_recording_identity=True, + ) assert restored_meta.title == "晴天" assert restored_meta.track_number == 3 @@ -360,6 +371,62 @@ def test_restore_album_context_keeps_album_identity_and_track_specific_tags(tmp_ assert restored_info.media_id == "release-group-1" +@pytest.mark.parametrize("field_source", ["selected", "resource", "file"]) +def test_restore_music_context_only_fills_missing_selected_fields(monkeypatch, field_source): + """已选语义字段仅补缺,不覆盖种子证据、文件标签或注入目标音质。""" + saved_meta, info = _music_context() + info.disc_number = 1 + info.version = "Studio" + info.isrc = "USQX91300108" + info.audio_format = "MP3" + info.bit_depth = 16 + if field_source == "selected": + saved_meta = MetaMusic() + else: + saved_meta.album = "Resource Album" + saved_meta.album_artist = "Resource Artist" + saved_meta.year = 2014 + saved_meta.total_tracks = 15 + saved_meta.disc_number = 2 + saved_meta.version = "Live" + saved_meta.isrc = "USQX91400108" + note = {"music": {"version": 1, "meta": saved_meta.to_dict(), "media": info.to_dict()}} + original_note = deepcopy(note) + file_meta = MetaMusic(title="File Title") + if field_source == "file": + file_meta.artists = ["File Artist"] + file_meta.album = "File Album" + file_meta.album_artist = "File Album Artist" + file_meta.year = 2020 + file_meta.total_tracks = 20 + file_meta.disc_number = 3 + file_meta.track_number = 10 + file_meta.version = "Remix" + file_meta.isrc = "USQX92000108" + monkeypatch.setattr(MediaChain, "read_path_meta", Mock(return_value=file_meta)) + + restored_meta, restored_info = TransferChain._restore_music_download_context( + SimpleNamespace(note=note), Path("/downloads/03 - File Title.flac"), + ) + + expected = {"selected": info, "resource": saved_meta, "file": file_meta}[field_source] + for field_name in ( + "artists", "album", "album_artist", "year", "total_tracks", + "disc_number", "track_number", "version", "isrc", + ): + assert getattr(restored_meta, field_name) == getattr(expected, field_name) + assert getattr(restored_info, field_name) == getattr(expected, field_name) + assert restored_meta.title == "File Title" + assert restored_info.music_type == "recording" + assert restored_info.media_source == info.media_source + assert restored_info.media_id == info.media_id + assert restored_meta.audio_format is None + assert restored_meta.bit_depth is None + assert note == original_note + restored_meta.artists.append("Another Artist") + assert file_meta.artists == (["File Artist"] if field_source == "file" else []) + + def test_restore_music_context_uses_file_title_over_subscription_title(tmp_path, monkeypatch): """曲目标题应优先取当前文件自身的标签/文件名,而非沿用订阅时的单曲标题。""" meta, info = _music_context()