+#[cfg(test)]
+mod test;
+
extern crate time;
extern crate timer;
extern crate chrono;
use std::fmt;
use std::iter;
use std::process;
+use std::cell::Cell;
use std::collections::BTreeMap;
use std::sync::mpsc::{channel, Receiver};
use super::http;
use super::settings;
-use super::spotify_api;
+use super::SpotifyEndpoints;
+use super::SPOTIFY_API;
use super::http::HttpResponse;
pub type DeviceId = String;
}
}
-pub fn request_oauth_tokens(auth_code: &str, settings: &settings::Settings) -> (String, String, u64) {
- let query = QueryString::new()
- .add("grant_type", "authorization_code")
- .add("code", auth_code)
- .add("redirect_uri", format!("http://127.0.0.1:{}", settings.port))
- .add("client_id", settings.client_id.clone())
- .add("client_secret", settings.secret.clone())
- .build();
-
- let json_response = http::http(spotify_api::TOKEN, &query, "", http::HttpMethod::POST,
- http::AccessToken::None).unwrap();
- parse_spotify_token(&json_response)
-}
-
#[derive(RustcDecodable, RustcEncodable)]
pub struct PlayContextOffset {
pub position: Option<u32>,
play: bool,
}
-pub struct SpotifyConnectr {
+pub struct SpotifyConnectr<'a> {
+ api: Cell<SpotifyEndpoints<'a>>,
settings: settings::Settings,
auth_code: String,
access_token: Option<String>,
refresh_timer_channel: Option<Receiver<()>>,
}
-impl SpotifyConnectr {
- pub fn new() -> SpotifyConnectr {
+impl<'a> SpotifyConnectr<'a> {
+ pub fn new() -> SpotifyConnectr<'a> {
let settings = match settings::read_settings() {
Some(s) => s,
None => process::exit(0),
let expire = settings.expire_utc;
let access = settings.access_token.clone();
let refresh = settings.refresh_token.clone();
- SpotifyConnectr {settings: settings,
+ SpotifyConnectr {api:Cell::new(SPOTIFY_API),
+ settings: settings,
auth_code: String::new(),
access_token: access,
refresh_token: refresh,
refresh_timer_guard: None,
refresh_timer_channel: None}
}
- fn is_token_expired(&self) -> bool {
- let now = time::now_utc().to_timespec().sec as u64;
- let expire_utc = self.expire_utc.unwrap_or(0);
- expire_utc <= (now - 60)
+ #[cfg(test)]
+ fn with_api(self, api: SpotifyEndpoints<'a>) -> SpotifyConnectr<'a> {
+ self.api.set(api);
+ self
}
+ //fn is_token_expired(&self) -> bool {
+ // let now = time::now_utc().to_timespec().sec as u64;
+ // let expire_utc = self.expire_utc.unwrap_or(0);
+ // expire_utc <= (now - 60)
+ //}
fn expire_offset_to_utc(&self, expires_in: u64) -> u64 {
let now = time::now_utc().to_timespec().sec as u64;
now + expires_in
let (tx, rx) = channel::<()>();
self.refresh_timer_channel = Some(rx);
let expire_offset = self.expire_utc_to_offset(expire_utc) as i64;
+ // Refresh a bit before it expires
+ let expire_offset = match expire_offset {
+ x if x > 60 => x - 60,
+ _ => expire_offset,
+ };
let expire_offset = chrono::Duration::seconds(expire_offset);
+ info!("Refreshing Spotify credentials in {} sec", expire_offset.num_seconds());
let closure = move || { tx.send(()).unwrap(); };
self.refresh_timer_guard = Some(self.refresh_timer.schedule_with_delay(expire_offset, closure));
Ok(())
_ => Err(())
}
}
+ pub fn refresh_access_token(&mut self) {
+ info!("Refreshing Spotify credentials now.");
+ self.refresh_timer_channel = None;
+ match self.refresh_oauth_tokens() {
+ Some((access_token, expires_in)) => {
+ self.access_token = Some(access_token.clone());
+ self.expire_utc = Some(self.expire_offset_to_utc(expires_in));
+ },
+ None => {
+ self.authenticate();
+ }
+ }
+ //let (access_token, expires_in) = ;
+
+ info!("Refreshed credentials.");
+ let _ = self.schedule_token_refresh();
+
+ let access_token = self.access_token.clone().unwrap();
+ let refresh_token = self.refresh_token.clone().unwrap();
+ let _ = settings::save_tokens(&access_token,
+ &refresh_token,
+ self.expire_utc.unwrap());
+ }
pub fn await_once(&mut self, blocking: bool) {
// Choose between blocking or non-blocking receive.
let recv_fn: Box<Fn(&Receiver<()>) -> bool> = match blocking {
if !need_refresh {
return ()
}
- self.refresh_timer_channel = None;
- let (access_token, expires_in) = self.refresh_oauth_tokens();
- self.access_token = Some(access_token.clone());
- self.expire_utc = Some(self.expire_offset_to_utc(expires_in));
- println!("Refreshed credentials.");
- let _ = self.schedule_token_refresh();
-
- let access_token = self.access_token.clone().unwrap();
- let refresh_token = self.refresh_token.clone().unwrap();
- let _ = settings::save_tokens(&access_token,
- &refresh_token,
- self.expire_utc.unwrap());
+ self.refresh_access_token();
}
pub fn authenticate(&mut self) {
- println!("Requesting fresh credentials.");
- self.auth_code = http::authenticate(&self.settings);
- let (access_token, refresh_token, expires_in) = request_oauth_tokens(&self.auth_code, &self.settings);
+ info!("Requesting fresh credentials.");
+ self.auth_code = http::authenticate(self.api.get().scopes, self.api.get().authorize, &self.settings);
+ let (access_token, refresh_token, expires_in) = self.request_oauth_tokens(&self.auth_code, &self.settings);
let expire_utc = self.expire_offset_to_utc(expires_in);
let _ = settings::save_tokens(&access_token, &refresh_token, expire_utc);
self.access_token = Some(access_token);
self.expire_utc = Some(expire_utc);
let _ = self.schedule_token_refresh();
}
+ pub fn request_oauth_tokens(&self, auth_code: &str, settings: &settings::Settings) -> (String, String, u64) {
+ let query = QueryString::new()
+ .add("grant_type", "authorization_code")
+ .add("code", auth_code)
+ .add("redirect_uri", format!("http://127.0.0.1:{}", settings.port))
+ .add("client_id", settings.client_id.clone())
+ .add("client_secret", settings.secret.clone())
+ .build();
+ let json_response = http::http(self.api.get().token, &query, "", http::HttpMethod::POST,
+ http::AccessToken::None).unwrap();
+ parse_spotify_token(&json_response)
+ }
pub fn connect(&mut self) {
- if self.access_token.is_some() && !self.is_token_expired() {
- println!("Reusing saved credentials.");
- let _ = self.schedule_token_refresh();
+ if self.access_token.is_some() {
+ info!("Reusing saved credentials.");
+ self.refresh_access_token();
return ()
}
self.authenticate()
None => http::AccessToken::None,
}
}
- pub fn refresh_oauth_tokens(&self) -> (String, u64) {
+ pub fn refresh_oauth_tokens(&self) -> Option<(String, u64)> {
let query = QueryString::new()
.add("grant_type", "refresh_token")
.add("refresh_token", self.refresh_token.as_ref().unwrap())
.add("client_id", self.settings.client_id.clone())
.add("client_secret", self.settings.secret.clone())
.build();
- let json_response = http::http(spotify_api::TOKEN, &query, "",
- http::HttpMethod::POST, http::AccessToken::None).unwrap();
- let (access_token, _, expires_in) = parse_spotify_token(&json_response);
- (access_token, expires_in)
+ let json_response = http::http(self.api.get().token, &query, "",
+ http::HttpMethod::POST, http::AccessToken::None);
+ match json_response.code {
+ Some(200) => {
+ let (access_token, _, expires_in) = parse_spotify_token(&json_response.data.unwrap());
+ Some((access_token, expires_in))
+ },
+ _ => { None }
+ }
}
pub fn request_device_list(&mut self) -> Option<ConnectDeviceList> {
- let json_response = http::http(spotify_api::DEVICES, "", "",
+ let json_response = http::http(self.api.get().devices, "", "",
http::HttpMethod::GET, self.bearer_token());
match json_response.code {
Some(200) => Some(json::decode(&json_response.data.unwrap()).unwrap()),
Some(401) => {
- self.authenticate();
+ warn!("Access token invalid. Attempting to reauthenticate.");
+ self.refresh_access_token();
None
}
_ => None
}
}
pub fn request_player_state(&mut self) -> Option<PlayerState> {
- let json_response = http::http(spotify_api::PLAYER_STATE, "", "",
+ let json_response = http::http(self.api.get().player_state, "", "",
http::HttpMethod::GET, self.bearer_token());
match json_response.code {
Some(200) => Some(json::decode(&json_response.data.unwrap()).unwrap()),
Some(401) => {
- self.authenticate();
+ warn!("Access token invalid. Attempting to reauthenticate.");
+ self.refresh_access_token();
None
}
_ => None
Some(x) => json::encode(x).unwrap(),
None => String::new(),
};
- http::http(spotify_api::PLAY, &query, &body, http::HttpMethod::PUT, self.bearer_token())
+ http::http(self.api.get().play, &query, &body, http::HttpMethod::PUT, self.bearer_token())
}
pub fn pause(&self) -> SpotifyResponse {
let query = QueryString::new().add_opt("device_id", self.device.clone()).build();
- http::http(spotify_api::PAUSE, &query, "", http::HttpMethod::PUT, self.bearer_token())
+ http::http(self.api.get().pause, &query, "", http::HttpMethod::PUT, self.bearer_token())
}
pub fn next(&self) -> SpotifyResponse {
let query = QueryString::new().add_opt("device_id", self.device.clone()).build();
- http::http(spotify_api::NEXT, &query, "", http::HttpMethod::POST, self.bearer_token())
+ http::http(self.api.get().next, &query, "", http::HttpMethod::POST, self.bearer_token())
}
pub fn previous(&self) -> SpotifyResponse {
let query = QueryString::new().add_opt("device_id", self.device.clone()).build();
- http::http(spotify_api::PREVIOUS, &query, "", http::HttpMethod::POST, self.bearer_token())
+ http::http(self.api.get().previous, &query, "", http::HttpMethod::POST, self.bearer_token())
}
pub fn seek(&self, position: u32) -> SpotifyResponse {
let query = QueryString::new()
.add_opt("device_id", self.device.clone())
.add("position_ms", position)
.build();
- http::http(spotify_api::SEEK, &query, "", http::HttpMethod::PUT, self.bearer_token())
+ http::http(self.api.get().seek, &query, "", http::HttpMethod::PUT, self.bearer_token())
}
pub fn volume(&self, volume: u32) -> SpotifyResponse {
let query = QueryString::new()
.add_opt("device_id", self.device.clone())
.add("volume_percent", volume)
.build();
- http::http(spotify_api::VOLUME, &query, "", http::HttpMethod::PUT, self.bearer_token())
+ http::http(self.api.get().volume, &query, "", http::HttpMethod::PUT, self.bearer_token())
}
pub fn shuffle(&self, shuffle: bool) -> SpotifyResponse {
let query = QueryString::new()
.add_opt("device_id", self.device.clone())
.add("state", shuffle)
.build();
- http::http(spotify_api::SHUFFLE, &query, "", http::HttpMethod::PUT, self.bearer_token())
+ http::http(self.api.get().shuffle, &query, "", http::HttpMethod::PUT, self.bearer_token())
}
pub fn repeat(&self, repeat: SpotifyRepeat) -> SpotifyResponse {
let query = QueryString::new()
.add_opt("device_id", self.device.clone())
.add("state", repeat)
.build();
- http::http(spotify_api::REPEAT, &query, "", http::HttpMethod::PUT, self.bearer_token())
+ http::http(self.api.get().repeat, &query, "", http::HttpMethod::PUT, self.bearer_token())
}
pub fn transfer_multi(&mut self, devices: Vec<String>, play: bool) -> SpotifyResponse {
let device = devices[0].clone();
let body = json::encode(&DeviceIdList {device_ids: devices, play: play}).unwrap();
self.set_target_device(Some(device));
- http::http(spotify_api::PLAYER, "", &body, http::HttpMethod::PUT, self.bearer_token())
+ http::http(self.api.get().player, "", &body, http::HttpMethod::PUT, self.bearer_token())
}
pub fn transfer(&mut self, device: String, play: bool) -> SpotifyResponse {
let body = json::encode(&DeviceIdList {device_ids: vec![device.clone()], play: play}).unwrap();
self.set_target_device(Some(device));
- http::http(spotify_api::PLAYER, "", &body, http::HttpMethod::PUT, self.bearer_token())
+ http::http(self.api.get().player, "", &body, http::HttpMethod::PUT, self.bearer_token())
}
pub fn get_presets(&mut self) -> &Vec<(String,String)> {
&self.settings.presets
+#[cfg(test)]
+mod tests {
+ extern crate futures;
+ extern crate hyper;
+
+ use super::super::*;
+ use super::super::super::SpotifyEndpoints;
+
+ use std;
+ use std::thread;
+ use std::thread::sleep;
+ use std::sync::atomic::AtomicBool;
+ use std::sync::atomic::Ordering;
+ use std::time::Duration;
+ use std::sync::{Once, ONCE_INIT};
+
+ use self::hyper::{Post, StatusCode};
+ use self::hyper::server::{Service, Request, Response};
+ use self::hyper::server::Http;
+ use self::futures::Stream;
+ use self::futures::Future;
+
+ static START: Once = ONCE_INIT;
+
+ lazy_static! {
+ static ref WEBSERVER_STARTED: AtomicBool = AtomicBool::new(false);
+ }
+
+ pub const TEST_API: SpotifyEndpoints = SpotifyEndpoints {
+ scopes: "user-read-private streaming user-read-playback-state",
+ authorize: "http://127.0.0.1:9799/en/authorize",
+ token: "http://127.0.0.1:9799/api/token",
+ devices: "http://127.0.0.1:9799/v1/me/player/devices",
+ player_state: "http://127.0.0.1:9799/v1/me/player",
+ play: "http://127.0.0.1:9799/v1/me/player/play",
+ pause: "http://127.0.0.1:9799/v1/me/player/pause",
+ next: "http://127.0.0.1:9799/v1/me/player/next",
+ previous: "http://127.0.0.1:9799/v1/me/player/previous",
+ seek: "http://127.0.0.1:9799/v1/me/player/seek",
+ volume: "http://127.0.0.1:9799/v1/me/player/volume",
+ shuffle: "http://127.0.0.1:9799/v1/me/player/shuffle",
+ repeat: "http://127.0.0.1:9799/v1/me/player/repeat",
+ player: "http://127.0.0.1:9799/v1/me/player",
+ };
+
+ /// Macro to parse the body of a POST request and send a response.
+ ///
+ /// There's probably a "body.to_string()" function somewhere. I didn't find it.
+ /// So instead there's this unreadable, overly complicated bullshit.
+ ///
+ /// $body_in: a POST body (hyper::Body) from a received POST request
+ /// $pairs_out: the name of the key/value pair variable provided to the $block_in
+ /// $block_in: a block of code to be executed, with $pairs_out in scope, that evaluates
+ /// to tuple (status_code: StatusCode, body: &str) to send as a response.
+ macro_rules! post {
+ ($body_in:ident, $pairs_out:ident, $block_in:block) => {
+ {
+ // Read chunks from user provided body var $body_in
+ $body_in.fold(vec![], |mut acc, chunk| {
+ acc.extend(chunk);
+ Ok::<_, hyper::Error>(acc)
+ }).and_then(move |bytes| {
+ // [u8] -> String
+ let post_data: String = std::str::from_utf8(&bytes).unwrap().to_string();;
+ // Split on & to get ["key=value"...]
+ let pairs = post_data.split("&");
+ // Split on = to get [[key,value]...], put in user provided var name $pairs_out
+ let $pairs_out = pairs.map(|pair| pair.split("=").collect::<Vec<&str>>()).collect::<Vec<Vec<&str>>>();
+ // User provided block takes $pairs_out and returns response string
+ let (code, response) = $block_in;
+ let res = Response::new();
+ Ok(res.with_status(code).with_body(response))
+ }).boxed()
+ }
+ };
+ }
+
+ fn token_response(pairs: &Vec<Vec<&str>>) -> (StatusCode, String) {
+ let mut resp = String::new();
+ let mut code = StatusCode::Ok;
+ resp.push_str("{");
+ resp.push_str(r#""access_token": "valid_access_code","#);
+ resp.push_str(r#""token_type": "Bearer","#);
+ resp.push_str(r#""scope": "user-read-private user-read-email","#);
+ resp.push_str(r#""expires_in": 3600"#);
+ resp.push_str("}");
+ for pair in pairs {
+ let (key,value) = (pair[0], pair[1]);
+ if key == "refresh_token" && value == "error" {
+ code = StatusCode::Forbidden;
+ }
+ }
+ (code, resp)
+ }
+
+ fn init() {
+ while !WEBSERVER_STARTED.load(Ordering::Relaxed) {
+ sleep(Duration::from_millis(100));
+ }
+ START.call_once(|| {
+ #[derive(Clone, Copy)]
+ struct Webapi;
+ impl Service for Webapi {
+ type Request = Request;
+ type Response = Response;
+ type Error = hyper::Error;
+ type Future = futures::BoxFuture<Response, hyper::Error>;
+ fn call(&self, req: Request) -> Self::Future {
+ let (method, uri, _, _headers, body) = req.deconstruct();
+ match(method, uri.path()) {
+ (Post, "/api/token") => post!(body, pairs, { token_response(&pairs) }),
+ _ => futures::future::ok(Response::new().with_status(StatusCode::NotFound)).boxed(),
+ }
+ }
+ }
+ thread::spawn(move || {
+ let addr = "127.0.0.1:9799".parse().unwrap();
+ let server = Http::new().bind(&addr, || Ok(Webapi)).unwrap();
+ server.run().unwrap();
+ WEBSERVER_STARTED.store(true, Ordering::Relaxed);
+ });
+ });
+ }
+
+ #[test]
+ fn test_refresh_oauth_tokens_no_connection() {
+ let spotify = SpotifyConnectr::new().with_api(TEST_API);
+ let res = spotify.refresh_oauth_tokens();
+ // Unlock webserver init so all other tests can run
+ WEBSERVER_STARTED.store(true, Ordering::Relaxed);
+ assert!(res.is_none());
+ }
+
+ #[test]
+ fn test_refresh_oauth_tokens_pass() {
+ init();
+ let spotify = SpotifyConnectr::new().with_api(TEST_API);
+ match spotify.refresh_oauth_tokens() {
+ Some((access,expires)) => {
+ assert_eq!(access, "valid_access_code");
+ assert_eq!(expires, 3600);
+ },
+ None => { assert!(false) },
+ }
+ }
+
+ #[test]
+ fn test_refresh_oauth_tokens_error_status() {
+ init();
+ let mut spotify = SpotifyConnectr::new().with_api(TEST_API);
+ spotify.refresh_token = Some("error".to_string());
+ match spotify.refresh_oauth_tokens() {
+ Some(_) => { assert!(false) },
+ None => { },
+ }
+ }
+
+}