Profile
Back to NewsBack
GitHub Trending 14 min
Reader Mode
luigi311/JellyPlex-Watched: Sync watched status between jellyfin, plex and emby locally

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

9 hours ago

JellyPlex-Watched

Codacy Badge</a>

Sync watched between jellyfin, plex and emby locally

Description

Keep in sync all your users watched history between jellyfin, plex and emby servers locally. This uses file names and provider ids to find the correct episode/movie between the two. This is not perfect but it works for most cases. You can use this for as many servers as you want by entering multiple options in the config.

Features

Plex

  • \[x] Match via filenames
  • \[x] Match via provider ids
  • \[x] Map usernames
  • \[x] Use single login
  • \[x] One way/multi way sync
  • \[x] Sync watched
  • \[x] Sync in progress
  • \[ ] Sync view dates

Jellyfin

  • \[x] Match via filenames
  • \[x] Match via provider ids
  • \[x] Map usernames
  • \[x] Use single login
  • \[x] One way/multi way sync
  • \[x] Sync watched
  • \[x] Sync in progress
  • \[x] Sync view dates

Emby

  • \[x] Match via filenames
  • \[x] Match via provider ids
  • \[x] Map usernames
  • \[x] Use single login
  • \[x] One way/multi way sync
  • \[x] Sync watched
  • \[x] Sync in progress
  • \[x] Sync view dates

Configuration

Use sample.config.yaml as the primary configuration template. Copy it to config.yaml, replace the placeholder server URLs and credentials, and remove or edit server entries that do not apply to your setup.

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

Legacy dotenv compatibility

Existing installations may continue using the unprefixed legacy .env format. For a new legacy file, use a clearly labeled compatibility configuration such as:

# 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; legacy dotenv values remain supported for existing deployments and migration.

When a legacy .env exists and no YAML file is present, a starter YAML file is generated without modifying .env. Generation is optional: if it cannot be written, the application continues loading the legacy configuration.

The generated YAML remains a lower-priority configuration source. Removed or blank legacy filters and mappings can still use their saved YAML values. A saved server list can also remain active when all legacy definitions for that server type are removed. Keeping PLEX_BASEURL while blanking or removing PLEX_TOKEN, without complete account credentials, instead leaves an incomplete legacy entry that fails validation; it does not inherit the YAML token.

If you continue managing only .env, delete the generated YAML after removing filters, mappings, or complete server definitions and restart to regenerate it from the current .env. Preserve any manual YAML-only changes before doing so. Process-environment-only deployments without a dotenv file do not auto-generate YAML.

Source-backed environment overrides

The normal entry point is load_settings(). New-style environment variables use the JPW_ prefix and Pydantic's JSON syntax for lists and objects:

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. Existing unprefixed legacy variables, including comma-separated lists, remain supported for current deployments and migration.

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.
AppSettings(...) is the plain data model and validates only values supplied directly to it. Use load_settings() when environment or YAML sources should be read. ENV_FILE and YAML_FILE are path selectors, not JPW_ fields; set them in the process environment or pass env_file= and yaml_file= to load_settings().

An absent, empty, or valueless new-style entry inherits from the next source. Use JSON [] when an empty list should replace a lower-priority list; a full JSON server list replaces the lower-priority server definitions as a whole. Malformed new-style JSON is rejected. Legacy empty and valueless entries are unset after process/file precedence is resolved and do not fall back to the file. Legacy boolean values accept 1/0, true/false, yes/no, on/off, t/f, and y/n case-insensitively with surrounding whitespace ignored; other non-empty values are rejected. A prefixed dotenv value therefore takes precedence over a legacy process value.

Compatibility notes

New-style environment variables use the JPW_ prefix. Keep the original unprefixed spelling for legacy deployments, including its comma-separated syntax. For example, WHITELIST_USERS=alice,bob remains valid legacy input, while the new equivalent is JPW_WHITELIST_USERS='["alice", "bob"]'. YAML field names without a legacy environment spelling must use the prefixed form, such as JPW_BLACKLIST_LIBRARIES='["Movies"]'.

When a legacy variable exists in both the process environment and the selected dotenv file, the process value now wins. This source decision is made before legacy aliases are translated, so a process LOGFILE or LOG_FILE value wins over either spelling in the file; the existing alias preference still applies within the winning source. The same rule applies to MARKFILE/MARK_FILE and DEBUG/DEBUG_LEVEL.

An empty or valueless legacy process entry still takes precedence over the file entry and is then treated as unset; it does not silently fall back to the file. Legacy loading remains supported. Prefixing new variables and changing legacy process/file precedence do not deprecate or remove the legacy runtime path.

