fix(transfer): suppress completion events without target items

This commit is contained in:
jxxghp
2026-10-08 13:42:08 +08:00
parent cf889ba7a0
commit 11303a8e88
3 changed files with 108 additions and 2 deletions

View File

@@ -345,7 +345,7 @@ class TransferSettlementOwner(_TransferOwnerBase):
self, task: TransferTask, transferinfo: TransferInfo, /
) -> Tuple[bool, str]:
"""
整理完成后处理
原子结算整理结果;成功但无目标项的静默跳过不发布完成事件。
"""
# 状态
ret_status = True
@@ -504,7 +504,12 @@ class TransferSettlementOwner(_TransferOwnerBase):
# 转移成功
logger.info(f"{task.fileitem.name} 入库成功:{target_dir_path or ''}")
durable_event = self._durable_transfer_event(task, success=True)
# 静默跳过沿用成功终态,但没有目标文件可供完成事件的消费者处理。
durable_event = (
self._durable_transfer_event(task, success=True)
if transferinfo.target_item is not None
else None
)
topic = durable_event[0] if durable_event else None
event_type = durable_event[1] if durable_event else None
event_payload = self._transfer_result_payload(task, transferinfo)

View File

@@ -58,6 +58,10 @@ Event Contract Registry 是 53 个事件的逐项机器清单。下表按相同
`AudioTransferComplete`、`AudioTransferFailed`:整理步骤本身属于 E3,
但历史行提交后向事件消费者发布结果属于 E2,使用同一 JSON 快照与恢复 handler。
宿主仅在整理成功且 `transferinfo.target_item` 非空时登记媒体、字幕或音频的完成事件。
静默跳过即使返回 `success=True`,也不产生完成事件的 outbox intent 或广播;任务终态与历史
仍按原成功路径结算。失败结果继续登记对应失败事件,无需目标项。
## 非 Event 后台机制映射
### FastAPI BackgroundTasks

View File

@@ -0,0 +1,97 @@
"""整理完成事件只对应实际目标文件,静默跳过仍正常结算。"""
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from app.schemas.transfer import TransferInfo
from app.schemas.types import EventType
from tests.test_transfer_job_manager import (
FakeMedia,
bind_terminal_checkpoint,
make_fileitem,
make_task,
make_transfer_chain,
)
@pytest.mark.parametrize(
("extension", "completed_topic", "completed_event", "failed_topic", "failed_event"),
[
("mkv", "transfer.completed", EventType.TransferComplete,
"transfer.failed", EventType.TransferFailed),
("srt", "transfer.subtitle.completed", EventType.SubtitleTransferComplete,
"transfer.subtitle.failed", EventType.SubtitleTransferFailed),
("flac", "transfer.audio.completed", EventType.AudioTransferComplete,
"transfer.audio.failed", EventType.AudioTransferFailed),
],
)
@pytest.mark.parametrize("outcome", ["skipped", "without_target", "completed", "failed"])
def test_transfer_result_events_require_completed_target(
extension: str,
completed_topic: str,
completed_event: EventType,
failed_topic: str,
failed_event: EventType,
outcome: str,
) -> None:
"""视频、字幕和音频跳过不发完成事件,正常完成和失败保留原事件与终态。"""
chain = make_transfer_chain()
chain.eventmanager = MagicMock()
chain.queue_failed_transfer_notification = MagicMock()
chain._TransferChain__mark_torrent_completed_if_done = MagicMock()
chain._record_scrape_target = MagicMock()
chain._send_metadata_scrape_event = MagicMock()
task = make_task(1)
task.fileitem = make_fileitem(f"/downloads/Test.Show.S01E01.{extension}")
task.mediainfo = FakeMedia()
assert chain._TransferChain__put_to_jobview(task)
transferinfo = TransferInfo(
success=outcome != "failed",
message="综艺非正片已全部跳过(1 个文件)" if outcome == "skipped" else None,
fileitem=task.fileitem,
target_item=(
make_fileitem(f"/library/Test.Show.S01E01.{extension}")
if outcome == "completed"
else None
),
transfer_type="copy",
need_notify=False,
)
bind_terminal_checkpoint(task, transferinfo)
with patch(
"app.chain.transfer.settlement.add_transfer_success",
return_value=SimpleNamespace(id=1),
) as add_success, patch(
"app.chain.transfer.settlement.add_transfer_fail",
return_value=SimpleNamespace(id=1),
) as add_fail, patch(
"app.chain.transfer.settlement.clear_transfer_failures",
), patch(
"app.chain.transfer.settlement.record_transfer_failure",
):
state, _message = chain._TransferChain__default_callback(task, transferinfo)
assert state is (outcome != "failed")
chain.durable_event_writer.transfer_result.assert_called_once()
call = chain.durable_event_writer.transfer_result.call_args.kwargs
assert call["settlement"].outcome == ("failed" if outcome == "failed" else "succeeded")
assert task.terminal_settled is True
if outcome == "failed":
add_fail.assert_called_once()
add_success.assert_not_called()
assert call["topic"] == failed_topic
assert chain.eventmanager.send_event.call_args.args[0] is failed_event
else:
add_success.assert_called_once()
add_fail.assert_not_called()
chain.queue_failed_transfer_notification.assert_not_called()
if outcome in ("skipped", "without_target"):
assert call["topic"] is None
chain.eventmanager.send_event.assert_not_called()
else:
assert call["topic"] == completed_topic
event_type, payload = chain.eventmanager.send_event.call_args.args
assert event_type is completed_event
assert payload["transferinfo"].target_item == transferinfo.target_item