Building a static HTML widget to showcase your new favourite songs

Hey, the 90s Internet called, they want their music discovery back

February 2024

Sam Boyer

Ah, Geocities. MySpace. Napster. The good old days.

Was I around for any of it? …no. Can I still pretend to be nostalgic for my own nefarious purposes? hell yes.

Websites were quite different back then. Piles of GIF images and jokes and whimsy that you very rarely find on today’s index pages. A lot of that content moved with the users , but one thing that I think was lost was music sharing. It was acceptable, and encouraged, to share the music they’ve made or that they enjoy. (It might even play automatically at max volume if you’re lucky.) Sharing songs you like via DMs is still a thing, but these days music discovery is almost overwhelmingly done for us by the platform’s algorithms. And as a certified Algorithm-Hater, I take that personally.

While I’m still a few years off littering my website with GIFs and bright red Comic Sans, I did recently add a widget that shares the newest handful songs in my ‘favourites’ playlist. Let me embed it again for you:

In this post I’ll show you how to make your own! We’ll be covering things like: - Accessing the Spotify Web (‘REST’) API - Fetching album art images & processing them with ImageMagick - Generating HTML pages with Jinja templates - Uploading the result to a web server automatically

Note: I’ll currently only describing the workflow for Spotify playlists. If this isn’t the case, search for '{your music platform} API get playlist' and replace Step 1 with those instructions :)

Step 1: Get your playlist data from Spotify

The process to access Spotify’s API is:

We’ll be using the ‘Get Playlist Items’ endpoint, which lives at https://api.spotify.com/v1/playlists/{playlist_id}/tracks. The ‘playlist ID’ mentioned in that URL is just the bit of code that comes after ‘playlist/’ in a playlist URL, e.g. ‘https://open.spotify.com/playlist/5EIjIqnxsxQrlms9XSWhEs?si=…’.

Unfortunately there’s no option to order the returned items by most recently added, and we don’t get all the playlist contents in one request - they’re split into pages - so we’ll need a loop to fetch all the pages.

Here’s the first incarnation of our script:

#!/usr/bin/env python3

# ⬆ Ignore this for now - it's important in step 6.



# (⬇ surprise tools that will help us later)

import ftplib

import json

from pathlib import Path

import requests

import subprocess

from urllib.parse import urlencode







CLIENT_ID = "PASTE_YOUR_CLIENT_ID_HERE"

# ⬇ Be careful not to commit this! ⬇

CLIENT_SECRET = "PASTE_YOUR_CLIENT_SECRET_HERE"



print("Getting API token from client secret...")

API_KEY = requests.post(

    "https://accounts.spotify.com/api/token",

    data={

        "grant_type": "client_credentials",

        "client_id": CLIENT_ID,

        "client_secret": CLIENT_SECRET,

    },

).json()["access_token"]



# Fetch the playlist items - paste your playlist ID below.

PLAYLIST_ID = "5EIjIqnxsxQrlms9XSWhEs"

all_tracks = []

# List of data fields to get from the endpoint

api_fields = "items(track(id,artists(name),name, preview_url), added_at), next"

playlist_url = f"https://api.spotify.com/v1/playlists/{PLAYLIST_ID}/tracks?{urlencode({'fields':api_fields,'limit':100})}"



more = True

while playlist_url:

    print(f"Fetching {playlist_url}")

    data = requests.get(

        playlist_url, headers={"Authorization": f"Bearer {API_KEY}"}

    ).json()

    all_tracks.extend(data["items"])

    playlist_url = data["next"]



all_tracks.sort(key=lambda x: x["added_at"], reverse=True)

tracks_to_use = all_tracks[:5]

This selects the 5 most recently added entries to the playlist and puts them in tracks_to_use. Actually I’m going to prevent it from picking the same artist twice, to avoid the selection looking too stale:

# replace `tracks_to_use = all_tracks[:5]` with:

tracks_to_use = []

artists_used = []

while len(tracks_to_use) < 5:

    track = all_tracks.pop(0)

    artist = track["track"]["artists"][0]["name"]

    if artist not in artists_used:

        tracks_to_use.append(track)

        artists_used.append(track["track"]["artists"][0]["name"])

But you can do any kind of selection you like :)

Step 2: Use Odesli to map between platforms

In our quest to satisfy all our visitors, we would be remiss to only provide links back to Spotify. (Don’t you know some people use YouTube Music?!)

