Telegramusic.ml is a site for sharing music built by my friend Frank. It builds up a playlist from user-submitted music links that automatically follows from one song to another without requiring interaction. Until now, it’s only had support for YouTube embeds. This is the story of how I added Bandcamp embeds to telegramusic.ml.
YouTube offers an iframe embed API that allows you to set functions to be called when the video loads and when the video ends. Telegramusic uses this to (1) automatically start playback for every piece of music and (2) automatically redirect to the next piece of music when playback ends.
Using an official embed
Bandcamp allows one to embed a player using an iframe. As my first step, I wrote a script that generates the appropriate HTML for an embed. This worked by:
- making a request to Bandcamp to get the track number, canonical url, and name
- generating the appropriate HTML
This script works to generate an embed for any track I want. The next step was to figure out how to autoplay and autoredirect as described above. Knowing essentially no Javascript, I tried the following in the browser console:
iframe = document.getElementById('player');
inner_page = iframe.contentDocument;
The second line gave me a SecurityError: Permission denied
. What I was
attempting to do (reaching into the elements inside
the iframe and inspecting/modifying them) is considered a security
vulnerability: cross-site scripting. This makes sense, so I had to take a new
approach.
Cheating the embed
Bandcamp doesn’t offer a similar API for responding to events that happen in iframes. So I get the raw URL of the audio and embed that in my own player. For UX reasons1, I still embed the bandcamp song as an iframe, but crucially, the iframe is not cross-origin, so I can access its interior elements.
Getting the raw URL
The first step is to actually find the raw URL of the Bandcamp audio. Looking at the source of a song page, I noticed a structure in the JS:
trackinfo: [{
...,
"title": "Losing You",
...,
"file": {"mp3-128":"https://t4.bcbits.com/stream/4d71b6ef161c506d9b62cd6ae71cbf17/mp3-128/2214571996?p=0&ts=1527876499&t=4a7179ac4255f4ee08a807b6f9a4b8d0c261a3a8&token=1527876499_8261c2da31690fd726cbacdccba39d8b308d4151"},
...
}],
I wrote functions that uses the regular expression trackinfo:\s+(.+),
to capture the json structure, then parses it with Python’s json.loads()
and
returns the title or the raw URL.
The /bandcamp/ endpoint
I added a new endpoint to the site, /bandcamp/
, that loads the raw URL of a
Bandcamp song as well as the title. It takes a link (and title) passed in as a
parameter, fetches the raw audio link, and embeds that in a template.
@app.route('/bandcamp/')
def bandcamp():
link = request.args.get('link')
if not link:
return '', 400
raw_link = get_raw_link(link)
title = request.args.get('title') or get_title(link) # get_title() makes a network call
if not raw_link:
return '', 400
return render_template('bandcamp_iframe.html', raw_link=raw_link, title=title, link=link)
And here’s the simple template:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Bandcamp</title>
</head>
<body>
<a href="{{ link }}" target="_top"><b>{{ title }}</b></a>
<br><br>
<audio id="player" style="width: 600px;" src="{{ raw_link }}" autoplay controls>No audio, RIP</audio>
</body>
</html>
All this function does is renders a page with a title, a link to the song, and
an <audio>
element with the source of the audio. It’s a rudimentary sort of
embed and I hope to improve it with album art and better styling in the future,
but it functions.
The autoplay
attribute solves half of my scripting problems —
audio will play once it’s loaded.
Adapting the models
The previous code of the site assumed that all music was from YouTube. Here’s
how the model Music
looked:
class Music(ndb.Model):
link = ndb.StringProperty(required=True)
date_added = ndb.DateTimeProperty(required=True)
added_by = ndb.StringProperty(required=True)
ip = ndb.StringProperty()
title = ndb.StringProperty() # set on creation
title_cache_time = ndb.DateTimeProperty(required=True)
position = ndb.IntegerProperty(required=True)
I added a type
attribute with a default value of 'youtube'
, to use when
a user requests the music, to determine exactly how to render the HTML.
Factoring YouTube assumptions out of templates
get.html
is the template used to render “music view” pages like
https://telegramusic.appspot.com/122/. I moved all of the YouTube-specific HTML and
Javascript to an external file, youtube.html
, and replaced it with
{% if entry.type == 'youtube' %}
{% include "youtube.html" %}
{% endif %}
This allowed me to handle different content types within the get.html
template. Then I updated the template to take advantage of a Bandcamp
subtemplate:
{% if entry.type == 'youtube' %}
{% include 'youtube.html' %}
{% elif entry.type == 'bandcamp' %}
{% include 'bandcamp.html' %}
{% endif %}
The Bandcamp template
My final step was to get HTML and Javascript working in bandcamp.html
. The
HTML was easy enough: just an iframe
with some styles and a source that calls
out to the /bandcamp/
endpoint.
<div class="row">
<iframe id="player" width="640" height="200" src="{{ url_for('bandcamp', link=entry.link, title=entry.title) }}"
frameborder="0"></iframe>
</div>
This code passes through the content link and content title as parameters to the Bandcamp endpoint. It passes the link so that the Bandcamp endpoint can get the raw audio link, and it passes the title so that the Bandcamp endpoint doesn’t have to make a second request to get the title2.
The Javascript was a little bit harder, since I don’t know Javascript. I knew
that I needed to add an event listener to the inner player that calls a function
to redirect once playback completes. With a variable player
that represents
the player, that can be accomplished as
player.addEventListener('ended', redirect, false);
But I couldn’t get the player immediately, because the iframe takes time to load. So I added another event listener to the iframe that adds a listener to the player once the iframe loads:
iframe.addEventListener('load', handlePlayer, false);
Altogether, this is what I ended up with:
<div class="row">
<iframe id="player" width="640" height="200" src="{{ url_for('bandcamp', link=entry.link, title=entry.title) }}"
frameborder="0"></iframe>
</div>
<script>
function redirect() {
if (!document.getElementById("shuffle").checked) {
location = "{{ url_for('get', musicid=id + 1) }}";
} else {
location = "{{ url_for('random', shuffle=true) }}";
}
}
function handlePlayer() {
player = iframe.contentDocument.getElementById('player');
player.addEventListener('ended', redirect, false);
}
iframe = document.getElementById('player');
iframe.addEventListener('load', handlePlayer, false);
</script>
This solves the second half of my scripting needs. Hoorah!
Conclusion
If you are curious, here are the commits discussed in this post. It was a fun project to implement, because it required changing assumptions made in many locations in the code. Now that I have added Bandcamp support, I expect it will be easier to add other sources in the future. Next up: Vimeo!
-
Getting the raw audio URL requires a request to bandcamp. If the
<audio>
element was on the main music page, that page would not load until the Bandcamp request is complete. By “iframing-out” to another page, the music page can load and the audio embed can load after, which provides a snappier experience. ↩︎ -
There’s no reason that getting the title has to take a second request; that’s just a consequence of how I implemented my parsing functions. I should probably refactor that. ↩︎