Files
MoviePilot/tests/test_transfer_success_notification_aggregation.py

547 lines
24 KiB
Python

"""
成功通知按明确批次或静默窗口聚合,且不改变逐文件结算副作用。
"""
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from app.application.configuration import ChainRuntimeConfig
from app.application.transfer.execution import TransferExecutionCheckpoint, TransferSettlementResult
from app.application.transfer.jobs import JobManager
from app.application.transfer.models import TransferTask
from app.application.transfer.notifications import (
TransferNotificationAggregator,
TransferSuccessNotification,
build_transfer_success_group_key,
)
from app.chain.transfer.facade import TransferChain
from app.domain.context import MediaInfo, MusicInfo
from app.domain.meta.metamusic import MetaMusic
from app.domain.metainfo import MetaInfo
from app.runtime.config import ConfigModel
from app.runtime.loop import main_loop_registry
from app.schemas.file import FileItem
from app.schemas.transfer import TransferInfo
from app.schemas.types import ContentType, EventType, MediaType, MessageType, SystemConfigKey
from tests.test_manual_transfer_history import _patch_transfer_planning
from tests.test_transfer_sync_extra_files import make_transfer_chain
class _Loop:
"""
记录静默窗口而不创建真实后台任务。
"""
def __init__(self):
"""
初始化可手动触发的定时回调。
"""
self.timers = []
self.now = 0
def call_soon_threadsafe(self, callback, *args):
"""
同步登记事件循环回调。
"""
callback(*args)
def call_later(self, delay, callback, *args):
"""
保存定时器并提供可取消的句柄。
"""
timer = SimpleNamespace(callback=callback, args=args, cancel=Mock(), deadline=self.now + delay)
self.timers.append(timer)
return timer
def advance(self, seconds):
"""
推进虚拟时间,只触发已到期且未取消的定时器。
"""
self.now += seconds
for timer in list(self.timers):
if timer.deadline <= self.now and not timer.cancel.called:
timer.callback(*timer.args)
def flush(self):
"""
按登记顺序触发定时器,包含已取消回调以验证代际保护。
"""
for timer in self.timers:
timer.callback(*timer.args)
def _task(number=1, *, batch="batch", username="tester", download_hash="hash"):
"""
构造同一专辑内具有不同录音身份的分轨整理任务。
"""
return TransferTask(
fileitem=FileItem(storage="local", path=f"/downloads/Album/{number}.flac", name=f"{number}.flac", extension="flac", type="file", size=100),
meta=MetaMusic(title=f"Track {number}", album="Album", track_number=number),
mediainfo=MusicInfo(title=f"Track {number}", album="Album", artists=["Artist"], year=2013,
media_source="musicbrainz", media_id=f"recording-{number}", total_tracks=74),
target_path=Path("/library/Album"), transfer_batch_id=batch,
download_hash=download_hash, username=username, background=True,
)
def _result(number=1):
"""
提供包含真实目标路径的成功结果。
"""
path = f"/library/Album/{number}.flac"
return TransferInfo(
target_item=FileItem(storage="local", path=path, type="file"),
target_diritem=FileItem(storage="local", path="/library/Album", type="dir"),
file_list_new=[path], file_count=1, total_size=100, need_notify=True,
)
@pytest.fixture
def chain():
"""
构造没有线程、数据库和真实消息通道的通知宿主。
"""
owner = object.__new__(TransferChain)
owner.runtime_config = ChainRuntimeConfig(media_extensions=(".flac", ".mkv"), history_url="https://example.test/history")
owner.success_notification_aggregator = TransferNotificationAggregator[TransferSuccessNotification]()
owner.post_message = Mock()
owner._scrape_batches = {}
owner._audio_exts = [".flac"]
owner._media_exts = [".mkv"]
owner._subtitle_exts = [".srt"]
owner.eventmanager = Mock()
yield owner
owner.success_notification_aggregator.close()
def test_success_notification_aggregation_defaults_on():
"""
成功侧默认值与失败侧一致。
"""
assert ConfigModel.model_fields["TRANSFER_SUCCESS_NOTIFICATION_AGGREGATION"].default is True
@pytest.mark.parametrize("change", ["username", "download_hash", "downloader", "destination", "storage"])
def test_success_group_isolates_source_destination_and_user(change):
"""
不同用户、种子、下载器、目标或存储不能混入同一通知。
"""
first, second = _task(), _task(2)
result = _result()
modified = _result(2)
if change == "destination":
modified.target_diritem.path = "/library/Other Album"
elif change == "storage":
modified.target_diritem.storage = "smb"
else:
setattr(second, change, "other")
assert build_transfer_success_group_key(first, result) != build_transfer_success_group_key(second, modified)
def test_all_74_slow_tracks_wait_for_batch_and_send_once(chain):
"""
曲目间隔超过静默窗也不拆批,统计真实文件并以专辑名发送。
"""
tasks = [_task(number) for number in range(1, 75)]
for task in tasks:
chain._register_scrape_batch_task(task)
chain._close_scrape_batch("batch")
for number, task in enumerate(tasks, start=1):
result = _result(number)
chain.queue_successful_transfer_notification(task, result)
# 模拟旧通知计数在同一结果对象上被改写。
result.file_count = 999
chain._finish_scrape_batch_task(task)
assert chain.post_message.call_count == (1 if number == 74 else 0)
message = chain.post_message.call_args.args[0]
assert message.title is None and message.text is None
assert chain.post_message.call_args.kwargs["mediainfo"].album == "Album"
assert chain.post_message.call_args.kwargs["mediainfo"].total_tracks == 74
assert chain.post_message.call_args.kwargs["transferinfo"].file_count == 74
assert message.buttons == [[{"text": "查看整理记录", "url": "https://example.test/history"}]]
assert message.ctype == ContentType.OrganizeSuccess
assert message.mtype == MessageType.Organize
assert chain.post_message.call_args.kwargs["transferinfo"].total_size == 7400
assert not chain.eventmanager.send_event.called
def test_single_file_batch_is_immediate_and_keeps_template(chain):
"""
一个文件的明确批次结束即发,保留用户模板和无批量按钮行为。
"""
task = _task()
chain._register_scrape_batch_task(task)
chain._close_scrape_batch("batch")
chain.queue_successful_transfer_notification(task, _result())
chain._finish_scrape_batch_task(task)
message = chain.post_message.call_args.args[0]
assert message.title is None and message.text is None
assert message.buttons is None
assert chain.post_message.call_count == 1
def test_closed_batch_is_required_even_if_work_finishes_while_enumerating(chain):
"""
目录枚举期间先完成的曲目要等登记结束,防止过早发送。
"""
task = _task()
chain._register_scrape_batch_task(task)
chain.queue_successful_transfer_notification(task, _result())
chain._finish_scrape_batch_task(task)
chain.post_message.assert_not_called()
chain._close_scrape_batch("batch")
chain.post_message.assert_called_once()
def test_unbatched_events_use_quiet_window_and_deduplicate_paths(chain, monkeypatch):
"""
没有明确批次的外部任务使用静默窗,相同目标不会重复累计。
"""
loop = _Loop()
monkeypatch.setattr(main_loop_registry, "require", lambda: loop)
for number in [1, 2, 1]:
chain.queue_successful_transfer_notification(_task(number, batch=None), _result(number))
chain.post_message.assert_not_called()
loop.flush()
chain.post_message.assert_called_once()
assert chain.post_message.call_args.kwargs["transferinfo"].file_count == 2
assert chain.post_message.call_args.kwargs["transferinfo"].total_size == 200
@pytest.mark.parametrize("manual,source_type", [(False, "file"), (True, "file"), (False, "dir")])
def test_single_file_workflow_notification_policy(chain, monkeypatch, manual, source_type):
"""
真实工作流仅为自动文件入口启用静默窗,目录与手动单文件仍在批次结束即发。
"""
owner = make_transfer_chain()
owner.runtime_config = chain.runtime_config
owner.success_notification_aggregator = chain.success_notification_aggregator
owner.post_message = chain.post_message
task = _task()
fileitem = FileItem(storage="local", path="/downloads/Show/S01E01.mkv", name="S01E01.mkv",
extension="mkv", type="file")
_patch_transfer_planning(monkeypatch, owner, fileitem, None, [], [])
owner._register_scrape_batch_task = TransferChain._register_scrape_batch_task.__get__(owner)
owner._close_scrape_batch = TransferChain._close_scrape_batch.__get__(owner)
queued = []
def enqueue(task):
"""
保留真实批次登记,只隔离后台线程与文件执行。
"""
owner._register_scrape_batch_task(task)
queued.append(task)
return True
owner.put_to_queue = enqueue
loop = _Loop()
monkeypatch.setattr(type(main_loop_registry), "current", property(lambda _self: loop))
source = fileitem if source_type == "file" else FileItem(storage="local", path="/downloads/Album/", type="dir")
success, _ = owner.do_transfer(fileitem=source, manual=manual, background=True)
assert success and len(queued) == 1
queued[0].meta, queued[0].mediainfo = task.meta, task.mediainfo
owner.notify_successful_transfer(queued[0], _result())
owner._finish_scrape_batch_task(queued[0])
if not manual and source_type == "file":
owner.post_message.assert_not_called()
loop.advance(30)
owner.post_message.assert_called_once()
assert not owner._scrape_batches
def test_monitor_episode_batches_merge_after_last_completion(chain, monkeypatch):
"""
13 个间隔十秒的单文件批次只在末集静默三十秒后发一条季集汇总。
"""
loop = _Loop()
monkeypatch.setattr(type(main_loop_registry), "current", property(lambda _self: loop))
batch_ids = []
for number in range(1, 14):
loop.advance(10)
batch_id = f"monitor-{number}"
batch_ids.append(batch_id)
task = _task(number, batch=batch_id, download_hash=None)
task.meta = MetaInfo(f"Show S01E{number:02d}")
task.mediainfo = MediaInfo(title="Show", year="2026", type=MediaType.TV)
chain._register_scrape_batch_task(task)
chain._scrape_batches[batch_id]["notification_debounce"] = True
chain._close_scrape_batch(batch_id)
chain.notify_successful_transfer(task, _result(number))
chain._finish_scrape_batch_task(task)
chain.post_message.assert_not_called()
assert len(set(batch_ids)) == 13
loop.advance(29)
chain.post_message.assert_not_called()
loop.advance(1)
chain.post_message.assert_called_once()
context = chain.post_message.call_args.kwargs
assert context["season_episode"] == "S01 E01-E13"
assert context["transferinfo"].file_count == 13
assert context["transferinfo"].total_size == 1300
assert chain.post_message.call_args.args[0].title is None
assert not chain._scrape_batches
@pytest.mark.parametrize("monitor_first", [False, True])
def test_monitor_window_survives_overlapping_explicit_batch(monitor_first):
"""
重叠批次两种完成顺序都保留静默窗,旧定时器不得越过新批次等待。
"""
loop = _Loop()
aggregator = TransferNotificationAggregator[str]()
callback = Mock()
aggregator.schedule(group_key="group", notification="monitor", callback=callback, defer_until="monitor")
aggregator.release_batch("monitor", loop=loop)
old_timer = loop.timers[-1]
aggregator.schedule(group_key="group", notification="batch", callback=callback, defer_until="batch")
aggregator.schedule(group_key="group", notification="monitor2", callback=callback, defer_until="monitor2")
old_timer.callback(*old_timer.args)
if monitor_first:
aggregator.release_batch("monitor2", loop=loop)
else:
aggregator.release_batch("batch")
loop.advance(60)
callback.assert_not_called()
if monitor_first:
aggregator.release_batch("batch")
else:
aggregator.release_batch("monitor2", loop=loop)
loop.advance(29)
aggregator.release_batch("unrelated")
aggregator.release_batch("monitor2", loop=loop)
callback.assert_not_called()
loop.advance(1)
callback.assert_called_once_with(["monitor", "batch", "monitor2"])
assert not aggregator._debounce_loops
def test_monitor_timer_failure_falls_back_without_losing_notification():
"""
批次释放时循环停止要立即发送,关闭时不再重复发送。
"""
loop = Mock()
loop.call_soon_threadsafe.side_effect = RuntimeError("loop closed")
aggregator = TransferNotificationAggregator[str]()
callback = Mock()
aggregator.schedule(group_key="group", notification="monitor", callback=callback, defer_until="monitor")
aggregator.release_batch("monitor", loop=loop)
aggregator.close()
callback.assert_called_once_with(["monitor"])
assert not aggregator._debounce_loops
def test_overlapping_batches_wait_for_both_and_shutdown_flushes_once(chain):
"""
同组重叠批次不会互相提前刷新;关闭发送剩余快照且重复关闭幂等。
"""
for number, batch in [(1, "a"), (2, "b")]:
chain.queue_successful_transfer_notification(_task(number, batch=batch), _result(number))
chain.success_notification_aggregator.release_batch("a")
chain.post_message.assert_not_called()
chain.success_notification_aggregator.close()
chain.success_notification_aggregator.close()
chain.success_notification_aggregator.release_batch("b")
chain.post_message.assert_called_once()
assert chain.post_message.call_args.kwargs["transferinfo"].file_count == 2
@pytest.mark.parametrize("notify,manual,background", [(False, False, True), (True, True, False)])
def test_disabled_or_foreground_manual_notifications_remain_silent(chain, notify, manual, background):
"""
关闭通知及前台手动整理不进入缓冲。
"""
task = _task()
task.manual, task.background = manual, background
result = _result()
result.need_notify = notify
chain.notify_successful_transfer(task, result)
chain.success_notification_aggregator.close()
chain.post_message.assert_not_called()
@pytest.mark.parametrize("media_type", [MediaType.MOVIE, MediaType.TV, MediaType.MUSIC])
def test_global_disabled_keeps_legacy_notifications(chain, media_type):
"""
全局关闭时成功任务不进入新的缓冲,由原有作业通知负责发送。
"""
chain.runtime_config = replace(chain.runtime_config, transfer_success_notification_aggregation=False)
task = _task()
if media_type != MediaType.MUSIC:
task.meta = MetaInfo("Movie 2026" if media_type == MediaType.MOVIE else "Show S01E01")
task.mediainfo = MediaInfo(title="Movie" if media_type == MediaType.MOVIE else "Show", year="2026", type=media_type)
chain.notify_successful_transfer(task, _result())
chain.success_notification_aggregator.close()
chain.post_message.assert_not_called()
def test_movie_batch_uses_same_success_aggregation(chain):
"""
电影同样在批次结束后汇总成功文件,并保持入库消息类型。
"""
for number in [1, 2]:
task = _task(number)
task.meta = MetaInfo("Movie 2026")
task.mediainfo = MediaInfo(title="Movie", year="2026", type=MediaType.MOVIE)
chain.notify_successful_transfer(task, _result(number))
chain.post_message.assert_not_called()
chain.success_notification_aggregator.release_batch("batch")
message = chain.post_message.call_args.args[0]
assert message.title is None and message.text is None
assert chain.post_message.call_args.kwargs["mediainfo"].title == "Movie"
assert chain.post_message.call_args.kwargs["transferinfo"].file_count == 2
assert message.ctype == ContentType.OrganizeSuccess
assert chain.post_message.call_count == 1
def test_television_batch_retains_season_episode_range(chain):
"""
影视汇总保留季集范围,不把不同季混合。
"""
for number in [1, 2]:
task = _task(number)
task.meta = MetaInfo(f"Show S01E{number:02d}")
task.mediainfo = MediaInfo(title="Show", year="2026", type=MediaType.TV)
chain.queue_successful_transfer_notification(task, _result(number))
chain.success_notification_aggregator.release_batch("batch")
message = chain.post_message.call_args.args[0]
assert message.title is None and message.text is None
assert chain.post_message.call_args.kwargs["season_episode"] == "S01 E01-E02"
def test_mixed_outcomes_preserve_each_history_event_and_only_count_success(chain, monkeypatch):
"""
批次成败混合时逐任务持久化和事件不变,成功汇总只计已入库文件。
"""
chain.jobview = JobManager()
chain._success_target_files = {}
chain.transfer_history_repository = Mock()
chain.queue_failed_transfer_notification = Mock()
chain._TransferChain__mark_torrent_completed_if_done = Mock()
chain._record_scrape_target = Mock()
chain._send_metadata_scrape_event = Mock()
chain.durable_event_writer = Mock()
chain.durable_event_writer.transfer_result.return_value = TransferSettlementResult(
history_id=1, settlement_revision=1, pending_deleted=True,
)
monkeypatch.setattr("app.chain.transfer.settlement.record_transfer_failure", Mock())
monkeypatch.setattr("app.chain.transfer.settlement.clear_transfer_failures", Mock())
tasks = [_task(number) for number in [1, 2, 3]]
for task in tasks:
chain.jobview.add_task(task)
chain._register_scrape_batch_task(task)
chain._close_scrape_batch("batch")
for number, task in enumerate(tasks, start=1):
result = _result(number)
if number == 3:
result.success = False
result.message = "测试失败"
task.bind_admission_task_id(f"task-{number}")
task.bind_execution_lease(owner_id="worker", lease_token=f"lease-{number}")
task.bind_execution_checkpoint(TransferExecutionCheckpoint.create(
payload={"outcome": "succeeded" if result.success else "failed", "transferinfo": result.model_dump(mode="json")},
operation_ids=(f"operation-{number}",),
))
chain._TransferChain__default_callback(task, result)
chain._finish_scrape_batch_task(task)
assert chain.durable_event_writer.transfer_result.call_count == 3
assert chain.queue_failed_transfer_notification.call_count == 1
assert chain._record_scrape_target.call_count == 2
assert chain._TransferChain__mark_torrent_completed_if_done.call_count == 3
assert chain.post_message.call_args.kwargs["transferinfo"].file_count == 2
assert chain.post_message.call_count == 1
calls = chain.durable_event_writer.transfer_result.call_args_list
assert [call.kwargs["topic"] for call in calls] == ["transfer.completed", "transfer.completed", "transfer.audio.failed"]
for call in calls:
call.kwargs["publish"]({"test": True})
assert [call.args[0] for call in chain.eventmanager.send_event.call_args_list] == [
EventType.TransferComplete, EventType.TransferComplete, EventType.AudioTransferFailed,
]
def test_old_callback_cannot_flush_recreated_group():
"""
已刷新的组重新创建后,旧代回调不得发送新组快照。
"""
loop = _Loop()
aggregator = TransferNotificationAggregator[str]()
callback = Mock()
aggregator.schedule(group_key="group", notification="old", callback=callback, loop=loop)
old_timer = loop.timers[0]
old_timer.callback(*old_timer.args)
aggregator.schedule(group_key="group", notification="new", callback=callback, loop=loop)
old_timer.callback(*old_timer.args)
assert callback.call_args_list == [((['old'],), {})]
loop.timers[1].callback(*loop.timers[1].args)
assert callback.call_count == 2
callback.assert_called_with(["new"])
@pytest.mark.parametrize("media_type", [MediaType.MOVIE, MediaType.TV, MediaType.MUSIC])
@pytest.mark.parametrize("file_count", [1, 3])
@pytest.mark.parametrize("debounce", [False, True])
@pytest.mark.parametrize("history_url", [None, "", "https://example.test/history"])
def test_success_notifications_render_configured_template(chain, monkeypatch, media_type, file_count, debounce, history_url):
"""
单文件与汇总通知经真实入口渲染,仅在配置域名时提供整理记录按钮。
"""
chain.runtime_config = replace(chain.runtime_config, history_url=history_url)
template = """{
'title': '自定义 {{ title_year }}{% if track_number %} #{{ track_number }}{% endif %}'
'{% if season_episode %} {{ season_episode }}{% endif %}',
'text': '{{ type }}|文件={{ file_count }}|大小={{ total_size }}'
'{% if total_tracks %}|总曲目={{ total_tracks }}{% endif %}'
'|目录={{ __transferinfo__.target_diritem.path }}'
}"""
config = Mock()
config.get.return_value = {ContentType.OrganizeSuccess.value: template}
monkeypatch.setattr("app.application.messaging.message.get_configured_system_config", lambda: config)
chain.post_message = TransferChain.post_message.__get__(chain)
chain.messageoper = Mock()
chain._dispatch_notification_steps = Mock()
loop = _Loop()
monkeypatch.setattr(type(main_loop_registry), "current", property(lambda _self: loop))
for number in range(1, file_count + 1):
task = _task(number)
if media_type != MediaType.MUSIC:
task.meta = MetaInfo("Movie 2026" if media_type == MediaType.MOVIE else f"Show S01E{number:02d}")
task.mediainfo = MediaInfo(title="Movie" if media_type == MediaType.MOVIE else "Show", year="2026", type=media_type)
chain._register_scrape_batch_task(task)
chain._scrape_batches["batch"]["notification_debounce"] = debounce
chain.notify_successful_transfer(task, _result(number))
chain._finish_scrape_batch_task(task)
chain._dispatch_notification_steps.assert_not_called()
chain._close_scrape_batch("batch")
if debounce:
chain._dispatch_notification_steps.assert_not_called()
loop.advance(30)
chain._dispatch_notification_steps.assert_called_once()
message = chain._dispatch_notification_steps.call_args.args[0]
expected_title = {
MediaType.MUSIC: "Album (2013)",
MediaType.MOVIE: "Movie (2026)",
MediaType.TV: "Show (2026) S01 E01" + ("-E03" if file_count > 1 else ""),
}[media_type]
assert message.title == f"自定义 {expected_title}"
assert message.text.startswith(f"{media_type.value}|文件={file_count}|大小={file_count * 100}.0B")
assert message.text.endswith("|目录=/library/Album")
if media_type == MediaType.MUSIC:
assert "|总曲目=74" in message.text
assert "#" not in message.title
assert message.buttons == (
[[{"text": "查看整理记录", "url": history_url}]] if file_count > 1 and history_url else None
)
assert message.ctype == ContentType.OrganizeSuccess
assert message.mtype == MessageType.Organize
assert message.link == history_url
assert message.username == "tester"
config.get.assert_called_with(SystemConfigKey.NotificationTemplates)
chain.messageoper.add.assert_called_once()