Implement auth.getSession and user.getRecentTracks
This commit is contained in:
parent
d62005c371
commit
a6b9f8f675
16 changed files with 3882 additions and 9 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1 +1 @@
|
|||
/target
|
||||
**/target
|
||||
|
|
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
13
.idea/lastfm-vore.iml
generated
Normal file
13
.idea/lastfm-vore.iml
generated
Normal file
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="EMPTY_MODULE" version="4">
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/example/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/example/target" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/lastfm-vore.iml" filepath="$PROJECT_DIR$/.idea/lastfm-vore.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
1589
Cargo.lock
generated
Normal file
1589
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
11
Cargo.toml
11
Cargo.toml
|
@ -1,6 +1,17 @@
|
|||
[package]
|
||||
name = "lastfm-vore"
|
||||
license = "Apache-2.0"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
http = "1.3.1"
|
||||
|
||||
reqwest = { version = "0.12.15", features = ["json"] }
|
||||
|
||||
md5 = "0.7.0"
|
||||
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
||||
serde_json = "1.0.140"
|
||||
|
||||
thiserror = "2.0.12"
|
1749
example/Cargo.lock
generated
Normal file
1749
example/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
12
example/Cargo.toml
Normal file
12
example/Cargo.toml
Normal file
|
@ -0,0 +1,12 @@
|
|||
[package]
|
||||
name = "example"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
lastfm-vore = { path = "../" }
|
||||
|
||||
tokio = { version = "1.45.0", features = ["full"] }
|
||||
axum = "0.8.4"
|
||||
|
||||
serde = { version = "1.0.219", features = ["derive"] }
|
83
example/src/main.rs
Normal file
83
example/src/main.rs
Normal file
|
@ -0,0 +1,83 @@
|
|||
use std::env;
|
||||
use std::fmt::format;
|
||||
use axum::routing::get;
|
||||
use axum::{Extension, Router};
|
||||
use axum::extract::Query;
|
||||
use axum::response::IntoResponse;
|
||||
use lastfm_vore::{LastfmApiError, LastfmClient};
|
||||
use serde::Deserialize;
|
||||
use lastfm_vore::auth::get_session::GetSessionResponse;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Context {
|
||||
lastfm: LastfmClient,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let key = env::var("API_KEY").expect("API_KEY does not exist");
|
||||
let secret_key = env::var("SECRET_KEY").expect("SECRET_KEY does not exist");
|
||||
|
||||
let lastfm_client = LastfmClient::new(&key, &secret_key);
|
||||
|
||||
let app = Router::new()
|
||||
.route("/callback", get(callback))
|
||||
.layer(Extension(Context { lastfm: lastfm_client }));
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await.unwrap();
|
||||
|
||||
println!("wait a couple seconds and then head over to https://www.last.fm/api/auth/?api_key={}", key);
|
||||
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct CallbackQuery {
|
||||
token: String,
|
||||
}
|
||||
|
||||
async fn callback(
|
||||
Extension(ctx): Extension<Context>,
|
||||
Query(query): Query<CallbackQuery>,
|
||||
) -> String {
|
||||
let res = lastfm_vore::auth::get_session::GetSession::send(&ctx.lastfm, query.token).await;
|
||||
|
||||
match res {
|
||||
Ok(res) => {
|
||||
let res_two = lastfm_vore::user::get_recent_tracks::GetRecentTracks::send(&ctx.lastfm, res.session.name.clone(), None, None, None, None, true).await;
|
||||
|
||||
match res_two {
|
||||
Ok(res_two) => {
|
||||
let res_two = res_two.recenttracks;
|
||||
|
||||
let track = res_two.track.first().unwrap();
|
||||
let artist = track.artist.clone().unwrap();
|
||||
|
||||
let mut format_string = format!("Hello {}! ", res.session.name);
|
||||
|
||||
if track.is_playing() {
|
||||
format_string.push_str("You're currently playing: ");
|
||||
} else {
|
||||
format_string.push_str("You last listened to: ");
|
||||
}
|
||||
|
||||
format_string.push_str(format!("{} by {} ", track.name, artist.get_artist_name()).as_str());
|
||||
|
||||
if let Some(album) = track.album.clone() {
|
||||
format_string.push_str(format!("on {}", album.text).as_str());
|
||||
}
|
||||
|
||||
format_string.push_str("!");
|
||||
|
||||
format_string
|
||||
}
|
||||
Err(e) => {
|
||||
e.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
e.to_string()
|
||||
}
|
||||
}
|
||||
}
|
26
src/auth/get_session.rs
Normal file
26
src/auth/get_session.rs
Normal file
|
@ -0,0 +1,26 @@
|
|||
use serde::{Deserialize};
|
||||
use crate::auth::Session;
|
||||
use crate::{ApiError, LastfmApiError, LastfmClient};
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
pub struct GetSessionResponse {
|
||||
pub session: Session,
|
||||
}
|
||||
|
||||
pub struct GetSession;
|
||||
|
||||
impl GetSession {
|
||||
pub async fn send(lastfm_client: &LastfmClient, token: String) -> Result<GetSessionResponse, LastfmApiError> {
|
||||
let api_url = lastfm_client.build_get_url("auth.getSession", &[("token", token.as_str())], true);
|
||||
|
||||
let resp = reqwest::get(api_url.as_str())
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
match serde_json::from_str::<GetSessionResponse>(resp.as_str()) {
|
||||
Ok(resp) => Ok(resp),
|
||||
Err(_) => Err(serde_json::from_str::<ApiError>(resp.as_str())?.into_error())
|
||||
}
|
||||
}
|
||||
}
|
12
src/auth/mod.rs
Normal file
12
src/auth/mod.rs
Normal file
|
@ -0,0 +1,12 @@
|
|||
use crate::util::bool_from_u8;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub mod get_session;
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
pub struct Session {
|
||||
pub name: String,
|
||||
pub key: String,
|
||||
#[serde(deserialize_with = "bool_from_u8")]
|
||||
pub subscriber: bool
|
||||
}
|
151
src/lib.rs
151
src/lib.rs
|
@ -1,14 +1,149 @@
|
|||
pub fn add(left: u64, right: u64) -> u64 {
|
||||
left + right
|
||||
use http::Uri;
|
||||
use serde::Deserialize;
|
||||
use thiserror::Error;
|
||||
|
||||
pub mod auth;
|
||||
pub mod user;
|
||||
mod util;
|
||||
|
||||
const DEFAULT_LAST_FM_API_URI: &str = "https://ws.audioscrobbler.com/2.0/";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LastfmClient {
|
||||
api_url: String,
|
||||
key: String,
|
||||
secret: String,
|
||||
}
|
||||
|
||||
impl LastfmClient {
|
||||
pub fn new(key: &str, secret: &str) -> Self {
|
||||
LastfmClient {
|
||||
api_url: DEFAULT_LAST_FM_API_URI.to_string(),
|
||||
key: key.to_string(),
|
||||
secret: secret.to_string(),
|
||||
}
|
||||
}
|
||||
pub fn new_with_custom_api(url: &str, key: &str, secret: &str) -> Self {
|
||||
LastfmClient {
|
||||
api_url: url.parse::<Uri>().expect("Invalid API URL").to_string(),
|
||||
key: key.to_string(),
|
||||
secret: secret.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sign_call(&self, method: &str, params: &[(&str, &str)]) -> md5::Digest {
|
||||
let mut string = String::new();
|
||||
|
||||
string.push_str("api_key");
|
||||
string.push_str(self.key.as_str());
|
||||
|
||||
string.push_str("method");
|
||||
string.push_str(method);
|
||||
|
||||
for i in params {
|
||||
string.push_str(&i.0);
|
||||
string.push_str(&i.1);
|
||||
}
|
||||
|
||||
string.push_str(self.secret.as_str());
|
||||
|
||||
md5::compute(string)
|
||||
}
|
||||
|
||||
fn build_get_url(&self, method: &str, params: &[(&str, &str)], sign: bool) -> String {
|
||||
let mut api_url = format!("{}?method={}&api_key={}", self.api_url, method, self.key);
|
||||
|
||||
for i in params {
|
||||
api_url.push_str(format!("&{}={}", i.0, i.1).as_str());
|
||||
}
|
||||
|
||||
if sign {
|
||||
api_url.push_str(format!("&api_sig={:x}", self.sign_call(method, params)).as_str());
|
||||
}
|
||||
|
||||
api_url.push_str("&format=json");
|
||||
|
||||
api_url
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[repr(u16)]
|
||||
pub enum LastfmApiError {
|
||||
#[error("This service does not exist")]
|
||||
InvalidService = 2,
|
||||
#[error("No method with that name in this package")]
|
||||
InvalidMethod,
|
||||
#[error("You do not have permissions to access the service")]
|
||||
AuthenticationFailed,
|
||||
#[error("This services doesn't exist in that format")]
|
||||
InvalidFormat,
|
||||
#[error("Your request is missing a required parameter")]
|
||||
InvalidParameters,
|
||||
#[error("Invalid resource specified")]
|
||||
InvalidResource,
|
||||
#[error("Something else went wrong")]
|
||||
OperationFailed,
|
||||
#[error("Please re-authenticate")]
|
||||
InvalidSessionKey,
|
||||
#[error("You must be granted a valid key by last.fm")]
|
||||
InvalidAPIKey,
|
||||
#[error("This service is temporarily offline. Try again later.")]
|
||||
ServiceOffline,
|
||||
#[error("Invalid method signature supplied")]
|
||||
InvalidMethodSignature = 13,
|
||||
#[error("This token has not been authorized")]
|
||||
UnauthorizedToken,
|
||||
#[error("This token has expired")]
|
||||
ExpiredToken,
|
||||
#[error("Access for your account has been suspended, please contact Last.fm")]
|
||||
SuspendedKey = 26,
|
||||
#[error("Your IP has made too many requests in a short period")]
|
||||
RateLimitExceeded = 29,
|
||||
#[error("Unknown Error")]
|
||||
Unknown,
|
||||
#[error(transparent)]
|
||||
Reqwest(#[from] reqwest::Error),
|
||||
#[error(transparent)]
|
||||
SerdeJson(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct ApiError {
|
||||
error: u16,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn into_error(self) -> LastfmApiError {
|
||||
self.error.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<u16> for LastfmApiError {
|
||||
fn from(error: u16) -> Self {
|
||||
match error {
|
||||
2 => LastfmApiError::InvalidService,
|
||||
3 => LastfmApiError::InvalidMethod,
|
||||
4 => LastfmApiError::AuthenticationFailed,
|
||||
5 => LastfmApiError::InvalidFormat,
|
||||
6 => LastfmApiError::InvalidParameters,
|
||||
7 => LastfmApiError::InvalidResource,
|
||||
8 => LastfmApiError::OperationFailed,
|
||||
9 => LastfmApiError::InvalidSessionKey,
|
||||
10 => LastfmApiError::InvalidAPIKey,
|
||||
11 => LastfmApiError::ServiceOffline,
|
||||
13 => LastfmApiError::InvalidMethodSignature,
|
||||
14 => LastfmApiError::UnauthorizedToken,
|
||||
15 => LastfmApiError::ExpiredToken,
|
||||
26 => LastfmApiError::SuspendedKey,
|
||||
29 => LastfmApiError::RateLimitExceeded,
|
||||
_ => LastfmApiError::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn it_works() {
|
||||
let result = add(2, 2);
|
||||
assert_eq!(result, 4);
|
||||
}
|
||||
}
|
||||
|
|
76
src/user/get_recent_tracks.rs
Normal file
76
src/user/get_recent_tracks.rs
Normal file
|
@ -0,0 +1,76 @@
|
|||
use crate::util::i32_from_string;
|
||||
use serde::Deserialize;
|
||||
use crate::{ApiError, LastfmApiError, LastfmClient};
|
||||
use crate::user::Track;
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
pub struct GetRecentTracksResponse {
|
||||
pub recenttracks: RecentTracks,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
pub struct RecentTracks {
|
||||
pub track: Vec<Track>,
|
||||
#[serde(rename(deserialize = "@attr"))]
|
||||
pub attributes: GetRecentTracksAttributes,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetRecentTracksAttributes {
|
||||
pub user: String,
|
||||
#[serde(deserialize_with = "i32_from_string")]
|
||||
pub total_pages: i32,
|
||||
#[serde(deserialize_with = "i32_from_string")]
|
||||
pub page: i32,
|
||||
#[serde(deserialize_with = "i32_from_string")]
|
||||
pub per_page: i32,
|
||||
#[serde(deserialize_with = "i32_from_string")]
|
||||
pub total: i32
|
||||
}
|
||||
|
||||
pub struct GetRecentTracks;
|
||||
|
||||
impl GetRecentTracks {
|
||||
pub async fn send(lastfm_client: &LastfmClient, username: String, limit: Option<i32>, page: Option<i32>, from: Option<i32>, to: Option<i32>, extended: bool) -> Result<GetRecentTracksResponse, LastfmApiError> {
|
||||
let mut params: Vec<(&str, String)> = Vec::new();
|
||||
|
||||
params.push(("user", username));
|
||||
|
||||
if let Some(l) = limit {
|
||||
params.push(("limit", l.to_string()));
|
||||
}
|
||||
|
||||
if let Some(p) = page {
|
||||
params.push(("page", p.to_string()));
|
||||
}
|
||||
|
||||
if let Some(f) = from {
|
||||
params.push(("from", f.to_string()));
|
||||
}
|
||||
|
||||
if let Some(t) = to {
|
||||
params.push(("to", t.to_string()));
|
||||
}
|
||||
|
||||
if extended {
|
||||
params.push(("extended", "1".to_string()));
|
||||
}
|
||||
|
||||
let api_url = lastfm_client.build_get_url("user.getRecentTracks", params.iter().map(| (k, v) | (*k, v.as_str())).collect::<Vec<(&str, &str)>>().as_slice(), false);
|
||||
|
||||
let resp = reqwest::get(api_url.as_str())
|
||||
.await?
|
||||
.text()
|
||||
.await?;
|
||||
|
||||
match serde_json::from_str::<GetRecentTracksResponse>(resp.as_str()) {
|
||||
Ok(resp) => Ok(resp),
|
||||
Err(e) => {
|
||||
println!("{:#?}", e);
|
||||
|
||||
Err(serde_json::from_str::<ApiError>(resp.as_str())?.into_error())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
82
src/user/mod.rs
Normal file
82
src/user/mod.rs
Normal file
|
@ -0,0 +1,82 @@
|
|||
use crate::util::bool_from_string_u8;
|
||||
use crate::util::i64_from_string;
|
||||
use crate::util::bool_from_string;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub mod get_recent_tracks;
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
pub struct Track {
|
||||
pub mbid: String,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
#[serde(deserialize_with = "bool_from_string_u8")]
|
||||
pub streamable: bool,
|
||||
pub image: Vec<Image>,
|
||||
pub artist: Option<Artist>,
|
||||
pub album: Option<MbidText>,
|
||||
pub date: Option<UnixTimestampText>,
|
||||
#[serde(default)]
|
||||
#[serde(deserialize_with = "bool_from_string_u8")]
|
||||
pub loved: bool,
|
||||
#[serde(rename(deserialize = "@attr"))]
|
||||
pub attributes: Option<TrackAttributes>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
pub struct Artist {
|
||||
pub url: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub image: Option<Vec<Image>>,
|
||||
pub mbid: String,
|
||||
#[serde(rename(deserialize = "#text"))]
|
||||
pub text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
pub struct MbidText {
|
||||
pub mbid: String,
|
||||
#[serde(rename(deserialize = "#text"))]
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
pub struct UnixTimestampText {
|
||||
#[serde(deserialize_with = "i64_from_string")]
|
||||
pub uts: i64,
|
||||
#[serde(rename(deserialize = "#text"))]
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
pub struct Image {
|
||||
pub size: String,
|
||||
#[serde(rename(deserialize = "#text"))]
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Clone, Debug)]
|
||||
pub struct TrackAttributes {
|
||||
#[serde(deserialize_with = "bool_from_string")]
|
||||
pub nowplaying: bool,
|
||||
}
|
||||
|
||||
impl Track {
|
||||
pub fn is_playing(&self) -> bool {
|
||||
if let Some(attr) = self.clone().attributes {
|
||||
attr.nowplaying
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Artist {
|
||||
pub fn get_artist_name(&self) -> String {
|
||||
if self.name.is_some() {
|
||||
self.name.clone().unwrap()
|
||||
} else {
|
||||
self.mbid.clone()
|
||||
}
|
||||
}
|
||||
}
|
63
src/util.rs
Normal file
63
src/util.rs
Normal file
|
@ -0,0 +1,63 @@
|
|||
use serde::de::{self, Deserialize, Deserializer, Unexpected};
|
||||
|
||||
pub(crate) fn bool_from_string<'de, D>(deserializer: D) -> Result<bool, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
match String::deserialize(deserializer)?.as_str() {
|
||||
"false" => Ok(false),
|
||||
"true" => Ok(true),
|
||||
other => Err(de::Error::invalid_value(
|
||||
Unexpected::Str(other),
|
||||
&"zero or one",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn bool_from_string_u8<'de, D>(deserializer: D) -> Result<bool, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
match String::deserialize(deserializer)?.as_str() {
|
||||
"0" => Ok(false),
|
||||
"1" => Ok(true),
|
||||
other => Err(de::Error::invalid_value(
|
||||
Unexpected::Str(other),
|
||||
&"zero or one",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn bool_from_u8<'de, D>(deserializer: D) -> Result<bool, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
match u8::deserialize(deserializer)? {
|
||||
0 => Ok(false),
|
||||
1 => Ok(true),
|
||||
other => Err(de::Error::invalid_value(
|
||||
Unexpected::Unsigned(other as u64),
|
||||
&"zero or one",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn i32_from_string<'de, D>(deserializer: D) -> Result<i32, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
match String::deserialize(deserializer)?.as_str().parse::<i32>() {
|
||||
Ok(res) => Ok(res),
|
||||
Err(err) => Err(de::Error::invalid_value(Unexpected::Str(&err.to_string()), &"i32")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn i64_from_string<'de, D>(deserializer: D) -> Result<i64, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
match String::deserialize(deserializer)?.as_str().parse::<i64>() {
|
||||
Ok(res) => Ok(res),
|
||||
Err(err) => Err(de::Error::invalid_value(Unexpected::Str(&err.to_string()), &"i64")),
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue