Compare commits
7
Commits
213640c9a8
...
7de1b4edc1
| 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.
|
- `mediacontrols` — MPRIS integration on Linux via D-Bus.
|
||||||
- `system` — OS-specific paths (XDG on Linux, `%LOCALAPPDATA%` on Windows).
|
- `system` — OS-specific paths (XDG on Linux, `%LOCALAPPDATA%` on Windows).
|
||||||
- `explore` — Catalog search and browse over `explore_index`. See below.
|
- `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/`).
|
- `profiling` — pprof server on `:6060`, compiled out in non-dev builds via build tags (`internal/dev/`).
|
||||||
|
|
||||||
**Explore catalog** (`backend/explore/`): the searchable MusicBrainz/
|
**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.
|
**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/`.
|
**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
|
Emit through **`events.Emit(ctx, name, data...)`**, never
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"yellowjacket/backend/events"
|
"yellowjacket/backend/events"
|
||||||
"yellowjacket/backend/explore"
|
"yellowjacket/backend/explore"
|
||||||
"yellowjacket/backend/frontendutil"
|
"yellowjacket/backend/frontendutil"
|
||||||
|
"yellowjacket/backend/home"
|
||||||
"yellowjacket/backend/jobs"
|
"yellowjacket/backend/jobs"
|
||||||
"yellowjacket/backend/library"
|
"yellowjacket/backend/library"
|
||||||
"yellowjacket/backend/maintenance"
|
"yellowjacket/backend/maintenance"
|
||||||
@@ -210,6 +211,11 @@ func NewYellowJacketApp(
|
|||||||
yjApp.explore,
|
yjApp.explore,
|
||||||
yjApp.autotag,
|
yjApp.autotag,
|
||||||
jobs.NewService(yjApp.jobs),
|
jobs.NewService(yjApp.jobs),
|
||||||
|
home.NewService(
|
||||||
|
yjApp.logger.WithGroup("home"),
|
||||||
|
yjApp.database,
|
||||||
|
yjApp.library,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
if yjApp.downloadSvc != nil {
|
if yjApp.downloadSvc != nil {
|
||||||
|
|||||||
@@ -176,6 +176,16 @@ WHERE state = 'wanted'
|
|||||||
ORDER BY attempts, created_at
|
ORDER BY attempts, created_at
|
||||||
LIMIT ?;
|
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
|
-- name: ListChildDownloadRequests :many
|
||||||
SELECT * FROM download_requests WHERE parent_id = ? ORDER BY id;
|
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
|
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
|
const recordDownloadRequestAttempt = `-- name: RecordDownloadRequestAttempt :exec
|
||||||
UPDATE download_requests
|
UPDATE download_requests
|
||||||
SET attempts = attempts + 1,
|
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 is how many requests were pushed to an external list.
|
||||||
Synced int `json:"synced"`
|
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
|
// 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
|
return s.Expanded > 0 || s.Satisfied > 0 || s.Started > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunOnce works the request list once. It is safe to call directly, and
|
// RunOnce works the request list once, honouring each request's
|
||||||
// the "search now" button does.
|
// backoff. This is what the loop calls.
|
||||||
func (r *Reconciler) RunOnce(ctx context.Context) (Summary, error) {
|
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()
|
r.runMu.Lock()
|
||||||
defer r.runMu.Unlock()
|
defer r.runMu.Unlock()
|
||||||
|
|
||||||
@@ -287,13 +310,15 @@ func (r *Reconciler) RunOnce(ctx context.Context) (Summary, error) {
|
|||||||
|
|
||||||
summary.Synced = r.syncExternalLists(ctx)
|
summary.Synced = r.syncExternalLists(ctx)
|
||||||
|
|
||||||
attempted, started, err := r.attemptDue(ctx)
|
attempted, started, err := r.attemptDue(ctx, force)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return summary, err
|
return summary, err
|
||||||
}
|
}
|
||||||
|
|
||||||
summary.Attempted = attempted
|
summary.Attempted = attempted
|
||||||
summary.Started = started
|
summary.Started = started
|
||||||
|
summary.Waiting = r.countWaiting(ctx)
|
||||||
|
summary.NoProviders = len(r.manager.enabledProviders()) == 0
|
||||||
|
|
||||||
r.logger.Info(
|
r.logger.Info(
|
||||||
"reconciled request list",
|
"reconciled request list",
|
||||||
@@ -506,10 +531,19 @@ func (r *Reconciler) retireOwned(ctx context.Context) (int, error) {
|
|||||||
// Attempting downloads
|
// Attempting downloads
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// attemptDue searches for a bounded batch of due requests and grabs the
|
// attemptDue searches for a bounded batch of requests and grabs the
|
||||||
// ones with a clear winner.
|
// ones with a clear winner. force takes requests whose backoff has not
|
||||||
func (r *Reconciler) attemptDue(ctx context.Context) (attempted, started int, err error) {
|
// elapsed as well.
|
||||||
due, err := r.store.ListDueRequests(ctx, r.batch)
|
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 {
|
if err != nil {
|
||||||
return 0, 0, err
|
return 0, 0, err
|
||||||
}
|
}
|
||||||
@@ -615,6 +649,26 @@ func (r *Reconciler) attempt(ctx context.Context, req Request) (bool, string) {
|
|||||||
return started, reason
|
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
|
// External list sync
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -495,3 +495,101 @@ func waitFor(t *testing.T, cond func() bool, msg string) {
|
|||||||
|
|
||||||
t.Fatal(msg)
|
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
|
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
|
// ListChildRequests returns the requests an artist subscription
|
||||||
// produced.
|
// produced.
|
||||||
func (s *Store) ListChildRequests(
|
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 {
|
if err != nil {
|
||||||
return summary, err
|
return summary, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const (
|
|||||||
TrackChanged = "TrackChanged"
|
TrackChanged = "TrackChanged"
|
||||||
SeekFailed = "SeekFailed"
|
SeekFailed = "SeekFailed"
|
||||||
VolumeChanged = "VolumeChanged"
|
VolumeChanged = "VolumeChanged"
|
||||||
|
MuteChanged = "MuteChanged"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Queue events (backend → frontend push).
|
// 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())
|
volume := int(p.getUserVolume())
|
||||||
|
muted := p.volume != nil && p.volume.Silent
|
||||||
p.logger.Info(
|
p.logger.Info(
|
||||||
"Emitting VolumeChangedEvent", "volume", volume,
|
"Emitting VolumeChangedEvent", "volume", volume, "muted", muted,
|
||||||
)
|
)
|
||||||
|
|
||||||
events.Emit(p.ctx, events.VolumeChanged, volume)
|
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 {
|
if p.mediaControls != nil {
|
||||||
// MPRIS volume is 0.0–1.0 linear.
|
// MPRIS volume is 0.0–1.0 linear.
|
||||||
p.mediaControls.UpdateVolume(
|
p.mediaControls.UpdateVolume(
|
||||||
@@ -692,12 +699,27 @@ func (p *Player) getUserVolume() UserVolume {
|
|||||||
return Volume(p.volume.Volume).ToUserVolume()
|
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.
|
// MuteToggle toggles the mute state.
|
||||||
func (p *Player) MuteToggle() error {
|
func (p *Player) MuteToggle() error {
|
||||||
p.mu.Lock()
|
p.mu.Lock()
|
||||||
defer p.mu.Unlock()
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
if p.volume == nil {
|
||||||
|
return errNoAudioFileLoaded
|
||||||
|
}
|
||||||
|
|
||||||
|
speaker.Lock()
|
||||||
p.volume.Silent = !p.volume.Silent
|
p.volume.Silent = !p.volume.Silent
|
||||||
|
speaker.Unlock()
|
||||||
|
|
||||||
p.emitVolumeChanged()
|
p.emitVolumeChanged()
|
||||||
p.saveState()
|
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/job-indicator.ts';
|
||||||
import '@components/jobs/jobs-view.ts';
|
import '@components/jobs/jobs-view.ts';
|
||||||
import '@components/downloads-view/downloads-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/styles/themes/default.css';
|
||||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
|
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
|
||||||
@@ -57,6 +58,7 @@ setBasePath('/dist/webawesome');
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const VIEW_TAGS: Record<string, string> = {
|
const VIEW_TAGS: Record<string, string> = {
|
||||||
|
home: 'home-view',
|
||||||
tracks: 'track-list',
|
tracks: 'track-list',
|
||||||
albums: 'cover-grid',
|
albums: 'cover-grid',
|
||||||
artists: 'artists-view',
|
artists: 'artists-view',
|
||||||
@@ -209,7 +211,14 @@ document.addEventListener('navigate', (e: Event) => {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'explore-album-details': {
|
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');
|
const el = document.createElement('explore-album-details');
|
||||||
|
|
||||||
if (releaseGroupMBID) el.setAttribute('release-group-mbid', releaseGroupMBID);
|
if (releaseGroupMBID) el.setAttribute('release-group-mbid', releaseGroupMBID);
|
||||||
@@ -218,6 +227,9 @@ document.addEventListener('navigate', (e: Event) => {
|
|||||||
if (highlightTrackMBID) {
|
if (highlightTrackMBID) {
|
||||||
el.setAttribute('highlight-track-mbid', highlightTrackMBID);
|
el.setAttribute('highlight-track-mbid', highlightTrackMBID);
|
||||||
}
|
}
|
||||||
|
if (highlightTrackTitle) {
|
||||||
|
el.setAttribute('highlight-track-title', highlightTrackTitle);
|
||||||
|
}
|
||||||
if (localAlbumId) el.setAttribute('local-album-id', String(localAlbumId));
|
if (localAlbumId) el.setAttribute('local-album-id', String(localAlbumId));
|
||||||
mainContent.appendChild(el);
|
mainContent.appendChild(el);
|
||||||
currentDetailEl = el;
|
currentDetailEl = el;
|
||||||
|
|||||||
@@ -45,6 +45,23 @@ export class VolumeControl extends LitElement {
|
|||||||
align-items: center;
|
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 {
|
.volume-popup {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 100%;
|
bottom: 100%;
|
||||||
@@ -56,6 +73,8 @@ export class VolumeControl extends LitElement {
|
|||||||
padding: 16px 8px;
|
padding: 16px 8px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
}
|
}
|
||||||
@@ -91,7 +110,8 @@ export class VolumeControl extends LitElement {
|
|||||||
private get volumeIcon(): string {
|
private get volumeIcon(): string {
|
||||||
const vol = this.currentVolume;
|
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';
|
if (vol <= 50) return 'volume-low';
|
||||||
|
|
||||||
return 'volume-high';
|
return 'volume-high';
|
||||||
@@ -169,13 +189,25 @@ export class VolumeControl extends LitElement {
|
|||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
|
const muted = this.player.muted;
|
||||||
|
|
||||||
return html`
|
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>
|
<wa-icon name=${this.volumeIcon}></wa-icon>
|
||||||
</button>
|
</button>
|
||||||
${this.showSlider
|
${this.showSlider
|
||||||
? html`
|
? html`
|
||||||
<div class="volume-popup" @click="${this.handlePopupClick}">
|
<div
|
||||||
|
class="volume-popup ${muted ? 'muted' : ''}"
|
||||||
|
@click="${this.handlePopupClick}"
|
||||||
|
>
|
||||||
<wa-slider
|
<wa-slider
|
||||||
orientation="vertical"
|
orientation="vertical"
|
||||||
min="0"
|
min="0"
|
||||||
@@ -183,6 +215,12 @@ export class VolumeControl extends LitElement {
|
|||||||
.value="${this.currentVolume}"
|
.value="${this.currentVolume}"
|
||||||
@input="${this.handleInput}"
|
@input="${this.handleInput}"
|
||||||
></wa-slider>
|
></wa-slider>
|
||||||
|
<button
|
||||||
|
class="mute-toggle"
|
||||||
|
@click=${() => this.player.toggleMute()}
|
||||||
|
>
|
||||||
|
${muted ? 'Unmute' : 'Mute'}
|
||||||
|
</button>
|
||||||
</div>
|
</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;
|
@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;
|
private unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
static override styles = [
|
static override styles = [
|
||||||
@@ -167,6 +175,24 @@ export class DownloadsView extends LitElement {
|
|||||||
color: var(--yj-text-secondary, #b3b3b3);
|
color: var(--yj-text-secondary, #b3b3b3);
|
||||||
margin: 8px 0 0;
|
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.unsubscribe = downloadStore.subscribe(() => {
|
||||||
this.requests = downloadStore.requests;
|
this.requests = downloadStore.requests;
|
||||||
this.downloads = downloadStore.downloads;
|
this.downloads = downloadStore.downloads;
|
||||||
|
this.canDownload = downloadStore.available;
|
||||||
});
|
});
|
||||||
|
|
||||||
void downloadStore.init().then(() => {
|
void downloadStore.init().then(() => {
|
||||||
this.requests = downloadStore.requests;
|
this.requests = downloadStore.requests;
|
||||||
this.downloads = downloadStore.downloads;
|
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 {
|
override disconnectedCallback(): void {
|
||||||
@@ -189,6 +223,7 @@ export class DownloadsView extends LitElement {
|
|||||||
|
|
||||||
this.unsubscribe?.();
|
this.unsubscribe?.();
|
||||||
this.unsubscribe = null;
|
this.unsubscribe = null;
|
||||||
|
clearInterval(this.clockTimer);
|
||||||
}
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
@@ -201,10 +236,11 @@ export class DownloadsView extends LitElement {
|
|||||||
size="small"
|
size="small"
|
||||||
appearance="outlined"
|
appearance="outlined"
|
||||||
?disabled=${this.checking}
|
?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()}
|
@click=${() => void this.checkNow()}
|
||||||
>
|
>
|
||||||
<wa-icon slot="start" name="rotate"></wa-icon>
|
<wa-icon slot="start" name="rotate"></wa-icon>
|
||||||
${this.checking ? 'Checking…' : 'Check now'}
|
${this.checking ? 'Searching…' : 'Check now'}
|
||||||
</wa-button>
|
</wa-button>
|
||||||
`
|
`
|
||||||
: nothing}
|
: nothing}
|
||||||
@@ -213,7 +249,10 @@ export class DownloadsView extends LitElement {
|
|||||||
<p class="subtitle">
|
<p class="subtitle">
|
||||||
Music you have requested, and the download attempts that
|
Music you have requested, and the download attempts that
|
||||||
have run for it. A request that cannot be found today stays
|
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>
|
</p>
|
||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
@@ -248,6 +287,7 @@ export class DownloadsView extends LitElement {
|
|||||||
const satisfied = this.requests.filter((r) => r.state === 'satisfied');
|
const satisfied = this.requests.filter((r) => r.state === 'satisfied');
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
|
${this.renderProviderNotice()}
|
||||||
${this.renderSummary()}
|
${this.renderSummary()}
|
||||||
${satisfied.length > 0
|
${satisfied.length > 0
|
||||||
? html`
|
? html`
|
||||||
@@ -269,7 +309,18 @@ export class DownloadsView extends LitElement {
|
|||||||
subscriptions,
|
subscriptions,
|
||||||
(r) => this.renderSubscription(r),
|
(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('Paused', paused, (r) => this.renderRequest(r))}
|
||||||
${this.renderRequestSection('Found', satisfied, (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() {
|
private renderSummary() {
|
||||||
if (!this.lastSummary) return nothing;
|
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.expanded > 0 ? `${s.expanded} new album${s.expanded === 1 ? '' : 's'} found` : '',
|
||||||
s.satisfied > 0 ? `${s.satisfied} already owned` : '',
|
s.satisfied > 0 ? `${s.satisfied} already owned` : '',
|
||||||
s.started > 0 ? `${s.started} downloading` : '',
|
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);
|
].filter(Boolean);
|
||||||
|
|
||||||
return html`
|
if (parts.length > 0) {
|
||||||
<p class="summary">
|
return html`<p class="summary">${parts.join(' · ')}</p>`;
|
||||||
${parts.length > 0 ? parts.join(' · ') : 'Nothing new this time.'}
|
}
|
||||||
</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(
|
private renderRequestSection(
|
||||||
@@ -361,7 +441,7 @@ export class DownloadsView extends LitElement {
|
|||||||
${request.artist ? `${request.artist} — ` : ''}${request.title ||
|
${request.artist ? `${request.artist} — ` : ''}${request.title ||
|
||||||
request.mbid}
|
request.mbid}
|
||||||
</div>
|
</div>
|
||||||
<div class="detail">${requestDetail(request)}</div>
|
<div class="detail">${requestDetail(request, this.nowMs)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
${request.state === 'satisfied'
|
${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
|
* 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.
|
* 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 === '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 {
|
declare global {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { EventsOn } from '@runtime/runtime';
|
|||||||
import { Events } from '../../events';
|
import { Events } from '../../events';
|
||||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
import '../library-status-indicator/library-status-indicator.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 '@awesome.me/webawesome/dist/components/button/button.js';
|
||||||
import '../download-picker/download-picker';
|
import '../download-picker/download-picker';
|
||||||
import { downloadStore } from '../../store/download-store';
|
import { downloadStore } from '../../store/download-store';
|
||||||
@@ -97,6 +99,11 @@ export class ExploreAlbumDetails extends LitElement {
|
|||||||
@property({ type: String, attribute: 'highlight-track-mbid' })
|
@property({ type: String, attribute: 'highlight-track-mbid' })
|
||||||
highlightTrackMBID = '';
|
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' })
|
@property({ type: Number, attribute: 'local-album-id' })
|
||||||
localAlbumId = 0;
|
localAlbumId = 0;
|
||||||
|
|
||||||
@@ -108,6 +115,13 @@ export class ExploreAlbumDetails extends LitElement {
|
|||||||
@state() private loadingReleases = true;
|
@state() private loadingReleases = true;
|
||||||
@state() private errorInfo = '';
|
@state() private errorInfo = '';
|
||||||
@state() private errorReleases = '';
|
@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. */
|
/** Unified entries shown in the dropdown — synthetics first, then real clusters. */
|
||||||
@state() private versionEntries: VersionEntry[] = [];
|
@state() private versionEntries: VersionEntry[] = [];
|
||||||
/** Currently-selected dropdown entry (by VersionEntry.key). */
|
/** Currently-selected dropdown entry (by VersionEntry.key). */
|
||||||
@@ -543,6 +557,7 @@ export class ExploreAlbumDetails extends LitElement {
|
|||||||
if (this.releasesReloaded.has(mbid)) return;
|
if (this.releasesReloaded.has(mbid)) return;
|
||||||
|
|
||||||
this.releasesReloaded.add(mbid);
|
this.releasesReloaded.add(mbid);
|
||||||
|
this.catalogPending = false;
|
||||||
if (this.releases.length === 0) this.loadingReleases = false;
|
if (this.releases.length === 0) this.loadingReleases = false;
|
||||||
}, 12000);
|
}, 12000);
|
||||||
}
|
}
|
||||||
@@ -552,13 +567,15 @@ export class ExploreAlbumDetails extends LitElement {
|
|||||||
|
|
||||||
override updated() {
|
override updated() {
|
||||||
if (
|
if (
|
||||||
this.highlightTrackMBID &&
|
(this.highlightTrackMBID || this.highlightTrackTitle) &&
|
||||||
!this.hasScrolledToHighlight &&
|
!this.hasScrolledToHighlight &&
|
||||||
!this.loadingReleases
|
!this.loadingReleases
|
||||||
) {
|
) {
|
||||||
const el = this.shadowRoot?.querySelector<HTMLElement>(
|
const el = this.highlightTrackMBID
|
||||||
`[data-track-mbid="${this.highlightTrackMBID}"]`,
|
? this.shadowRoot?.querySelector<HTMLElement>(
|
||||||
);
|
`[data-track-mbid="${this.highlightTrackMBID}"]`,
|
||||||
|
)
|
||||||
|
: this.findRowByTitle(this.highlightTrackTitle);
|
||||||
|
|
||||||
if (el) {
|
if (el) {
|
||||||
this.hasScrolledToHighlight = true;
|
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 ── */
|
/* ── Data Loading ── */
|
||||||
|
|
||||||
private async loadAllData() {
|
private async loadAllData() {
|
||||||
@@ -619,6 +656,8 @@ export class ExploreAlbumDetails extends LitElement {
|
|||||||
this.errorReleases = '';
|
this.errorReleases = '';
|
||||||
this.loadingInfo = true;
|
this.loadingInfo = true;
|
||||||
this.loadingReleases = true;
|
this.loadingReleases = true;
|
||||||
|
this.catalogReleasesLoaded = false;
|
||||||
|
this.catalogPending = Boolean(this.releaseGroupMBID);
|
||||||
this.releases = [];
|
this.releases = [];
|
||||||
this.versionEntries = [];
|
this.versionEntries = [];
|
||||||
this.selectedVersionKey = '';
|
this.selectedVersionKey = '';
|
||||||
@@ -903,6 +942,8 @@ export class ExploreAlbumDetails extends LitElement {
|
|||||||
if (releases && releases.length > 0) {
|
if (releases && releases.length > 0) {
|
||||||
// Warm cache hit (or the background re-fetch landed):
|
// Warm cache hit (or the background re-fetch landed):
|
||||||
// authoritative MB versions replace any local placeholder.
|
// authoritative MB versions replace any local placeholder.
|
||||||
|
this.catalogReleasesLoaded = true;
|
||||||
|
this.catalogPending = false;
|
||||||
this.releases = releases;
|
this.releases = releases;
|
||||||
this.buildClusters();
|
this.buildClusters();
|
||||||
this.loadingReleases = false;
|
this.loadingReleases = false;
|
||||||
@@ -917,11 +958,18 @@ export class ExploreAlbumDetails extends LitElement {
|
|||||||
if (this.releases.length > 0 || this.releasesReloaded.has(mbid)) {
|
if (this.releases.length > 0 || this.releasesReloaded.has(mbid)) {
|
||||||
this.loadingReleases = false;
|
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) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
this.errorReleases = msg;
|
this.errorReleases = msg;
|
||||||
console.error(`[explore-album] BrowseReleases error: ${msg}`);
|
console.error(`[explore-album] BrowseReleases error: ${msg}`);
|
||||||
this.loadingReleases = false;
|
this.loadingReleases = false;
|
||||||
|
this.catalogPending = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1425,6 +1473,11 @@ export class ExploreAlbumDetails extends LitElement {
|
|||||||
return html`
|
return html`
|
||||||
${this.renderHeader()}
|
${this.renderHeader()}
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
<catalog-scope-notice
|
||||||
|
scope=${this.catalogScope()}
|
||||||
|
entity-type="album"
|
||||||
|
@catalog-retry=${this.retryCatalog}
|
||||||
|
></catalog-scope-notice>
|
||||||
${this.renderVersionSelector()}
|
${this.renderVersionSelector()}
|
||||||
${this.renderTracklist()}
|
${this.renderTracklist()}
|
||||||
</div>
|
</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. */
|
/** Tracks of the version currently selected in the dropdown. */
|
||||||
private currentTracks(): MBTrack[] {
|
private currentTracks(): MBTrack[] {
|
||||||
const entry = this.versionEntries.find(
|
const entry = this.versionEntries.find(
|
||||||
@@ -1904,6 +1989,7 @@ export class ExploreAlbumDetails extends LitElement {
|
|||||||
<div
|
<div
|
||||||
class="track-row"
|
class="track-row"
|
||||||
data-track-mbid="${track.mbid}"
|
data-track-mbid="${track.mbid}"
|
||||||
|
data-track-title="${track.title}"
|
||||||
>
|
>
|
||||||
<span class="track-position"
|
<span class="track-position"
|
||||||
>${track.position}</span
|
>${track.position}</span
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import { EventsOn } from '@runtime/runtime';
|
|||||||
import { Events } from '../../events';
|
import { Events } from '../../events';
|
||||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
import '../library-status-indicator/library-status-indicator.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 ── */
|
/* ── Constants ── */
|
||||||
|
|
||||||
@@ -103,6 +105,12 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
@state() private loadingReleases = true;
|
@state() private loadingReleases = true;
|
||||||
@state() private errorArtist = '';
|
@state() private errorArtist = '';
|
||||||
@state() private errorReleases = '';
|
@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 similarArtists: LBSimilarArtist[] = [];
|
||||||
@state() private loadingSimilar = true;
|
@state() private loadingSimilar = true;
|
||||||
@state() private artistImageURL = '';
|
@state() private artistImageURL = '';
|
||||||
@@ -920,6 +928,7 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
|
|
||||||
this.discogReloaded.add(mbid);
|
this.discogReloaded.add(mbid);
|
||||||
this.similarReloaded.add(mbid);
|
this.similarReloaded.add(mbid);
|
||||||
|
this.catalogPending = false;
|
||||||
if (this.topTracks.length === 0) this.loadingTracks = false;
|
if (this.topTracks.length === 0) this.loadingTracks = false;
|
||||||
if (this.topReleaseGroups.length === 0) this.loadingTopReleases = false;
|
if (this.topReleaseGroups.length === 0) this.loadingTopReleases = false;
|
||||||
if (this.releaseGroups.length === 0) this.loadingReleases = false;
|
if (this.releaseGroups.length === 0) this.loadingReleases = false;
|
||||||
@@ -1037,6 +1046,8 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
// forever if ArtistDiscographyReady never arrives.
|
// forever if ArtistDiscographyReady never arrives.
|
||||||
this.discogReloaded.delete(mbid);
|
this.discogReloaded.delete(mbid);
|
||||||
this.similarReloaded.delete(mbid);
|
this.similarReloaded.delete(mbid);
|
||||||
|
this.catalogLoaded = false;
|
||||||
|
this.catalogPending = true;
|
||||||
this.armDiscogFallback(mbid);
|
this.armDiscogFallback(mbid);
|
||||||
|
|
||||||
// Phase 1: fire all API requests independently so the UI
|
// 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) {
|
private async fetchArtist(mbid: string) {
|
||||||
try {
|
try {
|
||||||
this.artist = await LookupArtist(mbid);
|
this.artist = await LookupArtist(mbid);
|
||||||
@@ -1396,7 +1436,17 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
private async fetchReleaseGroups(mbid: string) {
|
private async fetchReleaseGroups(mbid: string) {
|
||||||
try {
|
try {
|
||||||
const rgs = await BrowseReleaseGroups(mbid);
|
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
|
// Populate libraryMBIDs from the inLibrary flag (already
|
||||||
// set by the backend via local_release_group_id cross-ref).
|
// set by the backend via local_release_group_id cross-ref).
|
||||||
@@ -1414,6 +1464,7 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
this.errorReleases = msg;
|
this.errorReleases = msg;
|
||||||
|
this.catalogPending = false;
|
||||||
console.error(
|
console.error(
|
||||||
`[explore-artist] BrowseReleaseGroups error: ${msg}`,
|
`[explore-artist] BrowseReleaseGroups error: ${msg}`,
|
||||||
);
|
);
|
||||||
@@ -1889,6 +1940,11 @@ export class ExploreArtistDetails extends LitElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
|
<catalog-scope-notice
|
||||||
|
scope=${this.catalogScope()}
|
||||||
|
entity-type="artist"
|
||||||
|
@catalog-retry=${this.retryCatalog}
|
||||||
|
></catalog-scope-notice>
|
||||||
${this.renderTopSection()} ${this.renderDiscography()}
|
${this.renderTopSection()} ${this.renderDiscography()}
|
||||||
${this.renderSimilarArtists()}
|
${this.renderSimilarArtists()}
|
||||||
</div>
|
</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="" />`
|
? html`<img src="${track.CoverArtSmall || track.CoverArtMedium}" alt="" />`
|
||||||
: nothing}
|
: nothing}
|
||||||
</div>
|
</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-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>`}
|
<span class="cell col-duration">${formatMilliseconds(track.Duration)}</span>`}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1422,7 +1422,7 @@ export class QueuePanel
|
|||||||
${artUrl ? html`<div class="track-art"><img src="${artUrl}" alt="" loading="lazy" /></div>` : nothing}
|
${artUrl ? html`<div class="track-art"><img src="${artUrl}" alt="" loading="lazy" /></div>` : nothing}
|
||||||
<div class="track-details">
|
<div class="track-details">
|
||||||
<span class="track-title">
|
<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>
|
||||||
<span class="track-artist">
|
<span class="track-artist">
|
||||||
${artistLink(track.artist, track.artistMbid) || 'Unknown Artist'}
|
${artistLink(track.artist, track.artistMbid) || 'Unknown Artist'}
|
||||||
|
|||||||
@@ -1245,9 +1245,9 @@ export class SmartPlaylistDetails
|
|||||||
? html`<img src="${track.CoverArtSmall || track.CoverArtMedium}" alt="" />`
|
? html`<img src="${track.CoverArtSmall || track.CoverArtMedium}" alt="" />`
|
||||||
: nothing}
|
: nothing}
|
||||||
</div>
|
</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-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>`}
|
<span class="cell col-duration">${formatMilliseconds(track.Duration)}</span>`}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1766,11 +1766,11 @@ export class TrackList extends LitElement implements SelectionHost, ContextMenuH
|
|||||||
|
|
||||||
// Wrap artist/album/track values in explore links.
|
// Wrap artist/album/track values in explore links.
|
||||||
if (col.id === 'trackName') {
|
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') {
|
} else if (col.id === 'artistName') {
|
||||||
display = artistLink(track.ArtistName, track.ArtistMBID, display as any);
|
display = artistLink(track.ArtistName, track.ArtistMBID, display as any);
|
||||||
} else if (col.id === 'album') {
|
} 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`
|
return html`
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export const Events = {
|
|||||||
TrackChanged: "TrackChanged",
|
TrackChanged: "TrackChanged",
|
||||||
SeekFailed: "SeekFailed",
|
SeekFailed: "SeekFailed",
|
||||||
VolumeChanged: "VolumeChanged",
|
VolumeChanged: "VolumeChanged",
|
||||||
|
MuteChanged: "MuteChanged",
|
||||||
|
|
||||||
// Queue events (backend → frontend push)
|
// Queue events (backend → frontend push)
|
||||||
QueueChanged: "QueueChanged",
|
QueueChanged: "QueueChanged",
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ export class PlayerController implements ReactiveController {
|
|||||||
return this.state.volume;
|
return this.state.volume;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get muted(): boolean {
|
||||||
|
return this.state.muted;
|
||||||
|
}
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
// ACTIONS
|
// ACTIONS
|
||||||
// Delegate to store (which delegates to backend)
|
// Delegate to store (which delegates to backend)
|
||||||
@@ -82,4 +86,8 @@ export class PlayerController implements ReactiveController {
|
|||||||
setVolume(level: number): void {
|
setVolume(level: number): void {
|
||||||
playerStore.setVolume(level);
|
playerStore.setVolume(level);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleMute(): void {
|
||||||
|
playerStore.toggleMute();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -28,6 +28,7 @@ export interface PlayerState {
|
|||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
currentTrack: TrackInfo | null;
|
currentTrack: TrackInfo | null;
|
||||||
volume: number; // 0-100
|
volume: number; // 0-100
|
||||||
|
muted: boolean; // silenced independently of the volume level
|
||||||
|
|
||||||
// Frontend-only state (for future use)
|
// Frontend-only state (for future use)
|
||||||
// selectedTrackIds: Set<number>;
|
// selectedTrackIds: Set<number>;
|
||||||
@@ -41,6 +42,7 @@ class PlayerStore {
|
|||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
currentTrack: null,
|
currentTrack: null,
|
||||||
volume: 50,
|
volume: 50,
|
||||||
|
muted: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
private subscribers = new Set<Subscriber>();
|
private subscribers = new Set<Subscriber>();
|
||||||
@@ -72,6 +74,10 @@ class PlayerStore {
|
|||||||
EventsOn(Events.VolumeChanged, (volume: number) => {
|
EventsOn(Events.VolumeChanged, (volume: number) => {
|
||||||
this.update({ volume });
|
this.update({ volume });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
EventsOn(Events.MuteChanged, (muted: boolean) => {
|
||||||
|
this.update({ muted });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
@@ -103,6 +109,10 @@ class PlayerStore {
|
|||||||
Player.SetVolume(level);
|
Player.SetVolume(level);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleMute(): void {
|
||||||
|
void Player.MuteToggle();
|
||||||
|
}
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
// SUBSCRIPTION SYSTEM
|
// SUBSCRIPTION SYSTEM
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* Utility for rendering artist/album names as clickable links
|
* Utility for rendering track/album/artist names as clickable links.
|
||||||
* that navigate to their MusicBrainz explore detail pages.
|
|
||||||
*
|
*
|
||||||
* Links are rendered only when an MBID is provided. If the MBID
|
* A name links to its MusicBrainz page when the entity is tagged, and
|
||||||
* is empty (entity not tagged), the name renders as plain text.
|
* 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 { html, css } from 'lit';
|
||||||
import type { TemplateResult } from 'lit';
|
import type { TemplateResult } from 'lit';
|
||||||
|
import { libraryStore } from '../store/library-store';
|
||||||
|
|
||||||
/** Shared CSS for explore link styling. Import into component styles. */
|
/** Shared CSS for explore link styling. Import into component styles. */
|
||||||
export const exploreLinkStyles = css`
|
export const exploreLinkStyles = css`
|
||||||
@@ -22,58 +32,125 @@ export const exploreLinkStyles = css`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
/**
|
/** Fire a navigate event from the clicked element. */
|
||||||
* Dispatch a navigate event to the explore-artist-details page.
|
function navigate(target: EventTarget, detail: Record<string, unknown>): void {
|
||||||
* 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;
|
|
||||||
|
|
||||||
target.dispatchEvent(
|
target.dispatchEvent(
|
||||||
new CustomEvent('navigate', {
|
new CustomEvent('navigate', {
|
||||||
bubbles: true,
|
bubbles: true,
|
||||||
composed: true,
|
composed: true,
|
||||||
detail: {
|
detail,
|
||||||
view: 'explore-artist-details',
|
|
||||||
artistMBID: mbid,
|
|
||||||
artistName,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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.
|
* Find the library album row for a name, loading the album cache first
|
||||||
* The event bubbles through shadow DOM boundaries.
|
* if a view that populates it has not been opened yet.
|
||||||
*/
|
*/
|
||||||
function navigateToAlbum(albumName: string, mbid: string, e: Event): void {
|
async function findLocalAlbum(
|
||||||
e.stopPropagation();
|
albumName: string,
|
||||||
e.preventDefault();
|
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(
|
let fallback: (typeof albums)[0] | null = null;
|
||||||
new CustomEvent('navigate', {
|
|
||||||
bubbles: true,
|
for (const album of albums ?? []) {
|
||||||
composed: true,
|
if (!sameName(album.Name, albumName)) continue;
|
||||||
detail: {
|
if (artistName && sameName(album.ArtistName, artistName)) return album;
|
||||||
view: 'explore-album-details',
|
fallback ??= album;
|
||||||
releaseGroupMBID: mbid,
|
}
|
||||||
albumName,
|
|
||||||
},
|
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,
|
* How long a link waits before navigating.
|
||||||
* or as plain text if not.
|
*
|
||||||
|
* 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 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
|
* @param content - Optional custom content to render inside the link
|
||||||
* (e.g. highlighted search result). Defaults to artistName.
|
* (e.g. highlighted search result). Defaults to artistName.
|
||||||
*/
|
*/
|
||||||
@@ -83,78 +160,75 @@ export function artistLink(
|
|||||||
content?: TemplateResult | string,
|
content?: TemplateResult | string,
|
||||||
): TemplateResult | string {
|
): TemplateResult | string {
|
||||||
if (!artistName) return artistName;
|
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
|
return html`<a
|
||||||
class="explore-link"
|
class="explore-link"
|
||||||
@click=${(e: Event) => navigateToArtist(artistName, mbid, e)}
|
@click=${onClick}
|
||||||
title="View artist on Explore"
|
title=${mbid ? 'View artist on Explore' : 'View artist in your library'}
|
||||||
>${content ?? artistName}</a>`;
|
>${content ?? artistName}</a>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dispatch a navigate event to the explore-album-details page
|
* Render an album name as a link to the album page — the MusicBrainz
|
||||||
* with a highlight on a specific track.
|
* one when tagged, the library one when not.
|
||||||
*/
|
|
||||||
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.
|
|
||||||
*
|
*
|
||||||
* @param albumName - The album name to display.
|
* @param albumName - The album name to display.
|
||||||
* @param mbid - The MusicBrainz release group ID. Empty string = no link.
|
* @param mbid - The MusicBrainz release group ID. Empty = local only.
|
||||||
* @param content - Optional custom content to render inside the link
|
* @param content - Optional custom content to render inside the link.
|
||||||
* (e.g. highlighted search result). Defaults to albumName.
|
* @param artistName - Disambiguates same-named albums in the library.
|
||||||
*/
|
*/
|
||||||
export function albumLink(
|
export function albumLink(
|
||||||
albumName: string,
|
albumName: string,
|
||||||
mbid: string,
|
mbid: string,
|
||||||
content?: TemplateResult | string,
|
content?: TemplateResult | string,
|
||||||
|
artistName?: string,
|
||||||
): TemplateResult | string {
|
): TemplateResult | string {
|
||||||
if (!albumName) return albumName;
|
if (!albumName) return albumName;
|
||||||
if (!mbid) return content ?? albumName;
|
|
||||||
|
|
||||||
return html`<a
|
return html`<a
|
||||||
class="explore-link"
|
class="explore-link"
|
||||||
@click=${(e: Event) => navigateToAlbum(albumName, mbid, e)}
|
@click=${singleClick((target) => {
|
||||||
title="View album on Explore"
|
void openAlbum(target, albumName, mbid, artistName);
|
||||||
|
})}
|
||||||
|
title=${mbid ? 'View album on Explore' : 'View album in your library'}
|
||||||
>${content ?? albumName}</a>`;
|
>${content ?? albumName}</a>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render a track name as a clickable link that opens the album's
|
* Render a track name as a link that opens the track's album with the
|
||||||
* explore page with the track highlighted. Requires both a
|
* track highlighted. An untagged track highlights by title on the
|
||||||
* release group MBID (album) and a recording MBID (track).
|
* library album page instead, so every row in a list behaves the same.
|
||||||
*
|
*
|
||||||
* @param trackName - The track name to display.
|
* @param trackName - The track name to display.
|
||||||
* @param albumName - The album name (for the page title).
|
* @param albumName - The album name (for the page title).
|
||||||
* @param releaseGroupMBID - The album's MusicBrainz release group ID.
|
* @param releaseGroupMBID - The album's MusicBrainz release group ID.
|
||||||
* @param recordingMBID - The track's MusicBrainz recording ID.
|
* @param recordingMBID - The track's MusicBrainz recording ID.
|
||||||
* @param content - Optional custom content (e.g. highlighted text).
|
* @param content - Optional custom content (e.g. highlighted text).
|
||||||
|
* @param artistName - Disambiguates same-named albums in the library.
|
||||||
*/
|
*/
|
||||||
export function trackLink(
|
export function trackLink(
|
||||||
trackName: string,
|
trackName: string,
|
||||||
@@ -162,13 +236,58 @@ export function trackLink(
|
|||||||
releaseGroupMBID: string,
|
releaseGroupMBID: string,
|
||||||
recordingMBID: string,
|
recordingMBID: string,
|
||||||
content?: TemplateResult | string,
|
content?: TemplateResult | string,
|
||||||
|
artistName?: string,
|
||||||
): TemplateResult | string {
|
): TemplateResult | string {
|
||||||
if (!trackName) return trackName;
|
if (!trackName) return trackName;
|
||||||
if (!releaseGroupMBID || !recordingMBID) return content ?? trackName;
|
if (!albumName) return content ?? trackName;
|
||||||
|
|
||||||
return html`<a
|
return html`<a
|
||||||
class="explore-link"
|
class="explore-link"
|
||||||
@click=${(e: Event) => navigateToTrack(albumName, releaseGroupMBID, recordingMBID, e)}
|
@click=${singleClick((target) => {
|
||||||
title="View track on album page"
|
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>`;
|
>${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/controls/player-controls';
|
||||||
import '@components/audio-player/seekbar/seek-bar';
|
import '@components/audio-player/seekbar/seek-bar';
|
||||||
|
import '@components/audio-player/volume-control/volume-control';
|
||||||
import { Events } from '../../src/events';
|
import { Events } from '../../src/events';
|
||||||
import { emit, calls, lastArgs, flush } from '@test/support/harness';
|
import { emit, calls, lastArgs, flush } from '@test/support/harness';
|
||||||
import {
|
import {
|
||||||
@@ -310,3 +311,51 @@ describe('<seek-bar>', () => {
|
|||||||
expect(text(el, '[data-testid="elapsed-time"]')).toBe('00:30');
|
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);
|
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', () => {
|
it('replaces state rather than mutating it, so a saved reference is stable', () => {
|
||||||
emit(Events.VolumeChanged, 10);
|
emit(Events.VolumeChanged, 10);
|
||||||
const before = playerStore.getState();
|
const before = playerStore.getState();
|
||||||
@@ -110,12 +123,14 @@ describe('player store: actions', () => {
|
|||||||
playerStore.loadTrack('/music/one.mp3');
|
playerStore.loadTrack('/music/one.mp3');
|
||||||
playerStore.seek(30);
|
playerStore.seek(30);
|
||||||
playerStore.setVolume(60);
|
playerStore.setVolume(60);
|
||||||
|
playerStore.toggleMute();
|
||||||
|
|
||||||
expect(calls().map((c) => c.path)).toEqual([
|
expect(calls().map((c) => c.path)).toEqual([
|
||||||
'player.Player.Pause',
|
'player.Player.Pause',
|
||||||
'player.Player.LoadFile',
|
'player.Player.LoadFile',
|
||||||
'player.Player.Seek',
|
'player.Player.Seek',
|
||||||
'player.Player.SetVolume',
|
'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;
|
attempted: number;
|
||||||
started: number;
|
started: number;
|
||||||
synced: number;
|
synced: number;
|
||||||
|
waiting: number;
|
||||||
|
noProviders: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new Summary(source);
|
return new Summary(source);
|
||||||
@@ -880,6 +882,8 @@ export namespace download {
|
|||||||
this.attempted = source["attempted"];
|
this.attempted = source["attempted"];
|
||||||
this.started = source["started"];
|
this.started = source["started"];
|
||||||
this.synced = source["synced"];
|
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 namespace jobs {
|
||||||
|
|
||||||
export class Caps {
|
export class Caps {
|
||||||
|
|||||||
+2
@@ -22,6 +22,8 @@ export function LoadFile(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function MuteToggle():Promise<void>;
|
export function MuteToggle():Promise<void>;
|
||||||
|
|
||||||
|
export function Muted():Promise<boolean>;
|
||||||
|
|
||||||
export function Pause():Promise<void>;
|
export function Pause():Promise<void>;
|
||||||
|
|
||||||
export function Play():Promise<void>;
|
export function Play():Promise<void>;
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ export function MuteToggle() {
|
|||||||
return window['go']['player']['Player']['MuteToggle']();
|
return window['go']['player']['Player']['MuteToggle']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function Muted() {
|
||||||
|
return window['go']['player']['Player']['Muted']();
|
||||||
|
}
|
||||||
|
|
||||||
export function Pause() {
|
export function Pause() {
|
||||||
return window['go']['player']['Player']['Pause']();
|
return window['go']['player']['Player']['Pause']();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user