Fortunately for us, some metadata wizards have made services that can map from a song on one platform to those on many platforms, such as Odesli (formerly Songlink). You can paste a URL from any major platform and they’ll give you a lovely page linking to the same song on all the others. Even better for us, they (currently) have a free API that we can use in our script!

For the music platform links, you can get a few icons from my repo here. (For other platforms, you’ll have to find your own icons :))

At the top of your .py file:



# You can choose your own platforms from the list in songlink_data["linksByPlatform"]

PLATFORMS_TO_LINK_TO = [

    "spotify",

    "youtube",

    "itunes",

    "deezer",

    "soundcloud",

]



PLATFORM_TO_DISPLAY_NAME = {

    "spotify": "Spotify",

    "deezer": "Deezer",

    "itunes": "Apple Music",

    "youtube": "YouTube",

    "soundcloud": "SoundCloud",

}



# (Assumes all the icons live at 'assets/PLATFORM_NAME.svg'

PLATFORM_TO_ICON = {

    platform: Path(f"assets/{platform}.svg").read_text()

    for platform in PLATFORMS_TO_LINK_TO

}

...

Now at the bottom of your file:

...



# Fetch song.link data & artwork for each track

TRACKS_JSON_PATH = Path('tracks.json')

old_track_data = (

    json.loads(TRACKS_JSON_PATH.read_text()) if TRACKS_JSON_PATH.exists() else {}

)



any_track_changed = False

Path("img").mkdir(exist_ok=True)

Path("audio").mkdir(exist_ok=True)



new_track_data = {}

for track in tracks_to_use:

    track_id = track["track"]["id"]

    # Don't fetch song.link data & artwork if we already have it

    if track_id in old_track_data:

        print(f"Already got '{track['track']['name']}'")

        new_track_data[track_id] = old_track_data[track_id]

    else:

        any_track_changed = True

        songlink_url = f"https://api.song.link/v1-alpha.1/links?url=spotify%3Atrack%3A{track_id}&userCountry=GB"

        print(f"Fecthing {songlink_url}...")

        songlink_data = requests.get(songlink_url).json()



        entity_data = songlink_data["entitiesByUniqueId"][

            [

                key

                for key in songlink_data["entitiesByUniqueId"].keys()

                if key.startswith("SPOTIFY_SONG::")

            ][0]

        ]



        link_data = [

            {

                "platform": platform,

                "name": PLATFORM_TO_DISPLAY_NAME[platform],

                "url": songlink_data["linksByPlatform"][platform]["url"],

            }

            for platform in PLATFORMS_TO_LINK_TO

            if platform in songlink_data["linksByPlatform"]

        ]



        new_track_data[track_id] = {

            "title": entity_data["title"],

            "artist": entity_data["artistName"],

            "links": link_data,

            "songlink_url": songlink_data["pageUrl"],

        }



TRACKS_JSON_PATH.write_text(json.dumps(new_track_data))

(This code sneakily saves the data from the previous run, so if your playlist hasn’t changed since then it won’t make any further web requests :) )

Step 3: Store & process the album art & song preview

The Spotify API handily offers both a link to the song’s album art, and a low-bitrate 30-second preview of the track, which we can embed in the page! We’ll need to put them on our server first, which we can do by downloading the files and uploading via FTP later.

These need to be downloaded once for each track, so we’ll need to add to the for track in tracks_to_use loop:

...

for track in tracks_to_use:

    ...

    else:

        ...

        thumbnail_url = entity_data["thumbnailUrl"]

        image_path = Path(f"img/{thumbnail_url.split('/')[-1]}.jpg")

        print(f"Fetching {thumbnail_url}...")

        image_path.write_bytes(requests.get(thumbnail_url).content)



        preview_url = track["track"]["preview_url"]

        # Sometimes the track doesn't have a preview :( so we'll just have to

        # skip it.

        if preview_url is not None:

            print(f"Fetching {preview_url}...")

            preview_path = Path(

                f"audio/{preview_url.split('/')[-1].split('?')[0]}.mp3"

            )

            preview_path.write_bytes(requests.get(preview_url).content)



        ...



        new_track_data[track_id] = {

            ...

            "image": str(image_path),

            "preview_mp3": str(preview_path) if preview_url else None,

        }

Next, you might want to downscale the image we downloaded from Spotify’s servers (640x640px) to whatever resolution you’ll display it at (150x150px for me). This isn’t that big of a difference, but other services might send you images many thousands of pixels wide/high, so it’s good practice to do this step!

