Cyclically reads the watch state from a Plex media server and writes it into Jellyfin .nfo
files, so that everything you watch in Plex shows up as watched in Jellyfin. Existing .nfo files are
left untouched except for the watch fields (watched, playcount, lastplayed); missing .nfo
files are generated from the Plex metadata.
Built with C# / .NET 10, runs as a small container, and ships a web dashboard for status and live logs.
Inspired by
2dee11/PlexXMLtoJellyfinNFO– rewritten in C#, running continuously instead of as a one-off, and supporting movies and series.
- Polling based – no Plex Pass required; also picks up items manually marked as watched.
- Incremental history poll (fast) plus a periodic full reconcile (catch-up).
- Writes movie, episode, season and series NFO files (aggregated watch state for seasons/series).
- Single user (the Plex server owner), matching Jellyfin's single-user NFO user-data import.
- Updates existing NFO files in place – only the watch fields are changed.
- Web dashboard (Blazor Server) with status cards and live logs, optionally protected by a token.
docker run -d \
--name plextojellyfinsync \
-e PLEXSYNC__Plex__BaseUrl="http://plex:32400" \
-e PLEXSYNC__Plex__Token="<your-plex-token>" \
-e PLEXSYNC__PathMappings__0__Plex="/data/Movies" \
-e PLEXSYNC__PathMappings__0__Local="/media/Movies" \
-e PLEXSYNC__PathMappings__1__Plex="/data/Shows" \
-e PLEXSYNC__PathMappings__1__Local="/media/Shows" \
-v /path/to/media:/media \
-v /path/to/config:/config \
-p 8080:8080 \
networlddev/plextojellyfinsync:latest-
Image:
networlddev/plextojellyfinsyncon Docker Hub –latestplus a tag per released version (for example1.2.2). -
Dashboard:
http://<host>:8080/– logs at/logs, health at/health. -
The media volume must be writable so NFO files can be written next to the media.
-
The
/configvolume storesstate.json(the incremental high-water mark). -
The container runs as the base image's non-root user (not
root). Make sure the host directories mounted to/mediaand/configare writable by that user's UID – check it withdocker run --rm networlddev/plextojellyfinsync:latest id. -
Prefer the
PLEXSYNC__environment variables above for configuration. If you bind-mount a customappsettings.jsonover/app/appsettings.jsoninstead, that file must be readable by the same non-root UID – a bind mount keeps the host file's ownership/permissions as-is, so the image's own file permissions do not apply to it, and a600/root-owned file causes a startup crash (UnauthorizedAccessException: Access to the path '/app/appsettings.json' is denied).
Plex reports file paths as the Plex server sees them (for example /data/Movies/...). This tool runs
in its own container and sees the same files under its own mount point (for example /media/Movies/...).
A path mapping rewrites the Plex path prefix into the local prefix; the rest of the path is kept
unchanged. Configure one mapping per library root so the tool can locate each file and write the .nfo
next to it.
The path that matters is the one Plex reports, not the folder you see in Plex or Jellyfin. Even when Plex and Jellyfin point at the same physical folder, the path string Plex returns through its API can differ from the path this container has mounted. Compare the path Plex reports against the right-hand side of this container's volume mounts.
A matching mapping is mandatory. If no mapping prefix matches a file path, that item is skipped
(logged as No path mapping for <path>, skipping) – the path is never passed through unchanged. So even
when this container mounts the media under the exact path Plex reports, you still need an identity
mapping where Plex and Local are equal:
"PathMappings": [
{ "Plex": "/data/Movies", "Local": "/data/Movies" },
{ "Plex": "/data/Shows", "Local": "/data/Shows" }
]To find the right values: the Plex prefix is what Plex shows under Settings → Library → Manage
Folders (and exactly what appears in the No path mapping for … log line if a mapping is missing); the
Local prefix is the right-hand side of this container's volume mounts (-v /host/movies:/media/Movies
→ Local = /media/Movies). When several mappings match, the longest matching Plex prefix wins.
All settings can be provided via appsettings.json or environment variables (prefix PLEXSYNC__,
double underscore for nesting).
| Key | Env var | Default | Description |
|---|---|---|---|
Plex:BaseUrl |
PLEXSYNC__Plex__BaseUrl |
– | Plex base URL — required, an absolute http/https URL (e.g. http://plex:32400); the container does not start without it |
Plex:Token |
PLEXSYNC__Plex__Token |
– | Plex auth token (X-Plex-Token); required unless Plex allows unauthenticated access from this container's network |
Plex:OwnerAccountId |
PLEXSYNC__Plex__OwnerAccountId |
auto | Owner account id (auto-detected; ≥ 1) |
Plex:Libraries |
PLEXSYNC__Plex__Libraries__0 |
all | Restrict to library section keys |
Sync:PollIntervalSeconds |
PLEXSYNC__Sync__PollIntervalSeconds |
60 |
Incremental poll interval (5–86400) |
Sync:FullReconcileIntervalHours |
PLEXSYNC__Sync__FullReconcileIntervalHours |
24 |
Full reconcile interval (1–8760) |
Sync:CreateMissingNfo |
PLEXSYNC__Sync__CreateMissingNfo |
true |
Create complete NFO if missing |
Sync:WriteSeriesSeasonAggregates |
PLEXSYNC__Sync__WriteSeriesSeasonAggregates |
true |
Write season/series aggregates |
Sync:EpisodeReconcileParallelism |
PLEXSYNC__Sync__EpisodeReconcileParallelism |
4 |
Concurrent episode writes per series reconcile (minimum 1) |
Sync:LibraryReconcileParallelism |
PLEXSYNC__Sync__LibraryReconcileParallelism |
2 |
Concurrent libraries reconciled per full reconcile run (minimum 1) |
PathMappings:N:Plex / :Local |
PLEXSYNC__PathMappings__N__Plex / __Local |
– | Path prefix mapping |
Nfo:DateTimeFormat |
PLEXSYNC__Nfo__DateTimeFormat |
yyyy-MM-dd HH:mm:ss |
lastplayed format |
Nfo:MovieFilenameStrategy |
PLEXSYNC__Nfo__MovieFilenameStrategy |
PreferExistingMovieNfo |
Movie NFO naming |
State:Directory |
PLEXSYNC__State__Directory |
/config |
Where state.json is stored (must not be empty) |
Dashboard:Enabled |
PLEXSYNC__Dashboard__Enabled |
true |
Enable the web dashboard |
Dashboard:Token |
PLEXSYNC__Dashboard__Token |
– | Optional access token |
Dashboard:LogBufferSize |
PLEXSYNC__Dashboard__LogBufferSize |
1000 |
In-memory log entries (minimum 1) |
Invalid or missing required settings stop the application at startup, before the sync worker or the dashboard
start, with an error naming the setting (visible in docker logs), instead of failing on every sync.
Jellyfin can only import NFO user-data (watched / playcount / lastplayed) for one user. In your Jellyfin library settings enable the NFO metadata reader and set the user whose watch state should be imported to the same user that corresponds to the Plex owner.
dotnet restore PlexToJellyfinSync.slnx
reihitsu-format ./ # format (dotnet tool install -g Reihitsu.Cli)
dotnet build PlexToJellyfinSync.slnx -c Release --no-restore
dotnet test PlexToJellyfinSync.slnx -c Release --no-buildSee CONTRIBUTING.md for the full contribution workflow,
ARCHITECTURE.md for how the sync pipeline, dashboard and deployment are put
together, and UNIT_TESTS.md for test conventions (unit tests are mandatory for
new code).
MIT © 2026 Lars Laskowski