From e82b685776e78eaddaf8fb020dd552ded76ffd60 Mon Sep 17 00:00:00 2001 From: Koen Bolhuis Date: Sat, 9 Jan 2021 04:36:14 +0100 Subject: [PATCH] Initial commit --- .gitignore | 2 + Cargo.toml | 13 +++ src/bin/validate.rs | 7 ++ src/lib.rs | 186 +++++++++++++++++++++++++++++++++++++++++ src/models/mod.rs | 2 + src/models/request.rs | 40 +++++++++ src/models/response.rs | 55 ++++++++++++ 7 files changed, 305 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 src/bin/validate.rs create mode 100644 src/lib.rs create mode 100644 src/models/mod.rs create mode 100644 src/models/request.rs create mode 100644 src/models/response.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..87cfad7 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "listenbrainz" +version = "0.1.0" +authors = ["Koen Bolhuis "] +edition = "2018" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +thiserror = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +ureq = { version = "2", features = ["json"] } diff --git a/src/bin/validate.rs b/src/bin/validate.rs new file mode 100644 index 0000000..98df66a --- /dev/null +++ b/src/bin/validate.rs @@ -0,0 +1,7 @@ +use listenbrainz::Client; + +fn main() { + let token = std::env::args().nth(1).unwrap(); + let mut client = Client::new(); + println!("{:#?}", client.validate_token(&token)); +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..a00aefa --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,186 @@ +//! API bindings for ListenBrainz. + +use std::fmt; +use std::io; + +use serde::{Serialize, Deserialize}; +use serde::de::DeserializeOwned; +use thiserror::Error; +use ureq::Agent; + +pub mod models; + +use models::request::*; +use models::response::*; + +const API_ROOT_URL: &str = "https://api.listenbrainz.org/1/"; + +enum Endpoint<'a> { + SubmitListens, + ValidateToken, + DeleteListen, + //UsersRecentListens(&'a [&'a str]), + UserListenCount(&'a str), + // UserPlayingNow(&'a str), + // UserListens(&'a str), + // LatestImport, + // StatsSitewideArtists, + // StatsUserListeningActivity(&'a str), + // StatsUserDailyActivity(&'a str), + // StatsUserRecordings(&'a str), + // StatsUserArtistMap(&'a str), + // StatsUserReleases(&'a str), + // StatsUserArtists(&'a str), + StatusGetDumpInfo, +} + +impl<'a> fmt::Display for Endpoint<'a> { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let s = match self { + Self::SubmitListens => "submit-listens", + Self::ValidateToken => "validate-token", + Self::DeleteListen => "delete-listen", + // Self::UsersRecentListens(users) => { + // let users = users.join(","); + // return write!(f, "users/{}/recent-listens", users); + // } + Self::UserListenCount(user) => return write!(f, "user/{}/listen-count", user), + // Self::UserPlayingNow(user) => return write!(f, "user/{}/playing-now", user), + // Self::UserListens(user) => return write!(f, "user/{}/listens", user), + // Self::LatestImport => "latest-import", + // Self::StatsSitewideArtists => "stats/sitewide/artists", + // Self::StatsUserListeningActivity(user) => return write!(f, "stats/user/{}/listening-activity", user), + // Self::StatsUserDailyActivity(user) => return write!(f, "stats/user/{}/daily-activity", user), + // Self::StatsUserRecordings(user) => return write!(f, "stats/user/{}/recordings", user), + // Self::StatsUserArtistMap(user) => return write!(f, "stats/user/{}/artist-map", user), + // Self::StatsUserReleases(user) => return write!(f, "stats/user/{}/releases", user), + // Self::StatsUserArtists(user) => return write!(f, "stats/user/{}/artists", user), + Self::StatusGetDumpInfo => "status/get-dump-info", + }; + write!(f, "{}", s) + } +} + +/// Represents errors that can occor while interacting with the API. +#[derive(Error, Debug)] +pub enum Error { + /// The API returned a non-200 status code. + #[error("API error ({code}): {error}")] + Api { code: u16, error: String }, + + /// The input data could not be converted into JSON. + #[error("could not convert request input into JSON")] + RequestJson(#[source] serde_json::Error), + + /// The HTTP response could not be converted into JSON. + #[error("could not convert HTTP response into JSON")] + ResponseJson(#[source] io::Error), + + /// There was some other HTTP error while interacting with the API. + #[error("HTTP error")] + Http(#[source] ureq::Error), +} + +#[derive(Debug, Deserialize)] +struct ApiError { + code: u16, + error: String, +} + +impl From for Error { + fn from(api_error: ApiError) -> Self { + Error::Api { + code: api_error.code, + error: api_error.error, + } + } +} + +impl From for Error { + fn from(error: ureq::Error) -> Self { + match error { + ureq::Error::Status(_code, response) => { + match response.into_json::() { + Ok(api_error) => api_error.into(), + Err(err) => Error::ResponseJson(err), + } + } + ureq::Error::Transport(_) => Error::Http(error), + } + } +} + +/// Low-level client that more-or-less directly wraps the ListenBrainz HTTP API. +/// +/// Client exposes functions that map one-to-one to the API methods described +/// in the [ListenBrainz API docs](https://listenbrainz.readthedocs.io/en/production/dev/api/). +pub struct Client { + agent: Agent, +} + +impl Client { + /// Construct a new client. + pub fn new() -> Self { + Self { + agent: ureq::agent(), + } + } + + fn get(&mut self, endpoint: Endpoint, query: &[(&str, &str)]) -> Result { + let endpoint = format!("{}{}", API_ROOT_URL, endpoint); + + let mut request = self.agent.get(&endpoint); + for &(param, value) in query.iter() { + request = request.query(param, value); + } + request.call()? + .into_json() + .map_err(Error::ResponseJson) + } + + fn post(&mut self, endpoint: Endpoint, token: &str, data: D) -> Result + where + D: Serialize, + R: DeserializeOwned, + { + let data = serde_json::to_value(data).map_err(Error::RequestJson)?; + + let endpoint = format!("{}{}", API_ROOT_URL, endpoint); + + self.agent.post(&endpoint) + .set("Authorization", &format!("Token {}", token)) + .send_json(data)? + .into_json() + .map_err(Error::ResponseJson) + } + + /// Endpoint: `submit-listens` + pub fn submit_listens(&mut self, token: &str, data: Submission) -> Result { + self.post(Endpoint::SubmitListens, token, data) + } + + /// Endpoint: `validate-token` + pub fn validate_token(&mut self, token: &str) -> Result { + self.get(Endpoint::ValidateToken, &[("token", token)]) + } + + /// Endpoint: `delete-listen` + pub fn delete_listen(&mut self, token: &str, data: DeleteListen) -> Result { + self.post(Endpoint::DeleteListen, token, data) + } + + /// Endpoint: `user/{user_name}/listen-count` + pub fn user_listen_count(&mut self, user: &str) -> Result { + self.get(Endpoint::UserListenCount(user), &[]) + } + + /// Endpoint: `status/get-dump-info` + pub fn status_get_dump_info(&mut self, id: Option) -> Result { + if let Some(id) = id { + let id = id.to_string(); + self.get(Endpoint::StatusGetDumpInfo, &[("id", &id)]) + } else { + self.get(Endpoint::StatusGetDumpInfo, &[]) + } + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..e006218 --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,2 @@ +pub mod request; +pub mod response; diff --git a/src/models/request.rs b/src/models/request.rs new file mode 100644 index 0000000..bb76954 --- /dev/null +++ b/src/models/request.rs @@ -0,0 +1,40 @@ +use std::collections::HashMap; + +use serde::Serialize; + +// --------- submit-listens + +#[derive(Debug, Serialize)] +pub struct Submission<'a> { + pub listen_type: ListenType, + pub payload: Vec>, +} + +#[derive(Debug, Serialize)] +pub enum ListenType { + Single, + PlayingNow, + Import, +} + +#[derive(Debug, Serialize)] +pub struct Payload<'a> { + pub listened_at: i64, + pub track_metadata: TrackMetadata<'a> +} + +#[derive(Debug, Serialize)] +pub struct TrackMetadata<'a> { + pub artist_name: &'a str, + pub track_name: &'a str, + pub release_name: Option<&'a str>, + pub additional_info: Option>, +} + +// --------- delete-listen + +#[derive(Debug, Serialize)] +pub struct DeleteListen<'a> { + pub listened_at: i64, + pub recording_msid: &'a str, +} diff --git a/src/models/response.rs b/src/models/response.rs new file mode 100644 index 0000000..22781bf --- /dev/null +++ b/src/models/response.rs @@ -0,0 +1,55 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize)] +pub struct ApiResponse { + code: u16, + message: String, +} + +// --------- submit-listens + +#[derive(Debug, Deserialize)] +pub struct SubmitListensResponse { + #[serde(flatten)] + pub api_response: ApiResponse, +} + +// --------- validate-token + +#[derive(Debug, Deserialize)] +pub struct ValidateTokenResponse { + #[serde(flatten)] + pub api_response: ApiResponse, + + pub valid: bool, + pub user_name: Option, +} + +// --------- delete-listen + +#[derive(Debug, Deserialize)] +pub struct DeleteListenResponse { + #[serde(flatten)] + pub api_response: ApiResponse, +} + +// --------- user/{user_name}/listen-count + +#[derive(Debug, Deserialize)] +pub struct UserListenCountResponse { + #[serde(flatten)] + pub api_response: ApiResponse, + + pub count: u64, +} + +// --------- status/get-dump-info + +#[derive(Debug, Deserialize)] +pub struct StatusGetDumpInfoResponse { + #[serde(flatten)] + pub api_response: ApiResponse, + + pub id: i64, + pub timestamp: String, +}