# JellyPlex-Watched: keeping watched status in step across three media servers

> A Python tool that synchronises watched history between local Jellyfin, Plex and Emby servers by matching on file names and provider ids, with a configuration system that layers seven sources from process environment down to field defaults. The matching is approximate, and the README says so, and the Plex support is missing one feature the other two have.

**luigi311/JellyPlex-Watched** — Sync watched status between jellyfin, plex and emby locally

- Repository: https://github.com/luigi311/JellyPlex-Watched
- Stars: 1,058 · Forks: 46
- Language: Python
- License: GPL-3.0
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/luigi311-jellyplex-watched

## What the tool does and how it matches

JellyPlex-Watched keeps the watched history of all your users in sync between Jellyfin, Plex and Emby servers running locally. The description in the repository is one line: sync watched status between jellyfin, plex and emby locally.

The matching mechanism is the part that determines how well it works. It uses file names and provider ids to find the correct episode or movie between the two servers, and the README is careful to qualify that, saying it is not perfect but it works for most cases. You can use it for as many servers as you want by entering multiple options in the configuration.

Two matching strategies are offered for each of the three servers, matching via filenames and matching via provider ids. Provider ids are the external identifiers a metadata provider assigns to a film or episode, so they survive a rename of the local file, which filenames do not.

Beyond matching, the feature lists for the three servers are nearly identical. Each one supports mapping usernames, using a single login, one way or multi way sync, syncing watched status and syncing in progress, which is the playback position of a partly watched item.

The one gap is in the Plex list, where Sync view dates is unchecked, while the Jellyfin and Emby lists have it checked. So a Plex server in the set will receive watched flags but not the dates on which something was watched, which is visible in any client that shows a last-watched date.

The primary language is Python, the licence is GPL-3.0, the repository is not archived with the last push on 2026-10-13, and the recent releases are v8.5.3 from 2026-05-28, v8.5.2 from 2026-05-19 and v8.5.1 from 2026-03-01.

## Seven configuration sources, in priority order

The configuration section is the most detailed part of the README, and it documents a layering model that is unusual enough to be worth reading carefully.

The normal entry point is `load_settings()`. `AppSettings(...)` is the plain data model and validates only the values supplied directly to it, so you use it when you do not want environment or YAML sources read at all.

For source-backed loading, values are applied from highest to lowest priority:

1. Explicit field values supplied to the internal source loader.
2. `JPW_`-prefixed process environment variables.
3. `JPW_`-prefixed values in the selected dotenv file.
4. Legacy process environment variables.
5. Legacy values in the selected dotenv file.
6. The selected YAML file.
7. Field defaults.

Two rules govern what happens when a value is empty. An absent, empty or valueless new-style entry inherits from the next source. But to replace a lower-priority list with an empty one you have to write JSON `[]`, and a full JSON server list replaces the lower-priority server definitions as a whole.

The legacy path has different rules, and this is where the sharp edges are. Legacy boolean values accept `1/0`, `true/false`, `yes/no`, `on/off`, `t/f` and `y/n`, case-insensitively, with surrounding whitespace ignored, and any other non-empty value is rejected. Malformed new-style JSON is rejected outright. An empty or valueless legacy process entry still takes precedence over the file entry and is then treated as unset, so it does not silently fall back to the file.

`ENV_FILE` and `YAML_FILE` are path selectors rather than `JPW_` fields, and they are set in the process environment or passed to `load_settings()` as `env_file=` and `yaml_file=`.

## The JPW_ prefix and the legacy dotenv format

New-style environment variables use the `JPW_` prefix and Pydantic's JSON syntax for lists and objects:

```dotenv
JPW_DRYRUN=false
JPW_WHITELIST_USERS='["alice", "bob"]'
JPW_SERVER_TOKENS='{"plex-main":"replacement-token"}'
```

The same variables can be placed in the selected dotenv file. The unprefixed legacy format remains supported, and the README gives a labelled compatibility example:

```dotenv
# Legacy compatibility example
PLEX_BASEURL=http://plex.example.com:32400
PLEX_TOKEN=replace-with-a-plex-token
JELLYFIN_BASEURL=http://jellyfin.example.com:8096
JELLYFIN_TOKEN=replace-with-a-jellyfin-token
SYNC_FROM_PLEX_TO_JELLYFIN=true
```

