# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. Ephyra is a stats dashboard for Jellyfin: one Go binary with a React SPA baked in. It reads *copies* of Jellyfin's SQLite files on a schedule and serves rolled-up aggregates. It never writes to Jellyfin or to the mounted directory. **Five pages live.** Library Overview; Watch Stats & Cleanup; Now Playing; Profiles (per-user completion / rewatch / binge / taste, backed by an append-only `playback_events` spine that outlives the plugin's retention). The design specs and implementation plans live in `docs/superpowers/`. ## Commands Go (module `github.com/andriykohut/ephyra`, floor `go 1.26`): - `make test` → `CGO_ENABLED=0 go test ./...` - One package / one test: `go test ./internal/aggregate/ -run TestLibraryAggregates -v` - `make lint` → `go vet ./...` + `golangci-lint run`. **golangci-lint must be v2** — `.golangci.yml` is v2 schema. If it isn't on `PATH`, it's usually at `~/go/bin/golangci-lint` (installed via `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest`). - `make build` → builds `web/` then the binary (`./ephyra`). Frontend must build first. - `make dist [VERSION=vX.Y.Z]` → cross-compiled release tarballs into `./dist/` for linux/darwin × amd64/arm64, plus `SHA256SUMS`. Same set `release.yml` attaches to the GitHub release; `./dist` is gitignored. - Always `CGO_ENABLED=0` (pure-Go SQLite via `modernc.org/sqlite`). Frontend (in `web/`): - `npm run test` (Vitest) · one file: `npx vitest run src/routes/library.test.tsx` - `npm run lint` → `biome ci .` · `npx tsc -b` (type-check) · `npm run build` Dev — two processes: ```sh cd web && npm run dev # :5173, proxies /api and /healthz to :8097 go run ./cmd/ephyra # :8097 (needs a Jellyfin DB to read — see below) ``` Run against the checked-in fixture instead of a real Jellyfin: ```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 ``` ## Architecture **Data pipeline (one direction, no live reads):** ``` scheduler tick / POST /api/refresh └─ Source.LibraryFacts(ctx) FileSource: stat the item DB (jellyfin.db, under data/ or data/data/); if mtime == refresh_meta.source_mtime → skip, just bump the meta. Otherwise copy it + -wal/-shm into WORK_DIR, query the copy, delete it. └─ aggregate.Library(snapshot, loc) ← pure, no I/O, no SQL └─ store.WriteLibraryAggregates(...) ← one txn: DELETE + re-INSERT the agg_* rows Most API handlers read only from store's agg_* tables, never from Jellyfin. Two exceptions: the `/api/art/*` routes proxy Jellyfin's image endpoints live, and `/plays` reads the `playback_events` spine directly (see below). ``` - `internal/source` — the `Source` interface (`LibraryFacts`, `PlaybackEvents`, `Kind`) plus the plain structs it returns. `internal/source/file` is the only implementation. This is the pluggability seam: an `APISource` for Postgres-backed / remote Jellyfin is designed-in but not built — `SOURCE=api` returns an error today. `MTimer` is the optional file-source extra the scheduler uses for the skip check. - `internal/aggregate` — pure functions. `buckets.go` classifies raw values (resolution/codec/HDR/decade); `library.go` walks a snapshot once into `LibraryAggregates`. Tested with in-code snapshots and golden values. - `internal/store` — Ephyra's own SQLite (`STORE_PATH`). Migrations are embedded (`migrations/*.sql`), forward-only, integer-versioned by filename prefix; the runner owns `schema_migrations`, so a migration must **not** `CREATE` it. `agg_*` tables are fully rewritten per refresh; the API always sees a complete set. `refresh_meta` drives staleness. The one exception is `playback_events`: append-only, never rewritten — the watch job upserts new plugin rows into it, reads the whole history back, and feeds *that* to both `aggregate.Watch` and `aggregate.Profiles`. A play is keyed by `(at, user_id, item_id)`, the plugin's own session key: the plugin rewrites an open session's `PlayDuration` in place, so the upsert refreshes the duration on the existing row instead of minting a new one per refresh. `dim_credit` and `dim_played` join `playback_events` the same way: upsert-only, never rewritten, so a play in the spine keeps its cast and watched tick after Jellyfin drops the item -- `UserData` cascading on item delete means `dim_played` is the only surviving copy of the tick. `dim_jf_ref` is upsert-only for a different reason: it's a name → Jellyfin-id cache backing the "link out" feature on `agg_profile_people` / `agg_profile_taste` rows, not a spine join, and pruning it would just mean re-resolving names Jellyfin already answered. - `internal/scheduler` — one ticker per job + a manual-trigger channel, per-job mutex, mtime-skip. Runs each job once on startup. - `internal/api` — `net/http` `ServeMux` (method patterns). Every JSON response is `{ "data": ..., "meta": { "generated_at": , "stale": } }`; errors are `{ "error": { "code", "message" } }`. **Staleness** = no `refresh_meta` row, or last run failed, or older than 2× the interval. Unknown `/api/*` paths return 404 JSON; every other GET falls through to the SPA (`index.html`). List fields serialize as `[]`, never `null` — the SPA iterates them straight off the response, so the `store` reads run every slice through `orEmpty` before returning. `GET /api/profile/{userID}/plays` reads `playback_events` directly instead of a pre-rolled `agg_*` table — a paginated history can't be pre-rolled without either capping it or rewriting a slab every refresh, so it's an indexed keyset read instead. `GET /api/profile` (the list) touches the spine too, via `SpineCoverage`, for the lifetime first/last-play coverage line. - `internal/jellyfin` / `internal/live` — the Now Playing live path, off to the side of the pipeline above. `internal/jellyfin` is a thin read-only HTTP client (not a `Source`). `internal/live` holds the SSE hub whose poll loop runs **only** while a browser has `/now` open — zero standing load otherwise. `hub.Prime` is non-fatal: Ephyra boots even with the Jellyfin API down. - `cmd/ephyra/main.go` — wiring + graceful shutdown. Picks the source, fails fast with a clear message if the item DB isn't reachable. **Frontend (`web/`):** React 19 + Vite + TypeScript, Tailwind v4, TanStack Router + Query, Apache ECharts. - Built to `web/dist/`, embedded by `web/embed.go` (`//go:embed all:dist`) and served by the Go binary. **`web/dist/.gitkeep` must stay tracked** — without it `go build ./...` fails on a clean checkout with "pattern all:dist: no matching files found". Build output under `web/dist/` is gitignored. - Design tokens are Tailwind v4 `@theme` custom properties in `src/index.css`. Components are hand-rolled in `src/components/` (no shadcn/ui). `src/charts/` wraps ECharts; its theme reads the CSS custom properties at runtime, and the chart chunk is lazy-loaded so the page paints before it arrives. - The `Mark` component and `web/public/{favicon,icon}.svg` are the logo; brand assets and rules are in `docs/brand/`. ## Jellyfin schema `internal/source/file/queries.go` targets the `jellyfin.db` of Jellyfin **10.11 and 12.0** (EF-Core: `BaseItems` / `MediaStreamInfos` / `Users` / `UserData`), verified against a real DB on 10.11.11 (2026-08-30) and 12.0.0 (2026-09-10). `docs/schema-notes.md` records the layout and the gotchas (integer `StreamType` enum, `TopParentId` points at the physical folder not the `CollectionFolder`, no `Container` column — derived from `Path`). Older `library.db` installs (10.10 and earlier) are not handled. Re-run README's "Test against a real library" after any Jellyfin upgrade. `testdata/library.fixture.sql` + `testdata/playback_reporting.fixture.sql` are hand-built stand-ins; `internal/testsupport` loads them (`LibraryFixtureDB`, `PlaybackFixtureDB`, `TwoDBLayout`). **Watch job reads two DBs.** `Source.PlaybackEvents` opens the Playback Reporting plugin's `playback_reporting.db` for the events and does a targeted read of the `jellyfin.db` copy for user names + current title/series. Plugin absent → `source.ErrPluginUnavailable`, job records `plugin_available=false`, writes no `agg_watch_*` rows (not a failure). **Canonical IDs.** Every user/item/series id in Ephyra's store is dashless lowercase (`source.CanonID`). The plugin DB is already that shape; `jellyfin.db` is dashed-uppercase and is normalized on read. **Watch timestamps are not re-zoned.** `playback_reporting.db` writes server-local wall time; `aggregate.Watch` buckets `day`/`dow`/`hour` off the literal components and ignores `TZ` (unlike `aggregate.Library`, whose `jellyfin.db` `DateCreated` is UTC). ## Conventions - Documentation and code comments: casual and deadpan. No exclamation marks, no "simply" / "powerful" / marketing voice. - The `ephyra` name and mark are carved out of the MIT license (see `README.md` and `NOTICES.md`); bundled fonts are SIL OFL 1.1 (`web/public/OFL.txt` ships in the build).