Compare commits

..
7 Commits
Author SHA1 Message Date
logan 7de1b4edc1 docs: record the orientation fixes and the two new frontend fixtures
Build & publish Arch package / arch-package (push) Successful in 1m58s
Search index maintenance / maintain-index (push) Successful in 6s
CI / check (push) Successful in 2m23s
CI / e2e (push) Successful in 2m30s
CLAUDE.md gains backend/home and the two cross-cutting frontend pieces
a list or detail view now has to know about: explore-link's
always-navigate rule with its double-click grace, and
<catalog-scope-notice> with the catalogPending/catalogLoaded
distinction behind it.
2026-08-11 01:15:39 -04:00
logan ff687f0bd9 feat(home): populate the home page with start-listening shelves
The sidebar had a Home item that fell through to "Coming soon". What
was missing was not another view of the library — four of those exist,
sorted and complete — but the opposite: a complete, sorted library is
exactly what gives you nothing to play, because every entry point into
it is alphabetical and identical every time you open the app.

So a shelf is a *reason*, not a filter. Each one answers a different
question you might be asking when you do not know what you want (what
was I listening to, what is new, what do I keep coming back to, what
have I forgotten, what fits, what would I never pick myself) and each
says which question it answered — a row of covers with no explanation
is just another grid.

Two consequences run through it. Shelves are built from what the user
actually did — play counts, last played, import order — with random
sampling only where there is no signal to use, so randomness is the
fallback rather than the design. And a shelf with nothing behind it is
omitted instead of rendered empty: a fresh library legitimately gets
three, and an empty row labelled "on repeat" would be a lie.

The queries return album ids and nothing else, joined back to
GetAllAlbumsWithDetails in Go, so the album projection keeps having one
definition rather than one per shelf.
2026-08-11 01:15:34 -04:00
logan 62bb40fc4d fix(download): make "check now" actually check now, and say what it did
The button ran a normal reconcile pass, which honours each request's
retry backoff — so a request searched an hour ago was not due, nothing
was searched, and the button looked broken. The backoff is a promise to
the providers, not to the user: a person pressing "check now" *is* the
schedule, so a user-initiated pass ignores it and the loop still does
not.

"Nothing happened" also needed a reason. Summary now carries how many
requests are still being looked for and whether any download client is
enabled at all, which is the one cause of silence the user can fix —
and the requests tab says so above the list rather than leaving an
inert list to be interpreted.

The rest is the retry schedule finally being admitted to: rows show
when the next check falls due, "Looking for" explains that a request
sitting there is waiting rather than failing, and the page header says
how often the list is worked.
2026-08-11 01:15:23 -04:00
logan ba35858208 feat(explore): say whether a page is the catalog or your own copy
The album and artist pages draw from two sources and rendered
identically either way. An album showing one track because that is all
you own was indistinguishable from an album that has one track, and
both were indistinguishable from a page still waiting on a background
catalog fetch — so the answer to "is more coming?" was to keep
reloading and find out.

<catalog-scope-notice> names the source in one line: silent for full
catalog data, "still loading" while a fetch may land, "library only"
for an entity with no MBID (which will never fill in, so it points at
Autotag), and a retryable notice when the catalog had nothing to say.

Both pages needed a new distinction to drive it. loadingReleases and
its artist-side equivalents mean "something is renderable", which a
library stand-in satisfies — so catalogPending/catalogLoaded track the
different question of whether the catalog has actually answered.

Also fixes the artist page clobbering its library-hydrated discography
with an empty catalog result. An empty BrowseReleaseGroups means the
index has not built this artist yet, not that they released nothing.
2026-08-11 01:15:11 -04:00
logan 7c3c0e25b9 fix(ui): make every track, album and artist name navigate somewhere
A name linked only when the entity carried an MBID — and for tracks,
only when it carried two. That rule is invisible, so a track list read
as randomly broken: some titles were clickable, most were not, and
nothing on screen said why.

A name now always goes somewhere. Tagged entities open their
MusicBrainz page as before; untagged ones open the *library* page for
the same album or artist, which both detail views already support via
a local id — they just had no caller passing one. An untagged track
highlights by title, since a recording MBID is exactly what it lacks.

Links now fire on a genuine single click only. Every list these appear
in also plays a row on double-click, and the title is the widest thing
in the row, so the first click of that gesture lands on the link:
navigating immediately meant double-clicking a track title opened a
page instead of playing it, which the e2e playback suite caught. The
navigation is held for one double-click interval and dropped if the
second click arrives, while the dblclick itself is left to bubble to
the row — so rows do not need to know links exist.
2026-08-11 01:15:00 -04:00
logan 0ca37a31a6 fix(player): show mute in the volume indicator
Muting does not change the volume level, and VolumeChanged carried
nothing but that level — so pressing M silenced playback and left the
indicator showing the volume it still had. The UI had nothing to react
to.

Mute rides on its own event rather than widening the volume payload,
since the two are genuinely independent: a muted player at 40% is a
different state from a player at 0%, and only one of them comes back
when you unmute. The icon crosses out and dims, and the popup gains an
explicit Mute/Unmute so the keyboard shortcut is not the only way in.

MuteToggle also now takes the speaker lock (it was mutating the effects
chain from outside it) and refuses politely rather than dereferencing a
nil streamer when nothing has been loaded yet.
2026-08-11 01:14:47 -04:00
logan c48123f7a3 docs(planning): move plan 005 to completed with a recap 2026-08-10 23:56:31 -04:00
42 changed files with 3265 additions and 817 deletions

No files matched your search

