Jan 18, 2025

Speedrunning weekend project with Claude AI

What does it look like to bring on board Claude AI to develop a weekend project in a lesser-known language?

Yevhenii Kurtov
Yevhenii Kurtov

Speedrunning weekend project with Claude AI

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.

Initial exploration

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.

💡
Lesson learned: think before typing to narrow down the answer space. Or don't and refine it later on.

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!

Implementing Spotify client

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:

  • accepts a URL and checks that it starts with https://open.spotify.com/track/
  • sends an HTTP request to get track details.
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.

Zed AI assistant

That completely changed my perspective on where are next frontiers in product development. In front of my eyes iteration cycle shrunk like never before.

Grunt work

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.

Reaching the limit

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.

The worst code Claude generated was when I just linked Spotify Get Track API documentation page without including code that I refactored.Maybe that was a fluke.

Further investigation on Saturday

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.

💡
Lesson learned: I'm still vulnerable to the confirmation bias and the a-ha moment reward just got one step closer.

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.

💡
Lesson learned: it won't tell you about unknown unknowns and there will always be such.

Conclusions

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!

Further reading

Bonus

The code is available at https://github.com/hyperinstant/mstr/tree/main/lib/sspotify .

Please notice it’s not quite production-ready:

  • it doesn’t gracefully handle the case when it can’t refresh the token and just blows up
  • calling 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

Categories

Development Elixir