Ctrl K
ComunidadGuías…

README.md

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

Archivo original ↗Preguntar a KiBot
<p align="center">
  <img src="docs/brand/banner.png" alt="ephyra" width="720" />
</p>

# 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://<host>: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
<dir>` 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' <jellyfin-config>/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_<version>_linux_amd64.tar.gz
cd ephyra_<version>_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 `<that dir>/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 `<dir>/data/` or `<dir>/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
`<jellyfin-config>/data/jellyfin.db` — or `<jellyfin-config>/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 <jellyfin-config>/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.