@@ -1,692 +0,0 @@
# 005 — Agent development harness
**Status:** complete — all seven phases shipped
**Branch:** —
**Created:** 2026-08-10
**Follows:** 004-wanted-list
## Progress
| Phase | State | Notes |
|---|---|---|
| 1 — Reproducible fixtures | **done** | `cmd/gentestdata`, `make testdata`, `internal/testfixtures` |
| 2 — Headless launch | **done** | `scripts/dev-headless.sh`, `dev-stop.sh`, `seed-sandbox.sh` |
| 3 — Driving and seeing | **done** | event bridge, `data-testid`/aria pass, `backend/testctl`, `e2e/` smoke suite |
| 4 — Component coverage | **done** | Vitest 4 browser mode, 313 tests, `make ui-test`; `make bindings-check` |
| 5 — `events.Emit` wrapper | **done** | `backend/events/emit.go`, `Recorder`, 35 sites converted, service tests in `queue`/`config`/`playlist` |
| 6 — pi affordances | **done** | `.pi/skills/yellowjacket-dev/`, `.pi/prompts/e2e.md`, `.pi/journal.md`, `make skill-check` |
| 7 — CI that gates | **done** | `.gitea/workflows/ci.yml`, two jobs, both prototyped in a container first |
**Verified end to end after phase 7:** both jobs were built as shell
scripts and run to green in a bare `ubuntu:24.04` container before any
YAML existed, then the steps were transcribed *back out of the
workflow* and re-run in the same container to prove the transcription —
job 1 (lint × 3, test × 3, `tsc --noEmit`, 313 ui-tests,
`bindings-check`, `skill-check`) and job 2 (fixtures, seed,
`dev-headless`, 19/19 chromium, 19/19 **webkit**). Push-and-see was
not an acceptable loop here: a Gitea Actions run that never starts
looks identical to one that passed.
It found a real bug on day one. **`make lint` was linting three
configurations that nothing builds** — all three passes omitted
`webkit2_41`, so wails resolved `webkit2gtk-4.0`, which Arch still
ships and Ubuntu 24.04 dropped. The `dev` pass is the one that breaks,
because wails' own `app_dev.go` is `dev`-tagged and drags in the 4.0
assetserver that the other two passes never compile. The tag sets now
match `make test` exactly. "Lint passes" and "lint compiled what we
ship" were different claims, and only a second distro could tell them
apart.
Two decisions were settled by measurement rather than argument. The
**audio sink** is a four-line ALSA `null` PCM, no daemon: `InitSpeaker`
succeeds in 36 ms and the elapsed clock advances, because ALSA's null
plugin advances its pointer on a timer. The **explore artifact** is
stubbed at a dead address as `seed-sandbox.sh` already does — and
setting it for the *app* run too, which `dev-headless.sh` never did,
turned out to be worth 8x on suite wall clock. **Playwright's WebKit
is a required step**, not an advisory one: it had never been run
anywhere, so one throwaway container run turned a coin flip into a
decision (19/19, +11 s), and nothing in `e2e/` compares pixels, so a
WebKit failure is an engine bug rather than baseline noise.
**Verified end to end after phase 6:** all four tiers were re-run
green *before* anything was written — `make ui-test` (313),
`make lint` (0 issues × 3 configurations), `make test` (3 passes),
`make e2e` (19/19 against a seeded `dev-headless` app) — so the skill
documents commands that were observed working, not remembered.
`make skill-check` then verified the 25 make targets the skill cites
all exist, and was itself verified to fail on a missing one. The
remaining check is the one no tooling can do: an agent following
`.pi/skills/yellowjacket-dev/` cold on a real task, which should
happen before phase 7 encodes the same commands into CI.
**That cold run has now happened.** An agent that did not write the
skill brought the app up from a wiped `.dev/` and no fixture library,
drove an undocumented flow (queue panel + shuffle, asserted on
`QueueModeChanged`, confirmed against `queue.Queue.GetState`) and
stopped it, in about a minute with no dead ends; all four tiers then
re-ran green from that cold state. It found one real config bug —
`outputDir` in `.playwright/cli.config.json` resolves against the
shell's cwd, not the config file's directory, so every snapshot was
landing one level *above* the repo where a stale copy from the
previous session answered instead — and four missing or wrong steps
(`sandbox-seed` already runs `testdata`; `make ui-setup` /
`make e2e-setup` are undocumented once-per-clone prerequisites;
`snapshot` prints a path, not a tree; `make dev-stop` leaves the
browser session open). All fixed in place, with the detail in
`.planning/NOTES.md`.
**Verified end to end after phase 5:** all 35 `runtime.EventsEmit`
call sites across 14 files now route through `events.Emit`, and
`TestNoDirectRuntimeEmits` fails the build if a new one appears. Four
packages had each hand-rolled their own guard against the same
`log.Fatalf` (`library.emit`, `download.emit`, `autotagservice.
emitEvent` with its own `ctxReady` field, `playlist.emitEvent`) and
nine more sites guarded on `ctx != nil`, which does not actually
prevent it; all of that collapsed into one place. 16 new tests assert
what the *frontend receives* — queue mode/index/delta payloads,
config theme and shortcut snapshots, playlist create/add/delete —
none of which was reachable before. `make lint` (3 configurations),
`make test` (3 passes), `make bindings-check`, `make ui-test` and
`make e2e` (19/19 against the seeded headless app) all green.
**Verified end to end after phase 4:** `make ui-test` runs 313 tests in
a real Chromium in ~2 s with no app, no backend and no display — 196
covering all 13 stores plus the keyboard shortcut service, 117 covering
components (transport, sidebar, library filter, status indicator,
track-info, now-playing, queue panel) including a smoke mount of all 46
custom elements against an empty backend. `make ui-visual` adds six
`toMatchScreenshot` baselines. `make bindings-check` regenerates
`frontend/wailsjs` in ~1.5 s and was verified to fail on a renamed
bound method. `tsc --noEmit`, `make lint` (all three configurations)
and `make e2e` (19/19) all stayed green, and the one frontend fix the
tier surfaced was confirmed in the running app by screenshot.
**Verified end to end after phase 3:** `make e2e` runs 19 Playwright
specs against the seeded app — harness self-tests, library views
(31 fixture tracks, unicode, sidebar navigation), playback (play,
pause, elapsed time, volume round-trip), queue (population, shuffle
state) and the control surface (snapshot → mutate → restore, forced
event, SQL, input validation). All 19 pass; `make lint` is at 0 issues
across all three build configurations and `make test` is green.
**Verified end to end after phase 2:** `make sandbox-seed NAME=default`
built a seed by driving the real `AddLibrary` binding and waiting for
the real scan to reach 31 tracks; `make dev-headless SEED=default`
restored it and landed *in* the app with no first-run wizard;
`playwright-cli` clicked through to Artists and screenshotted six real
artists with generated cover art, unicode names and the long-artist
truncation case; `LoadFile` + `Play` produced audible playback with the
transport bar at 00:04.
One re-sequencing against the plan below: `sandbox-seed` is described
under phase 1 but shipped at the end of phase 2, because seeding *by
running the app* makes it a consumer of the launcher.
One bug found by the fixtures, not yet fixed: **WAV tags are
write-only.** `backend/tagwriter` writes them into a RIFF `id3 ` chunk;
`backend/metadata` reads through `dhowden/tag`, which has no RIFF
parser, so every WAV scans in untitled. Pinned by
`TestWAVTagsAreNotReadableYet`.
## Problem
A coding agent can develop the Go packages of this repo competently and
cannot develop the *application* at all. It can read 66k lines of
backend, run 31k lines of tests, and lint two build configurations. It
cannot start the app, see a window, click anything, or find out whether
a change it made to a Lit component rendered.
The gap is not "we lack tests". It is that every path to running
YellowJacket ends in a blocking GTK window:
| Entry point | Behaviour |
|---|---|
| `make dev` | launches a WebKit window, blocks the terminal forever |
| `make sandbox <n>` | same, plus an interactive name argument |
| `make fresh-install` | same, and lands on the first-run wizard every time |
So 265 bound methods across 11 services, 46 backend events, 33 Lit
component directories, 13 reactive stores and a 357-line keyboard
shortcut service have exactly one form of verification available to an
agent: `tsc --noEmit`.
Three secondary facts make it worse. `test_data/music_library_test/` is
referenced by three test files, is in `.gitignore`, is not on disk, and
has no generator — so the audio path and `YELLOWJACKET_INTEGRATION=1`
are unreachable from a clean clone. No Gitea workflow runs `make test`
or `make lint`; quality gating exists only in `lefthook.yml`, which is
local and `--no-verify`-skippable. And there is no `.pi/` directory, so
none of the awkward invocations (`-tags "webkit2_41 indexbuild"`,
sandbox lifecycle, log tailing) are wrapped in anything an agent can
call.
## The unlock
`wails dev` already runs a full HTTP + WebSocket dev server on
`localhost:34115` (`internal/frontend/devserver/devserver.go`). It
serves the real frontend assets, injects the real generated bindings,
and bridges every method call and every `runtime.EventsEmit` over a
websocket to the **same running Go backend** the desktop window is
attached to.
A plain Chromium can load `http://localhost:34115` and get a fully
functional YellowJacket. Not a mock, not a stub `wailsjs` layer: the
actual application, talking to the actual `explore`, `library`,
`player` and `queue` services, receiving the actual events. The
bindings land on `window.go`, so anything reachable from the frontend
is reachable from a one-line `page.evaluate`.
This is not a trick we invented. Wails v3's documentation ships an
"End-to-End Testing" guide that is exactly this, and the v2 community
arrived at the same answer independently
(`wailsapp/wails` discussion #4205). It is the sanctioned approach.
**The one caveat:** `devserver.Run` still calls `d.Frontend.Run(ctx)`,
which opens the GTK window and blocks. No flag suppresses it, and
nobody upstream has found a way around it. The app needs a display —
a virtual one.
## Validated end to end, 2026-08-10
The premise was proven before this plan was committed, on a scratch
`YJ_HOME` under `~/.cache/yellowjacket-harness`:
```
go build -tags "dev webkit2_41" -o build/bin/yj-dev .
setsid dbus-run-session -- xvfb-run -a ./build/bin/yj-dev \
-devserver localhost:34115 -assetdir frontend/dist
playwright-cli -s=yj open http://localhost:34115
```
| Claim | Result |
|---|---|
| App boots headless under Xvfb | yes, ~1 s; `:34115` listening |
| `YJ_HOME` isolates the sandbox | yes, own `yj.db`, untouched real install |
| Chromium loads the real app | yes, console shows `wails dev / Connected to backend` |
| a11y snapshot pierces shadow DOM | yes — sidebar, queue panel, transport buttons, all with stable refs, through Lit **and** Web Awesome roots |
| `window.go` carries the bindings | yes, all 11 services |
| A bound method round-trips to Go | yes — `queue.Queue.GetState()` returned real JSON |
| Events reach the browser | yes — `SetVolume(42)` produced `VolumeChanged` with payload `42` |
| Screenshot is readable by the agent | yes — full render, correct theme, fonts and icons |
| MPRIS registers | yes — `org.mpris.MediaPlayer2.yellowjacket` on the private bus |
| Audio initialises | **yes** — see below |
Five things the run taught that were not obvious beforehand:
- **No null audio sink is needed.** `dbus-run-session` replaces the
*bus*, not the runtime dir, so `/run/user/1000/pulse` stays reachable
and `InitSpeaker` succeeded in 17 ms. The mitigation planned for
Phase 3 is unnecessary on a developer machine. A CI container with no
`/run/user` will still need one.
- **The first-run wizard blocks every interaction.** The first click
attempt failed with `<first-run-wizard> intercepts pointer events`.
Phase 1 is not a convenience; nothing downstream works without it.
- **A malformed binding call hangs forever.** `SetVolume(0.42)` against
a `player.UserVolume` (an `int`) made the backend log
`error parsing arguments` and never fire the callback, so the
in-page promise never settled. Every harness call needs a timeout,
and the app log is the only place the reason appears.
- **Playwright's WebKit does not run on Arch.** Its Linux build links
Ubuntu 24.04 libraries — `libicu74`, `libWPEWebKit-2.0.so.1`,
`libflite` — none of which Arch provides. `--browser=webkit` is a
**CI-only** capability, not a local one. Chromium is unaffected.
- **Event listeners accumulate across calls.** Hooks registered by one
`eval` survive into the next, so a naive recorder double-counts. The
`initScript` must install exactly one recorder, and tests must reset
its buffer rather than re-register.
## Tooling decisions taken up front
Three things exist that we would otherwise have built badly.
**`@playwright/cli`** (`npm i -g @playwright/cli`) is Microsoft's
CLI-plus-agent-skills front end to Playwright, built specifically
because coding agents do better with terse commands than with MCP tool
schemas. `playwright-cli install --skills` drops the skills where an
agent finds them. It gives us, for free, everything this plan was
otherwise going to hand-roll:
| Need | Command |
|---|---|
| See the page | `snapshot` — a11y tree with stable `ref=eNN` handles, pierces open shadow roots |
| Search a big page | `find <text>` / `find --regex` |
| Call a bound method | `eval "() => window.go.player.Player.Play(1)"` |
| Screenshot for the agent to read | `screenshot --filename=` |
| Frontend errors | `console` — Lit render failures are currently invisible |
| Stub the explore artifact | `route <pattern>` |
| Keep a browser across separate shell calls | `-s=<session>` |
| Watch, and take over | `show` — live dashboard, per-session screencast, click in to grab the mouse |
Plus video and trace recording when a flow needs explaining rather than
asserting. It is v0.1.x and moving; `@playwright/mcp` is the same engine
behind an MCP server and is the fallback if the CLI churns.
**Playwright's WebKit build.** The shipped binary is WebKit2GTK, so a
Chromium-only suite would validate a renderer we do not ship.
`--browser=webkit` is not byte-identical to WebKit2GTK but shares the
engine core, and it is a flag rather than a project. The X11-grab of the
real GTK window drops to an optional spot-check.
**Vitest 4 browser mode.** Stable Browser Mode plus `toMatchScreenshot`
landed in Vitest 4.0, it is the Lit ecosystem's current recommendation
over `@web/test-runner`, and it uses Playwright as its provider — the
same browsers already cached. Components render in a real browser with
real shadow DOM, and get visual regression, **with no Wails, no backend,
no seeded library and no virtual display**. This is a tier the earlier
draft of this plan did not have and is the cheapest coverage available.
So the harness is three tiers, cheapest first:
1. **Vitest browser mode** — components and stores. Seconds. No app.
2. **`playwright-cli` against `:34115`** — real flows against the real
backend, driven interactively by an agent.
3. **Playwright specs** — the same thing, frozen as a regression suite,
in CI.
Only tier 2 and 3 need the app running, and therefore Xvfb.
## Phase 1 — Reproducible fixtures *(shipped)*
Nothing can be driven end-to-end against an empty library, and no two
runs are comparable unless the library is identical. No tool provides
this; it is ours to write.
**`cmd/gentestdata`** writes `test_data/music_library_test/`
deterministically: silent/tone audio at known durations across MP3,
FLAC, OGG Vorbis and WAV, with tags written by our own `tagwriter` so
fixtures and reader cannot drift. Coverage must include the cases the
app has code for — embedded cover art shared across an album (dedup),
missing and partial tags, unicode and RTL text, multi-disc, various
artists, and a deliberate duplicate pair for
`duplicate-tracks-dialog`.
`make testdata` generates it; it stays gitignored. A manifest hash lets
a test assert it is looking at the library it thinks it is.
**Seeded sandboxes.** `make sandbox-seed NAME=<n>` builds a `YJ_HOME`
with `config.toml` already pointing at the fixture library and `yj.db`
already scanned, so a run starts *in the app* rather than in the
first-run wizard. A `--fresh` variant deliberately omits config, because
the wizard is itself a surface that needs testing. Seeds rebuild from
scratch in seconds and are never hand-edited.
Explicitly **not** seeded: the explore artifact. `artifactfetch.go`
already honours `YJ_CORE_INDEX_URL` ("overridable for testing"), so
tests point it at a local file server holding a cut-down artifact —
`cmd/indexexport` already produces that shape, so a tiny core is a
config change, not new code. This also makes the failure paths testable
(404, checksum mismatch, the `206` partial-content resume). A nightly
job can use the real artifact.
## Phase 2 — Headless launch *(shipped)*
`scripts/dev-headless.sh` wraps:
```
dbus-run-session -- xvfb-run -a \
./build/bin/yj-dev -devserver localhost:34115 -assetdir frontend/dist
```
**Run the dev binary directly, not `wails dev`.** `app_dev.go` parses
`-assetdir`, `-devserver`, `-frontenddevserverurl` and `-loglevel`
straight from `os.Args`, so `go build -tags "dev webkit2_41"` produces a
binary that serves the identical devserver with no file watcher, no
rebuild supervisor and no reload broadcast. One process, one PID,
deterministic startup. `wails dev`'s watcher is a human ergonomic; an
agent that just edited a file knows to rebuild. (`-noreload` and
`-nogorebuild` exist if the watcher is ever wanted anyway.)
**`dbus-run-session` is not incidental.** A private session bus means
`backend/mediacontrols/mpris_linux.go` actually registers, which turns
MPRIS from "untestable" into a surface assertable with `busctl` —
properties out, `Play`/`Pause`/`Next` in.
The script backgrounds the process, writes `.dev/app.pid` and
`.dev/app.log`, polls `:34115` until it answers, then exits, leaving the
app up. `make dev-headless SEED=<n>`, `make dev-stop`, `make dev-logs`.
**Kill by saved PID, never `pkill -f`.** A `pkill -f` whose pattern
appears in the invoking shell's own command line kills that shell and
silently drops the rest of the chain.
New dependency: `xorg-server-xvfb`. Everything else — Playwright and its
Chromium, ffmpeg, `import`, `dbus-run-session`, `busctl`, `pactl` — is
already present.
**Audio needs nothing locally.** Measured: `InitSpeaker` succeeds under
`dbus-run-session` + Xvfb because the PulseAudio socket in
`/run/user/1000` is untouched by a private bus. Only a CI container
without `/run/user` needs a null sink (PipeWire null sink, or an ALSA
`null` PCM via a scoped `asoundrc`), and even then `app.go:325` joins
`InitSpeaker()` failure into `startupErr` rather than aborting, so
everything except playback still runs. Sample-level correctness stays
where it already is, in `backend/player` unit tests.
## Phase 3 — Driving and seeing the app *(shipped)*
What landed, and the five things that were not obvious:
- **`.playwright/cli.config.json`** now sets `testIdAttribute`, a
1440×900 viewport, timeouts and the `initScript`. Every path in it is
resolved **relative to the config file**, not the repo root.
- **`.playwright/init-events.js`** is the event bridge, and it hooks
`window.wails.EventsNotify` rather than `EventsOn` — every backend
event enters the page at that one call (`case "n"` in wails'
`ipc_websocket.js`), so one wrap captures all 46 whether or not the
app subscribes. `window.wails` does not exist when an initScript
runs, so it is wrapped via an accessor installed on `window` that
collapses back to a data property on assignment. It also carries
`ready()` and `call()`, the latter timing out so the
"malformed binding call hangs forever" trap is paid for once.
- **No closed shadow roots** anywhere: nothing in `frontend/src`
overrides `createRenderRoot`/`shadowRootOptions` and Web Awesome's
dist never calls `attachShadow` directly. Snapshots pierce
everything.
- **The `data-testid` pass was mostly an accessibility fix.** The five
transport buttons had no accessible name at all, so they were
unnameable to a screen reader *and* to a selector; they now carry
`aria-label` plus `aria-pressed` for the shuffle/repeat toggles.
`data-testid` was added only where a selector would otherwise be
structural: `track-row`, `queue-row` (both with `data-file-path`),
`main-content` (plus a `data-active-view` attribute, since which view
is showing was previously only inferable from which cached child
lacked `.view-hidden`), the now-playing title/artist and the seek
bar's two clocks. Sidebar items got `data-testid` and `aria-current`.
- **`backend/testctl`** mounts `/__test/` on the existing asset
handler: `health`, `db/snapshot`, `db/restore`, `emit`, `sql`. Gated
twice — the implementation is behind the `dev` build tag with a no-op
`!dev` twin, and it refuses to register unless `YJ_TESTCTL=1`, which
`dev-headless.sh` sets and `make dev` does not.
- **`e2e/`** is its own npm package (`make e2e`), deliberately not
inside `frontend/`, so phase 4's Vitest browser mode does not have to
share a package with the Playwright runner.
The original plan for the phase follows.
- `playwright-cli install --skills`, and a project
`.playwright/cli.config.json` setting `testIdAttribute`, viewport, and
an `initScript`.
- **The `initScript` is the event bridge.** It runs before any app
script, so it can hook `EventsOn` and buffer all 46 backend events on
`window.__yjEvents`, read back with `eval`. Half of what this app does
is push-driven — scan progress, job updates, download progress,
`WantedListChanged` — and assertions must await an event, not a
timeout.
- Add `data-testid` where selectors would otherwise be structural. First
task of the phase is confirming nothing in the Lit / Web Awesome tree
uses a *closed* shadow root, which would defeat snapshots.
- A **dev-only control surface**. `backend/assets/handler.go` is ours and
already has `RegisterHandler(pattern, handler)`, so `/__test/...` can
be mounted on the same port with no new server: seed, snapshot and
restore the SQLite DB mid-run, force backend-internal state. Roughly
five endpoints, compiled out of non-dev builds. This is the residue of
what Playwright genuinely cannot reach — everything browser-side is
already covered by the CLI.
**Screenshots as the primary agent primitive.** `screenshot --filename=`
then reading the PNG is the feedback loop that makes UI iteration
possible at all, and it matters more than the assertion suite built on
top of it. `snapshot` is the cheaper companion for structure.
**Smoke suite**, once flows are stable, frozen as Playwright specs:
first-run wizard, library views (artists / genres / cover grid / track
list), playback and queue manipulation, playlist and smart-playlist
editing, explore search and detail pages, settings (the HTMX/templ path,
which renders differently from everything else), jobs, downloads.
**Renderer fidelity is CI-only.** Playwright's Linux WebKit is built
against Ubuntu 24.04 and will not start on Arch (missing `libicu74`,
`libWPEWebKit-2.0.so.1`, `libflite`); the download succeeds and the
binary then fails to link. So `--browser=webkit` runs in Job 2 of CI,
where the runner image is Debian-family, and local work is Chromium
only. The X11 grab of the real GTK window stays available as an
optional spot-check for views where WebKit2GTK-specific rendering
matters — and is the *only* WebKit2GTK signal obtainable on this
machine.
**Two live frontends, one backend.** The GTK window and the browser are
both websocket clients of the same backend. This is supported —
`devserver.go` keeps a client map and `notifyExcludingSender`
deliberately fans frontend-emitted events out to the other clients *and*
the desktop frontend; `-browser` exists for exactly this. The risk is
not the transport but our own singletons: 13 stores × 2 instances means
duplicate cover-art fetches on connect and two clients able to issue
`player.Play`. If that proves noisy, the fix is contained — our asset
handler can serve a blank page to the WebKitGTK user agent under
`YJ_HEADLESS=1`, making the window inert. Start without it.
## Phase 4 — Component and store coverage *(shipped)*
What landed:
- **The Wails fake is the whole trick.** Everything in
`frontend/wailsjs/` is a pure passthrough to `window.go` and
`window.runtime`, so `test/support/wails-fake.ts` replaces those two
globals and every test then runs the *real* generated bindings and
the *real* store code. No module mocking, and no second description
of the Wails layer free to drift. Its event dispatcher mirrors
`desktop/events.js`, including `maxCallbacks` expiry and the fact
that a frontend `EventsEmit` notifies local listeners before Go.
- **Stores are singletons constructed at import**, so the fake is
installed from `setupFiles`, which runs first. A handful of stores
read config in their constructor before any test can stub, so the
setup file carries import-time defaults — without them a store
caches `undefined` where Go would have sent `[]`, and every consumer
crashes on `.length` in a way that looks like a component bug.
- **`make ui-test` / `ui-watch` / `ui-visual` / `ui-visual-update`.**
Visual regression is opt-in (`YJ_VISUAL=1`) because baselines are
font-hinting and compositing sensitive; the default run asserts
behaviour only, so nobody's loop breaks over antialiasing.
- **`make bindings-check`** (`scripts/bindings-check.sh`) runs
`wails generate module` and fails on a dirty tree, ignoring the file
modes the generator churns. Now in `lefthook.yml` pre-commit; the
Vitest suite is in pre-push.
- Two frontend bugs the tier found: `ScrollManager.setupResizeObserver`
threw an unhandled rejection on an empty library (fixed, one guard),
and `themeStore.loadFromBackend`'s failure handler throws again on
the state that failed it, so it cannot recover (left alone —
reachable only if the backend returns an empty accent).
The original plan for the phase follows.
Vitest 4 browser mode with the Playwright provider, in `frontend/`.
- The 13 stores and the keyboard shortcut service. Queue mutation,
shuffle, repeat transitions, explore cache invalidation and shortcut
dispatch are near-pure TypeScript with zero tests today.
- Component rendering for the 33 component directories, with
`toMatchScreenshot` visual regression per component. Real browser,
real shadow DOM, no app, no display — this is where the bulk of UI
regression should live, leaving e2e for flows.
- **Binding drift check.** `frontend/wailsjs/` is generated by
`wails build`, *not* `go generate`, so the existing pre-commit codegen
check does not cover it. A renamed Go struct field currently surfaces
at runtime, in a window. Add a target that regenerates bindings and
fails on a dirty tree.
## Phase 5 — An `events.Emit` wrapper *(shipped)*
What landed:
- **`events.Emit(ctx, name, data...)`** drops an event that has
nowhere to go, at debug level, instead of taking the process down.
**`events.Deliver`** is the same call returning `ErrNoRuntime`, for
the one caller that must know: `/__test/emit`, whose job is to
impersonate a backend emit and which would otherwise answer `200`
for an event that reached nobody.
- **The sink is carried in the context**, not in a package-level
variable — `events.WithSink(ctx, rec)` — so parallel tests cannot
observe each other's events and production emits pay no
synchronisation cost. `events.Recorder` implements it with
`Events`/`Named`/`Names`/`Count`/`Last`/`Reset` and a `Wait` that
blocks on background emitters (scan progress, `SetQueue` phase 2).
- **Enforcement is a test, not a linter.** golangci-lint runs once per
build configuration, so a stray emit in an `indexbuild`- or
`dev`-tagged file would only be seen by the pass that compiles it;
`TestNoDirectRuntimeEmits` walks the tree and sees all of them.
- **The tier it unblocks, exercised**: `backend/queue` (7),
`backend/config` (5), `backend/playlist` (4). Playlist is the one
that matters beyond the wrapper itself — it proves the pattern on a
service whose emits interleave with SQLite writes and M3U8 file
writes, and its test reads the playlist back the way the frontend
would on receipt of the event.
Deferred out of this phase: a general `backend/playlist` CRUD suite.
The service is 2,900 lines with no CRUD coverage today, and that is
its own piece of work rather than a rider on a mechanical refactor.
The original plan for the phase follows.
34 call sites use `runtime.EventsEmit` directly. `runtime.getEvents`
(`runtime.go:47`) `log.Fatalf`s unless `ctx.Value("events")` satisfies
`frontend.Events` — an interface under `wails/v2/internal/`, which we
cannot implement. So none of those code paths can run outside a real
Wails app, and in-process service tests are impossible.
A thin `events.Emit(ctx, name, data...)` in `backend/events`,
delegating to `runtime.EventsEmit` normally and to a recorder when a
test sink is installed, unblocks that. It is mechanical, and it pays for
itself independently as the one place to log or trace all 46 events.
Sequenced after the e2e tiers because it is a refactor touching many
packages, and the tiers above deliver value without it.
## Phase 6 — pi affordances *(shipped)*
What landed, and the one decision that mattered:
- **`.pi/skills/yellowjacket-dev/`**, a directory rather than a flat
file. Only a skill's description is always in context, so `SKILL.md`
holds the tier decision table, the canonical command sequences and
the five gotchas — the last inline rather than in a reference,
because they are needed *before* the failure — and
`references/{harness,fixtures,ui-tier,schema-change}.md` hold the
per-surface depth.
- **The split from `CLAUDE.md` is grammatical, not topical.** A topical
split is what rots: every new fact has two plausible homes. Three
docs, three tenses — `NOTES.md` past (measured, dated, append-only),
`CLAUDE.md` present (what the system is), the skill imperative (what
to run). CLAUDE.md's harness section lost about half its length to
this; leaving both would have been exactly the duplicate description
this repo has a standing rule against.
- **`make skill-check`** makes the rule enforceable rather than
aspirational: every command in `.pi/**/*.md` must be a real make
target, so the Makefile stays the source of truth for *how* to invoke
something and the skill only decides *which* and *in what order*. A
pre-commit hook; instant.
- **`.pi/prompts/e2e.md`** treats promotion as a transcription with
four fixed substitutions (snapshot refs → testids, sleeps →
`waitForEvent`, raw `window.go` → `callBinding`, short fixture →
`LONG_TRACK`) and three runs — pass, pass again, pass after a DB
restore — because the characteristic failure of a promoted spec is
depending on state the hand-driving left behind.
- **`.pi/journal.md`**, per the `/handoff` convention.
The original plan for the phase follows.
With the mechanics settled, wrap them. Much less than the first draft
assumed, because `playwright-cli`'s own skills cover browser work.
`.pi/` gains:
- **`skills/yellowjacket-dev/`** — the build-tag matrix, the two-file
schema rule, seed and sandbox lifecycle, the harness commands, and
when to reach for which of the three test tiers. `CLAUDE.md` has the
architectural half; this is the operational half. It must also carry
the gotchas the live run surfaced: time out every binding call, check
`.dev/app.log` when one hangs, and never assume a click will land
while the first-run wizard is up.
- **`settings.json`** pointing at `../.claude/skills`, because
`playwright-cli install --skills` writes to `.claude/skills/` and pi
does not discover that path by default. *(Already in place.)*
- **`.pi/journal.md`**, per the `/handoff` convention.
- A `/e2e` prompt template for promoting an exploratory
`playwright-cli` session into a committed spec.
No custom extension. Browser control is a solved, actively maintained
problem and a hand-rolled version would be worse and would rot.
## Phase 7 — CI that actually gates *(shipped)*
What landed, and the decisions behind it:
- **One image for both jobs, `ubuntu:24.04`.** Not `golang:1.25`,
because job 1 runs `make ui-test` — Vitest *browser* mode — so the
"fast job needs no browser" split does not survive contact. Not the
Playwright image either, because `e2e/` pins `@playwright/test`
^1.56 and `frontend/` pins `playwright` ^1.62, so a prebuilt browser
set matches at most one of them. Ubuntu 24.04 is also what
Playwright's WebKit links against, which job 2 needs.
- **Caching needed no runner-side change.** `valid_volumes` is already
a glob over the runner's cache root, and `GOMODCACHE` / `GOCACHE` /
`GOLANGCI_LINT_CACHE` are mounted and exported for every job by
`container.options`. Only the Node-side caches (browsers, pnpm store,
the Go tarball) are declared in the workflow.
- **The repo is cloned by hand**, as the other three workflows do:
`actions/checkout` is a JS action and needs node in the container
before any step has had a chance to install it.
- **Failure output goes to the job log, not only to an artifact.**
`.dev/app.log` is tailed into the log on failure so `gitea_ci
job_logs` can reach it, with the Playwright report uploaded
alongside as `continue-on-error` so a broken upload cannot mask the
real failure.
The original plan for the phase follows.
`.gitea/workflows/ci.yml` — the repository has three workflows and none
of them test anything, so `gitea_ci` currently reports only packaging
jobs, which actively misleads an agent checking whether a push was
healthy.
- **Job 1 (fast, no display):** `make lint`, both `make test` passes,
`tsc --noEmit`, Vitest browser mode.
- **Job 2 (display):** Xvfb + `dbus-run-session` + a seeded sandbox +
`make dev-headless` + the Playwright smoke suite, with screenshots and
traces uploaded on failure.
Job 2 depends on the fixture generator and the stubbed artifact, so it
lands last.
## Order and why
1 and 2 are the hard blockers and are worth doing even if nothing else
follows — a seeded, scriptable, non-blocking launch is the difference
between an agent that can and cannot run this app. 3 is the payoff and
is now mostly configuration. 4 is the cheapest coverage per hour and can
proceed in parallel with everything else, since it depends on none of
it. 5 is a refactor that unblocks a fourth tier we do not have yet. 6 is
ergonomics and should wait until the commands stop changing. 7 is last
because it depends on all of it.
## Risks
- **`@playwright/cli` is v0.1.x.** Interface churn is likely. The
mitigation is that `@playwright/mcp` is the same engine behind a
different front end, so a switch is a config change, and the specs
written in Phase 3 are plain Playwright either way.
- **Playwright's WebKit is not WebKit2GTK, and does not run here at
all.** Closer than Chromium in CI, unavailable locally. A
GTK-specific rendering bug can still escape, and will not be caught
until CI runs — or ever, for views not in the smoke suite.
- **Xvfb is X11, and the app has a Wayland-specific NVIDIA workaround**
(`main.go`'s DMABuf disable). CI will not exercise the Wayland path at
all. Acceptable — that path is a crash workaround, not a feature — but
it should be a known blind spot rather than a surprise.
- **Seeds are a second description of a valid `YJ_HOME`.** If the
generator drifts from what the app actually writes, tests pass against
a state no real install has. Seeds must be produced by *running the
app*, not by writing config and DB rows by hand — the same discipline
`sql/schemas/` gets, for the same reason.
## Deferred
- Driving the real WebKit2GTK window directly.
`WEBKIT_INSPECTOR_SERVER=127.0.0.1:9222` exposes WebKit's remote
inspector, but the protocol is not CDP and Playwright cannot attach.
A bespoke client is the only route and is not worth it.
- Wails v3, whose e2e story is better documented and whose dev server is
the same idea on port 9245. Not a reason to migrate.
- Component testing via `playwright-ct-web`. Vitest browser mode covers
the same ground with fewer moving parts and a first-party visual
regression story.
@@ -0,0 +1,175 @@
# 005 — Agent development harness
**Status:** implemented
**Branch:** main
**Created:** 2026-08-10
**Shipped:** 2026-08-11 (`5ca6cad`, `ccacd67`)
**Follows:** 004-wanted-list
## Problem
A coding agent could develop this repo's Go packages competently and
could not develop the *application* at all. It could read 66k lines of
backend, run 31k lines of tests and lint two build configurations. It
could not start the app, see a window, click anything, or find out
whether a change to a Lit component rendered.
The gap was not missing tests. Every path to running YellowJacket ended
in a blocking GTK window — `make dev`, `make sandbox <n>` and
`make fresh-install` all launch a WebKit window and never return the
shell. So 265 bound methods across 11 services, 46 backend events, 33
component directories, 13 reactive stores and a 357-line keyboard
shortcut service had exactly one form of verification available:
`tsc --noEmit`.
Three secondary facts made it worse. `test_data/music_library_test/` was
referenced by three test files, gitignored, absent, and had no
generator, so the audio path was unreachable from a clean clone. No
workflow ran `make test` or `make lint` — gating existed only in
`lefthook.yml`, which is local and `--no-verify`-skippable. And there
was no `.pi/`, so none of the awkward invocations were wrapped in
anything an agent could call.
## The unlock
`wails dev` already runs an HTTP + WebSocket dev server on
`localhost:34115` (`internal/frontend/devserver/`). It serves the real
frontend assets, injects the real generated bindings, and bridges every
method call and every event over a websocket to the **same running Go
backend** a desktop window attaches to. A plain Chromium pointed at
that port gets a fully functional YellowJacket — not a mock, not a stub
`wailsjs` layer. This is the sanctioned approach; Wails v3 ships a guide
for it and the v2 community reached the same answer independently
(discussion #4205).
The one caveat: `devserver.Run` still calls `d.Frontend.Run(ctx)`, which
opens the GTK window and blocks, with no flag to suppress it. So the app
needs a display — a virtual one.
## What shipped
117 files, ~14.6k lines. Four test tiers, cheapest first:
| Tier | Command | Cost | Needs the app? |
|---|---|---|---|
| Components and stores | `make ui-test` | ~2 s, 313 tests | no |
| Services, in-process | `make test` | 3 passes | no |
| Exploration | `make dev-headless` + `playwright-cli` | interactive | yes |
| Frozen regressions | `make e2e` | ~20 s, 19 specs × 2 browsers | yes |
**Fixtures** (`cmd/gentestdata`, `make testdata`, `internal/testfixtures`).
31 tracks across MP3/FLAC/OGG/WAV in ~1 s, deterministic, gitignored,
covering the cases the app has code for: shared album art (dedup),
missing and partial tags, unicode and RTL, multi-disc, various artists,
a deliberate duplicate pair. Tests select by *case*
(`CaseCoverDedup`, `CaseUnicode`, …) rather than by path, and skip
themselves when the library has not been generated.
**Headless launch** (`scripts/dev-headless.sh`, `dev-stop.sh`,
`seed-sandbox.sh`). `dbus-run-session -- xvfb-run -a` around the
`dev`-tagged binary, backgrounded, writing `.dev/app.pid` and
`.dev/app.log` and returning once `:34115` answers. The dev binary is
run directly rather than through `wails dev`: `app_dev.go` parses
`-devserver`/`-assetdir` from `os.Args`, so one process with a
deterministic startup replaces a file watcher and rebuild supervisor an
agent does not want. `dbus-run-session` is not incidental — a private
session bus makes MPRIS actually register.
**Driving and seeing.** `.playwright/init-events.js` records every
backend event on `window.__yjEvents` by wrapping
`window.wails.EventsNotify`, the single choke point all 46 events pass
through, so assertions await an event rather than a timeout. It also
provides `ready()` and a `call()` that times out. `backend/testctl`
mounts `/__test/` on the existing asset handler — `health`,
`db/snapshot`, `db/restore`, `emit`, `sql` — gated twice, behind the
`dev` build tag and behind `YJ_TESTCTL=1`. A `data-testid`/aria pass
turned out to be mostly an accessibility fix: the five transport
buttons had no accessible name at all.
**Component coverage** (`frontend/test/`, Vitest 4 browser mode).
`frontend/wailsjs/` is a pure passthrough to `window.go` /
`window.runtime`, so faking just those two globals runs the *real*
generated bindings and the *real* store code — no module mocking, and
no second description of the Wails layer free to drift.
`make bindings-check` regenerates `frontend/wailsjs` in ~1.5 s and
fails on a dirty tree, closing the gap where a renamed Go field first
appeared at runtime in a window.
**`events.Emit`** (`backend/events/`). `runtime.getEvents` `log.Fatalf`s
on any context lacking wails' internal `"events"` value — any
`context.Background()` — so 35 emit sites could not run under test and a
background worker could take the app down. All 35 now route through one
wrapper that drops at debug level instead. Four packages had each
hand-rolled the same guard; nine more guarded on `ctx != nil`, which
does not help. The test sink rides in the context
(`events.WithSink`), and `TestNoDirectRuntimeEmits` walks the tree —
not a lint rule, because lint runs once per build configuration and
would miss a stray emit in a tagged-out file.
**pi affordances** (`.pi/`). `skills/yellowjacket-dev/` is the
operational manual; `prompts/e2e.md` promotes a hand-driven session
into a spec; `journal.md` is the work log. `make skill-check` fails a
commit if the skill cites a make target that does not exist.
**CI that gates** (`.gitea/workflows/ci.yml`). Two jobs in
`ubuntu:24.04`: `check` (lint ×3, test ×3, `tsc --noEmit`, `ui-test`,
`bindings-check`, `skill-check`) and `e2e` (Xvfb + private bus +
fixtures + seed + `dev-headless` + Playwright on **Chromium and
WebKit**). The other three workflows only package, so `gitea_ci`
previously reported nothing about whether a push was healthy.
## Decisions worth keeping
- **The split between the three docs is grammatical, not topical.**
`NOTES.md` past, `CLAUDE.md` present, the skill imperative. A topical
split rots because every new fact gets two plausible homes.
- **Seeds are produced by running the app**, never by hand-writing
`config.toml` and DB rows — the same discipline `sql/schemas/` gets,
for the same reason. A hand-built `YJ_HOME` is a second description
of a valid one and will drift.
- **The Makefile is the source of truth for *how* to invoke something**;
the skill only decides *which* and *in what order*, and
`make skill-check` enforces it.
- **Verify in a fresh clone, not a copy of the working tree.** The CI
prototype ran both jobs in one mounted directory and so consumed a
`frontend/dist` an earlier job had built — hiding that `main.go`
embeds it and every Go typecheck needs it. The question is not
clean-vs-dirty but *whose* dirt.
- **`make lint`'s tag sets must equal `make test`'s.** Without
`webkit2_41` wails resolves `webkit2gtk-4.0`, which Arch ships and
Ubuntu 24.04 does not, so lint was checking a configuration that only
built on one distro. CI caught this on its first run.
- **Playwright's WebKit gates** because it was measured (19/19) rather
than assumed, and because nothing in `e2e/` compares pixels — so a
failure is an engine difference, not baseline noise. It is the only
WebKit2GTK signal obtainable, since it cannot start on Arch at all.
## Known blind spots
- **Xvfb is X11**, and `main.go` carries a Wayland-specific NVIDIA
DMABuf workaround. CI never exercises that path. Acceptable — it is a
crash workaround, not a feature — but it is a blind spot, not a
surprise.
- **Playwright's WebKit is not WebKit2GTK.** Closer than Chromium,
still not the shipped renderer. A GTK-specific rendering bug can
escape, and will for any view not in the smoke suite.
- **The fixture hash is deterministic per ffmpeg, not across versions**
(`5425fbb454a2` on Arch, `599a8dd4f152` on Ubuntu 24.04). Nothing
asserts a literal hash; a test that did would be portable by accident.
## Left open, deliberately
- **WAV tags are write-only.** `backend/tagwriter` writes them into a
RIFF `id3 ` chunk; `backend/metadata` reads through `dhowden/tag`,
which has no RIFF parser, so every WAV scans in untitled. Found by
the fixtures and pinned by `TestWAVTagsAreNotReadableYet`. The fix is
small: unwrap the chunk, hand the payload to `tag.ReadFrom`.
- **`themeStore.loadFromBackend`'s failure handler cannot recover** — it
re-derives the colour ramp from the state that just failed it. One
line; reachable only if the backend returns an empty accent.
- **`backend/playlist` has no CRUD suite.** 2,900 lines; phase 5 added
four emit-focused tests. Its own piece of work.
- **Driving the real WebKit2GTK window.**
`WEBKIT_INSPECTOR_SERVER` exposes WebKit's remote inspector, but the
protocol is not CDP and Playwright cannot attach. A bespoke client is
the only route and is not worth it.
@@ -0,0 +1,89 @@
# 006 — Orientation fixes: knowing where you are and what you're looking at
**Status:** implemented
**Branch:** main
**Created:** 2026-08-11
**Follows:** 005-agent-development-harness
## Problem
Six reports from using the app, which turned out to be one theme with
six faces: **the UI knew things it did not say.**
1. Some track names in the track list were links, most were not. The
rule (has both a release-group *and* a recording MBID) was invisible,
so the list looked randomly broken.
2. Opening an album sometimes showed the full catalog tracklist and
sometimes only the tracks the user owned, with nothing on screen
distinguishing the two — or distinguishing either from "still
fetching".
3. The same on artist pages: a discography that was the artist's, or a
discography that was the user's shelf, rendered identically.
4. "Check now" on the requests list appeared to do nothing, because it
honoured each request's retry backoff — a request searched an hour
ago was not due, so a deliberate button press produced silence.
5. Pressing **M** muted playback and left the volume indicator
unchanged, because mute does not change the volume *number* and
`VolumeChanged` carried nothing else.
6. The home page did not exist. The sidebar had a Home item; it fell
through to "Coming soon: home".
## What shipped
**Backend**
- `events.MuteChanged` (bool), emitted alongside `VolumeChanged` so the
UI has something to react to when silence is the only thing that
changed. `Player.Muted()` for symmetry; `MuteToggle` now takes the
speaker lock and refuses politely when no streamer exists.
- `download.Reconciler.RunNow` — a forced pass that ignores backoff,
backed by a new `ListWantedDownloadRequests` query. `RunOnce` (the
loop) still honours it: the backoff is a promise to the providers,
not to the user, and a person pressing a button *is* the schedule.
`Summary` gained `Waiting` and `NoProviders` so "nothing happened"
can be reported with a reason.
- `backend/home` — the shelf builder, with queries in
`sql/queries/home.sql` that return album ids only, joined back to
`GetAllAlbumsWithDetails` in Go rather than restating the album
projection six times. A shelf with nothing behind it is omitted.
**Frontend**
- `explore-link.ts` rewritten: a name always goes somewhere. No MBID
means the *library* page for the same album/artist (both detail views
already accept a local id), resolved through the library store, with
an untagged track highlighted by title instead of by recording MBID.
Links now fire on a genuine single click only — see below.
- `<catalog-scope-notice>` — one banner, four states (`catalog`,
`loading`, `library`, `unavailable`), used by both detail pages. The
album and artist pages grew an explicit `catalogPending` /
`catalogLoaded` pair, because `loadingReleases` already meant
"something is renderable" and a library stand-in satisfies that.
- Artist page: an empty `BrowseReleaseGroups` no longer wipes the
library-hydrated discography — an empty catalog answer means "not
indexed yet", not "released nothing".
- Downloads: a no-client banner, per-request "next check in …", honest
idle summaries, and copy that says the retry schedule exists.
- `<home-view>`: shelves as horizontal rows; a cover opens the album, a
play button plays it.
## The one thing worth remembering
**Making every track name a link broke double-click-to-play**, and the
e2e playback suite caught it: the title is the widest thing in a row,
so the first click of the double-click landed on the link and navigated
away. Fixed in one place — `singleClick()` in `explore-link.ts` holds
the navigation for one double-click interval (250 ms) and drops it if a
`dblclick` arrives, while leaving the dblclick itself to bubble to the
row. Rows do not need to know links exist.
This is exactly the failure mode plan 005's e2e tier was built for; it
was invisible before the change because the seeded fixture library has
no MBIDs, so no track name was a link.
## Verification
`make lint` (3 configs), `make test` (3 passes), `make ui-test`
(329 passing, up from 313), `make e2e` (23 passing, up from 19 — four
new home-page specs), `tsc --noEmit`, and manual verification of all
six items in the running app via `make dev-headless` + `playwright-cli`.
+27
View File
@@ -192,6 +192,13 @@ See `.planning/plans/active/005-agent-development-harness.md`.
- `mediacontrols` — MPRIS integration on Linux via D-Bus.
- `system` — OS-specific paths (XDG on Linux, `%LOCALAPPDATA%` on Windows).
- `explore` — Catalog search and browse over `explore_index`. See below.
- `home` — The home page's "start listening" shelves. Each shelf is a
*reason* (what you played last, what you never played, a genre you
have depth in) rather than a filter, and carries the sentence that
says so. Its queries (`sql/queries/home.sql`) return album ids only
and are joined back to `GetAllAlbumsWithDetails` in Go, so the album
projection has one definition. A shelf with nothing behind it is
omitted, never rendered empty.
- `profiling` — pprof server on `:6060`, compiled out in non-dev builds via build tags (`internal/dev/`).
**Explore catalog** (`backend/explore/`): the searchable MusicBrainz/
@@ -217,6 +224,26 @@ work happens **once, centrally**, and users download the result:
**Frontend** (`frontend/`): Lit 3.2 web components + Web Awesome UI library + HTMX. State management via singleton reactive stores in `src/store/`. Wails bindings auto-generated in `frontend/wailsjs/` — don't edit by hand.
Two cross-cutting pieces of that UI are worth knowing before touching
a list or a detail view:
- **`utils/explore-link.ts`** renders every track/album/artist name in
the app. A name always navigates: to the MusicBrainz page when the
entity is tagged, and to the *library* page for the same thing when
it is not (`explore-album-details` and `explore-artist-details` both
accept a local id instead of an MBID). It fires on a genuine single
click only — the navigation is held for one double-click interval
and dropped if a second click arrives, because the title is the
widest thing in a row and double-clicking a row plays it. Rows do
not need to know links exist.
- **`<catalog-scope-notice>`** is how a detail page admits what it is
showing: catalog data (silent), a library stand-in while a fetch is
in flight, library-only because the entity has no MBID, or a failed/
empty catalog answer with a retry. Both detail views track
`catalogPending`/`catalogLoaded` separately from their loading flags,
since "something is renderable" and "this is the catalog's answer"
are different questions.
**Event-driven communication**: Backend emits events via Wails runtime; frontend stores subscribe to them. Event names are constants in `backend/events/`.
Emit through **`events.Emit(ctx, name, data...)`**, never
+6
View File
@@ -23,6 +23,7 @@ import (
"yellowjacket/backend/events"
"yellowjacket/backend/explore"
"yellowjacket/backend/frontendutil"
"yellowjacket/backend/home"
"yellowjacket/backend/jobs"
"yellowjacket/backend/library"
"yellowjacket/backend/maintenance"
@@ -210,6 +211,11 @@ func NewYellowJacketApp(
yjApp.explore,
yjApp.autotag,
jobs.NewService(yjApp.jobs),
home.NewService(
yjApp.logger.WithGroup("home"),
yjApp.database,
yjApp.library,
),
}
if yjApp.downloadSvc != nil {
+10
View File
@@ -176,6 +176,16 @@ WHERE state = 'wanted'
ORDER BY attempts, created_at
LIMIT ?;
-- name: ListWantedDownloadRequests :many
-- The same set ignoring the backoff, for a pass the user asked for by
-- hand: "check now" that respected a six-hour retry schedule looked
-- like a button that did nothing.
SELECT * FROM download_requests
WHERE state = 'wanted'
AND entity <> 'artist'
ORDER BY attempts, created_at
LIMIT ?;
-- name: ListChildDownloadRequests :many
SELECT * FROM download_requests WHERE parent_id = ? ORDER BY id;
+119
View File
@@ -0,0 +1,119 @@
-- Queries behind the home page's "start listening" shelves.
--
-- Every one of these returns album ids and nothing else. The display
-- columns (cover art, artist credit, year) already have exactly one
-- correct expression of them, in GetAllAlbumsWithDetails, and a second
-- copy per shelf would be six more places for that to drift. The home
-- service joins the ids back to that one album list in Go.
-- name: HomeRecentlyPlayedAlbums :many
-- Albums with the most recent play, newest first.
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
WHERE af.last_played IS NOT NULL
GROUP BY rg.id
ORDER BY MAX(af.last_played) DESC
LIMIT ?;
-- name: HomeRecentlyAddedAlbums :many
-- Newest albums. audio_files has no import timestamp, so the row id
-- stands in for one: it is monotonic and assigned at import, which is
-- the same ordering an added_at column would give.
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
GROUP BY rg.id
ORDER BY MAX(af.id) DESC
LIMIT ?;
-- name: HomeMostPlayedAlbums :many
-- Albums by total plays across their tracks.
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
GROUP BY rg.id
HAVING SUM(af.play_count) > 0
ORDER BY SUM(af.play_count) DESC
LIMIT ?;
-- name: HomeUnplayedAlbums :many
-- Albums nothing on has ever been played, sampled at random so the
-- shelf is a different suggestion each time rather than the same
-- alphabetical head of the list forever.
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
GROUP BY rg.id
HAVING SUM(af.play_count) = 0
ORDER BY RANDOM()
LIMIT ?;
-- name: HomeStaleAlbums :many
-- Played before, but not for a long while.
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
WHERE af.last_played IS NOT NULL
GROUP BY rg.id
HAVING MAX(af.last_played) < datetime('now', ?)
ORDER BY MAX(af.last_played) ASC
LIMIT ?;
-- name: HomeRandomAlbums :many
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
GROUP BY rg.id
ORDER BY RANDOM()
LIMIT ?;
-- name: HomeAlbumsByGenre :many
-- A random sample of albums carrying a genre, so the same genre shelf
-- is not the same ten albums every time the page opens.
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN recording_genres rgen ON rgen.recording_id = rgr.recording_id
JOIN genres g ON g.id = rgen.genre_id
WHERE g.name = ?
GROUP BY rg.id
ORDER BY RANDOM()
LIMIT ?;
-- name: HomeTopGenres :many
-- Genres ranked by how much of the library carries them, restricted to
-- ones with at least a few albums: a shelf built from a genre one
-- album carries is a shelf about that one album.
SELECT
g.name AS genre,
COUNT(DISTINCT rgr.release_group_id) AS album_count
FROM genres g
JOIN recording_genres rgen ON rgen.genre_id = g.id
JOIN release_group_recordings rgr ON rgr.recording_id = rgen.recording_id
GROUP BY g.id
HAVING album_count >= 3
ORDER BY album_count DESC
LIMIT ?;
-- name: HomeTopArtists :many
-- Artists by total plays, as the album-artist credit text the album
-- list already displays.
SELECT
COALESCE(ac.text, '') AS artist_name,
SUM(af.play_count) AS plays
FROM release_groups rg
JOIN artist_credit ac ON ac.id = rg.album_artist_credit_id
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
WHERE ac.text <> ''
GROUP BY ac.text
HAVING plays > 0
ORDER BY plays DESC
LIMIT ?;
@@ -739,6 +739,58 @@ func (q *Queries) ListLiveDownloads(ctx context.Context) ([]DownloadDownload, er
return items, nil
}
const listWantedDownloadRequests = `-- name: ListWantedDownloadRequests :many
SELECT id, mbid, entity, library_id, artist, title, scope, secondary, state, parent_id, attempts, last_error, last_tried_at, next_try_at, external_ids, created_at, updated_at FROM download_requests
WHERE state = 'wanted'
AND entity <> 'artist'
ORDER BY attempts, created_at
LIMIT ?
`
// The same set ignoring the backoff, for a pass the user asked for by
// hand: "check now" that respected a six-hour retry schedule looked
// like a button that did nothing.
func (q *Queries) ListWantedDownloadRequests(ctx context.Context, limit int64) ([]DownloadRequest, error) {
rows, err := q.db.QueryContext(ctx, listWantedDownloadRequests, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []DownloadRequest
for rows.Next() {
var i DownloadRequest
if err := rows.Scan(
&i.ID,
&i.Mbid,
&i.Entity,
&i.LibraryID,
&i.Artist,
&i.Title,
&i.Scope,
&i.Secondary,
&i.State,
&i.ParentID,
&i.Attempts,
&i.LastError,
&i.LastTriedAt,
&i.NextTryAt,
&i.ExternalIds,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const recordDownloadRequestAttempt = `-- name: RecordDownloadRequestAttempt :exec
UPDATE download_requests
SET attempts = attempts + 1,
+367
View File
@@ -0,0 +1,367 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: home.sql
package sqlcgen
import (
"context"
"database/sql"
)
const homeAlbumsByGenre = `-- name: HomeAlbumsByGenre :many
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN recording_genres rgen ON rgen.recording_id = rgr.recording_id
JOIN genres g ON g.id = rgen.genre_id
WHERE g.name = ?
GROUP BY rg.id
ORDER BY RANDOM()
LIMIT ?
`
type HomeAlbumsByGenreParams struct {
Name string
Limit int64
}
// A random sample of albums carrying a genre, so the same genre shelf
// is not the same ten albums every time the page opens.
func (q *Queries) HomeAlbumsByGenre(ctx context.Context, arg HomeAlbumsByGenreParams) ([]int64, error) {
rows, err := q.db.QueryContext(ctx, homeAlbumsByGenre, arg.Name, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var album_id int64
if err := rows.Scan(&album_id); err != nil {
return nil, err
}
items = append(items, album_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const homeMostPlayedAlbums = `-- name: HomeMostPlayedAlbums :many
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
GROUP BY rg.id
HAVING SUM(af.play_count) > 0
ORDER BY SUM(af.play_count) DESC
LIMIT ?
`
// Albums by total plays across their tracks.
func (q *Queries) HomeMostPlayedAlbums(ctx context.Context, limit int64) ([]int64, error) {
rows, err := q.db.QueryContext(ctx, homeMostPlayedAlbums, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var album_id int64
if err := rows.Scan(&album_id); err != nil {
return nil, err
}
items = append(items, album_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const homeRandomAlbums = `-- name: HomeRandomAlbums :many
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
GROUP BY rg.id
ORDER BY RANDOM()
LIMIT ?
`
func (q *Queries) HomeRandomAlbums(ctx context.Context, limit int64) ([]int64, error) {
rows, err := q.db.QueryContext(ctx, homeRandomAlbums, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var album_id int64
if err := rows.Scan(&album_id); err != nil {
return nil, err
}
items = append(items, album_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const homeRecentlyAddedAlbums = `-- name: HomeRecentlyAddedAlbums :many
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
GROUP BY rg.id
ORDER BY MAX(af.id) DESC
LIMIT ?
`
// Newest albums. audio_files has no import timestamp, so the row id
// stands in for one: it is monotonic and assigned at import, which is
// the same ordering an added_at column would give.
func (q *Queries) HomeRecentlyAddedAlbums(ctx context.Context, limit int64) ([]int64, error) {
rows, err := q.db.QueryContext(ctx, homeRecentlyAddedAlbums, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var album_id int64
if err := rows.Scan(&album_id); err != nil {
return nil, err
}
items = append(items, album_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const homeRecentlyPlayedAlbums = `-- name: HomeRecentlyPlayedAlbums :many
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
WHERE af.last_played IS NOT NULL
GROUP BY rg.id
ORDER BY MAX(af.last_played) DESC
LIMIT ?
`
// Queries behind the home page's "start listening" shelves.
//
// Every one of these returns album ids and nothing else. The display
// columns (cover art, artist credit, year) already have exactly one
// correct expression of them, in GetAllAlbumsWithDetails, and a second
// copy per shelf would be six more places for that to drift. The home
// service joins the ids back to that one album list in Go.
// Albums with the most recent play, newest first.
func (q *Queries) HomeRecentlyPlayedAlbums(ctx context.Context, limit int64) ([]int64, error) {
rows, err := q.db.QueryContext(ctx, homeRecentlyPlayedAlbums, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var album_id int64
if err := rows.Scan(&album_id); err != nil {
return nil, err
}
items = append(items, album_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const homeStaleAlbums = `-- name: HomeStaleAlbums :many
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
WHERE af.last_played IS NOT NULL
GROUP BY rg.id
HAVING MAX(af.last_played) < datetime('now', ?)
ORDER BY MAX(af.last_played) ASC
LIMIT ?
`
type HomeStaleAlbumsParams struct {
Datetime interface{}
Limit int64
}
// Played before, but not for a long while.
func (q *Queries) HomeStaleAlbums(ctx context.Context, arg HomeStaleAlbumsParams) ([]int64, error) {
rows, err := q.db.QueryContext(ctx, homeStaleAlbums, arg.Datetime, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var album_id int64
if err := rows.Scan(&album_id); err != nil {
return nil, err
}
items = append(items, album_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const homeTopArtists = `-- name: HomeTopArtists :many
SELECT
COALESCE(ac.text, '') AS artist_name,
SUM(af.play_count) AS plays
FROM release_groups rg
JOIN artist_credit ac ON ac.id = rg.album_artist_credit_id
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
WHERE ac.text <> ''
GROUP BY ac.text
HAVING plays > 0
ORDER BY plays DESC
LIMIT ?
`
type HomeTopArtistsRow struct {
ArtistName string
Plays sql.NullFloat64
}
// Artists by total plays, as the album-artist credit text the album
// list already displays.
func (q *Queries) HomeTopArtists(ctx context.Context, limit int64) ([]HomeTopArtistsRow, error) {
rows, err := q.db.QueryContext(ctx, homeTopArtists, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []HomeTopArtistsRow
for rows.Next() {
var i HomeTopArtistsRow
if err := rows.Scan(&i.ArtistName, &i.Plays); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const homeTopGenres = `-- name: HomeTopGenres :many
SELECT
g.name AS genre,
COUNT(DISTINCT rgr.release_group_id) AS album_count
FROM genres g
JOIN recording_genres rgen ON rgen.genre_id = g.id
JOIN release_group_recordings rgr ON rgr.recording_id = rgen.recording_id
GROUP BY g.id
HAVING album_count >= 3
ORDER BY album_count DESC
LIMIT ?
`
type HomeTopGenresRow struct {
Genre string
AlbumCount int64
}
// Genres ranked by how much of the library carries them, restricted to
// ones with at least a few albums: a shelf built from a genre one
// album carries is a shelf about that one album.
func (q *Queries) HomeTopGenres(ctx context.Context, limit int64) ([]HomeTopGenresRow, error) {
rows, err := q.db.QueryContext(ctx, homeTopGenres, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []HomeTopGenresRow
for rows.Next() {
var i HomeTopGenresRow
if err := rows.Scan(&i.Genre, &i.AlbumCount); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const homeUnplayedAlbums = `-- name: HomeUnplayedAlbums :many
SELECT rg.id AS album_id
FROM release_groups rg
JOIN release_group_recordings rgr ON rgr.release_group_id = rg.id
JOIN audio_files af ON af.recording_id = rgr.recording_id
GROUP BY rg.id
HAVING SUM(af.play_count) = 0
ORDER BY RANDOM()
LIMIT ?
`
// Albums nothing on has ever been played, sampled at random so the
// shelf is a different suggestion each time rather than the same
// alphabetical head of the list forever.
func (q *Queries) HomeUnplayedAlbums(ctx context.Context, limit int64) ([]int64, error) {
rows, err := q.db.QueryContext(ctx, homeUnplayedAlbums, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []int64
for rows.Next() {
var album_id int64
if err := rows.Scan(&album_id); err != nil {
return nil, err
}
items = append(items, album_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+61 -7
View File
@@ -251,6 +251,16 @@ type Summary struct {
// Synced is how many requests were pushed to an external list.
Synced int `json:"synced"`
// Waiting is how many requests are on the list and still being
// looked for. A pass that did nothing is the normal case, and the
// UI can only say so honestly if it knows the list was not empty.
Waiting int `json:"waiting"`
// NoProviders reports that nothing could be searched because no
// download client is enabled — the one "nothing happened" the user
// can actually fix.
NoProviders bool `json:"noProviders"`
}
// changed reports whether the pass altered anything worth refreshing
@@ -259,9 +269,22 @@ func (s Summary) changed() bool {
return s.Expanded > 0 || s.Satisfied > 0 || s.Started > 0
}
// RunOnce works the request list once. It is safe to call directly, and
// the "search now" button does.
// RunOnce works the request list once, honouring each request's
// backoff. This is what the loop calls.
func (r *Reconciler) RunOnce(ctx context.Context) (Summary, error) {
return r.run(ctx, false)
}
// RunNow works the request list ignoring backoff. This is what the
// "check now" button calls: a scheduled retry is a promise to the
// provider, not to the user, and a person who presses a button expects
// their list to actually be searched rather than to be told it is not
// due yet.
func (r *Reconciler) RunNow(ctx context.Context) (Summary, error) {
return r.run(ctx, true)
}
func (r *Reconciler) run(ctx context.Context, force bool) (Summary, error) {
r.runMu.Lock()
defer r.runMu.Unlock()
@@ -287,13 +310,15 @@ func (r *Reconciler) RunOnce(ctx context.Context) (Summary, error) {
summary.Synced = r.syncExternalLists(ctx)
attempted, started, err := r.attemptDue(ctx)
attempted, started, err := r.attemptDue(ctx, force)
if err != nil {
return summary, err
}
summary.Attempted = attempted
summary.Started = started
summary.Waiting = r.countWaiting(ctx)
summary.NoProviders = len(r.manager.enabledProviders()) == 0
r.logger.Info(
"reconciled request list",
@@ -506,10 +531,19 @@ func (r *Reconciler) retireOwned(ctx context.Context) (int, error) {
// Attempting downloads
// ---------------------------------------------------------------------------
// attemptDue searches for a bounded batch of due requests and grabs the
// ones with a clear winner.
func (r *Reconciler) attemptDue(ctx context.Context) (attempted, started int, err error) {
due, err := r.store.ListDueRequests(ctx, r.batch)
// attemptDue searches for a bounded batch of requests and grabs the
// ones with a clear winner. force takes requests whose backoff has not
// elapsed as well.
func (r *Reconciler) attemptDue(
ctx context.Context,
force bool,
) (attempted, started int, err error) {
list := r.store.ListDueRequests
if force {
list = r.store.ListWantedRequests
}
due, err := list(ctx, r.batch)
if err != nil {
return 0, 0, err
}
@@ -615,6 +649,26 @@ func (r *Reconciler) attempt(ctx context.Context, req Request) (bool, string) {
return started, reason
}
// countWaiting reports how many non-artist requests are still being
// looked for, so "nothing happened" can be reported as "nothing new
// for the twelve things on your list" rather than as silence.
func (r *Reconciler) countWaiting(ctx context.Context) int {
requests, err := r.store.ListRequests(ctx)
if err != nil {
return 0
}
waiting := 0
for _, req := range requests {
if req.State == RequestStateWanted && !req.Entity.Expands() {
waiting++
}
}
return waiting
}
// ---------------------------------------------------------------------------
// External list sync
// ---------------------------------------------------------------------------
+98
View File
@@ -495,3 +495,101 @@ func waitFor(t *testing.T, cond func() bool, msg string) {
t.Fatal(msg)
}
// "Check now" is the user overriding the retry schedule, so it must
// search a request whose backoff has not elapsed. The scheduled pass
// must not: the backoff exists to keep a fruitless search off the
// providers, and a loop that ignored it would hammer them.
func TestRunNowIgnoresBackoffAndRunOnceDoesNot(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
provider := NewFakeProvider(1, "weak", Caps{CanSearch: true, CanTransport: true})
provider.Candidates = []Candidate{candidateFor(
"weak-1", []string{"Something Else Entirely"}, ".mp3", 3_000_000,
)}
f.manager.installProvider(Config{ID: 1, Priority: 50}, provider)
id, err := f.store.AddRequest(ctx, Request{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
Artist: "Radiohead",
Title: "OK Computer",
})
if err != nil {
t.Fatalf("AddRequest: %v", err)
}
f.catalog.tracklists["rg-1"] = fourTrackDownload().Expected
// First pass: attempted, found nothing, backoff armed.
if _, err := f.reconciler.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce: %v", err)
}
scheduled, err := f.reconciler.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce (second): %v", err)
}
if scheduled.Attempted != 0 {
t.Errorf("scheduled pass attempted %d, want 0 while backed off",
scheduled.Attempted)
}
forced, err := f.reconciler.RunNow(ctx)
if err != nil {
t.Fatalf("RunNow: %v", err)
}
if forced.Attempted != 1 {
t.Errorf("forced pass attempted %d, want 1", forced.Attempted)
}
if forced.Waiting != 1 {
t.Errorf("summary reported %d waiting, want 1 so the UI can say "+
"what was searched", forced.Waiting)
}
req, err := f.store.GetRequest(ctx, id)
if err != nil {
t.Fatalf("GetRequest: %v", err)
}
if req.Attempts != 2 {
t.Errorf("attempts = %d, want 2 after a forced re-check", req.Attempts)
}
}
// A pass with no providers says so, because "nothing happened" with no
// reason is the one outcome the user cannot act on.
func TestSummaryReportsNoProviders(t *testing.T) {
t.Parallel()
f := newReconcileFixture(t)
ctx := context.Background()
if _, err := f.store.AddRequest(ctx, Request{
MBID: "rg-1",
Entity: EntityReleaseGroup,
LibraryID: 1,
Title: "OK Computer",
}); err != nil {
t.Fatalf("AddRequest: %v", err)
}
f.catalog.tracklists["rg-1"] = fourTrackDownload().Expected
summary, err := f.reconciler.RunNow(ctx)
if err != nil {
t.Fatalf("RunNow: %v", err)
}
if !summary.NoProviders {
t.Error("summary did not report that no download client is enabled")
}
}
+17
View File
@@ -152,6 +152,23 @@ func (s *Store) ListDueRequests(ctx context.Context, limit int) ([]Request, erro
return requestRowsToRequests(rows), nil
}
// ListWantedRequests returns downloadable requests regardless of their
// backoff, least-attempted first. Only a user-initiated pass uses
// this: the loop honours the schedule, a person pressing "check now"
// is the schedule.
func (s *Store) ListWantedRequests(ctx context.Context, limit int) ([]Request, error) {
if limit <= 0 {
limit = defaultDueBatch
}
rows, err := s.db.ReadQueries.ListWantedDownloadRequests(ctx, int64(limit))
if err != nil {
return nil, fmt.Errorf("list wanted download requests: %w", err)
}
return requestRowsToRequests(rows), nil
}
// ListChildRequests returns the requests an artist subscription
// produced.
func (s *Store) ListChildRequests(
+1 -1
View File
@@ -609,7 +609,7 @@ func (s *Service) ReconcileRequests() (Summary, error) {
)
}
summary, err := s.reconciler.RunOnce(context.Background())
summary, err := s.reconciler.RunNow(context.Background())
if err != nil {
return summary, err
}
+1
View File
@@ -12,6 +12,7 @@ const (
TrackChanged = "TrackChanged"
SeekFailed = "SeekFailed"
VolumeChanged = "VolumeChanged"
MuteChanged = "MuteChanged"
)
// Queue events (backend → frontend push).
+422
View File
@@ -0,0 +1,422 @@
// Package home builds the "start listening" shelves on the home page.
//
// The problem the home page solves is not "show the library" — four
// other views already do that, sorted and complete. It is the opposite:
// a complete, sorted library is exactly what gives you nothing to play,
// because every entry point into it is alphabetical and therefore
// identical every time you open the app.
//
// So a shelf here is a *reason*, not a filter. Each one answers a
// different question the user might be asking when they do not know
// what they want — what was I listening to, what is new, what do I keep
// coming back to, what have I forgotten, what fits the mood, what would
// I never pick myself — and each says which question it answered, since
// a row of covers with no explanation is just another grid.
//
// Two consequences of that framing show up throughout:
//
// - Shelves are built from what the user actually did (play counts,
// last played, import order) with random sampling only where there
// is no signal to use. Randomness is the fallback, not the design.
// - A shelf with nothing behind it is omitted rather than rendered
// empty. A fresh library has no history, so its home page is
// legitimately three shelves, and lying about that with empty rows
// labelled "on repeat" would be worse than showing fewer.
package home
import (
"context"
"log/slog"
"math/rand/v2"
"strings"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/library"
)
// Kind identifies what a shelf is built from, so the frontend can pick
// an icon and the e2e suite can assert on a shelf without matching
// display copy.
type Kind string
// Shelf kinds.
const (
KindRecentlyPlayed Kind = "recently-played"
KindRecentlyAdded Kind = "recently-added"
KindMostPlayed Kind = "most-played"
KindUnplayed Kind = "unplayed"
KindStale Kind = "stale"
KindArtist Kind = "artist"
KindGenre Kind = "genre"
KindRandom Kind = "random"
)
// Shelf is one horizontal row on the home page.
type Shelf struct {
// ID is stable within a response, for list keying.
ID string `json:"id"`
Kind Kind `json:"kind"`
// Title is the row heading.
Title string `json:"title"`
// Subtitle says why these albums are here. It is not decoration:
// without it a shelf is indistinguishable from a random grid.
Subtitle string `json:"subtitle"`
Albums []library.Album `json:"albums"`
}
// Shelf sizing.
const (
// shelfSize is how many albums one row holds. Wide enough to be
// worth scrolling, small enough that every shelf is a considered
// selection rather than a dump of the library.
shelfSize = 12
// maxGenreShelves bounds how many genre rows appear, so a
// heavily-tagged library does not turn the home page into the
// genres view.
maxGenreShelves = 2
// genreCandidates is how many top genres to draw the genre shelves
// from. Sampling from a pool rather than taking the top two is
// what stops the same two genres appearing forever.
genreCandidates = 8
// staleWindow is how long an album must go unplayed to count as
// forgotten. Six months is past "I listened to that recently" for
// almost everyone without reaching back to things they no longer
// own.
staleWindow = "-6 months"
// artistShelfMin is the fewest albums by one artist worth a shelf
// of their own.
artistShelfMin = 3
)
// Library is the album data the shelves are rendered from. Narrow on
// purpose: the home page needs one list of albums, not the library
// package.
type Library interface {
GetAllAlbums() ([]library.Album, error)
}
// Service builds the home page's shelves.
type Service struct {
logger *slog.Logger
db *database.DB
lib Library
}
// NewService builds the home service.
func NewService(
logger *slog.Logger,
db *database.DB,
lib Library,
) *Service {
return &Service{logger: logger, db: db, lib: lib}
}
// GetShelves returns the home page's rows, in display order.
//
// It is a single call rather than one per shelf because the shelves
// share an album lookup and because the page has nothing useful to
// render until it knows which rows exist — a page that pops rows in one
// at a time reflows under the user's cursor.
func (s *Service) GetShelves() ([]Shelf, error) {
ctx := s.db.Ctx
if ctx == nil {
ctx = context.Background()
}
albums, err := s.lib.GetAllAlbums()
if err != nil {
return nil, err
}
if len(albums) == 0 {
return []Shelf{}, nil
}
byID := make(map[int64]library.Album, len(albums))
for _, album := range albums {
byID[album.ID] = album
}
shelves := make([]Shelf, 0, 8) //nolint:mnd // rough capacity hint
add := func(shelf Shelf, ok bool) {
if ok && len(shelf.Albums) > 0 {
shelves = append(shelves, shelf)
}
}
add(s.recentlyPlayed(ctx, byID))
add(s.recentlyAdded(ctx, byID))
add(s.mostPlayed(ctx, byID))
add(s.favouriteArtist(ctx, albums))
for _, shelf := range s.genreShelves(ctx, byID) {
add(shelf, true)
}
add(s.forgotten(ctx, byID))
add(s.random(ctx, byID))
return shelves, nil
}
// resolve turns album ids into albums, dropping any the library no
// longer has (a scan can remove an album between the two queries).
func resolve(ids []int64, byID map[int64]library.Album) []library.Album {
out := make([]library.Album, 0, len(ids))
for _, id := range ids {
if album, ok := byID[id]; ok {
out = append(out, album)
}
}
return out
}
func (s *Service) recentlyPlayed(
ctx context.Context,
byID map[int64]library.Album,
) (Shelf, bool) {
ids, err := s.db.ReadQueries.HomeRecentlyPlayedAlbums(ctx, shelfSize)
if err != nil {
s.logger.Warn("home: recently played", "error", err)
return Shelf{}, false
}
return Shelf{
ID: "recently-played",
Kind: KindRecentlyPlayed,
Title: "Pick up where you left off",
Subtitle: "The last albums you played",
Albums: resolve(ids, byID),
}, true
}
func (s *Service) recentlyAdded(
ctx context.Context,
byID map[int64]library.Album,
) (Shelf, bool) {
ids, err := s.db.ReadQueries.HomeRecentlyAddedAlbums(ctx, shelfSize)
if err != nil {
s.logger.Warn("home: recently added", "error", err)
return Shelf{}, false
}
return Shelf{
ID: "recently-added",
Kind: KindRecentlyAdded,
Title: "Fresh in your library",
Subtitle: "Most recently added",
Albums: resolve(ids, byID),
}, true
}
func (s *Service) mostPlayed(
ctx context.Context,
byID map[int64]library.Album,
) (Shelf, bool) {
ids, err := s.db.ReadQueries.HomeMostPlayedAlbums(ctx, shelfSize)
if err != nil {
s.logger.Warn("home: most played", "error", err)
return Shelf{}, false
}
return Shelf{
ID: "most-played",
Kind: KindMostPlayed,
Title: "On repeat",
Subtitle: "What you play the most",
Albums: resolve(ids, byID),
}, true
}
// forgotten is two shelves' worth of intent in one row: albums played
// long ago, and — for a library with no history at all — albums never
// played. Both answer "what am I ignoring", which is the shelf a large
// library benefits from most.
func (s *Service) forgotten(
ctx context.Context,
byID map[int64]library.Album,
) (Shelf, bool) {
stale, err := s.db.ReadQueries.HomeStaleAlbums(
ctx,
sqlcgen.HomeStaleAlbumsParams{Datetime: staleWindow, Limit: shelfSize},
)
if err != nil {
s.logger.Warn("home: stale albums", "error", err)
stale = nil
}
if len(stale) > 0 {
return Shelf{
ID: "forgotten",
Kind: KindStale,
Title: "You haven't played this in a while",
Subtitle: "Last played over six months ago",
Albums: resolve(stale, byID),
}, true
}
unplayed, err := s.db.ReadQueries.HomeUnplayedAlbums(ctx, shelfSize)
if err != nil {
s.logger.Warn("home: unplayed albums", "error", err)
return Shelf{}, false
}
return Shelf{
ID: "forgotten",
Kind: KindUnplayed,
Title: "Never played",
Subtitle: "In your library, still unheard",
Albums: resolve(unplayed, byID),
}, true
}
func (s *Service) random(
ctx context.Context,
byID map[int64]library.Album,
) (Shelf, bool) {
ids, err := s.db.ReadQueries.HomeRandomAlbums(ctx, shelfSize)
if err != nil {
s.logger.Warn("home: random albums", "error", err)
return Shelf{}, false
}
return Shelf{
ID: "random",
Kind: KindRandom,
Title: "Take a chance",
Subtitle: "A handful of albums at random",
Albums: resolve(ids, byID),
}, true
}
// favouriteArtist builds a shelf around whoever the user plays most,
// which is the one recommendation here that reads as personal rather
// than statistical.
func (s *Service) favouriteArtist(
ctx context.Context,
albums []library.Album,
) (Shelf, bool) {
rows, err := s.db.ReadQueries.HomeTopArtists(ctx, artistPoolSize)
if err != nil {
s.logger.Warn("home: top artists", "error", err)
return Shelf{}, false
}
// Sampling from the top few rather than always taking first place
// keeps the shelf from being a permanent fixture about one artist.
rand.Shuffle(len(rows), func(i, j int) {
rows[i], rows[j] = rows[j], rows[i]
})
for _, row := range rows {
name := strings.TrimSpace(row.ArtistName)
if name == "" {
continue
}
byArtist := make([]library.Album, 0, shelfSize)
for _, album := range albums {
if strings.EqualFold(album.ArtistName, name) {
byArtist = append(byArtist, album)
}
}
if len(byArtist) < artistShelfMin {
continue
}
if len(byArtist) > shelfSize {
byArtist = byArtist[:shelfSize]
}
return Shelf{
ID: "artist",
Kind: KindArtist,
Title: "More from " + name,
Subtitle: "One of your most played artists",
Albums: byArtist,
}, true
}
return Shelf{}, false
}
// artistPoolSize is how many top artists the favourite-artist shelf
// picks from.
const artistPoolSize = 5
// genreShelves picks a couple of genres the library actually has depth
// in, sampled from the top handful so the page varies between visits.
func (s *Service) genreShelves(
ctx context.Context,
byID map[int64]library.Album,
) []Shelf {
rows, err := s.db.ReadQueries.HomeTopGenres(ctx, genreCandidates)
if err != nil {
s.logger.Warn("home: top genres", "error", err)
return nil
}
rand.Shuffle(len(rows), func(i, j int) {
rows[i], rows[j] = rows[j], rows[i]
})
shelves := make([]Shelf, 0, maxGenreShelves)
for _, row := range rows {
if len(shelves) >= maxGenreShelves {
break
}
genre := strings.TrimSpace(row.Genre)
if genre == "" {
continue
}
ids, err := s.db.ReadQueries.HomeAlbumsByGenre(
ctx,
sqlcgen.HomeAlbumsByGenreParams{Name: genre, Limit: shelfSize},
)
if err != nil {
s.logger.Warn("home: albums by genre", "genre", genre, "error", err)
continue
}
found := resolve(ids, byID)
if len(found) == 0 {
continue
}
shelves = append(shelves, Shelf{
ID: "genre-" + genre,
Kind: KindGenre,
Title: genre,
Subtitle: "Because your library is full of it",
Albums: found,
})
}
return shelves
}
+259
View File
@@ -0,0 +1,259 @@
package home_test
import (
"log/slog"
"testing"
"yellowjacket/backend/database"
"yellowjacket/backend/home"
"yellowjacket/backend/library"
)
// fakeLibrary answers the one question the home service asks of the
// library package, so these tests are about shelf selection rather than
// about album rendering.
type fakeLibrary struct {
albums []library.Album
err error
}
func (f fakeLibrary) GetAllAlbums() ([]library.Album, error) {
return f.albums, f.err
}
// seed inserts one album with one played-or-not track, returning the
// release group id. Written with raw SQL rather than the library
// scanner because the shelves are queries, and a query is best tested
// against rows it can be given precisely.
func seed(
t *testing.T,
db *database.DB,
name, artist, genre string,
playCount int,
lastPlayed string,
) int64 {
t.Helper()
exec := func(query string, args ...any) {
t.Helper()
if _, err := db.ExecContext(query, args...); err != nil {
t.Fatalf("seed %q: %v", query, err)
}
}
exec(`INSERT INTO artist_credit (text) VALUES (?)
ON CONFLICT DO NOTHING`, artist)
exec(`INSERT INTO release_groups (name, album_artist_credit_id)
VALUES (?, (SELECT id FROM artist_credit WHERE text = ?))`,
name, artist)
exec(`INSERT INTO recordings (name, artist_credit_id)
VALUES (?, (SELECT id FROM artist_credit WHERE text = ?))`,
name+" track", artist)
exec(`INSERT INTO release_group_recordings (release_group_id, recording_id)
VALUES ((SELECT MAX(id) FROM release_groups),
(SELECT MAX(id) FROM recordings))`)
exec(`INSERT INTO file_types (extension) VALUES ('mp3')
ON CONFLICT DO NOTHING`)
exec(`INSERT INTO audio_files
(file_path, length_milliseconds, file_type_id, recording_id,
play_count, last_played)
VALUES (?, 1000,
(SELECT MAX(id) FROM file_types),
(SELECT MAX(id) FROM recordings),
?, ?)`,
"/music/"+name+".mp3", playCount, nullable(lastPlayed))
if genre != "" {
exec(`INSERT INTO genres (name) VALUES (?) ON CONFLICT DO NOTHING`, genre)
exec(`INSERT INTO recording_genres (recording_id, genre_id)
VALUES ((SELECT MAX(id) FROM recordings),
(SELECT id FROM genres WHERE name = ?))`, genre)
}
var id int64
if err := db.QueryRowWriter(
`SELECT MAX(id) FROM release_groups`,
).Scan(&id); err != nil {
t.Fatalf("seed: read album id: %v", err)
}
return id
}
func nullable(s string) any {
if s == "" {
return nil
}
return s
}
func shelfKinds(shelves []home.Shelf) []home.Kind {
kinds := make([]home.Kind, 0, len(shelves))
for _, s := range shelves {
kinds = append(kinds, s.Kind)
}
return kinds
}
func hasKind(shelves []home.Shelf, kind home.Kind) bool {
for _, s := range shelves {
if s.Kind == kind {
return true
}
}
return false
}
func shelfFor(shelves []home.Shelf, kind home.Kind) home.Shelf {
for _, s := range shelves {
if s.Kind == kind {
return s
}
}
return home.Shelf{}
}
func TestGetShelvesEmptyLibraryHasNoShelves(t *testing.T) {
db := database.NewTestDB(t)
svc := home.NewService(slog.Default(), db, fakeLibrary{})
shelves, err := svc.GetShelves()
if err != nil {
t.Fatalf("GetShelves: %v", err)
}
if len(shelves) != 0 {
t.Fatalf("empty library produced shelves: %v", shelfKinds(shelves))
}
}
func TestGetShelvesOmitsShelvesWithNothingBehindThem(t *testing.T) {
// A library nothing has ever been played from must not claim to
// know what is on repeat: an empty row labelled with a reason is a
// worse answer than no row.
db := database.NewTestDB(t)
id := seed(t, db, "Quiet", "Nobody", "", 0, "")
svc := home.NewService(slog.Default(), db, fakeLibrary{
albums: []library.Album{{ID: id, Name: "Quiet", ArtistName: "Nobody"}},
})
shelves, err := svc.GetShelves()
if err != nil {
t.Fatalf("GetShelves: %v", err)
}
if hasKind(shelves, home.KindRecentlyPlayed) {
t.Error("recently-played shelf built from no plays")
}
if hasKind(shelves, home.KindMostPlayed) {
t.Error("most-played shelf built from no plays")
}
if !hasKind(shelves, home.KindUnplayed) {
t.Errorf("expected an unplayed shelf, got %v", shelfKinds(shelves))
}
if !hasKind(shelves, home.KindRecentlyAdded) {
t.Errorf("expected a recently-added shelf, got %v", shelfKinds(shelves))
}
}
func TestGetShelvesRanksPlayHistory(t *testing.T) {
db := database.NewTestDB(t)
old := seed(t, db, "Old Favourite", "A", "", 20, "2020-01-01 00:00:00")
recent := seed(t, db, "Last Night", "B", "", 3, "2999-01-01 00:00:00")
svc := home.NewService(slog.Default(), db, fakeLibrary{
albums: []library.Album{
{ID: old, Name: "Old Favourite", ArtistName: "A"},
{ID: recent, Name: "Last Night", ArtistName: "B"},
},
})
shelves, err := svc.GetShelves()
if err != nil {
t.Fatalf("GetShelves: %v", err)
}
played := shelfFor(shelves, home.KindRecentlyPlayed)
if len(played.Albums) == 0 || played.Albums[0].ID != recent {
t.Errorf("recently played led with %v, want the newest play", played.Albums)
}
most := shelfFor(shelves, home.KindMostPlayed)
if len(most.Albums) == 0 || most.Albums[0].ID != old {
t.Errorf("most played led with %v, want the highest play count", most.Albums)
}
// Played long ago is "forgotten"; the never-played fallback must
// not take over while there is real history to report.
forgotten := shelfFor(shelves, home.KindStale)
if len(forgotten.Albums) == 0 || forgotten.Albums[0].ID != old {
t.Errorf("forgotten shelf = %v, want the album last played in 2020", forgotten.Albums)
}
}
func TestGetShelvesBuildsAGenreShelf(t *testing.T) {
db := database.NewTestDB(t)
albums := make([]library.Album, 0, 4)
for _, name := range []string{"One", "Two", "Three", "Four"} {
id := seed(t, db, name, "Various", "Doom Jazz", 1, "2024-01-01 00:00:00")
albums = append(albums, library.Album{
ID: id, Name: name, ArtistName: "Various",
})
}
svc := home.NewService(slog.Default(), db, fakeLibrary{albums: albums})
shelves, err := svc.GetShelves()
if err != nil {
t.Fatalf("GetShelves: %v", err)
}
genre := shelfFor(shelves, home.KindGenre)
if genre.Title != "Doom Jazz" {
t.Fatalf("genre shelf = %q, want the library's one genre", genre.Title)
}
if len(genre.Albums) != len(albums) {
t.Errorf("genre shelf had %d albums, want %d", len(genre.Albums), len(albums))
}
}
func TestGetShelvesSkipsAlbumsTheLibraryNoLongerHas(t *testing.T) {
// The id queries and the album list are two reads, and a scan can
// remove an album between them. A stale id must vanish from the
// shelf, not render as a blank card.
db := database.NewTestDB(t)
kept := seed(t, db, "Kept", "A", "", 5, "2024-01-01 00:00:00")
seed(t, db, "Removed", "B", "", 5, "2024-01-02 00:00:00")
svc := home.NewService(slog.Default(), db, fakeLibrary{
albums: []library.Album{{ID: kept, Name: "Kept", ArtistName: "A"}},
})
shelves, err := svc.GetShelves()
if err != nil {
t.Fatalf("GetShelves: %v", err)
}
for _, shelf := range shelves {
for _, album := range shelf.Albums {
if album.ID != kept {
t.Fatalf("shelf %q surfaced a removed album: %+v", shelf.ID, album)
}
}
}
}
+23 -1
View File
@@ -224,12 +224,19 @@ func (p *Player) emitVolumeChanged() {
}
volume := int(p.getUserVolume())
muted := p.volume != nil && p.volume.Silent
p.logger.Info(
"Emitting VolumeChangedEvent", "volume", volume,
"Emitting VolumeChangedEvent", "volume", volume, "muted", muted,
)
events.Emit(p.ctx, events.VolumeChanged, volume)
// Mute rides on its own event rather than widening the volume
// payload: silence does not change the volume level, so a UI that
// only watched VolumeChanged saw nothing happen when the user hit
// the mute key.
events.Emit(p.ctx, events.MuteChanged, muted)
if p.mediaControls != nil {
// MPRIS volume is 0.0–1.0 linear.
p.mediaControls.UpdateVolume(
@@ -692,12 +699,27 @@ func (p *Player) getUserVolume() UserVolume {
return Volume(p.volume.Volume).ToUserVolume()
}
// Muted reports whether playback is currently silenced.
func (p *Player) Muted() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.volume != nil && p.volume.Silent
}
// MuteToggle toggles the mute state.
func (p *Player) MuteToggle() error {
p.mu.Lock()
defer p.mu.Unlock()
if p.volume == nil {
return errNoAudioFileLoaded
}
speaker.Lock()
p.volume.Silent = !p.volume.Silent
speaker.Unlock()
p.emitVolumeChanged()
p.saveState()
+81
View File
@@ -0,0 +1,81 @@
import { test, expect, waitForEvent, resetEvents } from '../support/fixtures.js';
/**
* The home page, which is the one view whose content is a *judgement*
* rather than a listing: `backend/home` decides which shelves exist for
* this library and why, and the page is only correct if that reasoning
* survives to the screen.
*
* The fixture library has never been played, so the shelves that need
* history are legitimately absent — asserting on which ones appear is
* asserting that the page does not invent them.
*/
test.describe('home', () => {
test.beforeEach(async ({ app }) => {
await app.getByTestId('nav-home').click();
await expect(app.getByTestId('main-content')).toHaveAttribute(
'data-active-view',
'home',
);
});
test('offers shelves, each with the reason it is there', async ({ app }) => {
const shelves = app.locator('home-view .shelf');
await expect(shelves.first()).toBeVisible();
const count = await shelves.count();
expect(count).toBeGreaterThan(0);
// Every shelf says why it exists. A row of covers with no reason
// is the albums grid, which the user already has.
for (let i = 0; i < count; i += 1) {
await expect(shelves.nth(i).locator('.shelf-sub')).not.toBeEmpty();
}
});
test('never renders a shelf with nothing on it', async ({ app }) => {
// The reason shelves are built server-side: a row that promises
// "what you play the most" and then shows nothing is worse than no
// row, so a shelf with no albums must not reach the page at all.
const shelves = app.locator('home-view .shelf');
await expect(shelves.first()).toBeVisible();
const count = await shelves.count();
for (let i = 0; i < count; i += 1) {
await expect(shelves.nth(i).locator('.card').first()).toBeVisible();
}
});
test('a cover opens that album', async ({ app }) => {
const card = app.locator('home-view .card').first();
const name = await card.locator('.name').innerText();
await card.click();
await expect(app.locator('explore-album-details')).toBeVisible();
await expect(app.locator('explore-album-details .album-title')).toContainText(
name,
);
});
test('the play button plays the album instead of opening it', async ({
app,
}) => {
await resetEvents(app);
const card = app.locator('home-view .card').first();
await card.hover();
await card.locator('.play').click();
await waitForEvent(app, 'TrackChanged');
// Playing must not also navigate: the two actions live on the same
// card and the inner one has to win outright.
await expect(app.locator('home-view')).toBeVisible();
await expect(app.locator('explore-album-details')).toHaveCount(0);
});
});
+13 -1
View File
@@ -24,6 +24,7 @@ import '@components/first-run-wizard/first-run-wizard.ts';
import '@components/jobs/job-indicator.ts';
import '@components/jobs/jobs-view.ts';
import '@components/downloads-view/downloads-view.ts';
import '@components/home-view/home-view.ts';
import '@awesome.me/webawesome/dist/styles/themes/default.css';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
@@ -57,6 +58,7 @@ setBasePath('/dist/webawesome');
// ---------------------------------------------------------------------------
const VIEW_TAGS: Record<string, string> = {
home: 'home-view',
tracks: 'track-list',
albums: 'cover-grid',
artists: 'artists-view',
@@ -209,7 +211,14 @@ document.addEventListener('navigate', (e: Event) => {
break;
}
case 'explore-album-details': {
const { releaseGroupMBID, albumName, artistName, highlightTrackMBID, localAlbumId } = detail;
const {
releaseGroupMBID,
albumName,
artistName,
highlightTrackMBID,
highlightTrackTitle,
localAlbumId,
} = detail;
const el = document.createElement('explore-album-details');
if (releaseGroupMBID) el.setAttribute('release-group-mbid', releaseGroupMBID);
@@ -218,6 +227,9 @@ document.addEventListener('navigate', (e: Event) => {
if (highlightTrackMBID) {
el.setAttribute('highlight-track-mbid', highlightTrackMBID);
}
if (highlightTrackTitle) {
el.setAttribute('highlight-track-title', highlightTrackTitle);
}
if (localAlbumId) el.setAttribute('local-album-id', String(localAlbumId));
mainContent.appendChild(el);
currentDetailEl = el;
@@ -45,6 +45,23 @@ export class VolumeControl extends LitElement {
align-items: center;
}
/* Muted is a state the volume number cannot express, so it gets a
colour of its own on top of the crossed-out icon. */
button.muted {
color: var(--yj-text-tertiary, #888);
}
.volume-popup.muted wa-slider::part(indicator) {
background: var(--yj-text-tertiary, #888);
}
.mute-toggle {
margin-top: 10px;
font-size: 11px;
color: var(--yj-text-secondary, #b3b3b3);
white-space: nowrap;
}
.volume-popup {
position: absolute;
bottom: 100%;
@@ -56,6 +73,8 @@ export class VolumeControl extends LitElement {
padding: 16px 8px;
margin-bottom: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 100;
}
@@ -91,7 +110,8 @@ export class VolumeControl extends LitElement {
private get volumeIcon(): string {
const vol = this.currentVolume;
if (vol === 0) return 'volume-xmark';
if (this.player.muted) return 'volume-xmark';
if (vol === 0) return 'volume-off';
if (vol <= 50) return 'volume-low';
return 'volume-high';
@@ -169,13 +189,25 @@ export class VolumeControl extends LitElement {
// ===================================================================
override render() {
const muted = this.player.muted;
return html`
<button @click="${this.toggleSlider}" @wheel="${this.handleWheel}">
<button
class=${muted ? 'muted' : ''}
title=${muted ? 'Muted — click for volume' : 'Volume'}
aria-label=${muted ? 'Muted' : `Volume ${this.currentVolume}%`}
data-muted=${muted ? 'true' : 'false'}
@click="${this.toggleSlider}"
@wheel="${this.handleWheel}"
>
<wa-icon name=${this.volumeIcon}></wa-icon>
</button>
${this.showSlider
? html`
<div class="volume-popup" @click="${this.handlePopupClick}">
<div
class="volume-popup ${muted ? 'muted' : ''}"
@click="${this.handlePopupClick}"
>
<wa-slider
orientation="vertical"
min="0"
@@ -183,6 +215,12 @@ export class VolumeControl extends LitElement {
.value="${this.currentVolume}"
@input="${this.handleInput}"
></wa-slider>
<button
class="mute-toggle"
@click=${() => this.player.toggleMute()}
>
${muted ? 'Unmute' : 'Mute'}
</button>
</div>
`
: ''}
@@ -0,0 +1,170 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { designTokens } from '../../styles/tokens.css';
/**
* How much of an album or artist page the user is actually looking at.
*
* The album and artist pages draw from two sources — the MusicBrainz
* catalog, and the local library — and until now the page looked the
* same either way. That is the confusing part: a page showing one
* track because that is all you own is indistinguishable from a page
* showing one track because that is all the album has, and a page that
* is still waiting on a background catalog fetch looks like a page that
* has finished and found nothing.
*
* - `catalog` — full catalog data. Nothing is rendered; the normal
* case does not need a banner.
* - `loading` — a catalog fetch is in flight; what is on screen is
* the library copy, standing in.
* - `library` — the entity carries no MusicBrainz ID, so the catalog
* has nothing to say about it, now or later.
* - `unavailable` — the catalog was asked and did not answer (offline,
* timeout, error). Retrying is meaningful here, and
* only here.
*/
export type CatalogScope = 'catalog' | 'loading' | 'library' | 'unavailable';
/**
* One-line banner naming the source of what is on screen.
*
* Emits `catalog-retry` (bubbling, composed) when the user asks for
* another attempt, which only appears for the `unavailable` scope.
*/
@customElement('catalog-scope-notice')
export class CatalogScopeNotice extends LitElement {
@property({ type: String })
scope: CatalogScope = 'catalog';
/** What the page is about, so the copy can name it. */
@property({ type: String, attribute: 'entity-type' })
entityType: 'album' | 'artist' = 'album';
static override styles = [
designTokens,
css`
:host {
display: block;
}
.notice {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 6px;
font-size: var(--yj-text-sm, 12px);
line-height: 1.4;
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
color: var(--yj-text-secondary, #b3b3b3);
}
.notice.unavailable {
color: var(--yj-text-primary, #fff);
}
wa-icon {
flex-shrink: 0;
}
.text {
flex: 1;
min-width: 0;
}
button {
flex-shrink: 0;
background: none;
border: 1px solid var(--yj-border-subtle, #333);
border-radius: 4px;
color: inherit;
cursor: pointer;
font-size: var(--yj-text-sm, 12px);
padding: 3px 10px;
}
button:hover {
border-color: var(--yj-accent, #ffd43b);
}
.spin {
animation: spin 1.4s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
`,
];
override render() {
if (this.scope === 'catalog') return nothing;
const entity = this.entityType;
const copy = this.copyFor(entity);
return html`
<div class="notice ${this.scope}" role="status">
<wa-icon
class=${this.scope === 'loading' ? 'spin' : ''}
name=${copy.icon}
></wa-icon>
<span class="text">${copy.text}</span>
${this.scope === 'unavailable'
? html`<button @click=${this.retry}>Retry</button>`
: nothing}
</div>
`;
}
private copyFor(entity: 'album' | 'artist'): {
icon: string;
text: string;
} {
const thing = entity === 'album' ? 'album' : 'artist';
switch (this.scope) {
case 'loading':
return {
icon: 'rotate',
text: `Showing what your library has while the full ${thing} details load\u2026`,
};
case 'library':
return {
icon: 'database',
text:
`Library only \u2014 this ${thing} isn't matched to MusicBrainz, `
+ 'so only what you already have is shown. Tag it in Autotag '
+ 'to see the rest.',
};
case 'unavailable':
return {
icon: 'triangle-exclamation',
// Deliberately covers both "the fetch failed" and
// "the catalog has nothing for this one": the user
// cannot tell those apart and does not need to —
// what matters is that this page is their own copy.
text:
`No catalog details for this ${thing} right now, so this is your `
+ 'library copy \u2014 anything you do not own is missing from this page.',
};
default:
return { icon: 'circle-info', text: '' };
}
}
private retry = () => {
this.dispatchEvent(
new CustomEvent('catalog-retry', { bubbles: true, composed: true }),
);
};
}
declare global {
interface HTMLElementTagNameMap {
'catalog-scope-notice': CatalogScopeNotice;
}
}
@@ -33,6 +33,14 @@ export class DownloadsView extends LitElement {
@state() private lastSummary: RequestSummary | null = null;
/** True when at least one download client is enabled. */
@state() private canDownload = false;
/** Ticks so "next check in …" ages while the page is open. */
@state() private nowMs = Date.now();
private clockTimer?: ReturnType<typeof setInterval>;
private unsubscribe: (() => void) | null = null;
static override styles = [
@@ -167,6 +175,24 @@ export class DownloadsView extends LitElement {
color: var(--yj-text-secondary, #b3b3b3);
margin: 8px 0 0;
}
.notice {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
margin-bottom: 12px;
border-radius: 6px;
font-size: 12px;
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
color: var(--yj-text-secondary, #b3b3b3);
}
.section-hint {
margin: 0 0 8px;
font-size: 12px;
color: var(--yj-text-tertiary, #888);
}
`,
];
@@ -176,12 +202,20 @@ export class DownloadsView extends LitElement {
this.unsubscribe = downloadStore.subscribe(() => {
this.requests = downloadStore.requests;
this.downloads = downloadStore.downloads;
this.canDownload = downloadStore.available;
});
void downloadStore.init().then(() => {
this.requests = downloadStore.requests;
this.downloads = downloadStore.downloads;
this.canDownload = downloadStore.available;
});
// A "next check" that never moves reads as a stuck page, so the
// relative times re-render on their own.
this.clockTimer = setInterval(() => {
this.nowMs = Date.now();
}, 30_000);
}
override disconnectedCallback(): void {
@@ -189,6 +223,7 @@ export class DownloadsView extends LitElement {
this.unsubscribe?.();
this.unsubscribe = null;
clearInterval(this.clockTimer);
}
override render() {
@@ -201,10 +236,11 @@ export class DownloadsView extends LitElement {
size="small"
appearance="outlined"
?disabled=${this.checking}
title="Search every download client for everything on this list right now, instead of waiting for the next scheduled check"
@click=${() => void this.checkNow()}
>
<wa-icon slot="start" name="rotate"></wa-icon>
${this.checking ? 'Checking…' : 'Check now'}
${this.checking ? 'Searching…' : 'Check now'}
</wa-button>
`
: nothing}
@@ -213,7 +249,10 @@ export class DownloadsView extends LitElement {
<p class="subtitle">
Music you have requested, and the download attempts that
have run for it. A request that cannot be found today stays
on the list and is looked for again later.
on the list and is looked for again later — roughly every
six hours at first, then less often the longer it goes
unfound. “Check now” skips that wait and searches
everything on the list immediately.
</p>
<div class="tabs">
@@ -248,6 +287,7 @@ export class DownloadsView extends LitElement {
const satisfied = this.requests.filter((r) => r.state === 'satisfied');
return html`
${this.renderProviderNotice()}
${this.renderSummary()}
${satisfied.length > 0
? html`
@@ -269,7 +309,18 @@ export class DownloadsView extends LitElement {
subscriptions,
(r) => this.renderSubscription(r),
)}
${this.renderRequestSection('Looking for', wanted, (r) => this.renderRequest(r))}
${wanted.length > 0
? html`
<h2>Looking for</h2>
<p class="section-hint">
Requested, not found yet. Nothing is wrong — each
of these is searched again on the schedule below,
and moves to “Found” the moment it lands in your
library, however it got there.
</p>
${wanted.map((r) => this.renderRequest(r))}
`
: nothing}
${this.renderRequestSection('Paused', paused, (r) => this.renderRequest(r))}
${this.renderRequestSection('Found', satisfied, (r) => this.renderRequest(r))}
`;
@@ -284,6 +335,26 @@ export class DownloadsView extends LitElement {
`;
}
/**
* A request list with no download client behind it is a list that
* can never move, and that is the single most likely reason “check
* now” appears to do nothing. Say so where the button is.
*/
private renderProviderNotice() {
if (this.canDownload) return nothing;
return html`
<div class="notice">
<wa-icon name="triangle-exclamation"></wa-icon>
<span>
No download client is enabled, so nothing on this list
can be searched for. Requests are still kept — add a
client under Settings → Downloads and they start moving.
</span>
</div>
`;
}
private renderSummary() {
if (!this.lastSummary) return nothing;
@@ -293,14 +364,23 @@ export class DownloadsView extends LitElement {
s.expanded > 0 ? `${s.expanded} new album${s.expanded === 1 ? '' : 's'} found` : '',
s.satisfied > 0 ? `${s.satisfied} already owned` : '',
s.started > 0 ? `${s.started} downloading` : '',
s.attempted > 0 ? `${s.attempted} searched for` : '',
s.attempted > 0
? `${s.attempted} searched, no clear match yet`
: '',
].filter(Boolean);
return html`
<p class="summary">
${parts.length > 0 ? parts.join(' · ') : 'Nothing new this time.'}
</p>
`;
if (parts.length > 0) {
return html`<p class="summary">${parts.join(' · ')}</p>`;
}
// "Nothing happened" needs a reason, or the button looks broken.
const idle = s.noProviders
? 'Nothing was searched: no download client is enabled.'
: s.waiting > 0
? `Searched all ${s.waiting} request${s.waiting === 1 ? '' : 's'} — no source has anything new yet.`
: 'Nothing on the list to search for.';
return html`<p class="summary">${idle}</p>`;
}
private renderRequestSection(
@@ -361,7 +441,7 @@ export class DownloadsView extends LitElement {
${request.artist ? `${request.artist} — ` : ''}${request.title ||
request.mbid}
</div>
<div class="detail">${requestDetail(request)}</div>
<div class="detail">${requestDetail(request, this.nowMs)}</div>
</div>
<div class="actions">
${request.state === 'satisfied'
@@ -495,15 +575,54 @@ export class DownloadsView extends LitElement {
* looked for rather than as an error, because that is what it is — the
* retry is already scheduled and there is nothing for the user to do.
*/
function requestDetail(request: Request): string {
function requestDetail(request: Request, nowMs: number): string {
if (request.state === 'satisfied') return 'In your library';
if (request.state === 'paused') return 'Paused';
if (request.state === 'paused') return 'Paused — not being looked for';
if (request.attempts === 0) return 'Not looked for yet';
if (request.attempts === 0) return 'Queued — not searched for yet';
const reason = request.lastError ? ` — ${request.lastError}` : '';
const tries = `Searched ${request.attempts} time${request.attempts === 1 ? '' : 's'}`;
const reason = request.lastError ? `, ${request.lastError}` : '';
// Wails types a Go time.Time as an opaque class; over the wire it
// is the RFC 3339 string JSON marshalled it as.
const next = nextCheckPhrase(
request.nextTryAt as unknown as string | undefined,
nowMs,
);
return `Looked for ${request.attempts} time${request.attempts === 1 ? '' : 's'}${reason}`;
return `${tries}${reason}${next}`;
}
/**
* "Next check" as a phrase, because the retry schedule is the part of
* this feature nothing in the UI used to admit existed — a row that
* says only "searched 3 times" gives the user no way to tell a waiting
* request from an abandoned one.
*/
function nextCheckPhrase(nextTryAt: string | undefined, nowMs: number): string {
if (!nextTryAt) return '';
const due = new Date(nextTryAt).getTime();
if (Number.isNaN(due)) return '';
const deltaMs = due - nowMs;
if (deltaMs <= 0) return ' · due for another search';
return ` · next check ${relativeFuture(deltaMs)}`;
}
/** Coarse "in 3 hours" phrasing; minutes are noise on a 6-hour cycle. */
function relativeFuture(ms: number): string {
const minutes = Math.round(ms / 60_000);
if (minutes < 60) return `in ${Math.max(1, minutes)} min`;
const hours = Math.round(minutes / 60);
if (hours < 48) return `in ${hours} hour${hours === 1 ? '' : 's'}`;
const days = Math.round(hours / 24);
return `in ${days} day${days === 1 ? '' : 's'}`;
}
declare global {
@@ -19,6 +19,8 @@ import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '../library-status-indicator/library-status-indicator.js';
import '../catalog-scope-notice/catalog-scope-notice.js';
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
import '@awesome.me/webawesome/dist/components/button/button.js';
import '../download-picker/download-picker';
import { downloadStore } from '../../store/download-store';
@@ -97,6 +99,11 @@ export class ExploreAlbumDetails extends LitElement {
@property({ type: String, attribute: 'highlight-track-mbid' })
highlightTrackMBID = '';
/** Highlight target for a track with no recording MBID — the only
* handle an untagged track has is its title. */
@property({ type: String, attribute: 'highlight-track-title' })
highlightTrackTitle = '';
@property({ type: Number, attribute: 'local-album-id' })
localAlbumId = 0;
@@ -108,6 +115,13 @@ export class ExploreAlbumDetails extends LitElement {
@state() private loadingReleases = true;
@state() private errorInfo = '';
@state() private errorReleases = '';
/** True once the versions/tracklist on screen came from the catalog
* rather than standing in from the local library. */
@state() private catalogReleasesLoaded = false;
/** True while a catalog fetch (foreground or background) may still
* land. Distinct from loadingReleases, which goes false as soon as
* *something* is renderable — including a library stand-in. */
@state() private catalogPending = false;
/** Unified entries shown in the dropdown — synthetics first, then real clusters. */
@state() private versionEntries: VersionEntry[] = [];
/** Currently-selected dropdown entry (by VersionEntry.key). */
@@ -543,6 +557,7 @@ export class ExploreAlbumDetails extends LitElement {
if (this.releasesReloaded.has(mbid)) return;
this.releasesReloaded.add(mbid);
this.catalogPending = false;
if (this.releases.length === 0) this.loadingReleases = false;
}, 12000);
}
@@ -552,13 +567,15 @@ export class ExploreAlbumDetails extends LitElement {
override updated() {
if (
this.highlightTrackMBID &&
(this.highlightTrackMBID || this.highlightTrackTitle) &&
!this.hasScrolledToHighlight &&
!this.loadingReleases
) {
const el = this.shadowRoot?.querySelector<HTMLElement>(
`[data-track-mbid="${this.highlightTrackMBID}"]`,
);
const el = this.highlightTrackMBID
? this.shadowRoot?.querySelector<HTMLElement>(
`[data-track-mbid="${this.highlightTrackMBID}"]`,
)
: this.findRowByTitle(this.highlightTrackTitle);
if (el) {
this.hasScrolledToHighlight = true;
@@ -609,6 +626,26 @@ export class ExploreAlbumDetails extends LitElement {
}
}
/**
* Locate a track row by title, for tracks with no recording MBID.
* Matched in JS rather than by attribute selector because titles
* contain quotes and other things a selector cannot carry.
*/
private findRowByTitle(title: string): HTMLElement | null {
const wanted = title.trim().toLowerCase();
const rows = this.shadowRoot?.querySelectorAll<HTMLElement>(
'.track-row[data-track-title]',
);
for (const row of rows ?? []) {
if ((row.dataset.trackTitle ?? '').toLowerCase() === wanted) {
return row;
}
}
return null;
}
/* ── Data Loading ── */
private async loadAllData() {
@@ -619,6 +656,8 @@ export class ExploreAlbumDetails extends LitElement {
this.errorReleases = '';
this.loadingInfo = true;
this.loadingReleases = true;
this.catalogReleasesLoaded = false;
this.catalogPending = Boolean(this.releaseGroupMBID);
this.releases = [];
this.versionEntries = [];
this.selectedVersionKey = '';
@@ -903,6 +942,8 @@ export class ExploreAlbumDetails extends LitElement {
if (releases && releases.length > 0) {
// Warm cache hit (or the background re-fetch landed):
// authoritative MB versions replace any local placeholder.
this.catalogReleasesLoaded = true;
this.catalogPending = false;
this.releases = releases;
this.buildClusters();
this.loadingReleases = false;
@@ -917,11 +958,18 @@ export class ExploreAlbumDetails extends LitElement {
if (this.releases.length > 0 || this.releasesReloaded.has(mbid)) {
this.loadingReleases = false;
}
// A cold miss after the background fetch already signalled
// ready is as far as the catalog is going to get.
if (this.releasesReloaded.has(mbid)) {
this.catalogPending = false;
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.errorReleases = msg;
console.error(`[explore-album] BrowseReleases error: ${msg}`);
this.loadingReleases = false;
this.catalogPending = false;
}
}
@@ -1425,6 +1473,11 @@ export class ExploreAlbumDetails extends LitElement {
return html`
${this.renderHeader()}
<div class="content">
<catalog-scope-notice
scope=${this.catalogScope()}
entity-type="album"
@catalog-retry=${this.retryCatalog}
></catalog-scope-notice>
${this.renderVersionSelector()}
${this.renderTracklist()}
</div>
@@ -1648,6 +1701,38 @@ export class ExploreAlbumDetails extends LitElement {
`;
}
/**
* Where the tracklist on screen came from. The distinction the
* user cares about is not "did a fetch fail" but "is what I am
* looking at everything, or only my own copy" — so an album with
* no MBID is `library` permanently, while one whose catalog fetch
* has not landed is `loading` and then either resolves or degrades
* to `unavailable`.
*/
private catalogScope(): CatalogScope {
if (!this.releaseGroupMBID) return 'library';
if (this.catalogReleasesLoaded) return 'catalog';
if (this.catalogPending) return 'loading';
// Not pending and no catalog data: the browse errored, came
// back empty, or the fallback timer gave up. Whatever is on
// screen is the library copy, and retrying is worth offering.
return 'unavailable';
}
/** Ask the catalog again after a failed or empty fetch. */
private retryCatalog = () => {
const mbid = this.releaseGroupMBID;
if (!mbid) return;
this.errorReleases = '';
this.loadingReleases = this.releases.length === 0;
this.catalogPending = true;
this.releasesReloaded.delete(mbid);
this.armReleasesFallback(mbid);
void this.fetchReleases(mbid);
};
/** Tracks of the version currently selected in the dropdown. */
private currentTracks(): MBTrack[] {
const entry = this.versionEntries.find(
@@ -1904,6 +1989,7 @@ export class ExploreAlbumDetails extends LitElement {
<div
class="track-row"
data-track-mbid="${track.mbid}"
data-track-title="${track.title}"
>
<span class="track-position"
>${track.position}</span
@@ -32,6 +32,8 @@ import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '../library-status-indicator/library-status-indicator.js';
import '../catalog-scope-notice/catalog-scope-notice.js';
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
/* ── Constants ── */
@@ -103,6 +105,12 @@ export class ExploreArtistDetails extends LitElement {
@state() private loadingReleases = true;
@state() private errorArtist = '';
@state() private errorReleases = '';
/** True once the discography on screen came from the catalog rather
* than standing in from the local library. */
@state() private catalogLoaded = false;
/** True while a catalog fetch — foreground or the background
* discography build — may still land. */
@state() private catalogPending = false;
@state() private similarArtists: LBSimilarArtist[] = [];
@state() private loadingSimilar = true;
@state() private artistImageURL = '';
@@ -920,6 +928,7 @@ export class ExploreArtistDetails extends LitElement {
this.discogReloaded.add(mbid);
this.similarReloaded.add(mbid);
this.catalogPending = false;
if (this.topTracks.length === 0) this.loadingTracks = false;
if (this.topReleaseGroups.length === 0) this.loadingTopReleases = false;
if (this.releaseGroups.length === 0) this.loadingReleases = false;
@@ -1037,6 +1046,8 @@ export class ExploreArtistDetails extends LitElement {
// forever if ArtistDiscographyReady never arrives.
this.discogReloaded.delete(mbid);
this.similarReloaded.delete(mbid);
this.catalogLoaded = false;
this.catalogPending = true;
this.armDiscogFallback(mbid);
// Phase 1: fire all API requests independently so the UI
@@ -1246,6 +1257,35 @@ export class ExploreArtistDetails extends LitElement {
}
}
/**
* Where this page's discography came from. An artist with no MBID
* can only ever show what the library holds; one whose catalog
* fetch is still in flight says so rather than looking finished.
*/
private catalogScope(): CatalogScope {
if (!this.artistMBID) return 'library';
if (this.catalogLoaded) return 'catalog';
if (this.catalogPending) return 'loading';
return 'unavailable';
}
/** Ask the catalog again after a failed or empty discography fetch. */
private retryCatalog = () => {
const mbid = this.artistMBID;
if (!mbid) return;
this.errorReleases = '';
this.catalogPending = true;
this.discogReloaded.delete(mbid);
this.similarReloaded.delete(mbid);
this.armDiscogFallback(mbid);
void this.fetchTopTracks(mbid);
void this.fetchTopReleaseGroups(mbid);
void this.fetchReleaseGroups(mbid);
void this.fetchSimilarArtists(mbid);
};
private async fetchArtist(mbid: string) {
try {
this.artist = await LookupArtist(mbid);
@@ -1396,7 +1436,17 @@ export class ExploreArtistDetails extends LitElement {
private async fetchReleaseGroups(mbid: string) {
try {
const rgs = await BrowseReleaseGroups(mbid);
this.releaseGroups = rgs ?? [];
// An empty result is "the index has not built this artist
// yet", not "this artist released nothing" — so it must not
// wipe the library albums hydrateFromCache put on screen.
if (rgs && rgs.length > 0) {
this.releaseGroups = rgs;
this.catalogLoaded = true;
this.catalogPending = false;
} else if (this.discogReloaded.has(mbid)) {
this.catalogPending = false;
}
// Populate libraryMBIDs from the inLibrary flag (already
// set by the backend via local_release_group_id cross-ref).
@@ -1414,6 +1464,7 @@ export class ExploreArtistDetails extends LitElement {
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.errorReleases = msg;
this.catalogPending = false;
console.error(
`[explore-artist] BrowseReleaseGroups error: ${msg}`,
);
@@ -1889,6 +1940,11 @@ export class ExploreArtistDetails extends LitElement {
</div>
</div>
<div class="content">
<catalog-scope-notice
scope=${this.catalogScope()}
entity-type="artist"
@catalog-retry=${this.retryCatalog}
></catalog-scope-notice>
${this.renderTopSection()} ${this.renderDiscography()}
${this.renderSimilarArtists()}
</div>
@@ -0,0 +1,376 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/button/button.js';
import { GetShelves } from '@go/home/Service';
import { GetAlbumTracks } from '@go/library/Library';
import type { home, library } from '@go/models';
import { queueStore } from '@store/queue-store';
import { libraryStore } from '@store/library-store';
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import { designTokens } from '../../styles/tokens.css';
type Shelf = home.Shelf;
/** Icon per shelf kind — a row's reason, at a glance. */
const KIND_ICONS: Record<string, string> = {
'recently-played': 'clock-rotate-left',
'recently-added': 'star',
'most-played': 'repeat',
unplayed: 'box-open',
stale: 'hourglass-half',
artist: 'user',
genre: 'masks-theater',
random: 'shuffle',
};
/**
* The home page: a set of ways *into* the library, rather than another
* view of it.
*
* Everything here is computed by `backend/home`, including the reason
* each row exists, so the rows can change with the user's listening
* without the frontend holding a second opinion about what "on repeat"
* means. This component's job is only to render them and to make a
* cover do the two things a cover should: open the album, or play it.
*/
@customElement('home-view')
export class HomeView extends LitElement {
@state() private shelves: Shelf[] = [];
@state() private loading = true;
@state() private failed = false;
/** Generation of the library the shelves were built from. */
private builtFromGeneration = -1;
private unsubScan?: () => void;
static override styles = [
designTokens,
css`
:host {
display: block;
height: 100%;
overflow-y: auto;
padding: 24px 20px 40px;
box-sizing: border-box;
}
header {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 4px;
}
h1 {
margin: 0;
font-size: 24px;
font-weight: 700;
color: var(--yj-text-primary, #fff);
flex: 1;
}
.lede {
margin: 0 0 24px;
font-size: var(--yj-text-md, 13px);
color: var(--yj-text-secondary, #b3b3b3);
}
.shelf {
margin-bottom: 28px;
}
.shelf-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 2px;
}
.shelf-title {
font-size: var(--yj-text-xl, 18px);
font-weight: 700;
color: var(--yj-text-primary, #fff);
}
.shelf-sub {
margin: 0 0 10px;
font-size: var(--yj-text-sm, 12px);
color: var(--yj-text-tertiary, #888);
}
.row {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 160px;
gap: 14px;
overflow-x: auto;
padding-bottom: 6px;
scrollbar-width: thin;
}
.card {
background: none;
border: none;
padding: 0;
text-align: left;
cursor: pointer;
color: inherit;
display: block;
}
.art {
position: relative;
width: 160px;
height: 160px;
border-radius: 6px;
overflow: hidden;
background: var(--yj-bg-surface, #181818);
display: flex;
align-items: center;
justify-content: center;
color: var(--yj-text-tertiary, #888);
}
.art img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.play {
position: absolute;
right: 8px;
bottom: 8px;
width: 38px;
height: 38px;
border: none;
border-radius: 50%;
background: var(--yj-accent, #ffd43b);
color: #000;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transform: translateY(6px);
transition: opacity 0.12s ease, transform 0.12s ease;
}
.card:hover .play,
.card:focus-within .play {
opacity: 1;
transform: translateY(0);
}
.name {
margin-top: 8px;
font-size: var(--yj-text-md, 13px);
color: var(--yj-text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.artist {
font-size: var(--yj-text-sm, 12px);
color: var(--yj-text-tertiary, #888);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.empty {
padding: 48px 20px;
text-align: center;
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-text-lg, 15px);
}
`,
];
override connectedCallback(): void {
super.connectedCallback();
void this.load();
// A finished scan changes what every shelf would say, and the
// home page is the view most likely to be sitting open while
// one runs.
this.unsubScan = EventsOn(Events.LibraryScanComplete, () => {
void this.load();
});
}
override disconnectedCallback(): void {
super.disconnectedCallback();
this.unsubScan?.();
this.unsubScan = undefined;
}
/**
* Rebuild when the view is shown again after the library changed.
* Navigation keeps this element alive (see `frontend/index.ts`), so
* without this the shelves would be as old as the session.
*/
override willUpdate(): void {
if (
!this.loading
&& this.builtFromGeneration !== libraryStore.changeGeneration
) {
void this.load();
}
}
private async load(): Promise<void> {
this.builtFromGeneration = libraryStore.changeGeneration;
this.loading = true;
try {
this.shelves = (await GetShelves()) ?? [];
this.failed = false;
} catch (err) {
console.error('Could not build the home page:', err);
this.failed = true;
} finally {
this.loading = false;
}
}
override render() {
return html`
<header>
<h1>Home</h1>
<wa-button
size="small"
appearance="plain"
title="Reshuffle the suggestions"
@click=${() => void this.load()}
>
<wa-icon slot="start" name="shuffle"></wa-icon>
Shuffle
</wa-button>
</header>
<p class="lede">Somewhere to start listening.</p>
${this.renderBody()}
`;
}
private renderBody() {
if (this.loading && this.shelves.length === 0) {
return html`<div class="empty">Looking through your library\u2026</div>`;
}
if (this.failed) {
return html`<div class="empty">
Could not read your library just now.
</div>`;
}
if (this.shelves.length === 0) {
return html`<div class="empty">
Nothing to suggest yet \u2014 add a music folder under Settings
and the shelves fill in once it has been scanned.
</div>`;
}
return this.shelves.map((shelf) => this.renderShelf(shelf));
}
private renderShelf(shelf: Shelf) {
return html`
<section class="shelf" data-kind=${shelf.kind}>
<div class="shelf-head">
<wa-icon name=${KIND_ICONS[shelf.kind] ?? 'compact-disc'}></wa-icon>
<span class="shelf-title">${shelf.title}</span>
</div>
<p class="shelf-sub">${shelf.subtitle}</p>
<div class="row">
${shelf.albums.map((album) => this.renderCard(album))}
</div>
</section>
`;
}
private renderCard(album: library.Album) {
const art = album.CoverArtMedium || album.CoverArtSmall || album.CoverArtPath;
return html`
<div
class="card"
role="button"
tabindex="0"
title="${album.Name}${album.ArtistName ? ` \u2014 ${album.ArtistName}` : ''}"
@click=${() => this.openAlbum(album)}
@keydown=${(e: KeyboardEvent) => this.onCardKey(e, album)}
>
<div class="art">
${art
? html`<img src=${art} alt="" loading="lazy" />`
: html`<wa-icon name="compact-disc"></wa-icon>`}
<button
class="play"
title="Play this album"
aria-label="Play ${album.Name}"
@click=${(e: Event) => {
e.stopPropagation();
void this.playAlbum(album);
}}
>
<wa-icon name="play"></wa-icon>
</button>
</div>
<div class="name">${album.Name}</div>
${album.ArtistName
? html`<div class="artist">${album.ArtistName}</div>`
: nothing}
</div>
`;
}
private onCardKey(e: KeyboardEvent, album: library.Album): void {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
this.openAlbum(album);
}
private openAlbum(album: library.Album): void {
this.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: {
view: 'explore-album-details',
releaseGroupMBID: album.MBID || '',
albumName: album.Name,
artistName: album.ArtistName,
localAlbumId: album.ID,
},
}),
);
}
private async playAlbum(album: library.Album): Promise<void> {
try {
const tracks = await GetAlbumTracks(album.ID);
const paths = (tracks ?? []).map((t) => t.FilePath).filter(Boolean);
if (paths.length === 0) return;
queueStore.setQueue(paths, 0, true);
} catch (err) {
console.error('Could not play that album:', err);
}
}
}
declare global {
interface HTMLElementTagNameMap {
'home-view': HomeView;
}
}
@@ -1386,9 +1386,9 @@ export class PlaylistDetails
? html`<img src="${track.CoverArtSmall || track.CoverArtMedium}" alt="" />`
: nothing}
</div>
<span class="cell col-title" title="${track.Title || track.FilePath}">${trackLink(track.Title, track.Album, track.ReleaseGroupMBID, track.RecordingMBID) || track.FilePath}</span>
<span class="cell col-title" title="${track.Title || track.FilePath}">${trackLink(track.Title, track.Album, track.ReleaseGroupMBID, track.RecordingMBID, undefined, track.Artist) || track.FilePath}</span>
<span class="cell col-artist" title="${track.Artist}">${artistLink(track.Artist, track.ArtistMBID)}</span>
<span class="cell col-album" title="${track.Album}">${albumLink(track.Album, track.ReleaseGroupMBID)}</span>
<span class="cell col-album" title="${track.Album}">${albumLink(track.Album, track.ReleaseGroupMBID, undefined, track.Artist)}</span>
<span class="cell col-duration">${formatMilliseconds(track.Duration)}</span>`}
</div>
`;
@@ -1422,7 +1422,7 @@ export class QueuePanel
${artUrl ? html`<div class="track-art"><img src="${artUrl}" alt="" loading="lazy" /></div>` : nothing}
<div class="track-details">
<span class="track-title">
${trackLink(this.getDisplayTitle(track), track.album, track.releaseGroupMbid, track.recordingMbid)}
${trackLink(this.getDisplayTitle(track), track.album, track.releaseGroupMbid, track.recordingMbid, undefined, track.artist)}
</span>
<span class="track-artist">
${artistLink(track.artist, track.artistMbid) || 'Unknown Artist'}
@@ -1245,9 +1245,9 @@ export class SmartPlaylistDetails
? html`<img src="${track.CoverArtSmall || track.CoverArtMedium}" alt="" />`
: nothing}
</div>
<span class="cell col-title" title="${track.Title || track.FilePath}">${trackLink(track.Title, track.Album, track.ReleaseGroupMBID, track.RecordingMBID) || track.FilePath}</span>
<span class="cell col-title" title="${track.Title || track.FilePath}">${trackLink(track.Title, track.Album, track.ReleaseGroupMBID, track.RecordingMBID, undefined, track.Artist) || track.FilePath}</span>
<span class="cell col-artist" title="${track.Artist}">${artistLink(track.Artist, track.ArtistMBID)}</span>
<span class="cell col-album" title="${track.Album}">${albumLink(track.Album, track.ReleaseGroupMBID)}</span>
<span class="cell col-album" title="${track.Album}">${albumLink(track.Album, track.ReleaseGroupMBID, undefined, track.Artist)}</span>
<span class="cell col-duration">${formatMilliseconds(track.Duration)}</span>`}
</div>
`;
@@ -1766,11 +1766,11 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
// Wrap artist/album/track values in explore links.
if (col.id === 'trackName') {
display = trackLink(track.TrackName, track.Album, track.ReleaseGroupMBID, track.RecordingMBID, display as any);
display = trackLink(track.TrackName, track.Album, track.ReleaseGroupMBID, track.RecordingMBID, display as any, track.ArtistName);
} else if (col.id === 'artistName') {
display = artistLink(track.ArtistName, track.ArtistMBID, display as any);
} else if (col.id === 'album') {
display = albumLink(track.Album, track.ReleaseGroupMBID, display as any);
display = albumLink(track.Album, track.ReleaseGroupMBID, display as any, track.ArtistName);
}
return html`
+1
View File
@@ -7,6 +7,7 @@ export const Events = {
TrackChanged: "TrackChanged",
SeekFailed: "SeekFailed",
VolumeChanged: "VolumeChanged",
MuteChanged: "MuteChanged",
// Queue events (backend → frontend push)
QueueChanged: "QueueChanged",
@@ -62,6 +62,10 @@ export class PlayerController implements ReactiveController {
return this.state.volume;
}
get muted(): boolean {
return this.state.muted;
}
// ===================================================================
// ACTIONS
// Delegate to store (which delegates to backend)
@@ -82,4 +86,8 @@ export class PlayerController implements ReactiveController {
setVolume(level: number): void {
playerStore.setVolume(level);
}
toggleMute(): void {
playerStore.toggleMute();
}
}
+10
View File
@@ -28,6 +28,7 @@ export interface PlayerState {
isPlaying: boolean;
currentTrack: TrackInfo | null;
volume: number; // 0-100
muted: boolean; // silenced independently of the volume level
// Frontend-only state (for future use)
// selectedTrackIds: Set<number>;
@@ -41,6 +42,7 @@ class PlayerStore {
isPlaying: false,
currentTrack: null,
volume: 50,
muted: false,
};
private subscribers = new Set<Subscriber>();
@@ -72,6 +74,10 @@ class PlayerStore {
EventsOn(Events.VolumeChanged, (volume: number) => {
this.update({ volume });
});
EventsOn(Events.MuteChanged, (muted: boolean) => {
this.update({ muted });
});
}
// ===================================================================
@@ -103,6 +109,10 @@ class PlayerStore {
Player.SetVolume(level);
}
toggleMute(): void {
void Player.MuteToggle();
}
// ===================================================================
// SUBSCRIPTION SYSTEM
// ===================================================================
+204 -85
View File
@@ -1,13 +1,23 @@
/**
* Utility for rendering artist/album names as clickable links
* that navigate to their MusicBrainz explore detail pages.
* Utility for rendering track/album/artist names as clickable links.
*
* Links are rendered only when an MBID is provided. If the MBID
* is empty (entity not tagged), the name renders as plain text.
* A name links to its MusicBrainz page when the entity is tagged, and
* to the local library page for the same thing when it is not. Both
* destinations are the same two components — `explore-album-details`
* and `explore-artist-details` both accept a local id instead of an
* MBID — so an untagged album is not a dead end, it is just a page with
* less on it.
*
* Falling back rather than rendering plain text is deliberate: a list
* where some rows are clickable and others silently are not reads as a
* bug, not as a statement about metadata. The only case that still
* renders as text is one we genuinely cannot route (no name at all, or
* nothing in the library by that name).
*/
import { html, css } from 'lit';
import type { TemplateResult } from 'lit';
import { libraryStore } from '../store/library-store';
/** Shared CSS for explore link styling. Import into component styles. */
export const exploreLinkStyles = css`
@@ -22,58 +32,125 @@ export const exploreLinkStyles = css`
}
`;
/**
* Dispatch a navigate event to the explore-artist-details page.
* The event bubbles through shadow DOM boundaries.
*/
function navigateToArtist(artistName: string, mbid: string, e: Event): void {
e.stopPropagation();
e.preventDefault();
const target = e.currentTarget as HTMLElement;
/** Fire a navigate event from the clicked element. */
function navigate(target: EventTarget, detail: Record<string, unknown>): void {
target.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: {
view: 'explore-artist-details',
artistMBID: mbid,
artistName,
},
detail,
}),
);
}
/** Case-insensitive compare that tolerates undefined. */
function sameName(a: string | undefined, b: string | undefined): boolean {
return (a ?? '').toLowerCase() === (b ?? '').toLowerCase();
}
/**
* Dispatch a navigate event to the explore-album-details page.
* The event bubbles through shadow DOM boundaries.
* Find the library album row for a name, loading the album cache first
* if a view that populates it has not been opened yet.
*/
function navigateToAlbum(albumName: string, mbid: string, e: Event): void {
e.stopPropagation();
e.preventDefault();
async function findLocalAlbum(
albumName: string,
artistName?: string,
): Promise<{ ID: number; Name: string; ArtistName: string } | null> {
let albums = libraryStore.cachedAlbums;
const target = e.currentTarget as HTMLElement;
if (!albums) {
try {
albums = await libraryStore.getAlbums();
} catch {
return null;
}
}
target.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: {
view: 'explore-album-details',
releaseGroupMBID: mbid,
albumName,
},
}),
);
let fallback: (typeof albums)[0] | null = null;
for (const album of albums ?? []) {
if (!sameName(album.Name, albumName)) continue;
if (artistName && sameName(album.ArtistName, artistName)) return album;
fallback ??= album;
}
return fallback;
}
/** Find the library artist row for a name, loading the cache if needed. */
async function findLocalArtist(
artistName: string,
): Promise<{ ID: number; Name: string } | null> {
let artists = libraryStore.cachedArtists;
if (!artists) {
try {
artists = await libraryStore.getArtists();
} catch {
return null;
}
}
for (const artist of artists ?? []) {
if (sameName(artist.Name, artistName)) return artist;
}
return null;
}
/**
* Render an artist name as a clickable link if an MBID is provided,
* or as plain text if not.
* How long a link waits before navigating.
*
* Every list these links appear in also plays a row on double-click,
* and the title is the widest thing in the row — so the same gesture
* that plays a track starts with a click on its name. Navigating on
* the first of those two clicks means double-clicking a track title
* opens a page instead of playing it. Holding the navigation for one
* double-click interval, and dropping it if the second click arrives,
* lets one element serve both without the row having to know links
* exist.
*/
const DOUBLE_CLICK_GRACE_MS = 250;
/**
* Wrap a link action so it fires on a genuine single click only.
*
* The click's propagation is stopped (the row must not also treat it as
* a selection) but the *double*-click is left alone, so it still
* reaches the row and plays the track.
*/
function singleClick(
run: (target: EventTarget) => void,
): (e: MouseEvent) => void {
return (e: MouseEvent) => {
e.stopPropagation();
e.preventDefault();
// detail > 1 is the second click of a double click; the first
// one already scheduled and is about to be cancelled.
if (e.detail > 1) return;
const target = (e.currentTarget ?? e.target) as EventTarget;
const timer = window.setTimeout(() => {
target.removeEventListener('dblclick', cancel);
run(target);
}, DOUBLE_CLICK_GRACE_MS);
function cancel(): void {
window.clearTimeout(timer);
}
target.addEventListener('dblclick', cancel, { once: true });
};
}
/**
* Render an artist name as a link to the artist page — the
* MusicBrainz one when tagged, the library one when not.
*
* @param artistName - The artist name to display.
* @param mbid - The MusicBrainz artist ID. Empty string = no link.
* @param mbid - The MusicBrainz artist ID. Empty string = local only.
* @param content - Optional custom content to render inside the link
* (e.g. highlighted search result). Defaults to artistName.
*/
@@ -83,78 +160,75 @@ export function artistLink(
content?: TemplateResult | string,
): TemplateResult | string {
if (!artistName) return artistName;
if (!mbid) return content ?? artistName;
const onClick = singleClick((target) => {
void (async () => {
if (mbid) {
navigate(target, {
view: 'explore-artist-details',
artistMBID: mbid,
artistName,
});
return;
}
const local = await findLocalArtist(artistName);
if (!local) return;
navigate(target, {
view: 'explore-artist-details',
artistMBID: '',
artistName,
localArtistId: local.ID,
});
})();
});
return html`<a
class="explore-link"
@click=${(e: Event) => navigateToArtist(artistName, mbid, e)}
title="View artist on Explore"
@click=${onClick}
title=${mbid ? 'View artist on Explore' : 'View artist in your library'}
>${content ?? artistName}</a>`;
}
/**
* Dispatch a navigate event to the explore-album-details page
* with a highlight on a specific track.
*/
function navigateToTrack(
albumName: string,
releaseGroupMBID: string,
recordingMBID: string,
e: Event,
): void {
e.stopPropagation();
e.preventDefault();
const target = e.currentTarget as HTMLElement;
target.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: {
view: 'explore-album-details',
releaseGroupMBID,
albumName,
highlightTrackMBID: recordingMBID,
},
}),
);
}
/**
* Render an album name as a clickable link if an MBID is provided,
* or as plain text if not.
* Render an album name as a link to the album page — the MusicBrainz
* one when tagged, the library one when not.
*
* @param albumName - The album name to display.
* @param mbid - The MusicBrainz release group ID. Empty string = no link.
* @param content - Optional custom content to render inside the link
* (e.g. highlighted search result). Defaults to albumName.
* @param mbid - The MusicBrainz release group ID. Empty = local only.
* @param content - Optional custom content to render inside the link.
* @param artistName - Disambiguates same-named albums in the library.
*/
export function albumLink(
albumName: string,
mbid: string,
content?: TemplateResult | string,
artistName?: string,
): TemplateResult | string {
if (!albumName) return albumName;
if (!mbid) return content ?? albumName;
return html`<a
class="explore-link"
@click=${(e: Event) => navigateToAlbum(albumName, mbid, e)}
title="View album on Explore"
@click=${singleClick((target) => {
void openAlbum(target, albumName, mbid, artistName);
})}
title=${mbid ? 'View album on Explore' : 'View album in your library'}
>${content ?? albumName}</a>`;
}
/**
* Render a track name as a clickable link that opens the album's
* explore page with the track highlighted. Requires both a
* release group MBID (album) and a recording MBID (track).
* Render a track name as a link that opens the track's album with the
* track highlighted. An untagged track highlights by title on the
* library album page instead, so every row in a list behaves the same.
*
* @param trackName - The track name to display.
* @param albumName - The album name (for the page title).
* @param releaseGroupMBID - The album's MusicBrainz release group ID.
* @param recordingMBID - The track's MusicBrainz recording ID.
* @param content - Optional custom content (e.g. highlighted text).
* @param artistName - Disambiguates same-named albums in the library.
*/
export function trackLink(
trackName: string,
@@ -162,13 +236,58 @@ export function trackLink(
releaseGroupMBID: string,
recordingMBID: string,
content?: TemplateResult | string,
artistName?: string,
): TemplateResult | string {
if (!trackName) return trackName;
if (!releaseGroupMBID || !recordingMBID) return content ?? trackName;
if (!albumName) return content ?? trackName;
return html`<a
class="explore-link"
@click=${(e: Event) => navigateToTrack(albumName, releaseGroupMBID, recordingMBID, e)}
title="View track on album page"
@click=${singleClick((target) => {
void openAlbum(
target,
albumName,
releaseGroupMBID,
artistName,
recordingMBID,
trackName,
);
})}
title=${releaseGroupMBID
? 'View track on the album page'
: 'View track on the album page in your library'}
>${content ?? trackName}</a>`;
}
/**
* Route to an album page, preferring the catalog and falling back to
* the library copy. `highlight*` marks one track on arrival.
*/
async function openAlbum(
target: EventTarget,
albumName: string,
releaseGroupMBID: string,
artistName?: string,
highlightTrackMBID?: string,
highlightTrackTitle?: string,
): Promise<void> {
const detail: Record<string, unknown> = {
view: 'explore-album-details',
releaseGroupMBID,
albumName,
artistName: artistName ?? '',
};
if (highlightTrackMBID) detail.highlightTrackMBID = highlightTrackMBID;
if (highlightTrackTitle) detail.highlightTrackTitle = highlightTrackTitle;
if (!releaseGroupMBID) {
const local = await findLocalAlbum(albumName, artistName);
if (!local) return;
detail.localAlbumId = local.ID;
detail.artistName = local.ArtistName;
}
navigate(target, detail);
}
@@ -0,0 +1,66 @@
/**
* The scope notice exists because an album or artist page looked
* identical whether it was showing the catalog, a library stand-in, or
* nothing yet. So the assertions here are about the one thing it must
* never do — stay silent when the page is not the whole story — and
* about staying out of the way when it is.
*/
import { describe, expect, it } from 'vitest';
import '@components/catalog-scope-notice/catalog-scope-notice';
import { fixture, shadow, text } from '@test/support/render';
describe('catalog scope notice', () => {
it('renders nothing at all for full catalog data', async () => {
const el = await fixture('catalog-scope-notice', { scope: 'catalog' });
expect(shadow(el, '.notice')).toBeNull();
});
it('names the entity it is talking about', async () => {
const album = await fixture('catalog-scope-notice', {
scope: 'library',
entityType: 'album',
});
const artist = await fixture('catalog-scope-notice', {
scope: 'library',
entityType: 'artist',
});
expect(text(album, '.text')).toContain('album');
expect(text(artist, '.text')).toContain('artist');
});
it('distinguishes "still loading" from "this is all there is"', async () => {
const loading = await fixture('catalog-scope-notice', { scope: 'loading' });
const library = await fixture('catalog-scope-notice', { scope: 'library' });
expect(text(loading, '.text')).toContain('load');
expect(text(library, '.text')).toContain('Library only');
});
it('offers a retry only where retrying could change anything', async () => {
for (const scope of ['loading', 'library']) {
const el = await fixture('catalog-scope-notice', { scope });
expect(shadow(el, 'button')).toBeNull();
}
const el = await fixture('catalog-scope-notice', { scope: 'unavailable' });
expect(shadow(el, 'button')).not.toBeNull();
});
it('asks its host to retry rather than fetching anything itself', async () => {
const el = await fixture('catalog-scope-notice', { scope: 'unavailable' });
let asked = 0;
el.addEventListener('catalog-retry', () => {
asked += 1;
});
shadow<HTMLElement>(el, 'button')!.click();
expect(asked).toBe(1);
});
});
+151
View File
@@ -0,0 +1,151 @@
/**
* The home page renders whatever `backend/home` decided, and nothing
* else: the shelves, their stated reasons, and two things a cover can
* do. So these tests are about the contract between the two — that a
* shelf's reason is displayed rather than swallowed, that a card opens
* the album it names, and that a play button plays it instead of
* opening it.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import '@components/home-view/home-view';
import { stub, calls, lastArgs, stubFailure } from '@test/support/harness';
import { fixture, shadow, shadowAll, texts, text } from '@test/support/render';
function album(id: number, name: string, artist = 'Artist') {
return {
ID: id,
Name: name,
ArtistName: artist,
MBID: '',
Year: 2000,
ReleaseYear: 2000,
CoverArtPath: '',
CoverArtSmall: '',
CoverArtMedium: '',
CoverArtLarge: '',
ArtistMBID: '',
};
}
const SHELVES = [
{
id: 'recently-played',
kind: 'recently-played',
title: 'Pick up where you left off',
subtitle: 'The last albums you played',
albums: [album(1, 'Kid A'), album(2, 'Amnesiac')],
},
{
id: 'genre-Doom Jazz',
kind: 'genre',
title: 'Doom Jazz',
subtitle: 'Because your library is full of it',
albums: [album(3, 'Black Ships')],
},
];
describe('home view', () => {
beforeEach(() => {
stub('home.Service.GetShelves', SHELVES);
stub('library.Library.GetAlbumTracks', [
{ FilePath: '/music/1.mp3' },
{ FilePath: '/music/2.mp3' },
]);
});
it('renders a row per shelf, each with the reason it exists', async () => {
const el = await fixture('home-view');
await el.updateComplete;
expect(texts(el, '.shelf-title')).toEqual([
'Pick up where you left off',
'Doom Jazz',
]);
// The subtitle is the whole difference between a shelf and a grid.
expect(texts(el, '.shelf-sub')).toEqual([
'The last albums you played',
'Because your library is full of it',
]);
});
it('keys each row by the kind the backend assigned', async () => {
const el = await fixture('home-view');
await el.updateComplete;
expect(
shadowAll(el, '.shelf').map((s) => s.getAttribute('data-kind')),
).toEqual(['recently-played', 'genre']);
});
it('opens the album a card names, by local id', async () => {
const el = await fixture('home-view');
await el.updateComplete;
const seen: unknown[] = [];
el.addEventListener('navigate', (e) => seen.push((e as CustomEvent).detail));
shadow<HTMLElement>(el, '.card')!.click();
expect(seen).toEqual([
{
view: 'explore-album-details',
releaseGroupMBID: '',
albumName: 'Kid A',
artistName: 'Artist',
localAlbumId: 1,
},
]);
});
it('plays the album from the play button without navigating', async () => {
const el = await fixture('home-view');
await el.updateComplete;
const seen: unknown[] = [];
el.addEventListener('navigate', (e) => seen.push(e));
shadow<HTMLElement>(el, '.play')!.click();
await new Promise((r) => setTimeout(r, 0));
expect(seen).toEqual([]);
expect(lastArgs('library.Library.GetAlbumTracks')).toEqual([1]);
expect(lastArgs('queue.Queue.SetQueue')).toEqual([
['/music/1.mp3', '/music/2.mp3'],
0,
true,
]);
});
it('says so rather than rendering an empty page when there is nothing', async () => {
stub('home.Service.GetShelves', []);
const el = await fixture('home-view');
await el.updateComplete;
expect(text(el, '.empty')).toContain('Nothing to suggest yet');
});
it('reports a backend failure instead of pretending the library is empty', async () => {
stubFailure('home.Service.GetShelves');
const el = await fixture('home-view');
await el.updateComplete;
await el.updateComplete;
expect(text(el, '.empty')).toContain('Could not read your library');
});
it('rebuilds on demand', async () => {
const el = await fixture('home-view');
await el.updateComplete;
const before = calls('home.Service.GetShelves').length;
shadow<HTMLElement>(el, 'wa-button')!.click();
await el.updateComplete;
expect(calls('home.Service.GetShelves').length).toBe(before + 1);
});
});
@@ -8,6 +8,7 @@ import { describe, expect, it, beforeEach, vi, afterEach } from 'vitest';
import '@components/audio-player/controls/player-controls';
import '@components/audio-player/seekbar/seek-bar';
import '@components/audio-player/volume-control/volume-control';
import { Events } from '../../src/events';
import { emit, calls, lastArgs, flush } from '@test/support/harness';
import {
@@ -310,3 +311,51 @@ describe('<seek-bar>', () => {
expect(text(el, '[data-testid="elapsed-time"]')).toBe('00:30');
});
});
/**
* Mute is silence at an unchanged volume level, so the indicator has to
* be driven by its own event — watching the volume number, as it used
* to, meant pressing M visibly did nothing.
*/
describe('volume control: mute', () => {
beforeEach(() => {
emit(Events.VolumeChanged, 40);
emit(Events.MuteChanged, false);
});
it('shows a muted glyph and label once the backend reports mute', async () => {
const el = await fixture('volume-control');
expect(shadow(el, 'button')?.getAttribute('data-muted')).toBe('false');
emit(Events.MuteChanged, true);
await flush();
await el.updateComplete;
expect(shadow(el, 'button')?.getAttribute('data-muted')).toBe('true');
expect(shadow(el, 'button wa-icon')?.getAttribute('name')).toBe(
'volume-xmark',
);
expect(shadow(el, 'button')?.getAttribute('aria-label')).toBe('Muted');
});
it('keeps showing the volume level while muted, because it is unchanged', async () => {
emit(Events.MuteChanged, true);
await flush();
const el = await fixture('volume-control');
await click(el, 'button');
expect(shadow<HTMLInputElement>(el, 'wa-slider')?.value).toBe(40);
});
it('toggles mute through the backend rather than locally', async () => {
const el = await fixture('volume-control');
await click(el, 'button');
await click(el, '.mute-toggle');
expect(calls('player.Player.MuteToggle').length).toBe(1);
// Nothing optimistic: the icon follows the backend's event.
expect(shadow(el, 'button')?.getAttribute('data-muted')).toBe('false');
});
});
+15
View File
@@ -79,6 +79,19 @@ describe('player store: playback state', () => {
expect(playerStore.getState().volume).toBe(42);
});
it('tracks mute separately from the volume level', () => {
// Mute leaves the volume number alone, which is exactly why it
// needs an event of its own: the indicator had nothing to react to.
emit(Events.VolumeChanged, 42);
emit(Events.MuteChanged, true);
expect(playerStore.getState()).toMatchObject({ volume: 42, muted: true });
emit(Events.MuteChanged, false);
expect(playerStore.getState().muted).toBe(false);
});
it('replaces state rather than mutating it, so a saved reference is stable', () => {
emit(Events.VolumeChanged, 10);
const before = playerStore.getState();
@@ -110,12 +123,14 @@ describe('player store: actions', () => {
playerStore.loadTrack('/music/one.mp3');
playerStore.seek(30);
playerStore.setVolume(60);
playerStore.toggleMute();
expect(calls().map((c) => c.path)).toEqual([
'player.Player.Pause',
'player.Player.LoadFile',
'player.Player.Seek',
'player.Player.SetVolume',
'player.Player.MuteToggle',
]);
});
+5
View File
@@ -0,0 +1,5 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {home} from '../models';
export function GetShelves():Promise<Array<home.Shelf>>;
+7
View File
@@ -0,0 +1,7 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function GetShelves() {
return window['go']['home']['Service']['GetShelves']();
}
+47
View File
@@ -868,6 +868,8 @@ export namespace download {
attempted: number;
started: number;
synced: number;
waiting: number;
noProviders: boolean;
static createFrom(source: any = {}) {
return new Summary(source);
@@ -880,6 +882,8 @@ export namespace download {
this.attempted = source["attempted"];
this.started = source["started"];
this.synced = source["synced"];
this.waiting = source["waiting"];
this.noProviders = source["noProviders"];
}
}
@@ -1377,6 +1381,49 @@ export namespace explore {
}
export namespace home {
export class Shelf {
id: string;
kind: string;
title: string;
subtitle: string;
albums: library.Album[];
static createFrom(source: any = {}) {
return new Shelf(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
this.kind = source["kind"];
this.title = source["title"];
this.subtitle = source["subtitle"];
this.albums = this.convertValues(source["albums"], library.Album);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace jobs {
export class Caps {
+2
View File
@@ -22,6 +22,8 @@ export function LoadFile(arg1:string):Promise<void>;
export function MuteToggle():Promise<void>;
export function Muted():Promise<boolean>;
export function Pause():Promise<void>;
export function Play():Promise<void>;
+4
View File
@@ -38,6 +38,10 @@ export function MuteToggle() {
return window['go']['player']['Player']['MuteToggle']();
}
export function Muted() {
return window['go']['player']['Player']['Muted']();
}
export function Pause() {
return window['go']['player']['Player']['Pause']();
}