mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-09 01:16:50 +08:00
fix(music): rematch multi-track recording downloads as albums
This commit is contained in:
@@ -26,6 +26,7 @@ from app.runtime.log import logger
|
||||
from app.schemas.transfer import TransferInfo
|
||||
from app.schemas.types import (
|
||||
MUSIC_ENTITY_ALBUM,
|
||||
MUSIC_ENTITY_RECORDING,
|
||||
MediaType,
|
||||
)
|
||||
from app.schemas.workflow import FileItem
|
||||
@@ -251,8 +252,13 @@ class FileFilterMixin(_TransferOwnerBase):
|
||||
cls,
|
||||
download_history: Optional[DownloadHistorySnapshot],
|
||||
file_path: Path,
|
||||
discard_recording_identity: bool = False,
|
||||
) -> tuple[Optional[MetaMusic], Optional[MusicInfo]]:
|
||||
"""从下载历史恢复音乐上下文,并用当前音频标签覆盖曲目级字段。"""
|
||||
"""从下载历史恢复音乐上下文,并用当前音频标签覆盖曲目级字段。
|
||||
|
||||
多音轨批次误带单曲身份时只保留文件自身标签,避免把同一 recording
|
||||
身份传播到整张专辑;调用方随后可使用目录级证据重新匹配专辑。
|
||||
"""
|
||||
note = getattr(download_history, "note", None)
|
||||
music_note = note.get("music") if isinstance(note, dict) else None
|
||||
if not isinstance(music_note, dict) or music_note.get("version") != 1:
|
||||
@@ -264,6 +270,18 @@ class FileFilterMixin(_TransferOwnerBase):
|
||||
return None, None
|
||||
|
||||
file_tags = MediaChain.read_path_meta(file_path)
|
||||
should_discard_identity = (
|
||||
discard_recording_identity
|
||||
and saved_info.music_type == MUSIC_ENTITY_RECORDING
|
||||
)
|
||||
if should_discard_identity:
|
||||
# 共享 recording 上下文可能包含错误的专辑、年份等字段;整张丢弃,
|
||||
# 只保留当前文件实际标签,目录级匹配失败时也不会回落到错误身份。
|
||||
file_meta = deepcopy(file_tags)
|
||||
file_meta.org_string = file_path.name
|
||||
file_meta.title = file_meta.title or file_path.stem
|
||||
return file_meta, None
|
||||
|
||||
file_meta = deepcopy(saved_meta)
|
||||
file_meta.org_string = file_path.name
|
||||
# 曲目标题始终优先使用当前文件自身的标签(缺失时回退为文件名),
|
||||
|
||||
@@ -36,6 +36,7 @@ from app.schemas.media import resolve_media_identity
|
||||
from app.schemas.system import TransferDirectoryConf
|
||||
from app.schemas.transfer import EpisodeFormat, TransferInfo
|
||||
from app.schemas.types import (
|
||||
MUSIC_ENTITY_RECORDING,
|
||||
MediaSource,
|
||||
MediaType,
|
||||
ProgressKey,
|
||||
@@ -46,6 +47,25 @@ from app.schemas.workflow import FileItem
|
||||
from .request import _TransferCandidatePlanner
|
||||
|
||||
|
||||
def _should_discard_batch_recording_identity(
|
||||
*,
|
||||
multi_track_music_batch: bool,
|
||||
manual: bool,
|
||||
media_source: Optional[MediaSource],
|
||||
media_id: Optional[str],
|
||||
mediainfo: Optional[MediaInfo | MusicInfo],
|
||||
history_music_type: Optional[str],
|
||||
) -> bool:
|
||||
"""判断自动整专是否误带了共享单曲身份。"""
|
||||
if not multi_track_music_batch or (manual and media_source and media_id):
|
||||
return False
|
||||
batch_music_type = getattr(mediainfo, "music_type", None)
|
||||
return (
|
||||
batch_music_type == MUSIC_ENTITY_RECORDING
|
||||
or (not batch_music_type and history_music_type == MUSIC_ENTITY_RECORDING)
|
||||
)
|
||||
|
||||
|
||||
class TransferWorkflowOwner(_TransferOwnerBase):
|
||||
"""协调请求级候选构建并委托规划、执行与结算 owner。"""
|
||||
|
||||
@@ -654,6 +674,10 @@ class TransferWorkflowOwner(_TransferOwnerBase):
|
||||
skipped_history_count = 0
|
||||
skipped_torrents = set()
|
||||
cleanup_intent_assigned = False
|
||||
multi_track_music_batch = (
|
||||
batch_mtype == MediaType.MUSIC
|
||||
and sum(self._is_audio_file(item) for item, _ in file_items) > 1
|
||||
)
|
||||
try:
|
||||
for file_item, bluray_dir in file_items:
|
||||
if runtime_stop_state.is_system_stopped:
|
||||
@@ -749,9 +773,18 @@ class TransferWorkflowOwner(_TransferOwnerBase):
|
||||
download_hash=download_hash,
|
||||
)
|
||||
|
||||
discard_recording_identity = _should_discard_batch_recording_identity(
|
||||
multi_track_music_batch=multi_track_music_batch,
|
||||
manual=manual,
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
mediainfo=mediainfo,
|
||||
history_music_type=self._download_history_music_type(download_history),
|
||||
)
|
||||
history_music_meta, history_music_info = self._restore_music_download_context(
|
||||
download_history=download_history,
|
||||
file_path=file_path,
|
||||
discard_recording_identity=discard_recording_identity,
|
||||
)
|
||||
|
||||
if not meta:
|
||||
@@ -784,10 +817,16 @@ class TransferWorkflowOwner(_TransferOwnerBase):
|
||||
_download_hash = download_hash
|
||||
|
||||
# 自动整理预载的媒体信息来自整条下载历史;电影合集内文件年份冲突时逐文件识别。
|
||||
task_mediainfo = mediainfo or history_music_info
|
||||
task_mediainfo = (
|
||||
None
|
||||
if discard_recording_identity
|
||||
else mediainfo or history_music_info
|
||||
)
|
||||
if not task_mediainfo and isinstance(file_meta, MetaMusic):
|
||||
# 无标签音频按目录级专辑匹配补齐曲目身份,命中结果带缓存不会逐文件重复请求
|
||||
# 无标签音频或误带单曲身份的整包按目录级专辑匹配;命中结果带缓存不会逐文件重复请求
|
||||
file_meta, task_mediainfo = self._match_music_album_context(file_item, file_path, file_meta)
|
||||
if not task_mediainfo and discard_recording_identity:
|
||||
task_mediainfo = self._music_info_from_meta(file_meta)
|
||||
if not manual and task_mediainfo and self._is_movie_year_conflict(file_meta, task_mediainfo):
|
||||
task_mediainfo = None
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from unittest.mock import Mock
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
from app.application.history import DownloadHistorySnapshot
|
||||
from app.application.messaging.message import TemplateHelper
|
||||
from app.application.transfer.execution import (
|
||||
TransferExecutionCheckpoint,
|
||||
@@ -11,7 +12,7 @@ from app.application.transfer.execution import (
|
||||
)
|
||||
from app.application.transfer.workflow import JobManager, TransferTask
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.transfer import TransferChain
|
||||
from app.chain.transfer import TransferChain # pylint: disable=no-name-in-module
|
||||
from app.domain.context import MusicInfo
|
||||
from app.domain.meta.metamusic import MetaMusic
|
||||
from app.runtime.config import settings
|
||||
@@ -247,6 +248,50 @@ 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):
|
||||
"""多音轨批次不得把下载记录中的单曲身份恢复到每个音频文件。"""
|
||||
meta, info = _music_context()
|
||||
history = DownloadHistorySnapshot(
|
||||
id=1,
|
||||
path=tmp_path.as_posix(),
|
||||
type=MediaType.MUSIC.value,
|
||||
title="Random Access Memories",
|
||||
note={
|
||||
"music": {
|
||||
"version": 1,
|
||||
"meta": meta.to_dict(),
|
||||
"media": info.to_dict(),
|
||||
}
|
||||
},
|
||||
)
|
||||
audio_file = tmp_path / "01 - Give Life Back to Music.flac"
|
||||
audio_file.write_bytes(b"fake-flac")
|
||||
file_meta = MetaMusic(
|
||||
org_string=audio_file.name,
|
||||
title="Give Life Back to Music",
|
||||
artists=["Daft Punk"],
|
||||
album="Local Album",
|
||||
album_artist="Daft Punk",
|
||||
year=2020,
|
||||
track_number=1,
|
||||
)
|
||||
monkeypatch.setattr(MediaChain, "read_path_meta", Mock(return_value=file_meta))
|
||||
|
||||
restored_meta, restored_info = TransferChain._restore_music_download_context(
|
||||
history,
|
||||
audio_file,
|
||||
discard_recording_identity=True,
|
||||
)
|
||||
|
||||
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.media_source is None
|
||||
assert restored_meta.media_id is None
|
||||
assert restored_info is None
|
||||
|
||||
|
||||
def test_download_history_music_type_falls_back_to_versioned_note():
|
||||
"""旧下载记录缺少独立字段时应从版本化备注恢复实体类型。"""
|
||||
history = SimpleNamespace(
|
||||
@@ -673,6 +718,155 @@ def test_automatic_audio_transfer_runs_music_recognition(tmp_path, monkeypatch):
|
||||
assert preview["items"][0]["type"] == MediaType.MUSIC.value
|
||||
|
||||
|
||||
def test_automatic_multi_track_recording_context_rematches_album(tmp_path, monkeypatch):
|
||||
"""自动整专不得复用下载时误选的单曲身份,应按目录恢复各音轨身份。"""
|
||||
source_dir = tmp_path / "徐良 情话"
|
||||
source_dir.mkdir()
|
||||
audio_paths = [
|
||||
source_dir / "01 - 女骑士.flac",
|
||||
source_dir / "02 - 悲伤的李白.flac",
|
||||
]
|
||||
for audio_path in audio_paths:
|
||||
audio_path.write_bytes(b"fake-flac")
|
||||
source_items = [
|
||||
FileItem(
|
||||
storage="local",
|
||||
path=audio_path.as_posix(),
|
||||
name=audio_path.name,
|
||||
basename=audio_path.stem,
|
||||
type="file",
|
||||
extension="flac",
|
||||
size=audio_path.stat().st_size,
|
||||
)
|
||||
for audio_path in audio_paths
|
||||
]
|
||||
source_item = FileItem(
|
||||
storage="local",
|
||||
path=source_dir.as_posix(),
|
||||
name=source_dir.name,
|
||||
type="dir",
|
||||
)
|
||||
saved_recording = MusicInfo(
|
||||
media_source="musicbrainz",
|
||||
media_id="wrong-recording",
|
||||
music_type="recording",
|
||||
title="情话",
|
||||
artists=["徐良", "孙羽幽"],
|
||||
album="北京巷弄",
|
||||
album_artist="徐良",
|
||||
year=2013,
|
||||
cover_url="https://example.com/wrong-cover.jpg",
|
||||
)
|
||||
saved_meta = MetaMusic.from_music_info(saved_recording)
|
||||
history = DownloadHistorySnapshot(
|
||||
id=1,
|
||||
path=source_dir.as_posix(),
|
||||
type=MediaType.MUSIC.value,
|
||||
title="情话",
|
||||
note={
|
||||
"music": {
|
||||
"version": 1,
|
||||
"meta": saved_meta.to_dict(),
|
||||
"media": saved_recording.to_dict(),
|
||||
}
|
||||
},
|
||||
music_type="recording",
|
||||
downloader="qbittorrent",
|
||||
download_hash="hash-1",
|
||||
)
|
||||
file_metas = {
|
||||
audio_paths[0]: MetaMusic(
|
||||
org_string=audio_paths[0].name,
|
||||
title="女骑士",
|
||||
artists=["徐良"],
|
||||
album="情话",
|
||||
album_artist="徐良",
|
||||
year=2013,
|
||||
track_number=1,
|
||||
total_tracks=12,
|
||||
),
|
||||
audio_paths[1]: MetaMusic(
|
||||
org_string=audio_paths[1].name,
|
||||
title="悲伤的李白",
|
||||
artists=["徐良"],
|
||||
album="情话",
|
||||
album_artist="徐良",
|
||||
year=2013,
|
||||
track_number=2,
|
||||
total_tracks=12,
|
||||
),
|
||||
}
|
||||
matched_tracks = {
|
||||
str(path.resolve()): MusicInfo(
|
||||
media_source="musicbrainz",
|
||||
media_id=f"recording-{index}",
|
||||
music_type="recording",
|
||||
title=file_metas[path].title,
|
||||
artists=["徐良"],
|
||||
album="情话",
|
||||
album_artist="徐良",
|
||||
album_id="correct-release-group",
|
||||
year=2013,
|
||||
track_number=index,
|
||||
total_tracks=12,
|
||||
cover_url="https://example.com/correct-cover.jpg",
|
||||
)
|
||||
for index, path in enumerate(audio_paths, start=1)
|
||||
}
|
||||
chain = TransferChain()
|
||||
monkeypatch.setattr(
|
||||
chain,
|
||||
"_TransferChain__get_trans_fileitems",
|
||||
Mock(return_value=[(item, False) for item in source_items]),
|
||||
)
|
||||
monkeypatch.setattr(chain, "_resolve_download_history", Mock(return_value=history))
|
||||
monkeypatch.setattr(
|
||||
MediaChain,
|
||||
"read_path_meta",
|
||||
Mock(side_effect=lambda path: file_metas[Path(path)]),
|
||||
)
|
||||
album_match = Mock(return_value=matched_tracks)
|
||||
monkeypatch.setattr(MediaChain, "recognize_music_album_directory", album_match)
|
||||
captured_tasks = []
|
||||
|
||||
def execute(task, **_kwargs):
|
||||
captured_tasks.append(task)
|
||||
target_dir = tmp_path / "library" / "徐良" / "情话 (2013)"
|
||||
target_item = target_dir / task.fileitem.name
|
||||
return TransferInfo(
|
||||
success=True,
|
||||
fileitem=task.fileitem,
|
||||
target_item=FileItem(storage="local", path=target_item.as_posix(), type="file"),
|
||||
target_diritem=FileItem(storage="local", path=target_dir.as_posix(), type="dir"),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(chain, "_plan_checkpoint_and_execute", execute)
|
||||
|
||||
state, preview = chain.do_transfer(
|
||||
fileitem=source_item,
|
||||
mediainfo=saved_recording,
|
||||
mtype=MediaType.MUSIC,
|
||||
target_directory=TransferDirectoryConf(
|
||||
library_path=(tmp_path / "library").as_posix(),
|
||||
library_storage="local",
|
||||
),
|
||||
force=True,
|
||||
preview=True,
|
||||
)
|
||||
|
||||
assert state is True
|
||||
assert preview["summary"] == {"total": 2, "success": 2, "failed": 0}
|
||||
assert [task.mediainfo.media_id for task in captured_tasks] == [
|
||||
"recording-1",
|
||||
"recording-2",
|
||||
]
|
||||
assert {task.mediainfo.album_id for task in captured_tasks} == {"correct-release-group"}
|
||||
assert {task.mediainfo.cover_url for task in captured_tasks} == {
|
||||
"https://example.com/correct-cover.jpg"
|
||||
}
|
||||
assert album_match.call_count == 2
|
||||
|
||||
|
||||
def test_explicit_music_batch_excludes_video_from_mixed_directory(tmp_path, monkeypatch):
|
||||
"""明确音乐上下文时只规划音频主文件,混合目录中的视频不得套用音乐身份。"""
|
||||
audio_path = tmp_path / "08 - Get Lucky.flac"
|
||||
|
||||
Reference in New Issue
Block a user