Legacy SYNC_FROM__TO_ flags default to enabled independently. Setting one flag leaves omitted, empty, and valueless flags enabled; explicitly set each direction you want to disable to false. YAML sync_to lists remain explicit: an omitted or empty list enables no outgoing directions for that server.

Legacy USER_MAPPING and LIBRARY_MAPPING entries are expanded onto every configured server because the old format does not identify the owning server. Generated migration entries carry legacy: true; if both names from one pair are discovered on a server, that mapping is skipped and a warning identifies the configuration to review. Replace the entry with server-scoped YAML aliases before syncing those accounts or libraries.

Legacy decimal SLEEP_DURATION values such as 30.0 or 0.5 retain their interval in seconds. YAML sleep_duration and JPW_SLEEP_DURATION also accept finite, non-negative fractional seconds; invalid durations fail validation. Malformed legacy REQUEST_TIMEOUT and MAX_THREADS values are rejected instead of silently using defaults.

Dry-run now defaults to true for all installations, including a legacy file without DRYRUN. Set DRYRUN=false or dryrun: false to apply watched updates.

DEBUG is a supported boolean alias added with the settings migration. If your shell uses it for another tool (for example DEBUG=app:*), unset it or set process DEBUG_LEVEL=INFO explicitly. A process DEBUG takes precedence over a dotenv DEBUG_LEVEL, and invalid boolean values fail configuration loading rather than being ignored.

Legacy mapping pairs that differ only by case produce one alias per server.

Credentials and named overrides

Plex token authentication requires a baseurl. Account authentication uses the username, password, and servername triple and discovers the server through the Plex account, so baseurl may be omitted. Legacy account variables PLEX_USERNAME, PLEX_PASSWORD, and PLEX_SERVERNAME work without PLEX_BASEURL as well.

Legacy URL/token lists and username/password/server-name lists are independent. If both authentication modes are configured, both sets of Plex connections are created; no padding is required between the two modes. With multiple servers, generated names are numbered plex-1, plex-2, and so on. Review generated names before referring to them in YAML mappings, rules, or token overrides.

JPW_SERVER_TOKENS is a JSON object whose keys are exact configured server names and whose values are non-empty replacement credentials. It patches the effective YAML or environment server definitions in place, preserving each server's URL, sync directions, mappings, and other settings. 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 rejected without exposing the credential in the error.

The highest-priority JPW_SERVER_TOKENS map replaces lower-priority maps as a whole before its entries are applied to the effective server list.

A legacy PLEX_TOKEN without PLEX_BASEURL is a token-only override only when exactly one Plex server is already configured by a higher-priority source. A complete legacy base URL/token configuration retains its indexed server-list behavior. If no Plex server is configured, remove a stale PLEX_TOKEN; it now reports a configuration error instead of being ignored.

Rules and filters

Each server's sync_to list describes the direction “this server pushes to those servers.” It enables sync for users and libraries that pass the normal filters. Bidirectional sync requires both directions.

Explicit rules are exceptions for their exact from → to direction:

  • A matching user_sync_rules entry allows that user despite user whitelists
and blacklists. Library restrictions apply unless a library rule overrides them.
  • A matching library_sync_rules entry allows that library despite
library-name and library-type filters, including the mapped destination's type. User restrictions apply unless a user rule overrides them.
  • Rules add exceptions to an existing sync_to direction; unmatched users
and libraries keep the normal server defaults and filters.

For a direction absent from sync_to, either kind of rule can enable sync:

| Rules for that direction | Eligible history | | --- | --- | | User rules only | Matching users, with normally allowed libraries | | Library rules only | Matching libraries, with normally allowed users | | Both | Both permissions are added; matching exceptions can override both scopes' filters | | Neither | No sync |

For example, an Alice user rule and a Movies library rule for the same direction allow Alice's normally permitted libraries plus Movies, even if Movies is otherwise filtered out. Other normally permitted users also sync Movies. Their other libraries remain excluded unless another rule or sync_to enables them. Adding a library rule never narrows the user rule, or vice versa.

Rules for other directions do not restrict this one. A wildcard rule uses users: [""] or libraries: [""] as its only entry. It also covers unmapped runtime identities and overrides the corresponding filters for all matches.

Without a matching exception, a non-empty whitelist takes precedence over its blacklist. Otherwise blacklist matches are rejected. User and library name filters resolve the source server's canonical identity and all its aliases, so a filter naming one mapped alias applies in both directions. Without a matching library exception, configured type filters require both endpoint types to be known and allowed before writing history. Mappings still resolve actual accounts and libraries; unmapped identities use same-name matching only when the target name is not owned by another mapping. Rules do not create missing accounts or libraries, or enable unsupported media types.