I’ll use ImageMagick to process images, which unfortunately you’ll need to install separately to Python/PIP using that link. Once it’s installed and available on your PATH, though, you can transform images quickly from the command line, or from a Python subprocess:

for track in tracks_to_use:

    ...

    else:

        ...

        edited_image_path = image_path.with_suffix(".mono.png")

        print(f"Processing {image_path} with imagemagick...")

        subprocess.run(

            [

                "magick",

                "convert",

                image_path,

                "-resize",

                "200x200",

                edited_image_path,

            ],

        )

        ...

        new_track_data[track_id] = {

            ...

            "image_edited": str(edited_image_path),

        }

If you’re feeling fancy, you could apply more effects:

        subprocess.run(

            [

                "magick",

                "convert",

                image_path,

                "-colors",

                "2",

                "-resize",

                "200x200",

                "-ordered-dither",

                "o8x8,2",

                edited_image_path,

            ],

        )
Example dithered album art - I just think they’re neat!

The last step I would stongly recommend is using a program like ffmpeg to turn down the volume of the MP3s because my god are they loud1. 25% volume (-6.0dB) should do the trick.

        ...



        processed_preview_path = preview_path.with_suffix(".proc.mp3")

        print(f"Processing {preview_url}...")

        subprocess.run(

            ["ffmpeg", "-y","-i", preview_path, "-af", "volume=0.25", processed_preview_path]

        )



        ...

        new_track_data[track_id] = {

            ...

            "preview_mp3": str(processed_preview_path) if preview_url else None,

        }

Step 4: Create the widget file from a HTML template (& CSS!)

Now we have all the content (track data, album art, and links), we can put them together into a small HTML widget. We could construct some valid HTML in Python just with some string manipulation, but it gets a bit tricky when your data is complex. Instead, it’s a good opportunity to try out a ‘templating engine’ called Jinja: it takes in a template string (basically an f-string with more features), an ‘input data’ object, and outputs a new string containing the expanded intput data. You could use it to make all sorts of documents, but we’ll use it to make HTML :) (You may need to run pip install Jinja2 first.)

Firstly, let’s write the HTML template code to a file called widget.tmpl.html, next to your python script:

<!DOCTYPE html>

<html>

  <head>

    <title>Wow it's a Spotify Widget!</title>

  </head>

  <body>

    <div class="carousel">

        {% for track in tracks %}

        <div class="track">

            <img src="{{ track.image_edited }}" />

            <div class="trackinfo"><b>{{ track.title }}</b><br />

                by <i>{{ track.artist }}</i><br />

                <!-- Don't make an <audio> tag if the preview .mp3 file doesn't exist.-->

                {% if track.preview_mp3 %}

                <audio controls src="{{ track.preview_mp3 }}" preload="none" controlslist="play nodownload noplaybackrate" onplay="pauseOthers(this);"></audio>

                {% endif%}

                <br />

                {% for link in track.links %}<a href="{{ link.url }}" target="_blank">{{ icons[link.platform] }}</a>{% endfor %}

            </div>

        </div>

        {% endfor %}

      </ul>

    </div>

  </body>

  <script>

    // Adapted from https://stackoverflow.com/a/54839316 - thank you Sayyed Dawood!

    const pauseOthers = ele => [...document.getElementsByTagName('audio')].filter(audio=> audio != ele).forEach(audio =>audio.pause());

  </script>

</html>

There’s two for loops in this template: one for each track object we’ve just made, and one for each link. Any HTML code inside those loops will be written once for each entry in the list.

We’ll also need to add a bit of CSS, using a <style> tag in the same file (inside the <html> tag):

<style>

:root{

    --col-background: #fafefa;

}

body {

    font-family: Georgia, 'Times New Roman', Times, serif;

    background-color: var(--col-background);

    color: #666666;

}

.carousel {

    height: 160px;

    /*max-width: 1000px;*/

    overflow: hidden;

    margin:auto;

    white-space: nowrap;

    overflow-x: scroll;

    /* Adapted from https://stackoverflow.com/a/44794221 - thank you Hash! */

background:

linear-gradient(to right, var(--col-background) 30%, rgba(255, 255, 255, 0)), linear-gradient(to right, rgba(255, 255, 255, 0), var(--col-background) 70%) 100% 0,

radial-gradient(farthest-side at 0 50%, rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0)), radial-gradient(farthest-side at 100% 50%, rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0)) 100% 0;

