Control Rdio from Vim

I recently spent a number of hours trying to get Rdio to play playlists from the command line. My solution is a neat hack, but there’s also value in seeing what didn’t work before seeing what did.

My goal was to have Vim key bindings to play a specific playlist in Rdio. It turned out to be much more difficult than I expected. Here’s how I did it, but first a GIF of it in action:

vim-rdio in action

rdio-cli

First I found Wynn Netherland‘s rdio-cli gem, which interacts with the Rdio desktop app via AppleScript. It’s great, it works, and if you use the Rdio desktop app, I recommend it. I considered it, but I use Rdio online, and I wanted to script that if possible. rdio-cli also doesn’t let you select a playlist. Onwards!

Rdio API

Then I tried the Rdio API. I signed up for a developer key and browsed their docs, trying to figure out what “user key” means, and how to get a list of playlist names for a user. After figuring out how to get playlist names, I realized that there’s no way to play any music via the Rdio API. The only way to play music outside of the Rdio app on your desktop is via the Web Playback API, which is a Flash file. That’s a no-go for Vim.

[Hacking Montage]

Here’s where I considered a few crazy things:

  • Can I embed a browser in Vim somehow and use the Web Playback API? Emacs could probably do it, but I couldn’t find anything for Vim.
  • Could I use chromedriver to attach to an existing Chrome instance and control Rdio that way? Turns out, no.
  • OK, Rdio uses a lot of JavaScript, is there any way to send arbitrary JavaScript to Chrome?
  • Wait, rdio-cli uses AppleScript. AppleScript can interact with Chrome, and it can tell Chrome to run arbitrary JavaScript…hmmm.

AppleScript

I started writing some AppleScript to get a feel for what was possible with Chrome. Here’s my first script, runnable in Script Editor.app or with osascript:

tell application "Google Chrome"
  get URL of tab 1 of window 1
end tell

I quickly hit a wall with AppleScript: I needed to filter windows and tabs by title to find the one where Rdio was playing, which is possible but painful in AppleScript. Then I remembered: Apple added a JavaScript bridge to AppleScript in OS X Yosemite. Here’s the same function in JavaScript:

var app = Application("Google Chrome");

app.windows[0].tabs[0].url();

I used the official Apple docs to write a function to find the tab where Rdio is playing:

function findRdioTab(){
  var app = Application("Google Chrome");
  var rdioTab = undefined;

  for(var i = 0; i < app.windows().length; i++){
    var window = app.windows[i];
    var possibleRdioTabs = window.tabs.whose({ title: { _endsWith: 'Rdio' } })
    if( possibleRdioTabs.length > 0 ){
      rdioTab = possibleRdioTabs.at(0);
      break;
    }
  }

  return rdioTab;
}

Sweet. Note that I’m using the Apple-provided .whose method to filter the array. Once I have a reference to the Rdio tab, I can call rdioTab.execute({ javascript: "alert()"}) to run any JavaScript I want to. Let’s find out what JavaScript I should run.

Delving into Rdio’s JavaScript source

Unsurprisingly, this phase took a long time. Rdio is a large, complicated app, and it’s hard to figure out what’s happening as an end-user. Fortunately, Rdio provides source maps for their JavaScript, which made understanding it easier.

I figured out that Rdio is built on Backbone because I saw telltale Backbone.Model in the source. That gave my search structure, and I discovered R.player, which seemed like a good place to start. Unfortunately, getting the list of playlist names requires a lot of information from disparate sources, and it’s not realistic to gather that data as an end-user.

I did find R.burnItDown(), which will give you the peaceful Rdio experience you’ve always wanted.

I decided to fall back to clicking on UI elements with jQuery, since that required the least amount of knowledge and was the easiest to script. It’s also the easiest to fix if Rdio changes their code in the future. I inspected elements to find the correct selectors, and I was off.

It was straightforward to get the names of all the playlists:

function getPlaylistNames(){
  // Underscore.js's _.map is available since Rdio is a Backbone app.
  return _.map($('a.playlist'), function(a) { return $(a).prop('title'); })
}

It was also straightforward to visit a playlist with a specific name:

function selectPlaylist(playlistName){
  $('a.playlist[title="' + playlistName + '"]').click()
}

Here’s the rdio-list-playlists.applescript.js file so far:

function findRdioTab(){
  var app = Application("Google Chrome");
  var rdioTab = undefined;

  for(var i = 0; i < app.windows().length; i++){
    var window = app.windows[i];
    var possibleRdioTabs = window.tabs.whose({ title: { _endsWith: 'Rdio' } })
    if( possibleRdioTabs.length > 0 ){
      rdioTab = possibleRdioTabs.at(0);
      break;
    }
  }
  return rdioTab;
}

// The "run" function is automatically run when the file is run
function run(argv) {
  var rdioTab = findRdioTab();
  // The function needs to be passed to `execute` below as a string.
  var defineGetPlaylistNames = "function getPlaylistNames(){ return _.map($('a.playlist'), function(a) { return $(a).prop('title'); }); }";

  rdioTab.execute({javascript: defineGetPlaylistNames});
  var result = rdioTab.execute({javascript: "getPlaylistNames()"})

  // The return value gets printed to stdout
  return result.join("\n");
}

