🔑 Key Insights
✦ AI·GENOn 2026-07-30, with no announcement at all, Trakt made API app creation VIP-only — and existing apps on free accounts vanished along with it. My "Currently Watching" page quietly stopped updating, and all the backend had to say was `client not found`. This starts with the evidence: trakt-web's PR #3057 only blocked creation and explicitly noted the list stays "(still readable)", yet deletion is what actually happened; and the trakt-api Announcements category, which normally pre-announces things as small as zeroing out timestamp seconds, has nothing. The official response came four days later — issue transferred, converted to a discussion, locked, and only then three sentences about a "temporary measure". The rest is the migration itself: how Yamtrack, Floppy and reusing UTS were each eliminated by reading their source, why Simkl won (official extension, PIN flow with no callback, per-episode timestamps, native incremental sync), and the practice of killing every unknown before writing a single line of code. Three things I got wrong are included: docker-compose's environment overwriting env_file with an empty string, a documented endpoint returning zero rows against a real account, and blocking my own TMDb artwork backfill and ending up with a blurry poster. Finally, a fork of UTS with the backend swapped to Simkl, all 29 service extractors untouched — and an honest list of what still isn't wired up, namely the live "watching right now" state.
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:
client not found
session not foundclient 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:
"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:
| Number | Date | Title |
|---|---|---|
| #775 | 2026-04-29 | ‼️ Upcoming API Changes: Watched Endpoints Pagination & Extended Defaults |
| #694 | 2026-01-29 | 🔔 Upcoming Change: Seconds and Milliseconds Will Be Zeroed Out |
| #681 | 2026-01-20 | 📣 Upcoming API Changes: Pagination & Sorting Updates |
| #655 | 2025-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:
The issue was moved from trakt-web to trakt-api.
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.
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:
| Platform | Whose turf | Now |
|---|---|---|
| GitHub | their repo | transferred, converted, locked |
| Official forum | theirs | the whole thread 404s |
| not theirs | alive, 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:
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-offThose 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:
Yamtrack rest_framework / api/urls → 0 results
Floppy rest_framework / APIView → 0 resultsNeither 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:
Otherwise my backend never sees the data.
Ideally an extension the service maintains itself — one less link to break.
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.
curl https://api.simkl.com/movies/trending # just respondsTrakt 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 capture | UTS (third-party extension) | official extension, native Netflix + Crunchyroll |
| Authorization | OAuth + redirect_uri | also offers a PIN flow, no server callback needed |
| Per-episode timestamps | all NULL for me | episode_watched_at=yes gives real timestamps |
| Incremental sync | none, paginate everything every time | native date_from |
| Public endpoints | need an api-key | no 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.
This is the exact step Trakt fell at. If Simkl blocked it too, the whole plan died right there.
It worked. First unknown gone.
Eight polls, about 40 seconds. And the token response held a pleasant surprise:
{ "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.
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_fromto 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:
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) | |
|---|---|---|
| token | 7 days, needs rotation | never expires |
| concurrency guard | mutex + atomic write + double-check | not needed |
| token storage | a JSON file | environment variable |
| cursor | none, full pagination each run | one row in a table, incremental |
| polling | unconditional and complete | ask 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:
- 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:
/sync/movies → 0 rows
/sync/all-items/movies → 1 rowSame 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:
Trakt-specific code to rewrite ~1,396 lines (8 files)
29 service extractors untouched
disneyplus mentions Trakt 0 times
hbo-max mentions Trakt 0 timesThe 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:
No comments yet
✨ Be the first to comment