Year in Review for Plex Media Server
Obzorarr is a "Wrapped for Plex" application that syncs viewing history from your Plex Media Server and generates yearly statistics with an animated slideshow presentation - similar to Spotify Wrapped. It doesn't require Tautulli; it only relies on the Plex API.
- Yearly Statistics — Total watch time, top movies, shows, and genres
- 19 Slide Types — From watch streaks and binge sessions to decade breakdowns and series completion
- Two Ways to Watch — An animated story-mode slideshow, or a scrollable single-page recap
- Watch Patterns — Monthly, hourly, and weekday distribution charts
- Percentile Rankings — See how you compare to other users on your server
- Server Wrapped — A server-wide recap with a top-viewers leaderboard
- Privacy Modes — Real, hybrid, or fully anonymous names, with one-click privacy presets
- Five Themes — UI and Wrapped themes are chosen independently
- Slide Editor — Reorder, enable, or disable slides and add your own custom ones
- Plex OAuth — Secure authentication with your Plex account
- Automatic Sync — Scheduled background sync of viewing history, with live progress
- Reverse-Proxy Diagnostic — Compares what your browser sees, what the proxy forwards, and what Obzorarr uses
- AI Fun Facts — Optional AI-written fun facts, with the built-in templates as the fallback
Found a bug or have a feature request? Please submit issues and feature requests to the obzorarr-docker repository rather than this repository. This ensures your report reaches the maintainers monitoring issue tracking across the project.
Every screenshot below is captured from a running instance. Usernames are rendered by Obzorarr's own anonymisation mode, and server addresses are demo values.
Onboarding
First run walks through seven steps: Claim → Security → Reverse proxy → Connect → Sync → Configure → Done.
| Claim setup | CSRF origin |
|---|---|
![]() |
![]() |
| Public address | Address diagnostic |
|---|---|
![]() |
![]() |
| Server picker | Connection choice |
|---|---|
![]() |
![]() |
| Connected | Sync in progress |
|---|---|
![]() |
![]() |
| Sync complete | Choose slides |
|---|---|
![]() |
![]() |
| Pick a theme | Setup complete |
|---|---|
![]() |
![]() |
Admin
| Dashboard | Wrapped overview | Slide editor |
|---|---|---|
![]() |
![]() |
![]() |
| Sync (idle) | Sync running | Users |
|---|---|---|
![]() |
![]() |
![]() |
| Live logs | Settings | Connections |
|---|---|---|
![]() |
![]() |
![]() |
| Appearance | Privacy | Security |
|---|---|---|
![]() |
![]() |
![]() |
| Data | System |
|---|---|
![]() |
![]() |
Your Wrapped
Story mode plays the slides one at a time; scroll mode puts the whole recap on a single page.
| Total time | Top movies | Top shows |
|---|---|---|
![]() |
![]() |
![]() |
| Genres | Viewing patterns | Weekday patterns |
|---|---|---|
![]() |
![]() |
![]() |
| Movies vs shows | By decade | Series completion |
|---|---|---|
![]() |
![]() |
![]() |
| Rewatches | Marathon day | Longest streak |
|---|---|---|
![]() |
![]() |
![]() |
| Year comparison | Percentile | Binge sessions |
|---|---|---|
![]() |
![]() |
![]() |
| First and last | Fun fact | Summary |
|---|---|---|
![]() |
![]() |
![]() |
Scroll mode — the same recap as one continuous page:
| Scroll mode (top) | Scroll mode (further down) |
|---|---|
![]() |
![]() |
On a phone — the Wrapped experience is built portrait-first:
Themes
Five presets ship with Obzorarr. The admin UI theme and the Wrapped theme are set independently under Admin → Settings → Appearance, so the panel you work in and the recap your users see do not have to match.
UI themes — the admin dashboard in each preset:
Wrapped themes — the same slide in each preset:
Sharing & privacy
Names shown below come from Obzorarr's anonymous privacy mode, which renders every user as
User #1, User #2, and so on. Real and hybrid (you see your own name, everyone else is
anonymised) are the other options — see Admin → Settings → Privacy.
| Share modal | Public Wrapped |
|---|---|
![]() |
![]() |
| Server Wrapped | Top viewers |
|---|---|
![]() |
![]() |
| Component | Technology |
|---|---|
| Runtime | Bun |
| Framework | SvelteKit + Svelte 5 |
| Database | SQLite (Drizzle ORM) |
| Styling | UnoCSS + shadcn-svelte |
| Animation | GSAP + Motion |
Docker (Recommended) — Image Repo
services:
obzorarr:
container_name: obzorarr
image: ghcr.io/edbfi/obzorarr-docker
ports:
- 3000:3000
environment:
- PUID=1000
- PGID=1000
- UMASK=002
- TZ=Etc/UTC
# The address in your browser's address bar. Required when you open Obzorarr over plain
# HTTP (e.g. http://192.168.1.10:3000); leave it unset only behind an HTTPS reverse proxy
# that preserves the Host header. See "Running Behind a Reverse Proxy".
- ORIGIN=http://<host-or-ip>:3000
# Optional: lock Plex connection at the env layer. You can also leave
# these unset and configure the server from the admin UI after onboarding.
# - PLEX_SERVER_URL=http://plex-url-here:32400
# - PLEX_TOKEN=your-plex-token-here
volumes:
- /<host_folder_config>:/configReplace /<host_folder_config> with your desired config path and <host-or-ip> with the address you
open Obzorarr at, then open that address (http://localhost:3000 on the Docker host itself) to
complete setup.
git clone https://github.com/edbfi/obzorarr.git
cd obzorarr
cp .env.example .env
bun install
bun run devNote on
.envin local dev.bun run devdoes not auto-load.env, so anyPLEX_SERVER_URL/OPENAI_*values you put there are ignored — local dev configures the server through onboarding and the admin UI (values stored in the SQLite DB). Environment-variable precedence (and the "Locked by environment variable" UI) applies to Docker/production, where the container passes the vars into the process. To exercise env-precedence locally (e.g. to see an env-locked field render itsENVbadge), runbun run dev:env, which loads.envvia--env-file.
When PLEX_SERVER_URL and PLEX_TOKEN come from the environment, onboarding and
Admin → Settings → Connections show them as read-only with an ENV badge — the value is owned
by your container config, not the database:
The first time you open the web UI, Obzorarr runs a short onboarding wizard: Claim → Security → Reverse proxy → Connect → Sync → Configure.
So nobody can grab your fresh install before you do, the first step asks for a one-time bootstrap token. Obzorarr prints it to the server console — on a fresh install it is never shown in the browser:
Obzorarr initial setup requires a bootstrap claim.
Setup URL: http://localhost:3000/onboarding/claim
Bootstrap token: xxxx-xxxx-xxxx
With Docker, read it from the container logs:
docker logs obzorarrThe token expires after 15 minutes and only one browser can hold the claim at a time. If it lapses, restart Obzorarr to print a new one.
The remaining steps connect your Plex server (or confirm the values you set via PLEX_SERVER_URL /
PLEX_TOKEN), run the first history sync, and let you choose which slides users see. Anything set
here can be changed later under Admin → Settings.
Admin → Settings → Data has a Danger zone with Reset instance, which deletes everything Obzorarr has stored and drops you back at the claim screen, signed out. Before wiping, it shows you a fresh claim token to paste on the next screen — that one lasts 60 minutes, since you have to sign in to Plex, reconfigure, and sync again. It is also printed to the console as usual, so losing the tab is recoverable.
Your watch statistics come back: they re-sync from Plex. Everything else does not. That covers all
settings, every per-user share setting, and every share link you have already handed out stops
working, along with any manual curation and the log history. Anything configured through
environment variables (Plex, OpenAI, ORIGIN, TZ) is not in the database, so it
survives and the new setup arrives partly pre-filled. Obzorarr refuses to reset while a sync is
running.
Admin → Sync holds the automatic sync schedule as a cron expression. The schedule survives restarts: Obzorarr stores the expression and whether you left the scheduler running, paused, or stopped, and rebuilds the job on the next boot.
Cron expressions are interpreted in the configured timezone, which also drives the nightly log retention cleanup. Obzorarr resolves it in this order:
- the
TZenvironment variable, when it names a zone the runtime knows (TZ=Europe/Copenhagen); - the timezone saved under Admin → Settings → System;
UTC.
As with every other environment-backed setting, TZ wins: the field renders read-only with an ENV
badge, and a database value it shadows is dropped at startup. A TZ the runtime cannot resolve is
ignored rather than applied, so a typo leaves the admin field editable instead of scheduling syncs
in an unknown zone. Fixed offsets such as +02:00 are rejected for the same reason a DST-aware zone
is wanted here: 0 0 * * * should mean local midnight all year.
Obzorarr needs to know the address your browser uses, not the internal one it listens on. Otherwise login redirects, share links, and CSRF checks get built from the wrong hostname.
Set ORIGIN to the address people open in the browser, including the port if it isn't 80 or 443:
ORIGIN=https://obzorarr.example.comThat covers most setups, and it is required for plain-HTTP deployments (no TLS anywhere, e.g.
ORIGIN=http://192.168.1.10:3000). Leave it unset only behind an HTTPS reverse proxy that passes
the original Host header: without ORIGIN, Obzorarr assumes https:// plus that Host. Over
plain HTTP it would build sign-in redirects for https://… and mark the sign-in and session cookies
Secure, which browsers refuse over plain HTTP, so signing in fails. Obzorarr logs one warning at
startup when neither ORIGIN nor PROTOCOL_HEADER is set. It never takes its origin from the Host
header when ORIGIN is set.
ORIGIN must be a bare origin: a scheme (http or https), a host and an optional port. A trailing
/, uppercase letters and a default port (:80, :443) are fine and are normalized; a path, query,
fragment or user name and password stop Obzorarr at startup with an error that names the expected
form (it never prints the value).
With ORIGIN set, bun start (scripts/serve.ts) listens on HOST/PORT and passes requests to the
SvelteKit server over a private Unix socket, supplying ORIGIN itself; headers a client sends cannot
change it. Start Obzorarr through bun start (or the container's command): build/index.js started on
its own checks ORIGIN and uses it as the CSRF origin, but cannot make it the address links and
cookies are built from. IDLE_TIMEOUT keeps working as the client idle timeout in seconds (it maps to
CONNECTION_IDLE_TIMEOUT, which also works); event streams are exempt from it.
Client addresses behind a proxy. Behind a reverse proxy, every request comes from the proxy's
address unless you tell Obzorarr where the client's address is, so the per-client rate limits would
treat all your users as one client. Set ADDRESS_HEADER=x-forwarded-for, with XFF_DEPTH set to the
number of proxies in front of Obzorarr (default 1), and only when every request goes through those
proxies: a request that arrives without the header has no client address, and a visitor who can reach
Obzorarr directly could send a forged one. Without a proxy, leave ADDRESS_HEADER unset; Obzorarr then
uses the connection's own address.
Without ORIGIN, behind a trusted proxy. If you cannot set ORIGIN (one instance served under
several addresses, say), the Bun adapter can take the protocol and host from headers your proxy sets:
PROTOCOL_HEADER=x-forwarded-proto and HOST_HEADER=x-forwarded-host. They apply only without
ORIGIN (the front supplies the origin itself and ignores them), and only when both of these are
true:
- Obzorarr can only be reached through the proxy — nothing can hit it directly.
- Your proxy sets both headers itself, replacing whatever a visitor sends with a single value.
If either is false, a visitor can forge those headers and make Obzorarr build links pointing at a
domain they control. When in doubt, set ORIGIN.
TRUST_PROXY is no longer read: ORIGIN replaces it, or the two headers above where ORIGIN cannot
be used. If it is still set, in the environment or by the old switch in setup or on the Security
page, Obzorarr ignores it, removes the stored switch, and logs one warning at startup that names the
replacement.
The CSRF origin you confirm in onboarding (stored in the database) only decides which browser
Origin may submit changes; it does not change the address Obzorarr builds links from.
Onboarding and Admin → Settings → Security include a diagnostic that compares the address your
browser opened with the origin Obzorarr actually uses and, when they differ, tells you the ORIGIN
to set. Its technical details also show what the proxy forwards, with header recipes for Caddy,
Nginx, Nginx Proxy Manager, and Apache for the header route above. Changing any of these variables
requires a restart.
| Reverse-proxy step | Technical evidence |
|---|---|
![]() |
![]() |
Plex watch history records a server-local account ID rather than a global Plex identity, so on every sync Obzorarr rebuilds the mapping between those local IDs and real Plex users by comparing your server's account list with the users you've shared the server with. In practice:
- You, the server owner, are always local account
1. Never edit account IDs by hand. - A user only gets a Wrapped once their share is confirmed. If the check comes back incomplete (Plex unreachable, partial response), Obzorarr keeps the previous mapping instead of guessing.
- Un-sharing removes access — that user's public Wrapped link stops resolving. Re-share and run a sync to restore it.
- Mappings go stale after 24 hours and are re-proved by the next sync. They also reset whenever the Plex URL or token changes — back up your database before changing either, then restart and run a normal sync.
Public Wrapped links deliberately return the same "not found" response for an unknown user, a private profile, and a stale mapping, so the page can't be used to discover who has an account on your server.
Whether real usernames appear at all is a separate setting. Admin → Settings → Privacy offers
five presets — from Maximum Privacy (members-only, anonymous names) to Public Showcase (public
recap, real names) — plus a Custom card that lights up once you change anything underneath, and a
Names in stats control with Real, Anonymous (User #1, User #2, …), and Hybrid (you
see your own name, everyone else is anonymised).
This project is licensed under the GNU Affero General Public License v3.0.
Use the Bun version pinned in package.json (packageManager) for development, CI,
builds and production. Install dependencies with bun install --frozen-lockfile,
then use bun run dev for development.
For production, run bun run build followed by bun run start. The start script
sets NODE_ENV=production and runs scripts/serve.ts with Bun, which starts the generated
build/index.js (directly without ORIGIN, behind the front with it; see "Running Behind a
Reverse Proxy"). Keep build/, scripts/serve.ts, production node_modules/, package.json
and drizzle/ together, and retain the configured persistent database path.
Two more server settings, with their defaults:
SHUTDOWN_TIMEOUT=30: on stop (SIGTERMorSIGINT), Obzorarr stops accepting connections and lets open requests finish for up to this many seconds, open live-update streams included, then closes what is left. WithORIGINset, the process exits at that deadline even if Obzorarr is still waiting on Plex for a request. In a container, keep it below the stop timeout (Docker's default is 10 seconds) or raise both; otherwise the container is killed before Obzorarr has shut down cleanly. A second signal stops it at once.BODY_SIZE_LIMIT=512K: the largest request body accepted (K,MandGsuffixes;Infinityturns the limit off). Obzorarr has no uploads, so the default is enough; larger requests get413.


























