background-repeat: no-repeat;

background-size: 40px 100%, 40px 100%, 15px 100%, 15px 100%;

background-attachment: local, local, scroll, scroll;



}

b {

    font-size: 2em;

    font-style: italic;

    font-weight: 100;

    color: #999;

}

.track{

    display: inline-flex;

    flex-direction: row;

    align-items: center;

    margin-bottom: 10px;

    margin-right:10px;

    min-width:400px;

}

.track img{

    height:150px;

}

.track img, .trackinfo, .trackinfo a{

    animation: fade_in 1s cubic-bezier(0.16, 1, 0.3, 1);

    animation-fill-mode: both;

}

.trackinfo{

    margin-left: 20px;

    white-space: normal;

}

.trackinfo a{

    margin-right: 5px;

}

</style>

Now let’s edit the Python script to generate a HTML file based on the template:

import jinja2



print("Generating HTML...")

environment = jinja2.Environment(loader=jinja2.FileSystemLoader("."))

results_template = environment.get_template("widget.tmpl.html")

Path("index.html").write_text(

    results_template.render(

        {

            "tracks": list(new_track_data.values()),

            "icons": PLATFORM_TO_ICON,

        }

    )

)

If you open the new ‘index.html’ file in your browser, you should have something like this:

Nice!

Note on JavaScript: this file does contain a tiny bit of JS to improve the listening experience, but would work perfectly fine without it. You’re welcome to add more JS if it helps the layout/interactivity, but try to avoid external code dependencies if possible - it can make your website unsable on patchy internet connections.

Step 5: Publish to your server via FTP

Like all things worth doing, there’s a Python built-in library for that ;)

# top of file

import ftplib

FTP_DOMAIN = "your.ftp_server.com"

FTP_USERNAME = "your_username"

FTP_PASSWORD = "your_password"

...



# bottom of file

files_to_upload = [

    "index.html",

    *(track["image_edited"] for track in new_track_data.values()),

    *(track["preview_mp3"] for track in new_track_data.values()),

    *Path("assets").glob("*"),

]



def _try_mkdir(ftp, dir):

    try:

        ftp.mkd(dir)

    except ftplib.error_perm as e:

        pass



print("Sending to FTP server...")

with ftplib.FTP(FTP_ADDRESS, FTP_USER, FTP_PASS) as ftp:

    ftp.cwd(FTP_SUBDIR)

    _try_mkdir(ftp, "img")

    _try_mkdir(ftp, "audio")

    _try_mkdir(ftp, "assets")



    for file in files_to_upload:

        with open(file, "rb") as f:

            print(f"Uploading {file}...")

            ftp.storbinary(f"STOR {file}", f)



    ftp.quit()

Now run your script, and you should see

Note for GitHub Pages users: If you only have access to a GitHub Pages website, then you don’t have FTP access to a webserver… but fret not! Instead of using ftplib, you could use subprocess.run(['git', ...]) commands to commit & push these new files to GitHub instead. It should work fine (if a little spammy in the commuiit history), but be careful not to cause any merge conflicts!

Step 6: Run this script regularly

The proper way to deploy this would be to have a 24/7 server that runs the script periodically. Since I’m a cheapskate, I’ll just run it on my laptop, and hope it’s switched on often enough. On Mac/Linux, you can use cron: first run crontab -e, then type something like

0 */6 * * *  cd /path/to/your/script/directory && fav_music_gen.py

(That’ll run the script every 6 hours, which is often enough for me - if you want something different, see crontab.guru for help)

(Also don’t forget to chmod +x fav_music_gen.py so crontab can execute it!)

On Windows, you might be able to get crontab working under WSL, otherwise I’d just set it up like a normal Task Scheduler task.

That’s it!

I hope your new Spotify widget brings you & your visitors much joy! 😊 In case you find it useful, the full script I use is available on my GitHub. It contains a bit of extra styling (animations!) & config.

A little caveat. Sharing songs from your Spotify isn’t really reminiscent of the old ‘small web’; Spotify tends to favour established label artists over indie artists or stuff your friends make. Platforms like Bandcamp or Soundcloud are more likely to contain the bleeding edge of new music, so if that’s your thing I’d recommend trying to make your widget for those platforms instead. (Odesli still works for them.) Maybe I’ll migrate my widget to those platforms in the future, but I need to get off Spotify first!


Return to index.