# ADR 021: Trakt drives the watchlist

- HTML version: https://robbiepalmer.me/projects/homelab/adrs/021-trakt-watchlist
- Project: Home Lab (https://robbiepalmer.me/projects/homelab.md)
- Status: Accepted
- Date: 2026-08-23

# Context

Adding media through Sonarr and Radarr's web UIs works, but the natural place
to decide what to watch next is a phone, on a couch, in a recommendations
feed — not an administration panel. A social tracking service turns "that
looked interesting" into a library entry: tap watchlist, the stack fetches
it. Watched-state should flow the other way too, so the tracker's
recommendations improve off what Jellyfin actually plays.

The family's taste lives on Letterboxd, which publishes no open API (its
partner program is closed), so it cannot be the automation source. Trakt is
the API-friendly alternative for both movies and shows.

# Decision

Native integrations only, one OAuth grant each:

* **Watchlist as acquisition queue**: Sonarr and Radarr each get a native
  **Trakt User** import list over the account's watchlist — authorized once
  via their own "Authenticate with Trakt" buttons, bound to the TRaSH
  profiles from ADR 019 (`WEB-1080p` + `/media/TV`, `HD Bluray + WEB` +
  `/media/Movies`), monitoring everything and searching on add. A tap enters
  the pipeline immediately; whether it ends in a grab still depends on
  release availability and profile scoring.
* **Watched state**: the official [Jellyfin Trakt plugin][trakt-plugin]
  (pinned v30, which fixes a `MissingMethodException` under Jellyfin 10.11)
  is installed by the Jellyfin bootstrap script and scrobbles plays to Trakt.

Two setup traps cost an afternoon; they are recorded here so the next
bootstrap doesn't repeat them:

1. **Slug spelling matters silently.** The import list fetches
   `/users/{username}/watchlist/...`, and Trakt answers `200 []` for a
   misspelled username rather than 404. Trakt auto-generated this account as
   `robbie_palmer_1cefe3`, but its canonical slug is the dashed
   `robbie-palmer-1cefe3` (visible in any profile URL); the underscore
   variant returned an empty watchlist that looked exactly like a broken
   connection. Use the slug from the OAuth'd profile URL, character for
   character.
2. **Failed syncs lock for 12 hours.** Each list records a last-sync time
   even when the fetch returns nothing, and Servarr skips re-fetching within
   a minimum refresh interval — so clicking Refresh after fixing a problem
   does nothing. When debugging, delete and recreate the list (creation runs
   an unlocked test fetch), or wait out the interval.

[trakt-plugin]: https://github.com/jellyfin/jellyfin-plugin-trakt

# Alternatives

## Letterboxd bridge

* **Pros**: meets people where their taste already lives.
* **Cons**: no open API; only per-user RSS feeds, which carry diary entries
  rather than a curated forward-looking list. Would need scraping or feed
  parsing plus a custom add-to-Radarr bridge.
* **Decision**: Deferred until there is a concrete username worth bridging;
  Trakt covers the need now.

## Custom headless sync service

A small script doing Trakt OAuth itself, polling the watchlist hourly, and
pushing adds into the \*arr APIs — built as `trakt_sync.py` when the native
path appeared blocked by free-tier limits (one community-app connection at a
time, app registration VIP-only).

* **Pros**: one connection instead of three; fully scriptable.
* **Cons**: \~200 lines of token-refresh, diffing, and add-logic duplicating
  what upstream already maintains — plus the same silent-slug trap would
  have lived in bespoke code with no Test button to catch it.
* **Decision**: Rejected once the actual failure turned out to be the slug,
  not the tier: with the correct dash-spelled username the native lists
  worked immediately, and every line of bridge code became pure liability.

# Consequences

### Pros

* Phone → Trakt watchlist → monitored series/movie → search → Jellyfin is
  fully wired with zero custom code; verified live end-to-end (43 movies and
  three shows imported on the first healthy sync).
* Upstream absorbs Trakt API changes across all three touchpoints; the hub
  holds no tokens or credentials beyond the standard \*arr configs.
* The debugging lessons above are now written down where bootstrap
  automation will meet them.

### Cons

* Three OAuth grants exist outside version control (plugin user, one per
  \*arr app). A fresh bootstrap needs them redone by hand; the click-paths
  live in the README caveats.
* List refresh runs at a minimum interval of 12 hours per list; same-day
  gratification means pressing Refresh — after checking you haven't just
  synced.
* Removing something from the watchlist does not remove or unmonitor it in
  the \*arr apps; the queue drains forward only.

---

Markdown index of this site: https://robbiepalmer.me/llms.txt
