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:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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}"),
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user