tautulli-remap

Fix broken Tautulli watch history after reorganizing your Plex libraries.
What it does
When you reorganize your Plex libraries (move files, re-add content, change folder structure), Plex assigns new internal IDs to your media. This breaks Tautulli's watch history: it can no longer link history entries to the right items. This tool automatically finds the correct new IDs and updates Tautulli's database, preserving your watch history and statistics.
For each stale entry, it finds the correct current rating key in Plex using a chain of strategies, most precise first:
- Episode-GUID resolution (TV shows): resolves a show through one of its watched episodes' stable Plex GUIDs, which map directly to the show's current key. Exact and collision-free, and it restores the show's full watch history (all seasons and episodes).
- GUID match: Plex's globally unique identifier; covers movies and shows whose history still carries a show-level GUID (e.g. the legacy
thetvdb agent).
- Title+year match (fallback): matches by title and release year when no GUID resolves.
- Title-only with media type guard (optional): last resort matching by title alone, restricted to the same media type to reduce false positives.
Why this design
- Three run modes:
SCHEDULE_INTERVAL set to a Go duration like 24h for a built-in timer, SCHEDULE_INTERVAL=off for resident-idle (stays healthy, awaits docker exec ... tautulli-remap trigger), or tautulli-remap trigger for a one-shot pass that reports its outcome via its exit code.
- Dry-run by default for safety: no changes are applied until you explicitly set
DRY_RUN=false, so you can always preview first.
- Matching strategies with increasing aggressiveness: starts with the exact ones (episode-GUID resolution for shows, GUID match for movies), falls back to title+year, and optionally title-only, giving you control over the risk/coverage tradeoff.
- Stdlib-first, minimal dependencies: pure Go on the standard library plus a first-party shared-lib set (
health, httpx, plexapi, scheduler, envx, slogx, runesafe) and golang.org/x/sync, minimizing supply-chain risk.
- Distroless and rootless: runs as
nonroot on gcr.io/distroless/static-debian13 with no shell or package manager.
Quick start
Images are published to both ghcr.io/cplieger/tautulli-remap and docker.io/cplieger/tautulli-remap; use whichever you prefer.
services:
tautulli-remap:
image: ghcr.io/cplieger/tautulli-remap:latest
container_name: tautulli-remap
restart: unless-stopped
environment:
TAUTULLI_URL: "http://tautulli:8181"
TAUTULLI_APIKEY: "your-tautulli-apikey" # required
PLEX_URL: "http://plex:32400"
PLEX_TOKEN: "your-plex-token" # required
SCHEDULE_INTERVAL: "24h" # Go duration; "off" = resident-idle
DRY_RUN: "true" # set to false to apply changes
Configuration reference
| Variable |
Description |
Default |
Required |
TAUTULLI_URL |
Tautulli instance URL (Docker DNS name or LAN IP) |
http://tautulli:8181 |
No |
TAUTULLI_APIKEY |
Tautulli API key (Settings → Web Interface → API Key) |
- |
Yes |
PLEX_URL |
Plex Media Server URL (Docker DNS name or LAN IP) |
http://plex:32400 |
No |
PLEX_TOKEN |
Plex authentication token (see Plex support article) |
- |
Yes |
SCHEDULE_INTERVAL |
Go duration between remap runs (e.g. 24h, 6h30m). off/disabled/0 = resident-idle (awaits external trigger via tautulli-remap trigger) |
off |
No |
FALLBACK_TITLE_YEAR |
Try title+year matching when GUID match fails |
true |
No |
FALLBACK_TITLE_ONLY |
Try title-only matching as last resort (risk of false matches) |
false |
No |
DRY_RUN |
Log what would change without applying; set to false to apply |
true |
No |
MAX_HISTORY_RECORDS |
Sanity cap on the Tautulli history size a run will process; runs abort above it. Raise it if your history is genuinely larger |
500000 |
No |
Subcommands
| Subcommand |
Description |
tautulli-remap health |
Checks the /tmp/.healthy marker file. Used as the Docker HEALTHCHECK. Exits 0 (healthy) or 1 (unhealthy). |
tautulli-remap trigger |
Executes a single remap pass immediately. Exits 0 on success, 1 on failure, 3 when interrupted by shutdown before completing (retryable). Designed for docker exec or Ofelia job-exec. |
One pass at a time
Remap passes are serialized by a cross-process lock (/tmp/.remap.lock): the
built-in timer, an external trigger, and a manual docker exec can never run
concurrent passes. A pass that finds another one already running refuses
immediately, before contacting Tautulli or Plex, and reports failure (a
trigger exits 1; a scheduled pass counts it toward the unhealthy threshold),
so a wedged pass surfaces through your scheduler's alerting instead of being
silently skipped. Since passes are idempotent, simply re-run once the active
pass finishes.
Recommended deployment with external scheduling
Use SCHEDULE_INTERVAL=off (resident-idle, one of the three run modes) with an external scheduler like Ofelia:
services:
tautulli-remap:
image: ghcr.io/cplieger/tautulli-remap:latest
environment:
SCHEDULE_INTERVAL: "off" # resident-idle, awaits trigger
DRY_RUN: "false"
# ... other env vars
labels:
ofelia.enabled: "true"
ofelia.job-exec.tautulli-remap.schedule: "0 0 3 * * *"
ofelia.job-exec.tautulli-remap.command: "/tautulli-remap trigger"
This keeps the container healthy (passing healthchecks) while delegating scheduling to Ofelia.
Healthcheck
The container includes a built-in Docker healthcheck via the /tautulli-remap health subcommand, which checks for a marker file at /tmp/.healthy. What that marker reflects depends on the run mode:
- Scheduled mode (
SCHEDULE_INTERVAL set to a duration): the main process refreshes /tmp/.healthy after each run and marks the container unhealthy after 3 consecutive failed runs (Tautulli or Plex APIs unreachable, returning errors, or the remap logic failing), recovering automatically on the next successful run (including runs where nothing needs remapping).
- Resident-idle mode (
SCHEDULE_INTERVAL=off): the marker reflects the resident process's liveness. Each tautulli-remap trigger run reports its own outcome via its exit code (0 success / 1 failure / 3 interrupted by shutdown before completing) for the external scheduler to act on; a failed trigger deliberately does not mark the long-lived container unhealthy.
Security
No network listener; the container connects outbound to Tautulli and Plex
only. Set DRY_RUN=true on first run to preview changes safely.
API tokens never reach the logs: the Plex token travels in the
X-Plex-Token header rather than the query string, and HTTP error messages
strip query parameters so the Tautulli API key cannot leak. Rating keys are
validated as numeric before URL interpolation, which blocks path traversal.
All HTTP calls use explicit timeouts and capped response bodies; transient
failures on reads are retried with bounded backoff, and mutating Tautulli
calls are never retried. The code uses no unsafe, reflect, or os/exec,
and its only file I/O is the health marker and run lock on /tmp.
The image runs as nonroot on a distroless base with no shell or package
manager. For a hardened deployment, add read_only: true, cap_drop: [ALL],
no-new-privileges:true, and a small tmpfs for /tmp (16 MB covers the
health marker and run lock).
Live scan results are on the repository's Security tab. One accepted
finding: semgrep reports a single informational hit, a false positive.
Dependencies
All dependencies are updated automatically via Renovate and pinned by digest or version for reproducibility.
Credits
This is an original tool that builds upon Tautulli.
Inspired by SwiftPanda16's Tautulli rating key update script.
Contributing
Issues and pull requests are welcome. Please open an issue first for
larger changes so the approach can be discussed before implementation.
Disclaimer
This project is built with care and follows security best practices, but it is intended for personal / self-hosted use. No guarantees of fitness for production environments. Use at your own risk.
This project was built with AI-assisted tooling using Claude, GPT, and Kiro. The human maintainer defines architecture, supervises implementation, and makes all final decisions.
License
GPL-3.0-or-later. See LICENSE.