Self-hosted service
arabcoders/watchstate avatar
arabcoders/watchstate

WatchState: matching Plex, Jellyfin and Emby watch history by path, and why path matching is now on

Self-hosted service to sync your plex, jellyfin and emby play state. without relying on 3rd-party external services.

1,600 stars29 forksPHPMIT

At a glance

What is it?
WatchState is a self-hosted PHP service that syncs users' play state between Plex, Jellyfin and Emby without a third-party service in the middle, and audits the matching it did in a Media Health report. Since 2026-09-15 it matches on local media paths by default, which fixes metadata disagreements but requires a full import before existing records benefit.
Who is it for?
WatchState fits a self-hoster running two or more of Plex, Jellyfin and Emby for the same household who wants watch history to follow the media rather than the server, and who can run a rootless container with a writable data directory. It does not fit a single-server setup with nothing to sync, or anyone who wants item matching to follow external metadata IDs rather than filesystem paths.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 2 days ago.
What is it written in?
Mainly PHP, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The problem is that three servers disagree about what you watched

WatchState's stated primary goal is to sync your backends' users' play state without relying on third party services, and it supports Jellyfin, Plex and Emby. The natural reading is that you run more than one of them, or migrated between them, and now your watch history is split across servers that do not talk to each other.

The feature list spells out the scope. Multi-users support via `identities`. Sync in `many-to-many` or `one-way` modes. Backup of play state into a `portable` format. Webhook events received from media backends. Media Health for finding record issues. Search over backend metadata. And sync of watch progress and play state via webhooks or scheduled tasks, which is finer than watched or unwatched.

The project is a solo effort. The README says so directly, notes replies may take some time, and places the maintainer in the UTC+3 timezone. That shapes what to expect from an issue tracker, not the software itself.

Path matching by default, and the migration it implies

This is the 2026-09-15 change and it is the one to understand before deploying. Path matching is now enabled by default: WatchState generates `guid_path` values for new and refreshed movie and episode records unless `WS_GUID_PATH_ENABLED` is explicitly disabled.

The reason is visible in the Media Health vocabulary from the 2026-07-20 change. That report replaced the earlier separate parity, duplicate-reference and file-integrity views with one place to audit backend metadata coverage, GUID conflicts, duplicate GUIDs, duplicate file references, structural metadata disagreement, path disagreement and weak matches, plus optional local file checks.

Path disagreement and structural metadata disagreement are the failures path matching addresses. Two servers can hold the same film with different external metadata IDs and different release years, and matching on those IDs alone leaves your history fragmented. Matching on the local file path works because the file is the same object on disk in both libraries.

The catch is stated plainly: existing records still need a full import before they can match by path. So an upgrade does not retroactively fix your history. You run the import once, and until you do, new records match by path and old ones do not, which produces exactly the kind of partial migration that is easy to misread as a bug.

Three import methods, and why you keep all of them

Data comes in three ways. Scheduled tasks are a scheduled job that pulls data from backends. On demand means running the import task manually. Webhooks receive events from backends and update the database accordingly.

Webhooks are the appealing one and the README advises against relying on them alone: keep the import task enabled even when all your backends support webhooks, because it can pick up missed events, and the webhook guide documents backend limitations.

That is a reasonable design for a reconciliation tool. Webhooks are fast, but missed events can leave state out of sync until a later import. A periodic full pull is the reconciliation that makes the incremental path safe. If you disable the schedule for performance, you have traded correctness for latency and the documentation tells you not to.

Sync direction is also a choice rather than a fixed behavior. `one-way` means backends push play state into WatchState; `two-way` means state moves between backends. The README routes you to separate guides for each, and the choice is per setup rather than global.

The container is rootless and the data directory decides whether it starts

WatchState ships as a container image at `ghcr.io/arabcoders/watchstate:latest`. The compose form, with the port comment carried over as written:

yaml
services:
    watchstate:
        image: ghcr.io/arabcoders/watchstate:latest
        # To change the user/group id associated with the tool change the following line.
        user: "${UID:-1000}:${UID:-1000}"
        container_name: watchstate
        restart: unless-stopped
        ports:
            - "8080:8080" # The port which the watchstate will listen on.
        volumes:
            - ./data:/config:rw # mount ./data in current directory to container /config directory.

The data directory has to exist first, and then:

bash
mkdir -p ./data && docker compose up -d

The single-command equivalent is:

bash
mkdir -p ./data && docker run -itd --name watchstate \
          --user "${UID:-1000}:${GID:-${UID:-1000}}"  \
          --restart unless-stopped -p 8080:8080 \
          -v ./data:/config:rw \
          ghcr.io/arabcoders/watchstate:latest

Then open `http://localhost:8080` and create a system user on first access, which the README marks as a one-time operation.

The permissions note is the part that catches people. Match `user:` and `--user` to the owner of the `data` directory, because the container runs rootless and exits if it cannot write there. If it fails to start, try `user: "0:0"` or `--user '0:0'`, and if that works the problem was permissions. Running as root is not recommended.

