On the night of July 31st, I noticed my "Currently Watching" page had stopped at July 25th.

No error message. The server was fine, the page rendered fine, the anime half was still updating — only the movies and TV half had frozen.

I went to Trakt's website to re-authorize manually, and it got stranger: I'd log in successfully, and every navigation would bounce me back to the landing page.

Meanwhile looked completely healthy. It was still picking up what I watched, still showing matched results.

A failure where nothing reports an error anywhere, and the data simply stops moving. I found out later that a lot of people around the world were stuck at exactly the same place that night.

Two strings#

Deep in the backend logs there were only two things:

text
client not found
session not found

client not found says it clearly enough — Trakt could no longer find my OAuth app. Not an expired secret, not an invalid token. The app itself did not exist.

I checked Trakt's API settings page. Empty. The app I'd created months earlier was gone.

Who deleted it#

Trakt's web frontend is open source, so this part doesn't require guesswork.

trakt/trakt-web PR #3057, titled feat(settings): make api application creation vip-only, authored by Trakt's own developer vladjerca, opened and merged on July 30th, 2026.

Among the changes were these new interface strings:

text
"Creating new apps requires Trakt VIP"
"VIP members can register unlimited OAuth applications."

...and wrapping the create button in <RenderFor audience="vip">.

All of which is reasonable. A company deciding a feature is for paying members is its call to make.

But that PR only blocked creation. Its own description says so plainly: free users get an upgrade card above the application list, and that list itself remains "(still readable)".

Nowhere does it say anything about deleting existing apps.

And yet mine was gone. The GitHub issue is literally titled "Can't see API apps" — the reporter's app predated v3, the whole list was empty, and their integrations had started getting 403s. Four comments follow, all saying the same thing:

my API app was deleted without warning. Not a VIP here either, but i feed them data and now get nothing in return. Is this intentional?

The lack of notice just plain sucks. Whoever took that decision needs to hire a comms team...

"No announcement" is provable from their own records#

This isn't a feeling. It's checkable.

The trakt/trakt-api repo has an Announcements category, and they genuinely use it:

NumberDateTitle
#7752026-04-29‼️ Upcoming API Changes: Watched Endpoints Pagination & Extended Defaults
#6942026-01-29🔔 Upcoming Change: Seconds and Milliseconds Will Be Zeroed Out
#6812026-01-20📣 Upcoming API Changes: Pagination & Sorting Updates
#6552025-12-01⚠️ Reminder: Set a proper User-Agent header

They pre-announce changes at the level of "we're going to zero out the seconds in timestamps".

As of writing, the most recent announcement in that category is still a documentation update from June 11th. About deleting a whole tier's API apps: nothing.

So what's provable is: the mechanism exists, they normally use it, and this time they didn't. What isn't provable is intent — I can't see any internal records. But everything visible points the same way.

Four days later, they responded#

On August 3rd, someone from Trakt finally showed up. How the response arrived matters more than what it said — three things happened inside the same minute:

08:53:05 · Transferred

The issue was moved from trakt-web to trakt-api.

08:53:29 · Converted to a discussion, and locked

In the same second, the issue became a Discussion and the original thread was locked. It went from bug tracker to Q&A forum — from something that gets fixed to something that gets answered.

08:54:37 · Then the reply

A minute later, three sentences from the official account.

This is just a temporary measure to prevent abuse. Our intention is to keep the API accessible for developers. We'll have more to announce soon.

The next comment came from another developer whose app ships on the App Store:

Why don't you communicate about it BEFORE deleting API apps then? Give us some notice and some delay before doing such moves, you shouldn't cut access like that, some of us depend on those for production apps. ... You've lost my trust as well.

As for "temporary": as of today PR #3057 has not been reverted, there is still no announcement, and in the meantime a new VIP feature has shipped.

Three places carried the discussion, and where each one stands today lines up neatly with who owns the platform:

PlatformWhose turfNow
GitHubtheir repotransferred, converted, locked
Official forumtheirsthe whole thread 404s
Redditnot theirsalive, 102 upvotes, 60 comments

I checked that the forum thread wasn't just a bad URL: the same server's search API responds normally, and only that topic is gone. Who removed it, I have no evidence for, so I won't say.

The Reddit thread has one detail that's easy to misread — the original post shows [deleted], but that was the author deleting their own post, which the page states outright. Every comment is still there. I nearly filed it as "another disappeared discussion" before checking.

The top-voted comment out of those 60:

Trakt is speedrunning the dumbest path to monetization.

And another one reminded me that for the people there, this isn't an isolated event:

they redesigned their UI which everyone hates, got rid of the option to use the old version of the site, increased pricing for VIP, and now cut off their API for free users. I'm sure there's more that I'm forgetting, but it's all anti-user.

