From b24632d1d8be9a865c30fbb5a0d0bcbcf17649fe Mon Sep 17 00:00:00 2001 From: lifegpc Date: Sun, 6 Oct 2024 06:27:02 +0000 Subject: [PATCH] Add support to send compressed image --- src/opthelper.rs | 15 +++ src/opts.rs | 10 ++ src/push/telegram/image.rs | 113 ++++++++++++++----- src/server/push/task/pixiv_send_message.rs | 125 ++++++++++++++++++--- src/settings_list.rs | 1 + 5 files changed, 225 insertions(+), 39 deletions(-) diff --git a/src/opthelper.rs b/src/opthelper.rs index d13f6da..b4fd532 100644 --- a/src/opthelper.rs +++ b/src/opthelper.rs @@ -730,6 +730,21 @@ impl OptHelper { }, } } + + /// The path to ffmpeg executable. + pub fn ffmpeg(&self) -> Option { + match &self.opt.get_ref().ffmpeg { + Some(s) => Some(s.clone()), + None => match self.settings.get_ref().get_str("ffmpeg") { + Some(s) => Some(s.clone()), + None => { + #[cfg(feature = "docker")] + return Some(String::from("ffmpeg")); + None + } + }, + } + } } impl Default for OptHelper { diff --git a/src/opts.rs b/src/opts.rs index c84c4cf..666a7c5 100644 --- a/src/opts.rs +++ b/src/opts.rs @@ -146,6 +146,8 @@ pub struct CommandOpts { pub client_timeout: Option, /// The path to ffprobe executable. pub ffprobe: Option, + /// The path to ffmpeg executable. + pub ffmpeg: Option, } impl CommandOpts { @@ -201,6 +203,7 @@ impl CommandOpts { connect_timeout: None, client_timeout: None, ffprobe: None, + ffmpeg: None, } } @@ -746,6 +749,12 @@ pub fn parse_cmd() -> Option { gettext("The path to ffprobe executable."), "PATH", ); + opts.optopt( + "", + "ffmpeg", + gettext("The path to ffmpeg executable."), + "PATH", + ); let result = match opts.parse(&argv[1..]) { Ok(m) => m, Err(err) => { @@ -1217,6 +1226,7 @@ pub fn parse_cmd() -> Option { } } re.as_mut().unwrap().ffprobe = result.opt_str("ffprobe"); + re.as_mut().unwrap().ffmpeg = result.opt_str("ffmpeg"); re } diff --git a/src/push/telegram/image.rs b/src/push/telegram/image.rs index c16bfd8..118df9e 100644 --- a/src/push/telegram/image.rs +++ b/src/push/telegram/image.rs @@ -2,30 +2,11 @@ use crate::error::PixivDownloaderError; use crate::ext::subprocess::PopenAsyncExt; use crate::ext::try_err::TryErr4; use crate::get_helper; -use std::{ffi::OsStr, io::Read}; -use subprocess::{ExitStatus, Popen, PopenConfig, Redirection}; +use std::{ffi::OsStr, io::Read, path::PathBuf}; +use subprocess::{Popen, PopenConfig, Redirection}; pub const MAX_PHOTO_SIZE: u64 = 10485760; -pub async fn check_ffprobe + ?Sized>(path: &S) -> Result { - let mut p = Popen::create( - &[path.as_ref(), "-h"], - PopenConfig { - stdin: Redirection::None, - stdout: Redirection::Pipe, - stderr: Redirection::Pipe, - ..PopenConfig::default() - }, - ) - .try_err4("Failed to create popen: ")?; - p.communicate(None)?; - let re = p.async_wait().await; - Ok(match re { - ExitStatus::Exited(o) => o == 0, - _ => false, - }) -} - pub struct SupportedImage { pub supported: bool, pub size_too_big: bool, @@ -61,7 +42,7 @@ pub async fn get_image_size + ?Sized, P: AsRef + ?Sized>( PopenConfig { stdin: Redirection::None, stdout: Redirection::Pipe, - stderr: Redirection::None, + stderr: Redirection::Pipe, ..PopenConfig::default() }, ) @@ -99,15 +80,95 @@ pub async fn get_image_size + ?Sized, P: AsRef + ?Sized>( Ok((s[0].parse()?, s[1].parse()?)) } +pub async fn generate_image + ?Sized, D: AsRef + ?Sized>( + src: &S, + dest: &D, + max_side: i64, + quality: i8, +) -> Result<(), PixivDownloaderError> { + let helper = get_helper(); + let ffprobe = helper.ffprobe().unwrap_or(String::from("ffprobe")); + let (width, height) = get_image_size(&ffprobe, src).await?; + let ffmpeg = helper.ffmpeg().unwrap_or(String::from("ffmpeg")); + let (w, h) = if width > height { + (max_side, max_side * height / width) + } else { + (max_side * width / height, max_side) + }; + let argv = [ + ffmpeg.into(), + "-n".into(), + "-i".into(), + src.as_ref().to_owned(), + "-vf".into(), + format!("scale={}x{}", w, h).into(), + "-qmin".into(), + format!("{}", quality).into(), + "-qmax".into(), + format!("{}", quality).into(), + dest.as_ref().to_owned(), + ]; + let mut p = Popen::create( + &argv, + PopenConfig { + stdin: Redirection::None, + stdout: Redirection::Pipe, + stderr: Redirection::Pipe, + ..PopenConfig::default() + }, + ) + .try_err4("Failed to create popen: ")?; + let re = p.async_wait().await; + if !re.success() { + log::error!(target: "telegram_image", "Failed to generate thumbnail for {}: {:?}.", src.as_ref().to_string_lossy(), re); + match &mut p.stdout { + Some(f) => { + let mut buf = Vec::new(); + f.read_to_end(&mut buf)?; + let s = String::from_utf8_lossy(&buf); + log::info!(target: "telegram_image", "ffmpeg output: {}", s); + } + None => {} + } + return Err(PixivDownloaderError::from("Failed to generate thumbnail.")); + } + let s = match &mut p.stdout { + Some(f) => { + let mut buf = Vec::new(); + f.read_to_end(&mut buf)?; + String::from_utf8_lossy(&buf).into_owned() + } + None => String::new(), + }; + log::debug!(target: "telegram_image", "Ffmpeg output: {}", s); + Ok(()) +} + +pub fn get_thumbnail_filename( + ori: &PathBuf, + max_side: i64, + quality: i8, +) -> Result { + let mut o = ori.to_path_buf(); + let filename = o + .as_path() + .file_stem() + .ok_or("No filename in path.")? + .to_owned(); + o.set_file_name(format!( + "{}-{}-q{}.jpg", + filename.to_string_lossy(), + max_side, + quality + )); + Ok(o) +} + pub async fn is_supported_image + ?Sized>( path: &S, ) -> Result { let helper = get_helper(); let ffprobe = helper.ffprobe().unwrap_or(String::from("ffprobe")); - let re = check_ffprobe(&ffprobe).await?; - if !re { - return Err(PixivDownloaderError::from("ffprobe seems not works.")); - } let (width, height) = get_image_size(&ffprobe, path).await?; let w = width as f64; let h = height as f64; diff --git a/src/server/push/task/pixiv_send_message.rs b/src/server/push/task/pixiv_send_message.rs index 6e4fd01..e149b08 100644 --- a/src/server/push/task/pixiv_send_message.rs +++ b/src/server/push/task/pixiv_send_message.rs @@ -1,7 +1,7 @@ use super::super::super::preclude::*; use crate::db::push_task::{ AuthorLocation, EveryPushConfig, PushConfig, PushDeerConfig, TelegramBackend, - TelegramPushConfig, + TelegramBigPhotoSendMethod, TelegramPushConfig, }; use crate::error::PixivDownloaderError; use crate::formdata::FormDataPartBuilder; @@ -12,10 +12,13 @@ use crate::pixivapp::illust::PixivAppIllust; use crate::push::every_push::{EveryPushClient, EveryPushTextType}; use crate::push::pushdeer::PushdeerClient; use crate::push::telegram::botapi_client::{BotapiClient, BotapiClientConfig}; -use crate::push::telegram::image::{is_supported_image, MAX_PHOTO_SIZE}; +use crate::push::telegram::image::{ + generate_image, get_thumbnail_filename, is_supported_image, MAX_PHOTO_SIZE, +}; use crate::push::telegram::text::{encode_data, TextSpliter}; use crate::push::telegram::tg_type::{ - InputFile, InputMedia, InputMediaPhotoBuilder, ParseMode, ReplyParametersBuilder, + InputFile, InputMedia, InputMediaDocumentBuilder, InputMediaPhotoBuilder, ParseMode, + ReplyParametersBuilder, }; use crate::utils::{get_file_name_from_url, parse_pixiv_id}; use crate::{get_helper, gettext}; @@ -225,6 +228,50 @@ impl RunContext { }; let send_as_file = !is_supported && (!too_big || cfg.big_photo.is_document()); + let p = if !is_supported && !send_as_file { + match &cfg.big_photo { + TelegramBigPhotoSendMethod::Compress(c) => { + if let Ok(filename) = + get_thumbnail_filename(&p, c.max_side, c.quality) + { + let fn1 = filename.to_string_lossy(); + let o = match self.ctx.tmp_cache.get_local_cache(&fn1).await + { + Ok(o) => o, + Err(_) => None, + }; + match o { + Some(o) => o, + None => { + match generate_image( + &p, &filename, c.max_side, c.quality, + ) + .await + { + Ok(_) => { + let _ = self + .ctx + .tmp_cache + .push_local_cache(&fn1) + .await; + filename + } + Err(e) => { + log::warn!(target: "pixiv_send_message", "Failed to generate thumbnial: {}", e); + p + } + } + } + } + } else { + p + } + } + TelegramBigPhotoSendMethod::Document => p, + } + } else { + p + }; let name = p .file_name() .map(|a| a.to_str().unwrap_or("")) @@ -899,28 +946,74 @@ impl RunContext { let mut i = 0u64; let mut photos = Vec::new(); let mut photo_files = Vec::new(); + let mut new_photos = Vec::new(); + let mut new_photo_files = Vec::new(); + let mut have_doc = false; + let mut have_nondoc = false; + let mut new_have_doc = false; + let mut new_have_nondoc = false; while i < len { let (f, send_as_file) = self .get_input_file(i, download_media, cfg) .await? .ok_or("Failed to get image.")?; + let mut is_content = false; let u = match f { InputFile::URL(u) => u, InputFile::Content(c) => { photo_files.push((format!("img{}", i), c)); + is_content = true; format!("attach://img{}", i) } }; - let mut img = InputMediaPhotoBuilder::default(); - img.media(u).has_spoiler(is_r18); - if photos.is_empty() { - let text = ts.to_html(None); - img.caption(Some(text)).parse_mode(Some(ParseMode::HTML)); + if send_as_file { + let mut doc = InputMediaDocumentBuilder::default(); + doc.media(u); + if photos.is_empty() { + let text = ts.to_html(None); + doc.caption(Some(text)).parse_mode(Some(ParseMode::HTML)); + } + let doc = doc.build().map_err(|_| "Failed to gen.")?; + if have_nondoc { + new_photos.push(InputMedia::from(doc)); + if is_content { + match photo_files.pop() { + Some(p) => new_photo_files.push(p), + None => {} + } + } + new_have_doc = true; + } else { + photos.push(InputMedia::from(doc)); + have_doc = true; + } + } else { + let mut img = InputMediaPhotoBuilder::default(); + img.media(u).has_spoiler(is_r18); + if photos.is_empty() { + let text = ts.to_html(None); + img.caption(Some(text)).parse_mode(Some(ParseMode::HTML)); + } + let img = img.build().map_err(|_| "Failed to gen.")?; + if have_doc { + new_photos.push(InputMedia::from(img)); + if is_content { + match photo_files.pop() { + Some(p) => new_photo_files.push(p), + None => {} + } + } + new_have_nondoc = true; + } else { + photos.push(InputMedia::from(img)); + have_nondoc = true; + } } - let img = img.build().map_err(|_| "Failed to gen.")?; - photos.push(InputMedia::from(img)); i += 1; - if i == len || photos.len() == 10 { + while (i == len && !photos.is_empty()) + || photos.len() == 10 + || !new_photos.is_empty() + { let r = match last_message_id { Some(m) => Some( ReplyParametersBuilder::default() @@ -944,8 +1037,14 @@ impl RunContext { .await? .to_result()?; last_message_id = m.first().map(|m| m.message_id); - photos = Vec::new(); - photo_files = Vec::new(); + photos = new_photos; + photo_files = new_photo_files; + new_photos = Vec::new(); + new_photo_files = Vec::new(); + have_doc = new_have_doc; + have_nondoc = new_have_nondoc; + new_have_doc = false; + new_have_nondoc = false; } } } diff --git a/src/settings_list.rs b/src/settings_list.rs index b91bc60..2e5a1bc 100644 --- a/src/settings_list.rs +++ b/src/settings_list.rs @@ -75,6 +75,7 @@ pub fn get_settings_list() -> Vec { SettingDes::new("ugoira-cli", gettext("Whether to use ugoira cli."), JsonValueType::Boolean, None).unwrap(), SettingDes::new("connect-timeout", gettext("Set a timeout in milliseconds for only the connect phase of a client."), JsonValueType::Number, Some(check_nonzero_u64)).unwrap(), SettingDes::new("client-timeout", gettext("Set request timeout in milliseconds. The timeout is applied from when the request starts connecting until the response body has finished. Not used for downloader."), JsonValueType::Number, Some(check_nonzero_u64)).unwrap(), + SettingDes::new("ffmpeg", gettext("The path to ffmpeg executable."), JsonValueType::Str, None).unwrap(), ] }