CLAUDE.md
Rama main · réplica de lectura del repositorio original.
# Beacon - repository guide
A self-hosted music client: an Electron desktop app (and a Docker/web build
of the same frontend) for Navidrome/Subsonic, Jellyfin and Plex, with casting
to Sonos, AirPlay, Chromecast and DLNA. The user-facing description lives in
`README.md`; this file is the working context - how the pieces fit, what the
conventions are, and which mistakes have already been made once.
## The two halves
**`src/` - the frontend** (Vue 3 **Options API**, Vuetify 4, Pinia, TypeScript)
| Path | What it is |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/main/index.ts` | Electron main process (~700 lines): the window (one instance, size and position remembered), the Windows taskbar buttons, auto-update, and spawning the bundled connect binary. Media keys are the renderer's (`services/mediaSession.ts`) |
| `src/preload/index.ts` | The context bridge. Thin, and it has to stay that way |
| `src/renderer/src/` | The whole app. `views/` (routed pages, `views/mobile/` for the phone shell), `components/`, `stores/` (Pinia), `services/` (everything that is not a component: `subsonic/` the API client, `connect/` the backend client, `playback/`, `lyrics/`, `library/`), `layouts/`, `i18n/locales/` (en, de, es, fr, it) |
**`connect/` - the backend** (Python 3.13, FastAPI, `uv`)
| Path | What it is |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `connect/main.py` | App bootstrap. `load_dotenv()` runs _before_ the `core.*`/`routes.*` imports on purpose - several of them read their config at module import time |
| `connect/routes/` | HTTP surface. `proxy.py` is the important one: `/rest/*` is a transparent passthrough to the media server, or to `media/jellyfin_bridge.py` / `media/plex_bridge.py`, which speak Subsonic on their behalf |
| `connect/core/` | Sessions, auth, the playback clock, radio (relay, ICY metadata, history), waveform, recommendations |
| `connect/delivery/` | One module per cast target: `sonos.py`, `airplay.py`, `chromecast.py`, `dlna.py`, coordinated by `manager.py` |
| `connect/static/remote/` | The phone Remote Control UI - plain JS, no build step, served by connect itself |
| `connect/static/party/` | Build output (gitignored) of the party-mode guest page, `src/renderer/src/party/` via `pnpm build:party`. Reachable without a login, so `routes/party.py` serves only it and checks everything (see `docs/help/guides/party-over-the-internet.md`) |
The frontend never talks to the media server directly: every call goes
through connect's proxy, which is also what makes Jellyfin and Plex look like
Subsonic to everything above it. Casting is entirely connect's job - the
speaker fetches from connect, not from the app.
## Commands
```bash
pnpm dev # frontend + backend together (this is the one you want)
pnpm test:unit # frontend unit suite (jsdom)
pnpm test:layout # the *.browser.test.ts suite, real Chromium via Playwright
pnpm type-check # vue-tsc
pnpm lint # oxlint + eslint, both with --fix
pnpm format:check # prettier
pnpm screenshots # README screenshots as WebP, set up in scripts/screenshots/shots.mjs
cd connect && uv run pytest # backend suite
cd connect && uv run --group dev ruff check . # backend lint
cd connect && uv run --group dev ruff format . # backend format (line length 100)
```
CI runs exactly these (`.github/workflows/test-frontend.yml`,
`test-python.yml`), so a green local run means a green CI run.
## Testing
Three layers, and which one a test belongs in is a real decision:
- **jsdom** (`**/__tests__/*.test.ts`) - the default. Logic, stores, services,
component behaviour.
- **Real browser** (`**/__tests__/*.browser.test.ts`, `pnpm test:layout`) -
only for what jsdom cannot answer: computed layout (container queries,
`clamp()`, flex-wrap resolution, 3D transforms) and IndexedDB. jsdom would
"pass" these against a fake layout without checking anything.
- **pytest** (`connect/tests/`) - the backend, including the Jellyfin/Plex
bridges and every delivery target.
### How much to test where
The two halves are deliberately not held to the same standard.
**The backend gets thorough coverage.** A fault there surfaces as silence in
another room, across three device classes, often on hardware that isn't on
the development machine - the suite is the only place it can be caught early.
**The frontend is tested on function, not on appearance.** Its
backend-shaped half - `stores/`, `services/`, everything that computes,
reconciles or holds state - counts as logic and gets the same thorough
coverage as `connect/`; that is where the real bugs have been. Components are
tested for behaviour: a click does what it should, an empty result shows the
empty state, an error path is reachable. Layout is the exception, not the
routine - and only what jsdom genuinely cannot answer (see the browser layer
above).
**Don't pin down what is meant to change.** Counts of rendered elements,
their order, class names, copy, sizes - a test over those never finds a bug
and turns every deliberate design change red. Before writing one, ask what a
failure would mean: a real defect, or someone having redesigned something on
purpose? Only the first is worth a test. This is the question the mutation
check below does _not_ answer - a skeleton-loader count passes it happily and
is still worth nothing. When a test like that goes red on an intentional
change, delete it rather than adjusting its numbers.
Coverage is reported over _every_ renderer source file, not only the imported
ones - a number that rises as coverage gets narrower is worse than none. But
a renderer coverage percentage is not a goal in itself, and neither half's
number is the measure: **check a test by breaking the code it covers.** If
the suite stays green, the test does not test.
## Conventions
### Style and UI
`docs/styleguide.md` is the design system - **read it before building or
reshaping any UI**, and update it when a decision in it changes. Its
enforceable half lives as shared classes in
`src/renderer/src/assets/base.css` (`.beacon-panel`, `.panel-title`,
`.section-title`, `.eyebrow-label`, `.beacon-dialog`, `.beacon-markdown`,
`.mobile-row`);
`docs/styleguide.html` is
the same thing rendered, for looking at rather than reading.
Two rules that come up on every frontend change:
- **No Vuetify utility classes in templates.** No `d-flex`, `align-center`,
`w-100`, `mb-4`. Layout goes in a scoped `<style>` block with named
classes. Vuetify's _type_ classes (`text-body-small`,
`text-medium-emphasis`) are fine - they are the app's type scale.
- **Options API, not Composition API.** The whole frontend is written this
way; a `setup()` in the middle of it is the odd one out.
### Comments
Comment the _why_, and keep it short. A comment that has to get long is
usually a sign the code itself is unclear - clarify the code first (a named
helper, a named constant) and see what is left to say.
At length only for hard problems: browser and device quirks, measured
numbers, and places that look wrong but are deliberate. A number with no
explanation is still a number nobody dares change later. The story of an
investigation belongs in `docs/investigations/`, not in the code.
Existing comments are load-bearing - when you rewrite the code around one,
rewrite the comment too rather than leaving it describing what used to
happen.
### CHANGELOG.md
The changelog documents the gap between the **last release** and the next
one, not the development history in between:
- New work goes in an `[Unreleased]` section above the released ones. A
section with a version number and a date is published and is not edited.
- A bug introduced _and_ fixed within the same unreleased cycle gets **no**
"Fixed" entry - nobody outside ever saw it. Correct the existing
Added/Changed entry instead so it describes the end state.
- Entries are non-technical: what a user can see, not which function changed.
### .github/workflows/
Four rules, all of them already visible in the existing files:
- A leading `_` means the workflow only ever runs as a `workflow_call`
building block, never on its own. Its `name:` says `(internal) ...` too,
so a run in the Actions list is recognisable as somebody else's job.
- Everything else is `<verb>-<subject>.yml`, and the `name:` reads the same
way round: `Test ...`, `Publish ...`. The Actions sidebar sorts by name,
so verb-first is what keeps the test workflows together and the publish
ones together instead of interleaving them.
- `... (Manual)` in the name means dispatch-only. A workflow that also runs
on push doesn't carry it.
- A workflow triggered by `push` always names its branches, or it runs on
every Dependabot branch too - which for the packaging ones is four
platform builds at a time. Which branches depends on what the workflow
answers: the frontend and backend suites are development feedback and
watch `[main, development]`, while `test-ffmpeg` and `test-package`
answer a release question and watch `[main]` only, since the merge to
main is when that question comes up. Both kinds keep
`workflow_dispatch`, so either can be run earlier by hand.
### docs/investigations/
Every case that cost more than an afternoon leaves a file behind -
**including the theories that were ruled out**, which is the most valuable
part of an entry. Read `docs/investigations/README.md` before investigating
anything about streaming, casting or the playback clock; most of the folder
is playback, and it is where the dead ends are recorded.
It is not restricted to playback, or to bugs: a measurement series, an
approach that was tried and dropped, a decision nobody will reconstruct from
the diff belongs there too. A bug that was found on first look does not need
an entry. Keep those docs anonymous: no IPs, no real speaker or room names
("room A", "room B").
Entries sit in one folder per area (`phone-and-browser/`, `casting/`,
`streaming/`, `radio/`, ...), named after what was seen, never after the
status - the README has the conventions. A change made for a case and taken
out again still gets a line under "Ruled out" in its entry.
### docs/backlog/
What is still to be built or decided, same layout as the investigations:
one folder per area, one file per item, `README.md` as the index. Remove an
item when it is done instead of ticking it off. A finding that is not worth
fixing right now goes here only when asked for - but then in enough detail
that nobody has to ask again.
## Working agreements
- Work happens on the **`development`** branch and is merged to `main`. Check
which branch you are on before making changes.
- **Commits, tagged releases, and Docker image builds/pushes are the
maintainer's own step.** Staging changes, showing a diff and drafting a
commit message is the useful part; running `git commit`, `build.sh` or
`docker push` is not.
- A dev session may be running while you work. `pnpm build` writes to the
same `out/` the running app uses, and connect holds port 9181 - don't
build over a live session, and don't kill ports blindly.
- Other agents may be working in this repo at the same time. Uncommitted
changes or failing tests that are not yours: report them, don't fix them.
## Things that have already bitten someone
- **Electron preload:** `window.api` is silently `undefined` unless the
window is created with `sandbox: false` _and_ the preload is ESM. The
symptom was a login that 404'd, nowhere near the actual cause.
- **Vuetify 4 ships its CSS in cascade layers.** An unlayered rule beats a
layered one however specific the layered one is - `base.css` declares the
layer order for exactly this reason, and only the reset lives in a layer.
- **Artwork caching has three layers already** (in-memory, IndexedDB, and
connect's server cache). Do not add a fourth. Batching once quietly killed
HTTP caching here.
- **Waveforms are deliberately never cached** - decoding takes under a
second, and a cache would be a new thing that can go stale.
- **The bundled ffmpeg is built from a hand-written component list**, and
`./configure` ignores a name it doesn't know as long as something else in
the same comma list matched - so a forgotten format produces a build that
succeeds and a track that won't play. Adding a format to the app means
adding it to `build/ffmpeg/required-components` first; the backend suite
and `scripts/verify-ffmpeg.sh` then fail until the recipe catches up. Read
`build/ffmpeg/README.md` before touching anything in there.
- **Navidrome sends Subsonic clients a synthesised file path**
(`Artist/Album/01-03 - Title.mp3`), not the real one, unless that player
has "Report Real Path" enabled. Beacon shows what the server sent; that is
not a bug in the path row.
- **Party guests have their own copy of Now Playing:**
`party/components/GuestNowPlaying.vue` is a copy of Now Playing. A change
to `NowPlayingView` or its components does not reach party guests; before
a larger one, do `docs/plans/now-playing-shared-with-party.md` first
instead of changing both.