Lossless Changelog
product

The playlists page shipped empty, and the fix turned 6,362 videos into 2,196 channels

A build log saying 6,362 videos, a page rendering four empty shells. Chasing that gap produced a working /playlists and a new /channels.

Michael StatonMusic Editoraugmented with Claude Code on Claude Opus 5

The playlists page shipped empty, and the fix turned 6,362 videos into 2,196 channels

Why Care?

The playlists page from the entry above went out broken. Not down — 200 OK, correct title, correct four tabs, and under every one of them a polite little message saying the playlist was not cached yet.

The build log for that same deploy said:

✓ Done. 4 fetched, 0 skipped, 0 failed; 6,362 videos total.

Both of those are true at once, which is the interesting part. The data was fetched. The page could not see it. Everything about the failure was silent, and the one symptom that surfaced — the counters — recovered on its own for a reason that had nothing to do with the actual bug.

The failure class generalises past this site: under output: 'server', any build artifact read off disk at request time is not there. If you have a gitignored JSON cache and a page that reads it, you have this bug and your build logs are telling you everything is fine.

What's New?

  • /playlists renders — four players, four scrollers, 6,362 videos, no facade.

  • /channels — 112 channels with ten or more videos kept, ranked by how many, each linking to its YouTube channel.

  • src/config/channel-favorites.json — a hand-editable overlay, seeded with all 112 entries so picking is flipping booleans rather than hunting UC... ids.

  • pnpm seed-channels — re-syncs that file additively; flags and notes survive, nothing is ever dropped.

  • The item-level channel is real — the fetcher recorded the playlist owner on every row. It now records the channel the video actually lives on, which turns one channel into 2,196.

  • R highlights in code fences, and the dev toolbar is off.

  • The mobile playlist scroller's Up and Down buttons work, which they had never done.

The Story

A page that was fine, except for the part that mattered

/playlists returned 200. The tabs were right, the blurbs were right, the counts were right. Under each tab:

This playlist isn't cached yet. Run pnpm fetch-playlists with YOUTUBE_API_KEY set.

The obvious suspect was the API key, and the diagnostics route said otherwise:

JSON
{ "hasKey": true, "total": 0,
  "playlists": [ { "label": "Lossless Toolkit", "buildCount": 0, "source": "build-cache" } ] }

Key present at runtime, build count zero. So the fetch worked and the read did not.

The loader resolved its cache like this:

TS
const CACHE_PATH = fileURLToPath(
  new URL('../data/youtube-playlist-cache.json', import.meta.url)
);
if (!existsSync(CACHE_PATH)) return {};

Which is correct from source and wrong from a serverless function, in two ways at once. The JSON is gitignored, so nothing traces it into the bundle — checking the built output, only the compiled chunk was there, never the data. And import.meta.url now points at dist/server/chunks/, so ../data/ would miss even if the file had been copied.

Both failures are silent. existsSync returns false, the loader returns {}, every lookup returns null, and the page renders its "not cached yet" state on a deploy whose fetcher succeeded perfectly.

Reading the cache through import.meta.glob makes Rollup inline the JSON into the chunk, so the data travels with the code:

TS
const modules = import.meta.glob<{ default: PlaylistCache }>(
  '../data/youtube-playlist-cache.json',
  { eager: true }
);
const cache: PlaylistCache = Object.values(modules)[0]?.default ?? {};

A glob rather than a plain import because the file is gitignored — on a clone that has not run the fetcher, a static import is a hard build failure, while a glob that matches nothing yields {} and degrades exactly as before. The built chunk went from a 4KB stub to 1.8MB carrying all four itemCount values, with no fs call left in it.

The counters lied about being fixed

Midway through, the counts came back on their own. That was genuinely confusing for a minute and it is worth understanding why, because it is the same shape as the original bug.

getLiveCounts() pulls counts from the YouTube API in the request path — that is the stale-while-revalidate layer from the entry above. So once a serverless instance warmed up, the header and the tab numbers were correct. But isUnavailable is computed from getPlaylist():

JS
isUnavailable = !data || data.unavailable || allItems.length === 0

The cache was still empty, so the player and scroller stayed collapsed. The runtime layer masked exactly half the bug: the integers came back, the 6,362 items did not. A page reporting the right number of videos while showing none of them is a good reminder that a partial recovery is not a signal that the cause was found.

One channel, 6,362 times

With the cache readable, the data was worth looking at — and every single item claimed the same channel:

distinct item channelTitle values: 1
   6362  Michael Staton

The first item is What is Apache Iceberg?, which is not his video.

This is the playlistItems.list trap. That response carries two different notions of "channel", and the fetcher was reading the wrong one. snippet.channelTitle names the account that added the item to the playlist — so on an owner-curated playlist it is the owner on every row, which the playlist-level entry already told us. The channel a video actually lives on is snippet.videoOwnerChannelTitle / videoOwnerChannelId.

Reading the videoOwner* pair turns 1 channel into 2,196, at no extra quota — the fields ride along on pages the fetcher already pulls. channelId is now stored alongside the title, because a display name cannot survive a rename or distinguish two channels that share one. That is not hypothetical here: the library has 2,196 channels by id against 2,195 by name, so two genuinely different channels are sharing a display name right now. Keying on the name would have silently merged them.

