Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7de1b4edc1 | ||
|
|
ff687f0bd9 | ||
|
|
62bb40fc4d | ||
|
|
ba35858208 | ||
|
|
7c3c0e25b9 | ||
|
|
0ca37a31a6 | ||
|
|
c48123f7a3 |
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`.
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ const (
|
||||
TrackChanged = "TrackChanged"
|
||||
SeekFailed = "SeekFailed"
|
||||
VolumeChanged = "VolumeChanged"
|
||||
MuteChanged = "MuteChanged"
|
||||
)
|
||||
|
||||
// Queue events (backend → frontend push).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
// ===================================================================
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>>;
|
||||
Executable
+7
@@ -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']();
|
||||
}
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
@@ -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']();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user