Podman needs `user` set to `0:0` in compose.yaml, which looks like root but maps to the user who ran the command. Unraid users install the Community Applications plugin and search for watchstate, with `--user 99:100` in Extra Parameters if installing manually, plus a `chown -R 99:100 /mnt/user/appdata/watchstate` if files were already created under a different id.

What is inside the image

The Dockerfile explains a lot about what WatchState actually needs. The frontend is built in a `node:lts-alpine` stage that installs bun and runs `bun run generate`, with a guard that skips the build entirely if a `frontend/exported` directory already exists, which is how prebuilt images avoid rebuilding the UI.

The runtime is `debian:13`, not Alpine, and it installs more than you would expect: nano, curl, procps, net-tools, iproute2, tzdata, sqlite3, ripgrep, redis, tini, gettext, ca-certificates and a set of fonts including FreeFont, Noto, Terminus and DejaVu, with fontconfig and a `fc-cache -f` warm-up. On amd64 it also installs `intel-media-va-driver`, `i965-va-driver` and `libmfx-gen1.2`, and `vainfo`.

So hardware transcoding probes, ripgrep and fonts are part of the runtime, not accidental bloat. That also explains the container's size. Redis is installed for the same reason, though the image deliberately deletes the `redis` user and changes the `users` group gid to 1588787 so Unraid users can use gid 100.

The PHP runtime is frankenphp, described in the Dockerfile as caddy plus php, copied from a builder image, together with a jellyfin-ffmpeg binary. `WS_DATA_PATH` is set to `/config` and `IN_CONTAINER=1`.

Where WatchState is the wrong tool

If you run exactly one media server, there is nothing to sync and this is overhead. The premise is two or more backends, or a migration between them.

Path matching also has a boundary worth stating. It works because your libraries share files on disk. If your Plex and Jellyfin libraries point at different storage for the same title, path matching will not join them and you are back to metadata matching. The Media Health report's path disagreement and weak match entries exist precisely to surface this.

The solo-maintainer factor is real for something holding your watch history. There is MIT licensing and a CHANGELOG, and releases are frequent, v1.10.8 on 2026-10-02 after v1.10.7 on 2026-09-28 and v1.10.6 on 2026-09-17 which added OIDC support. But a one-person project means an issue may sit, and your play history is the kind of data you would rather be able to export. The `portable` backup format is the mitigation, and you should verify an export before you need one.

There is also no hosted option. The whole point is not relying on third-party services, so there is no managed tier, and if your media server is behind a remote access solution you have to arrange your own connectivity for the webhooks.

Against doing it by hand

The manual alternative is real, but marking watched items in each server by hand requires repetitive work. This approach does not scale well or stay current automatically.

A script using each backend's API sits in between. It can sync in one direction and it is under your control, but you have to handle pagination, per-user credentials, progress versus played status, and the identification problem, which is the same problem WatchState's Media Health report is built to diagnose. Building and maintaining it requires additional work.

WatchState's value is that it has already solved the identification problem and, since the path matching change, has a defensible answer to it. If you have webhooks from two or more backends and more than one user, the reconciliation burden is real and this handles it. If you have one backend and one user, none of this applies to you.

Editorial conclusion

WatchState fits a self-hoster running two or more of Plex, Jellyfin and Emby for the same household who wants watch history to follow the media rather than the server, and who can run a rootless container with a writable data directory. It does not fit a single-server setup with nothing to sync, or anyone who wants item matching to follow external metadata IDs rather than filesystem paths. Before deploying, create the data directory and match the container user id to its owner or the container will exit on startup, import your existing records once so path matching applies to them, and keep the scheduled import task enabled even if every backend supports webhooks.

Frequently asked questions

How do I install WatchState?

Create a data directory, then either write a compose.yaml using the `ghcr.io/arabcoders/watchstate:latest` image with `./data:/config:rw` and run `docker compose up -d`, or use the single `docker run` command from the README. Match the container user id to the owner of the data directory, since the container runs rootless and exits if it cannot write there.

Which media servers does WatchState support?

Jellyfin, Plex and Emby. Sync can be `many-to-many` or `one-way`, and the README links separate guides for one-way and two-way sync that you follow after adding backends from the help button in the top-right corner.

What changed with path matching in WatchState?

From the 2026-09-15 update, path matching is enabled by default and WatchState generates `guid_path` values for new and refreshed movie and episode records unless `WS_GUID_PATH_ENABLED` is explicitly disabled. Existing records still need a full import before they can match by path.

What is Media Health in WatchState?

It is the central report for auditing media record problems, added 2026-07-20. It replaced the separate parity, duplicate-reference and file-integrity views with one report covering backend metadata coverage, GUID conflicts, duplicate GUIDs, duplicate file references, structural metadata disagreement, path disagreement, weak matches and optional local file checks.

Do I still need scheduled imports if my backends support webhooks?

Yes. The README advises keeping the import task enabled even when all backends support webhooks, because a scheduled pull can pick up missed events, and the webhook guide documents backend limitations.

Official sources

  1. arabcoders/watchstate on GitHub
  2. Issues
  3. License: MIT
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/arabcoders-watchstate.svg)](https://hysenlabs.com/projects/arabcoders-watchstate)

Was this useful?