feat: 支持配置通知器,在视频源处理或整个下载任务出现错误时会触发消息通知 (#526)
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -8,6 +8,7 @@ mod config;
|
||||
mod database;
|
||||
mod downloader;
|
||||
mod error;
|
||||
mod notifier;
|
||||
mod task;
|
||||
mod utils;
|
||||
mod workflow;
|
||||
|
||||
46
crates/bili_sync/src/notifier/mod.rs
Normal file
46
crates/bili_sync/src/notifier/mod.rs
Normal 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(¶ms).send().await?;
|
||||
}
|
||||
Notifier::Webhook { url } => {
|
||||
let payload = WebhookPayload { text: message };
|
||||
client.post(url).json(&payload).send().await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
122
web/src/routes/settings/NotifierDialog.svelte
Normal file
122
web/src/routes/settings/NotifierDialog.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user