Skip to content
LFFPicardPublic

About

A Plex logging "Vault"

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Sentinel

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.


Features

  • 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

(Screenshots coming soon)


Requirements

  • Docker + Docker Compose
  • A running Plex Media Server
  • Your Plex token (see below for how to find it)

Quick Start

# 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/admin

Environment Variables

Copy .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

Finding your Plex token

  1. Open Plex Web in your browser
  2. Play any item
  3. Go to Settings → Troubleshooting → Get an X-Plex-Token

Or check your existing Tautulli settings — it will be stored there.


Docker Compose

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: 7700

Tautulli Migration

Sentinel ships with a first-class Tautulli import tool. Your years of history come with you.

Clean migration (recommended)

  1. Stop Plex temporarily
  2. Copy your tautulli.db to ./data/import/tautulli.db on your host
  3. Start Sentinel — the collector will begin collecting live events immediately
  4. Open the admin UI at /admin → Import
  5. Click Start Import and watch the progress bar
  6. On completion you'll see a full summary — sessions imported, metadata linked, users created
  7. Restart Plex — Sentinel is now your primary data source

Live migration (minimal downtime)

If stopping Plex isn't practical:

  1. Copy tautulli.db to ./data/import/tautulli.db while Plex is still running
  2. Start Sentinel — the collector picks up live events from this point forward
  3. Run the import — any sessions started after the copy will be collected live
  4. 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.

Finding your tautulli.db

Platform Default path
Unraid /mnt/user/appdata/tautulli/tautulli.db
Linux ~/.local/share/Tautulli/tautulli.db
Docker Inside your Tautulli appdata volume

What gets imported

  • 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)

Performance expectations

Library size Estimated import time
~50k sessions ~3-4 minutes
~100k sessions ~6-7 minutes
~172k sessions ~11-13 minutes

After importing — full library sync

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.


First Run

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.


Logs

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.log

API

All endpoints require an Authorization: Bearer sk_xxxxx header.

Core endpoints

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

Query parameters for /v1/stats/server and /v1/stats/user/:userId

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

Query parameters for /v1/history

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

Admin endpoints (admin key required)

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

WebSocket

ws://host:7700/ws/live?key=sk_xxxxx

Pushes a JSON message on every Plex playback event.


API Keys

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.


Consuming Apps

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_xxxxxxxxxxxx

Unraid

Sentinel is available in the Unraid Community Applications store.

Search for LFFPicard - Sentinel and install. The template pre-configures the volume paths and environment variables.


Troubleshooting

Real issues hit during development, with the exact commands used to diagnose and fix them.

"Missing or invalid Authorization header" on every request

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"}

POST/DELETE requests fail with "Unsupported Media Type" or "Body cannot be empty"

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 "{}"

The admin UI Import or Library Sync page shows no progress

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.

Database counts on the Maintenance page look wrong right after a big import

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;"

A reset seemed to fail, or data looks inconsistent afterwards

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.

Year-scoped stats look surprisingly low compared to Tautulli

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;"

Two user records for the same person after importing

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.

Finding a user's correct ID

/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.


Licence

GPL-3.0 — see LICENSE


Part of the LFFPicard Homelab Ecosystem

  • Atrium — self-hosted homelab portal
  • Atrium Stats — Tautulli-replacement stats dashboard, Wrapped-style year in review, and per-user leaderboards (in development)

About

A Plex logging "Vault"

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages