Dx Music League Part 5 : Add Youtube Music Provider


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.
Nothing in the UI or domain knows what Spotify is. Every screen that touches music goes through one interface:
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.
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:
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.
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:
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.
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.
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:
GET /youtube-music/search?q=&limit=®ion=
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.
All three providers are registered and live:
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:
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.
Everything below is bounded, known work — the spike made sure there are no research questions left, only engineering:
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).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.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).{ 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.
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.