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:
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.