fix(transfer): reidentify selected music history batches

This commit is contained in:
yanjunpu
2026-09-08 22:17:45 +08:00
parent dbf6d5d890
commit 13f0ac4faa
13 changed files with 432 additions and 32 deletions

View File

@@ -7,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,
@@ -588,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,
)
@@ -599,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
@@ -726,7 +767,7 @@ 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
)

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

View File

@@ -320,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]]:
"""从下载历史恢复音乐上下文,并用当前音频标签覆盖曲目级字段。
@@ -327,6 +328,8 @@ class FileFilterMixin(_TransferOwnerBase):
沿用已选媒体,不根据专辑名或文件曲名在单曲与专辑之间转换。
多音轨批次误带单曲身份时只保留文件自身标签,避免把同一 recording
身份传播到整张专辑;调用方随后可使用目录级证据重新匹配专辑。
手动选中多条历史且未要求复用历史身份时,专辑身份也必须丢弃,确保
目录级识别能够重新补齐发行版、分类和规范名称。
"""
note = getattr(download_history, "note", None)
music_note = note.get("music") if isinstance(note, dict) else None
@@ -339,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
)

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
@@ -58,14 +58,20 @@ class TransferHistoryOwner(_TransferOwnerBase):
self,
transfer_kwargs: dict[str, Any],
selected_fileitems: Optional[list[FileItem]],
) -> Tuple[bool, Union[str, dict]]:
) -> Tuple[bool, Union[str, dict[str, Any]]]:
"""显式文件批次走内部入口,普通请求继续保持公开签名兼容。"""
if selected_fileitems is not None:
return self._execute_transfer(
**transfer_kwargs,
selected_fileitems=selected_fileitems,
return cast(
Tuple[bool, Union[str, dict[str, Any]]],
self._execute_transfer(
**transfer_kwargs,
selected_fileitems=selected_fileitems,
),
)
return self.do_transfer(**transfer_kwargs)
return cast(
Tuple[bool, Union[str, dict[str, Any]]],
self.do_transfer(**transfer_kwargs),
)
def remote_transfer(
self,

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:
"""持有一次整理请求的只读候选规划上下文。"""

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,
)
@@ -789,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,
@@ -800,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:
@@ -835,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
@@ -956,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,

View File

@@ -754,8 +754,8 @@ flowchart LR
| 指标 | 当前值 |
|---|---:|
| Python 模块 | 981 |
| 内部导入边 | 8,315 |
| Python 模块 | 982 |
| 内部导入边 | 8,324 |
| 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) |
| Application / Chain 具体 Adapter 直连 | 0 / 0 |
| Direct egress | 53(债务已清零,53 条精确 containment) |

View File

@@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁
| 指标 | 当前值 | 解释 |
|---|---:|---|
| 宿主 Python 模块 / 内部依赖边 | 981 / 8,315 | `dependency-baseline.json` 当前快照;分类离线词表新增一个纯领域模块及其受控依赖 |
| 宿主 Python 模块 / 内部依赖边 | 982 / 8,324 | `dependency-baseline.json` 当前快照;手动整理历史批次新增一个 HTTP 适配辅助模块及其受控下行依赖 |
| 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 |
| 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 |
| Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 |
@@ -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 热点文件

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 结算写入;响应仅返回任务、操作、决策、后续状态和复核修订号 |

View File

@@ -1074,8 +1074,8 @@
"runtime_only": true
}
},
"edge_count": 8315,
"edge_sha256": "798e408eb7130ed507124b1b775aee3fa04e592beb8c01f66394a264e8d37242",
"edge_count": 8324,
"edge_sha256": "74533a654108e22c91d7ff8ec8b004cc01945e89ccddfb84982b098d9d7a6d0c",
"edges": [
"app -> app.foundation",
"app -> app.foundation.environment",
@@ -2857,6 +2857,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",
@@ -2880,6 +2882,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",
@@ -9393,7 +9402,7 @@
"app.workflow.actions.transfer_file -> app.workflow",
"app.workflow.actions.transfer_file -> app.workflow.actions"
],
"module_count": 981,
"module_count": 982,
"modules": [
"app",
"app.adapters",
@@ -9578,6 +9587,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",

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
},

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"

View File

@@ -1,6 +1,5 @@
from types import SimpleNamespace
from app.api.endpoints.transfer import (
manual_transfer,
match_manual_transfer_target_path,
@@ -543,6 +542,69 @@ def test_manual_transfer_music_batch_ignores_non_disc_alternate_directory(monkey
]
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 = {}