A plain-English tour of how the code actually works, subsystem by subsystem. It complements
CLAUDE.md (the rules and constraints) and TMSync-PRD.md
(the what and why). This file is the how — read it when you need to answer "where does X
happen?" or "what talks to what?".
Scope note: TMSync tracks movies & non-anime TV → Trakt and anime series → AniList, and can multi-track anime to both at once. The tracker layer is a pluggable registry; the long-term vision is more trackers (Simkl, MyAnimeList) behind the same seam. Everything below reflects the code as it stands today.
TMSync watches a <video> on a streaming page, figures out what is playing from the page's own
metadata (using a recipe — declarative JSON, never code), and reports your progress to the
right tracker(s). Three moving parts:
- The content script runs on the page. It matches a recipe, finds the video, reads the title/season/episode, draws the on-page badge, and owns the live watch session (play/pause/stop).
- The background service worker is the hub. It resolves "Attack on Titan S1E5" into a real Trakt/AniList id, calls the tracker APIs, holds your OAuth tokens, and refreshes the recipe library. It is stateless — it forgets everything between wake-ups and re-reads storage each time (an MV3 requirement).
@tmsync/sharedis a pure package (no DOM, no browser APIs): the recipe schema, theextract()engine, and helper logic. It's the testable core, and could be reused server-side one day.
Everything tracker-specific (auth, id resolution, how progress is recorded, the anime numbering crosswalk) hides behind a tracker-adapter seam so the shared engine never needs to know Trakt from AniList.
┌─────────────────────────────── the streaming page ───────────────────────────────┐
│ content script (per frame, injected per-origin at runtime) │
│ matchRecipe → extract() → ParsedMedia → badge │
│ SessionManager + ScrobbleController (play / pause / stop, debounced) │
└──────────────┬────────────────────────────────────────────────────────────────────┘
│ typed messages (@webext-core/messaging)
▼
┌─────────────────────────── background service worker (stateless) ─────────────────┐
│ routeTracker → TrackerAdapter(s) │
│ Trakt adapter → Trakt REST (real-time scrobble start/pause/stop) │
│ AniList adapter → AniList GraphQL (one SaveMediaListEntry at threshold) │
│ animap crosswalk → multi-track fan-out (anime → both) │
│ reads/writes WXT storage for everything (tokens, caches, sessions) │
└────────────────────────────────────────────────────────────────────────────────────┘
pnpm workspace, two packages plus a recipe library:
| Path | What it is |
|---|---|
packages/shared/ |
Pure engine + schema. No DOM, no browser globals. Zod recipe schema, extract(), matching, transforms, quick-link templates. The testable core. |
packages/extension/ |
The WXT app. All entrypoints, the tracker adapters, the session/scrobble machine, storage, UI kit, element picker. Preact for injected UI. |
recipes/index.json |
One tracker-agnostic recipe + quick-link library (crowdsourced via PR). Trakt and AniList recipes coexist; each carries its own tracker field and the engine routes per-recipe. |
Root package.json scripts just delegate into the extension package via pnpm -F @tmsync/extension.
This is the single most useful thing to understand — trace one watch from page load to "marked watched". Follow the numbers:
- Injection. The content script (
entrypoints/content.tsx) is registered per-origin at runtime — it isn't on every page by default (constraint #5: no broad host access at install). You grant a site in the popup, which registers the script for that origin. - Match. On load it calls
loadRecipes()andselectRecipe()/matchRecipe()(packages/shared/src/match.ts) — the first enabled recipe whoseurlPatternregex matches and whosedomFingerprintselector exists. The fingerprint is the clone-resilient key: it matches a site across its many mirror domains. - Extract.
extract(recipe, { document, url })(packages/shared/src/extract.ts) reads each field from itssource(url/meta/jsonld/dom/title), appliesregex→group→transforms, and returns aParsedMedia({ mediaType, title, year?, season?, episode?, ids? }). It never throws — a bad selector just yieldsnull. - Badge + session. The top frame mounts the Shadow-DOM badge and starts a
SessionManager(lib/scrobble/session.ts). If the player is in a cross-origin iframe (common on gray-market sites), the matching frame publishes the media for the tab and the video-owning frame pulls it — they coordinate over messaging. - Route. The background decides which adapter(s) get this item via
routeTracker()/recipeTrackers(). Movies always route to Trakt; an anime recipe can route to both Trakt and AniList (multi-track fan-out). - Resolve (once, cached). The adapter turns the
ParsedMediainto aTrackedItem: Trakt via/search(returns trakt/imdb/tmdb ids), AniList via a GraphQLMediasearch. Results are cached in storage so this only happens once per title. - Record progress.
ScrobbleController(lib/scrobble/controller.ts) is the play/pause/stop state machine on the video element. It debounces bursts (seeking, ad breaks), fires exactly onestartper session, and commits astopthe moment progress crosseswatchedThreshold.- Trakt path: real-time
POST /scrobble/start|pause|stop. Trakt owns the "watched" decision (≥80% on stop → history). - AniList path: no scrobble API exists, so start/pause are no-ops and a single
SaveMediaListEntrywrite happens once the threshold is crossed. We own the watched decision here.
- Trakt path: real-time
- Survive a crash. Progress is throttle-persisted to session storage (
tabSessions) every ~5s. If the tab dies before a clean stop, the background'stabs.onRemovedhandler re-resolves and replays a reconcilingstopfrom the last persisted progress. This is why session state lives in the content script + storage, never in background memory.
The heart of the "recipes are data, not code" guarantee. Everything here is pure and unit-tested.
extract.ts—extract(recipe, ctx). TheDocumentis injected viactx, which is how this stays DOM-global-free (and testable withhappy-dom). Field pipeline:rawValue(switches onsource) →applyRegex→applyTransforms→ trim.readFieldis exported so the picker can show a live preview using the exact same logic that runs in production.readJsonLdflattens arrays and@graphand walks dotted paths.readIdsbuilds a namespace-keyed id map;primaryIdpicks the strongest id byID_NAMESPACE_ORDER(tmdb, imdb, tvdb, anilist, mal).match.ts—matchRecipe(urlPattern + domFingerprint) andselectRecipe(first match whoseschemaVersion ≤ SCHEMA_VERSION). Hostnames are hints only.schema.ts— the Zod source of truth.SCHEMA_VERSION = 3. A recipe is validated here before it's ever used; an invalid recipe is discarded, never partially applied.recipeTrackers()reads the multi-track set (trackersif present, else[tracker]). Schema evolution is handled with Zod.transforms for back-compat (e.g. legacytmdbIdfolds into the openidsmap).transforms.ts,recipes.ts(parse/validate untrusted library JSON, discarding bad entries individually),links.ts(quick-link URL templating),types.ts(ParsedMedia,EngineContext,ExtractResult).
lib/tracker/adapter.ts defines the contract; lib/tracker/index.ts is the routing single source
of truth (getAdapter, routeTracker, inferNativeTracker). Two implementations behind it:
Trakt (lib/trakt/) |
AniList (lib/anilist/) |
|
|---|---|---|
| Progress | real-time scrobble start/pause/stop |
none — one SaveMediaListEntry per episode at threshold |
| Watched decision | Trakt owns it (≥80% on stop) | we own it (crossing watchedThreshold) |
| Auth | OAuth authorization-code, refresh-token rotation | OAuth authorization-code, ~1-year token, no refresh |
| Identity | /search → trakt/imdb/tmdb ids |
GraphQL Media search → AniList id |
| Resolvable ids | tmdb, imdb, tvdb | anilist, mal |
Why they're deliberately different code paths: AniList has no concept of "currently watching",
so faking a scrobble loop for it would be wrong. It reads the viewer's existing list entry before
every write (the entry is the source of truth), never lowers progress, and treats a COMPLETED
season as sacred — re-watching prompts a "Rewatching?" confirmation in the badge before it touches
anything. That decision logic is pure and tested in lib/anilist/util.ts (planAniListWrite).
The anime crosswalk (lib/animap/). When an anime is multi-tracked, one tracker is native
(the page already speaks its numbering) and the other is derived via the Fribb TMDB↔AniList
crosswalk (anime-map.seed.json). forward()/reverse() return resolved | ambiguous | miss and
never guess — ambiguous or missing means skip that tracker, not mis-write it. The fan-out itself
(recordDerivedTrackers, resolveAcross) lives in background.ts. Hard rule: the crosswalk is
background-side only and must never be imported by the shared engine.
Rating, notes & exports are co-located with each tracker, not inlined in the background: Trakt
rating/notes in lib/trakt/review.ts, AniList in lib/anilist/review.ts, and Trakt's Letterboxd
CSV export in lib/trakt/letterboxd.ts. The background's rateItem/saveNote/etc. handlers are
thin dispatchers that call the right tracker's module. (The TrackerAdapter interface itself covers
resolve/record/ratingLevels/watchedState; folding rate/note writes into the interface is a future
step best done when a third tracker exists to shape it.)
session.ts—SessionManager(per frame). The messy real-world glue: the matcher/player iframe split, SPA navigation (it patcheshistory.pushState/replaceState), late metadata (watches<head>mutations for a lateog:title), hover-gated player chrome (it can even synthesize pointer nudges to make lazy players render their metadata), manual recipes, and episode-less URLs (prompts for the episode). It also collects cross-origin iframe origins so the popup can offer to grant them.controller.ts—ScrobbleController(the actual state machine). Debounces play/pause bursts (~800ms), is idempotent (never fires the same action twice), turns a late pause into a stop, andprogressTick()commits the stop the instant progress crosses the threshold — robust against players that never fireended.- Ownership:
claimScrobbleOwnerguarantees exactly one scrobbling frame per tab (5-min TTL), so an iframe player and the top page don't double-scrobble.
One typed ProtocolMap (~40 messages) via @webext-core/messaging — no ad-hoc postMessage. It's
the contract for content↔background↔popup/options. Content→background carries scrobble,
publishMedia, updateProgress, endSession, resolve/rate/note/correction messages;
background→content carries recheck and scrobbleStatus; popup/options→background carries status,
connect, search, and register/unregister. All handlers live in background.ts.
Every persisted value is a storage.defineItem, split by prefix:
sync:— small, cross-device, user-owned:custom_recipes,quick_links,corrections,manual_selections,badge_prefs.local:— per-device secrets/caches/regenerable:trakt_tokens,anilist_tokens, resolution caches, ratings/notes caches,remote_recipes,enabled_origins,animap_overrides.session:— ephemeral per-tab:tab_sessions(the crash-reconcile source of truth),tab_frame_origins,tab_status,manual_contexts,episode_overrides.
The background reads these fresh on each wake — there is no in-memory background state (constraint #4).
kit/kit.tsx— the shared design system:tokens(variant)(light/dark token maps), and primitivesBtn,IconBtn,Switch,Stars,Icon,TraktMark,AniListMark. Dark is the shipped direction.kit/*View.tsx— presentational views (PopupView,OptionsView,PickerPanel,BadgeView,QuickLinksView, …). They take mock-able props and hold no browser APIs, which is what lets the gallery (entrypoints/gallery/) render every surface + state with fake data as a live component catalog.badge.tsx— the injected on-page badge, mounted via WXT'screateShadowRootUifor Shadow DOM style isolation. Notable tricks:keepAboveModalsre-parents the shadow host into an active<dialog>top-layer so the badge stays clickable over site modals; drag-to-edge docking with FLIP animation; a key-shield so page shortcuts don't leak.
How a new site gets added without code. recipe-builder.ts is pure authoring logic:
autoDetectFields (tries og/jsonld/title first, using the real readField), suggestUrlPattern,
regex/number/title chip builders, buildRecipe (assembles + Zod-validates), and previewDraft
(runs the actual extract() for a live preview). PickerApp.tsx is the overlay UI — it uses
@medv/finder to turn a clicked element into a short, robust CSS selector, and saves the result to
custom_recipes, reflecting live into the running content script.
- WXT (
packages/extension/wxt.config.ts): Preact + Tailwind v4. Minimal install permissions (storage, alarms, scripting, identity, activeTab) + specific host perms (Trakt, AniList, the recipe CDN); broad access isoptional_host_permissionsrequested per-origin on a gesture. Abuild:manifestGeneratedhook strips WXT's derived broad host perms and re-expresses them as optional. A committed extensionkey/gecko.idkeeps the extension id — and thus the OAuth redirect URI — stable. - Multi-browser:
dev/dev:firefox/build/build:firefox/zip*; outputs under.output/. - Tests: Vitest (
happy-dom) for the ~19 colocated unit suites (engine, schema, match, controller, animap, recipe-builder, clients, …); Playwright for the E2E perf regression (e2e/perf.e2e.ts).pnpm testruns both packages. - TS: strict,
noUncheckedIndexedAccess,verbatimModuleSyntax. Biome for lint/format.
Not blockers — just the things a careful reader might notice, so you're never caught off guard.
One known limitation (deferred by choice):
- Picker vs schema id mismatch. The schema supports an open multi-id
idsmap, but the picker draft still carries a single id under a heuristically-chosen namespace. Bridged, and fine for v1's dedicated sites — a full multi-namespace authoring UI is deliberately deferred until there's a concrete need.
Recently cleaned up (kept here as a record of what changed):
inferNativeTracker's dead branch — collapsed to a single fallback.- ETag conditional refetch — now wired (
If-None-Match+ 304 handling infetchRemoteRecipes). - AniList/Trakt rating/notes — moved out of
background.tsintolib/anilist/review.tsandlib/trakt/review.ts; the background handlers are thin dispatchers. - The "crosswalk" name collision — the quick-link slug cache is now
quickLinkSlugs(local:quicklink_slugs), distinct from thelib/animap/numbering crosswalk. - Letterboxd CSV export — moved out of the pure
sharedpackage intolib/trakt/. - The
proto/UI folder — renamed tokit/. - Transitional one-time migrations — removed (the repo has a single user; no installed base to migrate).
| You want to… | Start here |
|---|---|
| Change how a value is read off a page | packages/shared/src/extract.ts |
| Add/adjust a recipe field or transform | packages/shared/src/schema.ts + transforms.ts |
| Change how a site is matched | packages/shared/src/match.ts |
| Touch play/pause/stop timing | lib/scrobble/controller.ts |
| Touch iframe/SPA/late-metadata handling | lib/scrobble/session.ts |
| Add or change a tracker | lib/tracker/adapter.ts + a new lib/<tracker>/ folder |
| Debug Trakt resolution/scrobble | lib/trakt/client.ts, lib/trakt/auth.ts |
| Debug AniList writes | lib/anilist/client.ts, lib/anilist/util.ts |
| Change rating / notes behaviour | lib/trakt/review.ts, lib/anilist/review.ts |
| Debug anime double-tracking | lib/animap/ + recordDerivedTrackers in background.ts |
| Change the badge / picker / popup UI | lib/ui/kit/ (+ entrypoints/gallery/ to preview) |
| Change stored data or add a cache | lib/storage.ts |
| Add a message between parts | packages/extension/messaging.ts |