summary history branches tags files
commit:e9970c17c73bba2d0cb66ddfdb48a7187fa27fab
author:Trevor Bentley
committer:Trevor Bentley
date:Sat Aug 5 23:42:12 2017 +0200
parents:6585d8351cda6837ee9579c254446ed6cd6e93a0
Add quick-save and search buttons.
diff --git a/Cargo.toml b/Cargo.toml
line changes: +0/-5
index b82f548..07b8293
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -59,23 +59,18 @@ systray = {path = "deps/systray-rs", version="0.1.1-connectr"}
 [target."cfg(windows)".dependencies.rubrail]
 default-features=false
 version = "0.5"
-#path = "deps/rubrail-rs"
-#version="0.4.3-rc"
 
 [target."cfg(all(unix, not(target_os = \"macos\")))".dependencies]
 
 [target."cfg(all(unix, not(target_os = \"macos\")))".dependencies.rubrail]
 default-features = false
 version = "0.5"
-#path = "deps/rubrail-rs"
-#version = "0.4.3-rc"
 
 [target."cfg(target_os = \"macos\")".dependencies]
 cocoa = "0.9"
 objc-foundation = "0.1.1"
 objc_id = "0.1"
 rubrail = "0.5"
-#rubrail = {path = "deps/rubrail-rs", version="0.4.3-rc"}
 
 [target."cfg(target_os = \"macos\")".dependencies.objc]
 version = "0.2.2"

diff --git a/README.md b/README.md
line changes: +32/-1
index 3fb0dc1..5312e22
--- a/README.md
+++ b/README.md
@@ -21,6 +21,7 @@ What it can do:
 * Play/pause
 * Skip tracks
 * Quick-play a saved 'preset'
+* Quick-save playing track to a playlist
 * Select playback device
 * Change volume
 
@@ -88,6 +89,20 @@ On the first launch, Connectr will guide you through setting up a Spotify develo
 * Add a Redirect URI: <em>http://127.0.0.1:5432</em>
 * Copy your <em>Client ID</em> and <em>Client Secret</em> to `connectr.ini` (see below).
 
+### Mac Touch Bar interface
+
+#### Setup
+* Be sure the "Control Strip" is enabled in the Keyboard section of System Preferences, under `Touch Bar shows`.
+* Press the Connectr icon in the Control Strip to expand it.  It will stay expanded until you press the `x` button on the left side.
+* The Control Strip only supports 4 icons, and stacks all new ones on the left-most icon.  If the Connectr icon is missing, it may be hidden "under" another icon.  You can keep clicking the left-most icon to open them all.
+
+#### Controls
+* Double-tap the track title to rotate through modes:
+ * Track and Artist
+ * Track
+ * Artist
+* To quick-save a track, swipe right on track title until a box is drawn around it and release.  Configure quick-save in `connectr.ini` first.
+
 ### Configuration file (connectr.ini) format
 
 **Note:** connectr uses `~/.connectr.ini` if it exists.  If it does _not_ exist, connectr will fallback to trying `connectr.ini` in the directory it is run from.  A template is provided in `connectr.ini.in`.
@@ -102,7 +117,23 @@ connectr's configuration is read from a regular INI file with these sections:
 * **secret** - Spotify web application's Client Secret (string). _ex: `secret = DEFDEFDEFDEF456456456`_
 
 #### [presets]
