- JavaScript 100%
| src | ||
| test | ||
| testing | ||
| .env.example | ||
| .gitignore | ||
| AGENTS.md | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| TESTS.md | ||
SIMKL to Trakt Watch History Importer
Import watch history from SIMKL into Trakt.tv with a strict append-only model. The importer can read directly from the SIMKL API or from a local SimklBackup.json fallback.
Guarantees
- Append-only Trakt writes - this tool never deletes or removes anything from Trakt.
- Event-level dedupe - a watch record is uniquely identified as
content + Trakt-normalized watched_at timestampbecause Trakt currently round-trips imported history at minute precision. - Rewatch-safe - a new timestamp is treated as a new watch; the same timestamp is never intentionally written twice.
- Crash-safe reruns - before writing anything, the importer seeds existing Trakt history and skips exact matches already present on the account.
- SIMKL-safe sync - normal SIMKL runs use
/sync/activitiesplusdate_frominstead of repeated full syncs. - Trakt-canonical episode matching - episode imports are resolved against Trakt's own episode catalog before they are written.
Prerequisites
- Node.js 18+
- A Trakt.tv API app with:
- redirect URI set to
urn:ietf:wg:oauth:2.0:oob
- redirect URI set to
- A SIMKL API app for direct API imports with:
- app created at
https://simkl.com/settings/developer/new/ - redirect URI set to
urn:ietf:wg:oauth:2.0:oob
- app created at
Setup
- Create a SIMKL app at
https://simkl.com/settings/developer/new/ - In the SIMKL app settings, set the redirect URI to
urn:ietf:wg:oauth:2.0:oob - Copy
.env.exampleto.env - Fill in:
SIMKL_CLIENT_IDfrom your SIMKL app for direct SIMKL API syncTRAKT_CLIENT_IDTRAKT_CLIENT_SECRET
- Install dependencies:
npm install
Usage
| Command | What it does | Notes |
|---|---|---|
npm start |
Runs the normal import | Uses SIMKL API when SIMKL_CLIENT_ID is set, otherwise falls back to SimklBackup.json |
npm run preview |
Runs a dry run | Skips Trakt history seeding by default so it stays non-interactive |
node src/index.js --dry-run --seed-trakt |
Runs a duplicate-aware dry run | Uses Trakt history seeding for an exact preview against your account |
npm run preview:backup |
Runs a dry run against SimklBackup.json |
Useful when testing backup-file mode without writing anything |
node src/index.js --source backup |
Forces backup-file mode | Reads from local SimklBackup.json |
node src/index.js --source simkl |
Forces SIMKL API mode | Bypasses automatic source selection |
node src/index.js --source simkl --full-reconcile |
Runs a full reconcile | Repair/bootstrap mode only; still append-only on Trakt |
Dry runs still perform episode resolution when TRAKT_CLIENT_ID is configured, so resolver decisions are visible before any write happens.
Every run also writes a structured JSON log file under data/logs/.
Episode Matching Policy
Plain-language flow
| Step | What happens |
|---|---|
| 1 | Read the watch event from SIMKL or SimklBackup.json |
| 2 | Check whether the same watch event already exists in local state or seeded Trakt history after normalizing watched_at to the precision Trakt stores |
| 3 | Ask Trakt what the show's episode catalog looks like |
| 4 | Try the source season and episode directly on Trakt |
| 5 | If that fails, try a very small set of safe remap rules |
| 6 | Import only if one exact Trakt episode is found |
| 7 | If the match is unclear, leave the event pending instead of guessing |
Why this exists
Some sources split seasons differently from Trakt, especially for anime and alternate ordering schemes.
Example:
- A source may label an episode as
Season 2 Episode 1 - Trakt may record the same content as
Season 1 Episode 36 - The importer checks Trakt's catalog and remaps only if that match is unique
If the importer cannot prove the match, it does not write the event.
Technical policy
- Every episode keeps both a source identity and a Trakt-canonical identity
- Local dedupe and Trakt dedupe use the canonical Trakt episode identity once resolution succeeds
- Direct matches are preferred over remaps
- The first remap rule is bounded absolute-number remapping for source data that clearly uses a single continuous episode order
- Blind count-based season shifting is not allowed
- Fuzzy matching by title alone is not allowed
- Ambiguous or unresolved episodes stay pending
- When unresolved events remain, the SIMKL checkpoint is not advanced
- Resolved episodes are written to Trakt using canonical episode IDs
Resolver Output
Resolver summary
The importer prints a summary like this during dry runs and live imports:
Episode resolution: direct-mapped=67, direct-source=652, absolute-number-remap=25, unresolved=1
| Label | Meaning |
|---|---|
direct-mapped |
The source's mapped season/episode already matches a Trakt episode exactly |
direct-source |
The raw source season/episode already matches a Trakt episode exactly |
absolute-number-remap |
The source numbering did not match directly, but the importer safely remapped a continuous episode number to one unique Trakt episode |
ambiguous |
More than one Trakt episode looked plausible, so nothing was written |
unresolved |
No unique Trakt episode could be proven, so the event was left pending |
Resolver decisions by show
The importer also prints a per-show summary:
Resolver decisions by show:
- Naruto: direct-mapped=27, direct-source=18, absolute-number-remap=25, unresolved=1
This tells you how each show's episodes were matched before any write happens.
Resolver decision meanings
| Resolver decision | What it means | Example output |
|---|---|---|
direct-mapped |
A source-side mapped season/episode matched Trakt exactly | direct-mapped | source S01E07 |
direct-source |
The original source season/episode matched Trakt exactly | direct-source | source S01E36 |
absolute-number-remap |
A safe remap converted a continuous source episode number into the Trakt-canonical season/episode | absolute-number-remap | S01E53 -> S02E53 |
ambiguous |
The importer found multiple plausible Trakt matches and skipped the event | ambiguous | source S01E71 | mapped S02E36 |
unresolved |
No unique Trakt match could be proven and the event was left pending | unresolved | source S01E71 | mapped S02E36 | No unique canonical Trakt episode could be resolved. |
What "safe remap" means
A remap is only allowed when all of the following are true:
- the source numbering does not directly match Trakt
- the importer can apply an explicit approved rule
- the rule produces one and only one Trakt episode
If more than one Trakt episode is plausible, the event is skipped and reported.
Run Logs
Every run writes a JSON log file to data/logs/ with a timestamped name such as data/logs/run-2026-04-02T03-12-45-123Z-12345.json.
| Section | What it contains |
|---|---|
meta |
Run id, timestamps, flags, source mode, and log file path |
source |
Source stats and checkpoint information for the run |
resolver |
Resolver summary stats and per-show decision summaries |
traktSeed |
Whether Trakt history was seeded and how many records were loaded |
dedupe |
Candidate counts, duplicate skips, and resolver-pending counts |
syncAttempts |
Raw Trakt sync attempt results for movie batches, movie retries, and show groups |
events |
Per-event records with resolver data, status history, and final outcome |
Per-episode log data
Each episode entry in events includes:
| Field group | Meaning |
|---|---|
keys |
Source, legacy, current, and canonical event keys |
sourceEpisode |
Source season/episode, raw source numbering, and mapped numbering |
canonicalEpisode |
The final Trakt-canonical season/episode and usable episode IDs when resolution succeeds |
resolution |
Resolver status, method, reason, and any ambiguity metadata |
statusHistory |
Lifecycle transitions such as source-loaded, resolver-resolved, skipped-ledger, imported-trakt-write, or unresolved-trakt |
These run logs are ignored by Git and are meant for troubleshooting and auditability.
State Files
The importer stores runtime auth and dedupe state under data/:
data/trakt-token.json- Trakt OAuth tokensdata/simkl-token.json- SIMKL access token from the PIN/device flowdata/import-state.json- versioned importer state, including:- committed SIMKL activity checkpoint
- durable imported-event ledger
Notes
- SIMKL deletions do not remove anything from Trakt.
- Missing or ambiguous timestamps are skipped rather than guessed.
- Episode resolution prefers Trakt-confirmed canonical matches over source-side numbering.