mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-31 12:24:39 +08:00
fix(music): sync local covers during automatic transfers (#7064)
This commit is contained in:
@@ -112,9 +112,12 @@ class _TransferCandidatePlanner:
|
||||
download_hash: Optional[str],
|
||||
sync_extra_files: bool,
|
||||
fileitem: FileItem,
|
||||
sync_music_covers: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
冻结候选规划依赖,避免请求阶段再读取可变配置。
|
||||
|
||||
:param sync_music_covers: 自动整理音乐封面,不扩展影视附加文件的同步范围
|
||||
"""
|
||||
self._chain = chain
|
||||
self._meta = meta
|
||||
@@ -127,6 +130,7 @@ class _TransferCandidatePlanner:
|
||||
self._transfer_exclude_words = transfer_exclude_words
|
||||
self._download_hash = download_hash
|
||||
self._sync_extra_files = sync_extra_files
|
||||
self._sync_music_covers = sync_extra_files or sync_music_covers
|
||||
self._fileitem = fileitem
|
||||
|
||||
def _build_file_meta(
|
||||
@@ -355,9 +359,9 @@ class _TransferCandidatePlanner:
|
||||
|
||||
def _is_music_cover_candidate(self, item: FileItem) -> bool:
|
||||
"""
|
||||
封面只随音乐主文件同步,影视批次及关闭附加文件同步时不接纳图片。
|
||||
封面随音乐主文件同步;自动请求可单独启用,影视批次仍不接纳图片。
|
||||
"""
|
||||
return (self._sync_extra_files and not self._has_reliable_video_source()
|
||||
return (self._sync_music_covers and not self._has_reliable_video_source()
|
||||
and self._chain._is_music_cover_file(item))
|
||||
|
||||
def _infer_music_batch_type(self, items: List[Tuple[FileItem, bool]]) -> None:
|
||||
|
||||
@@ -536,7 +536,7 @@ class TransferWorkflowOwner(_TransferOwnerBase):
|
||||
:param manual: 是否手动整理
|
||||
:param preview: 是否仅预览
|
||||
:param reorganize: 是否清理已有成功记录后重新整理
|
||||
:param sync_extra_files: 是否在整理主视频文件时同步整理同媒体附加文件
|
||||
:param sync_extra_files: 是否同步整理同媒体附加文件;自动音乐封面另受本地封面开关控制
|
||||
:param cleanup_dest_fileitem: 确认存在待整理任务后需要清理的旧目标文件
|
||||
:param continue_callback: 继续处理回调
|
||||
:param recovery_admission: 内部恢复调用绑定的既有 持久化 记录
|
||||
@@ -600,6 +600,7 @@ class TransferWorkflowOwner(_TransferOwnerBase):
|
||||
download_hash=download_hash,
|
||||
sync_extra_files=bool(sync_extra_files),
|
||||
fileitem=fileitem,
|
||||
sync_music_covers=not manual and selected_fileitems is None,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -513,6 +513,11 @@ moviepilot config set MUSIC_RELEASE_REGION_PRIORITY CN,TW,HK
|
||||
moviepilot config set MUSIC_RELEASE_SCRIPT_PRIORITY Hans,Hant,Latn
|
||||
```
|
||||
|
||||
`MUSIC_LOCAL_COVER_ENABLE` 开启时,下载器监控和文件系统监控的自动音乐整理会同步同目录中
|
||||
符合 `MUSIC_COVER_FILE_PATTERNS` 的封面,再由刮削按在线优先、本地回退的顺序写入音轨。
|
||||
封面须在该次规划时已存在,并能关联到唯一专辑;后来才落盘的封面需要重新提交专辑目录整理。
|
||||
手动整理仍遵循附加文件同步选项,手选曲目不会自动扩大文件范围。
|
||||
|
||||
查看所有可配置项:
|
||||
|
||||
```shell
|
||||
|
||||
@@ -13,7 +13,7 @@ from mutagen.flac import FLAC, Picture
|
||||
from PIL import Image
|
||||
|
||||
from app.application.settings.contract import ALL_SETTING_SPECS, build_value_schema
|
||||
from app.application.transfer.workflow import TransferPlanningInput
|
||||
from app.application.transfer.workflow import TransferPlanningInput, TransferTask
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.scraping import ScrapingChain
|
||||
from app.chain.transfer.request import _TransferCandidatePlanner
|
||||
@@ -26,7 +26,7 @@ from app.runtime.config import ConfigModel, Settings, settings
|
||||
from app.schemas.file import FileItem
|
||||
from app.schemas.system import TransferDirectoryConf
|
||||
from app.schemas.transfer import TransferInfo
|
||||
from app.schemas.types import MediaType
|
||||
from app.schemas.types import EventType, MediaType
|
||||
from tests.test_transfer_sync_extra_files import bind_empty_history_repositories, make_fileitem, make_transfer_chain
|
||||
|
||||
|
||||
@@ -43,14 +43,14 @@ def _tagged_audio(directory, name="01.flac", album="Album"):
|
||||
return path
|
||||
|
||||
|
||||
def _planner(chain, item, sync=True, mtype=MediaType.MUSIC):
|
||||
def _planner(chain, item, sync=True, mtype=MediaType.MUSIC, automatic=False):
|
||||
"""
|
||||
构造真实候选规划器,保留同目录匹配与附加文件开关。
|
||||
构造真实候选规划器,区分通用附件同步和自动音乐封面同步。
|
||||
"""
|
||||
return _TransferCandidatePlanner(
|
||||
chain, meta=None, season=None, formater=None, batch_mtype=mtype, mediainfo=None,
|
||||
continue_callback=None, has_episode_format_template=False, transfer_exclude_words=[],
|
||||
download_hash=None, sync_extra_files=sync, fileitem=item,
|
||||
download_hash=None, sync_extra_files=sync, fileitem=item, sync_music_covers=automatic,
|
||||
)
|
||||
|
||||
|
||||
@@ -81,7 +81,8 @@ def test_music_cover_enters_directory_and_single_transfer_only_when_sync_enabled
|
||||
assert context.related_main_keys[chain._get_file_key(cover)] == chain._get_file_key(audio)
|
||||
|
||||
|
||||
def test_music_cover_does_not_borrow_identity_from_different_albums_or_video(tmp_path):
|
||||
@pytest.mark.parametrize("automatic", [False, True])
|
||||
def test_music_cover_does_not_borrow_identity_from_different_albums_or_video(tmp_path, automatic):
|
||||
"""
|
||||
同目录多发行存在歧义时不关联封面,影视批次也不会吸收图片。
|
||||
"""
|
||||
@@ -92,16 +93,20 @@ def test_music_cover_does_not_borrow_identity_from_different_albums_or_video(tmp
|
||||
bind_empty_history_repositories(chain)
|
||||
context = chain._prepare_music_batch_context([(first, False), (second, False), (cover, False)], MediaType.MUSIC)
|
||||
assert chain._get_file_key(cover) not in context.related_main_keys
|
||||
assert not _planner(chain, first, mtype=MediaType.TV)._is_music_cover_candidate(cover)
|
||||
assert _planner(chain, cover)._plan_file_items([(cover, False)])[0] == []
|
||||
assert not _planner(chain, first, mtype=MediaType.TV, automatic=automatic)._is_music_cover_candidate(cover)
|
||||
assert _planner(chain, cover, automatic=automatic)._plan_file_items([(cover, False)])[0] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("single", [False, True])
|
||||
@pytest.mark.parametrize("mtype", [None, MediaType.MUSIC])
|
||||
@pytest.mark.parametrize("enabled", [False, True])
|
||||
def test_music_cover_reaches_public_transfer_entrypoint(tmp_path, monkeypatch, single, mtype, enabled):
|
||||
@pytest.mark.parametrize("manual,sync,selected", [
|
||||
(False, False, False), (True, True, False), (True, False, False),
|
||||
(False, False, True), (True, False, True),
|
||||
])
|
||||
def test_music_cover_reaches_public_transfer_entrypoint(tmp_path, monkeypatch, single, mtype, enabled, manual, sync, selected):
|
||||
"""
|
||||
公开整理入口遵守全局开关,开启时封面复用主音轨身份而不单独在线识别。
|
||||
自动目录与单文件请求默认带入封面,手动请求保留同步选项且均遵守全局开关。
|
||||
"""
|
||||
monkeypatch.setattr(settings, "MUSIC_LOCAL_COVER_ENABLE", enabled)
|
||||
audio = _tagged_audio(tmp_path / "source")
|
||||
@@ -136,15 +141,45 @@ def test_music_cover_reaches_public_transfer_entrypoint(tmp_path, monkeypatch, s
|
||||
return TransferInfo(success=True, fileitem=task.fileitem, target_item=task.fileitem)
|
||||
|
||||
monkeypatch.setattr(chain, "_plan_checkpoint_and_execute", preview)
|
||||
success, result = chain.do_transfer(
|
||||
transfer = chain._run_transfer_workflow if selected else chain.do_transfer
|
||||
success, result = transfer(
|
||||
fileitem=storage.get_item(audio if single else audio.parent),
|
||||
target_directory=TransferDirectoryConf(library_storage="local", library_path=str(tmp_path / "library")),
|
||||
mtype=mtype, sync_extra_files=True, force=True, preview=True,
|
||||
mtype=mtype, manual=manual, sync_extra_files=sync, force=True, preview=True,
|
||||
**({"selected_fileitems": [storage.get_item(audio)]} if selected else {}),
|
||||
)
|
||||
assert success, result
|
||||
assert [task.fileitem.path for task in tasks] == ([str(audio), str(cover)] if enabled else [str(audio)])
|
||||
with_cover = enabled and (sync or not manual and not selected)
|
||||
assert [task.fileitem.path for task in tasks] == ([str(audio), str(cover)] if with_cover else [str(audio)])
|
||||
assert all(task.mediainfo.album == "Album" for task in tasks)
|
||||
assert result["summary"] == {"total": 2 if enabled else 1, "success": 2 if enabled else 1, "failed": 0}
|
||||
assert result["summary"] == {"total": 2 if with_cover else 1, "success": 2 if with_cover else 1, "failed": 0}
|
||||
|
||||
|
||||
def test_music_scrape_waits_for_cover_transfer_completion():
|
||||
"""
|
||||
音轨先完成时仍等待封面任务,图片就绪后才发出只含音轨的刮削事件。
|
||||
"""
|
||||
chain = make_transfer_chain()
|
||||
chain.eventmanager = Mock()
|
||||
info = MusicInfo(title="Song", album="Album", artists=["Artist"])
|
||||
audio = TransferTask(fileitem=make_fileitem("/source/01.flac"), mediainfo=info,
|
||||
meta=MetaMusic(title="Song", album="Album"), transfer_batch_id="album")
|
||||
cover = audio.model_copy(update={"fileitem": make_fileitem("/source/cover.jpg")})
|
||||
for task in (audio, cover):
|
||||
chain._register_scrape_batch_task(task)
|
||||
chain._record_scrape_target(audio, TransferInfo(
|
||||
success=True, need_scrape=True, target_diritem=FileItem(storage="local", path="/library/Album", type="dir"),
|
||||
file_list_new=["/library/Album/01.flac"],
|
||||
))
|
||||
chain._close_scrape_batch("album")
|
||||
chain._finish_scrape_batch_task(audio)
|
||||
chain.eventmanager.send_event.assert_not_called()
|
||||
chain._finish_scrape_batch_task(cover)
|
||||
chain.eventmanager.send_event.assert_called_once()
|
||||
event, payload = chain.eventmanager.send_event.call_args.args
|
||||
assert event == EventType.MetadataScrape
|
||||
assert payload["file_list"] == ["/library/Album/01.flac"]
|
||||
assert not chain._scrape_batches
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["copy", "link", "move"])
|
||||
|
||||
Reference in New Issue
Block a user