feat: send book thumbnails in Telegram notifications

Use Telegram sendPhoto API for conversion and metadata-approved events
when a book thumbnail is available on disk. Falls back to text message
if photo upload fails.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 17:43:01 +01:00
parent cfd2321db2
commit 626e2e035d
5 changed files with 122 additions and 24 deletions

2
Cargo.lock generated
View File

@@ -2285,6 +2285,7 @@ dependencies = [
"base64", "base64",
"bytes", "bytes",
"futures-core", "futures-core",
"futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
@@ -2293,6 +2294,7 @@ dependencies = [
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
"mime_guess",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"quinn", "quinn",

View File

@@ -23,7 +23,7 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png",
jpeg-decoder = "0.3" jpeg-decoder = "0.3"
lru = "0.12" lru = "0.12"
rayon = "1.10" rayon = "1.10"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls"] }
rand = "0.8" rand = "0.8"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"

View File

@@ -369,13 +369,23 @@ pub async fn approve_metadata(
.await?; .await?;
} }
// Notify via Telegram // Notify via Telegram (with first book thumbnail if available)
let provider_for_notif: String = row.get("provider"); let provider_for_notif: String = row.get("provider");
let thumbnail_path: Option<String> = sqlx::query_scalar(
"SELECT thumbnail_path FROM books WHERE library_id = $1 AND series_name = $2 AND thumbnail_path IS NOT NULL ORDER BY sort_order LIMIT 1",
)
.bind(library_id)
.bind(&series_name)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
notifications::notify( notifications::notify(
state.pool.clone(), state.pool.clone(),
notifications::NotificationEvent::MetadataApproved { notifications::NotificationEvent::MetadataApproved {
series_name: series_name.clone(), series_name: series_name.clone(),
provider: provider_for_notif, provider: provider_for_notif,
thumbnail_path,
}, },
); );

View File

@@ -36,11 +36,18 @@ pub async fn run_worker(state: AppState, interval_seconds: u64) {
} }
}); });
struct JobInfo {
job_type: String,
library_name: Option<String>,
book_title: Option<String>,
thumbnail_path: Option<String>,
}
async fn load_job_info( async fn load_job_info(
pool: &sqlx::PgPool, pool: &sqlx::PgPool,
job_id: Uuid, job_id: Uuid,
library_id: Option<Uuid>, library_id: Option<Uuid>,
) -> (String, Option<String>, Option<String>) { ) -> JobInfo {
let row = sqlx::query("SELECT type, book_id FROM index_jobs WHERE id = $1") let row = sqlx::query("SELECT type, book_id FROM index_jobs WHERE id = $1")
.bind(job_id) .bind(job_id)
.fetch_optional(pool) .fetch_optional(pool)
@@ -64,18 +71,22 @@ pub async fn run_worker(state: AppState, interval_seconds: u64) {
None None
}; };
let book_title: Option<String> = if let Some(bid) = book_id { let (book_title, thumbnail_path): (Option<String>, Option<String>) = if let Some(bid) = book_id {
sqlx::query_scalar("SELECT title FROM books WHERE id = $1") let row = sqlx::query("SELECT title, thumbnail_path FROM books WHERE id = $1")
.bind(bid) .bind(bid)
.fetch_optional(pool) .fetch_optional(pool)
.await .await
.ok() .ok()
.flatten() .flatten();
match row {
Some(r) => (r.get("title"), r.get("thumbnail_path")),
None => (None, None),
}
} else { } else {
None (None, None)
}; };
(job_type, library_name, book_title) JobInfo { job_type, library_name, book_title, thumbnail_path }
} }
async fn load_scan_stats(pool: &sqlx::PgPool, job_id: Uuid) -> notifications::ScanStats { async fn load_scan_stats(pool: &sqlx::PgPool, job_id: Uuid) -> notifications::ScanStats {
@@ -111,6 +122,7 @@ pub async fn run_worker(state: AppState, interval_seconds: u64) {
job_type: &str, job_type: &str,
library_name: Option<String>, library_name: Option<String>,
book_title: Option<String>, book_title: Option<String>,
thumbnail_path: Option<String>,
stats: notifications::ScanStats, stats: notifications::ScanStats,
duration_seconds: u64, duration_seconds: u64,
) -> notifications::NotificationEvent { ) -> notifications::NotificationEvent {
@@ -123,6 +135,7 @@ pub async fn run_worker(state: AppState, interval_seconds: u64) {
"conversion" => notifications::NotificationEvent::ConversionCompleted { "conversion" => notifications::NotificationEvent::ConversionCompleted {
library_name, library_name,
book_title, book_title,
thumbnail_path,
}, },
_ => notifications::NotificationEvent::ScanCompleted { _ => notifications::NotificationEvent::ScanCompleted {
job_type: job_type.to_string(), job_type: job_type.to_string(),
@@ -137,6 +150,7 @@ pub async fn run_worker(state: AppState, interval_seconds: u64) {
job_type: &str, job_type: &str,
library_name: Option<String>, library_name: Option<String>,
book_title: Option<String>, book_title: Option<String>,
thumbnail_path: Option<String>,
error: String, error: String,
) -> notifications::NotificationEvent { ) -> notifications::NotificationEvent {
match notifications::job_type_category(job_type) { match notifications::job_type_category(job_type) {
@@ -148,6 +162,7 @@ pub async fn run_worker(state: AppState, interval_seconds: u64) {
"conversion" => notifications::NotificationEvent::ConversionFailed { "conversion" => notifications::NotificationEvent::ConversionFailed {
library_name, library_name,
book_title, book_title,
thumbnail_path,
error, error,
}, },
_ => notifications::NotificationEvent::ScanFailed { _ => notifications::NotificationEvent::ScanFailed {
@@ -163,8 +178,7 @@ pub async fn run_worker(state: AppState, interval_seconds: u64) {
Ok(Some((job_id, library_id))) => { Ok(Some((job_id, library_id))) => {
info!("[INDEXER] Starting job {} library={:?}", job_id, library_id); info!("[INDEXER] Starting job {} library={:?}", job_id, library_id);
let started_at = std::time::Instant::now(); let started_at = std::time::Instant::now();
let (job_type, library_name, book_title) = let info = load_job_info(&state.pool, job_id, library_id).await;
load_job_info(&state.pool, job_id, library_id).await;
if let Err(err) = job::process_job(&state, job_id, library_id).await { if let Err(err) = job::process_job(&state, job_id, library_id).await {
let err_str = err.to_string(); let err_str = err.to_string();
@@ -173,8 +187,8 @@ pub async fn run_worker(state: AppState, interval_seconds: u64) {
notifications::notify( notifications::notify(
state.pool.clone(), state.pool.clone(),
notifications::NotificationEvent::ScanCancelled { notifications::NotificationEvent::ScanCancelled {
job_type: job_type.clone(), job_type: info.job_type.clone(),
library_name: library_name.clone(), library_name: info.library_name.clone(),
}, },
); );
} else { } else {
@@ -182,7 +196,7 @@ pub async fn run_worker(state: AppState, interval_seconds: u64) {
let _ = job::fail_job(&state.pool, job_id, &err_str).await; let _ = job::fail_job(&state.pool, job_id, &err_str).await;
notifications::notify( notifications::notify(
state.pool.clone(), state.pool.clone(),
build_failed_event(&job_type, library_name.clone(), book_title.clone(), err_str), build_failed_event(&info.job_type, info.library_name.clone(), info.book_title.clone(), info.thumbnail_path.clone(), err_str),
); );
} }
} else { } else {
@@ -191,9 +205,10 @@ pub async fn run_worker(state: AppState, interval_seconds: u64) {
notifications::notify( notifications::notify(
state.pool.clone(), state.pool.clone(),
build_completed_event( build_completed_event(
&job_type, &info.job_type,
library_name.clone(), info.library_name.clone(),
book_title.clone(), info.book_title.clone(),
info.thumbnail_path.clone(),
stats, stats,
started_at.elapsed().as_secs(), started_at.elapsed().as_secs(),
), ),

View File

@@ -89,23 +89,66 @@ pub async fn load_telegram_config(pool: &PgPool) -> Option<TelegramConfig> {
// Telegram HTTP // 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<()> { async fn send_telegram(config: &TelegramConfig, text: &str) -> Result<()> {
let url = format!( let url = format!(
"https://api.telegram.org/bot{}/sendMessage", "https://api.telegram.org/bot{}/sendMessage",
config.bot_token config.bot_token
); );
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()?;
let body = serde_json::json!({ let body = serde_json::json!({
"chat_id": config.chat_id, "chat_id": config.chat_id,
"text": text, "text": text,
"parse_mode": "HTML", "parse_mode": "HTML",
}); });
let resp = client.post(&url).json(&body).send().await?; 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() { if !resp.status().is_success() {
let status = resp.status(); let status = resp.status();
@@ -165,16 +208,19 @@ pub enum NotificationEvent {
ConversionCompleted { ConversionCompleted {
library_name: Option<String>, library_name: Option<String>,
book_title: Option<String>, book_title: Option<String>,
thumbnail_path: Option<String>,
}, },
ConversionFailed { ConversionFailed {
library_name: Option<String>, library_name: Option<String>,
book_title: Option<String>, book_title: Option<String>,
thumbnail_path: Option<String>,
error: String, error: String,
}, },
// Metadata manual approve // Metadata manual approve
MetadataApproved { MetadataApproved {
series_name: String, series_name: String,
provider: String, provider: String,
thumbnail_path: Option<String>,
}, },
// Metadata batch (auto-match) // Metadata batch (auto-match)
MetadataBatchCompleted { MetadataBatchCompleted {
@@ -292,6 +338,7 @@ fn format_event(event: &NotificationEvent) -> String {
NotificationEvent::ConversionCompleted { NotificationEvent::ConversionCompleted {
library_name, library_name,
book_title, book_title,
..
} => { } => {
let lib = library_name.as_deref().unwrap_or("Unknown"); let lib = library_name.as_deref().unwrap_or("Unknown");
let title = book_title.as_deref().unwrap_or("Unknown"); let title = book_title.as_deref().unwrap_or("Unknown");
@@ -305,6 +352,7 @@ fn format_event(event: &NotificationEvent) -> String {
library_name, library_name,
book_title, book_title,
error, error,
..
} => { } => {
let lib = library_name.as_deref().unwrap_or("Unknown"); let lib = library_name.as_deref().unwrap_or("Unknown");
let title = book_title.as_deref().unwrap_or("Unknown"); let title = book_title.as_deref().unwrap_or("Unknown");
@@ -319,6 +367,7 @@ fn format_event(event: &NotificationEvent) -> String {
NotificationEvent::MetadataApproved { NotificationEvent::MetadataApproved {
series_name, series_name,
provider, provider,
..
} => { } => {
format!( format!(
"🔗 <b>Metadata linked</b>\n\ "🔗 <b>Metadata linked</b>\n\
@@ -420,6 +469,17 @@ fn is_event_enabled(config: &TelegramConfig, event: &NotificationEvent) -> bool
} }
} }
/// 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. /// Load config + format + send in a spawned task. Errors are only logged.
pub fn notify(pool: PgPool, event: NotificationEvent) { pub fn notify(pool: PgPool, event: NotificationEvent) {
tokio::spawn(async move { tokio::spawn(async move {
@@ -433,10 +493,21 @@ pub fn notify(pool: PgPool, event: NotificationEvent) {
} }
let text = format_event(&event); let text = format_event(&event);
if let Err(e) = send_telegram(&config, &text).await { let sent = if let Some(photo) = event_thumbnail(&event) {
warn!("[TELEGRAM] Failed to send notification: {e}"); 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 { } else {
info!("[TELEGRAM] Notification sent"); send_telegram(&config, &text).await
};
match sent {
Ok(()) => info!("[TELEGRAM] Notification sent"),
Err(e) => warn!("[TELEGRAM] Failed to send notification: {e}"),
} }
}); });
} }