diff --git a/app/api/endpoints/transfer.py b/app/api/endpoints/transfer.py index 564249e49..50b613d43 100644 --- a/app/api/endpoints/transfer.py +++ b/app/api/endpoints/transfer.py @@ -561,8 +561,8 @@ def _execute_manual_transfer( return _SchemaResponse( success=False, message=f"整理记录不存在,ID:{transer_item.logid}" ) - # 强制转移 - force = True + # 失败历史必须经过整理链的重试/重整判定,不能绕过旧任务直接重新准入。 + force = bool(history.status) # 下载器与 Hash 是同一组下载上下文,重新识别时由当前文件路径重新匹配。 downloader = history.downloader if transer_item.from_history else None download_hash = history.download_hash if transer_item.from_history else None diff --git a/docs/mcp-api.md b/docs/mcp-api.md index 8febc1c49..bd7712560 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -278,7 +278,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` 解释;命中失败历史时自动清理旧目标和记录后重试,`reorganize=true` 时清理命中的成功历史和非移动模式旧目标后重新整理 | +| POST | `/api/v1/transfer/manual` | 手动整理;请求体可用 `media_source` + `media_id` 指定本次识别与刮削数据源;音乐请求未传 `music_type` 时,目录按 `album`、文件按 `recording` 解释;命中持久失败历史,且未指定媒体身份、未开启 `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 结算写入;响应仅返回任务、操作、决策、后续状态和复核修订号 | diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index e03e37ff7..eed08663f 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -1460,6 +1460,7 @@ Purpose: Recommend an episode-number extraction template from supplied file samp ### `transfer.file` `POST /api/v1/transfer/manual`; policy effect: `external_side_effect`. Purpose: Run MoviePilot's manual file-transfer and organization workflow. +Failed durable history, including a `logid` request, retries the frozen plan through the scheduler unless an explicit media identity or `reorganize=true` requests replanning. Explicit replanning must first validate and discard the settled failed task; a pending manual review must be resolved before retrying. - `path_params`: none - `query`: `background` (boolean|null; default `False`): Run the transfer asynchronously and return before completion. - `body`: `episode_detail` (string|null): Episode mapping details used by manual transfer.; `episode_format` (string|null): Episode-number formatting rule used by manual transfer.; `episode_group` (string|null): TMDB episode-group identifier used for alternate episode ordering.; `episode_offset` (string|null): Integer offset added to detected episode numbers.; `episode_part` (string|null): Episode part number used when one episode is split across files.; `fileitem` (FileItem-Input): One complete source storage item returned by storage.list.; `fileitems` (array|null): Additional source storage items included in the same manual transfer.; `from_history` (boolean|null; default `False`): Treat the transfer input as originating from an existing history record.; `library_category_folder` (boolean|null): Create or use a category-level folder in the target library.; `library_type_folder` (boolean|null): Create or use a media-type folder in the target library.; `logid` (integer|null): One download-history or transfer-log identifier used by manual transfer.; `logids` (array|null): Multiple download-history or transfer-log identifiers included in manual transfer.; `media_id` (string|null): Source-native media ID. Always pair it with the exact media_source returned by search.; `media_source` (MediaSource|null): Metadata source identifier. Preserve the exact value returned with media_id.; `min_filesize` (integer|null; default `0`): Minimum source file size accepted by manual transfer, in bytes.; `music_type` (string(recording,album)|null): Music identity level: recording, album, or artist where supported.; `preview` (boolean|null; default `False`): Validate and preview manual-transfer output without committing file changes.; `reorganize` (boolean|null; default `False`): Allow manual transfer to organize an item that was already processed.; `scrape` (boolean|null; default `False`): Generate metadata and images after manual transfer.; `season` (integer|null): Season number used by the media, search, subscription, or transfer operation.; `target_path` (string|null): Destination path used by manual transfer.; `target_storage` (string|null): Configured storage name receiving the manual transfer.; `transfer_type` (string|null): Manual-transfer mode, such as move, copy, link, or softlink.; `type_name` (string|null): Explicit media type name used when source IDs alone are ambiguous. diff --git a/tests/test_manual_transfer_history.py b/tests/test_manual_transfer_history.py index e4abc6a5e..754c13415 100644 --- a/tests/test_manual_transfer_history.py +++ b/tests/test_manual_transfer_history.py @@ -1,5 +1,7 @@ from types import SimpleNamespace +import pytest + from app.api.endpoints.transfer import ( manual_transfer as manual_transfer_endpoint, ) @@ -210,6 +212,86 @@ def test_history_endpoint_reorganize_uses_chain_cleanup(monkeypatch): assert captured["cleanup_dest_fileitem"] is None +@pytest.mark.parametrize("accepted", [True, False]) +@pytest.mark.parametrize("background", [True, False]) +def test_failed_history_manual_auto_uses_durable_retry(monkeypatch, accepted, background): + """历史入口选择自动识别时仍须检查失败任务,不能绕过调度器重新准入。""" + chain = make_transfer_chain() + fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv") + history = SimpleNamespace( + id=14, + transfer_task_id="transfer-task-14", + status=False, + mode="copy", + src_fileitem=fileitem.model_dump(), + dest_fileitem=None, + download_hash=None, + downloader=None, + ) + planned, deleted, retries = [], [], [] + _patch_transfer_planning(monkeypatch, chain, fileitem, history, planned, deleted) + monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", lambda: chain) + + def request_retry(record, *, requested_by): + """记录旧任务重试,模拟调度器接受或拒绝请求。""" + retries.append((record.transfer_task_id, requested_by)) + return accepted, "已提交重试" if accepted else "任务需要人工处理" + + def reject_new_admission(task): + """模拟旧任务仍占用源路径时,新规划输入必然冲突。""" + raise AssertionError(f"旧任务重试不应重新准入:{task.fileitem.path}") + + monkeypatch.setattr(chain, "_request_durable_transfer_retry", request_retry) + monkeypatch.setattr(chain, "put_to_queue", reject_new_admission) + + response = manual_transfer_endpoint( + transer_item=ManualTransferItem(logid=history.id, from_history=False), + background=background, + history_query=SimpleNamespace(get=lambda _history_id: history), + _="token", + ) + + assert response.success is accepted + assert retries == [(history.transfer_task_id, "manual_reorganize")] + assert planned == [] + assert deleted == [] + if not accepted: + assert "任务需要人工处理" in response.message + + +@pytest.mark.parametrize("status", [True, False]) +def test_manual_history_auto_preserves_legacy_retry_and_success_force(monkeypatch, status): + """旧版失败历史仍清理后重试,成功历史保留原有强制整理行为。""" + chain = make_transfer_chain() + fileitem = make_fileitem("/downloads/Test.Show.S01E01.mkv") + history = SimpleNamespace( + id=15, + status=status, + mode="copy", + src=fileitem.path, + src_storage=fileitem.storage, + src_fileitem=fileitem.model_dump(), + dest_fileitem=None, + download_hash=None, + downloader=None, + ) + planned, deleted = [], [] + _patch_transfer_planning(monkeypatch, chain, fileitem, history, planned, deleted) + monkeypatch.setattr("app.api.endpoints.transfer.TransferChain", lambda: chain) + chain.transfer_execution_repository = None + + response = manual_transfer_endpoint( + transer_item=ManualTransferItem(logid=history.id, from_history=False), + background=False, + history_query=SimpleNamespace(get=lambda _history_id: history), + _="token", + ) + + assert response.success is True + assert planned == [fileitem.path] + assert deleted == ([] if status else [("history", history.id)]) + + def test_success_history_directory_query_excludes_failed_and_siblings(): """目录历史查询应限定路径边界,并且只返回成功记录。""" transfer_history_oper = _history_repository()