I only used Trakt for two months, so I'm not in a position to judge the earlier items. But it explains why the thread runs hotter than one broken API would justify.

First: what did I actually lose?#

Before hunting for a replacement, I checked something more basic: how was my data getting in at all?

It wasn't what I assumed:

text
netflix      788 rows   all written in the same second on 2026-05-28   ← one-off historical import
trakt        155 rows   films current to 7/25, shows frozen at 5/29
letterboxd     1 row    2026-05-28, one-off

Those 788 Netflix rows were a CSV I downloaded and imported myself. Once. Never touched again. Trakt only had 155 rows, which looks negligible —

except it was the only source still flowing. Its share was small only because it had been running for two months.

Which means: with Trakt gone, my films and TV had no automatic source at all. (Anime runs through a sync I wrote myself and was unaffected.)

I also turned up a problem I'd never noticed: across those 151 TV rows, watched_date was NULL for every single one. Trakt had never given me per-episode watch times. That turns out to matter later.

Three plausible options, all eliminated#

I went looking for something that could slot into the existing architecture. One criterion: my backend has to be able to pull the data back out.

Two self-hosted trackers, both active on GitHub, both looking like drop-in replacements for Trakt's slot.

I didn't just read the READMEs — I searched the source:

text
Yamtrack   rest_framework / api/urls  → 0 results
Floppy     rest_framework / APIView   → 0 results

Neither exposes a REST API for third parties to pull from. They're trackers for humans to look at, not data sources for another service.

And Floppy's Trakt import requires you to create a Trakt API app — a road that had just closed for me.

The criteria were already written into the architecture#

After going around in a circle I realized: I don't need to redesign this feature. The requirements are already encoded in what I built.

Only the middle piece needed replacing. Which made the criteria concrete:

A public API I can pull from

Otherwise my backend never sees the data.

Something that pushes watch history into it

Ideally an extension the service maintains itself — one less link to break.

A free tier that isn't bait

I'd just learned this one the hard way.

Why Simkl#

The first test Simkl passed was simple: its public endpoints return data with no credentials at all.

bash
curl https://api.simkl.com/movies/trending   # just responds

Trakt requires an api-key even for trending. That tells you something about how a company thinks about its API.

Then I found the architecture maps almost one to one:

Trakt (before it broke)Simkl
Netflix captureUTS (third-party extension)official extension, native Netflix + Crunchyroll
AuthorizationOAuth + redirect_urialso offers a PIN flow, no server callback needed
Per-episode timestampsall NULL for meepisode_watched_at=yes gives real timestamps
Incremental syncnone, paginate everything every timenative date_from
Public endpointsneed an api-keyno credentials at all

That PIN flow (device flow) was especially valuable: the token comes from polling /oauth/pin/{USER_CODE}, so nothing ever calls back to my server. The public endpoint I'd opened for Trakt's redirect could go away.

But let me be clear about something —

WARNING

Simkl is also a free third-party service, and could in principle do exactly the same thing.

What I found — no VIP wording on the developer page, API open and free — is today's fact, not a promise. Trakt looked like this too, right up until July 30th.

The only real immunity is data that lives on your own machine. This migration did not solve that problem. It swapped one counterparty for another that currently looks more open.

Kill the unknowns before writing any code#

Having been burned once, I didn't want to gamble again. So before touching a single line of backend code, minimal verification.

Create an app on a free account

This is the exact step Trakt fell at. If Simkl blocked it too, the whole plan died right there.

It worked. First unknown gone.

Run the PIN flow and get a token

Eight polls, about 40 seconds. And the token response held a pleasant surprise:

json
{ "access_token": "…", "token_type": "bearer", "scope": "public" }

No expires_in, no refresh_token. Simkl tokens don't expire.

Which means the mutex, atomic file writes and double-check I'd written for Trakt's token rotation are all unnecessary. That machinery is exactly what burned down my Trakt authorization in the first place.

Hit the real API and look at the shape

Mark one film as watched on Simkl, pull it back, and see what the JSON actually looks like.

Against my existing tables, every column had a counterpart — and ids hands you imdb / tmdb / tvdb / letterboxd all at once.

Up to this point I still hadn't changed a line of code.

One rule that dictates the implementation#

Simkl's docs carry a warning, and the bold is theirs:

Never run unconditional background polling timers without active user interaction. Ensure you always use date_from to avoid overloading the API server. If you don't follow these rules, your client_id will be suspended.

My Trakt setup pulled everything, unconditionally, every six hours. Port that over and the client_id gets suspended — which would be me re-enacting Trakt on myself.

The right shape is the three-step flow from their docs:

text
every N hours:
  1. GET /sync/activities        ← very light, just timestamps
  2. compare against the stored cursor; identical means done  ← most polls end here
  3. only if it changed: GET /sync/all-items/?date_from=<last timestamp>

