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"