mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-02 13:45:42 +08:00
feat(notification): typed system events, per-provider templates, composition-based base class
Introduce SystemEvent (RssFailureEvent/DownloadFailureEvent/OffsetReviewEvent)
raised by refresh_rss() on healthy->error transitions and add_torrent()
failures, and by the offset scanner; wire them through NotificationManager.send_event()
and the rss/offset-scan loops (gated by settings.notification.enable).
Extend the {{title}}/{{season}}/{{episode}}/{{poster_url}} template mechanism
from webhook-only to all providers via NotificationProvider._format_message(),
falling back to each provider's original default message when unset.
Replace NotificationProvider's multiple inheritance from RequestContent with
composition (self._http), removing the post_json self-shadowing workaround.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTnuEgR78cBk32bLNKar3z
This commit is contained in:
@@ -104,7 +104,7 @@ class AppContext:
|
||||
[
|
||||
PeriodicTask(
|
||||
name="rss",
|
||||
run=lambda: rss_tick(analyser),
|
||||
run=lambda: rss_tick(analyser, notifier),
|
||||
interval=lambda: settings_obj.program.rss_time,
|
||||
enabled=Checker.check_analyser,
|
||||
),
|
||||
@@ -116,7 +116,7 @@ class AppContext:
|
||||
),
|
||||
PeriodicTask(
|
||||
name="offset_scan",
|
||||
run=offset_scan_tick,
|
||||
run=lambda: offset_scan_tick(notifier),
|
||||
interval=lambda: OFFSET_SCAN_INTERVAL,
|
||||
initial_delay=OFFSET_SCAN_INITIAL_DELAY,
|
||||
),
|
||||
|
||||
@@ -18,7 +18,7 @@ from .offset_scanner import OffsetScanner
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def rss_tick(analyser: RSSAnalyser) -> None:
|
||||
async def rss_tick(analyser: RSSAnalyser, notifier: NotificationManager) -> None:
|
||||
"""Analyse aggregate RSS feeds and refresh the RSS engine once."""
|
||||
async with DownloadClient() as client:
|
||||
async with Database() as db:
|
||||
@@ -44,7 +44,10 @@ async def rss_tick(analyser: RSSAnalyser) -> None:
|
||||
exc_info=True,
|
||||
)
|
||||
# Run RSS Engine
|
||||
await engine.refresh_rss(client)
|
||||
events = await engine.refresh_rss(client)
|
||||
if settings.notification.enable:
|
||||
for event in events:
|
||||
await notifier.send_event(event)
|
||||
if settings.bangumi_manage.eps_complete:
|
||||
await eps_complete()
|
||||
|
||||
@@ -59,11 +62,14 @@ async def rename_tick(notifier: NotificationManager) -> None:
|
||||
await notifier.send_all(info)
|
||||
|
||||
|
||||
async def offset_scan_tick() -> None:
|
||||
async def offset_scan_tick(notifier: NotificationManager) -> None:
|
||||
"""Scan all bangumi for season/episode offset mismatches."""
|
||||
scanner = OffsetScanner()
|
||||
flagged = await scanner.scan_all()
|
||||
logger.info("[OffsetScanThread] Scan complete, flagged %s bangumi", flagged)
|
||||
events = await scanner.scan_all()
|
||||
logger.info("[OffsetScanThread] Scan complete, flagged %s bangumi", len(events))
|
||||
if settings.notification.enable:
|
||||
for event in events:
|
||||
await notifier.send_event(event)
|
||||
|
||||
|
||||
async def calendar_tick() -> None:
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
from .base import NotificationProvider
|
||||
from .events import (
|
||||
DownloadFailureEvent,
|
||||
OffsetReviewEvent,
|
||||
RssFailureEvent,
|
||||
SystemEvent,
|
||||
)
|
||||
from .manager import NotificationManager
|
||||
from .providers import PROVIDER_REGISTRY
|
||||
|
||||
__all__ = [
|
||||
"DownloadFailureEvent",
|
||||
"NotificationManager",
|
||||
"NotificationProvider",
|
||||
"OffsetReviewEvent",
|
||||
"PROVIDER_REGISTRY",
|
||||
"RssFailureEvent",
|
||||
"SystemEvent",
|
||||
]
|
||||
|
||||
@@ -1,20 +1,39 @@
|
||||
"""Base class for notification providers."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from module.conf import settings
|
||||
from module.models.bangumi import Notification
|
||||
from module.network import RequestContent
|
||||
from module.network.request_url import RequestURL
|
||||
from module.notification.events import SystemEvent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from module.models.config import NotificationProvider as ProviderConfig
|
||||
|
||||
|
||||
class NotificationProvider(RequestContent, ABC):
|
||||
class NotificationProvider(ABC):
|
||||
"""Abstract base class for notification providers.
|
||||
|
||||
All notification providers must inherit from this class and implement
|
||||
the send() and test() methods.
|
||||
the send() and test() methods. HTTP delivery is composed via a
|
||||
``RequestContent`` instance (``self._http``) rather than inherited -- a
|
||||
provider IS-A notification channel, not an HTTP client.
|
||||
"""
|
||||
|
||||
def __init__(self, config: "ProviderConfig") -> None:
|
||||
self._http = RequestContent()
|
||||
# 单集通知模板({{title}}/{{season}}/{{episode}}/{{poster_url}});
|
||||
# 未设置时 ``_format_message`` 回退到默认中文文案。
|
||||
self.template = config.template
|
||||
|
||||
async def __aenter__(self) -> "NotificationProvider":
|
||||
await self._http.__aenter__()
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||
await self._http.__aexit__(exc_type, exc_val, exc_tb)
|
||||
|
||||
@abstractmethod
|
||||
async def send(self, notification: Notification) -> bool:
|
||||
"""Send a notification.
|
||||
@@ -37,8 +56,43 @@ class NotificationProvider(RequestContent, ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
async def send_event(self, event: SystemEvent) -> bool:
|
||||
"""Send a system event (RSS failure, download failure, offset review).
|
||||
|
||||
System events use each provider's default title/body delivery
|
||||
(:meth:`_deliver_text`) -- the per-episode template only covers
|
||||
{{title}}/{{season}}/{{episode}}/{{poster_url}}, which don't apply
|
||||
here.
|
||||
|
||||
Args:
|
||||
event: The system event to send.
|
||||
|
||||
Returns:
|
||||
True if the event was delivered successfully, False otherwise.
|
||||
"""
|
||||
title, body = event.describe()
|
||||
return await self._deliver_text(title, body)
|
||||
|
||||
@abstractmethod
|
||||
async def _deliver_text(self, title: str, body: str) -> bool:
|
||||
"""Deliver a plain title + body text message. Backs :meth:`send_event`.
|
||||
|
||||
Args:
|
||||
title: Short event title.
|
||||
body: Event details.
|
||||
|
||||
Returns:
|
||||
True if delivered successfully, False otherwise.
|
||||
"""
|
||||
pass
|
||||
|
||||
def _format_message(self, notify: Notification) -> str:
|
||||
"""Format the default notification message.
|
||||
"""Format the per-episode notification message.
|
||||
|
||||
Uses the provider's configured ``template`` when set, substituting
|
||||
``{{title}}``/``{{season}}``/``{{episode}}``/``{{poster_url}}``;
|
||||
otherwise falls back to the default Chinese message (existing
|
||||
configs without a template keep this exact text).
|
||||
|
||||
Args:
|
||||
notify: The notification data.
|
||||
@@ -46,11 +100,22 @@ class NotificationProvider(RequestContent, ABC):
|
||||
Returns:
|
||||
Formatted message string.
|
||||
"""
|
||||
return (
|
||||
f"番剧名称:{notify.official_title}\n"
|
||||
f"季度: 第{notify.season}季\n"
|
||||
f"更新集数: 第{notify.episode}集"
|
||||
)
|
||||
if not self.template:
|
||||
return (
|
||||
f"番剧名称:{notify.official_title}\n"
|
||||
f"季度: 第{notify.season}季\n"
|
||||
f"更新集数: 第{notify.episode}集"
|
||||
)
|
||||
replacements = {
|
||||
"{{title}}": notify.official_title,
|
||||
"{{season}}": str(notify.season),
|
||||
"{{episode}}": str(notify.episode),
|
||||
"{{poster_url}}": self._poster_url(notify) or "",
|
||||
}
|
||||
rendered = self.template
|
||||
for pattern, value in replacements.items():
|
||||
rendered = rendered.replace(pattern, value)
|
||||
return rendered
|
||||
|
||||
def _poster_url(self, notify: Notification) -> str | None:
|
||||
"""Build a public poster URL from a locally-stored poster path.
|
||||
@@ -73,13 +138,17 @@ class NotificationProvider(RequestContent, ABC):
|
||||
return None
|
||||
return f"{base_url.rstrip('/')}/{notify.poster_path}"
|
||||
|
||||
async def post_data(self, url: str, data: dict):
|
||||
"""Form-encoded POST, delegating to the composed HTTP client."""
|
||||
return await self._http.post_data(url, data)
|
||||
|
||||
async def post_files(self, url: str, data: dict, files: dict):
|
||||
"""Multipart POST, delegating to the composed HTTP client."""
|
||||
return await self._http.post_files(url, data, files)
|
||||
|
||||
async def _post_json(self, url: str, json_data: dict):
|
||||
"""POST a JSON payload via ``RequestURL.post_json`` (httpx ``json=``).
|
||||
|
||||
Called explicitly on ``RequestURL`` rather than ``self.post_json``
|
||||
because ``RequestContent`` (a parent of this class) defines its own
|
||||
unrelated, form-encoded ``post_json`` that would otherwise shadow it.
|
||||
|
||||
Args:
|
||||
url: The target URL.
|
||||
json_data: The JSON-serializable payload.
|
||||
@@ -87,4 +156,4 @@ class NotificationProvider(RequestContent, ABC):
|
||||
Returns:
|
||||
The httpx response.
|
||||
"""
|
||||
return await RequestURL.post_json(self, url, json_data)
|
||||
return await self._http.post_json(url, json_data)
|
||||
|
||||
57
backend/src/module/notification/events.py
Normal file
57
backend/src/module/notification/events.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""非「新集数」通知的类型化事件负载。
|
||||
|
||||
刻意与 ``Notification``(module/models/bangumi.py,仅描述「新集数下载完成」)
|
||||
分开:RSS 订阅失败、下载添加失败、偏移量待确认三类事件携带的字段与集数通知完全
|
||||
无关,硬塞进 ``Notification`` 只会制造出没有意义的 season/episode 占位值。
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class RssFailureEvent:
|
||||
"""RSS 订阅连接状态从正常变为异常(仅状态翻转时触发一次,而非每个 tick)。"""
|
||||
|
||||
rss_name: str
|
||||
rss_url: str
|
||||
error: str
|
||||
|
||||
def describe(self) -> tuple[str, str]:
|
||||
"""返回该事件的默认 (标题, 正文)。"""
|
||||
return (
|
||||
"RSS 订阅连接异常",
|
||||
f"订阅:{self.rss_name}\n地址:{self.rss_url}\n错误:{self.error}",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class DownloadFailureEvent:
|
||||
"""匹配到的种子在(内置 HTTP 重试后)仍添加下载器失败。"""
|
||||
|
||||
official_title: str
|
||||
torrent_name: str
|
||||
|
||||
def describe(self) -> tuple[str, str]:
|
||||
return (
|
||||
"种子添加失败",
|
||||
f"番剧:{self.official_title}\n种子:{self.torrent_name}\n"
|
||||
"重试后仍添加失败,请检查下载器连接。",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class OffsetReviewEvent:
|
||||
"""番剧被标记为需要人工确认季度/集数偏移。"""
|
||||
|
||||
official_title: str
|
||||
reason: str
|
||||
|
||||
def describe(self) -> tuple[str, str]:
|
||||
return (
|
||||
"集数偏移待确认",
|
||||
f"番剧:{self.official_title}\n原因:{self.reason}\n请前往设置页确认偏移量。",
|
||||
)
|
||||
|
||||
|
||||
# 除「新集数」以外的通知事件的联合类型。
|
||||
SystemEvent = RssFailureEvent | DownloadFailureEvent | OffsetReviewEvent
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Awaitable, Callable
|
||||
|
||||
from module.conf import settings
|
||||
from module.database import Database
|
||||
from module.models.bangumi import Notification
|
||||
from module.notification.events import SystemEvent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from module.models.config import NotificationProvider as ProviderConfig
|
||||
@@ -60,6 +61,36 @@ class NotificationManager:
|
||||
if data:
|
||||
notification.poster_path = data.poster_link
|
||||
|
||||
async def _broadcast(
|
||||
self, label: str, send_one: Callable[["NotificationProvider"], Awaitable[None]]
|
||||
):
|
||||
"""Run ``send_one`` against every provider in parallel, logging failures.
|
||||
|
||||
Shared by :meth:`send_all` and :meth:`send_event` so both broadcast
|
||||
the same way: one provider's exception never blocks the others.
|
||||
|
||||
Args:
|
||||
label: Short description used in warning logs on failure.
|
||||
send_one: Coroutine function that delivers to a single provider.
|
||||
"""
|
||||
if not self.providers:
|
||||
logger.debug("No notification providers configured")
|
||||
return
|
||||
|
||||
async def guarded(provider: "NotificationProvider"):
|
||||
try:
|
||||
async with provider:
|
||||
await send_one(provider)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to send {label} via {provider.__class__.__name__}: {e}"
|
||||
)
|
||||
|
||||
await asyncio.gather(
|
||||
*[guarded(p) for p in self.providers],
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
async def send_all(self, notification: Notification):
|
||||
"""Send notification to all enabled providers.
|
||||
|
||||
@@ -73,25 +104,31 @@ class NotificationManager:
|
||||
# Fetch poster if needed
|
||||
await self._get_poster(notification)
|
||||
|
||||
# Send to all providers in parallel
|
||||
async def send_to_provider(provider: "NotificationProvider"):
|
||||
try:
|
||||
async with provider:
|
||||
await provider.send(notification)
|
||||
logger.debug(
|
||||
"Sent notification via %s: %s",
|
||||
provider.__class__.__name__,
|
||||
notification.official_title,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to send notification via {provider.__class__.__name__}: {e}"
|
||||
)
|
||||
async def send_one(provider: "NotificationProvider"):
|
||||
await provider.send(notification)
|
||||
logger.debug(
|
||||
"Sent notification via %s: %s",
|
||||
provider.__class__.__name__,
|
||||
notification.official_title,
|
||||
)
|
||||
|
||||
await asyncio.gather(
|
||||
*[send_to_provider(p) for p in self.providers],
|
||||
return_exceptions=True,
|
||||
)
|
||||
await self._broadcast("notification", send_one)
|
||||
|
||||
async def send_event(self, event: SystemEvent):
|
||||
"""Send a system event (RSS failure, download failure, offset review)
|
||||
to all enabled providers.
|
||||
|
||||
Args:
|
||||
event: The system event to send.
|
||||
"""
|
||||
|
||||
async def send_one(provider: "NotificationProvider"):
|
||||
await provider.send_event(event)
|
||||
logger.debug(
|
||||
"Sent system event via %s: %s", provider.__class__.__name__, event
|
||||
)
|
||||
|
||||
await self._broadcast("system event", send_one)
|
||||
|
||||
async def test_provider(self, index: int) -> tuple[bool, str]:
|
||||
"""Test a specific provider by index.
|
||||
|
||||
@@ -18,7 +18,7 @@ class BarkProvider(NotificationProvider):
|
||||
DEFAULT_SERVER = "https://api.day.app"
|
||||
|
||||
def __init__(self, config: "ProviderConfig"):
|
||||
super().__init__()
|
||||
super().__init__(config)
|
||||
# Support both legacy token field and new device_key field
|
||||
self.device_key = config.device_key or config.token
|
||||
server_url = config.server_url or self.DEFAULT_SERVER
|
||||
@@ -55,3 +55,9 @@ class BarkProvider(NotificationProvider):
|
||||
return False, f"Bark API returned status {resp.status_code}"
|
||||
except Exception as e:
|
||||
return False, f"Bark test failed: {e}"
|
||||
|
||||
async def _deliver_text(self, title: str, body: str) -> bool:
|
||||
"""Deliver a system event via Bark."""
|
||||
data = {"title": title, "body": body, "device_key": self.device_key}
|
||||
resp = await self._post_json(self.notification_url, data)
|
||||
return resp.status_code == 200
|
||||
|
||||
@@ -16,17 +16,24 @@ class DiscordProvider(NotificationProvider):
|
||||
"""Discord webhook notification provider."""
|
||||
|
||||
def __init__(self, config: "ProviderConfig"):
|
||||
super().__init__()
|
||||
super().__init__(config)
|
||||
self.webhook_url = config.webhook_url
|
||||
|
||||
async def send(self, notification: Notification) -> bool:
|
||||
"""Send notification via Discord webhook."""
|
||||
embed = {
|
||||
"title": f"📺 {notification.official_title}",
|
||||
"description": (
|
||||
# A configured template overrides the description only; the default
|
||||
# (no template) description is kept byte-for-byte for existing configs.
|
||||
description = (
|
||||
self._format_message(notification)
|
||||
if self.template
|
||||
else (
|
||||
f"**季度:** 第{notification.season}季\n"
|
||||
f"**集数:** 第{notification.episode}集"
|
||||
),
|
||||
)
|
||||
)
|
||||
embed = {
|
||||
"title": f"📺 {notification.official_title}",
|
||||
"description": description,
|
||||
"color": 0x00BFFF, # Deep Sky Blue
|
||||
}
|
||||
|
||||
@@ -60,3 +67,9 @@ class DiscordProvider(NotificationProvider):
|
||||
return False, f"Discord API returned status {resp.status_code}"
|
||||
except Exception as e:
|
||||
return False, f"Discord test failed: {e}"
|
||||
|
||||
async def _deliver_text(self, title: str, body: str) -> bool:
|
||||
"""Deliver a system event via Discord webhook."""
|
||||
embed = {"title": title, "description": body, "color": 0xFFA500} # Orange
|
||||
resp = await self._post_json(self.webhook_url, {"embeds": [embed]})
|
||||
return resp.status_code in (200, 204)
|
||||
|
||||
@@ -16,7 +16,7 @@ class GotifyProvider(NotificationProvider):
|
||||
"""Gotify notification provider."""
|
||||
|
||||
def __init__(self, config: "ProviderConfig"):
|
||||
super().__init__()
|
||||
super().__init__(config)
|
||||
server_url = config.server_url.rstrip("/")
|
||||
self.token = config.token
|
||||
self.notification_url = f"{server_url}/message?token={self.token}"
|
||||
@@ -62,3 +62,9 @@ class GotifyProvider(NotificationProvider):
|
||||
return False, f"Gotify API returned status {resp.status_code}"
|
||||
except Exception as e:
|
||||
return False, f"Gotify test failed: {e}"
|
||||
|
||||
async def _deliver_text(self, title: str, body: str) -> bool:
|
||||
"""Deliver a system event via Gotify."""
|
||||
data = {"title": title, "message": body, "priority": 5}
|
||||
resp = await self._post_json(self.notification_url, data)
|
||||
return resp.status_code == 200
|
||||
|
||||
@@ -18,7 +18,7 @@ class PushoverProvider(NotificationProvider):
|
||||
API_URL = "https://api.pushover.net/1/messages.json"
|
||||
|
||||
def __init__(self, config: "ProviderConfig"):
|
||||
super().__init__()
|
||||
super().__init__(config)
|
||||
self.user_key = config.user_key
|
||||
self.api_token = config.api_token
|
||||
|
||||
@@ -35,7 +35,10 @@ class PushoverProvider(NotificationProvider):
|
||||
}
|
||||
|
||||
# Add poster as supplementary URL if available
|
||||
if notification.poster_path and notification.poster_path != "https://mikanani.me":
|
||||
if (
|
||||
notification.poster_path
|
||||
and notification.poster_path != "https://mikanani.me"
|
||||
):
|
||||
data["url"] = notification.poster_path
|
||||
data["url_title"] = "查看海报"
|
||||
|
||||
@@ -67,3 +70,14 @@ class PushoverProvider(NotificationProvider):
|
||||
return False, f"Pushover API returned status {resp.status_code}"
|
||||
except Exception as e:
|
||||
return False, f"Pushover test failed: {e}"
|
||||
|
||||
async def _deliver_text(self, title: str, body: str) -> bool:
|
||||
"""Deliver a system event via Pushover."""
|
||||
data = {
|
||||
"token": self.api_token,
|
||||
"user": self.user_key,
|
||||
"title": title,
|
||||
"message": body,
|
||||
}
|
||||
resp = await self.post_data(self.API_URL, data)
|
||||
return resp.status_code == 200
|
||||
|
||||
@@ -16,7 +16,7 @@ class ServerChanProvider(NotificationProvider):
|
||||
"""Server Chan (Server酱) notification provider for WeChat."""
|
||||
|
||||
def __init__(self, config: "ProviderConfig"):
|
||||
super().__init__()
|
||||
super().__init__(config)
|
||||
token = config.token
|
||||
self.notification_url = f"https://sctapi.ftqq.com/{token}.send"
|
||||
|
||||
@@ -46,3 +46,9 @@ class ServerChanProvider(NotificationProvider):
|
||||
return False, f"Server Chan API returned status {resp.status_code}"
|
||||
except Exception as e:
|
||||
return False, f"Server Chan test failed: {e}"
|
||||
|
||||
async def _deliver_text(self, title: str, body: str) -> bool:
|
||||
"""Deliver a system event via Server Chan."""
|
||||
data = {"title": title, "desp": body}
|
||||
resp = await self.post_data(self.notification_url, data)
|
||||
return resp.status_code == 200
|
||||
|
||||
@@ -17,7 +17,7 @@ class TelegramProvider(NotificationProvider):
|
||||
"""Telegram Bot notification provider."""
|
||||
|
||||
def __init__(self, config: "ProviderConfig"):
|
||||
super().__init__()
|
||||
super().__init__(config)
|
||||
token = config.token
|
||||
self.chat_id = config.chat_id
|
||||
self.photo_url = f"https://api.telegram.org/bot{token}/sendPhoto"
|
||||
@@ -56,3 +56,13 @@ class TelegramProvider(NotificationProvider):
|
||||
return False, f"Telegram API returned status {resp.status_code}"
|
||||
except Exception as e:
|
||||
return False, f"Telegram test failed: {e}"
|
||||
|
||||
async def _deliver_text(self, title: str, body: str) -> bool:
|
||||
"""Deliver a system event via Telegram."""
|
||||
data = {
|
||||
"chat_id": self.chat_id,
|
||||
"text": f"{title}\n{body}",
|
||||
"disable_notification": True,
|
||||
}
|
||||
resp = await self.post_data(self.message_url, data)
|
||||
return resp.status_code == 200
|
||||
|
||||
@@ -29,9 +29,11 @@ class WebhookProvider(NotificationProvider):
|
||||
"""Generic webhook notification provider with customizable templates."""
|
||||
|
||||
def __init__(self, config: "ProviderConfig"):
|
||||
super().__init__()
|
||||
super().__init__(config)
|
||||
self.url = config.url
|
||||
self.template = config.template or DEFAULT_TEMPLATE
|
||||
# Narrower than the base class's `str | None` -- webhook always has a
|
||||
# rendered template (falls back to DEFAULT_TEMPLATE).
|
||||
self.template: str = config.template or DEFAULT_TEMPLATE
|
||||
|
||||
def _render_template(self, notification: Notification) -> dict:
|
||||
"""Render the template with notification data.
|
||||
@@ -96,3 +98,14 @@ class WebhookProvider(NotificationProvider):
|
||||
return False, f"Webhook returned status {resp.status_code}"
|
||||
except Exception as e:
|
||||
return False, f"Webhook test failed: {e}"
|
||||
|
||||
async def _deliver_text(self, title: str, body: str) -> bool:
|
||||
"""Deliver a system event via webhook (fixed minimal JSON shape).
|
||||
|
||||
System events don't use the user-configured episode template -- it
|
||||
expects {{title}}/{{season}}/{{episode}}/{{poster_url}}, which system
|
||||
events don't have.
|
||||
"""
|
||||
data = {"title": title, "message": body}
|
||||
resp = await self._post_json(self.url, data)
|
||||
return 200 <= resp.status_code < 300
|
||||
|
||||
@@ -16,7 +16,7 @@ class WecomProvider(NotificationProvider):
|
||||
"""WeChat Work (企业微信) notification provider using news message format."""
|
||||
|
||||
def __init__(self, config: "ProviderConfig"):
|
||||
super().__init__()
|
||||
super().__init__(config)
|
||||
# Support both webhook_url and legacy chat_id field
|
||||
self.notification_url = config.webhook_url or config.chat_id
|
||||
self.token = config.token
|
||||
@@ -56,3 +56,9 @@ class WecomProvider(NotificationProvider):
|
||||
return False, f"WeChat Work API returned status {resp.status_code}"
|
||||
except Exception as e:
|
||||
return False, f"WeChat Work test failed: {e}"
|
||||
|
||||
async def _deliver_text(self, title: str, body: str) -> bool:
|
||||
"""Deliver a system event via WeChat Work."""
|
||||
data = {"key": self.token, "type": "news", "title": title, "msg": body}
|
||||
resp = await self.post_data(self.notification_url, data)
|
||||
return resp.status_code == 200
|
||||
|
||||
@@ -11,6 +11,11 @@ from module.database.bangumi import match_bangumi_in_list
|
||||
from module.downloader import DownloadClient
|
||||
from module.models import Bangumi, ResponseModel, RSSItem, Torrent
|
||||
from module.network import RequestContent
|
||||
from module.notification.events import (
|
||||
DownloadFailureEvent,
|
||||
RssFailureEvent,
|
||||
SystemEvent,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -147,7 +152,9 @@ class RSSEngine:
|
||||
return matched
|
||||
return None
|
||||
|
||||
async def refresh_rss(self, client: DownloadClient, rss_id: Optional[int] = None):
|
||||
async def refresh_rss(
|
||||
self, client: DownloadClient, rss_id: Optional[int] = None
|
||||
) -> list[SystemEvent]:
|
||||
# Get All RSS Items
|
||||
if not rss_id:
|
||||
rss_items: list[RSSItem] = await self.db.rss.search_active()
|
||||
@@ -185,24 +192,46 @@ class RSSEngine:
|
||||
# torrent against it in memory (this is the job the old module-level
|
||||
# TTL cache existed to do).
|
||||
bangumi_list = await self.db.bangumi.search_all()
|
||||
events: list[SystemEvent] = []
|
||||
# Process results sequentially (DB operations)
|
||||
for rss_item, (new_torrents, error) in item_results:
|
||||
# Update connection status
|
||||
# Update connection status. Only notify on the working->error
|
||||
# transition (previous_status read before it's overwritten below),
|
||||
# not on every tick a feed stays broken.
|
||||
previous_status = rss_item.connection_status
|
||||
rss_item.connection_status = "error" if error else "healthy"
|
||||
rss_item.last_checked_at = now
|
||||
rss_item.last_error = error
|
||||
self.db.add(rss_item)
|
||||
if error and previous_status != "error":
|
||||
events.append(
|
||||
RssFailureEvent(
|
||||
rss_name=rss_item.name or rss_item.url,
|
||||
rss_url=rss_item.url,
|
||||
error=error,
|
||||
)
|
||||
)
|
||||
for torrent in new_torrents:
|
||||
matched_data = self.match_torrent(torrent, bangumi_list)
|
||||
if matched_data:
|
||||
if await client.add_torrent(torrent, matched_data):
|
||||
logger.debug("[Engine] Add torrent %s to client", torrent.name)
|
||||
torrent.downloaded = True
|
||||
# else: leave downloaded=False so a transient add failure
|
||||
# (e.g. downloader unreachable) gets retried next tick.
|
||||
else:
|
||||
# add_torrent() already retried the underlying fetch
|
||||
# internally (RequestURL.get_url, retry=3); leave
|
||||
# downloaded=False so a future tick can retry, and
|
||||
# surface the failure now.
|
||||
events.append(
|
||||
DownloadFailureEvent(
|
||||
official_title=matched_data.official_title,
|
||||
torrent_name=torrent.name,
|
||||
)
|
||||
)
|
||||
# Add all torrents to database
|
||||
await self.db.torrent.add_all(new_torrents)
|
||||
await self.db.commit()
|
||||
return events
|
||||
|
||||
async def download_bangumi(self, bangumi: Bangumi):
|
||||
async with RequestContent() as req:
|
||||
|
||||
125
backend/src/test/test_loops.py
Normal file
125
backend/src/test/test_loops.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Tests for core/loops.py: notifier wiring for system events.
|
||||
|
||||
Covers the new call sites added for non-episode notifications (RSS failure,
|
||||
download failure, offset review) -- the engine/scanner logic that produces
|
||||
these events is already covered by test_rss_engine_new.py and
|
||||
test_offset_scanner.py; these tests exercise the tick-level glue that turns
|
||||
returned events into ``notifier.send_event`` calls, gated by
|
||||
``settings.notification.enable``.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from module.core.loops import offset_scan_tick, rss_tick
|
||||
from module.notification.events import OffsetReviewEvent, RssFailureEvent
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# offset_scan_tick
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOffsetScanTick:
|
||||
async def test_sends_event_per_flagged_bangumi_when_enabled(self):
|
||||
"""Each event scan_all() returns is forwarded to the notifier."""
|
||||
event = OffsetReviewEvent(official_title="Test Anime", reason="mismatch")
|
||||
notifier = AsyncMock()
|
||||
|
||||
with patch("module.core.loops.OffsetScanner") as mock_scanner_cls:
|
||||
mock_scanner_cls.return_value.scan_all = AsyncMock(return_value=[event])
|
||||
with patch("module.core.loops.settings") as mock_settings:
|
||||
mock_settings.notification.enable = True
|
||||
await offset_scan_tick(notifier)
|
||||
|
||||
notifier.send_event.assert_awaited_once_with(event)
|
||||
|
||||
async def test_no_events_sent_when_notifications_disabled(self):
|
||||
"""settings.notification.enable=False suppresses all sends."""
|
||||
event = OffsetReviewEvent(official_title="Test Anime", reason="mismatch")
|
||||
notifier = AsyncMock()
|
||||
|
||||
with patch("module.core.loops.OffsetScanner") as mock_scanner_cls:
|
||||
mock_scanner_cls.return_value.scan_all = AsyncMock(return_value=[event])
|
||||
with patch("module.core.loops.settings") as mock_settings:
|
||||
mock_settings.notification.enable = False
|
||||
await offset_scan_tick(notifier)
|
||||
|
||||
notifier.send_event.assert_not_awaited()
|
||||
|
||||
async def test_no_events_flagged_sends_nothing(self):
|
||||
"""An empty scan result sends no notifications."""
|
||||
notifier = AsyncMock()
|
||||
|
||||
with patch("module.core.loops.OffsetScanner") as mock_scanner_cls:
|
||||
mock_scanner_cls.return_value.scan_all = AsyncMock(return_value=[])
|
||||
with patch("module.core.loops.settings") as mock_settings:
|
||||
mock_settings.notification.enable = True
|
||||
await offset_scan_tick(notifier)
|
||||
|
||||
notifier.send_event.assert_not_awaited()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# rss_tick
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _async_cm(entered_value):
|
||||
"""Build a mock usable as ``async with X() as y:`` yielding entered_value."""
|
||||
cm = AsyncMock()
|
||||
cm.__aenter__ = AsyncMock(return_value=entered_value)
|
||||
cm.__aexit__ = AsyncMock(return_value=False)
|
||||
return cm
|
||||
|
||||
|
||||
class TestRssTick:
|
||||
async def test_sends_events_returned_by_refresh_rss(self):
|
||||
"""Every event refresh_rss() returns is forwarded to the notifier."""
|
||||
events = [RssFailureEvent(rss_name="Feed", rss_url="u", error="e")]
|
||||
notifier = AsyncMock()
|
||||
analyser = AsyncMock()
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_db.rss.search_aggregate = AsyncMock(return_value=[])
|
||||
mock_engine = AsyncMock()
|
||||
mock_engine.refresh_rss = AsyncMock(return_value=events)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"module.core.loops.DownloadClient",
|
||||
return_value=_async_cm(AsyncMock()),
|
||||
),
|
||||
patch("module.core.loops.Database", return_value=_async_cm(mock_db)),
|
||||
patch("module.core.loops.RSSEngine", return_value=mock_engine),
|
||||
patch("module.core.loops.settings") as mock_settings,
|
||||
):
|
||||
mock_settings.notification.enable = True
|
||||
mock_settings.bangumi_manage.eps_complete = False
|
||||
await rss_tick(analyser, notifier)
|
||||
|
||||
notifier.send_event.assert_awaited_once_with(events[0])
|
||||
|
||||
async def test_no_events_sent_when_notifications_disabled(self):
|
||||
"""settings.notification.enable=False suppresses all sends."""
|
||||
events = [RssFailureEvent(rss_name="Feed", rss_url="u", error="e")]
|
||||
notifier = AsyncMock()
|
||||
analyser = AsyncMock()
|
||||
|
||||
mock_db = AsyncMock()
|
||||
mock_db.rss.search_aggregate = AsyncMock(return_value=[])
|
||||
mock_engine = AsyncMock()
|
||||
mock_engine.refresh_rss = AsyncMock(return_value=events)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"module.core.loops.DownloadClient",
|
||||
return_value=_async_cm(AsyncMock()),
|
||||
),
|
||||
patch("module.core.loops.Database", return_value=_async_cm(mock_db)),
|
||||
patch("module.core.loops.RSSEngine", return_value=mock_engine),
|
||||
patch("module.core.loops.settings") as mock_settings,
|
||||
):
|
||||
mock_settings.notification.enable = False
|
||||
mock_settings.bangumi_manage.eps_complete = False
|
||||
await rss_tick(analyser, notifier)
|
||||
|
||||
notifier.send_event.assert_not_awaited()
|
||||
@@ -6,7 +6,9 @@ import pytest
|
||||
|
||||
from module.models import Notification
|
||||
from module.models.config import NotificationProvider as ProviderConfig
|
||||
from module.network import RequestContent
|
||||
from module.notification import PROVIDER_REGISTRY, NotificationManager
|
||||
from module.notification.events import OffsetReviewEvent, RssFailureEvent
|
||||
from module.notification.providers import (
|
||||
BarkProvider,
|
||||
DiscordProvider,
|
||||
@@ -83,7 +85,9 @@ class TestNotificationManager:
|
||||
|
||||
def test_load_single_provider(self, mock_settings):
|
||||
"""Manager loads a single enabled provider."""
|
||||
config = ProviderConfig(type="telegram", enabled=True, token="test", chat_id="123")
|
||||
config = ProviderConfig(
|
||||
type="telegram", enabled=True, token="test", chat_id="123"
|
||||
)
|
||||
mock_settings.notification.providers = [config]
|
||||
|
||||
manager = NotificationManager()
|
||||
@@ -92,7 +96,9 @@ class TestNotificationManager:
|
||||
|
||||
def test_skip_disabled_provider(self, mock_settings):
|
||||
"""Manager skips disabled providers."""
|
||||
config = ProviderConfig(type="telegram", enabled=False, token="test", chat_id="123")
|
||||
config = ProviderConfig(
|
||||
type="telegram", enabled=False, token="test", chat_id="123"
|
||||
)
|
||||
mock_settings.notification.providers = [config]
|
||||
|
||||
manager = NotificationManager()
|
||||
@@ -102,7 +108,9 @@ class TestNotificationManager:
|
||||
"""Manager loads multiple enabled providers."""
|
||||
configs = [
|
||||
ProviderConfig(type="telegram", enabled=True, token="test", chat_id="123"),
|
||||
ProviderConfig(type="discord", enabled=True, webhook_url="https://discord.com/webhook"),
|
||||
ProviderConfig(
|
||||
type="discord", enabled=True, webhook_url="https://discord.com/webhook"
|
||||
),
|
||||
ProviderConfig(type="bark", enabled=True, device_key="device123"),
|
||||
]
|
||||
mock_settings.notification.providers = configs
|
||||
@@ -128,7 +136,9 @@ class TestNotificationManager:
|
||||
"""Manager sends to all providers."""
|
||||
configs = [
|
||||
ProviderConfig(type="telegram", enabled=True, token="test", chat_id="123"),
|
||||
ProviderConfig(type="discord", enabled=True, webhook_url="https://discord.com/webhook"),
|
||||
ProviderConfig(
|
||||
type="discord", enabled=True, webhook_url="https://discord.com/webhook"
|
||||
),
|
||||
]
|
||||
mock_settings.notification.providers = configs
|
||||
|
||||
@@ -146,11 +156,13 @@ class TestNotificationManager:
|
||||
await manager.send_all(notify)
|
||||
|
||||
for provider in manager.providers:
|
||||
provider.send.assert_called_once_with(notify)
|
||||
provider.send.assert_called_once_with(notify) # type: ignore[attr-defined]
|
||||
|
||||
async def test_test_provider(self, mock_settings):
|
||||
"""Manager can test a specific provider."""
|
||||
config = ProviderConfig(type="telegram", enabled=True, token="test", chat_id="123")
|
||||
config = ProviderConfig(
|
||||
type="telegram", enabled=True, token="test", chat_id="123"
|
||||
)
|
||||
mock_settings.notification.providers = [config]
|
||||
|
||||
manager = NotificationManager()
|
||||
@@ -182,18 +194,25 @@ class TestNotificationManager:
|
||||
class TestTelegramProvider:
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
config = ProviderConfig(type="telegram", enabled=True, token="test_token", chat_id="12345")
|
||||
config = ProviderConfig(
|
||||
type="telegram", enabled=True, token="test_token", chat_id="12345"
|
||||
)
|
||||
return TelegramProvider(config)
|
||||
|
||||
async def test_send_with_photo(self, provider):
|
||||
"""Sends photo when poster available."""
|
||||
notify = Notification(
|
||||
official_title="Test Anime", season=1, episode=5, poster_path="/path/to/poster.jpg"
|
||||
official_title="Test Anime",
|
||||
season=1,
|
||||
episode=5,
|
||||
poster_path="/path/to/poster.jpg",
|
||||
)
|
||||
|
||||
with patch.object(provider, "post_files", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = MagicMock(status_code=200)
|
||||
with patch("module.notification.providers.telegram.load_image") as mock_load:
|
||||
with patch(
|
||||
"module.notification.providers.telegram.load_image"
|
||||
) as mock_load:
|
||||
mock_load.return_value = b"image_data"
|
||||
result = await provider.send(notify)
|
||||
|
||||
@@ -206,7 +225,9 @@ class TestTelegramProvider:
|
||||
|
||||
with patch.object(provider, "post_data", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = MagicMock(status_code=200)
|
||||
with patch("module.notification.providers.telegram.load_image") as mock_load:
|
||||
with patch(
|
||||
"module.notification.providers.telegram.load_image"
|
||||
) as mock_load:
|
||||
mock_load.return_value = None
|
||||
result = await provider.send(notify)
|
||||
|
||||
@@ -227,14 +248,19 @@ class TestDiscordProvider:
|
||||
@pytest.fixture
|
||||
def provider(self):
|
||||
config = ProviderConfig(
|
||||
type="discord", enabled=True, webhook_url="https://discord.com/api/webhooks/123"
|
||||
type="discord",
|
||||
enabled=True,
|
||||
webhook_url="https://discord.com/api/webhooks/123",
|
||||
)
|
||||
return DiscordProvider(config)
|
||||
|
||||
async def test_send(self, provider):
|
||||
"""Sends embed message."""
|
||||
notify = Notification(
|
||||
official_title="Test Anime", season=1, episode=5, poster_path="https://example.com/poster.jpg"
|
||||
official_title="Test Anime",
|
||||
season=1,
|
||||
episode=5,
|
||||
poster_path="https://example.com/poster.jpg",
|
||||
)
|
||||
|
||||
with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post:
|
||||
@@ -295,6 +321,259 @@ class TestWebhookProvider:
|
||||
assert result is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Composition over inheritance (base.py no longer inherits RequestContent)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProviderComposition:
|
||||
def test_provider_composes_but_does_not_inherit_request_content(self):
|
||||
"""A provider holds a RequestContent instance rather than being one."""
|
||||
config = ProviderConfig(type="bark", enabled=True, device_key="device123")
|
||||
provider = BarkProvider(config)
|
||||
|
||||
assert isinstance(provider._http, RequestContent)
|
||||
assert not isinstance(provider, RequestContent)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-episode message templates (base._format_message)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFormatMessageTemplate:
|
||||
def test_format_message_no_template_uses_default_message(self):
|
||||
"""Without a template, existing configs keep the exact default message."""
|
||||
config = ProviderConfig(
|
||||
type="gotify", enabled=True, server_url="https://gotify.example", token="t"
|
||||
)
|
||||
provider = GotifyProvider(config)
|
||||
notify = Notification(official_title="Test Anime", season=1, episode=5)
|
||||
|
||||
message = provider._format_message(notify)
|
||||
|
||||
assert message == ("番剧名称:Test Anime\n季度: 第1季\n更新集数: 第5集")
|
||||
|
||||
def test_format_message_with_template_substitutes_variables(self):
|
||||
"""A configured template replaces {{title}}/{{season}}/{{episode}}."""
|
||||
config = ProviderConfig(
|
||||
type="gotify",
|
||||
enabled=True,
|
||||
server_url="https://gotify.example",
|
||||
token="t",
|
||||
template="{{title}} S{{season}}E{{episode}}",
|
||||
)
|
||||
provider = GotifyProvider(config)
|
||||
notify = Notification(official_title="Test Anime", season=1, episode=5)
|
||||
|
||||
assert provider._format_message(notify) == "Test Anime S1E5"
|
||||
|
||||
|
||||
class TestDiscordTemplate:
|
||||
async def test_send_no_template_keeps_default_embed_description(self):
|
||||
"""No template configured: Discord's default embed text is unchanged."""
|
||||
config = ProviderConfig(
|
||||
type="discord", enabled=True, webhook_url="https://discord.com/webhook"
|
||||
)
|
||||
provider = DiscordProvider(config)
|
||||
notify = Notification(official_title="Test Anime", season=1, episode=5)
|
||||
|
||||
with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = MagicMock(status_code=204)
|
||||
await provider.send(notify)
|
||||
|
||||
embed = mock_post.call_args[0][1]["embeds"][0]
|
||||
assert embed["description"] == "**季度:** 第1季\n**集数:** 第5集"
|
||||
|
||||
async def test_send_with_template_uses_rendered_description(self):
|
||||
"""A configured template overrides the embed description."""
|
||||
config = ProviderConfig(
|
||||
type="discord",
|
||||
enabled=True,
|
||||
webhook_url="https://discord.com/webhook",
|
||||
template="{{title}} ep {{episode}}",
|
||||
)
|
||||
provider = DiscordProvider(config)
|
||||
notify = Notification(official_title="Test Anime", season=1, episode=5)
|
||||
|
||||
with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = MagicMock(status_code=204)
|
||||
await provider.send(notify)
|
||||
|
||||
embed = mock_post.call_args[0][1]["embeds"][0]
|
||||
assert embed["description"] == "Test Anime ep 5"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System events (RSS failure / download failure / offset review)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSendEvent:
|
||||
async def test_send_event_delivers_title_and_body_from_event(self):
|
||||
"""send_event() formats the event and hands (title, body) to _deliver_text."""
|
||||
config = ProviderConfig(type="bark", enabled=True, device_key="device123")
|
||||
provider = BarkProvider(config)
|
||||
event = RssFailureEvent(
|
||||
rss_name="My Feed", rss_url="https://x.example/rss", error="timeout"
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
provider, "_deliver_text", new_callable=AsyncMock
|
||||
) as mock_deliver:
|
||||
mock_deliver.return_value = True
|
||||
result = await provider.send_event(event)
|
||||
|
||||
assert result is True
|
||||
title, body = mock_deliver.call_args[0]
|
||||
assert title == "RSS 订阅连接异常"
|
||||
assert "My Feed" in body
|
||||
assert "timeout" in body
|
||||
|
||||
async def test_manager_send_event_broadcasts_to_all_providers(self):
|
||||
"""NotificationManager.send_event fans out to every enabled provider."""
|
||||
with patch("module.notification.manager.settings") as mock_settings:
|
||||
configs = [
|
||||
ProviderConfig(
|
||||
type="telegram", enabled=True, token="test", chat_id="123"
|
||||
),
|
||||
ProviderConfig(type="bark", enabled=True, device_key="device123"),
|
||||
]
|
||||
mock_settings.notification.providers = configs
|
||||
manager = NotificationManager()
|
||||
|
||||
for provider in manager.providers:
|
||||
provider.send_event = AsyncMock(return_value=True)
|
||||
provider.__aenter__ = AsyncMock(return_value=provider)
|
||||
provider.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
event = OffsetReviewEvent(official_title="Test Anime", reason="mismatch")
|
||||
await manager.send_event(event)
|
||||
|
||||
for provider in manager.providers:
|
||||
provider.send_event.assert_called_once_with( # type: ignore[attr-defined]
|
||||
event
|
||||
)
|
||||
|
||||
|
||||
class TestDeliverText:
|
||||
"""Each provider's own ``_deliver_text`` hook, exercised directly."""
|
||||
|
||||
async def test_bark_deliver_text_posts_title_and_body(self):
|
||||
config = ProviderConfig(type="bark", enabled=True, device_key="device123")
|
||||
provider = BarkProvider(config)
|
||||
|
||||
with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = MagicMock(status_code=200)
|
||||
result = await provider._deliver_text("标题", "正文")
|
||||
|
||||
assert result is True
|
||||
data = mock_post.call_args[0][1]
|
||||
assert data["title"] == "标题"
|
||||
assert data["body"] == "正文"
|
||||
|
||||
async def test_discord_deliver_text_posts_embed(self):
|
||||
config = ProviderConfig(
|
||||
type="discord", enabled=True, webhook_url="https://discord.com/webhook"
|
||||
)
|
||||
provider = DiscordProvider(config)
|
||||
|
||||
with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = MagicMock(status_code=204)
|
||||
result = await provider._deliver_text("标题", "正文")
|
||||
|
||||
assert result is True
|
||||
embed = mock_post.call_args[0][1]["embeds"][0]
|
||||
assert embed["title"] == "标题"
|
||||
assert embed["description"] == "正文"
|
||||
|
||||
async def test_gotify_deliver_text_posts_title_and_message(self):
|
||||
config = ProviderConfig(
|
||||
type="gotify", enabled=True, server_url="https://gotify.example", token="t"
|
||||
)
|
||||
provider = GotifyProvider(config)
|
||||
|
||||
with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = MagicMock(status_code=200)
|
||||
result = await provider._deliver_text("标题", "正文")
|
||||
|
||||
assert result is True
|
||||
data = mock_post.call_args[0][1]
|
||||
assert data["title"] == "标题"
|
||||
assert data["message"] == "正文"
|
||||
|
||||
async def test_pushover_deliver_text_posts_title_and_message(self):
|
||||
config = ProviderConfig(
|
||||
type="pushover", enabled=True, user_key="u", api_token="a"
|
||||
)
|
||||
provider = PushoverProvider(config)
|
||||
|
||||
with patch.object(provider, "post_data", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = MagicMock(status_code=200)
|
||||
result = await provider._deliver_text("标题", "正文")
|
||||
|
||||
assert result is True
|
||||
data = mock_post.call_args[0][1]
|
||||
assert data["title"] == "标题"
|
||||
assert data["message"] == "正文"
|
||||
|
||||
async def test_server_chan_deliver_text_posts_title_and_desp(self):
|
||||
config = ProviderConfig(type="server-chan", enabled=True, token="t")
|
||||
provider = ServerChanProvider(config)
|
||||
|
||||
with patch.object(provider, "post_data", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = MagicMock(status_code=200)
|
||||
result = await provider._deliver_text("标题", "正文")
|
||||
|
||||
assert result is True
|
||||
data = mock_post.call_args[0][1]
|
||||
assert data["title"] == "标题"
|
||||
assert data["desp"] == "正文"
|
||||
|
||||
async def test_telegram_deliver_text_posts_combined_text(self):
|
||||
config = ProviderConfig(
|
||||
type="telegram", enabled=True, token="test_token", chat_id="12345"
|
||||
)
|
||||
provider = TelegramProvider(config)
|
||||
|
||||
with patch.object(provider, "post_data", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = MagicMock(status_code=200)
|
||||
result = await provider._deliver_text("标题", "正文")
|
||||
|
||||
assert result is True
|
||||
data = mock_post.call_args[0][1]
|
||||
assert data["text"] == "标题\n正文"
|
||||
|
||||
async def test_webhook_deliver_text_posts_title_and_message(self):
|
||||
config = ProviderConfig(
|
||||
type="webhook", enabled=True, url="https://example.com/webhook"
|
||||
)
|
||||
provider = WebhookProvider(config)
|
||||
|
||||
with patch.object(provider, "_post_json", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = MagicMock(status_code=200)
|
||||
result = await provider._deliver_text("标题", "正文")
|
||||
|
||||
assert result is True
|
||||
data = mock_post.call_args[0][1]
|
||||
assert data == {"title": "标题", "message": "正文"}
|
||||
|
||||
async def test_wecom_deliver_text_posts_title_and_msg(self):
|
||||
config = ProviderConfig(
|
||||
type="wecom", enabled=True, webhook_url="https://wecom.example/webhook"
|
||||
)
|
||||
provider = WecomProvider(config)
|
||||
|
||||
with patch.object(provider, "post_data", new_callable=AsyncMock) as mock_post:
|
||||
mock_post.return_value = MagicMock(status_code=200)
|
||||
result = await provider._deliver_text("标题", "正文")
|
||||
|
||||
assert result is True
|
||||
data = mock_post.call_args[0][1]
|
||||
assert data["title"] == "标题"
|
||||
assert data["msg"] == "正文"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config Migration
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -322,7 +601,9 @@ class TestConfigMigration:
|
||||
"""New format with providers doesn't trigger migration."""
|
||||
from module.models.config import Notification as NotificationConfig
|
||||
|
||||
provider = ProviderConfig(type="discord", enabled=True, webhook_url="https://discord.com/webhook")
|
||||
provider = ProviderConfig(
|
||||
type="discord", enabled=True, webhook_url="https://discord.com/webhook"
|
||||
)
|
||||
new_config = NotificationConfig(
|
||||
enable=True,
|
||||
providers=[provider],
|
||||
|
||||
@@ -8,6 +8,7 @@ import pytest_asyncio
|
||||
|
||||
from module.database import Database
|
||||
from module.models import Torrent
|
||||
from module.notification.events import DownloadFailureEvent, RssFailureEvent
|
||||
from module.rss.engine import RSSEngine
|
||||
from test.factories import make_bangumi, make_rss_item, make_torrent
|
||||
|
||||
@@ -272,6 +273,116 @@ class TestRefreshRss:
|
||||
mock_get.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# refresh_rss notification events (RSS failure transition, download failure)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRefreshRssEvents:
|
||||
async def test_healthy_to_error_transition_returns_rss_failure_event(
|
||||
self, rss_engine
|
||||
):
|
||||
"""A feed going from healthy to error is reported exactly once."""
|
||||
rss_item = make_rss_item(
|
||||
name="Feed 1", url="https://feed1.example/rss", enabled=True
|
||||
)
|
||||
await rss_engine.db.rss.add(rss_item)
|
||||
|
||||
with patch.object(
|
||||
RSSEngine, "_pull_rss_with_status", new_callable=AsyncMock
|
||||
) as mock_pull:
|
||||
mock_pull.return_value = ([], "connection refused")
|
||||
client = AsyncMock()
|
||||
events = await rss_engine.refresh_rss(client)
|
||||
|
||||
assert len(events) == 1
|
||||
assert isinstance(events[0], RssFailureEvent)
|
||||
assert events[0].rss_name == "Feed 1"
|
||||
assert events[0].error == "connection refused"
|
||||
|
||||
async def test_error_stays_error_does_not_repeat_event(self, rss_engine):
|
||||
"""A feed already in 'error' does not re-fire on every subsequent tick."""
|
||||
rss_item = make_rss_item(url="https://feed1.example/rss", enabled=True)
|
||||
await rss_engine.db.rss.add(rss_item)
|
||||
|
||||
with patch.object(
|
||||
RSSEngine, "_pull_rss_with_status", new_callable=AsyncMock
|
||||
) as mock_pull:
|
||||
mock_pull.return_value = ([], "connection refused")
|
||||
client = AsyncMock()
|
||||
first_events = await rss_engine.refresh_rss(client)
|
||||
second_events = await rss_engine.refresh_rss(client)
|
||||
|
||||
assert len(first_events) == 1
|
||||
assert second_events == []
|
||||
|
||||
async def test_no_error_returns_no_rss_failure_event(self, rss_engine):
|
||||
"""A healthy feed produces no failure event."""
|
||||
rss_item = make_rss_item(url="https://feed1.example/rss", enabled=True)
|
||||
await rss_engine.db.rss.add(rss_item)
|
||||
|
||||
with patch.object(
|
||||
RSSEngine, "_pull_rss_with_status", new_callable=AsyncMock
|
||||
) as mock_pull:
|
||||
mock_pull.return_value = ([], None)
|
||||
client = AsyncMock()
|
||||
events = await rss_engine.refresh_rss(client)
|
||||
|
||||
assert events == []
|
||||
|
||||
async def test_add_torrent_failure_returns_download_failure_event(self, rss_engine):
|
||||
"""A matched torrent that fails to add is reported as a download failure."""
|
||||
rss_item = make_rss_item(enabled=True)
|
||||
await rss_engine.db.rss.add(rss_item)
|
||||
bangumi = make_bangumi(title_raw="Mushoku Tensei", filter="")
|
||||
await rss_engine.db.bangumi.add(bangumi)
|
||||
|
||||
new_torrent = Torrent(
|
||||
name="[Sub] Mushoku Tensei - 12 [1080p].mkv",
|
||||
url="https://example.com/ep12.torrent",
|
||||
)
|
||||
with patch.object(
|
||||
RSSEngine, "_get_torrents", new_callable=AsyncMock
|
||||
) as mock_get:
|
||||
mock_get.return_value = [new_torrent]
|
||||
client = AsyncMock()
|
||||
client.add_torrent = AsyncMock(return_value=False)
|
||||
|
||||
events = await rss_engine.refresh_rss(client)
|
||||
|
||||
assert len(events) == 1
|
||||
assert isinstance(events[0], DownloadFailureEvent)
|
||||
assert events[0].official_title == bangumi.official_title
|
||||
assert events[0].torrent_name == new_torrent.name
|
||||
|
||||
all_torrents = await rss_engine.db.torrent.search_all()
|
||||
assert all_torrents[0].downloaded is False
|
||||
|
||||
async def test_add_torrent_success_returns_no_download_failure_event(
|
||||
self, rss_engine, mock_qb_client
|
||||
):
|
||||
"""A torrent that adds successfully produces no failure event."""
|
||||
rss_item = make_rss_item(enabled=True)
|
||||
await rss_engine.db.rss.add(rss_item)
|
||||
bangumi = make_bangumi(title_raw="Mushoku Tensei", filter="")
|
||||
await rss_engine.db.bangumi.add(bangumi)
|
||||
|
||||
new_torrent = Torrent(
|
||||
name="[Sub] Mushoku Tensei - 12 [1080p].mkv",
|
||||
url="https://example.com/ep12.torrent",
|
||||
)
|
||||
with patch.object(
|
||||
RSSEngine, "_get_torrents", new_callable=AsyncMock
|
||||
) as mock_get:
|
||||
mock_get.return_value = [new_torrent]
|
||||
client = AsyncMock()
|
||||
client.add_torrent = AsyncMock(return_value=True)
|
||||
|
||||
events = await rss_engine.refresh_rss(client)
|
||||
|
||||
assert events == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# add_rss
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user