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:

  1. making a request to Bandcamp to get the track number, canonical url, and name
  2. 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.

Here’s the function:

@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!


  1. 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. ↩︎

  2. 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. ↩︎