fix(transfer): preserve durable retry for failed manual history

This commit is contained in:
jxxghp
2026-09-07 23:17:00 +08:00
parent d6c8c8fbe7
commit b40eda2617
4 changed files with 86 additions and 3 deletions
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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 结算写入;响应仅返回任务、操作、决策、后续状态和复核修订号 |
+1
View File
@@ -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<FileItem-Input>|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<integer>|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.
+82
View File
@@ -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()