Private and deleted videos carry no owner at all. That accounts for exactly 44 items with no channel — the same 44 already flagged unavailable, so the gap is the known-dead set rather than silent loss.

Existing entries could not pick any of this up on their own: the 30-day TTL would skip all four playlists as fresh. The cache was dropped and refetched cold.

From a fixed bug to a second surface

2,196 channels ranked by how many of their videos got kept is a page. The distribution made the cutoff pick itself:

>=25 videos:   29 channels
>=15 videos:   65 channels
>=10 videos:  112 channels   <- the "same 100 or so" I actually watch
>= 5 videos:  229 channels

112 channels at ten or more, covering 2,845 videos. The long tail is real — 1,476 channels appear exactly once.

/channels derives that list at build time from the same cache /playlists reads. A written-down top-100 is wrong the moment a playlist grows, and would be a second claim about the library that can disagree with the first; deriving from one source means it cannot.

Volume is a proxy for "favorite" though, not the thing itself. It cannot say this one is excellent, I have only saved three of them. So channel-favorites.json holds that judgement — keyed by channel id, seeded by pnpm seed-channels with all 112 entries at favorite: false so the tedium of looking up ids is gone and only the choosing is left. The seeder is additive and never destructive: flags and notes survive a re-run, new channels arrive unpicked, and channels that fall below the cutoff are deliberately left in the file, because deleting curation on account of a playlist shifting is the one move here that cannot be undone.

In the end the whole ten-plus set was marked favorite, which flipped two display decisions: the secondary list is now subtracted from the favorites rather than repeating them, and the list is numbered rather than starred. Both markers had distinguished a picked card from its unpicked neighbours; once everything is picked they distinguish nothing, while the rank they had replaced is the useful number.

Patch: Up and Down did nothing on mobile

On a narrow screen the scroller collapses behind a Browse 2,683 videos button that slides the list in as a full-screen panel. The list scrolled by finger. The Up 5 and Down 5 buttons did not, and Up was disabled from the moment the panel opened.

The sidebar resolves its scroll container once, in onMount, by walking up for the nearest ancestor with overflow-y: auto. Sound, until you notice when it runs — on mobile, at mount, the panel is closed, and the body only becomes scrollable once open:

CSS
@media (max-width: 768px) {
  .mfp__full { display: none; }
  .mfp[data-mfp-open] .mfp__panel-body { overflow-y: auto; }
}

So the walk finds nothing, falls through to the inline desktop scroller, and the buttons spend the session driving an element the reader cannot see. The disabled state follows from the same mistake: isAtTop starts true and the only thing that clears it is a scroll listener bound to that same wrong element.

The container is now re-resolved on click and on a MutationObserver watching data-mfp-open — the attribute that flips the panel body to overflow-y: auto in the first place. Desktop still lands on .yt-pl-scroller as before.

Driven against a real 390×844 touch viewport:

beforeafter
Resolved containerinline scroller (max=0).mfp__panel-body (max=2373)
Down ×3no movement318 → 636 → 954
Up ×2disabled636 → 318
Disabled stateUp stuck oncorrect at both ends

318px is five items at 63.6px, which is what the buttons claim to do.

Two things that looked like bugs and were not

Playwright scrolls an element into view before clicking it. The button bars are position: sticky, so they look pinned — but the bottom bar's layout position sits after all fifty items. The pre-click scroll drags that into view, the panel snaps to the end, and it reads exactly like the component overshooting. A programmatic .click() stepped a clean 318px, which gave it away. A focus-suppression fix written for the phantom was reverted.

The dev toolbar was eating the taps. It overlays the bottom-centre of every page above the site's own chrome, so a tap aimed at the bottom bar hits the toolbar and does nothing. Two dead taps in a row is a persuasive impression of a broken button.

Neither was a product defect. Both were the instrument rather than the subject — a browser drive proves the buttons work, and the drive itself needs verifying before its verdict is worth anything.

Also Included

R now highlights. Builds warned [Shiki] The language "R" doesn't exist, falling back to "plaintext", leaving the R block in We need better Charts unstyled. Shiki does ship an R grammar; its ids are lowercase and case-sensitive, so ```R misses r. R is the one language whose community capitalises its name in prose, so the fence stays as an R author would write it and markdown.shikiConfig.langAlias meets it there:

JS
markdown: { shikiConfig: { langAlias: { R: 'r' } } }

Confirmed by building with and without it against a cleared content cache — the warning appears in the control and is absent with the alias.

The dev toolbar is disabled, for the reason it earned above. Dev-only; nothing about the deployed site changes.

What's Next

The same failure class is worth auditing elsewhere. src/data/og-cache.json is gitignored on exactly the same terms, and if its loader reads from disk at request time it is broken in production in exactly the same silent way.

Separately, builds are hitting GitHub API 403: rate limit exceeded while fetching context-v/ documents — seven spec files dropped out of one deploy. Unrelated to any of this, but it means some context-v pages are shipping incomplete and nothing in the build fails.

/channels currently has no avatars. One channels.list call per fifty channels would fetch them for about three quota units, cached the same way as everything else here.

  • [[2026-08-17_03]] — the /playlists surface these fixes rescue, and the runtime counter that masked half the bug

  • [[2026-05-07_03]] — the v1 ship of the playlist embed family

  • [[Versatile-Component-Library-for-Video-Players]] — the spec behind the component family