From 626e2e035ddecc8cdb669403c774f28703164b2c Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Sat, 21 Mar 2026 17:43:01 +0100 Subject: [PATCH] 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 --- Cargo.lock | 2 + Cargo.toml | 2 +- apps/api/src/metadata.rs | 12 ++++- apps/indexer/src/worker.rs | 43 ++++++++++------ crates/notifications/src/lib.rs | 87 ++++++++++++++++++++++++++++++--- 5 files changed, 122 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2b3003b..4d503cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2285,6 +2285,7 @@ dependencies = [ "base64", "bytes", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -2293,6 +2294,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime_guess", "percent-encoding", "pin-project-lite", "quinn", diff --git a/Cargo.toml b/Cargo.toml index a3c9a6d..f2f220b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png", jpeg-decoder = "0.3" lru = "0.12" 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" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" diff --git a/apps/api/src/metadata.rs b/apps/api/src/metadata.rs index b4c3e9e..3db9d85 100644 --- a/apps/api/src/metadata.rs +++ b/apps/api/src/metadata.rs @@ -369,13 +369,23 @@ pub async fn approve_metadata( .await?; } - // Notify via Telegram + // Notify via Telegram (with first book thumbnail if available) let provider_for_notif: String = row.get("provider"); + let thumbnail_path: Option = 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( state.pool.clone(), notifications::NotificationEvent::MetadataApproved { series_name: series_name.clone(), provider: provider_for_notif, + thumbnail_path, }, ); diff --git a/apps/indexer/src/worker.rs b/apps/indexer/src/worker.rs index b7469d3..8e793ae 100644 --- a/apps/indexer/src/worker.rs +++ b/apps/indexer/src/worker.rs @@ -36,11 +36,18 @@ pub async fn run_worker(state: AppState, interval_seconds: u64) { } }); + struct JobInfo { + job_type: String, + library_name: Option, + book_title: Option, + thumbnail_path: Option, + } + async fn load_job_info( pool: &sqlx::PgPool, job_id: Uuid, library_id: Option, - ) -> (String, Option, Option) { + ) -> JobInfo { let row = sqlx::query("SELECT type, book_id FROM index_jobs WHERE id = $1") .bind(job_id) .fetch_optional(pool) @@ -64,18 +71,22 @@ pub async fn run_worker(state: AppState, interval_seconds: u64) { None }; - let book_title: Option = if let Some(bid) = book_id { - sqlx::query_scalar("SELECT title FROM books WHERE id = $1") + let (book_title, thumbnail_path): (Option, Option) = if let Some(bid) = book_id { + let row = sqlx::query("SELECT title, thumbnail_path FROM books WHERE id = $1") .bind(bid) .fetch_optional(pool) .await .ok() - .flatten() + .flatten(); + match row { + Some(r) => (r.get("title"), r.get("thumbnail_path")), + None => (None, None), + } } 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 { @@ -111,6 +122,7 @@ pub async fn run_worker(state: AppState, interval_seconds: u64) { job_type: &str, library_name: Option, book_title: Option, + thumbnail_path: Option, stats: notifications::ScanStats, duration_seconds: u64, ) -> notifications::NotificationEvent { @@ -123,6 +135,7 @@ pub async fn run_worker(state: AppState, interval_seconds: u64) { "conversion" => notifications::NotificationEvent::ConversionCompleted { library_name, book_title, + thumbnail_path, }, _ => notifications::NotificationEvent::ScanCompleted { job_type: job_type.to_string(), @@ -137,6 +150,7 @@ pub async fn run_worker(state: AppState, interval_seconds: u64) { job_type: &str, library_name: Option, book_title: Option, + thumbnail_path: Option, error: String, ) -> notifications::NotificationEvent { 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 { library_name, book_title, + thumbnail_path, error, }, _ => notifications::NotificationEvent::ScanFailed { @@ -163,8 +178,7 @@ pub async fn run_worker(state: AppState, interval_seconds: u64) { Ok(Some((job_id, library_id))) => { info!("[INDEXER] Starting job {} library={:?}", job_id, library_id); let started_at = std::time::Instant::now(); - let (job_type, library_name, book_title) = - load_job_info(&state.pool, job_id, library_id).await; + let info = load_job_info(&state.pool, job_id, library_id).await; if let Err(err) = job::process_job(&state, job_id, library_id).await { let err_str = err.to_string(); @@ -173,8 +187,8 @@ pub async fn run_worker(state: AppState, interval_seconds: u64) { notifications::notify( state.pool.clone(), notifications::NotificationEvent::ScanCancelled { - job_type: job_type.clone(), - library_name: library_name.clone(), + job_type: info.job_type.clone(), + library_name: info.library_name.clone(), }, ); } 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; notifications::notify( 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 { @@ -191,9 +205,10 @@ pub async fn run_worker(state: AppState, interval_seconds: u64) { notifications::notify( state.pool.clone(), build_completed_event( - &job_type, - library_name.clone(), - book_title.clone(), + &info.job_type, + info.library_name.clone(), + info.book_title.clone(), + info.thumbnail_path.clone(), stats, started_at.elapsed().as_secs(), ), diff --git a/crates/notifications/src/lib.rs b/crates/notifications/src/lib.rs index 3c31f4b..9b3e1fe 100644 --- a/crates/notifications/src/lib.rs +++ b/crates/notifications/src/lib.rs @@ -89,23 +89,66 @@ pub async fn load_telegram_config(pool: &PgPool) -> Option { // Telegram HTTP // --------------------------------------------------------------------------- +fn build_client() -> Result { + 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 client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build()?; - let body = serde_json::json!({ "chat_id": config.chat_id, "text": text, "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() { let status = resp.status(); @@ -165,16 +208,19 @@ pub enum NotificationEvent { ConversionCompleted { library_name: Option, book_title: Option, + thumbnail_path: Option, }, ConversionFailed { library_name: Option, book_title: Option, + thumbnail_path: Option, error: String, }, // Metadata manual approve MetadataApproved { series_name: String, provider: String, + thumbnail_path: Option, }, // Metadata batch (auto-match) MetadataBatchCompleted { @@ -292,6 +338,7 @@ fn format_event(event: &NotificationEvent) -> String { NotificationEvent::ConversionCompleted { library_name, book_title, + .. } => { let lib = library_name.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, book_title, error, + .. } => { let lib = library_name.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 { series_name, provider, + .. } => { format!( "🔗 Metadata linked\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. pub fn notify(pool: PgPool, event: NotificationEvent) { tokio::spawn(async move { @@ -433,10 +493,21 @@ pub fn notify(pool: PgPool, event: NotificationEvent) { } let text = format_event(&event); - if let Err(e) = send_telegram(&config, &text).await { - warn!("[TELEGRAM] Failed to send notification: {e}"); + 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 { - 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}"), } }); }