The YAML template is the recommended starting point for new installations, and legacy dotenv values remain supported for existing deployments and for migration.

The compatibility notes carry a rule that will change behaviour if you are not aware of it. When a legacy variable exists in both the process environment and the selected dotenv file, the process value wins. The source decision is made before the legacy aliases are translated, so a process `LOGFILE` or `LOG_FILE` value wins over either spelling in the file, and the same applies to `MARKFILE` versus `MARK_FILE` and `DEBUG` versus `DEBUG_LEVEL`.

The README is explicit that prefixing new variables and changing the legacy process and file precedence do not deprecate or remove the legacy runtime path.

The translation also has a consequence for lists. `WHITELIST_USERS=alice,bob` remains valid legacy input with its comma-separated syntax, while the new equivalent is `JPW_WHITELIST_USERS='["alice", "bob"]'`. And YAML field names with no legacy environment spelling must use the prefixed form, such as `JPW_BLACKLIST_LIBRARIES='["Movies"]'`.

## Credentials in YAML, and the named token override

The credential story has three parts, and the first is the warning.

YAML credentials are plaintext, so the README says to keep `config.yaml` restricted to the application user and never commit it. Credentials may be stored in YAML or supplied through the supported environment variables, with the environment values overriding the matching YAML values. Docker secret files are not loaded automatically, so you have to inject the secret values through the environment variables or create a protected YAML file.

The second part is `JPW_SERVER_TOKENS`, a JSON object whose keys are exact configured server names and whose values are non-empty replacement credentials. It patches the effective server definitions in place, preserving each server's URL, sync directions, mappings and other settings.

The security behaviour of that override is specified in detail. A Plex token override selects token authentication and clears its username, password and server name fields. Unknown server names, malformed maps, empty credentials and a map that matches no configured server are all rejected without exposing the credential in the error. The highest-priority map replaces lower-priority maps as a whole before its entries are applied to the effective server list.

The third part is the legacy path. A legacy `PLEX_TOKEN` without a `PLEX_BASEURL` is a token-only override only when exactly one Plex server is already configured by a higher-priority source, which is the guard that stops a stray environment variable from silently redirecting a token to the wrong server.

For a tool that holds administrator tokens for three media servers, that level of care about where a credential ends up is the part that matters most.

## The legacy mapping expansion, and the collision warning

One compatibility behaviour is worth singling out because it can change what gets synchronised without any error being raised.

Legacy `USER_MAPPING` and `LIBRARY_MAPPING` entries are expanded onto every configured server, because the old format does not identify the owning server. That is a direct consequence of the old format's shape, and the consequence is that a mapping you meant for one server applies to all of them.

Generated migration entries carry a `legacy: true` marker. If both names from one pair are discovered on a server, that mapping is skipped and a warning identifies the configuration to review, so a duplicate does not silently produce two mappings for the same pair.

The README's advice is to replace the entry with server-scoped YAML aliases before syncing those accounts or libraries.

This is a real migration hazard in a tool that syncs watch history between accounts, because a mapping applied to the wrong server can mean that one user's history is written against another user's account on a different server. The warning is the mitigation, and it only appears in the duplicate case.

Everything else about the tool's behaviour is driven by the configuration: which servers exist, which way the sync runs, which users are whitelisted or blacklisted, which libraries are excluded, and the dry run switch, which is `JPW_DRYRUN` in the new-style variables.

## The Python stack and the container images

The `pyproject.toml` describes the package as `jellyplex-watched` at version 8.5.3, requiring Python 3.12 or newer.

The runtime dependencies are seven modules, and each one maps to a specific need. `plexapi` at 4.18.1 or newer is the Plex client library. `pydantic` and `pydantic-settings` with the YAML extra handle the settings model and the layered loading. `python-dotenv` handles the dotenv file, `requests` is the HTTP client for Jellyfin and Emby, `packaging` handles version comparison, and `loguru` is the logger.

The development group is a good sign of how the project is maintained: `ruff` for linting and formatting, `pytest` for the tests, `ty` as a type checker, `types-requests` for the stubs, and `vulture`, which finds dead code.

