Skip to content

Commit

Permalink
basic last.fm integration (scrobble count, loading, setup)
Browse files Browse the repository at this point in the history
  • Loading branch information
duckfromdiscord committed Dec 30, 2023
1 parent 9b18689 commit 0db9527
Show file tree
Hide file tree
Showing 7 changed files with 270 additions and 96 deletions.
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ shuttle-shared-db = { optional = true, version = "0.34.0", features = ["postgres
sqlx = { version = "0.7.3", features = ["postgres", "runtime-tokio"] }
futures-util = "0.3.30"
poise = { git = "https://github.com/serenity-rs/poise", features = ["collector"] }
lfm-stats = { git = "https://github.com/duckfromdiscord/lfm-stats-rs", version = "0.1.0" }
9 changes: 2 additions & 7 deletions src/db/postgres.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,7 @@ pub async fn get_discord_pairing_code(
.expect("Failed to query DB for pairing codes")
}


pub async fn get_lastfm_username(
pool: &PgPool,
formatted_user: String,
) -> Vec<DiscordLastFMUser> {
pub async fn get_lastfm_username(pool: &PgPool, formatted_user: String) -> Vec<DiscordLastFMUser> {
sqlx::query_as::<_, DiscordLastFMUser>(
r#"
SELECT * FROM lastfm_usernames
Expand Down Expand Up @@ -138,7 +134,6 @@ pub async fn delete_website(pool: &PgPool, formatted_user: String) -> u64 {
.rows_affected()
}


pub async fn delete_lastfm_user(pool: &PgPool, formatted_user: String) -> u64 {
sqlx::query(
r#"
Expand All @@ -151,4 +146,4 @@ pub async fn delete_lastfm_user(pool: &PgPool, formatted_user: String) -> u64 {
.await
.expect("Failed to delete Last.FM user")
.rows_affected()
}
}
99 changes: 69 additions & 30 deletions src/discord/bot.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use crate::db::postgres::{get_discord_pairing_code, get_websites};
use crate::db::postgres::{get_discord_pairing_code, get_lastfm_username, get_websites};
use crate::hos::*;
use core::num::NonZeroU16;
use mljcl::MalojaCredentials;
Expand All @@ -7,6 +7,8 @@ use sqlx::PgPool;
use std::result::Result;
use url::{ParseError, Url};

use super::lastfm::LastFMUser;

pub type Error = Box<dyn std::error::Error + Send + Sync>;
pub type Context<'a> = poise::Context<'a, BotData, Error>;

Expand All @@ -21,6 +23,12 @@ pub struct BotData {
pub lastfm_api: Option<String>,
}

#[derive(Clone, Debug)]
pub enum MljboardUser {
MalojaUser(MalojaCredentials),
LastFMUser(LastFMUser),
}

fn option_nonzerou16_to_u16(input: Option<NonZeroU16>) -> u16 {
// we want to keep the `#0` after the user
// it can't hurt
Expand Down Expand Up @@ -93,12 +101,7 @@ impl BotData {
);
Some(creds)
}
None => {
ctx.say("You don't have a HOS pairing code or a website set up.")
.await
.unwrap();
None
}
None => None,
}
}

Expand Down Expand Up @@ -143,18 +146,45 @@ impl BotData {
}
}

pub async fn handle_lfm_user(
&self,
formatted_user: String,
_ctx: Context<'_>,
) -> Option<LastFMUser> {
let mut assigned_username: Option<String> = None;

for result in get_lastfm_username(&self.pool, formatted_user).await {
assigned_username = result.lastfm_username;
}

if let Some(username) = assigned_username {
return Some(LastFMUser { username });
}

None
}

pub async fn handle_creds(
&self,
formatted_user: String,
ctx: Context<'_>,
) -> Option<MalojaCredentials> {
let creds = self
.handle_website_user(formatted_user.clone(), ctx.clone())
.await;
) -> Option<MljboardUser> {
// prioritize website, then HOS, then Last.FM. website probably responds fastest so it comes first
let creds = self.handle_website_user(formatted_user.clone(), ctx).await;
if let Ok(creds) = creds {
Some(creds)
Some(MljboardUser::MalojaUser(creds))
} else {
self.handle_hos_user(formatted_user, ctx.clone()).await
match self
.handle_hos_user(formatted_user.clone(), ctx)
.await
.map(MljboardUser::MalojaUser)
{
Some(hos_user) => Some(hos_user),
None => self
.handle_lfm_user(formatted_user, ctx)
.await
.map(MljboardUser::LastFMUser),
}
}
}
}
Expand All @@ -168,7 +198,7 @@ pub fn get_arg(content: String) -> String {
#[poise::command(slash_command)]
pub async fn hos_setup(ctx: poise::Context<'_, BotData, Error>) -> Result<(), Error> {
let formatted_user = format_user(ctx.author().clone());
super::setups::hos_setup(ctx.clone(), &ctx.data().pool, formatted_user).await;
super::setups::hos_setup(ctx, &ctx.data().pool, formatted_user).await;
Ok(())
}

Expand All @@ -178,7 +208,7 @@ pub async fn website_setup(
#[description = "Website URL"] website: String,
) -> Result<(), Error> {
let formatted_user = format_user(ctx.author().clone());
super::setups::website_setup(ctx.clone(), &ctx.data().pool, formatted_user, website).await;
super::setups::website_setup(ctx, &ctx.data().pool, formatted_user, website).await;
Ok(())
}

Expand All @@ -188,22 +218,30 @@ pub async fn lfm_setup(
#[description = "Last.FM username"] username: String,
) -> Result<(), Error> {
let formatted_user = format_user(ctx.author().clone());
super::setups::lfm_setup(ctx.clone(), &ctx.data().pool, formatted_user, username).await;
super::setups::lfm_setup(ctx, &ctx.data().pool, formatted_user, username).await;
Ok(())
}

#[poise::command(slash_command)]
pub async fn reset(ctx: poise::Context<'_, BotData, Error>) -> Result<(), Error> {
let formatted_user = format_user(ctx.author().clone());
super::setups::reset(ctx.clone(), &ctx.data().pool, formatted_user).await;
super::setups::reset(ctx, &ctx.data().pool, formatted_user).await;
Ok(())
}

#[poise::command(slash_command)]
pub async fn scrobbles(ctx: poise::Context<'_, BotData, Error>) -> Result<(), Error> {
let formatted_user = format_user(ctx.author().clone());
let creds = ctx.data().handle_creds(formatted_user, ctx.clone()).await;
super::ops::scrobbles_cmd(ctx.data().reqwest_client.clone(), creds, None, ctx.clone()).await;
let user = ctx.data().handle_creds(formatted_user, ctx).await;

if user.is_none() {
ctx.say("You don't have a HOS pairing code or a website set up.")
.await
.unwrap();
return Ok(());
}

super::ops::scrobbles_cmd(ctx.data().reqwest_client.clone(), user, None, ctx).await;
Ok(())
}

Expand All @@ -213,16 +251,17 @@ pub async fn artistscrobbles(
#[description = "Artist"] artist: String,
) -> Result<(), Error> {
let formatted_user = format_user(ctx.author().clone());
let creds = ctx.data().handle_creds(formatted_user, ctx.clone()).await;

super::ops::artistscrobbles_cmd(
ctx.data().reqwest_client.clone(),
creds,
None,
ctx.clone(),
artist,
)
.await;
let user = ctx.data().handle_creds(formatted_user, ctx).await;

if user.is_none() {
ctx.say("You don't have a HOS pairing code or a website set up.")
.await
.unwrap();
return Ok(());
}

super::ops::artistscrobbles_cmd(ctx.data().reqwest_client.clone(), user, None, ctx, artist)
.await;
Ok(())
}

Expand All @@ -231,6 +270,6 @@ pub async fn lfmuser(
ctx: poise::Context<'_, BotData, Error>,
#[description = "User"] user: String,
) -> Result<(), Error> {
super::lastfm::lfmuser_cmd(ctx.clone(), ctx.data().lastfm_api.clone(), user).await;
super::lastfm::lfmuser_cmd(ctx, ctx.data().lastfm_api.clone(), user).await;
Ok(())
}
63 changes: 44 additions & 19 deletions src/discord/lastfm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,38 @@ use std::{future::IntoFuture, time::SystemTime};

const LOADING_GIF: &str = "https://media1.tenor.com/m/mRbYKHgYCOIAAAAC/loading-gif-loading.gif";

#[derive(Clone, Debug)]
pub struct LastFMUser {
pub username: String,
}

#[derive(Clone, Debug)]
pub struct LfmRange {
start: Option<i64>,
end: Option<i64>,
}

impl LfmRange {
pub fn new(start: Option<i64>, end: Option<i64>) -> Self {
LfmRange { start, end }
}
pub fn available(&self) -> bool {
!(self.start.is_none() && self.end.is_none())
}
}

pub fn get_loading_message(username: String, loaded: Option<usize>) -> CreateReply {
let text = format!(
"Working on scrobbles for LastFM user {}... ({} loaded)",
username.clone(),
loaded.unwrap_or(0)
);
return CreateReply::default()
CreateReply::default()
.embed(CreateEmbed::new().title(text).image(LOADING_GIF))
.components(vec![CreateActionRow::Buttons(vec![CreateButton::new(
"cancel",
)
.label("Cancel")])]);
.label("Cancel")])])
}

