Ctrl K
ComunidadGuías…

CLAUDE.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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": <RFC3339>, "stale": <bool> } }`;
  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).