mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-11-03 13:55:00 +08:00
fix(transfer): reidentify selected music history batches
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
66
app/api/endpoints/transferhistory.py
Normal file
66
app/api/endpoints/transferhistory.py
Normal 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
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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 热点文件
|
||||
|
||||
@@ -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 结算写入;响应仅返回任务、操作、决策、后续状态和复核修订号 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
@@ -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 = {}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user