🔑 Key Insights
✦ AI·GENThis post records the whole process of building a live "what I'm watching" showcase page for my personal site, and the layered potholes along the way. Because my viewing history is scattered across Netflix, Disney+, HBO, Bahamut Anime and a pile of other platforms — and I mostly watch on apps or a TV — I first spent a round on tool selection, weighing a dozen options (Trakt, Letterboxd, MyAnimeList, AniList, Plex, various GDPR exports…) before converging on "anime via a self-built SDK, film/TV via Trakt, a Netflix CSV as the historical base, all links to TMDb." Since there's no user-facing Bahamut Anime API client on the market, I reverse-engineered it and open-sourced an npm SDK called anigamer (with a Rust port too), plus a browser extension that pushes cookies with one click. Once the data was in place, the thing that kept crashing was the "poster": the same borrow-from-the-wrong-source fallback pit showed up twice — a film in the static list that has a tmdb_id but no poster got skipped by an over-narrow enrich condition and broke, while the live card, because Trakt gives an id but no image, fell back to the previous Bahamut anime's poster and put the wrong face on it. The post works through the potholes in discovery order — borrowed poster, grabbed thumbnail, portrait cropped, wrong progress-bar length, all dates turned 1970, finish-not-syncing — and lands on a symmetric fix: "if there's an id, enrich the image live from TMDb; if you can't get it, be explicitly null; never borrow someone else's image."
The starting point actually had nothing to do with "watching." What I wanted was a little note-blog like Innei's — you finish something, toss in a link, jot a line. But there's one key fork between us: he maintains it by hand, and I'm lazy. That rhythm of his — "go jot a note after watching, just drop a link" — I can't and don't want to copy; I want it to update itself.
So "manual notes" slowly grew into something else: a page that automatically asks "what have I been watching lately." I called it "What I'm Watching."
Sounds like plumbing. But two things actually tripped me up on this road: one, the anime data source basically had to be built from scratch; two, something tiny — the poster — which can put the wrong face on things in five different ways.
Ten platforms, one list#
Before writing a single line of code, one thing had to be decided: what to track with. That's harder than it sounds, because my viewing habits are all over the place — anime on Bahamut Anime (動畫瘋), film and TV scattered across Netflix, Disney+, HBO Max, occasionally YouTube and Bilibili; and I mostly watch on an app or a TV, rarely opening a browser.
I laid out every tool I could think of and weighed them:
- TMDb: king of metadata (posters, years, genres, multilingual titles), and it's what Innei uses. But it only answers "what does this title look like," not "what did I watch." So it's destined to be the metadata layer, not a tracking source.
- Trakt: it eats film, TV, and live all at once — the closest thing to "Spotify for watching," so I picked it. But it has a cruel precondition — it can only see web playback in a browser, and can't catch apps or TVs at all. Rough estimate, its coverage of me is only 20–30%.
- Letterboxd: smoother UI than Trakt, a stronger community feel, an official CSV. I actually adopted it for a while — then found it overlaps almost entirely with Trakt (it just only takes films), so keeping it is pure redundancy.
- What about anime? Trakt's anime support is poor. I looked at MyAnimeList and AniList (whose API is indeed cleaner than MAL's), but they both require me to check things in by hand; and Bahamut Anime is a Taiwan-regional service that no one will ever wire into Trakt. So — better to reverse-engineer a fully automatic one myself, and skip the check-in entirely.
- How to backfill old films and history? This is where it gets stuck hardest. Netflix is kind — it has an official CSV export (I dumped in 95 films and 78 series at once); but neither Disney+ nor HBO has any export interface — I seriously looked into filing a GDPR data request, and the conclusion was there's no legal force for Taiwan users and you wait several weeks anyway; scraping "Continue Watching" is unstable and can get your account banned. In the end I accepted it: the history on those two platforms is simply given up — I can only accumulate going forward from the day I install Trakt, plus patch in a few by hand.
- A bunch of prettier solutions were also out because "I don't have it": Plex's self-hosted scrobble integration is the most perfect, but I don't have Plex; the desktop trakt-scrobbler only takes VLC / MPV, not browser streaming.
Along the way, the AI threw me a very useful reality check: do you actually use Trakt, AniList, Letterboxd? If the answer is "no, but I'll start" — then odds are you still won't in six months. That line turned out prophetic; Letterboxd really did get cut.
So the dust settled and the division of labor became clear:
- Anime → Bahamut Anime (self-built SDK, fully automatic)
- Film + TV → Trakt (new watches going forward + live), plus that Netflix CSV as a frozen historical base
- All links → TMDb across the board (no longer routing visitors to my private Bahamut Anime history page)
- Letterboxd, Disney+ / HBO history → released into the wild
Draw the chosen sources as a data flow, and it looks roughly like this:
NOTE
Put the three together and each one happens to be missing a corner: Bahamut Anime gives an image but no tmdb_id, Trakt gives an id but no image, TMDb gives a portrait poster but I want a landscape banner. Nearly every pothole later is about patching "the corner one of them didn't give" — the poster most of all.
Reverse-engineering a Bahamut Anime SDK myself#
Film and TV have Trakt's whole ecosystem; anime doesn't. All my anime is on Bahamut's ani.gamer, and I searched all of npm — everything that exists is a downloader, not a single API client for a program to fetch "what the user watched." Since no one had done it, I reverse-engineered one and open-sourced it along the way: a TypeScript SDK called anigamer.
(Later, when the backend moved wholesale to Rust, I added a Rust port to match, with a test suite aligned one-to-one with the TS one.)
Alongside it I made a browser extension, "Cookie Pusher":
Log into Bahamut Anime, click once, and it pushes the cookies to the backend and hot-refreshes the sync — because I really didn't want to paste a long string of cookies into an env var, then docker build, then restart, every single time. It also moonlights as the live detector; the scrobbler section later brings it back.
This line had no shortage of potholes, all very "Bahamut-flavored." For a dead session it returns a status code of 200 with a 401 stuffed in the body — a soft error that quietly broke my sync for three days while monitoring thought everything was fine. And that key BAHARUNE cookie is , so no matter how the extension tried, at first it could only read five non-HttpOnly ones —
— and in the end it took the browser's debugging interface (CDP) to get around it. The data itself hit a mine too: at first every anime showed only "1 episode," and the timestamps were all crammed into the same instant; chasing it down, it turned out the SDK and the backend both only took "the latest episode" — after expanding the whole nested history, the database grew from a hundred-odd rows to nine hundred-odd.
But all of this — the reverse-engineering fight, the cookie tug-of-war, that 401 hiding inside a 200, the SDK from zero to release — is really a whole other post's worth, which I'll write up separately in depth. On this page you only need to know: the anime data came in through a pipeline I built myself.
Dirty history#
With the anime line wrapped up, over to the film and TV side.
The broken-image Napoleon#
In "recently watched" there was a broken poster for NAPOLEON. My first instinct was to blame Letterboxd and just cut every source down to Trakt alone. But laying the data out and counting, the truth was the exact opposite of my impression:
| source | films | series | bad dates (1970) | missing poster |
|---|---|---|---|---|
| Netflix CSV | 95 | 78 titles / 693 eps | 0 | 0 |
| Trakt | 0 | 3 titles / 151 eps | 151 (all bad) | — |
| Letterboxd | 1 (Napoleon) | 0 | 0 | 1 |
Napoleon did come from Letterboxd — but it has a tmdb_id (753342), just a null poster_url. And my enrich only fills the ones where tmdb_id IS NULL, so it skipped Napoleon without a second glance. The broken image isn't a bad source; it's that my fill condition was written too narrowly. The fix: widen it so "has a tmdb_id but poster_url is null" also gets filled (fetch the detail directly by id, no re-search, to avoid a slip that corrupts tmdb_id). Napoleon's face grew back on the spot.
That was also the moment I formally retired Letterboxd — I'd already disliked its overlap with Trakt during selection, and now its one contribution turns out to be the broken-image culprit. I just commented out its cron and kept the function around: Trakt's web UI is genuinely awful, adding titles by hand is painful, so maybe someday I'll want it back.
Dates all in 1970#
That table hid another mine: Breaking Bad, Game of Thrones, Itaewon Class — all three dated 1970-01-01. They all come from Trakt, and 151 watched_at values were all broken to . The root cause isn't in the code, it's in me — I watched these years ago, genuinely can't remember the dates, and back then filled in "unknown" on Trakt. Trakt stored "unknown" as the Unix epoch, which shows all the way through as 1970.
The fix: add a cleanWatchedDate that, on sync, stores epoch / 1970 dates as NULL (rather than pretending it's really 1970); on the front end, no date shows "date unknown," and it always sorts last. I don't have to go back to Trakt to change a thing.
A movie isn't the same title#
Some anime I've watched on both Netflix and Bahamut Anime, so they show up twice. The dedupe rule I want is "keep the row with the most episodes," and it can't rely on the name alone — I'm afraid of hurting titles with similar names or theatrical movies. So dedupe goes by tmdb_id: same id keeps the most episodes; movie and tv namespaces are separate, so a movie (movie id) can never collide with a series (tv id); and anything without a tmdb_id simply isn't deduped — better duplicated than wrongly killed. A name, used as a primary key, will always eventually blow up.
Now watching, not recently watched#
What I want isn't "the last thing I watched," it's "what I'm watching right now" — series, film, or anime alike. My site already had a Spotify now-playing (204 when nothing's playing), so I built to that same rhythm:
- Film / TV: install an off-the-shelf Universal Trakt Scrobbler (UTS) extension that live-scrobbles Netflix, Disney+, HBO playback to Trakt, and the backend polls Trakt's
/users/{slug}/watching. Not one line changed on my end. - Bahamut Anime: it's not in the Trakt ecosystem, so it's up to me — and this is that Cookie Pusher extension's second job: a content script hooked onto
animeVideo.phpthat, when a<video>is playing, heartbeats every 30 seconds and stops on pause or tab close. Pure push, zero polling against Bahamut Anime.
Detection leans on only three stable primitives: the ?sn= in the URL (video_sn), the <video>'s play/pause events, and document.title (backup only). The real title, cover, and tmdb_id I don't scrape from fragile on-site selectors; the backend looks them up by video_sn in anime_history — hand-craft as little as possible. The backend keeps one in-memory state with a 90-second TTL; the front end polls, lights up "● now watching" if someone's playing, and honestly falls back to "recently finished" if not.
TIP
What I worried about most was whether polling would cause trouble. So the rule became: only ask Trakt when someone actually opens this page, at most once every 25 seconds, and don't ask Trakt at all while Bahamut Anime is playing. Idle it's 0 calls; worst case it uses about 1% of Trakt's quota.
The only thing out of my control is that the third-party UTS extension has a bit of a temper: sometimes it just doesn't catch playback. From my testing, on Netflix if you enter from the homepage and click in, you sometimes have to manually refresh the playback page once after entering for it to get scrobbled — my guess is it swapped in another render layer and the extension didn't attach. Honestly I couldn't truly confirm the root cause; I can only say: at least doing this, it behaves.
The live card's chain of mishaps#
Wearing the wrong show's face#
With live capture wired up, I eagerly opened the page. Trakt did catch it live — the text plainly said "now watching 100 METERS, a film," but the big image was the previous anime I'd watched on Bahamut Anime (That Time I Got Reincarnated as a Slime, season 4). The title's right; the face is someone else's.
It's that "gives an id, not an image" Trakt again. The Trakt branch of /api/watch/now hard-codes cover to null, and the front-end hero picks the poster like this:
// Watch.tsx — when cover is null, fall back to now?.poster
poster: liveNow.cover ?? now?.posternow is the "most recent" from the anime aggregation, i.e. the Slime. So the moment cover is null, the film being watched casually borrows the previous show's face.
IMPORTANT
This and the earlier broken-image Napoleon are two faces of the same pit. One is "when there's an id but no image, the fill condition is too narrow" (static list); the other is "when there's no image, the fallback target is wrong" (live card). The root is the same sentence — when TMDb / Trakt gives you an id but not an image, that fallback chain isn't designed right, and the image gets borrowed from next door.
The fix adds one cut each on backend and front end. Backend: when polling catches Trakt now-watching, use the tmdbId it gives to fetch a poster from TMDb as the cover (reusing the cached detail; null if it can't). Front end: change the hero poster to liveNow.cover ?? null, no longer falling back to now:
// Guard: the now-watching poster can only be its own; if it can't be had, leave it blank, never borrow someone else's
poster: liveNow.cover ?? nullBoth blurry and cropped#
The poster is finally its own. But then it started breaking on me in new ways.
First, blur — the big image was obviously an upscaled thumbnail. When filling I'd grabbed w342 (342px), stuffed into a big banner and upscaled nearly threefold. Switch to original and naturalWidth goes from 342 to 1433 — sharp.
Sharp now, next it's cropped — the original is clearly portrait, put up it's just a middle strip. TMDb's poster is portrait (1433×2048), I stuffed it into a landscape banner (880×587), and with the crop leaves only the middle.
The fix: have the TMDb detail also spit out a URL — a 16:9 landscape still — and let the now-watching big image prefer it, falling back to the poster only if there's none. The same image: first the wrong show, then blurry, then cropped — three rounds of fixing before it settled.
A progress bar that miscounts#
The progress bar didn't move at all at first. I added endsAt and had the client interpolate to push it along; then it was wrong again — I'd just finished 100 METERS, but the bar hadn't filled; clearly at the credits, it showed like it was only at 30%.
The real ghost is that Trakt's started_at / expires_at jump. For the same 100 METERS, the duration computes as 68 minutes one moment and 30 the next (the player re-scrobbles). Compute progress from a jumping start point and of course you're at the credits thinking it just began. The runtime shouldn't be asked of Trakt, and the start shouldn't use the jumping started_at.
The fix: have the TMDb detail also spit out runtime_min and compute with the real runtime (100 METERS = 106 minutes, stable); anchor on expires_at and back-derive; change front-end interpolation to "anchor on the backend snapshot's progress plus the elapsed-time delta," immune to clock drift. Measured rate: 1.57×10⁻² %/sec, exactly 100 ÷ (106×60) — a perfect match.
WARNING
Here I couldn't fix it 100%, and I won't pretend I did: the absolute progress is still affected by Trakt's jumping started_at — because the data has no such thing as "actually played to which second." What I can do is get the rate right and let the 30-second polling self-correct. A scrobbler gives you "what you're watching," never "which second you're on."
The vanished poster#
The last symptom is the most basic: the image is gone, and the "recently finished" at the top still hangs the previous anime. The just-finished 100 METERS made it into the list, but only as a clapperboard placeholder.
Two root causes stack up.
One, the INSERT in the Trakt history sync doesn't store poster_url at all:
-- missing poster_url, no wonder 100 METERS made the list with no face
INSERT OR IGNORE INTO film_history (title, watched_date, source, tmdb_id, release_year) ...Two, the hero's "recently finished" always uses the anime aggregation (= the Slime), not the truly-most-recent across types. The fix: for list Trakt films missing an image, backfill from TMDb live by tmdb_id; change the hero to compute "the most recent across types" (now watching / most-recent film / most-recent series, take the latest).
There's also a "too slow" pit: after I finish and close the player, Trakt shows it as finished, but my site doesn't sync — because the history-sync worker only runs every 6 hours. The fix is to detect the "now-watching → not-watching" transition (= finished and closed) and immediately kick off one more history sync (idempotent), so it updates in about 90 seconds.
Shapes that don't line up#
Pulling it all together, that recurring pit is really simple: three APIs, each gives me half, and all the pain happens at the seam where you join the missing half. Bahamut Anime doesn't even have an API client, so I had to reverse-engineer one; it gave me a whole string of history and I took only one slot; Trakt gave an id but no image, so I let it borrow the neighbor's face; TMDb gave a portrait poster and I forced it into a landscape frame. Every bug, taken to the bottom, is the same sentence — the shape the source gives you and the shape you want don't line up.
For that poster fallback chain, I finally set a rule: if there's an id, enrich the image live from TMDb; if you can't get it, be explicitly null and honestly leave it blank; never borrow someone else's image. The broken-image Napoleon and the mismatched live card are two ways of writing the same rule.
When a source gives you an id but no image, don't rush to borrow the one next door. Better to leave it blank than to mismatch — a wrongly-matched poster is uglier than an empty slot.
No comments yet
✨ Be the first to comment