Now I have a file that I can run with osascript -l JavaScript rdio-list-playlists.applescript.js, and it will print the playlist names to standard output (stdout). Let’s use fzf to add fuzzy-finding, and I end up with:

osascript -l JavaScript rdio-list-playlists.applescript.js | fzf

Now I can easily get the name of a playlist, with fuzzy-finding to boot. Let’s move on to playing the playlist.

Actually playing a playlist

There are three steps to playing a playlist: clicking on the playlist name, waiting for the playlist to load, and then clicking the play button for the first track in that playlist.

Let’s start with clicking the play button. I ran into some problems clicking on the “play” button once a playlist loaded. It turns out that Rdio doesn’t destroy old Backbone views, so $(".PlayButton:first") is the first “Play” button ever loaded. This means clicking on it will play the first playlist you’ve ever loaded, instead of the latest one. To fix this, I added the :visible pseudo-selector to only select play buttons that are visible on the page. Here’s the function:

function playCurrentPlaylist(){
  // Order of pseudo-selector matters: :first:visible finds the first play
  // button and checks if it's visible, which it's not.
  $('.PlayButton:visible:first').click();
}

Here, I ran into another problem: playlists take a few seconds to load once you click on them. I had to figure out how to run playCurrentPlaylist at the right time. I tried listening to events from R.router, Rdio’s Backbone.Router instance, by extending an empty object with Backbone.Events:

var eventProxy = {};
_.extend(eventProxy, Backbone.Events);
eventProxy.listenTo(R.router, 'contentChanged', playCurrentPlaylist);

But that fired playCurrentPlaylist either right before the playlist loaded, or at least before the playlist fully loaded. So I kicked it old-school by using setTimeout to wait a few seconds before hitting play:

function selectAndPlayPlaylist(playlistName){
  setTimeout(playCurrentPlaylist, 3000);

  $("a.playlist[title='" + playlistName + "']").click()
}

Here’s the full rdio-play-specific-playlist.applescript.js file:

function findRdioTab(){
  // Exactly the same implementation as before.
}

var definePlayCurrentPlaylist = "function playCurrentPlaylist(){$('.PlayButton:visible:first').click(); };";
var defineSelectAndPlayPlaylist = "function selectAndPlayPlaylist(playlistName){setTimeout(playCurrentPlaylist, 3000); $('a.playlist[title=\"'+playlistName+'\"]').click(); }"
var defineFunctions = definePlayCurrentPlaylist + defineSelectAndPlayPlaylist;

function run(argv){
  var rdioTab = findRdioTab();
  // Get the first argument
  var playlistName = argv[0];
  rdioTab.execute({javascript: defineFunctions});
  rdioTab.execute({javascript: 'selectAndPlayPlaylist("' + playlistName + '")'})
}

Putting it all together

So now I tried stringing it all together:

osascript -l JavaScript rdio-list-playlists.applescript.js | fzf | \
  osascript -l JavaScript rdio-play-specific-playlist.applescript.js

That didn’t work, and it took me a second to figure out why. Can you figure it out?

Pipes pass the output (stdout) of one function as input to another (stdin). But argv in the AppleScript files refers to arguments on the command line, not input from stdin. We need to take the piped output from fzf and pass it to osascript as if it were typed on the command line. Fortunately, xargs (man xargs) does exactly that:

osascript -l JavaScript rdio-list-playlists.applescript.js | fzf | \
  xargs osascript -l JavaScript rdio-play-specific-playlist.applescript.js

One last problem: playlists with spaces in them are interpreted as two arguments, because xargs splits on spaces. So we want the first argument, but Car Singalongs is read as two arguments, Car and Singalongs. Let’s add double quotes around the result with sed before passing it along:

osascript -l JavaScript rdio-list-playlists.applescript.js | fzf | \
  sed -e 's/^/"/g' -e 's/$/"/g' | \
  xargs osascript -l JavaScript rdio-play-specific-playlist.applescript.js

Now we have a working script that fuzzy-finds a playlist name, then plays that playlist.

Vim

Remember when I said I wanted to use this in Vim? Here’s that same command in idiomatic Vim, using fzf#run from fzf’s Vim plugin:

function! RdioPlaylist()
  let items = fzf#run({'source': 'osascript -l JavaScript rdio-list-playlists.applescript.js'})
  let playlistName = shellescape(items[0])
  call system("osascript -l JavaScript rdio-play-specific-playlist.applescript.js " . playlistName)
endfunction

" Allows us to do :RdioPlaylist directly instead of :call RdioPlaylist()
command! RdioPlaylist call RdioPlaylist()

Whew.

Finally, we can play a specific playlist using :RdioPlaylist in Vim. Let’s recap how it all works:

  • rdio-play-playlists.applescript.js sends JavaScript to the Rdio tab in Chrome that gets the name of every playlist. It then outputs each playlist on its own line to standard output.
  • fzf#run takes that output and provides a fuzzy-finding interface to allow you to select one of the playlist names, then returns that playlist name.
  • We run shellescape over the playlist name to put quotes around it, escape special characters, etc.
  • rdio-play-specific-playlist.applescript.js takes in the shellescaped playlist name as the argument and plays that playlist.

Try vim-rdio

I’ve turned this code into a Vim plugin called vim-rdio. In addition to playing playlists, it can play/pause the current track and skip to the next track. Check out the Rakefile, which does some neat things with Erb.