For example, configure Plex with sync_to: [jellyfin-main, emby-main] and the other two servers with sync_to: []. These rules allow luigi311 to send history back to Plex and onward to the other server, while other users retain the normal Plex-outbound behavior:

user_sync_rules:
  - users: [luigi311]
    from: jellyfin-main
    to: plex-main
  - users: [luigi311]
    from: emby-main
    to: plex-main

No library rules are needed for that example; normal library filters apply. This changes earlier behavior: rules now override their own scope's filters, and either rule type can enable a direction without a companion rule. To exclude an identity covered by an explicit rule, remove or narrow that rule as well as configuring the normal filters.

Each pass compares the fetched histories from all servers before applying any updates. For each destination user and library, it selects the best permitted movie or episode state using the existing completion, playback-position, and viewing-date rules. Competing sources produce one winning update per matching item; ties use a stable server/user/library name order. Matching retains alternate provider IDs and filenames from permitted histories, even when their watch state does not win, so a destination can match any known identifier for the item. Missing or failed fetch scopes are excluded, while a successfully fetched empty history can receive updates.

Comparisons use the same snapshot in dry-run and normal operation and follow permitted paths across servers. With a chain such as A → B → C, A's history is considered for both B and C in the same pass, even when B's fetched history is empty. Every hop must pass its direction, user, and library rules; missing or failed scopes cannot relay history. Cycles are visited once per original source. For example, if A has watched 20 minutes of Cars, B has not watched it, and C has completed it, both A and B receive the completed state in that pass when C has a permitted path to each. This requires no intermediate successful write.

Applying configuration changes

Settings are loaded once at startup and their lookup indexes are cached for that process. Restart the application after changing the YAML, dotenv file, or environment values; the regular loop does not live-reload configuration.

Installation

Baremetal

  • Copy the YAML template and edit it for your servers:
cp sample.config.yaml config.yaml
  • Run
uv run main.py
ENV_FILE="Test.env" uv run main.py

The ENV_FILE example is for an existing legacy dotenv configuration. Use YAML_FILE="other-config.yaml" uv run main.py to select a different YAML file.

Docker

  • Build docker image
docker build -f Dockerfile.alpine -t jellyplex-watched .
  # Debian-based slim image:
  docker build -f Dockerfile.slim -t jellyplex-watched:slim .
  • or use pre-built image
docker pull luigi311/jellyplex-watched:latest

When starting directly as a non-root user with --user or Compose user:, set both LOG_FILE and MARK_FILE to writable paths, such as files in a mounted runtime directory. Their defaults under /app are not writable by that user. For a read-only container filesystem, provide a writable volume or tmpfs for both paths.

With a YAML configuration

restrict it to your application user, and mount it into the container. The container runs as PUID/PGID; pass the matching host identity when using restrictive file permissions such as 0600:
cp sample.config.yaml config.yaml
  chmod 600 config.yaml
  docker run --rm -it \
    --env PUID="$(id -u)" \
    --env PGID="$(id -g)" \
    -v "$(pwd)/config.yaml:/app/config/config.yaml:ro" \
    luigi311/jellyplex-watched:latest

With a named credential override

To replace credentials for servers already defined in YAML, use a named JSON map such as JPW_SERVER_TOKENS='{"plex-main":"replacement-token"}'. The server name must match exactly; the replacement keeps its URL, sync directions, and mappings. A Plex replacement selects token authentication and clears the username/password/server name fields. A legacy PLEX_TOKEN without PLEX_BASEURL is supported when exactly one YAML Plex server exists.

With a legacy .env file

  • Create a legacy .env file using the compatibility example above and set the
variables to match your setup.
  • Run
docker run --rm -it \
     --env PUID="$(id -u)" \
     --env PGID="$(id -g)" \
     -v "$(pwd)/.env:/app/.env" \
     luigi311/jellyplex-watched:latest

Troubleshooting/Issues

  • Jellyfin
- Attempt to decode JSON with unexpected mimetype, make sure you enable remote access or add your docker subnet to lan networks in jellyfin settings
  • Configuration
- Do not use quotes around variables in docker compose - If you are not running all 3 supported servers, that is, Plex, Jellyfin, and Emby simultaneously, make sure to comment out the server url and token of the server you aren't using.

Contributing

I am open to receiving pull requests. If you are submitting a pull request, please make sure run it locally for a day or two to make sure it is working as expected and stable.

License

This is currently under the GNU General Public License v3.0.

Chat with me