Merge pull request #6608 from dogodefi/codex/music-album-batch-transfer

fix(music): preserve album context for manual batches
This commit is contained in:
jxxghp
2026-09-08 22:30:26 +08:00
committed by GitHub
17 changed files with 799 additions and 36 deletions
+111 -7
View File
@@ -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:
+66
View File
@@ -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
+23 -9
View File
@@ -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,
+10
View File
@@ -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],
+3
View File
@@ -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(
+5 -1
View File
@@ -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
)
+26 -3
View File
@@ -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(
+35
View File
@@ -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:
"""持有一次整理请求的只读候选规划上下文。"""
+22 -10
View File
@@ -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,
+1 -1
View File
@@ -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
View File
@@ -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 结算写入;响应仅返回任务、操作、决策、后续状态和复核修订号 |
+10
View File
@@ -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",
-3
View File
@@ -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
},
+20
View File
@@ -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 = [
+179
View File
@@ -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"
+177 -1
View File
@@ -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()