Last Friday night I sat out to develop a web app where people can match 3 favourite songs.
Below are my post-factum recollections from Friday evening and Saturday afternoon. I tried to stay focused on the product and be sidetracked by the shiny technical things. Apart from that I tried to keep the code tidy, especially because there were no tests.
I started by prompting Claude for the match-making data model and he suggested a model with User
, Match
and Message
.
I ignored that response because before matching and chatting users have to specify their favourite songs and I need to pull their details off Spotify.
At the same time, I recognised that I don’t know how to cross-match tracks between music services if I want to add another streaming platform later.
Claude suggested using ISRC: “For universal music identification, you can use ISRC (International Standard Recording Code) as your base identifier.”
That was something that I didn’t know about!
To be fair that’s what Gemini suggests at the top of Google search results. I spot-checked a few first top links and that wasn’t mentioned anywhere else. At this moment LLMs already saved me hours of lurking around the web!
I needed a simple functionality of pulling track details by sharing URLs. Quick lurking through Spotify clients on hex.pm and their absence of documentation suggested that it would be easier to write my own.
That’s where the fun got even more serious!
I asked Claude to write a function that:
https://open.spotify.com/track/
defmodule SpotifyTrack do
@spotify_url_prefix "https://open.spotify.com/track/"
@spotify_api_base "https://api.spotify.com/v1/tracks/"
def get_track_details(url, access_token) when is_binary(url) and is_binary(access_token) do
with {:ok, track_id} <- extract_track_id(url),
{:ok, response} <- make_api_request(track_id, access_token) do
{:ok, response}
end
end
defp extract_track_id(url) do
if String.starts_with?(url, @spotify_url_prefix) do
# Extract the track ID from the URL (everything between /track/ and ? or end of string)
track_id = url
|> String.replace(@spotify_url_prefix, "")
|> String.split("?")
|> List.first()
{:ok, track_id}
else
{:error, :invalid_url}
end
end
defp make_api_request(track_id, access_token) do
headers = [
{"Authorization", "Bearer #{access_token}"},
{"Content-Type", "application/json"}
]
request = Finch.build(:get, @spotify_api_base <> track_id, headers)
case Finch.request(request, MyFinch) do
{:ok, %Finch.Response{status: 200, body: body}} ->
{:ok, Jason.decode!(body)}
{:ok, %Finch.Response{status: 401}} ->
{:error, :unauthorized}
{:ok, %Finch.Response{status: 404}} ->
{:error, :track_not_found}
{:error, _reason} = error ->
error
end
end
end
No doubt this first iteration was naive so but it was a good enough starting point.
What was more interesting was that I discovered Zed’s functionality to fetch web pages and include files in context! By linking Spotify Authentication and Get Track documentation pages, and including the module in which I copy-pasted code, I got a working Spotify client without writing a single line of code in under forty minutes.
That completely changed my perspective on where are next frontiers in product development. In front of my eyes iteration cycle shrunk like never before.
I wanted to do as much validation before hitting the Spotify API to reduce my chances of being throttled, so I asked Claude to give me a function to further verify Spotify ID and he produced code that included data both from Spotify API docs and StackOverflow. Spotify API docs only mention that IDs are base-62 encoded but StackOverflow answers also mentioned that they are 22 characters long.
That didn’t save me much typing but I also learned that I can pattern-match on string length in the function header, which I was delighted about.
def valid_base62_id?(<<id::binary-size(22)>>) do
String.match?(id, ~r/^[0-9A-Za-z]+$/)
end
def valid_base62_id?(_), do: false
Notice how this neatly tucks nil check in the catch-all clause.
What was really cool is that it generated Artist
, Album
and Image
modules to neatly parse and represent Spotify response.
That spared me from boring, tedious and monotonic work and allowed me to stay on the exciting and fun part of the task at hand.
As exciting as it was Claude hit its limit when I asked him to write a function to retrieve track details in bulk via batch retrieval endpoint and if all passed URLs are valid and return invalid URLs otherwise.
def tracks_from(urls) when is_list(urls) do
track_ids_result = Enum.map(urls, &extract_track_id/1)
case Enum.find(track_ids_result, &match?({:error, _}, &1)) do
{:error, :invalid_track_url} ->
# Find the problematic URL
{bad_url, _} = Enum.zip(urls, track_ids_result)
|> Enum.find(fn {_, result} ->
result == {:error, :invalid_track_url}
end)
{:error, InvalidTrackUrlError.new(bad_url)}
nil ->
# All track IDs were successfully extracted
track_ids = Enum.map(track_ids_result, fn {:ok, id} -> id end)
case ApiClient.tracks(track_ids) do
{:ok, %{"tracks" => tracks}} ->
{:ok, Enum.map(tracks, &Track.from_json/1)}
error ->
error
end
end
end
That is typical AI slop. Horrible. But it was the first time when I had to write something that I had to think about for more than a few minutes. In about ten hours of development.
My mind was still fresh at that moment which was amazing!
Here is my implementation
def tracks_from(urls) when is_list(urls) do
validated_urls = validate_urls(urls)
if validated_urls.valid == urls do
ids = Enum.map(urls, &extract_track_id!/1)
with {:ok, tracks_map} <- ApiClient.tracks(ids) do
{:ok, Enum.map(tracks_map, &Track.from_json/1)}
end
else
{:error, InvalidTrackURLs.new(validated_urls.invalid)}
end
end
defp validate_urls(urls) do
Enum.reduce(urls, %{valid: [], invalid: []}, fn url, acc ->
if valid_track_url?(url) do
%{acc | valid: acc.valid ++ [url]}
else
%{acc | invalid: acc.invalid ++ [url]}
end
end)
end
NOTE: It can be further improved by combining individual url validation and subsequent list comparison in a one single step: if all_urls_are_valid?(urls) do
. I also don’t like nested conditionals if -> with
, but that’s good enough.
What it didn’t highlight and I didn’t think about asking is whether ISRC would be present at all times. Later on, it confirmed that older tracks and tracks from small labels would not necessarly have it.
I uploaded Ultrasonics plugins for Spotify and Deezer and asked what are alternatives when ISRC doesn’t work.
Claude suggested that the next best option is
Secondary matching by Cross-platform identifiers like MusicBrainz ID (MBID) UPC/EAN codes for albums ISWC (for compositions)
Which turned out to be a no-go as soon as I pasted response samples from Spotify, Amazon and Apple Music. They just don’t have that data.
As a developer and a Lead Developer, my takeaway is that we need to have dedicated time for knowledge acquisition because sorting and separating wheat from the chaff in LLM output is hard and we are vulnerable to confirmation bias. Study paths, dedicated learning time and deliberate practice are a good set of tools for teams that want to effectively ride the LLM wave.
Overall I had as much fun as I did when I just began my web journey with RoR 1.x. What an exciting times!
The code is available at https://github.com/hyperinstant/mstr/tree/main/lib/sspotify .
Please notice it’s not quite production-ready:
SSpotify.from_track
and SSpotify.from_tracks
at that time will lead to a runtime error because the ETS table holding the token won’t be available