Self-hosted companion app for a single Plex, Jellyfin or Emby server.
Watchlist · history · live activity · statistics · suggestions · year in review
Watcharr is not affiliated with, endorsed by, or derived from Servarr or any *arr application — Sonarr, Radarr, Lidarr, Readarr, Prowlarr, Bazarr and the rest are separate projects by separate people. The name simply rhymes.
Watcharr does not download, index, request, rename or manage anything. It reads from one media server you already run and shows you what was watched on it. If you are looking for request management, use Overseerr or Jellyseerr.
You already run a media server. It knows exactly what everyone watched, for how long, on what device and whether it had to transcode — and it shows you almost none of it. Watcharr is a small companion app that sits next to it and answers those questions, for regular users and for the admin, without asking anyone to create yet another account.
- One server per deployment. Plex or Jellyfin or Emby, chosen once during setup.
- No password system. People sign in with their media server account. The server's admin flag becomes the app's admin role.
- One container, one file. SQLite, no separate database service, no Redis, no queue.
- Everything drills down. A number, a genre, a day in the heatmap, an hour in the week grid and every title lead to the plays behind them, down to the individual episode.
| Watchlist | Search the library, mark titles as planned / watching / done. Plex watchlists are synced where the server exposes them. |
| History | Every play, filterable by search, type, period, genre, day, weekday and hour. Exportable as CSV. |
| Activity | What is playing right now, with progress, timecode and transcode state. |
| Statistics | Watch time, plays, streaks, active days, records, daily and monthly activity, top genres, titles, devices, peak hours, a weekday × hour grid, a 365-day film-strip heatmap, plus how your own streams were delivered (direct play vs. transcode, codecs, resolutions, bitrates). |
| Suggestions | Derived from your own history — genres, decades, formats — and optionally enriched with TMDB "similar titles". Each card links to the title page and out to the media server. |
| Wrapped | A year in review: totals, first and last play of the year, top genres and titles, a calendar of the year, weekday crown, devices. |
| Search | ⌘K / Ctrl+K over watched titles, the library and (for admins) users. |
| All activity | Every live stream on the server: user, title, client, device, bandwidth, transcode reason. |
| Server statistics | Server-wide totals, most watched content, user leaderboard, peak times. |
| Transcoding | Direct play share, transcode reasons, video codecs, containers, resolutions, bitrate distribution. |
| Clients | Sessions and watch time per client, per device and per user. |
| Users | All server users with a per-user drilldown into their stats and history. |
| System | Media server reachability, API health, sync status. |
| Configuration | Server URL and token, optional TMDB key, feature toggles. |
Transcoding and client statistics are recorded from live sessions, so they start empty on a fresh install and fill up as people watch. History-based statistics are backfilled from the media server on the first sync and are complete immediately.
Graphite throughout, with one rule: amber only ever means "playing" or "this is data".
Nothing decorative is amber. Charts are inline SVG rendered on the server — no charting
library, no client-side data fetching, no CDN. Dark and light schemes, full keyboard access,
prefers-reduced-motion respected, and a layout that is built for a phone rather than
merely surviving on one: a Material navigation bar, a modal drawer, thumb-sized targets and
horizontally scrollable charts.
- A running Plex, Jellyfin or Emby server that this app can reach over HTTP(S)
- Docker with Compose v2 — or Node.js 22+ if you would rather run it directly
- ~100 MB disk for the image, plus a few MB per year of watch history
Watcharr makes no outbound connections of its own. It talks to your media server, to
plex.tv if you use Plex sign-in, and to TMDB only if you configure an API key.
mkdir watcharr && cd watcharr
curl -O https://raw.githubusercontent.com/PD-Codes/watcharr/main/docker-compose.yml
# SESSION_SECRET signs session cookies and encrypts media server tokens at rest.
echo "SESSION_SECRET=$(openssl rand -hex 32)" > .env
docker compose up -ddocker compose up -d pulls the published image from ghcr.io/pd-codes/watcharr:latest.
To build this checkout instead, use docker compose up -d --build.
Pin a version in production so an upgrade is something you decide to do:
echo "WATCHARR_TAG=1.0.0" >> .env
docker compose up -ddocker run -d \
--name watcharr \
-p 3000:3000 \
-e SESSION_SECRET="$(openssl rand -hex 32)" \
-e DATABASE_PATH=/app/data/watcharr.db \
-v watcharr-data:/app/data \
--restart unless-stopped \
ghcr.io/pd-codes/watcharr:latestAvailable tags: latest, 1, 1.0, 1.0.0. Images are published for
linux/amd64 and linux/arm64, so a Raspberry Pi or an ARM NAS works without changes.
git clone https://github.com/PD-Codes/watcharr.git
cd watcharr
cp .env.example .env
echo "SESSION_SECRET=$(openssl rand -hex 32)" >> .env
# --ignore-scripts skips a needless native rebuild of better-sqlite3, which ships
# prebuilt binaries for linux/macOS/Windows on x64 and arm64. Without it you need
# Python and a C++ toolchain for no benefit.
npm ci --ignore-scripts
npm run build
npm startDatabase migrations are applied automatically on every start — npm run dev, npm start
and the container all run them first. There is no separate migration step to remember.
Everything except these few variables is configured in the app itself, on the admin configuration page.
| Variable | Default | What it does |
|---|---|---|
SESSION_SECRET |
(required) | Signs session cookies and derives the key that encrypts media server tokens at rest. Generate with openssl rand -hex 32. Changing it invalidates every stored token and signs everyone out. |
DATABASE_PATH |
./data/watcharr.db |
SQLite file. The container defaults to /app/data/watcharr.db; keep that path on a volume. |
APP_URL |
http://localhost:3000 |
Public base URL of this deployment — the address you actually type into the browser. Used for the Plex sign-in callback, and it decides whether the session cookie is marked Secure. If it says https:// while you reach the app over plain HTTP, your browser will throw the session cookie away and sign-in will appear to do nothing. Behind a reverse proxy that sets X-Forwarded-Proto, that header wins. |
PORT |
3000 |
Port the server listens on. |
NODE_TLS_REJECT_UNAUTHORIZED |
(unset) | Set to 0 only if your media server uses a self-signed certificate. It disables certificate checking process-wide. |
Open http://localhost:3000. The setup wizard runs once and asks for three things: the server type, the server URL and an admin API token.
Jellyfin — Dashboard → API Keys
- Sign in to Jellyfin as an administrator.
- Go to Dashboard → Advanced → API Keys and press +.
- Name it
Watcharrand copy the generated key. - Server URL is the same one you use in the browser, for example
http://192.168.1.10:8096. Do not include a trailing path.
Emby — Settings → API Keys
- Sign in to Emby as an administrator.
- Go to Settings → Advanced → API Keys and create a new key named
Watcharr. - Server URL is for example
http://192.168.1.10:8096.
Plex — the X-Plex-Token of the server owner
- Open any library item in Plex Web, choose ⋮ → Get Info → View XML.
- The URL of the XML page ends in
&X-Plex-Token=…— that value is your token. (Plex's own instructions) - Server URL is your Plex Media Server address, for example
http://192.168.1.10:32400. - Make sure
APP_URLis set to the URL people will actually open, otherwise the PIN sign-in flow returns them to the wrong place.
After setup, everyone else signs in with their own media server account:
- Plex — the plex.tv PIN flow. Approve the code, come back, done.
- Jellyfin / Emby — their normal username and password, verified by the server.
Watcharr never stores a password. The token the media server hands back is encrypted and kept server-side; the browser only ever gets a signed session id.
Set APP_URL to the public URL and forward to port 3000. The app sets no cookies that need
a subpath, but it does expect to live at the root of whatever host it is served from.
Caddy
watcharr.example.com {
reverse_proxy 127.0.0.1:3000
}nginx
server {
listen 443 ssl http2;
server_name watcharr.example.com;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}docker compose pull && docker compose up -dMigrations run on start; already-applied files are skipped. Take a backup first if you are crossing a major version — see below.
Watch Releases for what changed. Versioning is semantic: a major bump means something needs your attention.
Everything lives in one SQLite file. Stop the container, copy it, start it again:
docker compose stop
docker run --rm -v watcharr-data:/data -v "$PWD:/backup" busybox \
tar czf /backup/watcharr-backup.tar.gz -C /data .
docker compose startCopying the file while the app is running can catch it mid-write; SQLite runs in WAL mode,
so the -wal and -shm sidecars belong in the backup too. The command above takes the
whole directory, which covers them.
Your SESSION_SECRET is part of the backup in the sense that the database is useless
without it — stored media server tokens are encrypted with a key derived from it. Keep it
somewhere safe and separate.
- No local password store. Authentication is delegated to the media server, and its admin flag is the only source of the admin role.
- Media server tokens are encrypted at rest (AES-256-GCM) with a key derived from
SESSION_SECRET. - Session cookies carry a signed random id and nothing else; sessions live server-side and expired ones are cleaned up on sign-in.
- Artwork is proxied through
/api/art/[itemId]so a token never reaches a browser. The proxy enforces a session, an allowlist on the item id, an origin match against the configured server, animage/*response type and an upstream timeout. - Login and Plex PIN polling are rate limited.
/api/healthreports database and media server health and backs the container healthcheck.
Found something? Please open a security advisory rather than a public issue.
npm ci --ignore-scripts
npm run dev # applies migrations, then starts the dev server on :3000
npm run typecheck
npm test # adapters, suggestion scoring, migrations, aggregate SQL, encryption
npm run test:routes # builds, boots the app against a stub media server, requests every route
npm run preview # the app against the same stub, seeded with a year of deterministic playsEvery suite creates its own throwaway SQLite file and, where needed, a fake Jellyfin server.
Nothing has to be running alongside. npm run preview (port 3311) exists for design work,
because empty charts say nothing about layout and density.
src/db/schema.ts Schema: users, watchlist, watch_history, playback_sessions,
auth_sessions, suggestions_cache, app_config
scripts/migrate.mjs Applies drizzle/*.sql on start, tracked in a _migrations table
src/server/adapters/ The only place that knows a media server API
types.ts MediaServerAdapter — the interface every backend implements
jellyfin.ts Covers Jellyfin AND Emby (only the auth header differs)
plex.ts Including the PIN OAuth flow
src/server/config.ts Single-row deployment configuration + adapter factory
src/server/session.ts Server-side sessions
src/server/crypto.ts AES-256-GCM for tokens at rest
src/server/sync.ts Throttled history / activity / watchlist sync
src/server/stats.ts History aggregates, per user or server-wide
src/server/playback.ts Session aggregates: clients, codecs, transcoding
src/server/history.ts Filter building for the history page and the CSV export
src/server/deeplink.ts Link into the media server's own web UI
src/server/scoring.ts Pure suggestion heuristic (no server-only, unit tested)
src/components/ Charts, the Beam (now playing), tooltip, icons, palette
src/app/(app)/ User and admin pages, navigation, per-item detail
src/app/api/ Auth, setup, artwork proxy, search, CSV export, health
src/i18n/ Translations — en-US only in v1, ready for more
Rule: nothing outside src/server/adapters/ talks to a media server API. Adding a
backend means implementing MediaServerAdapter and registering it in index.ts.
The version in pyproject.toml is the only trigger. This is not a Python
project — the file exists so that one obvious, human-editable place decides what gets
published.
- Raise
versioninpyproject.tomlandpackage.json(the workflow refuses to publish when they disagree). - Merge to
main.
.github/workflows/release.yml then runs the test suite,
builds linux/amd64 and linux/arm64 images, pushes them to ghcr.io/pd-codes/watcharr
as <version>, <major>.<minor>, <major> and latest, and creates the matching git tag
and GitHub release. A version that is already tagged is skipped, so re-runs and reverts
cannot publish twice. A version containing a hyphen (1.1.0-rc1) is published as a
pre-release and never becomes latest.
Issues and pull requests are welcome. Please run npm run typecheck, npm test and
npm run test:routes before opening a PR — CI runs all three anyway, and the route suite
catches the render and SQL-dialect mistakes a type check cannot see.
Code comments are written in English and explain why, not what. The UI language is US English.
Copyright (C) 2026 PD-Codes
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License, version 3 or (at your option) any later version, as published by the Free Software Foundation.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
Plex, Jellyfin and Emby are trademarks of their respective owners. Watcharr is an independent project and is not affiliated with any of them, nor with TMDB, nor — as noted at the top — with the *arr / Servarr projects. Optional metadata is provided by TMDB; this product uses the TMDB API but is not endorsed or certified by TMDB.