Merge pull request #6583 from dogodefi/codex/music-album-scrape-context

This commit is contained in:
jxxghp
2026-09-05 15:29:15 +08:00
committed by GitHub
3 changed files with 255 additions and 4 deletions
+19 -1
View File
@@ -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
# 曲目标题始终优先使用当前文件自身的标签(缺失时回退为文件名),
+41 -2
View File
@@ -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
+195 -1
View File
@@ -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"