The repository layout is small and conventional. There is a `main.py` at the root, a `src/` directory with the implementation, a `test/` directory, a `sample.config.yaml` as the primary configuration template, an `entrypoint.sh`, and two Dockerfiles, `Dockerfile.alpine` and `Dockerfile.slim`.

There is a `uv.lock` file and a `.python-version` file, so the project pins its toolchain in two ways, and the `.vscode/` directory and the `.github/` directory round out the top level.

The README header carries a Codacy badge, which is the one quality signal in the document.

## Where JellyPlex-Watched is the wrong choice

The first wrong case is a Plex user who needs watch dates. The feature list leaves Sync view dates unchecked for Plex while both Jellyfin and Emby have it, so a mixed setup will have the flags synchronised and the dates missing on the Plex side. That is not a bug to report, it is a stated limitation, and it matters for anyone who uses a client's recently-watched list to decide what to watch next.

The second is a setup where matching by file name or provider id is unreliable. The README says the matching is not perfect but works for most cases, which is honest, and the failure case is specific: a library where the same film exists under different local names, or where one server has a file the other does not. With multiple servers and heavy renaming, the tool can mark the wrong item as watched, and the fix is manual.

The third is anyone wanting the tool to run against remote servers. The description says locally, and the configuration talks about server URLs, but the design assumes the servers are ones you control and can reach directly.

The fourth is a team that wants a clean configuration. The seven-level precedence, the two variable naming schemes, the legacy alias pairs and the legacy mapping expansion are a lot of surface to reason about, and the README is essentially a reference for it. A new install that follows the YAML template is simple, but a project that grew up with the legacy dotenv file is carrying a compatibility burden that only the author fully understands.

The fifth is a deployment that wants the credentials in Docker secrets. The README says Docker secret files are not loaded automatically, so you have to inject through the environment or hand-build a protected YAML file.

For a home setup with two servers and a handful of users, though, this is exactly the tool that removes a real annoyance, and the fact that the author documents the limits as precisely as the features is the reason to trust it.

## Conclusion

JellyPlex-Watched suits a household or small group running two or three of the supported media servers locally and wanting one person's watch history to follow them between them, and who are willing to accept approximate matching. It does not suit a Plex user who needs view dates synchronised, because that box is unchecked in the feature list while Jellyfin and Emby have it, and it does not suit a setup where server libraries have been reorganised, since the matching relies on file names and provider ids. Before adopting it, copy `sample.config.yaml` to `config.yaml` and restrict it, because the credentials in it are plaintext, and read the configuration priority list before you add environment variables, because the process environment beats the dotenv file for legacy variables in a way that will otherwise surprise you.

## FAQ

### How does JellyPlex-Watched match items across servers?

It uses file names and provider ids to find the correct episode or movie between the servers. The README says this is not perfect but works for most cases, and matching is available both by filename and by provider id for each of the three server types.

### Which features are not supported for Plex?

In the Plex feature list, Sync view dates is the only unchecked item. Jellyfin and Emby both have it checked, as do the other listed capabilities for all three servers.

### Which configuration source wins when several are set?

Values are applied from highest to lowest priority: explicit field values, prefixed process environment variables, prefixed dotenv file values, legacy process environment variables, legacy dotenv file values, the selected YAML file, and finally the field defaults. A legacy variable present in both the process environment and the dotenv file resolves to the process value.

### Where should credentials be stored?

They may be in the YAML file or in the supported environment variables, with the environment overriding YAML, but the README warns that YAML credentials are plaintext, so `config.yaml` must be restricted to the application user and never committed. Docker secret files are not loaded automatically.

### What are the runtime dependencies of JellyPlex-Watched?

Seven modules: loguru, packaging, plexapi, pydantic, pydantic-settings with the YAML extra, python-dotenv and requests. The package requires Python 3.12 or newer, and the development group adds ruff, pytest, ty, types-requests and vulture.

## Sources

- [Issues](https://github.com/luigi311/JellyPlex-Watched/issues)
- [License: GPL-3.0](https://github.com/luigi311/JellyPlex-Watched/blob/main/LICENSE)
- [luigi311/JellyPlex-Watched on GitHub](https://github.com/luigi311/JellyPlex-Watched)
- [README](https://github.com/luigi311/JellyPlex-Watched/blob/main/README.md)
- [Releases](https://github.com/luigi311/JellyPlex-Watched/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/luigi311-jellyplex-watched
