diff --git a/Cargo.lock b/Cargo.lock index 1858265..5aafd89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4270,7 +4270,7 @@ dependencies = [ [[package]] name = "watcat" -version = "0.3.0" +version = "0.5.0" dependencies = [ "arabic_reshaper", "dotenvy", diff --git a/Cargo.toml b/Cargo.toml index c1dfa14..bcf9528 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "watcat" -version = "0.4.0" +version = "0.5.0" edition = "2024" [dependencies] diff --git a/README.md b/README.md index 4cfa302..ad7f51b 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,11 @@ TODO ---- + ~~add art and lastfm url fetching~~ -+ add help command ++ ~~add help command~~ + ~~refactor to combine repeated parts of art, url, and fmi.~~ + use macro for commands + use Option instead of empty string sentinel value ++ add cache for album art colors + ~~add spotify link fetching~~ + make various parameters user-configurable + use tiny-skia instead of magick-rust? diff --git a/src/main.rs b/src/main.rs index 6de8a63..8776238 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,10 +11,12 @@ use serenity::model::id::UserId; use serenity::prelude::*; extern crate magick_rust; -use magick_rust::{ColorspaceType, CompositeOperator, MagickWand, PixelWand, magick_wand_genesis, FilterType}; +use magick_rust::{ + ColorspaceType, CompositeOperator, FilterType, MagickWand, PixelWand, magick_wand_genesis, +}; use std::env; -use std::sync::{Once, Arc}; +use std::sync::{Arc, Once}; use tokio::sync::Mutex; use palette::color_difference::ImprovedCiede2000; @@ -24,8 +26,8 @@ use palette::white_point::D65; extern crate sqlx; use sqlx::sqlite::SqlitePool; -use rspotify::{ClientCredsSpotify, Credentials, clients::BaseClient,}; use rspotify::model::{enums::types::SearchType, search::SearchResult}; +use rspotify::{ClientCredsSpotify, Credentials, clients::BaseClient}; use imagetext::fontdb::FontDB; use imagetext::superfont::SuperFont; @@ -52,13 +54,13 @@ macro_rules! now { macro_rules! log { ($a:expr) => { - if env::var("DEBUG").unwrap_or("0".to_string()) == "1" { + if env::var("DEBUG").unwrap_or("0".to_owned()) == "1" { println!(concat!("debug: {} ", $a), now!()) } }; ($a:expr, $($b:expr),+) => { - if env::var("DEBUG").unwrap_or("0".to_string()) == "1" { + if env::var("DEBUG").unwrap_or("0".to_owned()) == "1" { println!(concat!("debug: {} ", $a), now!(), $($b),+) } }; @@ -79,7 +81,7 @@ macro_rules! handle_magick_option { ($a: expr, $b: literal) => { match $a { Some(res) => res, - None => return Reply::Text($b.to_string()), + None => return Reply::Text($b.to_owned()), } }; } @@ -118,85 +120,103 @@ fn validate_color(col: &PixelWand) -> Option { Some(Lab::::from_components(color_raw)) } -async fn get_track(ctx: &Context, arg: &str, id: UserId) -> Result { +async fn get_track( + ctx: &Context, + arg: &str, + id: UserId, +) -> Result { let lastfm_user = match arg { "" => get_lastfm_username(ctx, id).await, - _ => Some(arg.to_string()), + _ => Some(arg.to_owned()), }; let lastfm_client = match lastfm_user { Some(s) => lastfm::Client::::from_env(s), - None => return Err(Reply::Text("No last.fm username set.".to_string())), + None => return Err(Reply::Text("No last.fm username set.".to_owned())), }; let now_playing = match lastfm_client.now_playing().await { Ok(np) => np, - Err(e) => return Err(Reply::Text(format!("Error: grabbing last.fm user data failed {e}"))), + Err(e) => { + return Err(Reply::Text(format!( + "Error: grabbing last.fm user data failed {e}" + ))); + } }; - let track = match now_playing { + let track = match now_playing { Some(track) => track, - None => return Err(Reply::Text("Nothing playing.".to_string())), + None => return Err(Reply::Text("Nothing playing.".to_owned())), }; Ok(track) } async fn art(ctx: &Context, arg: &str, id: UserId) -> Reply { - let track = match get_track(ctx, arg, id).await { - Ok(track) => track, - Err(e) => return e, - }; - let track_art = match track.image.extralarge { - Some(track_art) => track_art, - None => return Reply::Text("Error: getting image uri failed".to_string()), + let track = match get_track(ctx, arg, id).await { + Ok(track) => track, + Err(e) => return e, }; - Reply::Text(track_art.to_string()) + let track_art = match track.image.extralarge { + Some(track_art) => track_art, + None => return Reply::Text("Error: getting image uri failed".to_owned()), + }; + Reply::Text(track_art.to_owned()) } async fn url(ctx: &Context, arg: &str, id: UserId) -> Reply { - let track = match get_track(ctx, arg, id).await { - Ok(track) => track, - Err(e) => return e, - }; + let track = match get_track(ctx, arg, id).await { + Ok(track) => track, + Err(e) => return e, + }; Reply::Text(track.url) } async fn spot(ctx: &Context, arg: &str, id: UserId) -> Reply { - let track = match get_track(ctx, arg, id).await { - Ok(track) => track, - Err(e) => return e, - }; + let track = match get_track(ctx, arg, id).await { + Ok(track) => track, + Err(e) => return e, + }; let mut data = ctx.data.write().await; - let spotify_option = data.get_mut::().expect("Failed to have spotify option"); - if spotify_option.is_none() { - return Reply::Text("Unable to use Spotify command: Contact bot Administrator.".to_owned()) - } - let spotify_arc_mutex = spotify_option.as_mut().unwrap(); - //let spotify_arc_mutex_ref = Arc::clone(&spotify_arc_mutex); - let spotify_client = spotify_arc_mutex.lock().await; - spotify_client.request_token().await.unwrap(); - let search = spotify_client.search(format!("{} {}", track.name, track.artist.name).as_str(), SearchType::Track, None, None, None, None).await; - let tracks = match search { - Ok(SearchResult::Tracks(track_page)) => track_page, - Err(e) => return Reply::Text(format!("Failed to get track {e}")), - _ => return Reply::Text("Spotify search failed".to_owned()), - }; - match &tracks.items[0].external_urls.get("spotify") { - Some(url) => Reply::Text(url.to_owned().to_owned()), - None => Reply::Text("Unable to get spotify url".to_owned()), - } + let spotify_option = data + .get_mut::() + .expect("Failed to have spotify option"); + if spotify_option.is_none() { + return Reply::Text("Unable to use Spotify command: Contact bot Administrator.".to_owned()); + } + let spotify_arc_mutex = spotify_option.as_mut().unwrap(); + //let spotify_arc_mutex_ref = Arc::clone(&spotify_arc_mutex); + let spotify_client = spotify_arc_mutex.lock().await; + let search = spotify_client + .search( + format!(r#"track:"{}" album:"{}" artist:"{}""#, track.name.replace("\"", ""), track.album.replace("\"", ""), track.artist.name.replace("\"", "")).as_str(), + SearchType::Track, + None, + None, + None, + None, + ) + .await; + let tracks = match search { + Ok(SearchResult::Tracks(track_page)) => track_page, + Err(e) => return Reply::Text(format!("Failed to get track {e}")), + _ => return Reply::Text("Spotify search failed".to_owned()), + }; + match &tracks.items[0].external_urls.get("spotify") { + Some(url) => Reply::Text(url.to_owned().to_owned()), + None => Reply::Text("Unable to get spotify url".to_owned()), + } } async fn fmi(ctx: &Context, arg: &str, id: UserId, avatar: Option) -> Reply { - let track = match get_track(ctx, arg, id).await { - Ok(track) => track, - Err(e) => return e, - }; + let track = match get_track(ctx, arg, id).await { + Ok(track) => track, + Err(e) => return e, + }; let track_info = text::TrackInfo { - title: track.name, - artist: track.artist.name, - album: track.album, + title: track.name, + artist: track.artist.name, + album: track.album, }; let image_uri = match track.image.extralarge { Some(iu) => iu, - None => return Reply::Text("Error: getting image uri failed".to_string()), + None => return Reply::Text("Error: getting image uri failed".to_owned()), }; let image = match get_image(ctx, image_uri.as_str()).await { @@ -270,14 +290,14 @@ async fn fmi(ctx: &Context, arg: &str, id: UserId, avatar: Option) -> Re "Failed to set accent color" ); } - + let use_dark_color = match validate_color(&colors[0]) { - Some(color) => { - let black_delta_e = Lab::::new(0.0, 0.0, 0.0).improved_difference(color); - let white_delta_e = Lab::::new(100.0, 0.0, 0.0).improved_difference(color); - black_delta_e > white_delta_e - } - _ => false + Some(color) => { + let black_delta_e = Lab::::new(0.0, 0.0, 0.0).improved_difference(color); + let white_delta_e = Lab::::new(100.0, 0.0, 0.0).improved_difference(color); + black_delta_e > white_delta_e + } + _ => false, }; handle_magick_result!(white.set_color("#ffffff"), "Failed to init white"); @@ -328,15 +348,24 @@ async fn fmi(ctx: &Context, arg: &str, id: UserId, avatar: Option) -> Re "Failed to set avatar colorspace" ); - let (image_width, image_height) = (548, 147); + let (image_width, image_height) = (548, 147); let data = ctx.data.write().await; - let fonts = data - .get::().expect("Failed to load fonts"); - let text_image_blob = match text::fmi_text(image_width, image_height, track_info, fonts, 19.0, use_dark_color) { - Ok(bytes) => bytes, - Err(e) => return Reply::Text(e), + let fonts = data.get::().expect("Failed to load fonts"); + let text_image_blob = match text::fmi_text( + image_width, + image_height, + track_info, + fonts, + 19.0, + use_dark_color, + ) { + Ok(bytes) => bytes, + Err(e) => return Reply::Text(e), }; - handle_magick_result!(text_image_wand.read_image_blob(&text_image_blob), "Failed to load text image"); + handle_magick_result!( + text_image_wand.read_image_blob(&text_image_blob), + "Failed to load text image" + ); handle_magick_result!( text_image_wand.transform_image_colorspace(ColorspaceType::Lab), "Failed to set avatar colorspace" @@ -367,17 +396,17 @@ async fn fmi(ctx: &Context, arg: &str, id: UserId, avatar: Option) -> Re "Failed to combine mask" ); #[cfg(debug_assertions)] - if env::var("DEBUG_IMAGE").unwrap_or("0".to_string()) == "1" { - handle_magick_result!( - main_wand.compose_images( - &art_wand_cluster, - CompositeOperator::SrcOver, - false, - 160, - 12 - ), - "Failed to combine debug image" - ); + if env::var("DEBUG_IMAGE").unwrap_or("0".to_owned()) == "1" { + handle_magick_result!( + main_wand.compose_images( + &art_wand_cluster, + CompositeOperator::SrcOver, + false, + 160, + 12 + ), + "Failed to combine debug image" + ); } handle_magick_result!( main_wand.compose_images(&text_image_wand, CompositeOperator::SrcOver, false, 0, 0), @@ -390,14 +419,41 @@ async fn fmi(ctx: &Context, arg: &str, id: UserId, avatar: Option) -> Re Reply::Image(main_wand.write_image_blob("png").unwrap()) } +fn help(arg: &str) -> Reply { + Reply::Text(format!( + "```{}```", + match arg { + "" => + "usage: .k [lastfm user]\n .k img [lastfm user]: get now playing\n .k set : sets your username for the future\n .k art [lastfm user]: get now playing album art\n .k url [lastfm user]: get now playing lastfm url\n .k spot [lastfm user]: get now playing spotify link\n .k help [command]: displays a help message" + .to_owned(), + "img" | "fmi" | "fm" | "get" => + "usage: .k img [lastfm user]\nreturns a pretty-printed image of the user's now playing track on last.fm, using the album art's colors to theme the image. see also: .k set\naliases: .kfmi, .k , .kf, .ki, or even just .k" + .to_owned(), + "set" => + "usage: .k set \nties a lastfm username to your account. use this to not require your username for .k fmi every time, for example\naliases: .kset" + .to_owned(), + "art" => + "usage: .k art [lastfm user]\nfetches user's now playing track's album art\naliases: .k art, .kart, .ka" + .to_owned(), + "url" | "link" => + "usage: .k url [lastfm user]\nfetches a lastfm link to user's now playing track\naliases: .k url, .k link, .kl, .ku, .kurl" + .to_owned(), + "spot" | "spotify" => + "usage: .k spotify [lastfm user]\nsearches spotify for the user's now playing track and returns a link to that track\naliases: .k , .ks, .kspot" + .to_owned(), + _ => format!("unknown command: {arg}"), + } + )) +} + async fn set(ctx: &Context, arg: &str, id: UserId) -> Reply { - set_lastfm_username(ctx, id, arg.to_string()).await; + set_lastfm_username(ctx, id, arg.to_owned()).await; Reply::Text(format!("set user {arg}")) } //macro_rules! cmd { // (c:lit, abbr:lit, a:ident) => { -// (".k", $c, $a) | (".k", $a, $c) | (concat!(".k", $abbr), $ident, "") +// (".k", $c, $a) | (".k", $a, $c) | (concat!(".k", $abbr), $ident, "") // } //} @@ -412,23 +468,34 @@ impl EventHandler for Handler { (".set", arg, "") | (".k", "set", arg) | (".kset", arg, "") => { log!("{} received", msg.content); Some(set(&ctx, arg, msg.author.id).await) - }, + } (".k", "art", arg) | (".k", arg, "art") | (".ka" | ".kart", arg, "") => { - log!("{} received", msg.content); - Some(art(&ctx, arg, msg.author.id).await) - }, - (".k", "url" | "uri" | "link", arg) | (".k", arg, "url" | "uri" | "link") | (".ku" | ".kl" | ".kurl", arg, "") => { - log!("{} received", msg.content); - Some(url(&ctx, arg, msg.author.id).await) - }, - (".k", "spot" | "spotify", arg) | (".k", arg, "spot" | "spotify") | (".ks" | ".kspot", arg, "") => { - log!("{} received", msg.content); - Some(spot(&ctx, arg, msg.author.id).await) - }, - (".fmi" | ".k" | ".kf" | ".ki" | ".kfmi", arg, "") | (".k", "fm" | "fmi" | "get" | "img", arg) => { + log!("{} received", msg.content); + Some(art(&ctx, arg, msg.author.id).await) + } + (".k", "url" | "uri" | "link", arg) + | (".k", arg, "url" | "uri" | "link") + | (".ku" | ".kl" | ".kurl", arg, "") => { + log!("{} received", msg.content); + Some(url(&ctx, arg, msg.author.id).await) + } + (".k", "spot" | "spotify", arg) + | (".k", arg, "spot" | "spotify") + | (".ks" | ".kspot", arg, "") => { + log!("{} received", msg.content); + Some(spot(&ctx, arg, msg.author.id).await) + } + (".k", "help" | "h" | "?", arg) + | (".k", arg, "help" | "h" | "?") + | (".kh" | ".k?", arg, "") => { + log!("{} received", msg.content); + Some(help(arg)) + } + (".fmi" | ".k" | ".kf" | ".ki" | ".kfmi", arg, "") + | (".k", "fm" | "fmi" | "get" | "img", arg) => { log!("{} received", msg.content); Some(fmi(&ctx, arg, msg.author.id, msg.author.avatar_url()).await) - }, + } _ => None, }; if let Some(reply) = resp { @@ -460,12 +527,12 @@ impl TypeMapKey for PoolHaver { struct FontsHaver; impl TypeMapKey for FontsHaver { - type Value = (SuperFont<'static>, SuperFont<'static>); + type Value = (SuperFont<'static>, SuperFont<'static>); } struct SpotifyHaver; impl TypeMapKey for SpotifyHaver { - type Value = Option>>; + type Value = Option>>; } struct DBResponse { @@ -522,9 +589,7 @@ async fn set_lastfm_username(ctx: &Context, id: UserId, user: String) { async fn get_image(ctx: &Context, url: &str) -> Result, reqwest_middleware::Error> { log!("get {}", url); let data = ctx.data.write().await; - let http = data - .get::() - .expect("Failed to get http client"); + let http = data.get::().expect("Failed to get http client"); match http.get(url).send().await { Ok(resp) => { log!("response received"); @@ -564,8 +629,9 @@ async fn main() { })) .build(); - FontDB::load_from_dir("fonts"); //singlet instance??? wha????? - let regular_fonts = FontDB::query(" + FontDB::load_from_dir("fonts"); //singlet instance??? wha????? + let regular_fonts = FontDB::query( + " NotoSans-Regular NotoSansHK-Regular NotoSansJP-Regular @@ -577,8 +643,11 @@ async fn main() { NotoEmoji-Regular Symbola Unifont - ").expect("Failed to load regular fonts"); - let bold_fonts = FontDB::query(" + ", + ) + .expect("Failed to load regular fonts"); + let bold_fonts = FontDB::query( + " NotoSans-SemiBold NotoSansJP-Medium NotoSansKR-Medium @@ -589,11 +658,16 @@ async fn main() { NotoEmoji-Medium Symbola Unifont - ").expect("Failed to load bold fonts"); + ", + ) + .expect("Failed to load bold fonts"); let spotify_creds = Credentials::from_env(); - let spotify = spotify_creds.map(ClientCredsSpotify::new).map(Mutex::new).map(Arc::new); - + let spotify = spotify_creds.map(ClientCredsSpotify::new); + if let Some(spotify_client) = &spotify { + let _ = &spotify_client.request_token().await.unwrap(); + } + let mut discord_client = Client::builder(&token, intents) .event_handler(Handler) .await @@ -604,7 +678,7 @@ async fn main() { data.insert::(pool); data.insert::(http); data.insert::((regular_fonts, bold_fonts)); - data.insert::(spotify); + data.insert::(spotify.map(Mutex::new).map(Arc::new)); } if let Err(why) = discord_client.start().await { diff --git a/src/text.rs b/src/text.rs index 269e315..63ac602 100644 --- a/src/text.rs +++ b/src/text.rs @@ -3,12 +3,12 @@ // since I'm using the same text rendering library, my code is almost one-to-one use arabic_reshaper::arabic_reshape; -use imagetext::measure::text_width; -use imagetext::superfont::SuperFont; -use imagetext::wrap::{text_wrap, WrapStyle}; -use imagetext::prelude::{scale, BLACK, WHITE, Outline, draw_text_wrapped, TextAlign, stroke}; -use image::{RgbaImage, ExtendedColorType, ImageEncoder}; use image::codecs::png::PngEncoder; +use image::{ExtendedColorType, ImageEncoder, RgbaImage}; +use imagetext::measure::text_width; +use imagetext::prelude::{BLACK, Outline, TextAlign, WHITE, draw_text_wrapped, scale, stroke}; +use imagetext::superfont::SuperFont; +use imagetext::wrap::{WrapStyle, text_wrap}; use unicode_bidi::BidiInfo; enum TextField { @@ -68,54 +68,89 @@ fn process_text( (wrapped_strs, width) } -fn draw_info(image: &mut RgbaImage, text: String, text_type: TextField, font: &SuperFont<'static>, font_size: f32, dark_text: bool) -> Result<(), String> { - let (lines, width) = process_text(&text, &text_type, font, font_size); - let text = lines.join(""); - let y = match text_type { - TextField::Title => 24.0, - TextField::Artist => 73.0, - TextField::Album => 96.0, - }; - let color = if dark_text { &BLACK } else { &WHITE }; - match draw_text_wrapped( - image, - color, - Outline::Solid { - stroke: &stroke(0.5), - fill: color, - }, - 146.0, - y, - 0.0, - 0.0, - width as f32, - scale(font_size), - font, - &text, - 1.0, - TextAlign::Left, - WrapStyle::Character, - ) { - Ok(_) => Ok(()), - Err(e) => Err(e.to_string()), - } +fn draw_info( + image: &mut RgbaImage, + text: String, + text_type: TextField, + font: &SuperFont<'static>, + font_size: f32, + dark_text: bool, +) -> Result<(), String> { + let (lines, width) = process_text(&text, &text_type, font, font_size); + let text = lines.join(""); + let y = match text_type { + TextField::Title => 24.0, + TextField::Artist => 73.0, + TextField::Album => 96.0, + }; + let color = if dark_text { &BLACK } else { &WHITE }; + match draw_text_wrapped( + image, + color, + Outline::Solid { + stroke: &stroke(0.5), + fill: color, + }, + 146.0, + y, + 0.0, + 0.0, + width as f32, + scale(font_size), + font, + &text, + 1.0, + TextAlign::Left, + WrapStyle::Character, + ) { + Ok(_) => Ok(()), + Err(e) => Err(e.to_string()), + } } pub struct TrackInfo { - pub title: String, - pub artist: String, - pub album: String, + pub title: String, + pub artist: String, + pub album: String, } -pub fn fmi_text(width: u32, height: u32, track: TrackInfo, fonts: &(SuperFont<'static>, SuperFont<'static>), font_size: f32, dark_text: bool) -> Result, String> { - let mut img = RgbaImage::new(width, height); - draw_info(&mut img, track.title, TextField::Title, &fonts.1.clone(), font_size, dark_text)?; - draw_info(&mut img, track.artist, TextField::Artist, &fonts.0.clone(), font_size, dark_text)?; - draw_info(&mut img, track.album, TextField::Album, &fonts.0.clone(), font_size, dark_text)?; - let mut blob = Vec::::new(); - let png_encoder = PngEncoder::new(&mut blob); - match png_encoder.write_image(&img, width, height, ExtendedColorType::Rgba8) { - Ok(()) => Ok(blob), - Err(_) => Err("Failed to encode text png".to_string()), - } +pub fn fmi_text( + width: u32, + height: u32, + track: TrackInfo, + fonts: &(SuperFont<'static>, SuperFont<'static>), + font_size: f32, + dark_text: bool, +) -> Result, String> { + let mut img = RgbaImage::new(width, height); + draw_info( + &mut img, + track.title, + TextField::Title, + &fonts.1.clone(), + font_size, + dark_text, + )?; + draw_info( + &mut img, + track.artist, + TextField::Artist, + &fonts.0.clone(), + font_size, + dark_text, + )?; + draw_info( + &mut img, + track.album, + TextField::Album, + &fonts.0.clone(), + font_size, + dark_text, + )?; + let mut blob = Vec::::new(); + let png_encoder = PngEncoder::new(&mut blob); + match png_encoder.write_image(&img, width, height, ExtendedColorType::Rgba8) { + Ok(()) => Ok(blob), + Err(_) => Err("Failed to encode text png".to_string()), + } }