This is far cleaner than Trakt's brute-force pagination, and most of the time it never issues a second request.

The migration itself#

The new backend module is about 300 lines, replacing roughly 450 lines of Trakt logic. The difference isn't only line count:

Trakt (old)Simkl (new)
token7 days, needs rotationnever expires
concurrency guardmutex + atomic write + double-checknot needed
token storagea JSON fileenvironment variable
cursornone, full pagination each runone row in a table, incremental
pollingunconditional and completeask activities first, stop if unchanged

I deliberately didn't reuse a file for the cursor — Trakt's token file was the pain point. But the existing counter column is an integer and can't hold an ISO timestamp, so I added one very small sync_state table.

Three things I got wrong#

① docker-compose's environment overrides env_file

The credentials were in .env.backend, yet the worker reported a missing client id.

The cause was one extra line I'd added under environment in docker-compose.yml:

yaml
- SIMKL_CLIENT_ID=${SIMKL_CLIENT_ID:-}

${VAR:-} reads compose's own .env, which doesn't have that key → it expands to an empty string → and environment takes precedence over env_file, so an empty string overwrote the real value.

Deleting the line fixed it. The credentials load wholesale from env_file anyway; there was never a reason to list them individually.

② The documented endpoint returned 0 rows in my case

Simkl's phase-one docs say to call /sync/movies per type. In practice:

text
/sync/movies            → 0 rows
/sync/all-items/movies  → 1 row

Same account, same film. You need /sync/all-items/{type}.

Without that "hit the real API and look at the shape" step, I'd have found this after wiring up the backend, and my first thought would certainly have been "I wrote it wrong".

③ I blocked my own artwork-backfill path and got a blurry poster

This is my favourite, because fixing one thing broke another.

Once the sync worked, the hero banner was visibly blurry. My first hypothesis — Simkl has no high-res artwork — was wrong.

What actually happens: my backend already had logic to hit TMDb for any row whose poster field is empty, and it fetches not just a poster but an original-size landscape still.

The Trakt sync only wrote title, date and TMDb ID, which happened to leave that path open. When I wrote the Simkl one, I helpfully filled the poster field with Simkl's small CDN image → the backfill check saw "there's already a poster" → skipped → the landscape still stayed empty forever.

And the banner renders "landscape still, falling back to poster" — with no still, it stretches a portrait poster to fill 880px. Of course it's blurry.

The fix was an idempotent migration clearing that row's poster field so the backfill could take over again.

And a fork, while I was at it#

Simkl's official extension only covers Netflix and Crunchyroll, while UTS supports 29 services — Disney+, HBO Max and the rest are all in there.

I didn't want to lose those just to change hubs, so I measured what converting UTS to Simkl would cost. Better than expected:

text
Trakt-specific code to rewrite   ~1,396 lines (8 files)
29 service extractors            untouched
  disneyplus  mentions Trakt 0 times
  hbo-max     mentions Trakt 0 times

The extractors are cleanly decoupled from Trakt. However awkward each service's page structure is, upstream already absorbed that pain, and none of it depends on which backend you send to.

So this fork is "swap the backend, inherit 29 extractors", not a rewrite. Upstream is MIT; I used a GitHub fork rather than copying the code, which preserves the commit history and the original authors' credit, and keeps the licensing relationship unambiguous. Links at the end.

The most instructive moment: the rename left type-checking green and broke the build.

The rename script updated the icon filename referenced in code; the actual file kept its old name. Type-checking can't see asset paths — it only failed at bundling. So I added build to CI, which previously ran only type-checking and lint. Neither of those catches that class of problem.

There was also something that fails silently, which I consider far more dangerous than a broken build: upstream has a feature that pulls "correction" suggestions from a community database, and those are all Trakt ids. Applied against Simkl they resolve to completely unrelated titles, with no error whatsoever. Disabled entirely.

Where it stands#

On August 4th I finished a film on Netflix. I did nothing else.

When the schedule came around, the backend asked activities once, saw the timestamp had moved, pulled that increment, wrote it to the database, and backfilled the artwork. By the time I thought to check, it was already on the page.

The pipeline is connected:

One thing isn't finished, and I don't want to write this up as if everything is solved.

The live "watching right now" state only survives for anime. Trakt's live state went out with those 377 lines, and I decided against wiring Simkl's up after looking into it — because the extension only calls the API on play, pause and stop. Across a whole film played straight through, Simkl receives nothing. Using its "paused" data as "watching now" would display something you stopped two days ago at 1%.

The right answer is to have the extension send heartbeats directly to my own server, bypassing Simkl — which is exactly what the anime path does. But that requires shipping my server's credentials inside a publicly released extension that anyone can download. That's an unsolved design problem, not something you can copy across.

How this page was built in the first place: