Self-hosted Plex data vault for the modern homelab.
Sentinel connects to your Plex Media Server via WebSocket, captures every playback event in real time, and stores your complete watch history in PostgreSQL. It exposes a clean, versioned REST and WebSocket API that any app can consume — no more slow Tautulli queries, no more SQLite bottlenecks.
Built as the data backbone for the LFFPicard homelab ecosystem, powering Atrium and Atrium Stats.
- Real-time event ingestion — play, pause, resume, stop events captured via Plex WebSocket
- Live push — real-time now-playing data over WebSocket via Postgres LISTEN/NOTIFY, no polling
- PostgreSQL vault — proper indexing and year partitioning means history queries in milliseconds, not 10–30 seconds
- Full library sync — caches metadata for every item in your library, watched or not, so "recently added" and full browsing work correctly
- IP geolocation — cached country/region/city/ISP lookups, mirrors Tautulli's IP address modal
- Stats aggregation — server-wide and per-user endpoints for graphs, top content, heatmaps, and genre breakdowns
- Clean versioned API — REST endpoints under
/v1/and a WebSocket live stream at/ws/live - Tautulli import — migrate your full history in one click via the admin UI. 172k records in ~11-13 minutes.
- Admin UI — status, settings, API key management, import, library sync, user management, and database maintenance at
/admin - No cloud dependencies — fully self-hosted, runs entirely on your hardware
(Screenshots coming soon)
- Docker + Docker Compose
- A running Plex Media Server
- Your Plex token (see below for how to find it)
# 1. Clone the repo
git clone https://github.com/LFFPicard/sentinel.git
cd sentinel
# 2. Copy the example env file and fill in your values
cp .env.example .env
# 3. Start the stack
docker compose up -d
# 4. Open the admin UI
# Visit http://your-server-ip:7700/adminCopy .env.example to .env and set the following:
| Variable | Required | Description |
|---|---|---|
DB_PASSWORD |
Yes | Password for the internal PostgreSQL database |
PLEX_URL |
Yes | Full URL to your Plex server e.g. http://192.168.1.x:32400 |
PLEX_TOKEN |
Yes | Your Plex authentication token |
SENTINEL_PORT |
No | Port to expose the API on. Default: 7700 |
- Open Plex Web in your browser
- Play any item
- Go to Settings → Troubleshooting → Get an X-Plex-Token
Or check your existing Tautulli settings — it will be stored there.
services:
postgres:
image: postgres:17-alpine
container_name: sentinel-postgres
restart: unless-stopped
volumes:
- ./data/postgres:/var/lib/postgresql/data
environment:
POSTGRES_DB: sentinel
POSTGRES_USER: sentinel
POSTGRES_PASSWORD: ${DB_PASSWORD}
collector:
image: lffpicard/sentinel-collector:latest
container_name: sentinel-collector
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
volumes:
- ./data/import:/import
- ./data/logs:/data/logs
environment:
DATABASE_URL: postgres://sentinel:${DB_PASSWORD}@postgres:5432/sentinel
PLEX_URL: ${PLEX_URL}
PLEX_TOKEN: ${PLEX_TOKEN}
api:
image: lffpicard/sentinel-api:latest
container_name: sentinel-api
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
ports:
- "${SENTINEL_PORT:-7700}:7700"
environment:
DATABASE_URL: postgres://sentinel:${DB_PASSWORD}@postgres:5432/sentinel
SENTINEL_PORT: 7700Sentinel ships with a first-class Tautulli import tool. Your years of history come with you.
- Stop Plex temporarily
- Copy your
tautulli.dbto./data/import/tautulli.dbon your host - Start Sentinel — the collector will begin collecting live events immediately
- Open the admin UI at
/admin→ Import - Click Start Import and watch the progress bar
- On completion you'll see a full summary — sessions imported, metadata linked, users created
- Restart Plex — Sentinel is now your primary data source
If stopping Plex isn't practical:
- Copy
tautulli.dbto./data/import/tautulli.dbwhile Plex is still running - Start Sentinel — the collector picks up live events from this point forward
- Run the import — any sessions started after the copy will be collected live
- A small overlap or gap at the cutover point is expected and acceptable
The import is safe to run multiple times — duplicate sessions are detected via unique session keys and skipped automatically.
| Platform | Default path |
|---|---|
| Unraid | /mnt/user/appdata/tautulli/tautulli.db |
| Linux | ~/.local/share/Tautulli/tautulli.db |
| Docker | Inside your Tautulli appdata volume |
- Full play history with start/stop times and watch duration
- Media metadata (title, show, season, episode, year, poster paths)
- All users from your Plex server, automatically reconciled with any Tautulli-only placeholder accounts
- Transcode decisions and platform info
- IP geolocation (cached per unique IP)
| Library size | Estimated import time |
|---|---|
| ~50k sessions | ~3-4 minutes |
| ~100k sessions | ~6-7 minutes |
| ~172k sessions | ~11-13 minutes |
The Tautulli import only brings in metadata for content that's actually been played. To cache your entire library (including unwatched content, needed for "recently added" and full browsing), also run a Library Sync from /admin → Library Sync → Sync Now. This walks every movie, show, season, and episode in your Plex libraries. Expect roughly 15-20k items per minute — a library of ~130k items takes around 7-8 minutes. Runs automatically every 6 hours after that.
On first start, Sentinel automatically generates an admin API key and prints it to the container logs:
╔═════════════════════════════════════════════════════════════════╗
║ SENTINEL FIRST RUN — ADMIN KEY ║
║ ║
║ sk_admin_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ║
║ ║
║ Save this key — it will never be shown again. ║
║ Use it to log into the admin UI at /admin ║
╚═════════════════════════════════════════════════════════════════╝
On Unraid: Check the container logs in the Docker tab immediately after first start.
On Linux: Run docker compose logs sentinel-api | grep -A 8 "FIRST RUN"
Copy the key and use it to log into /admin. It is stored as a bcrypt hash — the plaintext is never shown again. You can generate additional read keys for consuming apps from the admin UI.
Collector logs are written to ./data/logs/collector.log on your host. Logs rotate at 10MB, keeping the last 3 files.
View recent logs in the admin UI under Maintenance → Collector Logs, or directly:
tail -f ./data/logs/collector.logAll endpoints require an Authorization: Bearer sk_xxxxx header.
GET /v1/status Server info, DB counts, collector status
GET /v1/history Paginated play history
GET /v1/users All users with summary stats
GET /v1/libraries All libraries with record counts
GET /v1/metadata/:ratingKey Cached metadata + poster path for a media item
GET /v1/sessions/live Current active streams
GET /v1/geo/:ip Cached geolocation for an IP (country, region, city, ISP)
GET /v1/stats/server Server-wide stats: top content, heatmap, daily play counts
GET /v1/stats/user/:userId Per-user stats: top shows/movies, genre breakdown, peak times
| Parameter | Description |
|---|---|
year |
Full calendar year e.g. 2026 — recommended, hits a single DB partition |
period |
7d, 30d, 90d, 365d, or all — used if year isn't given |
| Parameter | Description |
|---|---|
user_id |
Filter by user |
year |
Full calendar year e.g. 2024 |
from |
Unix timestamp |
to |
Unix timestamp |
limit |
Default 100, max 1000 |
offset |
Pagination |
complete |
true or false |
GET /v1/admin/settings
PUT /v1/admin/settings
GET /v1/admin/api-keys
POST /v1/admin/api-keys
DELETE /v1/admin/api-keys/:id
POST /v1/admin/import/tautulli
GET /v1/admin/import/status
POST /v1/admin/library-sync
GET /v1/admin/library-sync/status
POST /v1/admin/users/:id/purge
POST /v1/admin/users/merge-duplicates
POST /v1/admin/maintenance/vacuum
GET /v1/admin/maintenance/status
DELETE /v1/admin/history
DELETE /v1/admin/reset
ws://host:7700/ws/live?key=sk_xxxxx
Pushes a JSON message on every Plex playback event.
Sentinel uses two key tiers:
- Admin key — full access including settings, import, and database management. Generated on first setup via the admin UI.
- Read key — read-only access to history, stats, users, and metadata. Generate one per consuming app (Atrium, Rewind, etc.)
Keys are hashed with bcrypt on creation. The raw key is shown once — copy it immediately.
Any app can call Sentinel's API with a read key. Configure consuming apps with:
SENTINEL_URL=http://your-server-ip:7700
SENTINEL_API_KEY=sk_read_xxxxxxxxxxxxSentinel is available in the Unraid Community Applications store.
Search for LFFPicard - Sentinel and install. The template pre-configures the volume paths and environment variables.
Real issues hit during development, with the exact commands used to diagnose and fix them.
The header format is Authorization: Bearer sk_xxxxx — the Bearer prefix (with the space) is required. Missing it produces this exact error. Using PowerShell:
Invoke-RestMethod -Uri "http://your-server:7700/v1/status" -Headers @{Authorization = "Bearer sk_admin_yourkeyhere"}Fastify expects a JSON content-type and body on POST/DELETE routes that accept one, even if there's nothing meaningful to send. PowerShell's Invoke-RestMethod doesn't set a body or content-type by default. Add both explicitly:
Invoke-RestMethod -Uri "http://your-server:7700/v1/admin/reset" -Method DELETE -Headers @{Authorization = "Bearer sk_admin_yourkeyhere"; "Content-Type" = "application/json"} -Body "{}"Check the actual API response shape directly before assuming the UI is broken — polling bugs and API shape mismatches both look identical from the outside:
Invoke-RestMethod -Uri "http://your-server:7700/v1/admin/import/status" -Headers @{Authorization = "Bearer sk_admin_yourkeyhere"}If this returns live, incrementing processed values but the UI never updates, it's a frontend polling bug. If it returns stale or no data, the job likely isn't running — check collector logs.
pg_stat_user_tables (Postgres's internal row estimates) doesn't update in real time — only after autovacuum or ANALYZE runs. A count showing 2 immediately after importing 170k+ rows is a display lag, not lost data. Verify with a direct count:
docker exec -it sentinel-postgres psql -U sentinel -d sentinel -c "SELECT COUNT(*) FROM session_history;"If the real count is correct, running VACUUM ANALYZE (via the Maintenance page or directly) refreshes the estimate:
docker exec -it sentinel-postgres psql -U sentinel -d sentinel -c "VACUUM ANALYZE;"Always verify a reset actually completed before trusting whatever comes next — don't assume success from a UI checkmark alone:
docker exec -it sentinel-postgres psql -U sentinel -d sentinel -c "SELECT (SELECT COUNT(*) FROM session_history) AS sessions, (SELECT COUNT(*) FROM users) AS users, (SELECT COUNT(*) FROM metadata) AS metadata;"All three should read 0 immediately after a successful reset. If a reset errors out partway through, re-run it — the endpoint uses TRUNCATE ... CASCADE, which is safe to retry.
GET /v1/stats/user/:id?year=2026 only counts plays within that calendar year. Tautulli's own dashboard often displays all-time totals by default. These are not directly comparable — confirm both figures are using the same scope before assuming something's broken. To check your all-time count directly:
docker exec -it sentinel-postgres psql -U sentinel -d sentinel -c "SELECT COUNT(*) FROM session_history WHERE user_id = YOUR_ID;"Tautulli sometimes tracks the same person under slightly different identities (an email address vs a username, for example). Sentinel automatically reconciles matching usernames on every sync, and a manual merge is available for anything already in the database before that fix ran:
Invoke-RestMethod -Uri "http://your-server:7700/v1/admin/users/merge-duplicates" -Method POST -Headers @{Authorization = "Bearer sk_admin_yourkeyhere"; "Content-Type" = "application/json"} -Body "{}"This only merges unambiguous 1:1 pairs (exact username match, no conflicts) — anything more complex (e.g. the same person under two completely unrelated usernames) needs manual attention and isn't handled automatically.
/v1/users returns both plexId (Plex's own identifier — often not what you want) and id (Sentinel's internal row ID — what /v1/stats/user/:id and /v1/history?user_id= expect). Mixing these up is an easy mistake:
$response = Invoke-RestMethod -Uri "http://your-server:7700/v1/users" -Headers @{Authorization = "Bearer sk_admin_yourkeyhere"}
$response.users | Where-Object { $_.username -eq "YourUsername" }Use the id field from the result, not plexId.
GPL-3.0 — see LICENSE
- Atrium — self-hosted homelab portal
- Atrium Stats — Tautulli-replacement stats dashboard, Wrapped-style year in review, and per-user leaderboards (in development)