Skip to content
← Posts

March 29, 2026/1655 words/8 min read

Borrowing Taste From Discogs

Hacking together a half-decent music recommendation system.

I used to have a tool that fixed Apple Music's recommendations by shamelessly piggybacking on Spotify's /recommendations endpoint. I would describe a vibe in the terminal, an LLM would turn it into API parameters, then Spotify would return tracks to an Apple Shortcut that built the playlist. When it worked, it worked great. Then Spotify deprecated the endpoint in November 2024 with no warning or replacement. My tool died overnight, so I grieved its loss, shelved it, and went back to suffering through Apple Music's algorithm. To this day, it recommends me playlists that are mostly bands I already listen to mixed with bands I actively dislike.

Recommendation engines are only as good as the data underneath them. Spotify works because millions of listeners generate taste signals every time they play something. Discogs has another pool of taste data, with nearly nine million users cataloguing their record collections. Some of these people own three thousand records and track every detail about each release.

I know because, to a lesser degree, I am one of them! I ruthlessly tag every vinyl release I own with genres and styles, and if I could, I would track even more. People there keep careful track of what they want and what they have. They organise music with a specificity that no streaming algorithm comes close to. Over two decades, they have built a massive hand-curated map of how music relates to itself. I decided to borrow it.

The Discogs API lets you filter releases by style, genre, year, country, label, and format. It also returns community data, including how many users want or have a given release, which is a surprisingly useful signal. A record that twelve thousand people want and eight thousand people own is probably good, but it's also a mainstream hit. A record in the same style that forty people have catalogued might be an obscure gem or completely forgettable. The ratio still tells you something. Detailed metadata from obsessive humans is enough for my purposes if I query it right.

A paper by Terence Zeng published earlier this month tested mood-assisted music recommendations. Participants rated recommendations that used their desired mood more highly than those from a baseline system, with a statistically significant difference. The paper maps mood on an energy-valence spectrum, which is more formal than what I'm doing. I type "doom metal but pretty" and let an LLM figure out what I mean.

The pipeline has three parts, plus a feedback loop that changes the results over time. I describe a vibe to Claude via the Anthropic API, and Claude translates it into a structured Discogs search query. The Discogs API returns releases. A different call to Claude turns those releases into a playlist. An Apple Shortcut puts the tracks into Apple Music, and after I listen, I rate the playlist. That rating affects which Discogs users I trust for future queries. The whole thing runs on my home server, same setup as everything else I run.

Claude understands what I mean better than Spotify did. In the old pipeline, I had to translate "doom metal but pretty" into seed_genres: doom-metal, target_energy: 0.3, target_valence: 0.2, then hope Spotify interpreted those numbers the way I intended. Now Claude maps the vibe to Discogs styles, genres, year ranges, and labels.

typescript
const query = await anthropic.messages.create({
  model: "claude-haiku-4-5",
  max_tokens: 2048,
  system: `You translate music vibe descriptions into Discogs API search parameters.
Available parameters: style, genre, year (or year range), country, label, format.
Discogs styles are specific (e.g. "Doom Metal", "Shoegaze", "Gothic Metal", "Dream Pop").
Return 2-3 separate queries that approach the vibe from different angles.
Return JSON only. No explanation.
 
Format: { "queries": [{ "style": "...", "genre": "...", "year": "..." }] }`,
  messages: [{ role: "user", content: vibePrompt }],
});

For "doom metal but pretty," Claude returns queries like { style: "Doom Metal", genre: "Rock" }, { style: "Atmospheric Black Metal", genre: "Rock" }, and { style: "Shoegaze", genre: "Rock", year: "2010-2025" }. That's three different angles on the same mood. Spotify's genre seeds were too coarse and opaque for this. Discogs styles are granular because they were defined by people who care about the difference between Doom Metal and Stoner Rock, and Claude knows that difference too.

The second part hits the Discogs API with each query, collects releases, and tracks who contributed each one to the database.

