JP
HomeBlogProjectsResumeAbout

© 2026 JP. All rights reserved.

Back to blog

Dx Music League Part 5 : Add Youtube Music Provider

AdminJuly 12, 20267 min read
Dx Music League Part 5 : Add Youtube Music Provider

On this page

  • The seam
  • Know your provider: Spotify's shape vs. YouTube's shape
  • The spike: prove the scary half first
  • One constraint shapes the whole design
  • Why it's hidden, and where the gate lives
  • The honest checklist to finish
  • The moral

On this page

  • The seam
  • Know your provider: Spotify's shape vs. YouTube's shape
  • The spike: prove the scary half first
  • One constraint shapes the whole design
  • Why it's hidden, and where the gate lives
  • The honest checklist to finish
  • The moral
PreviousDx Music League Part 4 : Beta Week

Building DX Music League, Part 5: Adding a New Music Provider — YouTube Music

The finale of a 5-part series. Parts 1–4 took the app from nothing to a live beta. This part cashes in the architectural bet made on day one: that adding a music service is writing one class, not rewiring an app.


Not everyone in a friend group has Spotify. The whole point of self-hosting a music league is that you decide what it supports — so YouTube Music was always on the list. This post walks through the provider seam that makes it possible, the proof-of-concept that de-risked it, what's already built and working, and the honest checklist of what remains before it's a picker option next to Spotify.

The seam

Nothing in the UI or domain knows what Spotify is. Every screen that touches music goes through one interface:

ts
export interface MusicProvider {
  readonly info: MusicProviderInfo;
 
  /** Search the catalog for tracks matching a free-text query. */
  searchTracks(query: string, opts?: SearchOptions): Promise<Track[]>;
 
  /** Fetch one track by its provider-native id, or null if not found. */
  getTrack(providerTrackId: string): Promise<Track | null>;
 
  /**
   * Create a public playlist of the given tracks on the host/service account and
   * return its shareable URL. Used on round reveal. (Backend-side in production.)
   */
  createPlaylist(name: string, tracks: Track[], description?: string): Promise<Playlist>;
}

Three methods. That's the entire contract, because that's all a music league actually needs from a music service: find songs, look one up again, and publish the round's playlist.

A league stores a musicProvider id ("spotify" | "youtube-music" | "mock"); screens call getProvider(league.musicProvider) and render provider.info.name. Submissions store a normalized Track snapshot — { id: "youtube-music:dQw4w9WgXcQ", title, artists, album, artworkUrl, durationMs, externalUrl } — never a provider-shaped object. The database, the rules engine, the voting flow: none of them change when a provider is added.

Which is why the interesting part of this post isn't the app. It's YouTube Music itself.

Know your provider: Spotify's shape vs. YouTube's shape

Every provider integration is really two integrations wearing one interface — a read path (search, which every player uses) and a write path (playlist creation, which happens once per round on a host account). Spotify splits cleanly:

  • Read: Client Credentials flow — an app-level token, no user involved. Players never see a Spotify login.
  • Write: one host account's refresh token; the backend creates the round playlist on my account and shares the URL.

YouTube Music has no equivalent of Client Credentials — no official search API for YT Music at all. What it has is ytmusic-api, an unofficial npm library that wraps YouTube Music's internal API. That word unofficial is a real risk, so before writing any provider code, it got a proof-of-concept.

The spike: prove the scary half first

poc/ytmusic_search.mjs is a standalone Node script with one job — answer three questions:

1. Does search work without auth? Yes, and this was the headline finding:

js
const ytmusic = new YTMusic();
// No cookies passed → fully unauthenticated. This is the key thing to prove.
await ytmusic.initialize();
const songs = await ytmusic.searchSongs("Radiohead");

No API key, no OAuth, no cookies. It's YouTube Music's parallel to Client Credentials — simpler, even, since there's no client id at all. The script saved its receipts: real result tables for three test queries, checked into docs/, with correct titles, artists, albums, and playable video ids.

2. Does the data map onto our Track? Cleanly, with two quirks worth writing down: durations come in seconds (we store milliseconds), and the artist is a single object rather than an array. Also, no preview-clip URL exists — the preview button just isn't part of the YT Music experience.

js
function toTrack(song) {
  const art = [...(song.thumbnails ?? [])].sort((a, b) => b.width - a.width)[0];
  return {
    id: `youtube-music:${song.videoId}`,
    provider: "youtube-music",
    providerTrackId: song.videoId,
    title: song.name,
    artists: song.artist?.name ? [song.artist.name] : [],
    album: song.album?.name,
    artworkUrl: art?.url,
    durationMs: song.duration != null ? song.duration * 1000 : undefined,
    externalUrl: `https://music.youtube.com/watch?v=${song.videoId}`,
  };
}

