fix(music): 恢复下载历史中缺失的已选音乐元数据

This commit is contained in:
jxxghp
2026-09-06 08:21:30 +08:00
parent 49414ffad2
commit 82c0b4d09a
3 changed files with 171 additions and 9 deletions
+18
View File
@@ -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
# 曲目标题始终优先使用当前文件自身的标签(缺失时回退为文件名),
# 防止整包目录继续沿用订阅/下载标题(单曲名、专辑名等)导致所有文件重名。
+77
View File
@@ -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()
+76 -9
View File
@@ -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()