1use std::collections::BTreeMap;
4
5use anyhow::{bail, Context, Result};
6use reqwest::Client;
7use serde_json::Value;
8
9use crate::lastfm::{api_sig, ScrobbleService};
10
11#[derive(Debug, Clone)]
13pub struct AuthClient {
14 http: Client,
15 api_key: String,
16 api_secret: String,
17 service: ScrobbleService,
18}
19
20impl AuthClient {
21 pub fn new(service: ScrobbleService, api_key: String, api_secret: String) -> Self {
22 Self {
23 http: Client::new(),
24 api_key,
25 api_secret,
26 service,
27 }
28 }
29
30 pub fn service(&self) -> ScrobbleService {
31 self.service
32 }
33
34 pub async fn get_token(&self) -> Result<String> {
36 let mut params = BTreeMap::new();
37 params.insert("method".into(), "auth.getToken".into());
38 params.insert("api_key".into(), self.api_key.clone());
39 params.insert("format".into(), "json".into());
40
41 let body = self.post_signed(params).await?;
42 body.get("token")
43 .and_then(|t| t.as_str())
44 .map(str::to_string)
45 .context("auth.getToken response missing token")
46 }
47
48 pub fn authorize_url(&self, token: &str) -> String {
50 let base = match self.service {
51 ScrobbleService::LastFm => "https://www.last.fm/api/auth/",
52 ScrobbleService::LibreFm => "https://libre.fm/api/auth/",
53 };
54 format!("{base}?api_key={}&token={token}", self.api_key)
55 }
56
57 pub async fn get_session(&self, token: &str) -> Result<AuthSession> {
59 let mut params = BTreeMap::new();
60 params.insert("method".into(), "auth.getSession".into());
61 params.insert("api_key".into(), self.api_key.clone());
62 params.insert("token".into(), token.to_string());
63 params.insert("format".into(), "json".into());
64
65 let body = self.post_signed(params).await?;
66 let session = body
67 .get("session")
68 .context("auth.getSession response missing session")?;
69 let key = session
70 .get("key")
71 .and_then(|k| k.as_str())
72 .context("session missing key")?
73 .to_string();
74 let name = session
75 .get("name")
76 .and_then(|n| n.as_str())
77 .unwrap_or("")
78 .to_string();
79 Ok(AuthSession {
80 key,
81 username: name,
82 })
83 }
84
85 async fn post_signed(&self, mut params: BTreeMap<String, String>) -> Result<Value> {
86 let sig = api_sig(¶ms, &self.api_secret);
87 params.insert("api_sig".into(), sig);
88
89 let resp = self
90 .http
91 .post(self.service.api_base())
92 .form(¶ms)
93 .send()
94 .await
95 .with_context(|| format!("POST {}", self.service.api_base()))?;
96
97 let status = resp.status();
98 let body: Value = resp
99 .json()
100 .await
101 .with_context(|| format!("parsing {} response", self.service.display_name()))?;
102
103 if let Some(err) = body.get("error") {
104 let code = err.as_i64().unwrap_or(-1);
105 let message = body
106 .get("message")
107 .and_then(|m| m.as_str())
108 .unwrap_or("unknown error");
109 bail!(
110 "{} API error {code}: {message}",
111 self.service.display_name()
112 );
113 }
114
115 if !status.is_success() {
116 bail!("{} API HTTP {status}", self.service.display_name());
117 }
118
119 Ok(body)
120 }
121}
122
123#[derive(Debug, Clone)]
125pub struct AuthSession {
126 pub key: String,
127 pub username: String,
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 #[test]
135 fn authorize_url_lastfm() {
136 let client = AuthClient::new(ScrobbleService::LastFm, "abc123".into(), "secret".into());
137 assert_eq!(
138 client.authorize_url("tok"),
139 "https://www.last.fm/api/auth/?api_key=abc123&token=tok"
140 );
141 }
142
143 #[test]
144 fn authorize_url_librefm() {
145 let client = AuthClient::new(ScrobbleService::LibreFm, "abc123".into(), "secret".into());
146 assert_eq!(
147 client.authorize_url("tok"),
148 "https://libre.fm/api/auth/?api_key=abc123&token=tok"
149 );
150 }
151}