Skip to content
Devansh Soni
All posts

Your shelf should know what you watched

I built a media tracker called Shelf — films, TV, anime, games, books, all in one place, with

  • shelf
  • browser-extension
  • javascript

I built a media tracker called Shelf — films, TV, anime, games, books, all in one place, with progress and ratings and a GitHub-style activity heatmap. It works. I use it. And I kept forgetting to update it.

That's the whole problem with manual trackers. You finish an episode at 1am, you're not going to open a web app and increment a counter. So you don't, and a week later your shelf says you're on episode 4 of a show you finished. The data decays until you stop trusting it, and once you stop trusting it you stop using it.

The browser already knows. Netflix knows exactly which episode I just watched. So does Prime Video. The information exists — it's just trapped in a tab. A browser extension can read it and tell Shelf.

This is what I learned building that.


What it needed to do

The goal was narrow on purpose: finish something on a streaming service, and it appears on your shelf at the right episode. No dashboard, no manual step, no new habit.

Three constraints fell out of that:

It must not be wrong. A tracker that records the wrong episode is worse than one that records nothing, because now you have to check it. Silent corruption is the failure mode to design against.

It must not be creepy. It reads a title from a video player. It should not be able to read anything else, and that should be verifiable from the manifest rather than taken on faith.

It must survive being ignored. Laptops close mid-episode. Servers cold-start. If a sync can be lost by bad timing, it will be.


Deciding what not to support

The tempting design is generic: run everywhere, look for a <video>, scrape whatever title you can find. It's one adapter instead of many and it "supports" every site on the internet.

I went the other way — a closed registry of hand-written adapters, one per service:

export const PROVIDERS = [netflix, primevideo, jiohotstar];

If a site has no adapter, the extension does nothing there. No wildcard host permission, no heuristics.

This costs coverage and buys three things. Precision: a hand-written adapter knows that this element is the episode label, instead of guessing from page structure. Permissions: the manifest lists exactly three services, so "what can this thing read?" is answerable by reading it. And honesty: a generic scraper produces a long tail of near-misses, and every near-miss is a wrong row on your shelf.

Every adapter implements the same small interface:

{
  id, label, hostPatterns,
  isWatchPage(location),      // URL-level gate
  isPlayerActive(doc),        // is a player actually mounted
  extractItemId(location),    // stable per-episode id
  readMetadata(doc),          // { seriesTitle, season, episode, episodeTitle, kind }
  findVideoElement(doc),
}

Two gates before anything is tracked — the right URL and a real player — because on some services a detail page and a playing episode live at the same URL.


What counts as "watched"

The naive approach is to read video.currentTime and treat it as progress. It's wrong, and it's wrong in a way that quietly ruins data: drag the scrubber to the end to check something, and you've "watched" the episode.

So the tracker counts wall-clock seconds of actual playback:

if (isPlaying && isVisible) {
  current.watchedSeconds += elapsedSeconds;
}

Only while playing, only while the tab is visible, accumulated from ticks rather than derived from position. Seeking contributes nothing, because seeking takes no time.

An episode counts as watched at:

Math.min(duration * 0.85, duration - 120)

Whichever comes first — 85% through, or within two minutes of the end.

There's also a floor: anything under five minutes is ignored entirely. Trailers, recaps and "next episode" teasers autoplay in the same player on every one of these services, and without that floor a two-minute trailer registers as a full view of the show.

The output is one discrete fact per episode — unit N of this title was watched — emitted at most once. Not a stream of percentages. That turns out to matter a lot for the next part.


Making writes survivable

Extension service workers get killed after about thirty seconds of idle. Anything mid-await when that happens is simply gone.

So completions don't get sent, they get queued:

{ mediaId: 42, absoluteUnit: 13, idempotencyKey: 'media:42:13', attempts: 0, nextAttemptAt: ... }

A persisted outbox in chrome.storage.local, drained by an alarm and on demand, with exponential backoff. Every entry carries an idempotency key, so replaying is harmless — which is only true because the events are discrete facts rather than deltas.

