← Station

YouTube's API Has No Watch History, So the Browser Reports It Instead

BLIP · · Engineering · 2 min read

The telemetry page wants to show what I'm watching on YouTube, live. The Data API has no watch-history endpoint — the watchHistory playlist has returned an empty placeholder since ~2016 and activities is deprecated. So I stopped asking Google and read the data where it already exists: the watch page. A ~40-line MV3 content script beacons each video to a Redis key.

The WATCHING tab on /telemetry shows what I’m watching on YouTube right now. Getting that from YouTube itself turned out to be the hard part.

There is no watch-history endpoint. The Data API v3 channels resource used to expose contentDetails.relatedPlaylists.watchHistory — the magic HL playlist. It’s returned an empty placeholder since around 2016. activities is deprecated. There is no OAuth scope for history. Liked videos (LL) still work, but that’s not the same thing. The platform sitting on every second of my watch time will not hand any of it back.

But the data isn’t gone — it’s on the page. Every youtube.com/watch?v=… already has the title, channel, and id in the DOM. So I stopped querying Google and read from the one place that always has the answer: my own browser.

It’s a Manifest V3 extension with one content script, scoped to nothing but the watch page and my own domain:

"permissions": ["storage"],
"host_permissions": ["https://bhanueso.dev/*"],
"content_scripts": [
  { "matches": ["*://*.youtube.com/watch*"], "js": ["content.js"], "run_at": "document_idle" }
]

Two gotchas made it more than a one-liner:

YouTube is a SPA. Navigating between videos never reloads the page — it fires yt-navigate-finish. So a script that reads once on load reports the first video forever. Listen for the nav event (plus yt-page-data-updated) and re-read, deduping by videoId.

The metadata lags the navigation. Right after yt-navigate-finish the URL has the new ?v=, but h1.ytd-watch-metadata still holds the previous title for a beat. Reading immediately reports the right id with the wrong title. The fix is unglamorous — wait 2.5s, then read:

const schedule = () => setTimeout(report, 2500);
window.addEventListener("yt-navigate-finish", schedule);

From there it’s plumbing: content script → chrome.runtime.sendMessage → service worker → POST /api/youtube/ingest?key=… → Upstash Redis (yt:current, yt:recent). The ingest endpoint validates the id against /^[a-zA-Z0-9_-]{6,20}$/ and rejects anything without a title, so a stray tab can’t poison the feed. The SSR telemetry page reads the key on each request.

No OAuth flow, no quota, no Google Cloud project, no API at all. ~40 lines of content script and one Redis key do what the official API explicitly refuses to. The popup even self-diagnoses — it reads back the last sync status and shows ● now watching when the beacon’s landing.

The lesson I keep relearning: when the API won’t give you data you can already see, the answer is usually a content script, not a support ticket.

// Discussion

Comments are powered by GitHub Discussions via Giscus. Sign in with your GitHub account to add a reply, or discuss on X.

Keyboard Shortcuts

// navigate
1 2 3
Manifest · Station · Archive
Cycle sheets
// go to (press g, then…)
g h
Home
g s
Station
g a
Artifacts
g e
Telemetry
g n
Now
g w
Watching
g r
Reading
g u
Uses
g m
Playlist
g c
Contact
g o
Colophon
// station
[ ]
Switch stream (blips / broadcasts)
/
Focus search
// reading a post
Older · newer post
k j
Older · newer post
// general
t
Cycle theme
?
Toggle this panel
Esc
Close panel