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]]: ) -> tuple[Optional[MetaMusic], Optional[MusicInfo]]:
"""从下载历史恢复音乐上下文,并用当前音频标签覆盖曲目级字段。 """从下载历史恢复音乐上下文,并用当前音频标签覆盖曲目级字段。
种子未提供的语义字段由历史中已选媒体补缺;实体类型和来源身份始终
沿用已选媒体,不根据专辑名或文件曲名在单曲与专辑之间转换。
多音轨批次误带单曲身份时只保留文件自身标签,避免把同一 recording 多音轨批次误带单曲身份时只保留文件自身标签,避免把同一 recording
身份传播到整张专辑;调用方随后可使用目录级证据重新匹配专辑。 身份传播到整张专辑;调用方随后可使用目录级证据重新匹配专辑。
""" """
@@ -283,6 +285,22 @@ class FileFilterMixin(_TransferOwnerBase):
return file_meta, None return file_meta, None
file_meta = deepcopy(saved_meta) 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 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 from unittest.mock import Mock, patch
import pytest
from jinja2 import Template
import app.chain.download.submission as download_submission import app.chain.download.submission as download_submission
from app.api.endpoints.download import add, download 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.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.context import MUSIC_ENTITY_ALBUM, Context, MusicInfo
from app.domain.meta.metamusic import MetaMusic from app.domain.meta.metamusic import MetaMusic
from app.runtime.config import settings
from app.schemas.context import TorrentInfo from app.schemas.context import TorrentInfo
from app.schemas.mediaserver import ExistMediaInfo from app.schemas.mediaserver import ExistMediaInfo
from app.schemas.music import MusicInfo as MusicInfoSchema 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 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(): def test_manual_album_without_id_uses_subtitle_evidence():
"""没有 ID 的人工专辑下载保留实体意图,并使用副标题中的真实艺人识别。""" """没有 ID 的人工专辑下载保留实体意图,并使用副标题中的真实艺人识别。"""
media_chain = Mock() media_chain = Mock()
+76 -9
View File
@@ -1,7 +1,9 @@
from copy import deepcopy
from pathlib import Path from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import Mock from unittest.mock import Mock
import pytest
from jinja2 import Template from jinja2 import Template
from app.application.history import DownloadHistorySnapshot 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" 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() meta, info = _music_context()
history = DownloadHistorySnapshot( history = DownloadHistorySnapshot(
@@ -270,9 +273,9 @@ def test_restore_music_context_discards_shared_recording_identity(tmp_path, monk
org_string=audio_file.name, org_string=audio_file.name,
title="Give Life Back to Music", title="Give Life Back to Music",
artists=["Daft Punk"], artists=["Daft Punk"],
album="Local Album", album=file_album,
album_artist="Daft Punk", album_artist="Daft Punk" if file_album else None,
year=2020, year=2020 if file_album else None,
track_number=1, track_number=1,
) )
monkeypatch.setattr(MediaChain, "read_path_meta", Mock(return_value=file_meta)) 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 is not None
assert restored_meta.title == "Give Life Back to Music" assert restored_meta.title == "Give Life Back to Music"
assert restored_meta.album == "Local Album" assert restored_meta.album == file_meta.album
assert restored_meta.year == 2020 assert restored_meta.year == file_meta.year
assert restored_meta.media_source is None assert restored_meta.media_source is None
assert restored_meta.media_id is None assert restored_meta.media_id is None
assert restored_info 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" 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( album = MusicInfo(
media_source="musicbrainz", media_source="musicbrainz",
@@ -320,7 +326,10 @@ def test_restore_album_context_keeps_album_identity_and_track_specific_tags(tmp_
year=2003, year=2003,
total_tracks=11, 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={ history = SimpleNamespace(note={
"music": { "music": {
"version": 1, "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.title == "晴天"
assert restored_meta.track_number == 3 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" 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): def test_restore_music_context_uses_file_title_over_subscription_title(tmp_path, monkeypatch):
"""曲目标题应优先取当前文件自身的标签/文件名,而非沿用订阅时的单曲标题。""" """曲目标题应优先取当前文件自身的标签/文件名,而非沿用订阅时的单曲标题。"""
meta, info = _music_context() meta, info = _music_context()