mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-09 09:26:55 +08:00
Merge pull request #6608 from dogodefi/codex/music-album-batch-transfer
fix(music): preserve album context for manual batches
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Annotated, Any, List, Literal, Optional, cast
|
||||
|
||||
@@ -6,6 +7,7 @@ from fastapi import Depends, HTTPException, Query, status
|
||||
from app.adapters.web.security.access import verify_apitoken, verify_token
|
||||
from app.api.dependencies.auth import get_current_active_manage_user
|
||||
from app.api.dependencies.history import get_transfer_execution_repository, get_transfer_history_lookup_service
|
||||
from app.api.endpoints.transferhistory import restore_manual_transfer_history_batch
|
||||
from app.api.response import (
|
||||
CompatibleCountParam,
|
||||
CompatiblePageParam,
|
||||
@@ -115,6 +117,58 @@ def _merge_transfer_messages(messages: List[str]) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _is_music_file_batch(fileitems: List[FileItem]) -> bool:
|
||||
"""判断显式选中项是否为可按专辑识别的多音轨批次。"""
|
||||
if len(fileitems) < 2 or any(fileitem.type != "file" for fileitem in fileitems):
|
||||
return False
|
||||
audio_extensions = set(get_api_runtime_config_snapshot().audio_extensions)
|
||||
return all(
|
||||
Path(fileitem.path or fileitem.name or "").suffix.lower() in audio_extensions
|
||||
for fileitem in fileitems
|
||||
)
|
||||
|
||||
|
||||
def _selected_music_fileitems(
|
||||
fileitems: List[FileItem],
|
||||
*,
|
||||
explicitly_selected: bool,
|
||||
media_type: Optional[MediaType],
|
||||
) -> Optional[List[FileItem]]:
|
||||
"""返回需要共享专辑上下文的显式多选音轨。"""
|
||||
if not explicitly_selected or media_type not in (None, MediaType.MUSIC):
|
||||
return None
|
||||
if not _is_music_file_batch(fileitems):
|
||||
return None
|
||||
paths = [Path(fileitem.path) for fileitem in fileitems if fileitem.path]
|
||||
parent_counts: dict[Path, int] = {}
|
||||
for path in paths:
|
||||
parent_counts[path.parent] = parent_counts.get(path.parent, 0) + 1
|
||||
album_roots = [
|
||||
parent
|
||||
for parent in parent_counts
|
||||
if all(path.parent == parent or parent in path.parents for path in paths)
|
||||
]
|
||||
if not album_roots:
|
||||
return fileitems
|
||||
album_root = max(album_roots, key=lambda path: len(path.parts))
|
||||
filtered = []
|
||||
for fileitem, path in zip(fileitems, paths):
|
||||
if path.parent == album_root:
|
||||
filtered.append(fileitem)
|
||||
continue
|
||||
relative_parent = path.parent.relative_to(album_root)
|
||||
first_directory = relative_parent.parts[0] if relative_parent.parts else ""
|
||||
if re.fullmatch(r"(?:cd|disc|disk)\s*\d{1,2}", first_directory.strip(), re.IGNORECASE):
|
||||
filtered.append(fileitem)
|
||||
if filtered and len(filtered) < len(fileitems):
|
||||
logger.info(
|
||||
"音乐专辑批次忽略 %s 个非碟片子目录中的重复或附加音频",
|
||||
len(fileitems) - len(filtered),
|
||||
)
|
||||
return filtered
|
||||
return fileitems
|
||||
|
||||
|
||||
def _manual_review_actor(current_user: object) -> str:
|
||||
"""按名称、用户名和用户 ID 的稳定顺序提取人工复核操作者。"""
|
||||
for attribute in ("name", "username", "id"):
|
||||
@@ -535,10 +589,50 @@ def manual_transfer(
|
||||
:param history_query: 整理历史投影服务
|
||||
:param _: Token校验
|
||||
"""
|
||||
return _route_manual_transfer(
|
||||
transer_item=transer_item,
|
||||
background=background,
|
||||
history_query=history_query,
|
||||
)
|
||||
|
||||
|
||||
def _route_manual_transfer(
|
||||
transer_item: ManualTransferItem,
|
||||
background: Optional[bool],
|
||||
history_query: TransferHistoryLookupService,
|
||||
) -> Any:
|
||||
"""执行历史恢复、批量预览与 TransferChain 兼容编排。"""
|
||||
if not transer_item.logids:
|
||||
return _execute_manual_transfer(
|
||||
transer_item=transer_item,
|
||||
background=background,
|
||||
history_query=history_query,
|
||||
)
|
||||
|
||||
(
|
||||
src_fileitems,
|
||||
force,
|
||||
downloader,
|
||||
download_hash,
|
||||
history_error,
|
||||
) = restore_manual_transfer_history_batch(
|
||||
transer_item=transer_item,
|
||||
history_query=history_query,
|
||||
)
|
||||
if history_error or not src_fileitems:
|
||||
return _SchemaResponse(
|
||||
success=False,
|
||||
message=history_error or "缺少参数",
|
||||
)
|
||||
transer_item.fileitems = src_fileitems
|
||||
transer_item.logids = None
|
||||
return _execute_manual_transfer(
|
||||
transer_item=transer_item,
|
||||
background=background,
|
||||
history_query=history_query,
|
||||
force=force,
|
||||
downloader=downloader,
|
||||
download_hash=download_hash,
|
||||
)
|
||||
|
||||
|
||||
@@ -546,11 +640,11 @@ def _execute_manual_transfer(
|
||||
transer_item: ManualTransferItem,
|
||||
background: Optional[bool],
|
||||
history_query: TransferHistoryLookupService,
|
||||
force: bool = False,
|
||||
downloader: Optional[str] = None,
|
||||
download_hash: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""执行历史恢复、批量预览与 TransferChain 兼容编排。"""
|
||||
force = False
|
||||
downloader = None
|
||||
download_hash = None
|
||||
"""执行已还原源文件项的手动整理兼容编排。"""
|
||||
src_fileitems: List[FileItem] = []
|
||||
cleanup_dest_fileitem: Optional[FileItem] = None
|
||||
target_path = Path(transer_item.target_path) if transer_item.target_path else None
|
||||
@@ -673,7 +767,12 @@ def _execute_manual_transfer(
|
||||
part=transer_item.episode_part,
|
||||
offset=transer_item.episode_offset,
|
||||
)
|
||||
explicit_selected_files = bool(transer_item.fileitems)
|
||||
explicit_selected_files = bool(transer_item.fileitems or transer_item.logids)
|
||||
selected_music_fileitems = _selected_music_fileitems(
|
||||
src_fileitems, explicitly_selected=explicit_selected_files, media_type=mtype
|
||||
)
|
||||
mtype = MediaType.MUSIC if selected_music_fileitems is not None else mtype
|
||||
explicit_selected_files = explicit_selected_files and selected_music_fileitems is None
|
||||
|
||||
# 前端显式传入文件列表时,按选中的文件逐个处理,避免将目录整体展开。
|
||||
if explicit_selected_files:
|
||||
@@ -784,7 +883,11 @@ def _execute_manual_transfer(
|
||||
target_path=target_path,
|
||||
media_source=transer_item.media_source,
|
||||
media_id=transer_item.media_id,
|
||||
music_type=_resolve_music_type(src_fileitem),
|
||||
music_type=(
|
||||
MUSIC_ENTITY_ALBUM
|
||||
if selected_music_fileitems is not None
|
||||
else _resolve_music_type(src_fileitem)
|
||||
),
|
||||
music_release_regions=transer_item.music_release_regions,
|
||||
music_release_scripts=transer_item.music_release_scripts,
|
||||
mtype=mtype,
|
||||
@@ -802,8 +905,9 @@ def _execute_manual_transfer(
|
||||
download_hash=download_hash,
|
||||
preview=transer_item.preview,
|
||||
reorganize=transer_item.reorganize,
|
||||
sync_extra_files=True,
|
||||
sync_extra_files=selected_music_fileitems is None,
|
||||
cleanup_dest_fileitem=cleanup_dest_fileitem,
|
||||
selected_fileitems=selected_music_fileitems,
|
||||
)
|
||||
# 失败
|
||||
if not state:
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""手动整理历史批次的 HTTP 适配辅助函数。"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from app.application.history import ManualTransferHistory, TransferHistoryLookupService
|
||||
from app.runtime.log import logger
|
||||
from app.schemas.file import FileItem
|
||||
from app.schemas.transfer import ManualTransferItem
|
||||
|
||||
|
||||
def _common_history_value(
|
||||
histories: Sequence[ManualTransferHistory], attribute: str,
|
||||
) -> Any:
|
||||
"""返回一组整理历史中完全一致的非空字段值。"""
|
||||
values = [getattr(history, attribute, None) for history in histories]
|
||||
nonempty_values = [value for value in values if value is not None and value != ""]
|
||||
if not nonempty_values or len(nonempty_values) != len(values):
|
||||
return None
|
||||
first_value = nonempty_values[0]
|
||||
return first_value if all(value == first_value for value in nonempty_values) else None
|
||||
|
||||
|
||||
def restore_manual_transfer_history_batch(
|
||||
transer_item: ManualTransferItem,
|
||||
history_query: TransferHistoryLookupService,
|
||||
) -> tuple[List[FileItem], bool, Optional[str], Optional[str], Optional[str]]:
|
||||
"""还原多选历史的源文件集合与可安全复用的共同下载上下文。"""
|
||||
histories = []
|
||||
src_fileitems = []
|
||||
for logid in transer_item.logids or []:
|
||||
history = history_query.get(logid)
|
||||
if not history:
|
||||
return [], False, None, None, f"整理记录不存在,ID:{logid}"
|
||||
histories.append(history)
|
||||
source_payload = (
|
||||
history.dest_fileitem
|
||||
if history.status and history.mode and "move" in history.mode
|
||||
else history.src_fileitem
|
||||
)
|
||||
if not source_payload:
|
||||
return [], False, None, None, f"整理记录缺少文件信息,ID:{logid}"
|
||||
src_fileitems.append(FileItem.model_validate(source_payload))
|
||||
|
||||
force = bool(histories) and all(bool(history.status) for history in histories)
|
||||
downloader = None
|
||||
download_hash = None
|
||||
if transer_item.from_history and histories:
|
||||
transer_item.type_name = (
|
||||
_common_history_value(histories, "type") or transer_item.type_name
|
||||
)
|
||||
transer_item.media_source = (
|
||||
_common_history_value(histories, "media_source")
|
||||
or transer_item.media_source
|
||||
)
|
||||
transer_item.media_id = (
|
||||
_common_history_value(histories, "media_id") or transer_item.media_id
|
||||
)
|
||||
transer_item.music_type = (
|
||||
_common_history_value(histories, "music_type")
|
||||
or transer_item.music_type
|
||||
)
|
||||
downloader = _common_history_value(histories, "downloader")
|
||||
download_hash = _common_history_value(histories, "download_hash")
|
||||
logger.info("手动整理历史批次还原 %s 个源文件", len(src_fileitems))
|
||||
return src_fileitems, force, downloader, download_hash, None
|
||||
@@ -1,6 +1,7 @@
|
||||
"""专辑目录扫描、曲目对齐与缓存编排 owner。"""
|
||||
|
||||
import os
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union, cast
|
||||
|
||||
@@ -62,7 +63,8 @@ class MediaAlbumOwner(_MediaOwnerBase):
|
||||
except OSError:
|
||||
subdirectories = []
|
||||
for subdirectory in subdirectories:
|
||||
collect(subdirectory)
|
||||
if MetaMusic.parse_disc_dir(subdirectory.name) is not None:
|
||||
collect(subdirectory)
|
||||
return files
|
||||
|
||||
@staticmethod
|
||||
@@ -177,6 +179,24 @@ class MediaAlbumOwner(_MediaOwnerBase):
|
||||
).items()
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _album_track_map(
|
||||
cls,
|
||||
files: list[Path],
|
||||
metas: list[MetaMusic],
|
||||
album: MusicAlbumInfo,
|
||||
) -> dict[str, MusicInfo]:
|
||||
"""将专辑分类与识别事实传递给每条已对位曲目。"""
|
||||
aligned = cls._align_music_album_tracks(files, metas, album.tracks)
|
||||
for info in aligned.values():
|
||||
info.set_library_category(album.library_category)
|
||||
info.classification = deepcopy(album.classification)
|
||||
info.classification_facts = dict(album.classification_facts)
|
||||
return {
|
||||
str(file.resolve()): info
|
||||
for file, info in aligned.items()
|
||||
}
|
||||
|
||||
def _match_music_album_directory(
|
||||
self,
|
||||
directory: Path,
|
||||
@@ -199,10 +219,7 @@ class MediaAlbumOwner(_MediaOwnerBase):
|
||||
)
|
||||
if not album or not album.tracks:
|
||||
return {}
|
||||
return {
|
||||
str(file.resolve()): info
|
||||
for file, info in self._align_music_album_tracks(files, metas, album.tracks).items()
|
||||
}
|
||||
return self._album_track_map(files, metas, album)
|
||||
|
||||
async def _async_match_music_album_directory(
|
||||
self,
|
||||
@@ -226,10 +243,7 @@ class MediaAlbumOwner(_MediaOwnerBase):
|
||||
)
|
||||
if not album or not album.tracks:
|
||||
return {}
|
||||
return {
|
||||
str(file.resolve()): info
|
||||
for file, info in self._align_music_album_tracks(files, metas, album.tracks).items()
|
||||
}
|
||||
return self._album_track_map(files, metas, album)
|
||||
|
||||
def recognize_music_album_directory(
|
||||
self,
|
||||
|
||||
@@ -173,6 +173,16 @@ if TYPE_CHECKING:
|
||||
"""同步识别音乐专辑目录。"""
|
||||
...
|
||||
|
||||
@classmethod
|
||||
def _album_track_map(
|
||||
cls,
|
||||
files: list[Path],
|
||||
metas: list[MetaMusic],
|
||||
album: MusicAlbumInfo,
|
||||
) -> dict[str, MusicInfo]:
|
||||
"""将专辑级分类上下文传递给对位曲目。"""
|
||||
...
|
||||
|
||||
async def async_recognize_music_album_directory(
|
||||
self,
|
||||
path: Union[str, Path],
|
||||
|
||||
@@ -126,6 +126,9 @@ class MediaChain(ChainBase, metaclass=Singleton):
|
||||
_align_selected_music_album = classmethod( # type: ignore[var-annotated]
|
||||
MediaAlbumOwner._align_selected_music_album.__func__ # type: ignore[attr-defined]
|
||||
)
|
||||
_album_track_map = classmethod( # type: ignore[var-annotated]
|
||||
MediaAlbumOwner._album_track_map.__func__ # type: ignore[attr-defined]
|
||||
)
|
||||
_match_music_album_directory = MediaAlbumOwner._match_music_album_directory
|
||||
_async_match_music_album_directory = MediaAlbumOwner._async_match_music_album_directory
|
||||
recognize_music_album_directory = cast(
|
||||
|
||||
@@ -235,6 +235,7 @@ class FileFilterMixin(_TransferOwnerBase):
|
||||
merged_info.set_library_category(info.library_category)
|
||||
merged_info.metadata_category = info.metadata_category
|
||||
merged_info.classification = deepcopy(info.classification)
|
||||
merged_info.classification_facts = dict(info.classification_facts)
|
||||
merged_info.genres = list(info.genres)
|
||||
merged_info.tags = list(info.tags)
|
||||
merged_info.artist_country = info.artist_country
|
||||
@@ -319,6 +320,7 @@ class FileFilterMixin(_TransferOwnerBase):
|
||||
download_history: Optional[DownloadHistorySnapshot],
|
||||
file_path: Path,
|
||||
discard_recording_identity: bool = False,
|
||||
discard_saved_identity: bool = False,
|
||||
) -> tuple[Optional[MetaMusic], Optional[MusicInfo]]:
|
||||
"""从下载历史恢复音乐上下文,并用当前音频标签覆盖曲目级字段。
|
||||
|
||||
@@ -326,6 +328,8 @@ class FileFilterMixin(_TransferOwnerBase):
|
||||
沿用已选媒体,不根据专辑名或文件曲名在单曲与专辑之间转换。
|
||||
多音轨批次误带单曲身份时只保留文件自身标签,避免把同一 recording
|
||||
身份传播到整张专辑;调用方随后可使用目录级证据重新匹配专辑。
|
||||
手动选中多条历史且未要求复用历史身份时,专辑身份也必须丢弃,确保
|
||||
目录级识别能够重新补齐发行版、分类和规范名称。
|
||||
"""
|
||||
note = getattr(download_history, "note", None)
|
||||
music_note = note.get("music") if isinstance(note, dict) else None
|
||||
@@ -338,7 +342,7 @@ class FileFilterMixin(_TransferOwnerBase):
|
||||
return None, None
|
||||
|
||||
file_tags = MediaChain.read_path_meta(file_path)
|
||||
should_discard_identity = (
|
||||
should_discard_identity = discard_saved_identity or (
|
||||
discard_recording_identity
|
||||
and saved_info.music_type == MUSIC_ENTITY_RECORDING
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, List, Optional, Tuple, Union
|
||||
from typing import Any, List, Optional, Tuple, Union, cast
|
||||
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.transfer.contract import _TransferOwnerBase
|
||||
@@ -54,6 +54,25 @@ def _recognize_manual_media(
|
||||
class TransferHistoryOwner(_TransferOwnerBase):
|
||||
"""唯一持有手动历史、重整命令和通知公开入口。"""
|
||||
|
||||
def _run_manual_transfer_request(
|
||||
self,
|
||||
transfer_kwargs: dict[str, Any],
|
||||
selected_fileitems: Optional[list[FileItem]],
|
||||
) -> Tuple[bool, Union[str, dict[str, Any]]]:
|
||||
"""显式文件批次走内部入口,普通请求继续保持公开签名兼容。"""
|
||||
if selected_fileitems is not None:
|
||||
return cast(
|
||||
Tuple[bool, Union[str, dict[str, Any]]],
|
||||
self._execute_transfer(
|
||||
**transfer_kwargs,
|
||||
selected_fileitems=selected_fileitems,
|
||||
),
|
||||
)
|
||||
return cast(
|
||||
Tuple[bool, Union[str, dict[str, Any]]],
|
||||
self.do_transfer(**transfer_kwargs),
|
||||
)
|
||||
|
||||
def remote_transfer(
|
||||
self,
|
||||
arg_str: str,
|
||||
@@ -171,6 +190,7 @@ class TransferHistoryOwner(_TransferOwnerBase):
|
||||
music_type: Optional[str] = None,
|
||||
music_release_regions: Optional[list[str]] = None,
|
||||
music_release_scripts: Optional[list[str]] = None,
|
||||
selected_fileitems: Optional[list[FileItem]] = None,
|
||||
) -> Tuple[bool, Union[str, dict[str, Any]]]:
|
||||
"""
|
||||
手动整理,支持复杂条件,带进度显示
|
||||
@@ -199,6 +219,7 @@ class TransferHistoryOwner(_TransferOwnerBase):
|
||||
:param music_type: 音乐实体类型;为保持位置参数兼容,必须追加在签名末尾
|
||||
:param music_release_regions: 本次音乐整理的发行地区优先级,空值继承系统设置
|
||||
:param music_release_scripts: 本次音乐整理的文字字形优先级,空值继承系统设置
|
||||
:param selected_fileitems: 前端显式选中的批量文件
|
||||
"""
|
||||
logger.info(f"手动整理:{fileitem.path} ...")
|
||||
explicit_identity = media_source is not None or media_id is not None
|
||||
@@ -226,7 +247,7 @@ class TransferHistoryOwner(_TransferOwnerBase):
|
||||
self.obtain_images(mediainfo=mediainfo)
|
||||
|
||||
# 开始整理
|
||||
state, errmsg = self.do_transfer(
|
||||
transfer_kwargs = dict(
|
||||
fileitem=fileitem,
|
||||
target_storage=target_storage,
|
||||
target_path=target_path,
|
||||
@@ -253,6 +274,7 @@ class TransferHistoryOwner(_TransferOwnerBase):
|
||||
music_release_regions=music_release_regions,
|
||||
music_release_scripts=music_release_scripts,
|
||||
)
|
||||
state, errmsg = self._run_manual_transfer_request(transfer_kwargs, selected_fileitems)
|
||||
if not state:
|
||||
return False, errmsg
|
||||
|
||||
@@ -260,7 +282,7 @@ class TransferHistoryOwner(_TransferOwnerBase):
|
||||
return True, errmsg if preview else ""
|
||||
else:
|
||||
# 没有输入媒体ID时,按文件识别
|
||||
state, errmsg = self.do_transfer(
|
||||
transfer_kwargs = dict(
|
||||
fileitem=fileitem,
|
||||
target_storage=target_storage,
|
||||
target_path=target_path,
|
||||
@@ -285,6 +307,7 @@ class TransferHistoryOwner(_TransferOwnerBase):
|
||||
music_release_regions=music_release_regions,
|
||||
music_release_scripts=music_release_scripts,
|
||||
)
|
||||
state, errmsg = self._run_manual_transfer_request(transfer_kwargs, selected_fileitems)
|
||||
return state, errmsg
|
||||
|
||||
def send_transfer_message(
|
||||
|
||||
@@ -22,6 +22,19 @@ from app.schemas.types import (
|
||||
from app.schemas.workflow import FileItem
|
||||
|
||||
|
||||
def preview_media_title(
|
||||
mediainfo: Optional[Union[MediaInfo, MusicInfo]],
|
||||
) -> Optional[str]:
|
||||
"""音乐预览以专辑为批次标题,影视继续使用原有标题。"""
|
||||
if isinstance(mediainfo, MusicInfo) and mediainfo.album:
|
||||
return (
|
||||
f"{mediainfo.album} ({mediainfo.year})"
|
||||
if mediainfo.year
|
||||
else mediainfo.album
|
||||
)
|
||||
return mediainfo.title_year if mediainfo else None
|
||||
|
||||
|
||||
def _should_discard_batch_recording_identity(
|
||||
*,
|
||||
multi_track_music_batch: bool,
|
||||
@@ -41,6 +54,28 @@ def _should_discard_batch_recording_identity(
|
||||
)
|
||||
|
||||
|
||||
def _should_discard_batch_music_identity(
|
||||
*,
|
||||
manual: bool,
|
||||
multi_track_music_batch: bool,
|
||||
media_source: Optional[MediaSource],
|
||||
media_id: Optional[str],
|
||||
mediainfo: Optional[MediaInfo | MusicInfo],
|
||||
history_music_type: Optional[str],
|
||||
) -> bool:
|
||||
"""批次误带单曲身份或未显式指定媒体时重新识别整张专辑。"""
|
||||
if manual and multi_track_music_batch and not (media_source and media_id):
|
||||
return True
|
||||
return _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=history_music_type,
|
||||
)
|
||||
|
||||
|
||||
class _TransferCandidatePlanner:
|
||||
"""持有一次整理请求的只读候选规划上下文。"""
|
||||
|
||||
|
||||
@@ -44,8 +44,9 @@ from app.schemas.types import (
|
||||
from app.schemas.workflow import FileItem
|
||||
|
||||
from .request import (
|
||||
_should_discard_batch_recording_identity,
|
||||
_should_discard_batch_music_identity,
|
||||
_TransferCandidatePlanner,
|
||||
preview_media_title,
|
||||
)
|
||||
|
||||
|
||||
@@ -276,6 +277,7 @@ class TransferWorkflowOwner(_TransferOwnerBase):
|
||||
epformat: Optional[EpisodeFormat],
|
||||
season: Optional[int],
|
||||
continue_callback: Optional[Callable],
|
||||
selected_fileitems: Optional[list[FileItem]] = None,
|
||||
) -> Tuple[List[Tuple[FileItem, bool]], bool]:
|
||||
"""
|
||||
收集并过滤本次整理的候选文件。
|
||||
@@ -329,7 +331,15 @@ class TransferWorkflowOwner(_TransferOwnerBase):
|
||||
return False
|
||||
return not self._is_blocked_by_exclude_words(item.path, exclude_words)
|
||||
|
||||
candidates = self._TransferChain__get_trans_fileitems(fileitem, predicate=keep_candidate)
|
||||
if selected_fileitems is None:
|
||||
candidates = self._TransferChain__get_trans_fileitems(fileitem, predicate=keep_candidate)
|
||||
else:
|
||||
storage_chain = StorageChain()
|
||||
candidates = [
|
||||
(latest_fileitem, False)
|
||||
for selected_fileitem in selected_fileitems
|
||||
if (latest_fileitem := storage_chain.get_item(selected_fileitem))
|
||||
]
|
||||
return [
|
||||
(item, is_bluray_dir) for item, is_bluray_dir in candidates if is_allowed(item, is_bluray_dir)
|
||||
], matched_template
|
||||
@@ -437,6 +447,7 @@ class TransferWorkflowOwner(_TransferOwnerBase):
|
||||
recovery_admission: Optional[TransferAdmission] = None,
|
||||
music_release_regions: Optional[list[str]] = None,
|
||||
music_release_scripts: Optional[list[str]] = None,
|
||||
selected_fileitems: Optional[list[FileItem]] = None,
|
||||
) -> Tuple[bool, Union[str, dict]]:
|
||||
"""
|
||||
执行一个复杂目录的整理操作
|
||||
@@ -469,6 +480,7 @@ class TransferWorkflowOwner(_TransferOwnerBase):
|
||||
:param recovery_admission: 内部恢复调用绑定的既有 durable 记录
|
||||
:param music_release_regions: 本次音乐整理的发行地区优先级
|
||||
:param music_release_scripts: 本次音乐整理的文字字形优先级
|
||||
:param selected_fileitems: 前端显式选中的文件,按同一批次规划
|
||||
返回:成功标识,错误信息
|
||||
"""
|
||||
selected_music_album: Optional[MusicAlbumInfo]
|
||||
@@ -487,7 +499,6 @@ class TransferWorkflowOwner(_TransferOwnerBase):
|
||||
if identity_error:
|
||||
return False, identity_error
|
||||
|
||||
# 是否全部成功
|
||||
all_success = True
|
||||
transfer_batch_id = str(uuid.uuid4())
|
||||
batch_mtype = getattr(mediainfo, "type", None)
|
||||
@@ -536,6 +547,7 @@ class TransferWorkflowOwner(_TransferOwnerBase):
|
||||
epformat=epformat,
|
||||
season=season,
|
||||
continue_callback=continue_callback,
|
||||
selected_fileitems=selected_fileitems,
|
||||
)
|
||||
except OperationInterrupted:
|
||||
return False, f"{fileitem.name} 已取消"
|
||||
@@ -778,9 +790,8 @@ 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,
|
||||
discard_music_identity = _should_discard_batch_music_identity(
|
||||
multi_track_music_batch=multi_track_music_batch, manual=manual,
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
mediainfo=mediainfo,
|
||||
@@ -789,7 +800,7 @@ class TransferWorkflowOwner(_TransferOwnerBase):
|
||||
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,
|
||||
discard_saved_identity=discard_music_identity,
|
||||
)
|
||||
|
||||
if not meta:
|
||||
@@ -824,14 +835,15 @@ class TransferWorkflowOwner(_TransferOwnerBase):
|
||||
# 自动整理预载的媒体信息来自整条下载历史;电影合集内文件年份冲突时逐文件识别。
|
||||
file_meta, task_mediainfo = self._selected_music_task_context(
|
||||
file_item, file_path, file_meta, selected_music_track_map,
|
||||
None if discard_recording_identity else mediainfo or history_music_info,
|
||||
None if discard_music_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, music_release_regions, music_release_scripts,
|
||||
)
|
||||
if not task_mediainfo and discard_recording_identity:
|
||||
if not task_mediainfo and discard_music_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
|
||||
@@ -945,7 +957,7 @@ class TransferWorkflowOwner(_TransferOwnerBase):
|
||||
"success": transferinfo.success,
|
||||
"message": transferinfo.message,
|
||||
"type": item_media.type.value if item_media and item_media.type else None,
|
||||
"title": item_media.title_year if item_media else None,
|
||||
"title": preview_media_title(item_media),
|
||||
"season": item_meta.begin_season if item_meta else None,
|
||||
"episode": item_meta.begin_episode if item_meta else None,
|
||||
"episode_end": item_meta.end_episode if item_meta else None,
|
||||
|
||||
@@ -103,7 +103,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
|
||||
| Python 源码量 | 305,884 行 | 排除 `app/plugins/**`;61 个文件超过 1,000 行,11 个超过 2,000 行 |
|
||||
| 长方法 | 290 个超过 80 行 | AST 统计排除 `app/plugins/**`;65 个超过 150 行,21 个超过 250 行 |
|
||||
| 全量 mypy 历史债务 | 9,441 / 513 文件 | Agent API 重构后的现状基线;canonical Facade 与 endpoint 类型边界已补齐,低水位只允许继续下降 |
|
||||
| Ruff 历史诊断 | 538 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
||||
| Ruff 历史诊断 | 537 | 低水位门禁通过,但规则集只覆盖 `E4/E7/E9/F/I` |
|
||||
| 覆盖率固定基线 | Application 80.00%,Domain 80.00% | Chain、Runtime、Agent、Adapter、Startup 未进入包级覆盖率门禁 |
|
||||
|
||||
### 3.3 热点文件
|
||||
|
||||
+1
-1
@@ -280,7 +280,7 @@ FastAPI 的 HTTP 异常和参数校验异常统一使用 `message`,不再返
|
||||
| POST | `/api/v1/media/scrape/{storage}` | 刮削媒体元数据;请求体为 `FileItem`,可选查询参数 `media_source`、`media_id`、`type_name`(电影/电视剧/音乐)。音乐会按策略处理音频标签、封面和歌词 |
|
||||
| POST | `/api/v1/transfer/manual/target-path` | 按源文件与目录配置匹配手动整理目标路径;请求体为 `ManualTransferItem`,该接口不执行媒体识别 |
|
||||
| POST | `/api/v1/transfer/manual/history` | 查询文件、批量文件或目录命中的成功整理历史摘要,用于进入手动整理界面时显示重新整理状态 |
|
||||
| POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源;音乐请求未传 `music_type` 时,目录按 `album`、文件按 `recording` 解释;可用最多三项的 `music_release_regions`(ISO 3166-1)和 `music_release_scripts`(ISO 15924)仅覆盖本次 MusicBrainz 发行版本排序,省略时继承系统设置;命中持久失败历史,且未指定媒体身份、未开启 `reorganize` 时,由调度器重试原计划(包括 `logid` 历史入口);显式重整先校验并放弃确定失败任务,再清理旧目标和记录;旧版失败历史仍清理后重试;`reorganize=true` 时清理命中的成功历史和非移动模式旧目标后重新整理 |
|
||||
| POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源;`logids` 会先还原为同一个显式文件批次,多首音乐因而共享专辑识别上下文;音乐请求未传 `music_type` 时,目录按 `album`、文件按 `recording` 解释;可用最多三项的 `music_release_regions`(ISO 3166-1)和 `music_release_scripts`(ISO 15924)仅覆盖本次 MusicBrainz 发行版本排序,省略时继承系统设置;命中持久失败历史,且未指定媒体身份、未开启 `reorganize` 时,由调度器重试原计划(包括 `logid` 历史入口);显式重整先校验并放弃确定失败任务,再清理旧目标和记录;旧版失败历史仍清理后重试;`reorganize=true` 时清理命中的成功历史和非移动模式旧目标后重新整理 |
|
||||
| GET | `/api/v1/transfer/tasks/manual-reviews` | 管理员分页查询 durable 人工复核任务;`state` 仅允许 `manual_review`(默认)或已经人工判定、等待调度恢复的 `retry_wait`,支持 `page` 与 `page_size`。响应只公开任务、源文件、状态、步骤意图/证据/错误和复核修订号,不返回 lease 或 attempt 身份 |
|
||||
| GET | `/api/v1/transfer/tasks/{task_id}/manual-review` | 管理员查询单个 durable 人工复核任务详情;仅可读取 `manual_review` 或已经人工判定的 `retry_wait` 任务,其余状态按不存在处理 |
|
||||
| POST | `/api/v1/transfer/tasks/{task_id}/manual-review` | 管理员判定处于 `manual_review` 的 durable 整理步骤;请求包含 `operation_id`、`decision=not_applied|applied`、`reason`,`applied` 还必须提供 `result_payload`。`failed` 不属于公开决策,失败终态只能由持租约的 durable 结算写入;响应仅返回任务、操作、决策、后续状态和复核修订号 |
|
||||
|
||||
@@ -2858,6 +2858,8 @@
|
||||
"app.api.endpoints.transfer -> app.api.dependencies",
|
||||
"app.api.endpoints.transfer -> app.api.dependencies.auth",
|
||||
"app.api.endpoints.transfer -> app.api.dependencies.history",
|
||||
"app.api.endpoints.transfer -> app.api.endpoints",
|
||||
"app.api.endpoints.transfer -> app.api.endpoints.transferhistory",
|
||||
"app.api.endpoints.transfer -> app.api.response",
|
||||
"app.api.endpoints.transfer -> app.application",
|
||||
"app.api.endpoints.transfer -> app.application.configuration",
|
||||
@@ -2881,6 +2883,13 @@
|
||||
"app.api.endpoints.transfer -> app.schemas.transfer",
|
||||
"app.api.endpoints.transfer -> app.schemas.types",
|
||||
"app.api.endpoints.transfer -> app.schemas.workflow",
|
||||
"app.api.endpoints.transferhistory -> app.application",
|
||||
"app.api.endpoints.transferhistory -> app.application.history",
|
||||
"app.api.endpoints.transferhistory -> app.runtime",
|
||||
"app.api.endpoints.transferhistory -> app.runtime.log",
|
||||
"app.api.endpoints.transferhistory -> app.schemas",
|
||||
"app.api.endpoints.transferhistory -> app.schemas.file",
|
||||
"app.api.endpoints.transferhistory -> app.schemas.transfer",
|
||||
"app.api.endpoints.user -> app.api",
|
||||
"app.api.endpoints.user -> app.api.dependencies",
|
||||
"app.api.endpoints.user -> app.api.dependencies.auth",
|
||||
@@ -9603,6 +9612,7 @@
|
||||
"app.api.endpoints.tmdb",
|
||||
"app.api.endpoints.torrent",
|
||||
"app.api.endpoints.transfer",
|
||||
"app.api.endpoints.transferhistory",
|
||||
"app.api.endpoints.user",
|
||||
"app.api.endpoints.webhook",
|
||||
"app.api.endpoints.workflow",
|
||||
|
||||
@@ -964,9 +964,6 @@
|
||||
"tests/test_transfer_download_history_oper_sessions.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_transfer_history_retransfer.py": {
|
||||
"I001": 1
|
||||
},
|
||||
"tests/test_transfer_rename_build_event.py": {
|
||||
"I001": 1
|
||||
},
|
||||
|
||||
@@ -210,6 +210,7 @@ def test_recognize_album_directory_maps_files(tmp_path, media_chain, monkeypatch
|
||||
media_id="rg-1",
|
||||
title="七里香",
|
||||
artists=["周杰伦"],
|
||||
library_category="Album",
|
||||
tracks=[
|
||||
MusicInfo(
|
||||
media_source="musicbrainz",
|
||||
@@ -237,11 +238,30 @@ def test_recognize_album_directory_maps_files(tmp_path, media_chain, monkeypatch
|
||||
info = matched[str(file.resolve())]
|
||||
assert info.media_id == f"rec-{index + 1}"
|
||||
assert info.title == ALBUM_TRACKS[index][0]
|
||||
assert info.library_category == "Album"
|
||||
# 同一目录再次识别直接命中缓存,不重复请求模块
|
||||
assert media_chain.recognize_music_album_directory(album_dir) == matched
|
||||
source_chain.match_music_album.assert_called_once()
|
||||
|
||||
|
||||
def test_directory_audio_files_only_includes_disc_subdirectories(tmp_path):
|
||||
"""专辑根目录只应合并 CD/Disc 子目录,不得把附加版本当成额外碟。"""
|
||||
album_dir = tmp_path / "周杰伦 - 七里香 (2004)"
|
||||
disc_dir = album_dir / "CD2"
|
||||
alternate_dir = album_dir / "附加原版"
|
||||
disc_dir.mkdir(parents=True)
|
||||
alternate_dir.mkdir()
|
||||
root_track = album_dir / "01.flac"
|
||||
disc_track = disc_dir / "01.flac"
|
||||
alternate_track = alternate_dir / "01.flac"
|
||||
for path in (root_track, disc_track, alternate_track):
|
||||
path.write_bytes(b"audio")
|
||||
|
||||
files = MediaChain._directory_audio_files(album_dir)
|
||||
|
||||
assert files == [root_track, disc_track]
|
||||
|
||||
|
||||
def test_align_album_tracks_prefers_exact_titles_over_conflicting_positions():
|
||||
"""本地曲序与发行版本冲突时,精确曲名必须优先,避免整张专辑错位。"""
|
||||
files = [
|
||||
|
||||
@@ -374,6 +374,53 @@ def test_restore_album_context_keeps_album_identity_and_track_specific_tags(
|
||||
assert restored_info.media_id == "release-group-1"
|
||||
|
||||
|
||||
def test_manual_batch_can_discard_saved_album_identity(tmp_path, monkeypatch):
|
||||
"""手动批次关闭历史复用时应丢弃旧专辑身份,仅保留当前文件标签。"""
|
||||
album = MusicInfo(
|
||||
media_source="musicbrainz",
|
||||
media_id="stale-release-group",
|
||||
music_type="album",
|
||||
title="旧专辑",
|
||||
artists=["旧艺人"],
|
||||
album="旧专辑",
|
||||
album_artist="旧艺人",
|
||||
year=1999,
|
||||
category="",
|
||||
)
|
||||
history = SimpleNamespace(note={
|
||||
"music": {
|
||||
"version": 1,
|
||||
"meta": MetaMusic.from_music_info(album).to_dict(),
|
||||
"media": album.to_dict(),
|
||||
}
|
||||
})
|
||||
audio_file = tmp_path / "01 - 我的地盘.flac"
|
||||
audio_file.write_bytes(b"fake-flac")
|
||||
file_meta = MetaMusic(
|
||||
org_string=audio_file.name,
|
||||
title="我的地盘",
|
||||
artists=["周杰伦"],
|
||||
album="七里香",
|
||||
album_artist="周杰伦",
|
||||
year=2004,
|
||||
track_number=1,
|
||||
total_tracks=10,
|
||||
)
|
||||
monkeypatch.setattr(MediaChain, "read_path_meta", Mock(return_value=file_meta))
|
||||
|
||||
restored_meta, restored_info = TransferChain._restore_music_download_context(
|
||||
history,
|
||||
audio_file,
|
||||
discard_saved_identity=True,
|
||||
)
|
||||
|
||||
assert restored_meta.title == "我的地盘"
|
||||
assert restored_meta.album == "七里香"
|
||||
assert restored_meta.media_source is None
|
||||
assert restored_meta.media_id is None
|
||||
assert restored_info is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field_source", ["selected", "resource", "file"])
|
||||
def test_restore_music_context_only_fills_missing_selected_fields(monkeypatch, field_source):
|
||||
"""已选语义字段仅补缺,不覆盖种子证据、文件标签或注入目标音质。"""
|
||||
@@ -937,6 +984,138 @@ def test_automatic_multi_track_recording_context_rematches_album(tmp_path, monke
|
||||
assert album_match.call_count == 2
|
||||
|
||||
|
||||
def test_manual_history_batch_rematches_album_and_groups_preview(tmp_path, monkeypatch):
|
||||
"""手动多选历史不复用身份时应重识别整专,并以专辑标题汇总预览。"""
|
||||
source_dir = tmp_path / "未分类" / "周杰伦 - 七里香 (2004) [FLAC]"
|
||||
source_dir.mkdir(parents=True)
|
||||
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 = source_items[0]
|
||||
stale_album = MusicInfo(
|
||||
media_source="musicbrainz",
|
||||
media_id="stale-release-group",
|
||||
music_type="album",
|
||||
title="七里香",
|
||||
artists=["周杰伦"],
|
||||
album="七里香",
|
||||
album_artist="周杰伦",
|
||||
year=2004,
|
||||
category="",
|
||||
)
|
||||
history = DownloadHistorySnapshot(
|
||||
id=1,
|
||||
path=source_dir.as_posix(),
|
||||
type=MediaType.MUSIC.value,
|
||||
title="七里香",
|
||||
note={
|
||||
"music": {
|
||||
"version": 1,
|
||||
"meta": MetaMusic.from_music_info(stale_album).to_dict(),
|
||||
"media": stale_album.to_dict(),
|
||||
}
|
||||
},
|
||||
music_type="album",
|
||||
downloader="qbittorrent",
|
||||
download_hash="hash-1",
|
||||
)
|
||||
file_metas = {
|
||||
path: MetaMusic(
|
||||
org_string=path.name,
|
||||
title=title,
|
||||
artists=["周杰伦"],
|
||||
album="七里香",
|
||||
album_artist="周杰伦",
|
||||
year=2004,
|
||||
track_number=index,
|
||||
total_tracks=10,
|
||||
)
|
||||
for index, (path, title) in enumerate(
|
||||
zip(audio_paths, ("我的地盘", "七里香")), start=1
|
||||
)
|
||||
}
|
||||
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",
|
||||
album_type="Album",
|
||||
year=2004,
|
||||
track_number=index,
|
||||
total_tracks=10,
|
||||
library_category="Album",
|
||||
)
|
||||
for index, path in enumerate(audio_paths, start=1)
|
||||
}
|
||||
chain = TransferChain()
|
||||
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" / "Album" / "周杰伦" / "七里香 (2004)"
|
||||
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._execute_transfer(
|
||||
fileitem=source_item,
|
||||
mtype=MediaType.MUSIC,
|
||||
target_directory=TransferDirectoryConf(
|
||||
library_path=(tmp_path / "library").as_posix(),
|
||||
library_storage="local",
|
||||
library_category_folder=True,
|
||||
),
|
||||
selected_fileitems=source_items,
|
||||
manual=True,
|
||||
force=True,
|
||||
preview=True,
|
||||
)
|
||||
|
||||
assert state is True
|
||||
assert preview["summary"] == {"total": 2, "success": 2, "failed": 0}
|
||||
assert {item["title"] for item in preview["items"]} == {"七里香 (2004)"}
|
||||
assert [task.mediainfo.media_id for task in captured_tasks] == [
|
||||
"recording-1",
|
||||
"recording-2",
|
||||
]
|
||||
assert {task.mediainfo.library_category for task in captured_tasks} == {"Album"}
|
||||
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"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
from app.api.endpoints.transfer import (
|
||||
manual_transfer,
|
||||
match_manual_transfer_target_path,
|
||||
@@ -429,6 +428,183 @@ def test_manual_transfer_preview_multi_select_collects_failures(monkeypatch):
|
||||
assert resp.data["items"][1]["success"] is False
|
||||
|
||||
|
||||
def test_manual_transfer_music_files_share_one_album_batch(monkeypatch):
|
||||
"""多选音轨应一次进入整理链,避免按单曲丢失专辑分类上下文。"""
|
||||
selected_fileitems = [
|
||||
{
|
||||
"storage": "local",
|
||||
"path": f"/downloads/七里香/{index:02d}.flac",
|
||||
"name": f"{index:02d}.flac",
|
||||
"extension": "flac",
|
||||
"type": "file",
|
||||
}
|
||||
for index in (1, 2)
|
||||
]
|
||||
captured = []
|
||||
|
||||
class FakeTransferChain:
|
||||
def manual_transfer(self, **kwargs):
|
||||
captured.append(kwargs)
|
||||
return True, {
|
||||
"summary": {"total": 2, "success": 2, "failed": 0},
|
||||
"items": [
|
||||
{
|
||||
"source": item.path,
|
||||
"target": f"/library/Album/周杰伦/七里香/{item.name}",
|
||||
"target_dir": "/library/Album/周杰伦/七里香",
|
||||
"success": True,
|
||||
"message": "",
|
||||
"type": "音乐",
|
||||
"title": "七里香 (2004)",
|
||||
}
|
||||
for item in kwargs["selected_fileitems"]
|
||||
],
|
||||
"message": "",
|
||||
}
|
||||
|
||||
monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", FakeTransferChain)
|
||||
monkeypatch.setattr(
|
||||
"app.api.endpoints.transfer.get_api_runtime_config_snapshot",
|
||||
lambda: SimpleNamespace(audio_extensions=(".flac",)),
|
||||
)
|
||||
|
||||
resp = manual_transfer(
|
||||
transer_item=ManualTransferItem(
|
||||
fileitems=selected_fileitems,
|
||||
preview=True,
|
||||
type_name="自动",
|
||||
),
|
||||
background=False,
|
||||
history_query=SimpleNamespace(get=lambda _history_id: None),
|
||||
_="token",
|
||||
)
|
||||
|
||||
assert resp.success is True
|
||||
assert resp.data["summary"] == {"total": 2, "success": 2, "failed": 0}
|
||||
assert len(captured) == 1
|
||||
assert captured[0]["mtype"].value == "音乐"
|
||||
assert captured[0]["music_type"] == "album"
|
||||
assert [item.path for item in captured[0]["selected_fileitems"]] == [
|
||||
item["path"] for item in selected_fileitems
|
||||
]
|
||||
|
||||
|
||||
def test_manual_transfer_music_batch_ignores_non_disc_alternate_directory(monkeypatch):
|
||||
"""根目录已有完整专辑时,不得把“附加原版”等任意子目录当成第二张碟。"""
|
||||
selected_fileitems = [
|
||||
{
|
||||
"storage": "local",
|
||||
"path": path,
|
||||
"name": path.rsplit("/", 1)[-1],
|
||||
"extension": "flac",
|
||||
"type": "file",
|
||||
}
|
||||
for path in (
|
||||
"/downloads/七里香/01.flac",
|
||||
"/downloads/七里香/02.flac",
|
||||
"/downloads/七里香/附加原版/01.flac",
|
||||
"/downloads/七里香/附加原版/02.flac",
|
||||
)
|
||||
]
|
||||
captured = []
|
||||
|
||||
class FakeTransferChain:
|
||||
def manual_transfer(self, **kwargs):
|
||||
captured.append(kwargs)
|
||||
return True, {
|
||||
"summary": {"total": 2, "success": 2, "failed": 0},
|
||||
"items": [],
|
||||
"message": "",
|
||||
}
|
||||
|
||||
monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", FakeTransferChain)
|
||||
monkeypatch.setattr(
|
||||
"app.api.endpoints.transfer.get_api_runtime_config_snapshot",
|
||||
lambda: SimpleNamespace(audio_extensions=(".flac",)),
|
||||
)
|
||||
|
||||
response = manual_transfer(
|
||||
transer_item=ManualTransferItem(
|
||||
fileitems=selected_fileitems,
|
||||
preview=True,
|
||||
type_name="自动",
|
||||
),
|
||||
background=False,
|
||||
history_query=SimpleNamespace(get=lambda _history_id: None),
|
||||
_="token",
|
||||
)
|
||||
|
||||
assert response.success is True
|
||||
assert len(captured) == 1
|
||||
assert [item.path for item in captured[0]["selected_fileitems"]] == [
|
||||
"/downloads/七里香/01.flac",
|
||||
"/downloads/七里香/02.flac",
|
||||
]
|
||||
|
||||
|
||||
def test_manual_transfer_history_ids_share_one_music_album_batch(monkeypatch):
|
||||
"""多选整理历史应先还原文件集合,再以一个专辑批次进入整理链。"""
|
||||
paths = (
|
||||
"/downloads/七里香/01.flac",
|
||||
"/downloads/七里香/02.flac",
|
||||
"/downloads/七里香/附加原版/01.flac",
|
||||
"/downloads/七里香/附加原版/02.flac",
|
||||
)
|
||||
histories = {
|
||||
index: SimpleNamespace(
|
||||
status=1,
|
||||
mode="copy",
|
||||
src_fileitem={
|
||||
"storage": "local",
|
||||
"path": path,
|
||||
"name": path.rsplit("/", 1)[-1],
|
||||
"extension": "flac",
|
||||
"type": "file",
|
||||
},
|
||||
dest_fileitem=None,
|
||||
)
|
||||
for index, path in enumerate(paths, start=41)
|
||||
}
|
||||
captured = []
|
||||
|
||||
class FakeTransferChain:
|
||||
def manual_transfer(self, **kwargs):
|
||||
captured.append(kwargs)
|
||||
selected = kwargs["selected_fileitems"]
|
||||
return True, {
|
||||
"summary": {"total": len(selected), "success": len(selected), "failed": 0},
|
||||
"items": [],
|
||||
"message": "",
|
||||
}
|
||||
|
||||
monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", FakeTransferChain)
|
||||
monkeypatch.setattr(
|
||||
"app.api.endpoints.transfer.get_api_runtime_config_snapshot",
|
||||
lambda: SimpleNamespace(audio_extensions=(".flac",)),
|
||||
)
|
||||
|
||||
response = manual_transfer(
|
||||
transer_item=ManualTransferItem(
|
||||
logids=list(histories),
|
||||
preview=True,
|
||||
reorganize=True,
|
||||
type_name="自动",
|
||||
),
|
||||
background=False,
|
||||
history_query=SimpleNamespace(get=histories.get),
|
||||
_="token",
|
||||
)
|
||||
|
||||
assert response.success is True
|
||||
assert len(captured) == 1
|
||||
assert captured[0]["mtype"].value == "音乐"
|
||||
assert captured[0]["music_type"] == "album"
|
||||
assert [item.path for item in captured[0]["selected_fileitems"]] == [
|
||||
"/downloads/七里香/01.flac",
|
||||
"/downloads/七里香/02.flac",
|
||||
]
|
||||
|
||||
|
||||
def test_match_manual_transfer_target_path_returns_directory_match(monkeypatch):
|
||||
captured = {}
|
||||
|
||||
|
||||
@@ -149,6 +149,116 @@ def test_selected_album_tracks_override_source_tag_names(tmp_path, monkeypatch):
|
||||
]
|
||||
|
||||
|
||||
def test_selected_music_fileitems_keep_album_batch_context(tmp_path, monkeypatch):
|
||||
"""显式多选音轨应在单个批次中应用专辑曲目和分类。"""
|
||||
album_dir = tmp_path / "周杰伦 - 七里香 (2004)"
|
||||
album_dir.mkdir()
|
||||
paths = [album_dir / "01.flac", album_dir / "02.flac"]
|
||||
for path in paths:
|
||||
path.write_bytes(b"audio")
|
||||
fileitems = [make_fileitem(path.as_posix()) for path in paths]
|
||||
local_metas = {
|
||||
paths[0]: MetaMusic(title="我的地盤", artists=["周杰倫"], track_number=1),
|
||||
paths[1]: MetaMusic(title="藉口", artists=["周杰倫"], track_number=2),
|
||||
}
|
||||
chain = _prepare_chain(monkeypatch, fileitems)
|
||||
monkeypatch.setattr(
|
||||
"app.chain.transfer.workflow.StorageChain.get_item",
|
||||
lambda _self, item: item,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"app.chain.media.album.AudioMetadataHelper.read_many",
|
||||
lambda requested: [deepcopy(local_metas[path]) for path in requested],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
MediaChain,
|
||||
"read_path_meta",
|
||||
staticmethod(lambda path: deepcopy(local_metas[path])),
|
||||
)
|
||||
planned = []
|
||||
|
||||
def handle_transfer(task, callback=None):
|
||||
del callback
|
||||
planned.append((task.meta.title, task.mediainfo.library_category))
|
||||
return True, ""
|
||||
|
||||
monkeypatch.setattr(chain, "_TransferChain__handle_transfer", handle_transfer)
|
||||
|
||||
state, message = TransferChain._execute_transfer(
|
||||
chain,
|
||||
fileitem=fileitems[0],
|
||||
selected_fileitems=fileitems,
|
||||
mediainfo=_album(),
|
||||
mtype=MediaType.MUSIC,
|
||||
media_source=MediaSource.MusicBrainz,
|
||||
media_id="release-group-1",
|
||||
background=False,
|
||||
)
|
||||
|
||||
assert state is True
|
||||
assert message == ""
|
||||
assert planned == [("我的地盘", "Album"), ("借口", "Album")]
|
||||
|
||||
|
||||
def test_automatic_music_fileitems_receive_album_identity_and_category(tmp_path, monkeypatch):
|
||||
"""自动多选音轨应用目录级专辑识别结果覆盖本地繁体标签。"""
|
||||
album_dir = tmp_path / "周杰伦 - 七里香 (2004)"
|
||||
album_dir.mkdir()
|
||||
paths = [album_dir / "01.flac", album_dir / "02.flac"]
|
||||
for path in paths:
|
||||
path.write_bytes(b"audio")
|
||||
fileitems = [make_fileitem(path.as_posix()) for path in paths]
|
||||
local_metas = {
|
||||
paths[0]: MetaMusic(title="我的地盤", artists=["周杰倫"], track_number=1),
|
||||
paths[1]: MetaMusic(title="藉口", artists=["周杰倫"], track_number=2),
|
||||
}
|
||||
album = _album()
|
||||
matched = {}
|
||||
for path, track in zip(paths, album.tracks):
|
||||
matched_track = deepcopy(track)
|
||||
matched_track.set_library_category(album.library_category)
|
||||
matched_track.classification = deepcopy(album.classification)
|
||||
matched[str(path.resolve())] = matched_track
|
||||
chain = _prepare_chain(monkeypatch, fileitems)
|
||||
monkeypatch.setattr(
|
||||
"app.chain.transfer.workflow.StorageChain.get_item",
|
||||
lambda _self, item: item,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
MediaChain,
|
||||
"read_path_meta",
|
||||
staticmethod(lambda path: deepcopy(local_metas[path])),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
MediaChain,
|
||||
"recognize_music_album_directory",
|
||||
lambda _self, _path, **_kwargs: matched,
|
||||
)
|
||||
planned = []
|
||||
|
||||
def handle_transfer(task, callback=None):
|
||||
del callback
|
||||
planned.append((task.meta.title, task.mediainfo.album, task.mediainfo.library_category))
|
||||
return True, ""
|
||||
|
||||
monkeypatch.setattr(chain, "_TransferChain__handle_transfer", handle_transfer)
|
||||
|
||||
state, message = TransferChain._execute_transfer(
|
||||
chain,
|
||||
fileitem=fileitems[0],
|
||||
selected_fileitems=fileitems,
|
||||
mtype=MediaType.MUSIC,
|
||||
background=False,
|
||||
)
|
||||
|
||||
assert state is True
|
||||
assert message == ""
|
||||
assert planned == [
|
||||
("我的地盘", "七里香", "Album"),
|
||||
("借口", "七里香", "Album"),
|
||||
]
|
||||
|
||||
|
||||
def test_manual_album_identity_forwards_full_selected_album(monkeypatch):
|
||||
"""手动指定专辑 ID 时不应在进入整理链前丢失曲目表。"""
|
||||
chain = make_transfer_chain()
|
||||
|
||||
Reference in New Issue
Block a user