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:
- Register an App on their Developer Dashboard. The name/description aren’t important, but check the ‘Web API’ box. This will giv e you a ‘client ID’ and a ‘client secret’ string (this is like a password, so keep it safe!)
- Use the client ID & secret to request an API access token. By default the access token only works for one hour, so we’ll generate one each time the script runs
- When making requests to the API endpoints, send the access token in the HTTP ‘Authorization’ header.
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
= "PASTE_YOUR_CLIENT_ID_HERE"
CLIENT_ID # ⬇ Be careful not to commit this! ⬇
= "PASTE_YOUR_CLIENT_SECRET_HERE"
CLIENT_SECRET
print("Getting API token from client secret...")
= requests.post(
API_KEY "https://accounts.spotify.com/api/token",
={
data"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
},"access_token"]
).json()[
# Fetch the playlist items - paste your playlist ID below.
= "5EIjIqnxsxQrlms9XSWhEs"
PLAYLIST_ID = []
all_tracks # List of data fields to get from the endpoint
= "items(track(id,artists(name),name, preview_url), added_at), next"
api_fields = f"https://api.spotify.com/v1/playlists/{PLAYLIST_ID}/tracks?{urlencode({'fields':api_fields,'limit':100})}"
playlist_url
= True
more while playlist_url:
print(f"Fetching {playlist_url}")
= requests.get(
data ={"Authorization": f"Bearer {API_KEY}"}
playlist_url, headers
).json()"items"])
all_tracks.extend(data[= data["next"]
playlist_url
=lambda x: x["added_at"], reverse=True)
all_tracks.sort(key= all_tracks[:5] tracks_to_use
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:
= all_tracks.pop(0)
track = track["track"]["artists"][0]["name"]
artist if artist not in artists_used:
tracks_to_use.append(track)"track"]["artists"][0]["name"]) artists_used.append(track[
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 f"assets/{platform}.svg").read_text()
platform: Path(for platform in PLATFORMS_TO_LINK_TO
} ...
Now at the bottom of your file:
...
# Fetch song.link data & artwork for each track
= Path('tracks.json')
TRACKS_JSON_PATH = (
old_track_data if TRACKS_JSON_PATH.exists() else {}
json.loads(TRACKS_JSON_PATH.read_text())
)
= False
any_track_changed "img").mkdir(exist_ok=True)
Path("audio").mkdir(exist_ok=True)
Path(
= {}
new_track_data for track in tracks_to_use:
= track["track"]["id"]
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']}'")
= old_track_data[track_id]
new_track_data[track_id] else:
= True
any_track_changed = f"https://api.song.link/v1-alpha.1/links?url=spotify%3Atrack%3A{track_id}&userCountry=GB"
songlink_url print(f"Fecthing {songlink_url}...")
= requests.get(songlink_url).json()
songlink_data
= songlink_data["entitiesByUniqueId"][
entity_data
[
keyfor 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:
...= entity_data["thumbnailUrl"]
thumbnail_url = Path(f"img/{thumbnail_url.split('/')[-1]}.jpg")
image_path print(f"Fetching {thumbnail_url}...")
image_path.write_bytes(requests.get(thumbnail_url).content)
= track["track"]["preview_url"]
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}...")
= Path(
preview_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:
...= image_path.with_suffix(".mono.png")
edited_image_path 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,
], )
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.
...
= preview_path.with_suffix(".proc.mp3")
processed_preview_path 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 />
<i>{{ track.artist }}</i><br />
by <!-- 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 />
<a href="{{ link.url }}" target="_blank">{{ icons[link.platform] }}</a>{% endfor %}
{% for link in track.links %}</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...")
= jinja2.Environment(loader=jinja2.FileSystemLoader("."))
environment = environment.get_template("widget.tmpl.html")
results_template "index.html").write_text(
Path(
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
= "your.ftp_server.com"
FTP_DOMAIN = "your_username"
FTP_USERNAME = "your_password"
FTP_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:
dir)
ftp.mkd(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)"img")
_try_mkdir(ftp, "audio")
_try_mkdir(ftp, "assets")
_try_mkdir(ftp,
for file in files_to_upload:
with open(file, "rb") as f:
print(f"Uploading {file}...")
f"STOR {file}", f)
ftp.storbinary(
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.