3. Can it create playlists? No. ytmusic-api is read-only — there is no createPlaylist. The write path needs a completely different tool: the official YouTube Data API v3 (playlists.insert + playlistItems.insert), with OAuth on a single host Google account. Two libraries, mirroring Spotify's read/write split almost exactly.

The spike's verdict, verbatim from the PoC doc: "Half the integration is proven and cheap; the other half (playlist creation) is NOT covered by this library and needs a separate official-API integration." That one sentence is why YouTube Music isn't in the picker yet — and why it was safe to build everything else.

One constraint shapes the whole design

ytmusic-api is Node-only — it uses axios with a cookie jar, and YouTube Music's internal API has no CORS headers. It physically cannot run in a browser. So the provider class in the frontend never touches YouTube; it talks to our own proxy endpoints:

plaintext
GET  /youtube-music/search?q=&limit=&region=
GET  /youtube-music/tracks/:videoId
POST /youtube-music/playlists          ← the contract; backend TBD

(One mapping detail: the interface's market option becomes a region hint, since ytmusic has no market concept.)

That's the same pattern Spotify uses. But there's a dev-experience twist that's my favorite trick in the codebase: vite-plugin-ytmusic.ts runs the scraper inside the Vite dev server, serving those exact endpoints same-origin during npm run dev. The plugin is apply: "serve" — it never ships in a production build — and lazily initializes one shared unauthenticated client. The result: YouTube Music search works end-to-end on a laptop with zero configuration, against the identical endpoint contract a production Lambda will serve. When the real proxy lands, the provider class won't change at all.

The provider is also built to fail honestly rather than mysteriously: in a production build with no backend proxy configured, it throws "YouTube Music provider not configured: set VITE_API_BASE to the backend proxy URL" instead of letting searches silently 404.

Why it's hidden, and where the gate lives

All three providers are registered and live:

ts
const REGISTRY: Record<MusicProviderId, MusicProvider> = {
  mock: new MockMusicProvider(),
  spotify: new SpotifyMusicProvider(),
  "youtube-music": new YouTubeMusicProvider(),
};

Existing YouTube Music leagues work through this registry today. The gate is one level up — the create-league picker calls listProviderOptions(), which is deliberately hardcoded:

ts
export function listProviderOptions(): MusicProviderInfo[] {
  return [{ id: "spotify", name: "Spotify", available: true }];
}

This is the beta philosophy from part 3 applied again: don't offer what you can't stand behind. A league picks its provider forever at creation — offering YouTube Music before the playlist path exists would strand real leagues on a half-provider. Hiding it costs one line; re-enabling it costs the same line.

The honest checklist to finish

Everything below is bounded, known work — the spike made sure there are no research questions left, only engineering:

  1. Backend read proxy. Mirror the Spotify handler with ytmusic-api: register GET /youtube-music/search and GET /youtube-music/tracks/:id in the route table, bundle the library into the Lambda. No secrets needed — the read path is unauthenticated. Pin the library version (it's a scraper; treat upstream breakage as a known, monitorable risk).
  2. The write path. YouTube Data API v3 with a host Google account: OAuth refresh token in Secrets Manager next to the Spotify one, playlists.insert + playlistItems.insert inside the reveal flow, exactly parallel to createPlaylistForRound for Spotify. One caveat the PoC flagged: the Data API has a daily quota and playlist writes are expensive units — trivial at league volume (one playlist per round), but worth an alarm.
  3. Two frontend reconciliations found while writing this post — the payoff of documenting a codebase is that the codebase gets embarrassed into consistency: the YouTube provider reads VITE_API_BASE while Spotify and the data client read VITE_API_URL (unify), and its fetch helper doesn't attach the Cognito bearer token that a production route behind the authorizer will require (attach it).
  4. Flip the picker. Add { id: "youtube-music", name: "YouTube Music", available: true } to listProviderOptions().

Notice what's not on the list: no domain changes, no schema changes, no changes to submission, voting, reveal, standings, or any page. The Track snapshot in DynamoDB already stores provider: "youtube-music" rows happily — the backend has accepted the value since day one.

The moral

On day one of this project, before the backend existed, I built the YouTube Music read path first — specifically because it was the awkward provider: unofficial library, Node-only, no CORS, no playlist support. The reasoning was that if the MusicProvider seam could absorb the hard case, Spotify would be easy. That held up: Spotify slotted in during build-out week without touching the interface.

The general lesson I'd offer anyone designing a pluggable system: prove the seam against your ugliest planned implementation, not your cleanest. A port that's only ever been implemented by the friendly case isn't an abstraction yet — it's a wrapper with ambitions.

And the specific lesson for this feature: the gap between "works in a demo" and "offered to real leagues" is exactly one playlist API, two env-var fixes, and a one-line flag. That's what a good seam buys you — the last mile is short, and you know precisely how long it is.


Thanks for reading the series. The app is live, the beta league is arguing about anti-votes, and the code is small enough to read in an afternoon — which was always the real feature.