mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-15 12:50:20 +08:00
fix(music): inherit transfer defaults for final directories (#7403)
This commit is contained in:
@@ -166,6 +166,9 @@ class _MusicFileFilterBase(_TransferOwnerBase):
|
||||
:param task: 整理任务
|
||||
:return: 是否必须具备自动分类结果
|
||||
"""
|
||||
if (isinstance(task.mediainfo, MusicInfo) and task.planning_input
|
||||
and task.planning_input.options.get("music_target_path")):
|
||||
return False
|
||||
target_directory = task.target_directory
|
||||
if target_directory and DirectoryHelper.has_fixed_category(target_directory):
|
||||
return False
|
||||
|
||||
@@ -333,7 +333,6 @@ class TransferHistoryOwner(_TransferOwnerBase):
|
||||
transfer_kwargs["music_track_match"] = music_track_match
|
||||
if music_target_path is not None:
|
||||
transfer_kwargs["music_target_path"] = music_target_path
|
||||
transfer_kwargs["target_path"] = target_path or music_target_path
|
||||
if media_source and media_id:
|
||||
# 有输入媒体ID时预先识别,音乐与影视统一走 recognize_media 按类型分发
|
||||
mediainfo = _recognize_manual_media(
|
||||
|
||||
@@ -559,6 +559,8 @@ class FileManagerModule(_ModuleBase):
|
||||
目录配置优先于显式目标路径;未匹配配置时保留自定义路径能力并告警,
|
||||
未提供显式路径时业务拒绝,不自动使用存储根目录。
|
||||
|
||||
本次音乐最终目录沿用匹配目录的整理策略,未匹配目录且方式为自动时使用复制。
|
||||
|
||||
:param fileitem: 文件信息
|
||||
:param meta: 预识别的元数据
|
||||
:param mediainfo: 识别的媒体信息
|
||||
@@ -611,6 +613,7 @@ class FileManagerModule(_ModuleBase):
|
||||
need_category_folder=library_category_folder)
|
||||
elif target_path:
|
||||
explicit_target_path = target_path
|
||||
transfer_type = transfer_type or ("copy" if music_target_path else None)
|
||||
need_scrape = scrape or False
|
||||
need_rename = True
|
||||
need_notify = False
|
||||
|
||||
@@ -620,6 +620,7 @@ Emby、Jellyfin、Plex、Navidrome 的目录、条目、搜索、季集和活动
|
||||
专辑与音质目录层级统一落在该目录;留空沿用现有目录规则。歌词同步重命名,封面与整轨 CUE
|
||||
保持配套文件命名规则。最终目录与已选曲目身份会冻结到任务输入,任务恢复沿用原选择。
|
||||
音乐没有线上条目时,可留空媒体 ID,使用完整的本地标签整理。
|
||||
填写最终目录时,自动整理方式继承匹配目录配置,未匹配目录配置时采用复制。
|
||||
|
||||
### 分页与截断
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ Preview and execute using the same source scope and options, and inspect sequent
|
||||
`music_target_path` is an optional absolute final music directory. It applies only the
|
||||
filename template and places tracks and companions in that directory. The task freezes
|
||||
this choice for recovery. With no online album, omit the media ID to use complete local tags.
|
||||
Automatic transfer inherits the matched directory policy and defaults to copying if no directory matches.
|
||||
|
||||
|
||||
## Durable AI recognition correction
|
||||
|
||||
@@ -408,18 +408,19 @@ def test_manual_music_final_directory_preserves_filename_and_frozen_input(monkey
|
||||
|
||||
monkeypatch.setattr(transhandler_module, "get_runtime_setting", setting)
|
||||
directory = TransferDirectoryConf(
|
||||
library_path="/configured-library", library_storage="alist", transfer_type="copy", renaming=renaming,
|
||||
library_path="/configured-library", library_storage="alist", transfer_type="reflink", renaming=renaming,
|
||||
library_type_folder=True, library_category_folder=True, scraping=True, overwrite_mode="never",
|
||||
) if configured else None
|
||||
checkpoint = FileManagerModule().plan_transfer(
|
||||
fileitem=fileitem, meta=meta, mediainfo=info, target_directory=directory, target_path=Path("/default-root"),
|
||||
target_storage="alist", transfer_type="copy", library_type_folder=True, library_category_folder=True,
|
||||
target_storage="alist", transfer_type=None, library_type_folder=True, library_category_folder=True,
|
||||
source_oper=ReadOnlyTreeStorage({}), planning_input=planning,
|
||||
)
|
||||
target = Path(checkpoint.final_target_path)
|
||||
assert target.parent == Path("/chosen/OST/Album")
|
||||
assert target.name == ("song.jpg" if extension == "jpg" else f"01 - Selected Song.{extension}")
|
||||
assert checkpoint.root_target_path == "/chosen/OST/Album"
|
||||
assert checkpoint.resolved_transfer_type == ("reflink" if configured else "copy")
|
||||
assert checkpoint.planning_input.fingerprint == fingerprint
|
||||
assert checkpoint.resolved_meta["sample_rate"] == 48000
|
||||
assert checkpoint.resolved_meta["duration"] == 30
|
||||
|
||||
@@ -12,6 +12,7 @@ from unittest.mock import AsyncMock, Mock
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from mutagen.flac import FLAC
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.adapters.web.security.access import verify_token
|
||||
@@ -22,9 +23,13 @@ from app.chain.musicbrainz import MusicBrainzChain
|
||||
from app.chain.theaudiodb import TheAudioDbChain
|
||||
from app.chain.transfer.facade import TransferChain
|
||||
from app.domain.context import MusicAlbumInfo, MusicInfo
|
||||
from app.modules.filemanager.module import FileManagerModule
|
||||
from app.modules.musicbrainz import MusicBrainzModule
|
||||
from app.schemas.file import FileItem
|
||||
from app.schemas.system import TransferDirectoryConf
|
||||
from app.schemas.transfer import ManualTransferItem, TransferInfo
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
from tests.test_filemanager_planning import ReadOnlyTreeStorage
|
||||
from tests.test_music_resource_context import _audio_files
|
||||
from tests.test_transfer_music_album_selection import _directory_item, _prepare_chain
|
||||
from tests.test_transfer_sync_extra_files import make_fileitem, make_transfer_chain
|
||||
@@ -254,6 +259,69 @@ def test_manual_music_options_reject_invalid_namespace_or_directory(changes):
|
||||
ManualTransferItem(**({"music_type": "album"} | changes))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("configured", [False, True])
|
||||
@pytest.mark.parametrize("selected_files", [False, True])
|
||||
def test_music_final_directory_auto_defaults_reach_real_planner(tmp_path, monkeypatch, configured, selected_files):
|
||||
"""
|
||||
真实标签从手动 API 经目录选择与分类校验到命名规划,自动方式继承源目录或使用复制。
|
||||
"""
|
||||
paths = _audio_files(tmp_path / "Artist - Album")
|
||||
for index, path in enumerate(paths, 1):
|
||||
tags = FLAC(path)
|
||||
tags.update(title=[f"Song {index}"], artist=["Artist"], albumartist=["Artist"], album=["Album"],
|
||||
date=["2004"], tracknumber=[str(index)])
|
||||
tags.save()
|
||||
before = [path.read_bytes() for path in paths]
|
||||
items = [make_fileitem(str(path)) for path in paths]
|
||||
chain = _prepare_chain(monkeypatch, items)
|
||||
monkeypatch.setattr("app.chain.transfer.workflow.StorageChain.get_item", lambda _self, item: item)
|
||||
monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", lambda: chain)
|
||||
monkeypatch.setattr(chain, "_finalize_recognition_result", lambda info, **_kwargs: info)
|
||||
lookup = Mock(return_value=TransferDirectoryConf(
|
||||
library_path=str(tmp_path / "configured"), library_storage="local", transfer_type="reflink",
|
||||
library_category_folder=True, renaming=True,
|
||||
) if configured else None)
|
||||
monkeypatch.setattr("app.chain.transfer.execution.DirectoryHelper.get_dir", lookup)
|
||||
network = Mock(side_effect=AssertionError("完整本地标签不应请求在线识别"))
|
||||
monkeypatch.setattr(chain, "_match_music_album_context", network)
|
||||
monkeypatch.setattr(chain, "_match_music_recording_context", network)
|
||||
monkeypatch.setattr(chain, "_TransferChain__select_storage_oper", lambda _storage: None)
|
||||
planned = []
|
||||
|
||||
def plan(task, **_kwargs):
|
||||
"""
|
||||
在文件执行边界使用真实文件管理规划器,记录目录和方式后返回零写副作用的预览。
|
||||
"""
|
||||
assert task.target_path is None
|
||||
checkpoint = FileManagerModule().plan_transfer(
|
||||
fileitem=task.fileitem, meta=task.meta, mediainfo=task.mediainfo, target_directory=task.target_directory,
|
||||
target_storage=task.target_storage, target_path=task.target_path, transfer_type=task.transfer_type,
|
||||
library_category_folder=task.library_category_folder, planning_input=task.planning_input,
|
||||
source_oper=ReadOnlyTreeStorage({}), preview=True,
|
||||
)
|
||||
planned.append(checkpoint)
|
||||
target = Path(checkpoint.final_target_path)
|
||||
return TransferInfo(success=True, fileitem=task.fileitem, target_item=FileItem(
|
||||
storage=checkpoint.target_storage, path=target.as_posix(), name=target.name, type="file",
|
||||
))
|
||||
|
||||
monkeypatch.setattr(chain, "_plan_checkpoint_and_execute", plan)
|
||||
target = tmp_path / "chosen"
|
||||
request = ManualTransferItem(fileitem=_directory_item(paths[0].parent), fileitems=items if selected_files else None,
|
||||
type_name="音乐", music_type="album", music_target_path=str(target), preview=True)
|
||||
response = manual_transfer(request, background=False, history_query=SimpleNamespace(), _="test")
|
||||
assert response.success, response.message
|
||||
assert response.model_dump()["data"]["summary"]["failed"] == 0
|
||||
assert len(planned) == 2
|
||||
assert all(Path(item.final_target_path).parent == target for item in planned)
|
||||
assert all(item.resolved_transfer_type == ("reflink" if configured else "copy") for item in planned)
|
||||
assert lookup.call_count == 2
|
||||
assert all("src_path" in call.kwargs and "dest_path" not in call.kwargs for call in lookup.call_args_list)
|
||||
assert not target.exists()
|
||||
assert [path.read_bytes() for path in paths] == before
|
||||
network.assert_not_called()
|
||||
|
||||
|
||||
def test_invalid_manual_release_cannot_enqueue_or_fall_back(monkeypatch):
|
||||
"""
|
||||
旧调用绕过HTTP时同样拒绝不完整发行身份,查不到发行也不进入文件整理。
|
||||
|
||||
Reference in New Issue
Block a user