pub async fn get_streams(
Expand All @@ -38,7 +58,7 @@ pub async fn get_streams(
if tracks.len() % 1000 == 0 {
message
.edit(
ctx.clone(),
ctx,
get_loading_message(username.clone(), Some(tracks.len())),
)
.await
Expand All @@ -55,8 +75,7 @@ pub async fn get_lastfm_user(
ctx: Context<'_>,
api: String,
username: String,
from: Option<i64>,
to: Option<i64>,
lfm_range: LfmRange,
) -> Option<Vec<RecordedTrack>> {
// TODO: caching will skip this step or parts of it *if available*
let message = ctx
Expand All @@ -66,7 +85,7 @@ pub async fn get_lastfm_user(

let user = get_lastfm_client(username.clone(), api.clone()).await;

let recent_tracks = user.recent_tracks(from, to).await;
let recent_tracks = user.recent_tracks(lfm_range.start, lfm_range.end).await;
let ret: Option<Vec<RecordedTrack>>;
tokio::select! {
stream_vec = get_streams(recent_tracks, message.clone(), ctx, username) => {
Expand All @@ -82,7 +101,7 @@ pub async fn get_lastfm_user(

// we don't unwrap here just in case the bot isn't able to delete its own messages
// this is unlikely but there's no reason to crash the entire command for that
let _ = message.delete(ctx.clone()).await;
let _ = message.delete(ctx).await;

ret
}
Expand All @@ -95,24 +114,30 @@ pub async fn lfmuser_cmd(ctx: Context<'_>, api: Option<String>, arg: String) {
.map(|x| x.as_secs() as i64)
.ok();
let one_year_ago = now_secs.map(|x| x - 31_536_000);
let tracks =
get_lastfm_user(ctx.clone(), lastfm_api, arg.clone(), one_year_ago, now_secs).await;
let tracks = get_lastfm_user(
ctx,
lastfm_api,
arg.clone(),
LfmRange::new(one_year_ago, now_secs),
)
.await;

let trackcount = match tracks {
Some(tracks) => tracks.len().to_string(),
None => "[user not found, or cancel occurred]".to_string(),
};

ctx.channel_id().send_message(
ctx,
CreateMessage::new().embed(
CreateEmbed::new()
.title(format!("LastFM user {}'s scrobbles", arg.clone()))
.field("Within the past year", trackcount, false),
),
)
.await
.unwrap();
ctx.channel_id()
.send_message(
ctx,
CreateMessage::new().embed(
CreateEmbed::new()
.title(format!("LastFM user {}'s scrobbles", arg.clone()))
.field("Within the past year", trackcount, false),
),
)
.await
.unwrap();
}
None => {
ctx.say("The bot owner has not set up a Last.FM API key.")
Expand Down
2 changes: 1 addition & 1 deletion src/discord/macros.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use crate::discord::bot::format_user;
use poise::serenity_prelude::*;

pub async fn try_dm_channel(author: User, ctx: Context<'_>) -> Option<PrivateChannel> {
let dm_channel = author.create_dm_channel(ctx.clone()).await;
let dm_channel = author.create_dm_channel(ctx).await;
match dm_channel {
Ok(dm_channel) => Some(dm_channel),
Err(err) => {
Expand Down
Loading

0 comments on commit 0db9527

Please sign in to comment.