For each series with missing volumes and an approved metadata link, calls Prowlarr to find available matching releases and stores them in a report (no auto-download). Includes per-series detail page, Telegram notifications with per-event toggles, and stats display in the jobs table. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
698 lines
23 KiB
Rust
698 lines
23 KiB
Rust
use anyhow::Result;
|
|
use serde::Deserialize;
|
|
use sqlx::PgPool;
|
|
use tracing::{info, warn};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Config
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct TelegramConfig {
|
|
pub bot_token: String,
|
|
pub chat_id: String,
|
|
#[serde(default)]
|
|
pub enabled: bool,
|
|
#[serde(default = "default_events")]
|
|
pub events: EventToggles,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct EventToggles {
|
|
#[serde(default = "default_true")]
|
|
pub scan_completed: bool,
|
|
#[serde(default = "default_true")]
|
|
pub scan_failed: bool,
|
|
#[serde(default = "default_true")]
|
|
pub scan_cancelled: bool,
|
|
#[serde(default = "default_true")]
|
|
pub thumbnail_completed: bool,
|
|
#[serde(default = "default_true")]
|
|
pub thumbnail_failed: bool,
|
|
#[serde(default = "default_true")]
|
|
pub conversion_completed: bool,
|
|
#[serde(default = "default_true")]
|
|
pub conversion_failed: bool,
|
|
#[serde(default = "default_true")]
|
|
pub metadata_approved: bool,
|
|
#[serde(default = "default_true")]
|
|
pub metadata_batch_completed: bool,
|
|
#[serde(default = "default_true")]
|
|
pub metadata_batch_failed: bool,
|
|
#[serde(default = "default_true")]
|
|
pub metadata_refresh_completed: bool,
|
|
#[serde(default = "default_true")]
|
|
pub metadata_refresh_failed: bool,
|
|
#[serde(default = "default_true")]
|
|
pub reading_status_match_completed: bool,
|
|
#[serde(default = "default_true")]
|
|
pub reading_status_match_failed: bool,
|
|
#[serde(default = "default_true")]
|
|
pub reading_status_push_completed: bool,
|
|
#[serde(default = "default_true")]
|
|
pub reading_status_push_failed: bool,
|
|
#[serde(default = "default_true")]
|
|
pub download_detection_completed: bool,
|
|
#[serde(default = "default_true")]
|
|
pub download_detection_failed: bool,
|
|
}
|
|
|
|
fn default_true() -> bool {
|
|
true
|
|
}
|
|
|
|
fn default_events() -> EventToggles {
|
|
EventToggles {
|
|
scan_completed: true,
|
|
scan_failed: true,
|
|
scan_cancelled: true,
|
|
thumbnail_completed: true,
|
|
thumbnail_failed: true,
|
|
conversion_completed: true,
|
|
conversion_failed: true,
|
|
metadata_approved: true,
|
|
metadata_batch_completed: true,
|
|
metadata_batch_failed: true,
|
|
metadata_refresh_completed: true,
|
|
metadata_refresh_failed: true,
|
|
reading_status_match_completed: true,
|
|
reading_status_match_failed: true,
|
|
reading_status_push_completed: true,
|
|
reading_status_push_failed: true,
|
|
download_detection_completed: true,
|
|
download_detection_failed: true,
|
|
}
|
|
}
|
|
|
|
/// Load the Telegram config from `app_settings` (key = "telegram").
|
|
/// Returns `None` when the row is missing, disabled, or has empty credentials.
|
|
pub async fn load_telegram_config(pool: &PgPool) -> Option<TelegramConfig> {
|
|
let row = sqlx::query_scalar::<_, serde_json::Value>(
|
|
"SELECT value FROM app_settings WHERE key = 'telegram'",
|
|
)
|
|
.fetch_optional(pool)
|
|
.await
|
|
.ok()??;
|
|
|
|
let config: TelegramConfig = serde_json::from_value(row).ok()?;
|
|
|
|
if !config.enabled || config.bot_token.is_empty() || config.chat_id.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
Some(config)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Telegram HTTP
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn build_client() -> Result<reqwest::Client> {
|
|
Ok(reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(10))
|
|
.build()?)
|
|
}
|
|
|
|
async fn send_telegram(config: &TelegramConfig, text: &str) -> Result<()> {
|
|
let url = format!(
|
|
"https://api.telegram.org/bot{}/sendMessage",
|
|
config.bot_token
|
|
);
|
|
|
|
let body = serde_json::json!({
|
|
"chat_id": config.chat_id,
|
|
"text": text,
|
|
"parse_mode": "HTML",
|
|
});
|
|
|
|
let resp = build_client()?.post(&url).json(&body).send().await?;
|
|
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let text = resp.text().await.unwrap_or_default();
|
|
anyhow::bail!("Telegram API returned {status}: {text}");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn send_telegram_photo(config: &TelegramConfig, caption: &str, photo_path: &str) -> Result<()> {
|
|
let url = format!(
|
|
"https://api.telegram.org/bot{}/sendPhoto",
|
|
config.bot_token
|
|
);
|
|
|
|
let photo_bytes = tokio::fs::read(photo_path).await?;
|
|
let filename = std::path::Path::new(photo_path)
|
|
.file_name()
|
|
.unwrap_or_default()
|
|
.to_string_lossy()
|
|
.to_string();
|
|
let mime = if filename.ends_with(".webp") {
|
|
"image/webp"
|
|
} else if filename.ends_with(".png") {
|
|
"image/png"
|
|
} else {
|
|
"image/jpeg"
|
|
};
|
|
|
|
let part = reqwest::multipart::Part::bytes(photo_bytes)
|
|
.file_name(filename)
|
|
.mime_str(mime)?;
|
|
|
|
let form = reqwest::multipart::Form::new()
|
|
.text("chat_id", config.chat_id.clone())
|
|
.text("caption", caption.to_string())
|
|
.text("parse_mode", "HTML")
|
|
.part("photo", part);
|
|
|
|
let resp = build_client()?.post(&url).multipart(form).send().await?;
|
|
|
|
if !resp.status().is_success() {
|
|
let status = resp.status();
|
|
let text = resp.text().await.unwrap_or_default();
|
|
anyhow::bail!("Telegram API returned {status}: {text}");
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Send a test message. Returns the result directly (not fire-and-forget).
|
|
pub async fn send_test_message(config: &TelegramConfig) -> Result<()> {
|
|
send_telegram(
|
|
config,
|
|
"🔔 <b>Stripstream Librarian</b>\n\
|
|
✅ Test notification — connection OK!",
|
|
)
|
|
.await
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Notification events
|
|
// ---------------------------------------------------------------------------
|
|
|
|
pub struct ScanStats {
|
|
pub scanned_files: usize,
|
|
pub indexed_files: usize,
|
|
pub removed_files: usize,
|
|
pub new_series: usize,
|
|
pub errors: usize,
|
|
}
|
|
|
|
pub enum NotificationEvent {
|
|
// Scan jobs (rebuild, full_rebuild, rescan, scan)
|
|
ScanCompleted {
|
|
job_type: String,
|
|
library_name: Option<String>,
|
|
stats: ScanStats,
|
|
duration_seconds: u64,
|
|
},
|
|
ScanFailed {
|
|
job_type: String,
|
|
library_name: Option<String>,
|
|
error: String,
|
|
},
|
|
ScanCancelled {
|
|
job_type: String,
|
|
library_name: Option<String>,
|
|
},
|
|
// Thumbnail jobs (thumbnail_rebuild, thumbnail_regenerate)
|
|
ThumbnailCompleted {
|
|
job_type: String,
|
|
library_name: Option<String>,
|
|
duration_seconds: u64,
|
|
},
|
|
ThumbnailFailed {
|
|
job_type: String,
|
|
library_name: Option<String>,
|
|
error: String,
|
|
},
|
|
// CBR→CBZ conversion
|
|
ConversionCompleted {
|
|
library_name: Option<String>,
|
|
book_title: Option<String>,
|
|
thumbnail_path: Option<String>,
|
|
},
|
|
ConversionFailed {
|
|
library_name: Option<String>,
|
|
book_title: Option<String>,
|
|
thumbnail_path: Option<String>,
|
|
error: String,
|
|
},
|
|
// Metadata manual approve
|
|
MetadataApproved {
|
|
series_name: String,
|
|
provider: String,
|
|
thumbnail_path: Option<String>,
|
|
},
|
|
// Metadata batch (auto-match)
|
|
MetadataBatchCompleted {
|
|
library_name: Option<String>,
|
|
total_series: i32,
|
|
processed: i32,
|
|
},
|
|
MetadataBatchFailed {
|
|
library_name: Option<String>,
|
|
error: String,
|
|
},
|
|
// Metadata refresh
|
|
MetadataRefreshCompleted {
|
|
library_name: Option<String>,
|
|
refreshed: i32,
|
|
unchanged: i32,
|
|
errors: i32,
|
|
},
|
|
MetadataRefreshFailed {
|
|
library_name: Option<String>,
|
|
error: String,
|
|
},
|
|
// Reading status match (auto-link series to provider)
|
|
ReadingStatusMatchCompleted {
|
|
library_name: Option<String>,
|
|
total_series: i32,
|
|
linked: i32,
|
|
},
|
|
ReadingStatusMatchFailed {
|
|
library_name: Option<String>,
|
|
error: String,
|
|
},
|
|
// Reading status push (differential push to AniList)
|
|
ReadingStatusPushCompleted {
|
|
library_name: Option<String>,
|
|
total_series: i32,
|
|
pushed: i32,
|
|
},
|
|
ReadingStatusPushFailed {
|
|
library_name: Option<String>,
|
|
error: String,
|
|
},
|
|
// Download detection (Prowlarr search for missing volumes)
|
|
DownloadDetectionCompleted {
|
|
library_name: Option<String>,
|
|
total_series: i32,
|
|
found: i64,
|
|
},
|
|
DownloadDetectionFailed {
|
|
library_name: Option<String>,
|
|
error: String,
|
|
},
|
|
}
|
|
|
|
/// Classify an indexer job_type string into the right event constructor category.
|
|
/// Returns "scan", "thumbnail", or "conversion".
|
|
pub fn job_type_category(job_type: &str) -> &'static str {
|
|
match job_type {
|
|
"thumbnail_rebuild" | "thumbnail_regenerate" => "thumbnail",
|
|
"cbr_to_cbz" => "conversion",
|
|
_ => "scan",
|
|
}
|
|
}
|
|
|
|
fn format_event(event: &NotificationEvent) -> String {
|
|
match event {
|
|
NotificationEvent::ScanCompleted {
|
|
job_type,
|
|
library_name,
|
|
stats,
|
|
duration_seconds,
|
|
} => {
|
|
let lib = library_name.as_deref().unwrap_or("All libraries");
|
|
let duration = format_duration(*duration_seconds);
|
|
let mut lines = vec![
|
|
format!("✅ <b>Scan completed</b>"),
|
|
String::new(),
|
|
format!("📂 <b>Library:</b> {lib}"),
|
|
format!("🏷 <b>Type:</b> {job_type}"),
|
|
format!("⏱ <b>Duration:</b> {duration}"),
|
|
String::new(),
|
|
format!("📊 <b>Results</b>"),
|
|
format!(" 📗 New books: <b>{}</b>", stats.indexed_files),
|
|
format!(" 📚 New series: <b>{}</b>", stats.new_series),
|
|
format!(" 🔎 Files scanned: <b>{}</b>", stats.scanned_files),
|
|
format!(" 🗑 Removed: <b>{}</b>", stats.removed_files),
|
|
];
|
|
if stats.errors > 0 {
|
|
lines.push(format!(" ⚠️ Errors: <b>{}</b>", stats.errors));
|
|
}
|
|
lines.join("\n")
|
|
}
|
|
NotificationEvent::ScanFailed {
|
|
job_type,
|
|
library_name,
|
|
error,
|
|
} => {
|
|
let lib = library_name.as_deref().unwrap_or("All libraries");
|
|
let err = truncate(error, 200);
|
|
[
|
|
format!("🚨 <b>Scan failed</b>"),
|
|
String::new(),
|
|
format!("📂 <b>Library:</b> {lib}"),
|
|
format!("🏷 <b>Type:</b> {job_type}"),
|
|
String::new(),
|
|
format!("💬 <code>{err}</code>"),
|
|
]
|
|
.join("\n")
|
|
}
|
|
NotificationEvent::ScanCancelled {
|
|
job_type,
|
|
library_name,
|
|
} => {
|
|
let lib = library_name.as_deref().unwrap_or("All libraries");
|
|
[
|
|
format!("⏹ <b>Scan cancelled</b>"),
|
|
String::new(),
|
|
format!("📂 <b>Library:</b> {lib}"),
|
|
format!("🏷 <b>Type:</b> {job_type}"),
|
|
]
|
|
.join("\n")
|
|
}
|
|
NotificationEvent::ThumbnailCompleted {
|
|
job_type,
|
|
library_name,
|
|
duration_seconds,
|
|
} => {
|
|
let lib = library_name.as_deref().unwrap_or("All libraries");
|
|
let duration = format_duration(*duration_seconds);
|
|
[
|
|
format!("✅ <b>Thumbnails completed</b>"),
|
|
String::new(),
|
|
format!("📂 <b>Library:</b> {lib}"),
|
|
format!("🏷 <b>Type:</b> {job_type}"),
|
|
format!("⏱ <b>Duration:</b> {duration}"),
|
|
]
|
|
.join("\n")
|
|
}
|
|
NotificationEvent::ThumbnailFailed {
|
|
job_type,
|
|
library_name,
|
|
error,
|
|
} => {
|
|
let lib = library_name.as_deref().unwrap_or("All libraries");
|
|
let err = truncate(error, 200);
|
|
[
|
|
format!("🚨 <b>Thumbnails failed</b>"),
|
|
String::new(),
|
|
format!("📂 <b>Library:</b> {lib}"),
|
|
format!("🏷 <b>Type:</b> {job_type}"),
|
|
String::new(),
|
|
format!("💬 <code>{err}</code>"),
|
|
]
|
|
.join("\n")
|
|
}
|
|
NotificationEvent::ConversionCompleted {
|
|
library_name,
|
|
book_title,
|
|
..
|
|
} => {
|
|
let lib = library_name.as_deref().unwrap_or("Unknown");
|
|
let title = book_title.as_deref().unwrap_or("Unknown");
|
|
[
|
|
format!("✅ <b>CBR → CBZ conversion completed</b>"),
|
|
String::new(),
|
|
format!("📂 <b>Library:</b> {lib}"),
|
|
format!("📖 <b>Book:</b> {title}"),
|
|
]
|
|
.join("\n")
|
|
}
|
|
NotificationEvent::ConversionFailed {
|
|
library_name,
|
|
book_title,
|
|
error,
|
|
..
|
|
} => {
|
|
let lib = library_name.as_deref().unwrap_or("Unknown");
|
|
let title = book_title.as_deref().unwrap_or("Unknown");
|
|
let err = truncate(error, 200);
|
|
[
|
|
format!("🚨 <b>CBR → CBZ conversion failed</b>"),
|
|
String::new(),
|
|
format!("📂 <b>Library:</b> {lib}"),
|
|
format!("📖 <b>Book:</b> {title}"),
|
|
String::new(),
|
|
format!("💬 <code>{err}</code>"),
|
|
]
|
|
.join("\n")
|
|
}
|
|
NotificationEvent::MetadataApproved {
|
|
series_name,
|
|
provider,
|
|
..
|
|
} => {
|
|
[
|
|
format!("✅ <b>Metadata linked</b>"),
|
|
String::new(),
|
|
format!("📚 <b>Series:</b> {series_name}"),
|
|
format!("🔗 <b>Provider:</b> {provider}"),
|
|
]
|
|
.join("\n")
|
|
}
|
|
NotificationEvent::MetadataBatchCompleted {
|
|
library_name,
|
|
total_series,
|
|
processed,
|
|
} => {
|
|
let lib = library_name.as_deref().unwrap_or("All libraries");
|
|
[
|
|
format!("✅ <b>Metadata batch completed</b>"),
|
|
String::new(),
|
|
format!("📂 <b>Library:</b> {lib}"),
|
|
format!("📊 <b>Processed:</b> {processed}/{total_series} series"),
|
|
]
|
|
.join("\n")
|
|
}
|
|
NotificationEvent::MetadataBatchFailed {
|
|
library_name,
|
|
error,
|
|
} => {
|
|
let lib = library_name.as_deref().unwrap_or("All libraries");
|
|
let err = truncate(error, 200);
|
|
[
|
|
format!("🚨 <b>Metadata batch failed</b>"),
|
|
String::new(),
|
|
format!("📂 <b>Library:</b> {lib}"),
|
|
String::new(),
|
|
format!("💬 <code>{err}</code>"),
|
|
]
|
|
.join("\n")
|
|
}
|
|
NotificationEvent::MetadataRefreshCompleted {
|
|
library_name,
|
|
refreshed,
|
|
unchanged,
|
|
errors,
|
|
} => {
|
|
let lib = library_name.as_deref().unwrap_or("All libraries");
|
|
let mut lines = vec![
|
|
format!("✅ <b>Metadata refresh completed</b>"),
|
|
String::new(),
|
|
format!("📂 <b>Library:</b> {lib}"),
|
|
String::new(),
|
|
format!("📊 <b>Results</b>"),
|
|
format!(" 🔄 Updated: <b>{refreshed}</b>"),
|
|
format!(" ▪️ Unchanged: <b>{unchanged}</b>"),
|
|
];
|
|
if *errors > 0 {
|
|
lines.push(format!(" ⚠️ Errors: <b>{errors}</b>"));
|
|
}
|
|
lines.join("\n")
|
|
}
|
|
NotificationEvent::MetadataRefreshFailed {
|
|
library_name,
|
|
error,
|
|
} => {
|
|
let lib = library_name.as_deref().unwrap_or("All libraries");
|
|
let err = truncate(error, 200);
|
|
[
|
|
format!("🚨 <b>Metadata refresh failed</b>"),
|
|
String::new(),
|
|
format!("📂 <b>Library:</b> {lib}"),
|
|
String::new(),
|
|
format!("💬 <code>{err}</code>"),
|
|
]
|
|
.join("\n")
|
|
}
|
|
NotificationEvent::ReadingStatusMatchCompleted {
|
|
library_name,
|
|
total_series,
|
|
linked,
|
|
} => {
|
|
let lib = library_name.as_deref().unwrap_or("All libraries");
|
|
[
|
|
format!("✅ <b>Reading status match completed</b>"),
|
|
String::new(),
|
|
format!("📂 <b>Library:</b> {lib}"),
|
|
String::new(),
|
|
format!("📊 <b>Results</b>"),
|
|
format!(" 🔗 Linked: <b>{linked}</b> / <b>{total_series}</b> series"),
|
|
]
|
|
.join("\n")
|
|
}
|
|
NotificationEvent::ReadingStatusMatchFailed {
|
|
library_name,
|
|
error,
|
|
} => {
|
|
let lib = library_name.as_deref().unwrap_or("All libraries");
|
|
let err = truncate(error, 200);
|
|
[
|
|
format!("🚨 <b>Reading status match failed</b>"),
|
|
String::new(),
|
|
format!("📂 <b>Library:</b> {lib}"),
|
|
String::new(),
|
|
format!("💬 <code>{err}</code>"),
|
|
]
|
|
.join("\n")
|
|
}
|
|
NotificationEvent::ReadingStatusPushCompleted {
|
|
library_name,
|
|
total_series,
|
|
pushed,
|
|
} => {
|
|
let lib = library_name.as_deref().unwrap_or("All libraries");
|
|
[
|
|
format!("✅ <b>Reading status push completed</b>"),
|
|
String::new(),
|
|
format!("📂 <b>Library:</b> {lib}"),
|
|
String::new(),
|
|
format!("📊 <b>Results</b>"),
|
|
format!(" ⬆️ Pushed: <b>{pushed}</b> / <b>{total_series}</b> series"),
|
|
]
|
|
.join("\n")
|
|
}
|
|
NotificationEvent::ReadingStatusPushFailed {
|
|
library_name,
|
|
error,
|
|
} => {
|
|
let lib = library_name.as_deref().unwrap_or("All libraries");
|
|
let err = truncate(error, 200);
|
|
[
|
|
format!("🚨 <b>Reading status push failed</b>"),
|
|
String::new(),
|
|
format!("📂 <b>Library:</b> {lib}"),
|
|
String::new(),
|
|
format!("💬 <code>{err}</code>"),
|
|
]
|
|
.join("\n")
|
|
}
|
|
NotificationEvent::DownloadDetectionCompleted {
|
|
library_name,
|
|
total_series,
|
|
found,
|
|
} => {
|
|
let lib = library_name.as_deref().unwrap_or("All libraries");
|
|
[
|
|
format!("✅ <b>Download detection completed</b>"),
|
|
String::new(),
|
|
format!("📂 <b>Library:</b> {lib}"),
|
|
String::new(),
|
|
format!("📊 <b>Results</b>"),
|
|
format!(" 📥 Available: <b>{found}</b> / <b>{total_series}</b> series"),
|
|
]
|
|
.join("\n")
|
|
}
|
|
NotificationEvent::DownloadDetectionFailed {
|
|
library_name,
|
|
error,
|
|
} => {
|
|
let lib = library_name.as_deref().unwrap_or("All libraries");
|
|
let err = truncate(error, 200);
|
|
[
|
|
format!("🚨 <b>Download detection failed</b>"),
|
|
String::new(),
|
|
format!("📂 <b>Library:</b> {lib}"),
|
|
String::new(),
|
|
format!("💬 <code>{err}</code>"),
|
|
]
|
|
.join("\n")
|
|
}
|
|
}
|
|
}
|
|
|
|
fn truncate(s: &str, max: usize) -> String {
|
|
if s.len() > max {
|
|
format!("{}…", &s[..max])
|
|
} else {
|
|
s.to_string()
|
|
}
|
|
}
|
|
|
|
fn format_duration(secs: u64) -> String {
|
|
if secs < 60 {
|
|
format!("{secs}s")
|
|
} else {
|
|
let m = secs / 60;
|
|
let s = secs % 60;
|
|
format!("{m}m{s}s")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public entry point — fire & forget
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Returns whether this event type is enabled in the config.
|
|
fn is_event_enabled(config: &TelegramConfig, event: &NotificationEvent) -> bool {
|
|
match event {
|
|
NotificationEvent::ScanCompleted { .. } => config.events.scan_completed,
|
|
NotificationEvent::ScanFailed { .. } => config.events.scan_failed,
|
|
NotificationEvent::ScanCancelled { .. } => config.events.scan_cancelled,
|
|
NotificationEvent::ThumbnailCompleted { .. } => config.events.thumbnail_completed,
|
|
NotificationEvent::ThumbnailFailed { .. } => config.events.thumbnail_failed,
|
|
NotificationEvent::ConversionCompleted { .. } => config.events.conversion_completed,
|
|
NotificationEvent::ConversionFailed { .. } => config.events.conversion_failed,
|
|
NotificationEvent::MetadataApproved { .. } => config.events.metadata_approved,
|
|
NotificationEvent::MetadataBatchCompleted { .. } => config.events.metadata_batch_completed,
|
|
NotificationEvent::MetadataBatchFailed { .. } => config.events.metadata_batch_failed,
|
|
NotificationEvent::MetadataRefreshCompleted { .. } => config.events.metadata_refresh_completed,
|
|
NotificationEvent::MetadataRefreshFailed { .. } => config.events.metadata_refresh_failed,
|
|
NotificationEvent::ReadingStatusMatchCompleted { .. } => config.events.reading_status_match_completed,
|
|
NotificationEvent::ReadingStatusMatchFailed { .. } => config.events.reading_status_match_failed,
|
|
NotificationEvent::ReadingStatusPushCompleted { .. } => config.events.reading_status_push_completed,
|
|
NotificationEvent::ReadingStatusPushFailed { .. } => config.events.reading_status_push_failed,
|
|
NotificationEvent::DownloadDetectionCompleted { .. } => config.events.download_detection_completed,
|
|
NotificationEvent::DownloadDetectionFailed { .. } => config.events.download_detection_failed,
|
|
}
|
|
}
|
|
|
|
/// Extract thumbnail path from event if present and file exists on disk.
|
|
fn event_thumbnail(event: &NotificationEvent) -> Option<&str> {
|
|
let path = match event {
|
|
NotificationEvent::ConversionCompleted { thumbnail_path, .. } => thumbnail_path.as_deref(),
|
|
NotificationEvent::ConversionFailed { thumbnail_path, .. } => thumbnail_path.as_deref(),
|
|
NotificationEvent::MetadataApproved { thumbnail_path, .. } => thumbnail_path.as_deref(),
|
|
_ => None,
|
|
};
|
|
path.filter(|p| std::path::Path::new(p).exists())
|
|
}
|
|
|
|
/// Load config + format + send in a spawned task. Errors are only logged.
|
|
pub fn notify(pool: PgPool, event: NotificationEvent) {
|
|
tokio::spawn(async move {
|
|
let config = match load_telegram_config(&pool).await {
|
|
Some(c) => c,
|
|
None => return, // disabled or not configured
|
|
};
|
|
|
|
if !is_event_enabled(&config, &event) {
|
|
return;
|
|
}
|
|
|
|
let text = format_event(&event);
|
|
let sent = if let Some(photo) = event_thumbnail(&event) {
|
|
match send_telegram_photo(&config, &text, photo).await {
|
|
Ok(()) => Ok(()),
|
|
Err(e) => {
|
|
warn!("[TELEGRAM] Photo send failed, falling back to text: {e}");
|
|
send_telegram(&config, &text).await
|
|
}
|
|
}
|
|
} else {
|
|
send_telegram(&config, &text).await
|
|
};
|
|
|
|
match sent {
|
|
Ok(()) => info!("[TELEGRAM] Notification sent"),
|
|
Err(e) => warn!("[TELEGRAM] Failed to send notification: {e}"),
|
|
}
|
|
});
|
|
}
|