ephyra

# ephyra A fast, self-hosted stats dashboard for Jellyfin: library breakdowns, watch history, and cleanup candidates, in one Go binary with the frontend baked in. Runs beside Jellyfin in Docker Compose and puts next to no load on the server. Named after the juvenile stage of a jellyfin— sorry, jellyfish. > **Alpha.** All five pages work, and it has survived a major Jellyfin upgrade > (10.11 → 12.0) without code changes. Still only tested on one real server, so > expect rough edges and changes without notice. Run it if you're curious, not > if you're relying on it. **This build ships five pages: Library, Watch Stats, Cleanup, Profiles, and Now Playing.** Watch Stats and Profiles need the Playback Reporting plugin and show an "enable this plugin" panel without it. Now Playing needs a working API key; with a bad one it degrades to a notice. Library and Cleanup work against core Jellyfin alone, though the key is required for Ephyra to start at all. ## What you need - Jellyfin 10.11 or 12.0, running on the same Docker host. - The ability to bind-mount Jellyfin's config directory into another container, read-only. - A Jellyfin API key: **Dashboard → API Keys → +**, name it `ephyra`. Put it in `EPHYRA_JELLYFIN_API_KEY`. It powers Now Playing and the header's server name/version. Ephyra won't start without it. A key that's set but wrong is survivable: Now Playing degrades to a notice, the other three pages carry on. ## Run it ```yaml # docker-compose.yml services: ephyra: image: ghcr.io/andriykohut/ephyra:latest environment: JELLYFIN_URL: http://jellyfin:8096 JELLYFIN_API_KEY: ${EPHYRA_JELLYFIN_API_KEY} JELLYFIN_DATA_DIR: /jellyfin-data volumes: - /path/to/jellyfin/config:/jellyfin-data:ro - ephyra-data:/data ports: - "8097:8097" restart: unless-stopped volumes: ephyra-data: ``` `docker compose up -d`, then open `http://:8097`. The first refresh runs on startup; the Library page fills in a second or two later. The container runs as UID 65532. A fresh named volume for `/data` picks that up automatically; if you bind-mount a host directory there instead, `chown 65532 ` first. That same UID has to read the Jellyfin mount. If Jellyfin writes its DBs as some other user without world `r-x` — linuxserver images run as whatever `PUID`/`PGID` you set — the copy step fails with `stat ... permission denied`. Find the owning GID (`stat -c '%g' /data/data/jellyfin.db`) and add it to the container rather than loosening perms on Jellyfin's files: ```yaml group_add: - "1000" # whatever owns jellyfin.db — check, don't assume ``` No auth. Put it on your LAN, or behind whatever your reverse proxy already does for login. ## Run it without Docker Each release attaches a static binary per platform — `linux/amd64`, `linux/arm64`, `darwin/amd64`, `darwin/arm64`. Pure Go, no shared libraries, the frontend baked in. Grab the tarball for your box from the [releases page](https://github.com/andriykohut/ephyra/releases), check it, unpack it: ```sh sha256sum -c --ignore-missing SHA256SUMS # optional; SHA256SUMS is on the release too tar xzf ephyra__linux_amd64.tar.gz cd ephyra__linux_amd64 ``` Same environment variables as the container (see Configuration), with two caveats: the `/data/...` defaults for `STORE_PATH` and `WORK_DIR` assume the container's layout, so set them to real writable paths; and `JELLYFIN_DATA_DIR` points straight at Jellyfin's config directory — the process has to be able to read `/data/` (run it as the Jellyfin user, or one in that group). ```sh JELLYFIN_URL=http://localhost:8096 \ JELLYFIN_API_KEY=... \ JELLYFIN_DATA_DIR=/var/lib/jellyfin \ STORE_PATH=/var/lib/ephyra/ephyra.db \ WORK_DIR=/var/lib/ephyra/work \ ./ephyra ``` It listens on `:8097` (`LISTEN_ADDR` to change) and logs JSON to stdout; the version is in the first `starting` line. As a systemd service: ```ini # /etc/systemd/system/ephyra.service [Unit] Description=ephyra After=network-online.target Wants=network-online.target [Service] User=jellyfin ExecStart=/usr/local/bin/ephyra Environment=JELLYFIN_URL=http://localhost:8096 Environment=JELLYFIN_API_KEY=... Environment=JELLYFIN_DATA_DIR=/var/lib/jellyfin Environment=STORE_PATH=/var/lib/ephyra/ephyra.db Environment=WORK_DIR=/var/lib/ephyra/work Restart=on-failure [Install] WantedBy=multi-user.target ``` `install -Dm755 ephyra /usr/local/bin/ephyra`, `mkdir -p /var/lib/ephyra && chown jellyfin /var/lib/ephyra`, then `systemctl enable --now ephyra`. To build the tarballs yourself instead of downloading them: `make dist` (writes the same set to `./dist/`). ## Configuration | Variable | Required | Default | Notes | |---|---|---|---| | `JELLYFIN_URL` | yes | — | e.g. `http://jellyfin:8096` | | `JELLYFIN_API_KEY` | yes | — | for Now Playing + the server-name header | | `JELLYFIN_DATA_DIR` | yes (unless `SOURCE=api`) | — | the read-only mount; DBs read from `/data/` or `/data/data/` | | `SOURCE` | no | `auto` | `file` \| `api` \| `auto`. `api` is not implemented yet | | `STORE_PATH` | no | `/data/ephyra.db` | Ephyra's own database | | `WORK_DIR` | no | `/data/work` | scratch space for DB copies; must be writable | | `LISTEN_ADDR` | no | `:8097` | | | `REFRESH_LIBRARY` | no | `30m` | how often to re-read the library | | `REFRESH_WATCH` | no | `10m` | how often to re-read the Playback Reporting DB | | `LIVE_POLL_INTERVAL` | no | `4s` | how often Now Playing polls Jellyfin while the page is open | | `DIRECT_READ` | no | `false` | skip the copy, read the live DB with `immutable=1`. Only if your mount is read-write | | `STREAM_CAPACITY` | no | unset | max concurrent streams; shown on Now Playing as a capacity hint | | `LOG_LEVEL` | no | `info` | `debug` \| `info` \| `warn` \| `error`; JSON to stdout | | `TZ` | no | `UTC` | e.g. `Europe/Kyiv`; affects month bucketing on the growth chart | ## How it reads data The Jellyfin mount is read-only, and SQLite won't open a WAL database read-only without writing a `-shm` sidecar. So each refresh copies the item DB — `jellyfin.db` (plus its `-wal` / `-shm`) — into `WORK_DIR`, queries the copy, and deletes it. If the file's mtime hasn't changed since the last successful run, the refresh is skipped entirely — libraries don't change often, so most runs do no work. Ephyra keeps its own copy of playback history in a `playback_events` table so the Profiles page and Watch Stats survive the Playback Reporting plugin's retention window. One row per play — single-digit MB a year on a home server, tens of MB on a busy multi-user one. Nothing prunes it; that's deliberate. On the first refresh after upgrading, Ephyra backfills it from whatever the plugin currently retains. Ephyra never writes to Jellyfin or to the mounted directory. ## Test against a real library `internal/source/file/queries.go` targets the `jellyfin.db` of Jellyfin 10.11 and 12.0 (`docs/schema-notes.md`), checked against one real server on 10.11.11 and 12.0.0. Builds vary. Do this after upgrading Jellyfin, or if the numbers look wrong. ### Get a copy of the item DB 10.11 and 12.0 keep it in **`jellyfin.db`** at `/data/jellyfin.db` — or `/data/data/jellyfin.db` on the linuxserver image. Older installs have `library.db` in the same place. Bring the `-wal` and `-shm` sidecars with it (WAL mode). Jellyfin writes to it while running. Either stop Jellyfin for the copy, or copy all three files live — Ephyra replays the WAL on its own copy, so a slightly torn read of a library that isn't mid-scan is fine. ```sh mkdir -p ~/ephyra-test/jf/data # Jellyfin in a container (run wherever that container is): cid=$(docker ps --filter name=jellyfin --format '{{.ID}}' | head -1) dir=$(docker exec "$cid" sh -c 'ls -d /config/data/data /config/data 2>/dev/null | head -1') for name in jellyfin.db playback_reporting.db; do for f in "$dir/$name" "$dir/$name-wal" "$dir/$name-shm"; do docker cp "$cid:$f" ~/ephyra-test/jf/data/ 2>/dev/null || true done done # Jellyfin not containerised: cp jellyfin.db* (and playback_reporting.db* if the # Playback Reporting plugin is installed) from /data/. # Jellyfin on another machine: do the copy there, then scp/rsync the dir over. ``` `playback_reporting.db` is optional — it's what the Watch Stats page reads. Skip it and that page shows an "enable this plugin" panel; everything else is unaffected. ### Check the schema ```sh sqlite3 ~/ephyra-test/jf/data/jellyfin.db .schema | less # or: JELLYFIN_DATA_DIR=~/ephyra-test/jf ./scripts/dump-jellyfin-schema.sh ``` Compare against `docs/schema-notes.md`. Mismatches (renamed tables/columns, `TopParentId` semantics, HDR/Dolby-Vision fields) mean `internal/source/file/queries.go` needs a tweak — the notes say which. Also check credit coverage, which feeds the Profiles people charts: ```sql SELECT b.Type, count(*) items, sum(CASE WHEN EXISTS(SELECT 1 FROM PeopleBaseItemMap m WHERE m.ItemId = b.Id) THEN 1 ELSE 0 END) with_credits FROM BaseItems b WHERE b.Type LIKE '%Movie' OR b.Type LIKE '%Episode' GROUP BY b.Type; ``` Low episode coverage is expected — it's what the series fallback handles. ### Run against it and eyeball the numbers ```sh JELLYFIN_URL=x JELLYFIN_API_KEY=x JELLYFIN_DATA_DIR=~/ephyra-test/jf \ STORE_PATH=~/ephyra-test/ephyra.db WORK_DIR=~/ephyra-test/work \ go run ./cmd/ephyra ``` Open `http://localhost:8097`. The totals, genre band, decade spread, disk-by-codec and library growth should match what you know your library actually holds. If a number is wrong, the query that produced it is in `internal/source/file/queries.go` and the rollup is in `internal/aggregate/library.go`. ## Development Two processes: ```sh cd web && npm run dev # :5173, proxies /api and /healthz to :8097 go run ./cmd/ephyra # :8097 ``` Point `JELLYFIN_DATA_DIR` at a directory with a `data/jellyfin.db`. There's a throwaway fixture at `testdata/library.fixture.sql` if you don't want to touch a real one: ```sh mkdir -p /tmp/jf/data && sqlite3 /tmp/jf/data/jellyfin.db < testdata/library.fixture.sql JELLYFIN_URL=x JELLYFIN_API_KEY=x JELLYFIN_DATA_DIR=/tmp/jf \ STORE_PATH=/tmp/ephyra.db WORK_DIR=/tmp/ephyra-work go run ./cmd/ephyra ``` `make test` runs the Go tests. `make build` builds the frontend then the binary. `make docker` builds the image. ## Design docs `docs/superpowers/specs/` has the design spec; `docs/superpowers/plans/` has the implementation plans. All three are done. ## License MIT — see `LICENSE`. Third-party components (the bundled fonts under the SIL Open Font License, Apache ECharts, and the rest) are listed in `NOTICES.md`. The **ephyra** name and the mark aren't covered by the MIT license — fork the code freely, but don't ship it under this name or logo in a way that implies it's the same project.