-* **Your Preset Name** - Key is the display name of a playable preset, as it will appear in the menu.  The value is a Spotify URI to play, in the _spotify:type:aLoNgUnIqUeIdENTIFier_ format. (string).  _ex: `Bakesale = spotify:album:70XjdLKH7HHsFVWoQipP0T` will show as 'Bakesale' in the menu, and will play the specified Sebadoh album when clicked._
+
+One preset per line, in either format:
+
+* [Preset Name] = [Context URI]
+* [Preset Name] = [Context URI],[Quick-save Playlist URI]
+
+Where:
+
+* `Preset Name` is any name you want for the preset
+* `Context URI` is the Spotify context (album, playlist, etc) to play when selected
+* `Quick-save URI` is (optionally) a playlist to save the current track to if 'Quick-Save' is clicked while this preset is playing.
+
+*Example:*
+
+Make a preset called `Bakesale` that plays a Sebadoh album when selected, and saves my favorite tracks from that album to a private playlist:
+
+`Bakesale = spotify:album:70XjdLKH7HHsFVWoQipP0T,spotify:user:mrmekon:playlist:4aqg0RkXSxknWvIXARV7or`
 
 #### [tokens]
 _note: This section is auto-generated and auto-updated at runtime.  You can leave it empty if you make your own config file._

diff --git a/src/http/mod.rs b/src/http/mod.rs
line changes: +26/-15
index e7846f1..5c54514
--- a/src/http/mod.rs
+++ b/src/http/mod.rs
@@ -69,28 +69,40 @@ pub enum AccessToken<'a> {
     None,
 }
 
