WARNING: This project is a Work In Progress (WIP)
This project is currently under active development. Features may be incomplete, unstable, or subject to change. Use at your own risk.
The ultimate companion for your Plex Media Server.
Plex Management System transforms your Plex experience by combining powerful management tools, community features, and personalized insights into a single platform. It helps users configure their clients for optimal playback, manage media requests, report issues, and celebrate their viewing habits with "Plex Wrapped".
- Guided Onboarding for new users to the server
- Client Configuration Guides to ensure Direct Play and best quality
- Issue Reporting workflow to streamline support requests
- Media Request integration (via Overseerr)
- Discord Integration for community engagement and announcements (optional)
- Service Status monitoring for all connected platforms
- Plex watchlist to Overseerr - Members who turn it on get the titles on their Plex watchlist requested in Overseerr, on the admin's interval or with Sync Now (4 an hour)
- Filed as the member - Each request is sent as the member's own Overseerr user (
X-Api-User), so approval follows their Overseerr permissions: auto-approve rules, quotas and the requester shown in Overseerr are theirs, never the admin's - Needs an Overseerr account imported from Plex - A member is matched by their Plex account id, so they must have signed in to Overseerr with Plex once (or been imported from Plex by an admin). Members without a match are skipped, and their card says why
- Opt-in only - Nothing syncs for a member who has not turned it on, whoever triggers the sync, nor while the admin has the feature off
- Total watch time breakdown (movies vs. shows)
- Top movies and shows with play counts and ratings
- Server-wide leaderboards to see how you rank
- Visualizations with animated charts and transitions
- AI-Powered Insights for personalized viewing summaries
- One-click sharing of wrapped stats
- Public share links for easy social media sharing
- Analytics tracking for shared content
- Responsive design that works on any device
- User management dashboard with role-based access control
- Service management - Monitor and configure Plex, Tautulli, Overseerr, Sonarr, Radarr
- Announcements system - Server-wide notifications with markdown support
- LLM usage tracking and cost monitoring
- Share analytics to see what's popular
- Audit logging for security and compliance
- Centralized configuration for all integrations
- Subscription management - Cancel, comp/grant access, and mark users exempt (when Stripe is enabled)
- Stripe-powered access gating - Non-members can subscribe to auto-provision Plex access
- Stripe Checkout & Billing Portal - No in-app card handling; payment is fully hosted by Stripe
- Webhook-driven provisioning - Automatic Plex invite on subscribe and removal on cancellation
- Off by default - When disabled, the app behaves exactly as if the feature didn't exist
- See the Stripe Subscriptions guide for setup
- Node.js 22 or later (the Docker image ships Node.js 24 LTS)
- npm or yarn
- PostgreSQL database (SQLite is no longer supported as of Prisma v7)
- Redis, which Rewind and Stripe subscriptions require (it backs the background job queue)
- Access to:
- A Plex server (with admin token)
- Tautulli instance (for viewing statistics)
- Overseerr (optional, for request stats)
- Sonarr/Radarr (optional, for media management features)
- OpenAI API key (optional, for AI insights)
- A Stripe account (optional, for subscription-based access gating)
- Clone the repository
git clone <repository-url>
cd plex-wrapped- Install dependencies
npm install- Set up environment variables
cp example.env .envEdit .env and configure:
DATABASE_URL- PostgreSQL connection string (e.g.,postgresql://user:password@localhost:5432/plex_manager)NEXT_PUBLIC_APP_URL- Your public application URL (e.g.,http://localhost:3000for dev,https://yourdomain.comfor production)NEXTAUTH_URL- Your application URL for NextAuth callbacks (should matchNEXT_PUBLIC_APP_URLin production)NEXTAUTH_SECRET- Generate with:openssl rand -base64 32PLEX_CLIENT_IDENTIFIER- Unique identifier for your app instance (any string, doesn't need to be a UUID)
- Initialize the database
npm run db:generate
npm run db:deploydb:deploy applies the migrations in prisma/migrations, the same command the Docker image runs at start. Upgrades use it too; see Upgrading.
- Start the development server
npm run dev-
Open your browser Navigate to http://localhost:3000
-
Claim the server Until setup completes,
/setupasks for a setup code, so nobody else who reaches a fresh install can take it over. Each start prints a new one-time code to the server log; the newest one is valid:
docker logs <container> | grep 'Setup code' # or read the `npm run dev` outputTo choose the code yourself, set SETUP_TOKEN (at least 16 characters, e.g. openssl rand -hex 16) before starting; it replaces the printed code while it is set. The claim lasts 12 hours in that browser and ends when setup completes.
-
Connect Plex Choose Connect with Plex and approve Plex Manager on plex.tv with the account that owns your server. Back in the wizard, pick your server and the address Plex Manager should use (a local one is fastest). Servers shared with that account can't be chosen; only their owner can set them up. If your server doesn't show up, or this browser can't reach plex.tv, open Enter a token instead and type the server URL (for example
https://example.com:32400) and an X-Plex-Token. -
Complete the setup wizard Plex and Tautulli (URL format:
http://example.com:8181) are required. Overseerr, Sonarr, Radarr, the two AI providers, Discord and Prometheus are optional: Skip for now moves on, and you can set them up later in Admin, Settings. The summary lists what is connected and what was skipped; Open admin finishes setup and signs you in with the Plex account from step 1.
plex-wrapped/
├── app/ # Next.js App Router (pages, layouts, routes)
│ ├── (app)/ # Authenticated app layout group
│ ├── admin/ # Admin dashboard and management
│ ├── api/ # API routes (webhooks, external integrations)
│ ├── auth/ # Authentication pages
│ ├── onboarding/ # User onboarding flow
│ ├── setup/ # Initial setup wizard
│ └── rewind/ # Rewind pages and public share links
├── actions/ # Server Actions (mutations, data operations)
├── components/ # React components
│ ├── admin/ # Admin-specific components
│ ├── generator/ # Wrapped generation UI
│ ├── onboarding/ # Onboarding components
│ ├── setup-wizard/ # Setup wizard steps
│ └── ui/ # Shared UI primitives
├── lib/ # Utilities, helpers, business logic
│ ├── connections/ # External API clients (Plex, Tautulli, Overseerr, etc.)
│ ├── security/ # Security utilities (rate limiting, CSRF, audit logging)
│ ├── validations/ # Zod schemas for all services
│ ├── wrapped/ # Wrapped generation and statistics
│ └── utils/ # Shared utilities
├── types/ # TypeScript type definitions
├── prisma/ # Database schema and migrations
└── e2e/ # Playwright end-to-end tests
| Command | Description |
|---|---|
npm run dev |
Start development server |
npm run build |
Build for production |
npm run start |
Start production server |
npm run lint |
Run ESLint |
npm test |
Run unit/integration tests |
npm run test:watch |
Run tests in watch mode |
npm run test:coverage |
Generate coverage report |
npm run test:e2e |
Run end-to-end tests (Playwright) |
npm run test:e2e:ui |
Run E2E tests in UI mode |
npm run db:generate |
Generate Prisma Client |
npm run db:deploy |
Apply pending migrations (install and upgrade) |
npm run db:push |
Contributor-only: push the schema to a throwaway database while prototyping |
npm run db:migrate |
Contributor-only: create a new migration (prisma migrate dev) |
npm run db:studio |
Open Prisma Studio (database GUI) |
npm run db:seed |
Deletes all users, Rewinds and service configs, then creates test users. Local or _test databases only; needs E2E_TEST_MODE=true. Never run it on a host that serves production |
📖 For comprehensive development guidelines, see CLAUDE.md
The CLAUDE.md file contains detailed information about:
- Architecture patterns and component design
- Server Actions vs API routes
- Database patterns with Prisma
- Integration guides (Plex, Tautulli, Overseerr, Sonarr/Radarr)
- Security best practices
- Testing strategies
- Code style conventions
Architecture Principles
- Server Components by default - Use Client Components (
'use client') only when needed - Server Actions - Prefer Server Actions over API routes for mutations
- TanStack Query - Use for all client-side data fetching
- TypeScript strict mode - Maintain type safety with exhaustive checks
Code Style
- Tailwind CSS - Use utility classes over custom CSS
- Zod - Validate all user inputs and API responses
- Error boundaries - Implement proper error handling and loading states
- Component organization - Keep components focused and reusable (max ~200-300 lines)
- Avoid over-engineering - Implement exactly what's needed, no premature abstractions
Testing
- Jest - Unit and integration tests with comprehensive coverage
- Testing Library - Component testing utilities
- Playwright - E2E testing with authenticated sessions (use
data-testidselectors) - See E2E Testing Guide for authenticated session patterns
Logging
- Use the
createLoggerutility from@/lib/utils/logger - See the JSDoc in
lib/utils/logger.tsfor log levels, metadata, and best practices
npm run db:generate # Generate Prisma Client after schema changes
npm run db:deploy # Apply pending migrations (install and upgrade)
npm run db:push # Contributor-only: push the schema to a throwaway database while prototyping
npm run db:migrate # Contributor-only: create a migration after changing prisma/schema.prisma
npm run db:studio # Open database GUICreating a migration (contributors). After changing prisma/schema.prisma, run npx prisma migrate dev --create-only --name <descriptive_name> against a throwaway database whose name ends in _test, read the generated migration.sql, then apply it with npm run db:deploy. Never hand-type a migration timestamp. CI's Schema Drift job runs node scripts/check-migrations.mjs, which wants a timestamp later than every earlier folder's, at least two lowercase words in the name (add_member_export, not latest), and a migration.sql; the folders listed in prisma/migrations/.legacy-names.json predate the rule.
20250101000000_init is a retroactive baseline for installs that predate migrations: a database created with prisma db push marks each folder applied with npx prisma migrate resolve --applied <name> instead of running it (see Installed with db push? and docs/operations.md).
Database schema. DATABASE_URL may end in ?schema=<name> to keep the app's tables in a schema other than public. Migrations and the running app both use it. The name must be a letter or _ followed by letters, digits or _ (at most 63 characters), or a production server refuses to start.
See example.env for all available configuration options. Key variables:
- Database:
DATABASE_URL - Application URLs:
NEXT_PUBLIC_APP_URL(preferred, used for public URLs),NEXTAUTH_URL(required by NextAuth, should matchNEXT_PUBLIC_APP_URLin production) - Authentication:
NEXTAUTH_SECRET,PLEX_CLIENT_IDENTIFIER - Jellyfin:
JELLYFIN_DEVICE_ID(optional) — the DeviceId reported to Jellyfin at sign-in. By default it is derived fromPLEX_CLIENT_IDENTIFIER, so leave it unset unless you want your own value. If neither is set, Jellyfin sign-in uses the fixedplex-manager-serverand logsjellyfin.auth.device_id_fallbackat warn. - Encryption:
ENCRYPTION_KEY(optional but strongly recommended in production) — encrypts external-service secrets/tokens at rest. Use a dedicated value (do not reuseNEXTAUTH_SECRET); generate withopenssl rand -hex 32. Rollout is non-destructive; rotating it makes previously-encrypted secrets undecryptable until re-saved. - Job Queue:
REDIS_URL— enables the Redis-backed BullMQ worker. Without Redis, watchlist sync runs under a database lease in one process. Stripe and Rewind need Redis. - Docker Compose (development):
POSTGRES_PASSWORDandREDIS_PASSWORDare required;docker compose uprefuses to start without them.docker-compose.ymlpublishes Postgres (5432, 5433) and Redis (6379) on127.0.0.1only and builds the app container'sDATABASE_URLandREDIS_URLfrom the two passwords; use them in your own.envURLs as well. A database volume created before this change keeps its old password (postgres), so setPOSTGRES_PASSWORDto that or change it withALTER USER. The compose databases runpostgres:16-alpine(they were 15): Postgres 16 cannot open a 15 data directory, so dump an existingdbvolume while it still runs 15 (see docs/operations.md), remove the volume (docker compose down -vremoves every volume), then start the new one and restore the dump. The app container starts only oncedbandredispass their health checks. - Development:
DEV_*variables for setup wizard defaults (read only withNODE_ENV=developmentandDEV_SETUP_PREFILL=true, until setup is complete)- Use URL format:
DEV_PLEX_URL="https://localhost:32400"(includes protocol and port)
- Use URL format:
Stripe: There are intentionally no
STRIPE_*environment variables. The secret key, webhook signing secret, and price IDs are stored (encrypted) in the app config and managed entirely from Admin → Settings → Stripe Subscriptions. See docs/stripe.md.
When deploying to production, ensure the following environment variables are set:
-
NEXT_PUBLIC_APP_URL- Your public-facing domain (e.g.,https://yourdomain.com)- Used for sharing links, OG images, and public URLs
- Must be accessible from the internet
-
NEXTAUTH_URL- Your application URL (should matchNEXT_PUBLIC_APP_URL)- Used by NextAuth for OAuth callbacks and session management
- Must match the domain where your app is accessible
-
NEXTAUTH_SECRET- A secure random string- Generate with:
openssl rand -base64 32 - Keep this secret and never commit it to version control
- Generate with:
-
PLEX_CLIENT_IDENTIFIER- A unique, stable identifier for this install- Generate with:
openssl rand -hex 16 - Keep it unchanged across upgrades: Plex and Jellyfin see it as this app's device id
- Generate with:
-
DATABASE_URL- Your production PostgreSQL database connection string- Example:
postgresql://user:password@host:port/database - SQLite is no longer supported (Prisma v7 requirement)
- Example:
A production server checks these at start and exits with code 1, logging env.invalid and the variable's name (never its value), when NEXTAUTH_SECRET is missing, shorter than 32 characters, has fewer than 10 distinct characters or is a published placeholder (such as example.env's old value), when NEXTAUTH_URL or NEXT_PUBLIC_APP_URL is not https: (loopback hosts excepted), or when PLEX_CLIENT_IDENTIFIER is empty. Two URLs with different origins only log a warning. ALLOW_INSECURE_HTTP=true lets a LAN-only install run on plain http; it logs a warning at every start, and session cookies are then not Secure, so never set it on an install reachable from the internet.
-
No hardcoded localhost: The application will throw an error in production if neither
NEXT_PUBLIC_APP_URLnorNEXTAUTH_URLis set -
HTTPS required: In production, both URL variables must be HTTPS, or the server refuses to start (see above). Session cookies are Secure, with the
__Secure-name, exactly whenNEXTAUTH_URLis https -
Domain consistency: Both URL variables should point to the same domain to avoid authentication issues
-
Reverse proxy and rate limits: Rate limits key on the client IP (IPv6 clients by their /64), read from
X-Forwarded-For. Run the app behind a reverse proxy that adds the client address toX-Forwarded-For(Traefik and Caddy do by default, nginx withproxy_add_x_forwarded_for), and setTRUSTED_PROXY_HOPSto the number of proxies that append to it:1(the default) for one proxy. Keep the app port off the public internet so every request passes through the proxy; a client that reaches the app directly can choose its own address. SetREDIS_URLso limits hold across processes and restarts. The server logs a warning once ifTRUSTED_PROXY_HOPSis invalid or no client address can be found, because such requests share one bucket. -
CDN in front of the proxy (
TRUSTED_PROXY_HOPS=2): this is only correct when the inner proxy keeps the CDN'sX-Forwarded-Forand appends to it. Traefik and Caddy drop an incomingX-Forwarded-Forfrom senders they do not trust, so add the CDN's address ranges to Traefik'sforwardedHeaders.trustedIPsor Caddy'strusted_proxies; otherwise the header has a single entry and every visitor is keyed by a CDN edge address. Let the origin accept traffic only from the CDN (a firewall rule on the CDN's published ranges, or a tunnel), because a request sent straight to the proxy can choose the entry the app reads. -
Upgrading to a release with
TRUSTED_PROXY_HOPS: earlier releases keyed rate limits on the leftmostX-Forwarded-Forentry, which the client controls; the app now reads the entry the outermost trusted proxy wrote. CheckTRUSTED_PROXY_HOPSagainst your proxy chain when you upgrade (seeexample.env). Behind a CDN and a proxy, leaving it at1keys every visitor by the CDN's edge address, so visitors who share an edge share the sign-in, invite, share-page and poster limits and get false "Too many attempts" errors. -
Jellyfin sign-in limits: the app allows 10 Jellyfin sign-in attempts per 15 minutes from one client address and 5 failed attempts per 15 minutes for one username. Jellyfin's own lockout still applies, and the app's limits do not prevent it: by default Jellyfin disables a user after 3 failed sign-ins (5 for administrators), configurable per user as "Failed login tries before user is locked out". Anyone who knows a username can still lock that account through the sign-in page; the app's limits only slow password guessing. An administrator re-enables a locked user in the Jellyfin dashboard.
-
Jellyfin sign-in on Jellyfin 12: the app identifies itself in the
Authorizationheader, which Jellyfin 12.0 and later require (they ignore the legacyX-Emby-Authorizationheader unless legacy authorization is turned on), and it retries once with the legacy header only if the server answers 400. Each sign-in's Jellyfin session is logged out straight away, because the app keeps only the Jellyfin user ID; a logout that fails or takes longer than 3 seconds is logged asjellyfin.auth.logout_failedat warn and leaves that session on the server. Earlier releases left one session per member on the Jellyfin server, listed under the client "Plex Wrapped" and the device IDplex-wrapped-server(or yourJELLYFIN_DEVICE_ID); a Jellyfin administrator can delete them in Dashboard → Devices. -
Jellyfin accounts are never linked by email: a Jellyfin sign-in matches only the Jellyfin user ID, and the Jellyfin username is never stored as an email address, because any Jellyfin user can rename themselves to someone else's address. A new member who signs in with both Plex and Jellyfin gets two separate accounts. A Plex sign-in or Plex invite whose email another account already holds still goes ahead, without storing the email. "Allow Jellyfin sign-in" in Admin → Settings turns the Jellyfin provider off, not just the button on the login page; members already signed in with Jellyfin stay signed in until their session expires (at most 7 days).
-
Admin roles: roles set in Admin → Users stick. A sign-in only ever promotes: the Plex server owner is always an admin, and so is a Jellyfin administrator, because Jellyfin has no owner. A sign-in never demotes anyone, so app admins demote members in the UI, and a Jellyfin lookup that fails (a timeout or a server error) keeps the stored role. The Plex server owner and the last admin cannot be demoted.
-
Unsharing removes one server's share only: Unshare in Admin → Users, and the Stripe access removal, delete the member's share of the configured Plex server and nothing else. The member stays your plex.tv friend and keeps any other servers you share with them; remove those in Plex itself. A share that is already gone counts as success ("already removed"), so a retry or a second click settles. Managed Plex Home users have no share to remove and report "already removed".
-
Ending sessions: unsharing a member, demoting an admin, disabling an account (Admin → Users → a user → Disable account) and the Stripe access removal end that user's sessions within five minutes, when the session next checks the database. A disabled user cannot sign in with Plex or Jellyfin until an admin enables the account again.
-
Upgrading to the release that stops email linking: earlier releases linked a Jellyfin sign-in to any account whose email matched the Jellyfin username, so a Jellyfin user could take over an account, the admin's included, by renaming themselves to its email. The upgrade keeps those links, and the sessions they produced, so every server that allowed Jellyfin sign-in should check them:
- Take a database dump before deploying, and store it encrypted. The release runs a migration that clears data and cannot be undone.
- Before deploying, list the accounts that hold both a Plex and a Jellyfin ID, and keep the output. Each row is a link an earlier release made by email: either a Jellyfin sign-in onto a Plex account, which the server logs record as "Linked Jellyfin account to existing user" with the Jellyfin username, or a Plex invite redeemed onto a Jellyfin account, which was not logged.
SELECT id, name, email, "isAdmin", "plexUserId", "jellyfinUserId" FROM "User" WHERE "jellyfinUserId" IS NOT NULL AND "plexUserId" IS NOT NULL;
- For each row whose Jellyfin account you do not recognise as that member's own, remove the link and the email it may have written:
UPDATE "User" SET "jellyfinUserId" = NULL, "email" = NULL WHERE id = '...';. For each other row whose email is not that member's own address (an earlier release wrote the Jellyfin username there at every Jellyfin sign-in), clear the email only:UPDATE "User" SET "email" = NULL WHERE id = '...';. The migration leaves these rows alone, so this review is the only step that removes such an email. The member's Plex email comes back on their next Plex sign-in. - If you removed any link, rotate
NEXTAUTH_SECRETand restart the app. Everyone has to sign in again, which ends the sessions the Jellyfin user still holds on that account: they last up to 7 days and keep the account's admin rights. Then search the server logs forADMIN_PRIVILEGE_GRANTEDandADMIN_PRIVILEGE_REVOKEDaudit events on those accounts, and review the settings, invites and users changed while the link existed.
The migration then clears the stored email of every account that has a Jellyfin ID and no Plex ID, because such an email can only have come from a Jellyfin username. Accounts with a Plex ID keep their email unless you cleared it in step 3. An account whose email is cleared finds only the Overseerr requests of an Overseerr user with its Plex ID (none for an account without a Plex ID), cannot start Stripe Checkout, and cannot be invited to Plex by the Stripe grant, until a Plex sign-in stores its Plex email again.
-
Upgrading to the release where the share page reads the database: a Rewind share link now opens only when its token is exactly 43 URL-safe characters (
A-Z,a-z,0-9,-and_), the shape the app's link generator makes. A link with any other token answers 404 without a database lookup, and nothing is logged. Before deploying, count the shared Rewinds whose token has another shape:SELECT count(*) FROM "PlexWrapped" WHERE "shareToken" IS NOT NULL AND "shareToken" !~ '^[A-Za-z0-9_-]{43}$';
If the count is not 0, those links stop working after the upgrade. Clear their tokens (
UPDATE "PlexWrapped" SET "shareToken" = NULL WHERE "shareToken" IS NOT NULL AND "shareToken" !~ '^[A-Za-z0-9_-]{43}$';) and tell the owners to turn sharing on again for a new link, or keep the release back until those links can lapse.
Prisma has no down migrations, so a dump is the only way back from a migration. For every upgrade:
- Take a dump, encrypt it and keep it off the host (see docs/operations.md):
docker compose exec -T db pg_dump -U postgres -Fc plex_wrapped > plex-manager-$(date +%F-%H%M).dump
- Read the release notes for every version you are moving past, including the "Upgrading to the release…" notes above.
- Pull or rebuild: pull the new release and run
npm ci && npm run build, or pull or rebuild the Docker image. - Apply migrations: run
npm run db:deploy, or let the container do it at start. The container logsprisma migrate statusfirst; start it withSKIP_MIGRATIONS=trueto apply migrations by hand instead. - Check the app: sign in, and check that
/api/healthanswers 200 with"database":"ok"and"redis":"ok";docker psshows the container(healthy).
Earlier instructions installed the schema with npm run db:push (now contributor-only), which records no migrations, so db:deploy would try to create tables that already exist. Baseline the database once, on the old release, before upgrading, after taking a dump:
- Check that the database matches the old release's schema. This must exit 0 and report no difference:
If it reports a difference, stop: the database has drifted, and marking migrations applied would hide that.
npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --exit-code
- Mark every folder in
prisma/migrationsas applied:for dir in prisma/migrations/*/; do npx prisma migrate resolve --applied "$(basename "$dir")"; done
- Only if Stripe subscriptions were never enabled, run the grandfather update from
20260703000000_add_stripe_subscriptions, whichdb pushskipped (see docs/stripe.md):echo "UPDATE \"User\" SET \"isExempt\" = true, \"exemptReason\" = 'grandfathered';" | npx prisma db execute --stdin
Then upgrade as above. The new release's migrations, including their data steps, apply normally.
The project includes a Dockerfile for containerized deployments. When deploying with Docker:
- Set environment variables in your deployment platform or
.envfile - Ensure
NEXT_PUBLIC_APP_URLandNEXTAUTH_URLare set to your production domain - The application will fail to start in production mode if these are not configured
- Publish the app port on loopback or an internal network only, with your reverse proxy in front:
127.0.0.1:3000:3000under composeports:,-p 127.0.0.1:3000:3000withdocker run, or no published port when the proxy shares a Docker network with the app. Rate limits trustX-Forwarded-For(see "Reverse proxy and rate limits" above), so a client that reaches the app port directly gets a fresh limit on every request. - The image installs no native toolchain (
python3,make,g++).zlib-sync, which compresses the Discord gateway, is an optional dependency that is skipped without one, so the Discord bot connects uncompressed. See docs/discord-bot.md. - At every start the container logs
prisma migrate status, then runsprisma migrate deploy(whatnpm run db:deployruns). WithSKIP_MIGRATIONS=trueit logsSKIP_MIGRATIONS=true: not applying migrationsand starts without applying them. Take a dump before starting a release that carries a migration; see docs/operations.md. - The site starts serving without waiting for Redis or Discord. The queue worker retries Redis with backoff for about two minutes and then stays off (logging
queue.redis_unreachable) until the next restart. - On
docker stop(SIGTERM) the app drains running jobs, stops the Discord bot and releases its lock, closes its connections, and exits with 0, or with 1 if that takes longer thanSHUTDOWN_GRACE_MS(default 8000 ms, below Docker's 10 s stop timeout). The image andnpm run start:prodsetNEXT_MANUAL_SIG_HANDLE=trueso the app handles the signal instead of Next; Next then no longer closes the HTTP server itself, and in-flight requests finish inside the same grace window because the process exits last. - The image runs Node.js 24 LTS under
tinias the unprivilegednextjsuser. The app files are owned by root and cannot be rewritten at runtime; the one writable path is/app/.next/cache, where Next's own cache and Rewind's (REWIND_CACHE_DIR=/app/.next/cache/rewind: posters, cards, fonts) live. Mount a volume there to keep the Rewind cache across container replacements; it can always be rebuilt. Migrations run through a Prisma CLI pinned with its own lockfile indocker/prisma-cli/(keep its versions equal to the root lockfile; a test checks). - Health check.
GET /api/healthchecks the database and, whenREDIS_URLis set, Redis, each within 2 seconds. It answers200with{"status":"ok","checks":{"database":"ok","redis":"ok"}}, or503with"status":"fail"and the failed check (redisisnot_configuredwithoutREDIS_URL, which does not fail it). It needs no sign-in and carries no hostnames or error text; point monitoring at it.GET /api/health?probe=liveanswers200without touching either dependency: the image'sHEALTHCHECKuses it, so a Redis outage, which the site survives, never gets the container restarted.
- User Authentication - Sign in with your Plex account using PIN-based authentication
- Onboarding - New users are guided through server configuration and features
- Data Collection - Fetch viewing statistics from Tautulli and Plex
- Dashboard - View server info, request media, and explore content
- Plex Wrapped - Generate shareable year-end summaries (optional)
The platform integrates with multiple services to provide a comprehensive Plex management experience:
- Plex Media Server - Primary media server integration (required)
- Tautulli - Watch history and statistics tracking (required for Wrapped features)
- Overseerr - Media request management and availability tracking
- Sonarr - TV series management and monitoring
- Radarr - Movie management and monitoring
- Discord - Bot integration for server announcements and community features
- OpenAI/LLM Providers - AI-powered insights for Wrapped summaries
- Stripe - Subscription-based access gating with auto-provisioned Plex access (see docs/stripe.md)
- Redis - Backing store for the BullMQ background job queue
All integrations are configured through the setup wizard or admin panel, with validation and connection testing built-in.
- PIN-based authentication via Plex
- Server access verification - Only users with access to your Plex server can sign in
- Secure token generation for sharing features
- Admin-only actions protected by role checks
- Input validation with Zod schemas on all user inputs and API responses
- SQL injection protection via Prisma parameterized queries
- XSS prevention - React escapes by default, no unsafe HTML rendering
- CSRF protection - Built into Next.js Server Actions
- Rate limiting - Redis-backed limits on sign-in, invite, PIN, share and admin endpoints, keyed by the client IP behind
TRUSTED_PROXY_HOPSproxies - Audit logging - Tracking of admin actions and security events
- Encryption at rest - External-service API keys/tokens are AES-256-GCM encrypted when
ENCRYPTION_KEYis set - Signed webhooks - Stripe webhooks are verified against the raw request body (no admin auth needed)
- Member removal - Admins can remove a member: their access, sessions, Stripe subscription, Discord grant and stored data go. docs/privacy.md lists every table of personal data and what removal does to it
- Member export - Members download their data as JSON from Account, Your data (
/account/export, three a day) and delete single marks, watchlist items and assistant conversations there
See CLAUDE.md for detailed security best practices.
This project is licensed under the MIT License - see the LICENSE file for details.
Contributions are welcome! Please feel free to submit a Pull Request.
Inspired by Spotify Wrapped. Built for the Plex community.