The server side is built to match. Progress writes are:

progress = max(current, unit)

applied in one transaction. The client never reads the shelf before writing, two clients can't lose each other's updates, and a duplicate send changes nothing. A whole class of races disappears by making the operation idempotent rather than by coordinating.

One subtlety I got right by accident and then nearly broke: "already exists" has to be success. If the server says the unit is already recorded, that's the outbox's job done, not an error to retry.


Now the interesting part

Everything above is design. Here's what actually happened when it met three real streaming services.

A redirect that could never match

Sign-in failed with Invalid redirect_uri against a backend I'd configured correctly.

Extension auth uses chrome.identity.launchWebAuthFlow, which redirects to https://<extension-id>.chromiumapp.org/. I'd allowlisted https://*.chromiumapp.org/*. Obvious, and impossible.

The backend's validator normalizes URLs by stripping trailing slashes — from the candidate and the pattern. So https://abc.chromiumapp.org/ becomes https://abc.chromiumapp.org, with nothing after the host. And /* requires a slash plus something after it. That pattern could never match any extension, ever.

The fix is one character shorter: https://*.chromiumapp.org. The real fix was writing three tests — one asserting the correct pattern matches, one asserting the /* form doesn't, and one asserting a pinned extension id rejects other extensions.

That last one matters beyond the bug. A wildcard means any extension can ask your backend for a token. Fine while developing; pin the exact id before publishing.

A title that only exists sometimes

Detection worked, then didn't, with no pattern I could see.

Netflix's title element lives inside the player controls overlay — and when the controls auto-hide, the whole overlay is removed from the DOM. Not hidden. Removed. I'd been using the title's presence as the signal for "a player is mounted", so tracking silently stopped whenever the viewer stopped moving the mouse. Which is most of an episode.

Two changes. Playback is judged by the video element, which exists the whole time. And because the title genuinely is absent most of the time, the content script remembers it once read — identity comes from the URL, which is stable, so a hidden overlay no longer loses it.

It also polls for the title once a second until it has one, then stops. The controls are only up for a second or two after a mouse move; a five-second tick can miss that window for an entire episode.

The shape I guessed wrong

Detection reported "layout changed" on a film — my own warning for a stale selector, firing correctly.

I'd written the parser for the series shape: a heading for the show, siblings for the episode label. A film's title block is this:

<div class="medium default-ltr-iqcdef-cache-m1ta4i" data-uia="video-title">Laal Singh Chaddha</div>

No heading. No child elements at all. Just a text node. My parser read firstElementChild, got undefined, produced an empty title, and bailed. Films could never have been detected — not sometimes, never.

I'd written the test fixture from imagination. It passed, because it tested my assumption against itself.

The season that isn't there

The series shape, captured from a live player:

<div data-uia="video-title"><h4>The Mentalist</h4><span>E1</span><span>Pilot</span></div>

E1. Not S1:E1. So I checked season 2:

<div data-uia="video-title"><h4>The Mentalist</h4><span>E1</span><span>Redemption</span></div>

Also E1. Netflix never renders the season number.

This is the most dangerous kind of bug, because the naive handling looks completely reasonable: if no season is shown, assume season 1. That assumption places episode 3 of season 4 at position 3 — near the start of the show — and writes it as real progress. Wrong data, confidently, silently.

The episode name is the signal. "Redemption" is unambiguous within a show. So the server walks the seasons once and returns an index mapping "<episodeNumber>|<normalized name>" to a season, which the client caches. Later episodes are a local lookup.

Where the name doesn't match anything, it refuses to guess and asks. The only case it assumes season 1 is a show that has exactly one season, where it can't be wrong.

The model I never checked

Then I opened my own web app's add-media flow and watched it work:

const seasonTitle    = `${show.title} — Season ${season.seasonNumber}`;
const seasonSourceId = `${show.externalId}-S${season.seasonNumber}`;