typescript
const searchDiscogs = async (params: Record<string, string>) => {
  const query = new URLSearchParams({
    ...params,
    type: "release",
    per_page: "25",
  });
 
  const res = await fetch(`https://api.discogs.com/database/search?${query}`, {
    headers: {
      Authorization: `Discogs token=${DISCOGS_TOKEN}`,
      "User-Agent": "Robert's VibePipeline/1.0",
    },
  });
 
  const data = await res.json();
  return data.results.map((r: any) => ({
    id: r.id,
    title: r.title,
    year: r.year,
    style: r.style,
    genre: r.genre,
    community: r.community,
    user_data: r.user_data,
  }));
};
 
const allQueries = JSON.parse(queryResponse).queries;
const results = await Promise.all(allQueries.map(searchDiscogs));
const pool = results.flat();

Two or three queries with twenty-five results each give me a pool of fifty to seventy-five releases. There are some duplicates and obvious picks I already know, but also a lot of records I've never heard of, tagged by someone who clearly listens to the same kind of music I do. I'm asking a community of collectors what belongs next to the records I already love instead of delegating that judgement to an opaque algorithm that doesn't understand the genres it's recommending.

The third part is where Claude takes that pool of releases, looks at what's in them, and composes an actual playlist that matches the original vibe. Before composing, it checks my local trust database to see if any releases in the pool came from users I've implicitly rated highly in the past, and weights those more heavily.

typescript
const playlist = await anthropic.messages.create({
  model: "claude-haiku-4-5",
  max_tokens: 2048,
  system: `You are composing a playlist. The user described a vibe and a search returned
a pool of releases from Discogs. Pick 20-25 specific tracks from these releases
that best match the original vibe. Prioritise discovery — lean toward artists the
user is unlikely to already know. Releases marked as "trusted" come from users
whose taste has been validated — weight these more heavily. You may include a few
tracks from outside the pool if they fit perfectly. Return JSON only. No explanation.
 
Format: { "tracks": [{ "artist": "...", "title": "..." }] }`,
  messages: [
    {
      role: "user",
      content: `Vibe: "${vibePrompt}"\n\nReleases:\n${formatReleases(pool, trustScores)}`,
    },
  ],
});

The old Spotify version didn't have this step. Its endpoint returned tracks directly, which was convenient but gave me no say in the curation. Claude can look at a Woods of Ypres album and pick the atmospheric tracks instead of the heavier ones because it understands what "pretty" means in the context of doom metal. It can also throw in a few tracks from outside the pool if it knows something that fits, which keeps the playlists from feeling mechanical.

The last step is getting the playlist onto my phone, since there's no native integration with Apple Music. The server emails me a link with the encoded playlist JSON embedded in a Shortcuts URL scheme. I tap it on my phone, it opens the Shortcut, and the Shortcut iterates the track list, searches Apple Music for each one, and adds the matches to a new playlist.

url
shortcuts://run-shortcut?name=ImportPlaylist&input=text&text={encoded_json}

The Shortcut is about fifteen actions. For each track, it runs "Search Apple Music," takes the first result, and appends it to a playlist. It's not perfect. Some tracks don't match because of naming differences between catalogues or regional gaps, and occasionally I get a live version instead of the studio cut. Once it's done, the Shortcut tells the server how many tracks it found and added. I store that number with the playlist metadata. I haven't used it yet, but it should help me tune the pipeline. If a playlist only matches twelve out of twenty-five tracks, the vibe-to-query translation probably drifted too far into obscure territory. If it matches twenty-three, the queries were solid.

After I've listened to a playlist, I rate it on a simple 1-5 scale. That score goes into a SQLite database on the home server alongside the Discogs users who contributed the releases in the playlist.

sql
CREATE TABLE user_trust (
    discogs_username TEXT PRIMARY KEY,
    trust_score REAL DEFAULT 0,
    playlists_contributed INTEGER DEFAULT 0,
    last_updated TEXT
);
 
