mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 17:08:35 +08:00
Merge pull request #6583 from dogodefi/codex/music-album-scrape-context
This commit is contained in:
@@ -26,6 +26,7 @@ from app.runtime.log import logger
|
|||||||
from app.schemas.transfer import TransferInfo
|
from app.schemas.transfer import TransferInfo
|
||||||
from app.schemas.types import (
|
from app.schemas.types import (
|
||||||
MUSIC_ENTITY_ALBUM,
|
MUSIC_ENTITY_ALBUM,
|
||||||
|
MUSIC_ENTITY_RECORDING,
|
||||||
MediaType,
|
MediaType,
|
||||||
)
|
)
|
||||||
from app.schemas.workflow import FileItem
|
from app.schemas.workflow import FileItem
|
||||||
@@ -251,8 +252,13 @@ class FileFilterMixin(_TransferOwnerBase):
|
|||||||
cls,
|
cls,
|
||||||
download_history: Optional[DownloadHistorySnapshot],
|
download_history: Optional[DownloadHistorySnapshot],
|
||||||
file_path: Path,
|
file_path: Path,
|
||||||
|
discard_recording_identity: bool = False,
|
||||||
) -> tuple[Optional[MetaMusic], Optional[MusicInfo]]:
|
) -> tuple[Optional[MetaMusic], Optional[MusicInfo]]:
|
||||||
"""从下载历史恢复音乐上下文,并用当前音频标签覆盖曲目级字段。"""
|
"""从下载历史恢复音乐上下文,并用当前音频标签覆盖曲目级字段。
|
||||||
|
|
||||||
|
多音轨批次误带单曲身份时只保留文件自身标签,避免把同一 recording
|
||||||
|
身份传播到整张专辑;调用方随后可使用目录级证据重新匹配专辑。
|
||||||
|
"""
|
||||||
note = getattr(download_history, "note", None)
|
note = getattr(download_history, "note", None)
|
||||||
music_note = note.get("music") if isinstance(note, dict) else None
|
music_note = note.get("music") if isinstance(note, dict) else None
|
||||||
if not isinstance(music_note, dict) or music_note.get("version") != 1:
|
if not isinstance(music_note, dict) or music_note.get("version") != 1:
|
||||||
@@ -264,6 +270,18 @@ class FileFilterMixin(_TransferOwnerBase):
|
|||||||
return None, None
|
return None, None
|
||||||
|
|
||||||
file_tags = MediaChain.read_path_meta(file_path)
|
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 = deepcopy(saved_meta)
|
||||||
file_meta.org_string = file_path.name
|
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.system import TransferDirectoryConf
|
||||||
from app.schemas.transfer import EpisodeFormat, TransferInfo
|
from app.schemas.transfer import EpisodeFormat, TransferInfo
|
||||||
from app.schemas.types import (
|
from app.schemas.types import (
|
||||||
|
MUSIC_ENTITY_RECORDING,
|
||||||
MediaSource,
|
MediaSource,
|
||||||
MediaType,
|
MediaType,
|
||||||
ProgressKey,
|
ProgressKey,
|
||||||
@@ -46,6 +47,25 @@ from app.schemas.workflow import FileItem
|
|||||||
from .request import _TransferCandidatePlanner
|
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):
|
class TransferWorkflowOwner(_TransferOwnerBase):
|
||||||
"""协调请求级候选构建并委托规划、执行与结算 owner。"""
|
"""协调请求级候选构建并委托规划、执行与结算 owner。"""
|
||||||
|
|
||||||
@@ -654,6 +674,10 @@ class TransferWorkflowOwner(_TransferOwnerBase):
|
|||||||
skipped_history_count = 0
|
skipped_history_count = 0
|
||||||
skipped_torrents = set()
|
skipped_torrents = set()
|
||||||
cleanup_intent_assigned = False
|
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:
|
try:
|
||||||
for file_item, bluray_dir in file_items:
|
for file_item, bluray_dir in file_items:
|
||||||
if runtime_stop_state.is_system_stopped:
|
if runtime_stop_state.is_system_stopped:
|
||||||
@@ -749,9 +773,18 @@ class TransferWorkflowOwner(_TransferOwnerBase):
|
|||||||
download_hash=download_hash,
|
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(
|
history_music_meta, history_music_info = self._restore_music_download_context(
|
||||||
download_history=download_history,
|
download_history=download_history,
|
||||||
file_path=file_path,
|
file_path=file_path,
|
||||||
|
discard_recording_identity=discard_recording_identity,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not meta:
|
if not meta:
|
||||||
@@ -784,10 +817,16 @@ class TransferWorkflowOwner(_TransferOwnerBase):
|
|||||||
_download_hash = download_hash
|
_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):
|
if not task_mediainfo and isinstance(file_meta, MetaMusic):
|
||||||
# 无标签音频按目录级专辑匹配补齐曲目身份,命中结果带缓存不会逐文件重复请求
|
# 无标签音频或误带单曲身份的整包按目录级专辑匹配;命中结果带缓存不会逐文件重复请求
|
||||||
file_meta, task_mediainfo = self._match_music_album_context(file_item, file_path, file_meta)
|
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):
|
if not manual and task_mediainfo and self._is_movie_year_conflict(file_meta, task_mediainfo):
|
||||||
task_mediainfo = None
|
task_mediainfo = None
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from unittest.mock import Mock
|
|||||||
|
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
|
|
||||||
|
from app.application.history import DownloadHistorySnapshot
|
||||||
from app.application.messaging.message import TemplateHelper
|
from app.application.messaging.message import TemplateHelper
|
||||||
from app.application.transfer.execution import (
|
from app.application.transfer.execution import (
|
||||||
TransferExecutionCheckpoint,
|
TransferExecutionCheckpoint,
|
||||||
@@ -11,7 +12,7 @@ from app.application.transfer.execution import (
|
|||||||
)
|
)
|
||||||
from app.application.transfer.workflow import JobManager, TransferTask
|
from app.application.transfer.workflow import JobManager, TransferTask
|
||||||
from app.chain.media import MediaChain
|
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.context import MusicInfo
|
||||||
from app.domain.meta.metamusic import MetaMusic
|
from app.domain.meta.metamusic import MetaMusic
|
||||||
from app.runtime.config import settings
|
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"
|
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():
|
def test_download_history_music_type_falls_back_to_versioned_note():
|
||||||
"""旧下载记录缺少独立字段时应从版本化备注恢复实体类型。"""
|
"""旧下载记录缺少独立字段时应从版本化备注恢复实体类型。"""
|
||||||
history = SimpleNamespace(
|
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
|
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):
|
def test_explicit_music_batch_excludes_video_from_mixed_directory(tmp_path, monkeypatch):
|
||||||
"""明确音乐上下文时只规划音频主文件,混合目录中的视频不得套用音乐身份。"""
|
"""明确音乐上下文时只规划音频主文件,混合目录中的视频不得套用音乐身份。"""
|
||||||
audio_path = tmp_path / "08 - Get Lucky.flac"
|
audio_path = tmp_path / "08 - Get Lucky.flac"
|
||||||
|
|||||||
Reference in New Issue
Block a user