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 (e.g. "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 exits 0/1.
- 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 small first-party shared-lib set (
health, httpx) and golang.org/x/sync, minimizing supply-chain risk.
- Distroless and rootless — runs as
nonroot on gcr.io/distroless/static 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
user: "1000:1000" # match your host user
environment:
TAUTULLI_URL: "http://tautulli:8181"
TAUTULLI_APIKEY: "your-tautulli-apikey"
PLEX_URL: "http://plex:32400"
PLEX_TOKEN: "your-plex-token"
SCHEDULE_INTERVAL: "24h" # Go duration; "off" = resident-idle
FALLBACK_TITLE_YEAR: "true"
FALLBACK_TITLE_ONLY: "false" # risk of false matches
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 |
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. Designed for docker exec or Ofelia job-exec. |
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) for the external scheduler to act on; a failed trigger deliberately does not mark the long-lived container unhealthy.
Security
No vulnerabilities found. All scans clean across all scanners.
No network listener; connects outbound to Tautulli and Plex
only. Set DRY_RUN=true on first run to preview changes safely.
API tokens are never logged. Stdlib-first, with a minimal first-party
dependency set.
Runs as nonroot on a distroless base image with no shell,
under the hardened compose profile
(read_only: true, cap_drop: [ALL],
no-new-privileges:true, 16 MB tmpfs for /tmp).
Details for advanced users: All HTTP clients use explicit
timeouts (2 min client, 30s per direct request, 60s for the Plex
library fetch). Transient failures on Tautulli and Plex reads are
retried with bounded backoff (each attempt within the 2-min client
timeout); mutating Tautulli calls are never retried.
Response bodies capped
via io.LimitReader (30 MB Tautulli, 40 MB Plex library / 10 MB sections). Rating keys
validated as numeric before URL interpolation (prevents path
traversal). Plex token sent via X-Plex-Token header, not query
string. HTTP error messages sanitized to strip query parameters
(prevents API key leakage in logs). No unsafe, reflect,
os/exec, or file I/O beyond the health marker.
Dependencies
All dependencies are updated automatically via Renovate and pinned by digest or version for reproducibility.
| Dependency |
Source |
| golang |
Go |
| gcr.io/distroless/static |
Distroless |
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 Opus and Kiro. The human maintainer defines architecture, supervises implementation, and makes all final decisions.
License
This project is licensed under the GNU General Public License v3.0.