-pub fn http(url: &str, query: &str, body: &str,
+pub fn http(url: &str, query: Option<&str>, body: Option<&str>,
             method: HttpMethod, access_token: AccessToken) -> HttpResponse {
-    let enc_query = percent_encoding::utf8_percent_encode(&query, percent_encoding::QUERY_ENCODE_SET).collect::<String>();
-    let mut data = match method {
-        HttpMethod::POST => { enc_query.as_bytes() },
-        _ => { body.as_bytes() },
-        //_ => { query.as_bytes() }
+    let mut headers = List::new();
+    let data = match method {
+        HttpMethod::POST => {
+            match query {
+                Some(q) => {
+                    let enc_query = percent_encoding::utf8_percent_encode(&q, percent_encoding::QUERY_ENCODE_SET).collect::<String>();
+                    enc_query
+                },
+                None => {
+                    let header = format!("Content-Type: application/json");
+                    headers.append(&header).unwrap();
+                    body.unwrap_or("").to_string()
+                }
+            }
+        },
+        _ => { body.unwrap_or("").to_string() },
     };
-    let query_url = &format!("{}?{}", url, query);
+    let mut data = data.as_bytes();
+
     let url = match method {
-        HttpMethod::GET | HttpMethod::PUT => match query.len() {
-            0 => url,
-            _ => query_url,
+        HttpMethod::GET | HttpMethod::PUT => match query {
+            None => url.to_string(),
+            Some(q) => format!("{}?{}", url, q),
         },
-        _ => url
+        _ => url.to_string()
 
     };
     let mut response = None;
     let mut json_bytes = Vec::<u8>::new();
     {
         let mut easy = Easy::new();
-        easy.url(url).unwrap();
+        easy.url(&url).unwrap();
         match method {
             HttpMethod::POST => {
                 easy.post(true).unwrap();
@@ -111,10 +123,9 @@ pub fn http(url: &str, query: &str, body: &str,
                     AccessToken::Basic(token) => ("Basic", token),
                     _ => ("",""),
                 };
-                let mut list = List::new();
                 let header = format!("Authorization: {} {}", request.0, request.1);
-                list.append(&header).unwrap();
-                easy.http_headers(list).unwrap();
+                headers.append(&header).unwrap();
+                easy.http_headers(headers).unwrap();
             }
         }
 

diff --git a/src/lib.rs b/src/lib.rs
line changes: +5/-1
index 1969713..656330a
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -28,6 +28,7 @@ extern crate serde_derive;
 #[derive(Clone, Copy)]
 pub struct SpotifyEndpoints<'a> {
     scopes: &'a str,
+    scopes_version: u32,
     authorize: &'a str,
     token: &'a str,
     devices: &'a str,
@@ -41,10 +42,12 @@ pub struct SpotifyEndpoints<'a> {
     shuffle: &'a str,
     repeat: &'a str,
     player: &'a str,
+    add_to_playlist: &'a str,
 }
 
 pub const SPOTIFY_API: SpotifyEndpoints = SpotifyEndpoints {
-    scopes: "user-read-private streaming user-read-playback-state",
+    scopes: "user-read-private streaming user-read-playback-state playlist-modify-public playlist-modify-private",
+    scopes_version: 1, // increment if scopes change
     authorize: "https://accounts.spotify.com/en/authorize",
     token: "https://accounts.spotify.com/api/token",
     devices: "https://api.spotify.com/v1/me/player/devices",
@@ -58,6 +61,7 @@ pub const SPOTIFY_API: SpotifyEndpoints = SpotifyEndpoints {
     shuffle: "https://api.spotify.com/v1/me/player/shuffle",
     repeat: "https://api.spotify.com/v1/me/player/repeat",
     player: "https://api.spotify.com/v1/me/player",
+    add_to_playlist: "https://api.spotify.com/v1/users",
 };
 
 #[cfg(target_os = "linux")]

diff --git a/src/main.rs b/src/main.rs
line changes: +59/-20
index aa6c0e9..ad6b8c0
--- a/src/main.rs
+++ b/src/main.rs
@@ -69,6 +69,7 @@ enum CallbackAction {
     Preset,
     Redraw,
     Reconfigure,
+    SaveTrack,
 }
 
 #[derive(Serialize, Deserialize, Debug)]
@@ -83,6 +84,7 @@ struct MenuItems {
     play: MenuItem,
     next: MenuItem,
     prev: MenuItem,
+    save: MenuItem,
     preset: Vec<MenuItem>,
     volume: Vec<MenuItem>,
 }
@@ -206,28 +208,33 @@ impl TouchbarUI {
         // Fade text color to green as finger swipes right across the label, and
         // add a solid white border indicating the label is 'selected' after a
         // long enough slide.  Will be used for 'swipe to save' feature.
+        let tx_clone = tx.clone();
         touchbar.add_item_swipe_gesture(&playing_label, Box::new(move |item,state,translation| {
-            let color: f64 = match translation.abs().trunc() as u32 {
-                t if t < 10 => 1.0,
-                t if t > 150 => 0.0,
-                _ => (45. / translation.abs()),
+            let rgba = match translation {
+                t if t > 170. => (0.1, 1.0, 0.7, 1.0),
+                _ => (0.9, 0.9, 0.9, 1.0),
             };
-            let rgba = match state {
-                SwipeState::Ended => (1.0, 1.0, 1.0, 1.0),
-                _ => {
-                    match translation.is_sign_positive() {
-                        true => (color, 1.0, color, 1.0),
-                        false => (1.0, 1.0, 1.0, 1.0),
+            match state {
+                SwipeState::Cancelled | SwipeState::Failed | SwipeState::Unknown => {
+                    unsafe { rubrail::util::set_text_color(item, 1., 1., 1., 1.); }
+                },
+                SwipeState::Ended => {
+                    unsafe { rubrail::util::set_text_color(item, 1., 1., 1., 1.); }
+                    match translation {
+                        t if t > 170. => {
+                            let cmd = MenuCallbackCommand {
+                                action: CallbackAction::SaveTrack,
+                                sender: 0,
+                                data: String::new(),
+                            };
+                            let _ = tx_clone.send(serde_json::to_string(&cmd).unwrap());
+                        },
+                        _ => {},
                     }
                 }
-            };
-            unsafe { rubrail::util::set_text_color(item, rgba.0, rgba.1, rgba.2, rgba.3); }
-            if translation > 170. && state != SwipeState::Ended {
-                unsafe { rubrail::util::set_bg_color(item, 1.0, 1.0, 1.0, 0.95); }
-                // TODO: save track as favorite
-            }
-            else {
-                unsafe { rubrail::util::set_bg_color(item, 0.0, 0.0, 0.0, 0.0); }
+                _ => {
+                    unsafe { rubrail::util::set_text_color(item, rgba.0, rgba.1, rgba.2, rgba.3); }
+                }
             }
         }));
 
@@ -509,6 +516,16 @@ fn fill_menu<T: TStatusBar>(app: &mut ConnectrApp,
             let _ = tx.send(serde_json::to_string(&cmd).unwrap());
         });
         app.menu.prev = status.add_item("Previous", cb, false);
+
+        let cb: NSCallback = Box::new(move |sender, tx| {
+            let cmd = MenuCallbackCommand {
+                action: CallbackAction::SaveTrack,
+                sender: sender,
+                data: String::new(),
+            };
+            let _ = tx.send(serde_json::to_string(&cmd).unwrap());
+        });
+        app.menu.save = status.add_item("Quick-Save", cb, false);
     }
 
     status.add_label("");
@@ -530,7 +547,8 @@ fn fill_menu<T: TStatusBar>(app: &mut ConnectrApp,
                 };
                 let _ = tx.send(serde_json::to_string(&cmd).unwrap());
             });
-            let item = status.add_item(&name.clone(), cb, false);
+            let selected = player_state.playing_from_context(&preset.1);
+            let item = status.add_item(&name.clone(), cb, selected);
             app.menu.preset.push(item);
         }
     }
@@ -608,7 +626,7 @@ fn fill_menu<T: TStatusBar>(app: &mut ConnectrApp,
     let cb: NSCallback = Box::new(move |_sender, _tx| {
         let _ = open::that("https://open.spotify.com/search/");
     });
-    let item = status.add_item("Search Spotify", cb, false);
+    let _ = status.add_item("Search Spotify", cb, false);
 
     status.add_separator();
     status.add_quit("Exit");
@@ -620,6 +638,7 @@ fn clear_menu<T: TStatusBar>(app: &mut ConnectrApp, status: &mut T) {
         play: ptr::null_mut(),
         next: ptr::null_mut(),
         prev: ptr::null_mut(),
+        save: ptr::null_mut(),
         preset: Vec::<MenuItem>::new(),
         volume: Vec::<MenuItem>::new(),
     };
@@ -659,6 +678,25 @@ fn handle_callback(player_state: Option<&connectr::PlayerState>,
         CallbackAction::Redraw => {
             refresh = RefreshTime::Redraw;
         }
+        CallbackAction::SaveTrack => {
+            if let Some(player_state) = player_state {
+                if let Some(ref ctx) = player_state.context {
+                    let playlist: Option<String>;
+                    {
+                        match spotify.quick_save_playlist(&ctx.uri) {
+                            Some(u) => playlist = Some(u.to_owned()),
+                            None => playlist = None,
+                        }
+                    }
+                    if let Some(playlist) = playlist {
+                        if let Some(ref item) = player_state.item {
+                            let track = item.uri.to_owned();
+                            require(spotify.save_track(track, playlist));
+                        }
+                    }
+                }
+            }
+        }
         CallbackAction::Reconfigure => {}
     }
     refresh
@@ -879,6 +917,7 @@ fn main() {
             play: ptr::null_mut(),
             next: ptr::null_mut(),
             prev: ptr::null_mut(),
+            save: ptr::null_mut(),
             preset: Vec::<MenuItem>::new(),
             volume: Vec::<MenuItem>::new(),
         },

diff --git a/src/settings/mod.rs b/src/settings/mod.rs
line changes: +75/-14
index a2b8694..2fc8411
--- a/src/settings/mod.rs
+++ b/src/settings/mod.rs
@@ -22,6 +22,22 @@ pub struct Settings {
     pub refresh_token: Option<String>,
     pub expire_utc: Option<u64>,
     pub presets: Vec<(String,String)>,
+    pub default_quicksave: Option<String>,
+    pub quicksave: BTreeMap<String, String>,
+}
+
+impl Settings {
+    pub fn quick_save_playlist(&self, context: &str) -> Option<&str> {
+        match self.quicksave.get(context) {
+            Some(ref uri) => Some(&uri),
+            None => {
+                match self.default_quicksave {
+                    Some(ref uri) => Some(&uri),
+                    None => None,
+                }
+            }
+        }
+    }
 }
 
 fn default_inifile() -> String {
@@ -68,11 +84,30 @@ To create your free developer application for Connectr, follow these instruction
 <li> Submit this configuration form
 </ul></p>
 <form method="POST" action="#" accept-charset="UTF-8"><table>
-<tr><td>Client ID:</td><td><input type="text" name="client_id"></td></tr>
-<tr><td>Client Secret:</td><td><input type="text" name="secret"></td></tr>
+<tr><td colspan=2><h3>Spotify Credentials (all fields required):</h3></td></tr>
+<tr><td>Client ID:</td><td><input type="text" name="client_id" style="width:400px;"></td></tr>
+<tr><td>Client Secret:</td><td><input type="text" name="secret" style="width:400px;"></td></tr>
 <tr><td colspan=2></br></br></tr></tr>
-<tr><td>Presets:</br>(optional, one per line)</td><td><textarea rows="10" cols="80"  name="presets" placeholder="First Preset Name=spotify:user:spotify:playlist:37i9dQZEVXboyJ0IJdpcuT"></textarea></td></tr>
-<tr><td colspan=2><center><input type="submit" value="Write config file"></center></td></tr>
+<tr><td colspan=2><h3>Presets (all fields optional):</h3>
+    <div style="width:600px;">
+    Presets let you start your favorite Spotify contexts (playlist, album, artist, etc) from Connectr.
+    You can add an optional "quick save" playlist for each preset, to quickly save tracks you like to a known playlist.
+    For instance, you might have a "Discover Weekly" preset, and a quick save to a "Best of Discover Weekly" playlist.
+    You can also set a global "quick save" playlist, where tracks are saved if not playing from a preset with an associated quick-save playlist.</br>
+    </br>
+    All contexts must be specified in Spotify's URI format: ex: <code>spotify:album:2p2UgYlbg4yG44IKDp08Q8</code>
+    </div>
+    </br>
+    One preset per line, in either format::</br>
+    &nbsp;&nbsp;&nbsp;<code>[Preset Name] = [Context URI]</code></br>
+    &nbsp;&nbsp;&nbsp;<code>[Preset Name] = [Context URI],[Quick-save Playlist URI]</code>
+    </br></br>
+</td></tr>
+<tr><td>Presets:</br>(one per line)</td><td><textarea rows="10" cols="100"  name="presets" placeholder="First Preset Name = spotify:user:spotify:playlist:37i9dQZEVXboyJ0IJdpcuT"></textarea></td></tr>
+<tr><td style="width:150px;">Quick-Save URI:</br>(playlist URI)</td><td>
+    <input type="text" name="quicksave_default" style="width:400px;"></td></tr>
+<tr><td colspan=2></br></br></tr></tr>
+<tr><td colspan=2><center><input type="submit" value="Save Configuration" style="height:50px; width: 300px; font-size:20px;"></center></td></tr>
 </br>
 </table></form>
 </br>
@@ -100,8 +135,12 @@ pub fn save_web_config(mut config: BTreeMap<String,String>) -> Ini {
     let client_id = config.remove("client_id").unwrap_or("<PLACEHOLDER>".to_string());
     let presets = config.remove("presets").unwrap_or(String::new());
     c.with_section(Some("application".to_owned()))
-        .set("secret", secret)
-        .set("client_id", client_id);
+        .set("secret", secret.trim())
+        .set("client_id", client_id.trim());
+    if let Some(quicksave) = config.remove("quicksave_default") {
+        c.with_section(Some("connectr".to_owned()))
+            .set("quicksave_default", quicksave.trim());
+    }
     {
         // TODO: INI uses HashMap, doesn't support maintaining order
         for preset in presets.split("\n") {
@@ -117,7 +156,7 @@ pub fn save_web_config(mut config: BTreeMap<String,String>) -> Ini {
     c
 }
 
-pub fn read_settings() -> Option<Settings> {
+pub fn read_settings(scopes_version: u32) -> Option<Settings> {
     info!("Attempting to read config file.");
     let conf = match Ini::load_from_file(&inifile()) {
         Ok(c) => c,
@@ -134,6 +173,10 @@ pub fn read_settings() -> Option<Settings> {
 
     let section = conf.section(Some("connectr".to_owned())).unwrap();
     let port = section.get("port").unwrap().parse().unwrap();
+    let quicksave_default = match section.get("quicksave_default") {
+        Some(uri) => Some(uri.to_string()),
+        None => None,
+    };
 
     let section = conf.section(Some("application".to_owned())).unwrap();
     let secret = section.get("secret").unwrap();
@@ -156,30 +199,48 @@ pub fn read_settings() -> Option<Settings> {
     let mut refresh = None;
     let mut expire_utc = None;
     if let Some(section) = conf.section(Some("tokens".to_owned())) {
-        access = Some(section.get("access").unwrap().clone());
-        refresh = Some(section.get("refresh").unwrap().clone());
-        expire_utc = Some(section.get("expire").unwrap().parse().unwrap());
-        info!("Read access token from INI!");
+        let saved_version = section.get("version");
+        // Only accept saved tokens if the scopes version matches.  Otherwise
+        // it will authenticate but some actions will be invalid.
+        if saved_version.is_some() &&
+            saved_version.unwrap().parse::<u32>().unwrap() == scopes_version {
+            access = Some(section.get("access").unwrap().clone());
+            refresh = Some(section.get("refresh").unwrap().clone());
+            expire_utc = Some(section.get("expire").unwrap().parse().unwrap());
+            info!("Read access token from INI!");
+        }
     }
 
     let mut presets = Vec::<(String,String)>::new();
+    let mut quicksave = BTreeMap::<String,String>::new();
     if let Some(section) = conf.section(Some("presets".to_owned())) {
         for (key, value) in section {
-            presets.push((key.to_owned(), value.to_owned()));
+            let mut fields = value.split(",");
+            let uri = fields.next().unwrap().trim(); // URI is required
+            let save_uri = fields.next(); // quicksave is optional
+            presets.push((key.to_owned(), uri.to_owned()));
+            if let Some(save_uri) = save_uri {
+                quicksave.insert(uri.to_owned(), save_uri.trim().to_owned());
+            }
         }
     }
 
     Some(Settings { secret: secret.to_string(), client_id: client_id.to_string(), port: port,
                     access_token: access, refresh_token: refresh, expire_utc: expire_utc,
-                    presets: presets})
+                    presets: presets,
+                    default_quicksave: quicksave_default,
+                    quicksave: quicksave,
+    })
 }
 
 pub type SettingsError = String;
-pub fn save_tokens(access: &str, refresh: &str, expire_utc: u64) -> Result<(), SettingsError> {
+pub fn save_tokens(version: u32, access: &str,
+                   refresh: &str, expire_utc: u64) -> Result<(), SettingsError> {
     let mut conf = Ini::load_from_file(&inifile()).unwrap();
     conf.with_section(Some("tokens".to_owned()))
         .set("access", access)
         .set("refresh", refresh)
+        .set("version", format!("{}",version))
         .set("expire", expire_utc.to_string());
     conf.write_to_file(&inifile()).unwrap();
     Ok(())

diff --git a/src/webapi/mod.rs b/src/webapi/mod.rs
line changes: +47/-18
index 9e1ac82..d7b8b25
--- a/src/webapi/mod.rs
+++ b/src/webapi/mod.rs
@@ -111,7 +111,7 @@ pub struct ConnectPlaybackItem {
 
 #[derive(Deserialize, Debug)]
 pub struct ConnectContext {
-    uri: String,
+    pub uri: String,
 }
 
 #[derive(Deserialize, Debug)]
@@ -126,6 +126,15 @@ pub struct PlayerState {
     pub context: Option<ConnectContext>,
 }
 
+impl PlayerState {
+    pub fn playing_from_context(&self, context: &str) -> bool {
+        match self.context {
+            Some(ref ctx) => ctx.uri == context,
+            None => false,
+        }
+    }
+}
+
 impl fmt::Display for PlayerState {
     fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
         let play_state = match self.is_playing {
@@ -277,6 +286,11 @@ impl ToString for SpotifyRepeat {
 }
 
 #[derive(Serialize)]
+struct UriList {
+    uris: Vec<String>,
+}
+
+#[derive(Serialize)]
 struct DeviceIdList {
     device_ids: Vec<String>,
     play: bool,
@@ -322,7 +336,7 @@ impl<'a> SpotifyConnectrBuilder<'a> {
     pub fn build(&mut self) -> Option<SpotifyConnectr<'a>> {
         let mut settings: settings::Settings = Default::default();
         if self.expire.is_none() {
-            settings = match settings::read_settings() {
+            settings = match settings::read_settings(self.api.scopes_version) {
                 Some(s) => s,
                 None => { return None },
             };
@@ -365,6 +379,9 @@ impl<'a> SpotifyConnectr<'a> {
             expire: None,
         }
     }
+    pub fn quick_save_playlist(&self, context: &str) -> Option<&str> {
+        self.settings.quick_save_playlist(context)
+    }
     fn expire_offset_to_utc(&self, expires_in: u64) -> u64 {
         let now = time::now_utc().to_timespec().sec as u64;
         now + expires_in
@@ -416,7 +433,8 @@ impl<'a> SpotifyConnectr<'a> {
 
         let access_token = self.access_token.clone().unwrap();
         let refresh_token = self.refresh_token.clone().unwrap();
-        let _ = settings::save_tokens(&access_token,
+        let _ = settings::save_tokens(self.api.scopes_version,
+                                      &access_token,
                                       &refresh_token,
                                       self.expire_utc.unwrap());
     }
@@ -440,7 +458,8 @@ impl<'a> SpotifyConnectr<'a> {
         self.auth_code = http::authenticate(self.api.scopes, self.api.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);
+        let _ = settings::save_tokens(self.api.scopes_version, &access_token,
+                                      &refresh_token, expire_utc);
         self.access_token = Some(access_token);
         self.refresh_token = Some(refresh_token);
         self.expire_utc = Some(expire_utc);
@@ -454,7 +473,7 @@ impl<'a> SpotifyConnectr<'a> {
         .add("client_id", settings.client_id.clone())
         .add("client_secret", settings.secret.clone())
         .build();
-        let json_response = http::http(self.api.token, &query, "", http::HttpMethod::POST,
+        let json_response = http::http(self.api.token, Some(&query), None, http::HttpMethod::POST,
                                        http::AccessToken::None).unwrap();
         parse_spotify_token(&json_response)
     }
@@ -485,7 +504,7 @@ impl<'a> SpotifyConnectr<'a> {
             .add("client_id", self.settings.client_id.clone())
             .add("client_secret", self.settings.secret.clone())
             .build();
-        let json_response = http::http(self.api.token, &query, "",
+        let json_response = http::http(self.api.token, Some(&query), None,
                                        http::HttpMethod::POST, http::AccessToken::None);
         match json_response.code {
             Some(200) => {
@@ -496,7 +515,7 @@ impl<'a> SpotifyConnectr<'a> {
         }
     }
     pub fn request_device_list(&mut self) -> Option<ConnectDeviceList> {
-        let json_response = http::http(self.api.devices, "", "",
+        let json_response = http::http(self.api.devices, None, None,
                                        http::HttpMethod::GET, self.bearer_token());
         match json_response.code {
             Some(200) => serde_json::from_str(&json_response.data.unwrap()).unwrap(),
@@ -509,7 +528,7 @@ impl<'a> SpotifyConnectr<'a> {
         }
     }
     pub fn request_player_state(&mut self) -> Option<PlayerState> {
-        let json_response = http::http(self.api.player_state, "", "",
+        let json_response = http::http(self.api.player_state, None, None,
                                        http::HttpMethod::GET, self.bearer_token());
         match json_response.code {
             Some(200) => match serde_json::from_str(&json_response.data.unwrap()) {
@@ -533,58 +552,68 @@ impl<'a> SpotifyConnectr<'a> {
             Some(x) => serde_json::to_string(x).unwrap(),
             None => String::new(),
         };
-        http::http(self.api.play, &query, &body, http::HttpMethod::PUT, self.bearer_token())
+        http::http(self.api.play, Some(&query), Some(&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(self.api.pause, &query, "", http::HttpMethod::PUT, self.bearer_token())
+        http::http(self.api.pause, Some(&query), None, 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(self.api.next, &query, "", http::HttpMethod::POST, self.bearer_token())
+        http::http(self.api.next, Some(&query), None, 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(self.api.previous, &query, "", http::HttpMethod::POST, self.bearer_token())
+        http::http(self.api.previous, Some(&query), None, 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(self.api.seek, &query, "", http::HttpMethod::PUT, self.bearer_token())
+        http::http(self.api.seek, Some(&query), None, 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(self.api.volume, &query, "", http::HttpMethod::PUT, self.bearer_token())
+        http::http(self.api.volume, Some(&query), None, 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(self.api.shuffle, &query, "", http::HttpMethod::PUT, self.bearer_token())
+        http::http(self.api.shuffle, Some(&query), None, 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(self.api.repeat, &query, "", http::HttpMethod::PUT, self.bearer_token())
+        http::http(self.api.repeat, Some(&query), None, 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 = serde_json::to_string(&DeviceIdList {device_ids: devices, play: play}).unwrap();
         self.set_target_device(Some(device));
-        http::http(self.api.player, "", &body, http::HttpMethod::PUT, self.bearer_token())
+        http::http(self.api.player, None, Some(&body), http::HttpMethod::PUT, self.bearer_token())
     }
     pub fn transfer(&mut self, device: String, play: bool) -> SpotifyResponse {
         let body = serde_json::to_string(&DeviceIdList {device_ids: vec![device.clone()], play: play}).unwrap();
         self.set_target_device(Some(device));
-        http::http(self.api.player, "", &body, http::HttpMethod::PUT, self.bearer_token())
+        http::http(self.api.player, None, Some(&body), http::HttpMethod::PUT, self.bearer_token())
+    }
+    pub fn save_track(&mut self, track: String, playlist: String) -> SpotifyResponse {
+        let playlist_id = playlist.split(":").last().unwrap();
+        let user_id = playlist.split(":").nth(2).unwrap();
+        let uri = format!("{}/{}/playlists/{}/tracks",
+                          self.api.add_to_playlist,
+                          user_id, playlist_id);
+
+        let body = serde_json::to_string(&UriList {uris: vec![track.clone()]}).unwrap();
+        http::http(&uri, None, Some(&body), http::HttpMethod::POST, self.bearer_token())
     }
     pub fn get_presets(&mut self) -> &Vec<(String,String)> {
         &self.settings.presets

diff --git a/src/webapi/test.rs b/src/webapi/test.rs
line changes: +2/-0
index ec8fc57..5653699
--- a/src/webapi/test.rs
+++ b/src/webapi/test.rs
@@ -30,6 +30,7 @@ mod tests {
 
     pub const TEST_API: SpotifyEndpoints = SpotifyEndpoints {
         scopes: "user-read-private streaming user-read-playback-state",
+        scopes_version: 1,
         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",
@@ -43,6 +44,7 @@ mod tests {
         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",
+        add_to_playlist: "http://127.0.0.1:9799/v1/users",
     };
 
     /// Macro to parse the body of a POST request and send a response.