CREATE TABLE playlist_ratings (
    id INTEGER PRIMARY KEY,
    vibe TEXT,
    rating INTEGER CHECK (rating BETWEEN 1 AND 5),
    created_at TEXT,
    releases JSON
);
typescript
const ratePlaylist = async (playlistId: number, rating: number) => {
  db.run(`UPDATE playlist_ratings SET rating = ? WHERE id = ?`, [
    rating,
    playlistId,
  ]);
 
  const releases = db
    .prepare(`SELECT releases FROM playlist_ratings WHERE id = ?`)
    .get(playlistId);
 
  for (const release of JSON.parse(releases.releases)) {
    const username = release.submitter;
    if (!username) continue;
 
    db.run(
      `INSERT INTO user_trust (discogs_username, trust_score, playlists_contributed, last_updated)
       VALUES (?, ?, 1, datetime('now'))
       ON CONFLICT(discogs_username) DO UPDATE SET
         trust_score = (trust_score * playlists_contributed + ?) / (playlists_contributed + 1),
         playlists_contributed = playlists_contributed + 1,
         last_updated = datetime('now')`,
      [username, rating, rating],
    );
  }
};

The trust score is a running weighted average that Claude vibecoded for me. If a Discogs user's submissions keep ending up in playlists I rate highly, their score climbs. If their stuff consistently lands in playlists I rate poorly, it drops. Over time, I get a map of which collectors have taste that aligns with mine without them ever knowing about it. It's parasitic, but I'm not too worried about the ethics here. I'm using data they've made public on the platform, and I'm not sharing it with anyone else.

Once someone's trust score crosses 4.0 out of 5, the pipeline starts pulling from their Discogs collection outside the original genre query. If a user has consistently great taste in, say, doom metal, there's a decent chance their post-punk picks or ambient collection is equally good. For high-trust users, I fetch their collection via the Discogs API and let Claude browse it for tracks outside the original vibe that might still resonate.

typescript
const getHighTrustCollections = async () => {
  const trusted = db
    .prepare(
      `SELECT discogs_username FROM user_trust
       WHERE trust_score >= 4.0 AND playlists_contributed >= 3`,
    )
    .all();
 
  const collections = [];
  for (const user of trusted) {
    const res = await fetch(
      `https://api.discogs.com/users/${user.discogs_username}/collection/folders/0/releases?per_page=50`,
      {
        headers: {
          Authorization: `Discogs token=${DISCOGS_TOKEN}`,
          "User-Agent": "Robert's VibePipeline/1.0",
        },
      },
    );
    const data = await res.json();
    collections.push({
      username: user.discogs_username,
      releases: data.releases,
    });
  }
 
  return collections;
};

This is the feature I'm most excited about. It discovers music the way I do, by trusting someone's taste in one area and then seeing what else they're into. That's how I've found some of my favourite records in real life! A friend with impeccable taste in metal recommends a jazz album, so you try it and end up listening to something you'd never have found through an algorithm. I built a way to automate that instinct.

I've been testing this for the past couple of days, and the results have surprised me. "Doom metal but pretty" surfaced bands I'd never heard of that clearly live in the same neighbourhood as Katatonia and Alcest but never appear in any streaming recommendation. "If Rainbow and Portishead had a band together" returned records from labels I didn't know existed. The trust system is already starting to pay off too. A handful of Discogs users keep showing up in my highly rated playlists, and their collections outside the genres I've been querying are full of stuff I want to explore. One of them has an incredible ambient collection that I would never have stumbled into through a genre-based search.

The results are less polished than Spotify's, more raw, and occasionally weird or downright incorrect. That's the character of the data. Discogs users tag things because they care about accuracy, and that care shows up in the results.

The whole thing runs on my home server alongside everything else, accessible over Tailscale. The Discogs API is free with a personal token and allows sixty requests per minute for authenticated users. The Anthropic API costs a few cents per playlist, the SQLite database is a single file that barely grows, and the Apple Shortcut is the same fifteen actions from the Spotify version. Total infrastructure cost is basically zero on top of what I'm already running.

For now, the tool learns which humans have good taste and gets better at finding music the more I use it. It doesn't use my listening history or know what I played yesterday, though I collect that data too.

I'm still annoyed at Spotify for killing the endpoint that started all of this, but the replacement already gives me stranger and more interesting recommendations.

The code is on GitHub but isn't public yet. It relies way too much on my personal setup, but I'll open it up once I can get a more general version to work well. For now it's mine and it's messy and I'm fine with that.