JellyPlex-Watched
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:
- Explicit field values supplied to the internal source loader.
JPW_-prefixed process environment variables.JPW_-prefixed values in the selected dotenv file.- Legacy process environment variables.
- Legacy values in the selected dotenv file.
- The selected YAML file.
- 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_rulesentry allows that user despite user whitelists
- A matching
library_sync_rulesentry allows that library despite
- Rules add exceptions to an existing
sync_todirection; unmatched users
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
- Copy
sample.config.yamltoconfig.yaml, edit it,
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
.envfile using the compatibility example above and set the
- 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
- Configuration
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.