summary history branches tags files
commit:554140c345c3bc51161ee1e006c32f9d831558d5
author:Trevor Bentley
committer:Trevor Bentley
date:Sat Jan 20 16:57:06 2018 +0100
parents:92b86daa515e4d9e8bcda89ca133bdefb829ad4e
Add Last.fm scrobbling
diff --git a/Cargo.toml b/Cargo.toml
line changes: +3/-0
index cbb575d..a5bda1a
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -30,6 +30,8 @@ panic = 'unwind'
 [features]
 verbose_http = []
 mac_white_icon = []
+scrobble = ["rustfm-scrobble"]
+default = ["scrobble"]
 
 [dependencies]
 curl = "0.4.6"
@@ -46,6 +48,7 @@ chrono = "0.4"
 log = "0.3.7"
 log4rs = "0.6.3"
 ctrlc = "3.0.1"
+rustfm-scrobble = {version="0.9.1", optional = true}
 
 [dependencies.fruitbasket]
 version = "0.5"

diff --git a/README.md b/README.md
line changes: +67/-7
index 5312e22..76a6e6e
--- a/README.md
+++ b/README.md
@@ -3,9 +3,9 @@
 [![Windows Build Status](https://ci.appveyor.com/api/projects/status/4afwy0yj2477f84h/branch/master?svg=true)](https://ci.appveyor.com/project/mrmekon/connectr/branch/master)
 [![Crates.io Version](https://img.shields.io/crates/v/connectr.svg)](https://crates.io/crates/connectr)
 
-## 
+##
 ### A super lightweight Spotify controller.
-## 
+##
 
 Connectr is a tiny application that lets you quickly and easily see – or change – what's playing on your Spotify account.
 
@@ -24,9 +24,13 @@ What it can do:
 * Quick-save playing track to a playlist
 * Select playback device
 * Change volume
+* Alarm clock (play on a selected device at a specific time)
+* Scrobble to Last.fm
 
 Most importantly, it maintains a tiny memory footprint while running.  ~10MB on a Mac, compared to 300-1000MB for the Spotify desktop app.  You shouldn't need to buy extra RAM just to monitor what's playing on your speakers.
 
+The alarm clock and scrobbling features expect Connectr to run on an always-on server.  If you want to run it on a headless Linux machine, you can configure it on a local machine first and then move the `~/.connectr.ini` to your server.
+
 For developers: the API for communicating with the Spotify backend is provided as a Rust library, available as a Cargo crate. Connectr exposes the official [Spotify 'Player' Web API](https://developer.spotify.com/web-api/web-api-connect-endpoint-reference/) for controlling Spotify Connect devices.
 
 ***NOTE:*** Connectr is not an audio playback tool; it's just a remote control.  Spotify has not publicly released a library for implementing audio playback with Spotify Connect support.  There's a reverse engineering effort, coincidentally also in Rust, at [librespot](https://github.com/plietar/librespot).  The librespot + connectr combo gives you a full Spotify playback experience in ~15MB of RAM.  It's the most resource-efficient way to listen to Spotify.
@@ -43,7 +47,7 @@ For developers: the API for communicating with the Spotify backend is provided a
 
 ## Screenshots
 
-## 
+##
 
 <table><tr><td valign="top">
 
@@ -62,7 +66,7 @@ For developers: the API for communicating with the Spotify backend is provided a
 
 </td></tr></table>
 
-## 
+##
 
 
 ## Build Instructions
@@ -107,10 +111,13 @@ On the first launch, Connectr will guide you through setting up a Spotify develo
 
 **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`.
 
+The config file is generated by a graphical web configuration the first time Connectr is launched, and can be reconfigured by selecting `Reconfigure Connectr` from the menu.  It is not necessary to write `connectr.ini` yourself.  The following documentation is just for reference.
+
 connectr's configuration is read from a regular INI file with these sections:
 
 #### [connectr]
 * **port** - Port to temporarily run web server on when requesting initial OAuth tokens (integer).  Default is 5432. _ex: `port = 5432`_
+* **quicksave_default** - Playlist to save tracks to when 'Quick-Save' is selected
 
 #### [application]
 * **client_id** - Spotify web application's Client ID (string). _ex: `client_id = ABCABCABCABC123123123`_
@@ -135,9 +142,46 @@ Make a preset called `Bakesale` that plays a Sebadoh album when selected, and sa
 
 `Bakesale = spotify:album:70XjdLKH7HHsFVWoQipP0T,spotify:user:mrmekon:playlist:4aqg0RkXSxknWvIXARV7or`
 
+#### [alarms]
+_Note: This can and should be configured through the graphical web interface instead of by editing directly.  Select `Edit Alarms` from the Connectr menu to launch the graphical interface._
+
+Up to five alarm clock entries, which specify a time, device, playlist to play, and which days to repeat the alarm on.
+
+Format:
+
+`alarm<i> = <hour>:<minute>,<repeat>,<Spotify URI>,<Device ID>`
+
+* **`<i>`** - Number between 0 and 4 (inclusive)
+* **`<hour>`** - Hour in 24-hour time (0-23)
+* **`<minute>`** - Minute (0-59)
+* **`<repeat>`** - One of: `daily`, `weekdays`, `weekends`
+* **`<Spotify URI>`** - URI of a Spotify context to play.  Same format as presets.
+* **`<Device ID>`** - Unique ID of the device to play on.  These are listed on the graphical web interface, or can be found in the `~/.connectr.log` log file.
+
+_note: Connectr must be running and connected to the internet at the scheduled alarm time.  The target device must also be running and logged in with your Spotify account.  This means the alarm functionality is most useful when running on an always-on machine such as a home media server or a VPS.  You can run Connectr on a headless server by configuring it on a desktop machine, and copying the `~/.connectr.ini` config to the server._
+
+#### [lastfm]
+
+Optional configuration to have Connectr scrobble track plays to the Last.fm scrobbling service.  This requires a free Last.fm account and free Last.fm developer API tokens.
+
+_note: This MUST be configured through the graphical web interface.  The web interface requests your Last.fm username and password, and the password is swapped out for a session key before saving to the config file.  It is not possible to specify a password in the config file, so you cannot enable Last.fm scrobbling without the GUI.  Once enabled, a valid Last.fm configuration can be transferred to other machines._
+
+_note: Like the alarm feature, scrobbling requires Connectr to always be running.  This means it should be run from an always-on computer, such as a home media server or a VPS.  You can configure it on a regular machine first, and then copy the ¨`/.connectr.ini` file to your always-on server._
+
+There are options to ignore tracks played on phones/tablets or computers, in case you want to have the official Spotify clients handle scrobbling from those devices.  This is beneficial, especially for mobiles, because Spotify can scrobble tracks played while offline.
+
+* **enabled** - Whether Last.fm scrobbling is enabled
+* **key** - Last.fm developer API key
+* **secret** - Last.fm developer API secret
+* **session_key** - Cached Last.fm authentication token
+* **username** - Last.fm username
+* **ignore_pc** - Whether Connectr should ignore tracks played on a computer
+* **ignore_phone** - Whether Connectr should ignore tracks played on a phone/tablet
+
 #### [tokens]
 _note: This section is auto-generated and auto-updated at runtime.  You can leave it empty if you make your own config file._
 
+* **version** - Version of the Connectr authentication format
 * **access** - Spotify Web API access token
 * **refresh** - Spotify Web API refresh token
 * **expire** - Expiration time (UTC) for access token
@@ -158,9 +202,22 @@ Edge Detector=spotify:user:mrmekon:playlist:4SKkpDbZwNGklpIILmEZAg
 Play Today=spotify:user:mrmekon:playlist:4c8eKK6kKrcdt1HToEX7Jc
 
 [tokens]
+version=1
 access=this-is-autogenerated
 refresh=this-is-also-autogenerated
 expire=1492766270
+
+[lastfm]
+enabled=true
+key=aaaaabbbbbbccccccddddddeeeeee
+secret=ffffffgggggghhhhhhhiiiiiijjjjjj
+session_key=kkkkkkllllllmmmmmmnnnnnooooooppppp
+username=MyGloriousUsername
+ignore_phone=true
+ignore_pc=false
+
+[alarms]
+alarm1=08:00,weekdays,spotify:user:mrmekon:playlist:1BayoBGuBA5HhF0ZuYw2sN,1267eba791c19740744eb5c41a5165ce6691fb9b
 ```
 
 ### Feature Progress
@@ -180,6 +237,8 @@ expire=1492766270
 | Change volume                          | <ul><li> [x] </li></ul> | <ul><li> [x] </li></ul> | <ul><li> [x] </li></ul> |
 | Change repeat state                    | <ul><li> [x] </li></ul> | <ul><li> [x] </li></ul> | <ul><li> [x] </li></ul> |
 | Change shuffle state                   | <ul><li> [x] </li></ul> | <ul><li> [x] </li></ul> | <ul><li> [x] </li></ul> |
+| Alarm clock                            | <ul><li> [x] </li></ul> | <ul><li> [x] </li></ul> | <ul><li> [x] </li></ul> |
+| Last.fm Scrobbling                     | <ul><li> [x] </li></ul> | <ul><li> [x] </li></ul> | <ul><li> [x] </li></ul> |
 | Fetch album art                        | <ul><li> [ ] </li></ul> | <ul><li> [ ] </li></ul> | <ul><li> [ ] </li></ul> |
 |                                        |
 |                                        |
@@ -190,12 +249,12 @@ expire=1492766270
 | Device selection                       | <ul><li> [x] </li></ul> | <ul><li> [x] </li></ul> | <ul><li> [ ] </li></ul> |
 | Volume control                         | <ul><li> [x] </li></ul> | <ul><li> [x] </li></ul> | <ul><li> [ ] </li></ul> |
 | Presets                                | <ul><li> [x] </li></ul> | <ul><li> [x] </li></ul> | <ul><li> [ ] </li></ul> |
-| Save current track to playlist         | <ul><li> [ ] </li></ul> | <ul><li> [ ] </li></ul> | <ul><li> [ ] </li></ul> |
+| Save current track to playlist         | <ul><li> [x] </li></ul> | <ul><li> [x] </li></ul> | <ul><li> [ ] </li></ul> |
 |                                        |
 |                                        |
 | **System**                             |
-| Persistent configuration               | <ul><li> [x] </li></ul> | <ul><li> [x] </li></ul> | <ul><li> [ ] </li></ul> |
-| System logging                         | <ul><li> [x] </li></ul> | <ul><li> [x] </li></ul> | <ul><li> [ ] </li></ul> |
+| Persistent configuration               | <ul><li> [x] </li></ul> | <ul><li> [x] </li></ul> | <ul><li> [x] </li></ul> |
+| System logging                         | <ul><li> [x] </li></ul> | <ul><li> [x] </li></ul> | <ul><li> [x] </li></ul> |
 
 
 ## Notable Dependencies
@@ -205,3 +264,4 @@ expire=1492766270
 * [objc](https://github.com/SSheldon/rust-objc/) - SSheldon's suite of Objective-C wrappers for Rust.
 * [cocoa-rs](https://github.com/servo/cocoa-rs) - Cocoa bindings for Rust, which complement `objc`.
 * [systray](https://github.com/qdot/systray-rs) - Windows systray library for Rust.
+* [rustfm-scrobble](https://github.com/bobbo/rustfm-scrobble) - Last.fm scrobbling library.

diff --git a/src/lib.rs b/src/lib.rs
line changes: +30/-3
index 656330a..cd23921
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -115,9 +115,36 @@ impl TStatusBar for DummyStatusBar {
     fn run(&mut self, _: bool) {}
 }
 
-pub fn reconfigure() {
-    let web_config = settings::request_web_config();
-    let _ = settings::save_web_config(web_config);
+#[cfg(feature = "scrobble")]
+extern crate rustfm_scrobble;
+#[cfg(feature = "scrobble")]
+use self::rustfm_scrobble::{Scrobbler, Scrobble};
+#[cfg(not(feature = "scrobble"))]
+type Scrobbler = DummyScrobbler;
+#[cfg(not(feature = "scrobble"))]
+type Scrobble = DummyScrobble;
+
+pub struct DummyScrobbler {}
+impl DummyScrobbler {
+    pub fn new(_api: String, _key: String) -> DummyScrobbler {
+        DummyScrobbler {}
+    }
+    pub fn authenticate_with_password(&self, _a: String, _b: String) -> Result<(), String> { Ok(()) }
+    pub fn authenticate_with_session_key(&self, _a: String) {}
+    pub fn session_key(&self) -> Option<String> { None }
+    pub fn now_playing(&self, _a: DummyScrobble) -> Result<(), String> { Ok(())}
+    pub fn scrobble(&self, _a: DummyScrobble) -> Result<(), String>{ Ok(()) }
+}
+pub struct DummyScrobble {}
+impl DummyScrobble {
+    pub fn new(_a: String, _b: String, _c: String) -> DummyScrobble {
+        DummyScrobble {}
+    }
+}
+
+pub fn reconfigure(settings: Option<&settings::Settings>) {
+    let web_config = settings::request_web_config(settings);
+    let _ = settings::save_web_config(settings, web_config);
 }
 
 pub fn search_paths() -> Vec<String> {

diff --git a/src/main.rs b/src/main.rs
line changes: +164/-2
index 9e9013a..a036946
--- a/src/main.rs
+++ b/src/main.rs
@@ -458,7 +458,7 @@ fn fill_menu<T: TStatusBar>(app: &mut ConnectrApp,
         let cb: NSCallback = Box::new(move |_sender, _tx| {
             let _ = open::that(format!("http://127.0.0.1:{}", connectr::settings::WEB_PORT));
         });
-        let _ = status.add_item("Edit Alarms (relaunch)", cb, false);
+        let _ = status.add_item("Re-launch Config", cb, false);
         status.add_separator();
         status.add_quit("Exit");
         return;
@@ -663,6 +663,16 @@ fn fill_menu<T: TStatusBar>(app: &mut ConnectrApp,
     });
     let _ = status.add_item("Edit Alarms", cb, false);
 
+    let cb: NSCallback = Box::new(move |sender, tx| {
+        let cmd = MenuCallbackCommand {
+            action: CallbackAction::Reconfigure,
+            sender: sender,
+            data: String::new(),
+        };
+        let _ = tx.send(serde_json::to_string(&cmd).unwrap());
+    });
+    let _ = status.add_item("Reconfigure Connectr", cb, false);
+
     status.add_separator();
     let cb: NSCallback = Box::new(move |_sender, _tx| {
         let _ = open::that("https://open.spotify.com/search/");
@@ -789,6 +799,130 @@ fn find_wine_path() -> Option<std::path::PathBuf> {
     None
 }
 
+fn scrobble(spotify: &mut connectr::SpotifyConnectr,
+            state: Option<&connectr::PlayerState>,
+            played_ms: u32,
+            done: bool) {
+    if let Some(state) = flatten_player_state(state) {
+        let len = state.duration_ms;
+        let artist = state.artist.clone();
+        let track = state.track.clone();
+        let album = state.album.clone();
+        let devtype = state.device_type.clone();
+        if done {
+            // Scrobbling API rules:
+            //  - Track longer than 30 seconds
+            //  - Track played at least half its duration OR 4 minutes
+            if len > 30000 &&
+                (played_ms > 4*60000 || played_ms >= len / 2)
+            {
+                spotify.scrobble(artist, track, album, devtype);
+            }
+        }
+        else {
+            spotify.scrobbler_now_playing(artist, track, album, devtype);
+        }
+    }
+}
+
+struct FlatPlayState {
+    #[allow(dead_code)]
+    artist: String,
+    #[allow(dead_code)]
+    track: String,
+    #[allow(dead_code)]
+    album: String,
+    device_type: String,
+    uri: String,
+    progress_ms: u32,
+    duration_ms: u32,
+    is_playing: bool,
+}
+
+fn flatten_player_state(state: Option<&connectr::PlayerState>) -> Option<FlatPlayState> {
+    match state {
+        Some(state) => {
+            match state.item {
+                Some(ref item) => {
+                    let artist = match item.artists.get(0) {
+                        Some(ref a) => a.name.clone(),
+                        None => String::new(),
+                    };
+                    Some(FlatPlayState {
+                        artist: artist,
+                        track: item.name.clone(),
+                        album: item.album.name.clone(),
+                        device_type: state.device.device_type.clone(),
+                        uri: item.uri.clone(),
+                        progress_ms: state.progress_ms.unwrap_or(0),
+                        duration_ms: item.duration_ms,
+                        is_playing: state.is_playing,
+                    })
+                },
+                None => None,
+            }
+        },
+        None => None,
+    }
+}
+
+#[derive(Debug)]
+enum StateChange {
+    Stopped(u32),
+    Changed(u32),
+    Played(u32),
+    Unchanged,
+}
+
+fn compare_playback_states(old: Option<&connectr::PlayerState>,
+                           new: Option<&connectr::PlayerState>) -> StateChange {
+    let old_track = flatten_player_state(old);
+    let new_track = flatten_player_state(new);
+    if old_track.is_none() && new_track.is_none() {
+        return StateChange::Unchanged;
+    }
+    if old_track.is_some() && new_track.is_none() {
+        return StateChange::Stopped(0);
+    }
+    if old_track.is_none() && new_track.is_some() {
+        match new_track.unwrap().is_playing {
+            true => { return StateChange::Changed(0); },
+            false => { return StateChange::Stopped(0); },
+        }
+    }
+    let old_track = old_track.unwrap();
+    let new_track = new_track.unwrap();
+    let new_time = new_track.progress_ms;
+    let old_time = old_track.progress_ms;
+    if old_track.uri != new_track.uri {
+        // Assume that up to 31 seconds of the previous track played.
+        let played = std::cmp::min(
+            std::cmp::max(old_track.duration_ms as i64 - old_time as i64, 0),
+            31000) as u32;
+        if old_track.is_playing && !new_track.is_playing {
+            // End of context
+            return StateChange::Stopped(played);
+        }
+        return StateChange::Changed(played);
+    }
+    let played = std::cmp::max(new_time as i64 - old_time as i64, 0) as u32;
+    if (new_time == 0 && !new_track.is_playing) && (old_time > 0 && old_track.is_playing) {
+        // Playlist finished and reset.  Normally caught above, but this
+        // is a special case for a 1-track playlist.
+        let played = std::cmp::min(
+            std::cmp::max(old_track.duration_ms as i64 - old_time as i64, 0),
+            31000) as u32;
+        return StateChange::Stopped(played);
+    }
+    if played == 0 {
+        return StateChange::Unchanged;
+    }
+    if !old_track.is_playing {
+        return StateChange::Changed(played);
+    }
+    StateChange::Played(played)
+}
+
 struct SpotifyThread {
     #[allow(dead_code)]
     handle: std::thread::JoinHandle<()>,
@@ -814,6 +948,7 @@ fn create_spotify_thread(rx_cmd: Receiver<String>) -> SpotifyThread {
         let rx = rx_in;
         let rx_cmd = rx_cmd;
         let mut refresh_time_utc = 0;
+        let mut track_play_time_ms: u32 = 0;
 
         // Continuously try to create a connection to Spotify web API.
         // If it fails, assume that the settings file is corrupt and inform
@@ -829,7 +964,7 @@ fn create_spotify_thread(rx_cmd: Receiver<String>) -> SpotifyThread {
                     if let Ok(s) = rx_cmd.recv_timeout(Duration::from_secs(120)) {
                         let cmd: MenuCallbackCommand = serde_json::from_str(&s).unwrap();
                         if cmd.action == CallbackAction::Reconfigure {
-                            connectr::reconfigure();
+                            connectr::reconfigure(None);
                         }
                     }
                 },
@@ -867,6 +1002,14 @@ fn create_spotify_thread(rx_cmd: Receiver<String>) -> SpotifyThread {
                     spotify.alarm_configure((*devs).as_ref());
                     let _ = tx.send(SpotifyThreadCommand::ConfigInactive);
                 }
+                if cmd.action == CallbackAction::Reconfigure {
+                    info!("Reconfiguring settings.");
+                    let _ = tx.send(SpotifyThreadCommand::ConfigActive);
+                    connectr::reconfigure(Some(spotify.settings()));
+                    spotify.reread_settings();
+                    let _ = tx.send(SpotifyThreadCommand::ConfigInactive);
+                    info!("Finished reconfiguring.");
+                }
                 let refresh_strategy =  handle_callback(player_state.read().unwrap().as_ref(),
                                                         &mut spotify, &cmd);
                 refresh_time_utc = match refresh_strategy {
@@ -897,6 +1040,25 @@ fn create_spotify_thread(rx_cmd: Receiver<String>) -> SpotifyThread {
                 let play_state = spotify.request_player_state();
                 {
                     let mut player_writer = player_state.write().unwrap();
+                    let cmp = compare_playback_states(player_writer.as_ref(), play_state.as_ref());
+                    info!("State change: {:?}", cmp);
+                    match cmp {
+                        StateChange::Changed(time_ms) => {
+                            track_play_time_ms += time_ms;
+                            scrobble(&mut spotify, player_writer.as_ref(), track_play_time_ms, true);
+                            track_play_time_ms = 0;
+                            scrobble(&mut spotify, play_state.as_ref(), track_play_time_ms, false);
+                        }
+                        StateChange::Stopped(time_ms) => {
+                            track_play_time_ms += time_ms;
+                            scrobble(&mut spotify, player_writer.as_ref(), track_play_time_ms, true);
+                            track_play_time_ms = 0;
+                        },
+                        StateChange::Played(time_ms) => {
+                            track_play_time_ms += time_ms;
+                        },
+                        StateChange::Unchanged => {},
+                    }
                     *player_writer = play_state;
                 }
                 refresh_time_utc = refresh_time(player_state.read().unwrap().as_ref(), now);

diff --git a/src/settings/mod.rs b/src/settings/mod.rs
line changes: +204/-18
index b5e2da2..a8f3ecd
--- a/src/settings/mod.rs
+++ b/src/settings/mod.rs
@@ -4,6 +4,7 @@ use super::http;
 use super::AlarmRepeat;
 use super::AlarmConfig;
 use super::ConnectDeviceList;
+use super::Scrobbler;
 
 extern crate time;
 extern crate fruitbasket;
@@ -18,6 +19,16 @@ const PORT: u32 = 5432;
 pub const WEB_PORT: u32 = 5676;
 
 #[derive(Default)]
+pub struct LastfmSettings {
+    pub key: String,
+    pub secret: String,
+    pub session_key: String,
+    pub username: String,
+    pub ignore_pc: bool,
+    pub ignore_phone: bool,
+}
+
+#[derive(Default)]
 pub struct Settings {
     pub port: u32,
     pub secret: String,
@@ -29,6 +40,8 @@ pub struct Settings {
     pub default_quicksave: Option<String>,
     pub quicksave: BTreeMap<String, String>,
     pub alarms: Vec<AlarmConfig>,
+    pub lastfm_enabled: bool,
+    pub lastfm: Option<LastfmSettings>,
 }
 
 impl Settings {
@@ -67,11 +80,12 @@ fn inifile() -> String {
     String::new()
 }
 
-pub fn request_web_config() -> BTreeMap<String,String> {
-    let form = format!(r###"
+pub fn request_web_config(settings: Option<&Settings>) -> BTreeMap<String,String> {
+    let mut form = format!(r###"
 {}
 <!DOCTYPE HTML>
 <html>
+<meta http-equiv="cache-control" content="no-cache" /><meta http-equiv="expires" content="0"></head>
 <head><title>Connectr Installation</title></head>
 <body>
 <h2>Connectr Installation</h2>
@@ -90,8 +104,19 @@ To create your free developer application for Connectr, follow these instruction
 </ul></p>
 <form method="POST" action="#" accept-charset="UTF-8"><table>
 <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>
+"###,
+                           "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nCache-Control: no-cache, no-store, must-revalidate, max-age=0\r\n\r\n",
+                           PORT);
+    let client_id = settings.map_or("", |s| &s.client_id);
+    let secret = settings.map_or("", |s| &s.secret);
+    form.push_str(&format!(r###"
+<tr><td>Client ID:</td><td><input type="text" name="client_id" value="{}" style="width:400px;"></td></tr>
+<tr><td>Client Secret:</td><td><input type="text" name="secret" value="{}" style="width:400px;"></td></tr>
+"###,
+                           client_id, // client id
+                           secret // secret
+                           ));
+    form.push_str(&format!(r###"
 <tr><td colspan=2></br></br></tr></tr>
 <tr><td colspan=2><h3>Presets (all fields optional):</h3>
     <div style="width:600px;">
@@ -108,11 +133,102 @@ To create your free developer application for Connectr, follow these instruction
     &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>
+"###));
+    let presets = match settings {
+        Some(ref settings) => {
+            let mut p = String::new();
+            for &(ref name, ref uri) in &settings.presets {
+                let qsave = match settings.quicksave.get(uri) {
+                    Some(ref q) => format!(",{}", q),
+                    None => String::new(),
+                };
+                p.push_str(&format!("{} = {}{}", name, uri, qsave));
+            }
+            p
+        },
+        None => String::new(),
+    };
+    let quicksave = match settings {
+        Some(ref s) => match s.default_quicksave {
+            Some(ref d) => &d,
+            None => "",
+        },
+        None => "",
+    };
+    form.push_str(&format!(r###"
+<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:200px;">Quick-Save URI:</br>(playlist URI)</td><td>
+    <input type="text" name="quicksave_default" value="{}" style="width:400px;"></td></tr>
+"###,
+                           presets, // Preset list
+                           quicksave, // quicksave default
+    ));
+
+    form.push_str(&format!(r###"
 <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>
+<tr><td colspan=2><h3>Last.fm Scrobbling (optional):</h3>
+    <div style="width:600px;">
+    Enable Scrobbling to <a href="https://last.fm">Last.fm</a>.  If enabled, Connectr will scrobble any tracks that it sees playing on your Spotify account.  Note that Connectr must be running and online to scrobble, so this feature is most useful when Connectr is hosted on an always-on server like a home media machine or a VPS.</br></br>
+    This whole section is optional, but all fields are required if any of them are specified.</br></br>
+    Scrobbling requires a Last.fm account (free) and a developer API key (also free).  After you have signed up, you can <a href="https://www.last.fm/api/account/create">request an API key here</a>.</br></br>
+    Spotify's desktop and mobile clients have Last.fm scrobbling built in, while Spotify Connect devices like speakers and TVs do not.  Below you can set whether you want Connectr to ignore (not scrobble) certain classes of devices.  This is especially handy for mobile devices, which can scrobble tracks that were played offline.</br></br>
+    </div>
+"###));
+
+    let enabled = match settings {
+        None => false,
+        Some(settings) => match settings.lastfm {
+            None => false,
+            Some(_) => match settings.lastfm_enabled {
+                false => false,
+                true => true,
+            }
+        }
+    };
+    let mut key = String::new();
+    let mut secret = String::new();
+    let mut username = String::new();
+    let mut password = String::new();
+    let mut ignore_pc: &str = "";
+    let mut ignore_phone: &str = "";
+    if let Some(settings) = settings {
+        if let Some(ref lastfm) = settings.lastfm {
+            key = lastfm.key.clone();
+            secret = lastfm.secret.clone();
+            username = lastfm.username.clone();
+            password = "<UNCHANGED>".to_string();
+            ignore_pc = match lastfm.ignore_pc {
+                false => "",
+                true => "checked",
+            };
+            ignore_phone = match lastfm.ignore_phone {
+                false => "",
+                true => "checked",
+            };
+        }
+    }
+    let enabled = match enabled {
+        true => "checked",
+        false => "",
+    };
+    form.push_str(&format!(r###"
+<tr><td>Scrobbling enabled:</td><td><input type="checkbox" name="lastfm_enabled" {}></input></td></tr>
+<tr><td style="width: 200px;">Last.fm API key:</td><td><input type="text" name="lastfm_key" value="{}" style="width:400px;"></td></tr>
+<tr><td>Last.fm API Secret:</td><td><input type="text" name="lastfm_secret" value="{}" style="width:400px;"></td></tr>
+<tr><td>Last.fm username:</td><td><input type="text" name="lastfm_username" value="{}" style="width:400px;"></td></tr>
+<tr><td>Last.fm password:</td><td><input type="password" name="lastfm_password" value="{}" style="width:400px;"></td></tr>
+<tr><td>Ignore device types:</td>
+<td>
+<input type="checkbox" name="lastfm_ignore_pc" {}> Computers</input></br>
+<input type="checkbox" name="lastfm_ignore_phone" {}> Smartphones</input></br>
+</td>
+</tr>
+"###,
+    enabled, key, secret, username, password, ignore_pc, ignore_phone));
+
+    form.push_str(&format!(r###"
+<tr><td colspan=2></br></br></tr></tr>
+<tr><td colspan=2><center><input type="submit" name="cancel" value="Cancel" style="height:50px; width: 300px; font-size:20px;"> &nbsp; <input type="submit" value="Save Configuration" style="height:50px; width: 300px; font-size:20px;"></center></td></tr>
 </br>
 </table></form>
 </br>
@@ -120,11 +236,9 @@ To create your free developer application for Connectr, follow these instruction
 If something goes wrong or changes, edit or delete that file.</small>
 </body></html>
 "###,
-                       "HTTP/1.1 200 OK\r\n\r\n",
-                       PORT,
-                       default_inifile());
+                           default_inifile()));
     let reply = format!("{}Configuration saved.  You can close this window.",
-                        "HTTP/1.1 200 OK\r\n\r\n");
+                        "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n\r\n");
     let mut config = BTreeMap::<String,String>::new();
     config.insert("port".to_string(), PORT.to_string());
     config.append(&mut http::config_request_local_webserver(WEB_PORT, form, reply));
@@ -154,7 +268,7 @@ a.tooltip {{ color: #4e4e4e; text-decoration: none; vertical-align: super; font-
     <h3>Connected Devices: <a href="#" style="width: 300px;" class="tooltip" data-tip="These are the devices that are currently logged in and online.  You can specify any device for your alarm, but be sure it is on and logged in at the right time.  Make sure your device doesn't become unavailable when idle.">eh?</a></h3>
     <table><tr><th style="width:300px;">Name</th><th>Device ID</th></tr>
 "###,
-        "HTTP/1.1 200 OK\r\nCache-Control: no-cache, no-store, must-revalidate, max-age=0\r\n\r\n");
+        "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nCache-Control: no-cache, no-store, must-revalidate, max-age=0\r\n\r\n");
     if let Some(devices) = devices {
         for dev in devices {
             form.push_str(&format!(
@@ -203,14 +317,20 @@ It is wise to have a backup alarm, in case your internet or Spotify is down.</sm
 "###, inifile()));
 
     let reply = format!("{}Configuration saved.  You can close this window.",
-                        "HTTP/1.1 200 OK\r\n\r\n");
+                        "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n\r\n");
     let mut config = BTreeMap::<String,String>::new();
     config.append(&mut http::config_request_local_webserver(WEB_PORT, form, reply));
     config
 }
 
-pub fn save_web_config(mut config: BTreeMap<String,String>) -> Ini {
-    let mut c = Ini::new();
+pub fn save_web_config(old_settings: Option<&Settings>, mut config: BTreeMap<String,String>) -> Ini {
+    let mut c = match old_settings {
+        Some(_) => Ini::load_from_file(&inifile()).unwrap_or(Ini::new()),
+        None => Ini::new(),
+    };
+    if config.contains_key("cancel") {
+        return c;
+    }
     let port = config.remove("port").unwrap();
     c.with_section(Some("connectr".to_owned()))
         .set("port", port);
@@ -235,6 +355,49 @@ pub fn save_web_config(mut config: BTreeMap<String,String>) -> Ini {
             }
         }
     }
+    let lastfm_enabled = match config.remove("lastfm_enabled") {
+        Some(_) => true,
+        None => false,
+    };
+    let key = config.remove("lastfm_key").unwrap_or("".to_string());
+    let secret = config.remove("lastfm_secret").unwrap_or("".to_string());
+    let username = config.remove("lastfm_username").unwrap_or("".to_string());
+    let password = config.remove("lastfm_password").unwrap_or("".to_string());
+    let session_key = match password.as_str() {
+        "<UNCHANGED>" => match old_settings {
+            Some(ref settings) => match settings.lastfm {
+                Some(ref fm) => fm.session_key.clone(),
+                None => String::new(),
+            },
+            None => String::new(),
+        },
+        _ => String::new(),
+    };
+    let ignore_pc = config.remove("lastfm_ignore_pc").unwrap_or("".to_string()) != "";
+    let ignore_phone = config.remove("lastfm_ignore_phone").unwrap_or("".to_string()) != "";
+    // Use existing session key if it exists, otherwise calculate a new one from
+    // the password.
+    let session_key = match session_key.len() {
+        0 => {
+            let mut scrob = Scrobbler::new(key.to_owned(), secret.to_owned());
+            match scrob.authenticate_with_password(username.to_owned(), password.to_owned()) {
+                Ok(_) => match scrob.session_key() {
+                    Some(key) => key,
+                    None => String::new(),
+                },
+                Err(_) => String::new(),
+            }
+        },
+        _ => session_key,
+    };
+    c.with_section(Some("lastfm".to_owned()))
+        .set("enabled", lastfm_enabled.to_string())
+        .set("key", key.trim())
+        .set("secret", secret.trim())
+        .set("session_key", session_key)
+        .set("username", username.trim())
+        .set("ignore_pc", ignore_pc.to_string())
+        .set("ignore_phone", ignore_phone.to_string());
     c.write_to_file(&default_inifile()).unwrap();
     c
 }
@@ -306,8 +469,8 @@ pub fn read_settings(scopes_version: u32) -> Option<Settings> {
             info!("Requesting settings via web form.");
             // Launch a local web server and open a browser to it.  Returns
             // the Spotify configuration.
-            let web_config = request_web_config();
-            save_web_config(web_config)
+            let web_config = request_web_config(None);
+            save_web_config(None, web_config)
         }
     };
 
@@ -374,12 +537,35 @@ pub fn read_settings(scopes_version: u32) -> Option<Settings> {
         }
     }
 
+    let mut lastfm: Option<LastfmSettings> = None;
+    let mut lastfm_enabled = false;
+    if let Some(section) = conf.section(Some("lastfm".to_owned())) {
+        let enabled = section.get("enabled").unwrap_or(&"false".to_string()).clone();
+        let key = section.get("key").unwrap_or(&"".to_string()).clone();
+        let secret = section.get("secret").unwrap_or(&"".to_string()).clone();
+        let session_key = section.get("session_key").unwrap_or(&"".to_string()).clone();
+        let username = section.get("username").unwrap_or(&"".to_string()).clone();
+        let ignore_pc = section.get("ignore_pc").unwrap_or(&"false".to_string()).clone();
+        let ignore_phone = section.get("ignore_phone").unwrap_or(&"false".to_string()).clone();
+        lastfm_enabled = enabled == "true";
+        lastfm = Some(LastfmSettings {
+            key: key,
+            secret: secret,
+            session_key: session_key,
+            username: username,
+            ignore_pc: ignore_pc == "true",
+            ignore_phone: ignore_phone == "true",
+        });
+    }
+
     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,
                     default_quicksave: quicksave_default,
                     quicksave: quicksave,
                     alarms: alarms,
+                    lastfm_enabled: lastfm_enabled,
+                    lastfm: lastfm,
     })
 }
 

diff --git a/src/webapi/mod.rs b/src/webapi/mod.rs
line changes: +66/-0
index de4594f..58b8154
--- a/src/webapi/mod.rs
+++ b/src/webapi/mod.rs
@@ -18,6 +18,8 @@ use std::str::FromStr;
 extern crate serde_json;
 use self::serde_json::Value;
 
+use super::{Scrobbler, Scrobble};
+
 use super::http;
 use super::settings;
 use super::SpotifyEndpoints;
@@ -422,6 +424,8 @@ pub struct SpotifyConnectr<'a> {
 
     alarms: Vec<AlarmTimer>,
     next_alarm_id: AtomicUsize,
+
+    scrobbler: Option<Scrobbler>,
 }
 impl<'a> Default for SpotifyConnectr<'a> {
     fn default() -> Self {
@@ -438,6 +442,7 @@ impl<'a> Default for SpotifyConnectr<'a> {
             refresh_timer_channel: Default::default(),
             alarms: Vec::new(),
             next_alarm_id: AtomicUsize::new(0),
+            scrobbler: None,
         }
     }
 }
@@ -473,11 +478,13 @@ impl<'a> SpotifyConnectrBuilder<'a> {
             refresh_timer_channel: None,
             alarms: Vec::new(),
             next_alarm_id: AtomicUsize::new(0),
+            scrobbler: None,
         };
         let alarms: Vec<AlarmConfig> = cnr.settings.alarms.clone();
         for alarm in &alarms {
             let _ = cnr.schedule_alarm(alarm.into());
         }
+        cnr.scrobbler_authenticate();
         Some(cnr)
     }
     #[cfg(test)]
@@ -504,6 +511,26 @@ impl<'a> SpotifyConnectr<'a> {
             expire: None,
         }
     }
+    fn scrobbler_authenticate(&mut self) {
+        let scrobbler = match self.settings.lastfm_enabled {
+            false => None,
+            true => match self.settings.lastfm {
+                None => None,
+                Some(ref fm) => {
+                    let mut scrob = Scrobbler::new(fm.key.to_owned(), fm.secret.to_owned());
+                    scrob.authenticate_with_session_key(fm.session_key.to_owned());
+                    Some(scrob)
+                }
+            }
+        };
+        self.scrobbler = scrobbler;
+    }
+    pub fn reread_settings(&mut self) {
+        if let Some(settings) = settings::read_settings(self.api.scopes_version) {
+            self.settings = settings;
+        }
+        self.scrobbler_authenticate();
+    }
     pub fn quick_save_playlist(&self, context: &str) -> Option<&str> {
         self.settings.quick_save_playlist(context)
     }
@@ -898,4 +925,43 @@ impl<'a> SpotifyConnectr<'a> {
     pub fn get_presets(&mut self) -> &Vec<(String,String)> {
         &self.settings.presets
     }
+    fn device_can_scrobble(&self, device_type: &str) -> bool {
+        if let Some(ref fm) = self.settings.lastfm {
+            let dev = device_type.to_lowercase();
+            if fm.ignore_pc && dev == "computer" {
+                return false;
+            }
+            if fm.ignore_phone && ["smartphone".to_string(),"tablet".to_string()].contains(&dev) {
+                return false;
+            }
+        }
+        true
+    }
+    pub fn scrobbler_now_playing(&mut self, artist: String, track: String,
+                                 album: String, device_type: String) {
+        if self.device_can_scrobble(&device_type) {
+            if let Some(ref scrobbler) = self.scrobbler {
+                let s = Scrobble::new(artist, track.clone(), album);
+                match scrobbler.now_playing(s) {
+                    Ok(_) => { info!("Scrobbler now playing: {}", track); },
+                    Err(e) => {error!("Scrobbler update failed: {}", e)},
+                }
+            }
+        }
+    }
+    pub fn scrobble(&mut self, artist: String, track: String,
+                    album: String, device_type: String) {
+        if self.device_can_scrobble(&device_type) {
+            if let Some(ref scrobbler) = self.scrobbler {
+                let s = Scrobble::new(artist, track.clone(), album);
+                match scrobbler.scrobble(s) {
+                    Ok(_) => { info!("Scrobbled: {}", track); },
+                    Err(e) => {error!("Scrobbler update failed: {}", e)},
+                }
+            }
+        }
+    }
+    pub fn settings(&self) -> &settings::Settings {
+        &self.settings
+    }
 }