feat: 支持配置通知器,在视频源处理或整个下载任务出现错误时会触发消息通知 (#526)

This commit is contained in:
ᴀᴍᴛᴏᴀᴇʀ
2025-11-07 20:37:09 +08:00
committed by GitHub
parent a871db655f
commit 8ac6829e61
9 changed files with 350 additions and 5 deletions

View File

@@ -134,4 +134,8 @@ impl BiliClient {
pub async fn wbi_img(&self, credential: &Credential) -> Result<WbiImg> {
credential.wbi_img(&self.client).await
}
pub fn inner_client(&self) -> &reqwest::Client {
&self.client.0
}
}

View File

@@ -11,6 +11,7 @@ use crate::config::default::{default_auth_token, default_bind_address, default_t
use crate::config::item::{
ConcurrentLimit, NFOTimeType, SkipOption, default_collection_path, default_favorite_path, default_submission_path,
};
use crate::notifier::Notifier;
use crate::utils::model::{load_db_config, save_db_config};
pub static CONFIG_DIR: LazyLock<PathBuf> =
@@ -27,6 +28,8 @@ pub struct Config {
pub skip_option: SkipOption,
pub video_name: String,
pub page_name: String,
#[serde(default)]
pub notifiers: Option<Vec<Notifier>>,
#[serde(default = "default_favorite_path")]
pub favorite_default_path: String,
#[serde(default = "default_collection_path")]
@@ -98,6 +101,7 @@ impl Default for Config {
skip_option: SkipOption::default(),
video_name: "{{title}}".to_owned(),
page_name: "{{bvid}}".to_owned(),
notifiers: None,
favorite_default_path: default_favorite_path(),
collection_default_path: default_collection_path(),
submission_default_path: default_submission_path(),

View File

@@ -8,7 +8,7 @@ use tokio::sync::{OnceCell, watch};
use crate::bilibili::Credential;
use crate::config::{CONFIG_DIR, Config};
pub static VERSIONED_CONFIG: OnceCell<VersionedConfig> = OnceCell::const_new();
static VERSIONED_CONFIG: OnceCell<VersionedConfig> = OnceCell::const_new();
pub struct VersionedConfig {
inner: ArcSwap<Config>,

View File

@@ -8,6 +8,7 @@ mod config;
mod database;
mod downloader;
mod error;
mod notifier;
mod task;
mod utils;
mod workflow;

View File

@@ -0,0 +1,46 @@
use anyhow::Result;
use futures::future;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase", tag = "type")]
pub enum Notifier {
Telegram { bot_token: String, chat_id: String },
Webhook { url: String },
}
#[derive(Serialize)]
struct WebhookPayload<'a> {
text: &'a str,
}
pub trait NotifierAllExt {
async fn notify_all(&self, client: &reqwest::Client, message: &str) -> Result<()>;
}
impl NotifierAllExt for Option<Vec<Notifier>> {
async fn notify_all(&self, client: &reqwest::Client, message: &str) -> Result<()> {
let Some(notifiers) = self else {
return Ok(());
};
future::join_all(notifiers.iter().map(|notifier| notifier.notify(client, message))).await;
Ok(())
}
}
impl Notifier {
pub async fn notify(&self, client: &reqwest::Client, message: &str) -> Result<()> {
match self {
Notifier::Telegram { bot_token, chat_id } => {
let url = format!("https://api.telegram.org/bot{}/sendMessage", bot_token);
let params = [("chat_id", chat_id.as_str()), ("text", message)];
client.post(&url).form(&params).send().await?;
}
Notifier::Webhook { url } => {
let payload = WebhookPayload { text: message };
client.post(url).json(&payload).send().await?;
}
}
Ok(())
}
}

View File

@@ -8,6 +8,7 @@ use tokio::time;
use crate::adapter::VideoSource;
use crate::bilibili::{self, BiliClient};
use crate::config::{Config, TEMPLATE, VersionedConfig};
use crate::notifier::NotifierAllExt;
use crate::utils::model::get_enabled_video_sources;
use crate::utils::task_notifier::TASK_STATUS_NOTIFIER;
use crate::workflow::process_video_source;
@@ -20,7 +21,12 @@ pub async fn video_downloader(connection: DatabaseConnection, bili_client: Arc<B
let mut config = VersionedConfig::get().snapshot();
info!("开始执行本轮视频下载任务..");
if let Err(e) = download_all_video_sources(&connection, &bili_client, &mut config, &mut anchor).await {
error!("本轮视频下载任务执行遇到错误:{:#},跳过本轮执行", e);
let error_msg = format!("本轮视频下载任务执行遇到错误:{:#}", e);
error!("{error_msg}");
let _ = config
.notifiers
.notify_all(bili_client.inner_client(), &error_msg)
.await;
} else {
info!("本轮视频下载任务执行完毕");
}
@@ -67,7 +73,12 @@ async fn download_all_video_sources(
for video_source in video_sources {
let display_name = video_source.display_name();
if let Err(e) = process_video_source(video_source, &bili_client, connection, &template, config).await {
error!("处理 {} 时遇到错误:{:#},跳过该视频源", display_name, e);
let error_msg = format!("处理 {} 时遇到错误:{:#},跳过该视频源", display_name, e);
error!("{error_msg}");
let _ = config
.notifiers
.notify_all(bili_client.inner_client(), &error_msg)
.await;
}
}
Ok(())

View File

@@ -272,6 +272,20 @@ export interface ConcurrentLimit {
download: ConcurrentDownloadLimit;
}
// Notifier 相关类型
export interface TelegramNotifier {
type: 'telegram';
bot_token: string;
chat_id: string;
}
export interface WebhookNotifier {
type: 'webhook';
url: string;
}
export type Notifier = TelegramNotifier | WebhookNotifier;
export interface Config {
auth_token: string;
bind_address: string;
@@ -281,6 +295,7 @@ export interface Config {
skip_option: SkipOption;
video_name: string;
page_name: string;
notifiers: Notifier[] | null;
favorite_default_path: string;
collection_default_path: string;
submission_default_path: string;

View File

@@ -7,11 +7,13 @@
import * as Tabs from '$lib/components/ui/tabs/index.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import * as Dialog from '$lib/components/ui/dialog/index.js';
import PasswordInput from '$lib/components/custom/password-input.svelte';
import NotifierDialog from './NotifierDialog.svelte';
import api from '$lib/api';
import { toast } from 'svelte-sonner';
import { setBreadcrumb } from '$lib/stores/breadcrumb';
import type { Config, ApiError } from '$lib/types';
import type { Config, ApiError, Notifier } from '$lib/types';
let frontendToken = ''; // 前端认证token
let config: Config | null = null;
@@ -19,6 +21,55 @@
let saving = false;
let loading = false;
// Notifier 管理相关
let showNotifierDialog = false;
let editingNotifier: Notifier | null = null;
let editingNotifierIndex: number | null = null;
let isEditing = false;
function openAddNotifierDialog() {
editingNotifier = null;
editingNotifierIndex = null;
isEditing = false;
showNotifierDialog = true;
}
function openEditNotifierDialog(notifier: Notifier, index: number) {
editingNotifier = { ...notifier };
editingNotifierIndex = index;
isEditing = true;
showNotifierDialog = true;
}
function closeNotifierDialog() {
showNotifierDialog = false;
editingNotifier = null;
editingNotifierIndex = null;
isEditing = false;
}
function addNotifier(notifier: Notifier) {
if (!formData) return;
if (!formData.notifiers) {
formData.notifiers = [];
}
formData.notifiers = [...formData.notifiers, notifier];
closeNotifierDialog();
}
function updateNotifier(index: number, notifier: Notifier) {
if (!formData?.notifiers) return;
const newNotifiers = [...formData.notifiers];
newNotifiers[index] = notifier;
formData.notifiers = newNotifiers;
closeNotifierDialog();
}
function deleteNotifier(index: number) {
if (!formData?.notifiers) return;
formData.notifiers = formData.notifiers.filter((_, i) => i !== index);
}
async function loadConfig() {
loading = true;
try {
@@ -142,11 +193,12 @@
{:else if formData}
<div class="space-y-6">
<Tabs.Root value="basic" class="w-full">
<Tabs.List class="grid w-full grid-cols-5">
<Tabs.List class="grid w-full grid-cols-6">
<Tabs.Trigger value="basic">基本设置</Tabs.Trigger>
<Tabs.Trigger value="auth">B站认证</Tabs.Trigger>
<Tabs.Trigger value="filter">视频处理</Tabs.Trigger>
<Tabs.Trigger value="danmaku">弹幕渲染</Tabs.Trigger>
<Tabs.Trigger value="notifiers">通知设置</Tabs.Trigger>
<Tabs.Trigger value="advanced">高级设置</Tabs.Trigger>
</Tabs.List>
@@ -620,6 +672,66 @@
</div>
</Tabs.Content>
<!-- 通知设置 -->
<Tabs.Content value="notifiers" class="mt-6 space-y-6">
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold">通知器管理</h3>
<p class="text-muted-foreground text-sm">
配置通知器,在下载任务出现错误时发送通知
</p>
</div>
<Button onclick={openAddNotifierDialog}>+ 添加通知器</Button>
</div>
{#if !formData.notifiers || formData.notifiers.length === 0}
<div class="rounded-lg border-2 border-dashed py-12 text-center">
<p class="text-muted-foreground">暂无通知器配置</p>
<Button class="mt-4" variant="outline" onclick={openAddNotifierDialog}>
添加第一个通知器
</Button>
</div>
{:else}
<div class="space-y-3">
{#each formData.notifiers as notifier, index (index)}
<div class="flex items-center justify-between rounded-lg border p-4">
<div class="flex-1">
{#if notifier.type === 'telegram'}
<div class="flex items-center gap-2">
<Badge variant="secondary">Telegram</Badge>
<span class="text-muted-foreground text-sm">
Chat ID: {notifier.chat_id}
</span>
</div>
{:else if notifier.type === 'webhook'}
<div class="flex items-center gap-2">
<Badge variant="secondary">Webhook</Badge>
<span class="text-muted-foreground text-sm">
{notifier.url}
</span>
</div>
{/if}
</div>
<div class="flex gap-2">
<Button
size="sm"
variant="outline"
onclick={() => openEditNotifierDialog(notifier, index)}
>
编辑
</Button>
<Button size="sm" variant="destructive" onclick={() => deleteNotifier(index)}>
删除
</Button>
</div>
</div>
{/each}
</div>
{/if}
</div>
</Tabs.Content>
<!-- 高级设置 -->
<Tabs.Content value="advanced" class="mt-6 space-y-6">
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
@@ -756,3 +868,33 @@
</div>
{/if}
</div>
<Dialog.Root bind:open={showNotifierDialog}>
<Dialog.Portal>
<Dialog.Overlay class="bg-background/80 fixed inset-0 z-50 backdrop-blur-sm" />
<Dialog.Content
class="bg-background fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg"
>
<Dialog.Header>
<Dialog.Title>
{isEditing ? '编辑通知器' : '添加通知器'}
</Dialog.Title>
<Dialog.Description>配置通知器类型和参数</Dialog.Description>
</Dialog.Header>
{#if showNotifierDialog}
<NotifierDialog
notifier={editingNotifier}
onSave={(notifier) => {
if (isEditing && editingNotifierIndex !== null) {
updateNotifier(editingNotifierIndex, notifier);
} else {
addNotifier(notifier);
}
}}
onCancel={closeNotifierDialog}
/>
{/if}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>

View File

@@ -0,0 +1,122 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { toast } from 'svelte-sonner';
import type { Notifier } from '$lib/types';
const jsonExample = '{"text": "您的消息内容"}';
export let notifier: Notifier | null = null;
export let onSave: (notifier: Notifier) => void;
export let onCancel: () => void;
let type: 'telegram' | 'webhook' = 'telegram';
let botToken = '';
let chatId = '';
let webhookUrl = '';
// 初始化表单
$: {
if (notifier) {
if (notifier.type === 'telegram') {
type = 'telegram';
botToken = notifier.bot_token;
chatId = notifier.chat_id;
} else {
type = 'webhook';
webhookUrl = notifier.url;
}
} else {
type = 'telegram';
botToken = '';
chatId = '';
webhookUrl = '';
}
}
function handleSave() {
// 验证表单
if (type === 'telegram') {
if (!botToken.trim()) {
toast.error('请输入 Bot Token');
return;
}
if (!chatId.trim()) {
toast.error('请输入 Chat ID');
return;
}
const newNotifier: Notifier = {
type: 'telegram',
bot_token: botToken.trim(),
chat_id: chatId.trim()
};
onSave(newNotifier);
} else {
if (!webhookUrl.trim()) {
toast.error('请输入 Webhook URL');
return;
}
// 简单的 URL 验证
try {
new URL(webhookUrl.trim());
} catch {
toast.error('请输入有效的 Webhook URL');
return;
}
const newNotifier: Notifier = {
type: 'webhook',
url: webhookUrl.trim()
};
onSave(newNotifier);
}
}
</script>
<div class="space-y-4 py-4">
<div class="space-y-2">
<Label for="notifier-type">通知器类型</Label>
<select
id="notifier-type"
class="border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
bind:value={type}
>
<option value="telegram">Telegram Bot</option>
<option value="webhook">Webhook</option>
</select>
</div>
{#if type === 'telegram'}
<div class="space-y-2">
<Label for="bot-token">Bot Token</Label>
<Input
id="bot-token"
placeholder="1234567890:ABCdefGHIjklMNOpqrsTUVwxyz"
bind:value={botToken}
/>
<p class="text-muted-foreground text-xs">从 @BotFather 获取的 Bot Token</p>
</div>
<div class="space-y-2">
<Label for="chat-id">Chat ID</Label>
<Input id="chat-id" placeholder="-1001234567890" bind:value={chatId} />
<p class="text-muted-foreground text-xs">目标聊天室的 ID个人用户、群组或频道</p>
</div>
{:else if type === 'webhook'}
<div class="space-y-2">
<Label for="webhook-url">Webhook URL</Label>
<Input id="webhook-url" placeholder="https://example.com/webhook" bind:value={webhookUrl} />
<p class="text-muted-foreground text-xs">
接收通知的 Webhook 地址<br />
格式示例:{jsonExample}
</p>
</div>
{/if}
</div>
<div class="flex justify-end gap-3">
<Button variant="outline" onclick={onCancel}>取消</Button>
<Button onclick={handleSave}>保存</Button>
</div>