Dmitry Petrov Back

Building electron app - last.fm support

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:

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!

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:

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.