Building electron app - last.fm support
25 Jun 2017All posts from the series can be found here
Half a year ago I’ve built and Electron app to turn Google Music service into something behaving like a desktop app. The overal setup turned out to be exceptionally stable - there was no need for any change since then.
Last week I checked out my Last.FM profile and I though it’s quite a miss that I stopped collecting all the stats about the playback, mostly because there is a real discovery problem with a large music collection - you listen to small fraction of it and forget about older additions as soon as you switch to the new ones. And since you forget you don’t have an easy way to ask Google Music to play something that you liked in 2015. On the other hand Last.FM gives users decent analytics and some discovery tools which are quite handy for me as well because I don’t use Spotify.
So, last week I finally decided to spend time implementing it and there was a couple of issues I’ve spent a plenty of time on, that are worth talking about. So, here we go!
This is a general flow I wanted to implement:
- User launches the app first time
- There is a menu item “Connect to Last.FM”, user clicks on it
- New window is opened with Last.FM authorisation page
- After user confirms, window is closed, app starts scrobbling from the next track.
- Menu item is changed to “Disconnect from Last.FM”. If user clicks it app deletes session information, menu gets back to the restored state. If not, user should get into authorized state after launching app next time.
Track information
First of all I decided to check whether it’s even possible to discover information about new song without dumb polling or hacking into compiled js. There were no obvious events flying around, but the dom looked well structured, so just two functions were needed to understand the current state of the player:
function isPlaying() {
var el = document.querySelector("#player-bar-play-pause");
return el && el.classList.contains("playing");
}
function trackInfo() {
return {
track: document.querySelector("#currently-playing-title").innerText,
album: document.querySelector(".player-album").innerText,
artist: document.querySelector(".player-artist").innerText
};
}
I don’t do any error checking there at the moment, but it’s that simple. Now, that I can get the desired information, the solution is to store the information about current track and send an event outside whenever track is changed:
var currentTrack = {};
function tracksEqualp(one, two) {
return one.track === two.track
&& one.album === two.album
&& one.artist === two.artist;
}
function analyzeTrackChange() {
var playing = isPlaying();
if (!playing) return;
var info = trackInfo();
if (!tracksEqualp(currentTrack, info)) {
currentTrack = info;
scheduleScobbleUpdate(info); // I'll come to that later
ipcRenderer.send('player-song-change', info)
}
}
Really simple. Now we need to figure out when should I call this function to understand wether new track started playing. Luckily we have Mutation events now and we can simply subscribe to changes of the event we’re interested in.
function runWhenTrackInfoChanges(func) {
var target = document.querySelector('#playerSongInfo');
target.focus();
var observer = new MutationObserver(() => func());
var config = {
childList: true,
subtree: true // see crbug.com/134322
};
observer.observe(target, config);
}
Events work amazingly good - I’ve tested it and event is fired only once in my case. So now the only thing remaining is to understand when we can actually start listening for changes. It’s obviously not DOMReady, and I reverted to polling there.
function playerLoadedp() {
return !!document.querySelector("#player-bar-play-pause");
}
function runWhenLoaded(func) {
if (playerLoadedp()) {
func();
return;
}
var timer = setInterval(function() {
if (playerLoadedp()) {
clearInterval(timer);
func();
return;
}
}, 200);
So, all necessary pieces are there, and what’s left is to plug them together:
runWhenLoaded(runWhenTrackInfoChanges.bind(null, analyzeTrackChange));
Now, that would be enough if we only could notify Last.FM right after track change, but scrobbling has some limitations - it api should be called later then 30 seconds after start of the playback + there are limitations for the track length. Now that I think of it, the best solution would be to fire the scrobble event on track change as well, but back then I decided to have a scheduling function for that:
var updateTimer;
function scheduleScobbleUpdate(info) {
clearTimeout(updateTimer);
info.timestamp = Math.floor((+new Date()) / 1000);
updateTimer = setTimeout(function() {
ipcRenderer.send('player-scrobble-time', info)
}, 61000); // a bit bigger then minimal limit
}
Timestamp is necessary for the Last.FM api. Whenever user or app switches a song, timer is reset and scheduled again, so that I don’t fire events when it’s not needed.
Why did I decided to go with events? The reason is that they provide a good abstraction and decouple track notification from actual scrobbling calls.
Here is how things look like in main thread, after everything is implemented:
ipcMain.on('player-song-change', function(e, arg) {
lastfm.nowPlaying(arg);
});
ipcMain.on('player-scrobble-time', function(e, arg) {
lastfm.scrobble(arg);
});
lastfm.init();
In future it would be trivial to add notifications and other bloat there, but it’s not my future, consider forking if you really need it!
Menu
I follow a bottom-up approach when developing new features. That really helps - instead of trying to do everything at once I can slowly create building blocks that will make final solution trivial. Providing events was a first step (itself made from small building blocks). After that I decided to check if I can manipulate menu at all. It appears that it’s quite simple, just a bit unusual.
It’s not really possible to manipulate individual items of the menu, the only working way was to create a full menu and replace everything. If you think about it, it’s even better. First, template:
function getMenuTemplate(lastFMEnabled) {
var template = [
{
label: app.getName(),
submenu: [
{role: 'toggledevtools'},
{type: 'separator'},
(lastFMEnabled ?
{label: 'Disconnect from Last.FM', click: function() { lastfm.disconnect(updateMenu) } }
: {label: 'Connect to Last.FM', click: function() { lastfm.authorize(updateMenu); } }),
{type: 'separator'},
{role: 'quit'}
]
}
];
return template;
}
Whenever menu has to be modified, we call updateMenu function that gathers everything necessary for proper render:
function updateMenu() {
var lastFMEnabled = lastfm.isAuthorized();
var menu = Menu.buildFromTemplate(getMenuTemplate(lastFMEnabled));
Menu.setApplicationMenu(menu);
}
Now, the only thing remaining is actual authorization with Last.FM
Last.FM Authorization
Authorization logic has following requirements:
- When user clicks on authorize link we should open authorization window
- If user aborts the process / closes the window nothing should happen, user should be able to start again
- If user grants permission, we need to close the window and update the interface.
After some research I came to a conclusion that the only viable flow is to use Last.FM authorization method that redirects user to the specified url on success. Local urls (file:///) did not work for me with any options, so I decided to go with local webserver.
var port = 4567;
var host = 'http://127.0.0.1';
var connect_url = () => host + ':' + port + '/auth';
exports.startServer = function(cb) {
if (server) return;
port++ // we don't want to fail if we did not stop previous server
server = http.createServer(function (req, res) {
var pathname = url.parse(req.url).pathname;
if (pathname === '/auth') {
var token = url.parse(req.url, true).query.token;
exports.stopServer();
cb(token);
}
res.writeHead(200, { 'Content-Type' : 'text/html' });
res.end('');
});
server.listen(port);
}
exports.stopServer = function() {
if (!server) return;
server.close();
server = null;
};
Since we only need to understand if interaction happened, simple callback
is enough, connect_url
will be used in other parts of the program. Please
note the trick with the port - I decided to go for it since stopping the
server doesn’t necessarily mean that connection is closed, so I decided
to go with changing the port to be on the safe side. Frankly that’s not
enough since chosen ports might be used, but I decided not to fight with
that corner case for now. Also, functions are exported for testing purposes.
And now, main part:
exports.authorize = function(cb) {
if (lfmWindow) {
lfmWindow.focus();
return;
}
lfmWindow = new BrowserWindow({
width: 600,
height: 600,
})
exports.startServer(function(token) {
exports.stopServer();
lfmWindow.destroy();
lfmWindow = null;
lfm.authenticate(token, function (err, session) {
if (err) { throw err; }
sessionData = session;
settings.set('lastfm', session);
cb();
});
});
lfmWindow.on('close', function(e){
lfmWindow = null;
exports.stopServer();
});
var url = lfm.getAuthenticationUrl({
cb: connect_url()
});
lfmWindow.loadURL(url);
}
exports.disconnect = function(cb) {
settings.sef('lastfm', null);
sessionData = null;
cb();
}
Again, there is nothing we want to understand except whether authorization succeeded, so I go with a simple callback and had all handling logic inside. Settings are saved with synchronous library, since I did not want to deal with it for the sake of simplicity.
After authorization part is done, the only thing needed is a proper initialization:
exports.init = function() {
sessionData = settings.get('lastfm');
if (sessionData) {
lfm.setSessionCredentials(sessionData.username, sessionData.key);
}
return null;
}
exports.isAuthorized = function() {
return !!sessionData;
}
To complete the module I decided to add functions for scrobblying and updating the service about currently playing song. To avoid complicating interface in the main module they both fall back to noop if Last.FM integration is not set up.
After this point I’m ready to say that Perlotto reached full functionality state, and hopefully I won’t need to update it for another year or so. :)
Finally, I hope some of the tricks described in the post will save few hours to devs struggling with them.