Shelf stores a TV series as one entry per season. "The Mentalist — Season 2", 23 episodes.

My resolver was creating one entry for the whole show: 151 episodes, source id 1424. A different source id is a different row. Every series the extension touched would have created a duplicate sitting next to the one I'd added by hand — the precise outcome the design was supposed to prevent.

I'd spent real effort on cross-season arithmetic, converting season and episode into an absolute position across the whole run. All of it served a data model my own application doesn't use. Once series resolve per season, progress is just the episode number, and that entire layer deleted itself.

The bug wasn't in the code. It was that I'd modelled the domain from memory instead of reading it.

The threshold I reasoned about backwards

Small one, but my favourite.

I wrote the completion rule as min(0.85 × duration, duration − 120) and documented the second term as a credit-roll allowance for long films. Then a test failed.

min picks whichever comes first. For anything over about thirteen minutes, 85% is already earlier than "two minutes from the end" — so the tail rule never applies to a feature film at all. It only ever relaxes short content.

The code was right. My explanation of it was backwards, and I'd have left that comment there to mislead me later. The test disagreed with the comment, not the behaviour.

An endpoint that would have erased progress

To let you rate something from the extension, the obvious move is to reuse the existing update endpoint. It takes a full shelf entry.

Which means progress defaults to 0 when you omit it.

Attaching a rating that way would have silently reset how far through a series you were. A separate endpoint that touches only rating and notes, plus a test asserting progress, status and completion timestamp survive it untouched.

Reusing an endpoint is only free when its contract is partial update. This one's contract was replace, and the difference is invisible until it eats your data.


Things that made debugging possible

Three things paid for themselves several times over.

DOM fixtures. Adapter tests run against saved copies of real player markup. It's the only way to keep selectors honest — but only if the fixtures are captured, not imagined. My invented fixtures passed while the adapter couldn't detect a film. Every fixture in the repo now carries a note saying whether it came from a live player, because that distinction is the entire value.

A build stamp. Content scripts run in an isolated world, so a page console can't see their globals. That makes "did Chrome actually pick up my rebuild?" surprisingly hard to answer — and reloading the tab isn't enough, only reloading the extension re-reads the bundle. I burned two debugging rounds unable to distinguish a broken adapter from a stale build. One line fixed it permanently:

document.documentElement.dataset.shelfSync = chrome.runtime.getManifest().version;

dataset crosses the isolation boundary. Now any console can answer it.

A visible failure mode. When a player is mounted but the title won't parse for long enough, the extension says so: "Netflix layout changed — this extension needs an update." Streaming sites redesign without notice, and the alternative to a warning isn't correctness, it's silence. The warning retires itself once a title reads successfully, so it describes the present rather than the worst moment in history.


What I'd tell myself at the start

Capture the DOM before writing the selector. Every adapter bug I hit was a fixture I'd written from imagination. The film that was a bare text node, the episode label with no season — both would have taken five minutes to discover up front and both cost hours to find later.

Read your own data model. I built a careful cross-season position calculator for a schema that stores seasons separately. The most expensive assumption I made was about my own code.

Refuse to guess. Every place this thing doesn't know something — unknown season, unmatched title, ambiguous episode — it asks instead of assuming. A prompt is a small annoyance. Wrong data you didn't notice is a tracker you stop trusting, and then stop using.

Make state observable. The build stamp and the layout warning aren't features. They're the difference between "it doesn't work" and "it doesn't work because", and that gap is where the hours go.

Let tests argue with you. Two real bugs surfaced as tests disagreeing with comments I'd written confidently. The failing assertion wasn't noise to fix; it was the only thing in the room that had actually checked.


The extension is about 1,400 lines with 135 tests, plus a resolver and two endpoints on the backend with 64 more. It reads a title and a playback position on three services, and sends one message per finished episode to an account I own.

I finished an episode last night and didn't think about it once. That was the whole point.