Files
MoviePilot/app/application/transfer/notifications.py
DDSRem 2de976f6b3 Merge pull request #7050 from DDSRem-Dev/codex-comment-migration
docs: 统一全项目中文注释和结构化契约说明
2026-10-17 19:26:29 +08:00

290 lines
11 KiB
Python

"""
整理结果通知的分组、批次等待和短暂聚合。
"""
from __future__ import annotations
import asyncio
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Callable, Optional
from app.application.transfer.feedback import TransferFailureNotification
from app.domain.context import MediaInfo, MusicInfo
from app.domain.meta.metabase import MetaBase
from app.runtime.log import logger
from app.schemas.media import resolve_media_identity
from app.schemas.tmdb import TmdbEpisode
from app.schemas.transfer import TransferInfo
if TYPE_CHECKING:
from app.application.transfer.models import TransferTask
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}"
@dataclass(frozen=True, slots=True)
class TransferSuccessNotification:
"""
保存逐任务成功结果副本,避免后续作业统计修改已缓冲通知。
:param meta: 用于通知展示季集和发布名的已解析元数据
:param mediainfo: 用于标题、海报和来源链接的标准媒体信息
:param transferinfo: 文件整理结果,包括目录、大小与方式
:param episodes_info: 通知可使用的剧集详情;没有补充信息时为 None
:param username: 触发整理的本地用户名;未绑定时为 None
"""
meta: MetaBase
mediainfo: MediaInfo | MusicInfo
transferinfo: TransferInfo
episodes_info: Optional[list[TmdbEpisode]]
username: Optional[str]
def build_transfer_success_group_key(task: TransferTask, result: TransferInfo) -> str:
"""
按下载来源、媒体目录、季和用户隔离成功通知,避免跨专辑或用户混发。
"""
directory = result.target_diritem
destination = (directory.storage, directory.path) if directory else (task.target_storage, str(task.target_path))
source = (task.downloader, task.download_hash) if task.download_hash else (task.fileitem.storage, str(Path(task.fileitem.path or "").parent))
season = getattr(task.meta, "begin_season", None)
return repr((source, destination, season, task.username))
class TransferNotificationAggregator[Notification]:
"""
在线程安全缓冲中等待明确批次结束,或按静默窗口合并通知。
"""
NOTIFICATION_DEBOUNCE_SECONDS = 30
def __init__(self) -> None:
"""
初始化分组缓冲、回调、定时器与关闭状态。
"""
self._buffers: dict[str, list[Notification]] = {}
self._callbacks: dict[
str,
Callable[[list[Notification]], None],
] = {}
self._timers: dict[str, asyncio.TimerHandle] = {}
self._generations: dict[str, int] = {}
self._next_generation = 0
self._waiting_batches: dict[str, set[str]] = {}
self._debounce_loops: dict[str, asyncio.AbstractEventLoop] = {}
self._lock = threading.Lock()
self._closed = False
def schedule(
self,
*,
group_key: str,
notification: Notification,
callback: Callable[[list[Notification]], None],
loop: Optional[asyncio.AbstractEventLoop] = None,
defer_until: Optional[str] = None,
) -> None:
"""
先登记快照;明确批次等待完成信号,其余通知使用事件循环静默窗口。
"""
# 先在调用线程登记快照,关闭流程才能覆盖已接收但尚未进入事件循环的通知。
with self._lock:
if self._closed:
raise RuntimeError("整理通知聚合器正在关闭,不能再接收通知")
self._buffers.setdefault(group_key, []).append(notification)
self._callbacks[group_key] = callback
# 分组刷新后也不复用代号,避免旧回调命中新创建的同名分组。
self._next_generation += 1
generation = self._next_generation
self._generations[group_key] = generation
if defer_until:
self._waiting_batches.setdefault(group_key, set()).add(defer_until)
if loop is not None:
self._debounce_loops[group_key] = loop
if defer_until:
return
self._schedule_flush(group_key, generation, callback, loop)
def _schedule_flush(
self,
group_key: str,
generation: int,
callback: Callable[[list[Notification]], None],
loop: Optional[asyncio.AbstractEventLoop],
) -> None:
"""
统一投递静默窗口,事件循环不可用时回退为立即发送。
"""
if loop is None:
self.flush(group_key, generation, callback)
return
try:
loop.call_soon_threadsafe(
self._schedule_on_loop,
group_key,
generation,
callback,
loop,
)
except Exception as err:
logger.error(
f"创建整理通知聚合定时器失败,将立即发送 "
f"(group={group_key}): {err}"
)
self.flush(group_key, generation, callback)
def _schedule_on_loop(
self,
group_key: str,
generation: int,
callback: Callable[[list[Notification]], None],
loop: asyncio.AbstractEventLoop,
) -> None:
"""
在所属事件循环中为已登记缓冲重置静默窗口。
"""
schedule_error: Exception | None = None
with self._lock:
if (
self._closed
or group_key not in self._buffers
or self._generations.get(group_key) != generation
or self._waiting_batches.get(group_key)
):
return
timer = self._timers.pop(group_key, None)
if timer:
timer.cancel()
try:
self._timers[group_key] = loop.call_later(
self.NOTIFICATION_DEBOUNCE_SECONDS,
self.flush,
group_key,
generation,
callback,
)
except Exception as err:
schedule_error = err
if schedule_error is not None:
logger.error(
f"创建整理通知聚合定时器失败,将立即发送 "
f"(group={group_key}): {schedule_error}"
)
self.flush(group_key, generation, callback)
def flush(
self,
group_key: str,
generation: int,
callback: Callable[[list[Notification]], None],
) -> None:
"""
发送一个分组内的聚合结果并释放缓冲。
"""
with self._lock:
# 新通知已登记但 timer 重置回调尚未执行时,旧代不得提前发送新批次。
if self._generations.get(group_key) != generation or self._waiting_batches.get(group_key):
return
notifications = self._buffers.pop(group_key, [])
self._callbacks.pop(group_key, None)
timer = self._timers.pop(group_key, None)
self._generations.pop(group_key, None)
self._waiting_batches.pop(group_key, None)
self._debounce_loops.pop(group_key, None)
if timer:
timer.cancel()
if not notifications:
return
self._deliver(group_key, notifications, callback)
def release_batch(self, batch_id: str, *, loop: Optional[asyncio.AbstractEventLoop] = None) -> None:
"""
批次终结后释放等待;传入循环时合并后续文件,同组仍须等待其他批次。
"""
with self._lock:
ready = []
for group_key, batches in self._waiting_batches.items():
if batch_id not in batches:
continue
batches.remove(batch_id)
if loop is not None:
self._debounce_loops[group_key] = loop
if not batches:
ready.append((group_key, self._generations[group_key], self._callbacks[group_key],
self._debounce_loops.get(group_key)))
for group_key, generation, callback, debounce_loop in ready:
self._schedule_flush(group_key, generation, callback, debounce_loop)
@staticmethod
def _deliver(
group_key: str,
notifications: list[Notification],
callback: Callable[[list[Notification]], None],
) -> None:
"""
调用聚合通知回调,并统一观察发送异常。
"""
try:
callback(notifications)
except Exception as err:
logger.error(f"发送整理聚合通知失败 (group={group_key}): {err}")
def close(self) -> None:
"""
停止接收新通知,取消定时器并同步发送全部已缓冲通知。
"""
with self._lock:
if self._closed and not self._buffers and not self._timers:
return
self._closed = True
timers = list(self._timers.values())
pending = []
orphaned = []
for group_key, notifications in self._buffers.items():
callback = self._callbacks.get(group_key)
if callback is None:
orphaned.append((group_key, len(notifications)))
continue
pending.append((group_key, notifications, callback))
self._timers.clear()
self._generations.clear()
self._buffers.clear()
self._callbacks.clear()
self._waiting_batches.clear()
self._debounce_loops.clear()
for timer in timers:
timer.cancel()
for group_key, notification_count in orphaned:
logger.error(
f"整理通知聚合缓冲缺少发送回调,无法刷新 "
f"(group={group_key}, count={notification_count})"
)
for group_key, notifications, callback in pending:
self._deliver(group_key, notifications, callback)
class TransferFailureNotificationAggregator(TransferNotificationAggregator[TransferFailureNotification]):
"""
保留失败通知已有入口及三十秒静默聚合行为。
"""