feat(transfer): aggregate failure notifications by media

This commit is contained in:
jxxghp
2026-08-22 11:46:41 +08:00
parent 2d982f08fa
commit 30ebf98fbb
4 changed files with 386 additions and 60 deletions
+92
View File
@@ -16,6 +16,7 @@ TransferJob / TransferJobTask,那两个用 app.schemas 的同名 DTO——一
import asyncio
import threading
from copy import deepcopy
from dataclasses import dataclass
from pathlib import Path
from time import monotonic
from typing import Callable, Dict, List, Optional, Tuple, Union
@@ -155,6 +156,97 @@ class TransferQueueService:
return self._list_tasks()
@dataclass(frozen=True, slots=True)
class TransferFailureNotification:
"""整理失败聚合器保存的单条通知快照。"""
media_title: str
season_episode: str
reason: str
history_id: Optional[int]
image: Optional[str]
username: Optional[str]
manual_identity: bool = False
def build_transfer_failure_group_key(task: TransferTask) -> str:
"""构造主程序和第三方整理路径可共同使用的失败通知分组键。"""
media_source, media_id = resolve_media_identity(media=task.mediainfo)
if not media_source or not media_id:
media_source, media_id = resolve_media_identity(media=task)
season = getattr(task.meta, "begin_season", None) if task.meta else None
username = task.username or ""
if media_source and media_id:
return f"media:{media_source}:{media_id}:season:{season}:user:{username}"
if task.download_hash:
return f"download:{task.download_hash}:user:{username}"
source_path = str(task.fileitem.path) if task.fileitem else ""
parent_path = str(Path(source_path).parent) if source_path else ""
return f"path:{parent_path or source_path}:user:{username}"
class TransferFailureNotificationAggregator:
"""在短暂静默窗口内按媒体合并整理失败通知。"""
NOTIFICATION_DEBOUNCE_SECONDS = 30
def __init__(self) -> None:
"""初始化分组缓冲和定时器。"""
self._buffers: dict[str, list[TransferFailureNotification]] = {}
self._timers: dict[str, asyncio.TimerHandle] = {}
def schedule(
self,
*,
group_key: str,
notification: TransferFailureNotification,
callback: Callable[[list[TransferFailureNotification]], None],
loop: asyncio.AbstractEventLoop,
) -> None:
"""从整理线程安全地把失败快照加入事件循环中的聚合缓冲。"""
loop.call_soon_threadsafe(
self._schedule_on_loop,
group_key,
notification,
callback,
loop,
)
def _schedule_on_loop(
self,
group_key: str,
notification: TransferFailureNotification,
callback: Callable[[list[TransferFailureNotification]], None],
loop: asyncio.AbstractEventLoop,
) -> None:
"""在所属事件循环中更新缓冲并重置静默窗口。"""
self._buffers.setdefault(group_key, []).append(notification)
timer = self._timers.pop(group_key, None)
if timer:
timer.cancel()
self._timers[group_key] = loop.call_later(
self.NOTIFICATION_DEBOUNCE_SECONDS,
self.flush,
group_key,
callback,
)
def flush(
self,
group_key: str,
callback: Callable[[list[TransferFailureNotification]], None],
) -> None:
"""发送一个分组内的聚合结果并释放缓冲。"""
notifications = self._buffers.pop(group_key, [])
self._timers.pop(group_key, None)
if not notifications:
return
try:
callback(notifications)
except Exception as err:
logger.error(f"发送整理失败聚合通知失败 (group={group_key}): {err}")
# 作业锁:JobManager 与 TransferChain 共享,保护整理作业视图。
job_lock = threading.Lock()
+125 -60
View File
@@ -4,6 +4,7 @@ import re
import threading
import traceback
import uuid
from collections import Counter
from copy import deepcopy
from pathlib import Path
from typing import List, Optional, Tuple, Union, Dict, Callable, Any
@@ -58,9 +59,12 @@ from app.runtime.reload import ConfigReloadMixin
from app.application.transfer import (
FailedRetryScheduler,
JobManager,
TransferFailureNotification,
TransferFailureNotificationAggregator,
TransferQueue,
TransferQueueService,
TransferTask,
build_transfer_failure_group_key,
job_lock,
)
from app.chain._transfer import (EpisodeFormatMixin, FailedRetryMixin,
@@ -125,6 +129,8 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
self.jobview = JobManager()
# Agent重试管理器
self.retry_scheduler = FailedRetryScheduler()
# 整理失败通知聚合器
self.failure_notification_aggregator = TransferFailureNotificationAggregator()
# 待整理文件落盘登记,用于进程重启后回放内存队列里未完成的任务
self._pendingoper = TransferPendingOper()
# 转移成功的文件清单
@@ -323,28 +329,10 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
},
)
# 发送失败消息
self.post_message(
Message(
mtype=MessageType.Manual,
title=f"{task.mediainfo.title_year} {task.meta.season_episode} 入库失败!",
text="\n".join(
[
f"原因:{transferinfo.message or '未知'}",
(
f"如果按钮不可用,可回复:\n```\n/redo {history.id}\n```"
if history
else ""
),
]
).strip(),
image=task.mediainfo.get_message_image(),
username=task.username,
link=settings.MP_DOMAIN("#/history"),
buttons=self.build_failed_transfer_buttons(
history.id if history else None
),
)
self.queue_failed_transfer_notification(
task=task,
transferinfo=transferinfo,
history_id=history.id if history else None,
)
# 设置任务失败
@@ -359,11 +347,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
try:
# 使用 download_hash 或源文件父目录作为分组键,
# 同一批次(如同一个种子)的失败记录会被合并为一次agent调用
group_key = (
task.download_hash or str(task.fileitem.path).rsplit("/", 1)[0]
if task.fileitem
else ""
)
group_key = build_transfer_failure_group_key(task)
asyncio.run_coroutine_threadsafe(
self.retry_scheduler.schedule_retry(
history.id, group_key=group_key
@@ -544,6 +528,109 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
return ret_status, ret_message
def queue_failed_transfer_notification(
self,
*,
task: TransferTask,
transferinfo: TransferInfo,
history_id: Optional[int],
manual_identity: bool = False,
) -> None:
"""按配置逐条发送或按媒体聚合整理失败通知,供第三方整理补丁复用。"""
notification = TransferFailureNotification(
media_title=(
task.mediainfo.title_year
if task.mediainfo
else task.fileitem.name if task.fileitem else "未知媒体"
),
season_episode=getattr(task.meta, "season_episode", "") or "",
reason=transferinfo.message or "未知",
history_id=history_id,
image=(
task.mediainfo.get_message_image()
if task.mediainfo and hasattr(task.mediainfo, "get_message_image")
else None
),
username=task.username,
manual_identity=manual_identity,
)
if not settings.TRANSFER_FAILURE_NOTIFICATION_AGGREGATION:
self._send_transfer_failure_notifications([notification])
return
try:
self.failure_notification_aggregator.schedule(
group_key=build_transfer_failure_group_key(task),
notification=notification,
callback=self._send_transfer_failure_notifications,
loop=global_vars.loop,
)
except Exception as err:
logger.error(f"加入整理失败通知聚合缓冲失败,将立即发送:{err}")
self._send_transfer_failure_notifications([notification])
def _send_transfer_failure_notifications(
self,
notifications: List[TransferFailureNotification],
) -> None:
"""把一个媒体分组的失败快照渲染为单条消息。"""
if not notifications:
return
first = notifications[0]
history_ids = [item.history_id for item in notifications if item.history_id]
if len(notifications) == 1:
history_hint = (
(
"如果按钮不可用,可回复:\n"
f"```\n/redo {history_ids[0]}\n"
f"/redo {history_ids[0]} [media_source]|[media_id]|[类型]\n```\n"
"自动重试或手动识别整理。"
if first.manual_identity
else f"如果按钮不可用,可回复:\n```\n/redo {history_ids[0]}\n```"
)
if history_ids
else ""
)
text = "\n".join([f"原因:{first.reason}", history_hint]).strip()
buttons = self.build_failed_transfer_buttons(
history_ids[0] if history_ids else None
)
title = (
f"{first.media_title} 未识别到媒体信息,无法入库!"
if first.manual_identity
else f"{first.media_title} {first.season_episode} 入库失败!"
)
else:
reason_counts = Counter(item.reason for item in notifications)
reason_lines = [
f"- {reason} × {count}"
for reason, count in reason_counts.most_common()
]
history_text = "".join(f"#{history_id}" for history_id in history_ids)
text_parts = [
f"失败文件:{len(notifications)}",
"原因统计:",
*reason_lines,
]
if history_text:
text_parts.extend([f"整理记录:{history_text}", "可在整理历史中批量处理。"])
text = "\n".join(text_parts)
buttons = [[{
"text": "批量处理",
"url": settings.MP_DOMAIN("#/history"),
}]]
title = f"{first.media_title} 入库失败({len(notifications)} 个文件)"
self.post_message(
Message(
mtype=MessageType.Manual,
title=title,
text=text,
image=first.image,
username=first.username,
link=settings.MP_DOMAIN("#/history"),
buttons=buttons,
)
)
def __get_transfer_target_dir_path(
self, transferinfo: Optional[TransferInfo]
) -> Optional[str]:
@@ -1003,33 +1090,16 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
download_hash=task.download_hash,
transfer_history_oper=transferhis,
)
self.post_message(
Message(
mtype=MessageType.Manual,
title=f"{task.fileitem.name} 未识别到媒体信息,无法入库!",
# 历史落库失败时 his 为 Noneadd_transfer_fail 末尾的
# get_by_src 查不到即返回 None),此时 /redo 无 ID 可用,
# 只省去这段指引而不是让整条通知连同后续的作业清理、
# 种子完成标记一起崩在 NoneType 上
text="\n".join(
[
"原因:未识别到媒体信息",
(
"如果按钮不可用,可回复:\n"
f"```\n/redo {his.id}\n"
f"/redo {his.id} [media_source]|[media_id]|[类型]\n```\n"
"自动重试或手动识别整理。"
if his
else ""
),
]
).strip(),
username=task.username,
link=settings.MP_DOMAIN("#/history"),
buttons=self.build_failed_transfer_buttons(
his.id if his else None
),
)
self.queue_failed_transfer_notification(
task=task,
transferinfo=TransferInfo(
success=False,
fileitem=task.fileitem,
message="未识别到媒体信息",
transfer_type=task.transfer_type,
),
history_id=his.id if his else None,
manual_identity=True,
)
# 任务失败,直接移除task
self.jobview.remove_task(task.fileitem)
@@ -1045,12 +1115,7 @@ class TransferChain(FileFilterMixin, ScrapeBatchMixin, EpisodeFormatMixin, Histo
):
try:
# 使用 download_hash 或源文件父目录作为分组键
group_key = (
task.download_hash
or str(task.fileitem.path).rsplit("/", 1)[0]
if task.fileitem
else ""
)
group_key = build_transfer_failure_group_key(task)
asyncio.run_coroutine_threadsafe(
self.retry_scheduler.schedule_retry(
his.id, group_key=group_key
+2
View File
@@ -718,6 +718,8 @@ class ConfigModel(BaseModel):
AI_AGENT_VERBOSE: bool = False
# AI智能体自动重试整理失败记录开关
AI_AGENT_RETRY_TRANSFER: bool = False
# 是否按媒体聚合整理失败通知,关闭时保持逐条发送
TRANSFER_FAILURE_NOTIFICATION_AGGREGATION: bool = True
# 音频输入提供商:openai/openai_chat_audio/mimo/minimax
AUDIO_INPUT_PROVIDER: str = "openai"
@@ -0,0 +1,167 @@
from unittest.mock import Mock
from app.chain import transfer as transfer_module
from app.chain.transfer import TransferChain
from app.application.transfer import (
TransferFailureNotification,
TransferFailureNotificationAggregator,
TransferTask,
build_transfer_failure_group_key,
)
from app.domain.context import MediaInfo
from app.domain.metainfo import MetaInfo
from app.runtime.config import ConfigModel
from app.schemas.file import FileItem
from app.schemas.transfer import TransferInfo
from app.schemas.types import MediaSource, MediaType
class _Timer:
"""记录静默窗口是否因新失败到达而取消。"""
def __init__(self, callback, args):
"""保存定时回调与参数。"""
self.callback = callback
self.args = args
self.cancelled = False
def cancel(self):
"""标记当前定时器已取消。"""
self.cancelled = True
class _Loop:
"""同步执行线程安全入队,并保留延迟回调供测试触发。"""
def __init__(self):
"""初始化定时器清单。"""
self.timers = []
def call_soon_threadsafe(self, callback, *args):
"""同步执行本应投递到事件循环的回调。"""
callback(*args)
def call_later(self, _delay, callback, *args):
"""保存延迟回调并返回可取消句柄。"""
timer = _Timer(callback, args)
self.timers.append(timer)
return timer
def _task(*, episode: int, download_hash: str = "hash-1") -> TransferTask:
"""构造同一媒体不同剧集的整理任务。"""
return TransferTask(
fileitem=FileItem(
storage="local",
path=f"/downloads/Show/Show.S01E{episode:02d}.mkv",
type="file",
name=f"Show.S01E{episode:02d}.mkv",
),
meta=MetaInfo(f"Show S01E{episode:02d}"),
mediainfo=MediaInfo(
media_source=MediaSource.TMDB,
media_id="100",
tmdb_id=100,
title="测试剧",
type=MediaType.TV,
year="2026",
),
download_hash=download_hash,
username="tester",
)
def test_failure_group_key_prefers_media_identity_and_season():
"""同一媒体同一季应跨文件共享分组键。"""
first = build_transfer_failure_group_key(_task(episode=1, download_hash="hash-a"))
second = build_transfer_failure_group_key(_task(episode=2, download_hash="hash-b"))
assert first == second
assert first == "media:themoviedb:100:season:1:user:tester"
def test_failure_notification_aggregation_defaults_on():
"""整理失败通知聚合默认开启。"""
field = ConfigModel.model_fields["TRANSFER_FAILURE_NOTIFICATION_AGGREGATION"]
assert field.default is True
def test_aggregator_debounces_same_group_and_flushes_once():
"""同组失败应重置定时器并一次性回调全部快照。"""
loop = _Loop()
aggregator = TransferFailureNotificationAggregator()
callback = Mock()
notices = [
TransferFailureNotification("测试剧 (2026)", "S01E01", "原因A", 1, None, "tester"),
TransferFailureNotification("测试剧 (2026)", "S01E02", "原因B", 2, None, "tester"),
]
for notice in notices:
aggregator.schedule(
group_key="media:test",
notification=notice,
callback=callback,
loop=loop,
)
assert loop.timers[0].cancelled is True
assert loop.timers[1].cancelled is False
loop.timers[1].callback(*loop.timers[1].args)
callback.assert_called_once_with(notices)
def test_aggregated_message_contains_count_reason_stats_and_batch_entry():
"""聚合消息应给出失败数、原因统计、历史 ID 和批量处理入口。"""
chain = object.__new__(TransferChain)
sent = []
chain.post_message = sent.append
notices = [
TransferFailureNotification("测试剧 (2026)", "S01E01", "未识别到媒体信息", 11, None, "tester"),
TransferFailureNotification("测试剧 (2026)", "S01E02", "目标已存在", 12, None, "tester"),
TransferFailureNotification("测试剧 (2026)", "S01E03", "目标已存在", 13, None, "tester"),
]
chain._send_transfer_failure_notifications(notices)
assert len(sent) == 1
message = sent[0]
assert message.title == "测试剧 (2026) 入库失败(3 个文件)"
assert "失败文件:3 个" in message.text
assert "- 目标已存在 × 2" in message.text
assert "整理记录:#11、#12、#13" in message.text
assert message.buttons == [[{
"text": "批量处理",
"url": transfer_module.settings.MP_DOMAIN("#/history"),
}]]
def test_enabled_queue_uses_shared_group_key(monkeypatch):
"""开启聚合后公开通知入口应投递到聚合器而不是立即发送。"""
chain = object.__new__(TransferChain)
chain.failure_notification_aggregator = Mock()
chain.post_message = Mock()
task = _task(episode=1)
transferinfo = TransferInfo(
success=False,
fileitem=task.fileitem,
message="整理失败",
transfer_type="copy",
)
loop = transfer_module.global_vars.loop
monkeypatch.setattr(
transfer_module.settings,
"TRANSFER_FAILURE_NOTIFICATION_AGGREGATION",
True,
)
chain.queue_failed_transfer_notification(
task=task,
transferinfo=transferinfo,
history_id=22,
)
chain.failure_notification_aggregator.schedule.assert_called_once()
kwargs = chain.failure_notification_aggregator.schedule.call_args.kwargs
assert kwargs["group_key"] == build_transfer_failure_group_key(task)
assert kwargs["loop"] is loop
chain.post_message.assert_not_called()