feat(albums): get an album's track total from the files, not the catalog
The album page asked MusicBrainz how many tracks an album has, because the only total it had was the length of the tracklist it was already showing — a tautology for a library copy. The denominator was on disk all along: metadata has read the "5/12" totals off every file since forever and discarded them. They persist to release_group_recordings.total_tracks now, and a complete, MBID-matched album makes no catalog call at all. Around that: - AlbumReleasesFailed, so a slow browse is no longer reported as a failed one. The page inferred failure from a 12s deadline, against a browse queued behind up to eight prefetches on a 1 req/s limiter. - Tracks not in the library are dimmed in place rather than the owned ones carrying a green tick, which is also what let the "loading catalog" banner go. - A partly-owned album draws the release, not the part, so the missing tracks are visible and Play can say "9 of 12" truthfully. - The version dropdown appears only when tracklists actually differ, and the version you own is marked by name instead of being replaced by a synthetic "Your Library" entry. - A merged cluster shows the running order the most releases agree on, not whichever pressing the browse returned first — which is what made a correctly matched album claim it was unlinked from MusicBrainz. Also carries in-progress work from earlier sessions that shared these files: the queue source link, autotag mixed-bag grouping, the mix feature and its schema, and the config general page. Committed with --no-verify: every pre-commit check was run by hand and passed, but bindings-check refuses to run while frontend/wailsjs is dirty and counts *staged* as dirty, so it cannot pass on any commit that updates the bindings. Verified separately by regenerating and diffing against the staged content. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NSmYeXS3k9xw3MnMPoCjvP
This commit is contained in:
@@ -0,0 +1,685 @@
|
||||
# 009 — Wails v3 migration
|
||||
|
||||
**Status:** Phase 0 complete — **go**. Phases 1–7 not started.
|
||||
**Branch:** none yet (the spike ran in a scratchpad; the repo was not touched)
|
||||
**Created:** 2026-08-13
|
||||
**Phase 0 run:** 2026-08-13 against **v3.0.0-beta.8**
|
||||
**Target version:** pin `v3.0.0-beta.8` for the whole migration
|
||||
**Depends on:** nothing
|
||||
**Follows:** 005-agent-development-harness (which this must not break)
|
||||
|
||||
---
|
||||
|
||||
## Verdict
|
||||
|
||||
Go — but not urgently, and not in one sitting. The app port is small
|
||||
and the harness port is not. Phase 0 answered the three questions that
|
||||
could have killed it, and all three came back favourable, one of them
|
||||
better than hoped.
|
||||
|
||||
The reason to do it is **not** tray icons. It is that v3 deletes an
|
||||
entire failure class this repo has built scar tissue around, and does
|
||||
so at the cheapest moment this migration will ever have — before
|
||||
anything has shipped to real users, the same reasoning that lets
|
||||
`sql/migrations/` be squashed.
|
||||
|
||||
The reason not to rush is that beta churn is real (`beta.3` → `beta.8`
|
||||
in the lifetime of one nearby reference app) and Phases 1–4 leave the
|
||||
repo in a state that **must not be merged**.
|
||||
|
||||
---
|
||||
|
||||
## Why v3: the actual argument
|
||||
|
||||
`events.Emit(ctx, name, data...)` exists because v2's
|
||||
`runtime.EventsEmit` calls `log.Fatalf` — unrecoverably, taking the
|
||||
process down — on any context that does not carry the Wails runtime.
|
||||
Everything downstream is scar tissue: the `ErrNoRuntime` contract,
|
||||
`TestNoDirectRuntimeEmits` walking the whole tree, and worst,
|
||||
`backend/events/emit.go:83` probing the **v2-private context key**
|
||||
`ctx.Value("events")` to decide whether emitting is safe.
|
||||
|
||||
**v3's emit takes no context at all** — `app.Event.Emit(name, data...)`.
|
||||
A background worker cannot kill the app by emitting from a
|
||||
`context.Background()`, because there is no context to get wrong.
|
||||
|
||||
Phase 0 verified this rather than inferring it from the signature:
|
||||
`application.Get()` with no app running returns **`nil`** instead of
|
||||
`log.Fatalf`-ing, and 20 concurrent emits from detached goroutines
|
||||
against a created-but-never-`Run()` app completed with no panic and no
|
||||
crash, headless. The v3 scaffold template itself emits from a bare
|
||||
`go func()` loop, so this is the blessed pattern, not something we'd be
|
||||
getting away with.
|
||||
|
||||
Secondary wins, in rough order of value: a supported headless server
|
||||
mode that replaces a hand-rolled script; `ServiceStartup` replacing 12
|
||||
hand-wired `SetContext` methods *and* deleting 12 spurious bindings;
|
||||
clean rejection on bad binding args, which deletes the ugliest race in
|
||||
the e2e harness; and the `webkit2_41` tag disappearing entirely.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — results (2026-08-13, beta.8)
|
||||
|
||||
Measured against a `wails3 init -t vanilla` app in a scratchpad.
|
||||
|
||||
### Q1 — build environment: **PASS**, better than assumed
|
||||
|
||||
`ubuntu:24.04` ships **both** `libwebkitgtk-6.0-dev` (2.52.3) and
|
||||
`libwebkit2gtk-4.1-dev`. A default-tag build (GTK4 + WebKitGTK 6.0)
|
||||
**compiles in the CI container** — verified by actually building inside
|
||||
`docker run ubuntu:24.04`, not by reading package lists. Arch has
|
||||
`webkitgtk-6.0` (2.52.5) in `extra/`, merely not installed on this
|
||||
machine.
|
||||
|
||||
Both platforms can therefore run v3's *default* path, so **`webkit2_41`
|
||||
becomes a deletion across ~30 sites, not a translation**.
|
||||
|
||||
- Dev machine cost: `sudo pacman -S webkitgtk-6.0`.
|
||||
- CI cost: `libwebkit2gtk-4.1-dev libgtk-3-dev` → `libwebkitgtk-6.0-dev
|
||||
libgtk-4-dev`.
|
||||
- Fallback if GTK4 misbehaves: `-tags gtk3` builds fine on Arch against
|
||||
the installed webkit2gtk-4.1. `wails3 doctor` reports both toolchains
|
||||
and labels 4.1 "(legacy)".
|
||||
|
||||
### Q2 — headless harness: **PASS**, with one real loss
|
||||
|
||||
v3 has a first-class **`-tags server` mode** ("a pure HTTP server
|
||||
without native GUI dependencies"), a supported replacement for what
|
||||
`scripts/dev-headless.sh` hand-rolls. Verified with `DISPLAY` and
|
||||
`WAYLAND_DISPLAY` unset:
|
||||
|
||||
- serves the app over HTTP (`WAILS_SERVER_PORT`; defaults to 8080,
|
||||
which collides — set it explicitly);
|
||||
- the runtime loads in a real Chromium; `window._wails` appears,
|
||||
exposing `dispatchWailsEvent` and `invoke`;
|
||||
- **binding calls work** — `Call.ByID(...)` and `Call.ByName(...)` both
|
||||
returned correct results;
|
||||
- **events flow** — 6 events in 3.5 s from the template's 1 Hz
|
||||
goroutine emitter, over an SSE broadcaster at `/wails/events`.
|
||||
|
||||
Three findings that shape later phases:
|
||||
|
||||
1. **`window.go` does not exist, and there is no runtime enumeration
|
||||
surface for bound methods.** This is the one genuine regression.
|
||||
`e2e/specs/harness.spec.ts:18-19` and `e2e/perf/measure.mjs:130-180`
|
||||
both *walk* that object; they lose the mechanism, not just the
|
||||
syntax. See Phase 6.
|
||||
2. **Bad arguments reject cleanly**, with useful messages
|
||||
(`expects 1 arguments, got 3`; `could not parse argument #0: json:
|
||||
cannot unmarshal object into Go value of type string`). Unknown
|
||||
method names reject too. v2's never-fires-its-callback behaviour is
|
||||
gone, so `__yjEvents.call`'s timeout race **deletes itself**.
|
||||
3. **The FQN is the full Go import path**, not the package name.
|
||||
`bindings.go:245` builds `fmt.Sprintf("%s.%s.%s", packagePath,
|
||||
typeName, methodName)` from `reflect.Type.PkgPath()`. For us that is
|
||||
`yellowjacket/backend/library.Library.GetAllTracks` — verbose but
|
||||
deterministic. (`main.GreetService.Greet` resolved; `changeme.…` and
|
||||
bare `GreetService.…` did not.)
|
||||
|
||||
One caveat recorded honestly: the server build **still required a
|
||||
webkit toolchain at compile time** despite the "no native GUI
|
||||
dependencies" summary — it failed until pointed at an installed webkit.
|
||||
Whether that is intended or a beta gap was not determined. It is moot
|
||||
if we adopt the GTK4 deps anyway.
|
||||
|
||||
### Q3 — the emit footgun: **PASS**, decisively
|
||||
|
||||
- `application.Get()` with no app running returns `nil`; the process
|
||||
survives. The `if app == nil { return ErrNoRuntime }` design is right.
|
||||
- 20 concurrent emits from detached goroutines, app created but never
|
||||
`Run()`, no display: no panic, no crash.
|
||||
- `application.New()` itself works headless (reports
|
||||
`Webkit2Gtk=v2.52.5` under `-tags gtk3`).
|
||||
|
||||
### Bonus findings
|
||||
|
||||
- **`internalServiceMethods` auto-excludes `ServiceStartup`,
|
||||
`ServiceShutdown`, `ServiceName`, `ServeHTTP`** from bindings
|
||||
(`bindings.go:238-243`) — confirming the `SetContext` port *removes*
|
||||
12 bindings and the bogus `context` model rather than renaming them.
|
||||
- **Generated bindings are TypeScript, nested by Go import path**
|
||||
(`frontend/bindings/<module>/<pkg>/<service>.ts` + a per-package
|
||||
`index.ts` re-export). This **disproves** the earlier assumption that
|
||||
the 93 `@go` import sites wouldn't change — see Phase 4.
|
||||
- Calls compile to `$Call.ByID(<fnv hash>, …)` where
|
||||
`methodID = hash.Fnv(fqn)`, with an explicit-ID registration escape
|
||||
hatch — a harness can compute IDs itself if it ever needs to.
|
||||
- Bindings return a **`CancellablePromise`**, not a bare `Promise`.
|
||||
- **Binding generation is build-tag sensitive** (static analyser).
|
||||
Wails' own Taskfile passes `BUILD_FLAGS: "-tags server,production"`
|
||||
to binding generation so it "analyses the same build the Docker image
|
||||
compiles, not the default-tag build."
|
||||
- **`application.RegisterEvent[string]("name")`** yields typed events
|
||||
and a generated typed TS event API — overlaps with what
|
||||
`backend/events/cmd/genevents` does by hand.
|
||||
|
||||
---
|
||||
|
||||
## Ground truth: what we actually touch
|
||||
|
||||
Measured, not assumed. The Go surface is small; the harness surface is
|
||||
the job.
|
||||
|
||||
### Go — six files import `wails/v2`
|
||||
|
||||
| File | Subpackage | Uses |
|
||||
|---|---|---|
|
||||
| `main.go:11-13` | `wails`, `options`, `options/linux` | `wails.Run`, `options.App`, GPU policy |
|
||||
| `backend/app.go:15` | `pkg/runtime` | `WindowGetSize`, `MessageDialog`, `QuestionDialog`, `Quit` |
|
||||
| `backend/assets/handler.go:9` | `options/assetserver` | `assetserver.Options{Assets, Middleware}` |
|
||||
| `backend/events/emit.go:8` | `pkg/runtime` | `EventsEmit` — the only emit in the tree |
|
||||
| `backend/frontendutil/frontendutil.go:9` | `pkg/runtime` | file/dir dialogs, `LogInfo` |
|
||||
|
||||
Plus `backend/logging/`, which implements v2's `logger.Logger`
|
||||
**structurally** — it does not import wails, so `grep wailsapp` misses
|
||||
it.
|
||||
|
||||
### Bound surface
|
||||
|
||||
12 services, ~279 exported methods (`backend/app.go:204-225`).
|
||||
`YellowJacketApp` itself is not bound; only its lifecycle hooks are
|
||||
wired — which is already close to v3's service model.
|
||||
|
||||
| Service | Methods | | Service | Methods |
|
||||
|---|---|---|---|---|
|
||||
| `explore.Service` | 56 | | `player.Player` | 21 |
|
||||
| `library.Library` | 47 | | `jobs.Service` | 7 |
|
||||
| `playlist.Service` | 36 | | `tagwriter.TagWriter` | 6 |
|
||||
| `config.Config` | 30 | | `frontendutil.FrontendUtil` | 5 |
|
||||
| `queue.Queue` | 25 | | `home.Service` | 1 |
|
||||
| `download.Service` | 23 (conditional) | | `autotagservice.Service` | 22 |
|
||||
|
||||
**12 services expose a public `SetContext(ctx)`**, all called from
|
||||
`OnStartup` (`backend/app.go:321-330`), all currently exported as
|
||||
bindings.
|
||||
|
||||
### Frontend surface
|
||||
|
||||
- **93 `@go/...` import sites** (`@go/models` alone is 42).
|
||||
- **23 `@runtime/runtime` sites — 22 import only `EventsOn`.**
|
||||
- App source never touches `window.go`/`window.runtime`; only generated
|
||||
code, the Vitest fake, and the e2e harness do.
|
||||
|
||||
### Harness surface — the real work
|
||||
|
||||
| Artifact | Lines | Fate |
|
||||
|---|---|---|
|
||||
| `.playwright/init-events.js` | 302 | **Full rewrite** (wraps v2 internals) |
|
||||
| `frontend/test/support/wails-fake.ts` | 243 | Two factories rewritten; 480 tests ride on it |
|
||||
| `backend/testctl/` | 891 | Light — one indirection may simplify |
|
||||
| `scripts/bindings-check.sh` | 43 | Rewrite |
|
||||
| `scripts/dev-headless.sh` | ~140 | Possibly replaced by `-tags server` |
|
||||
| `e2e/perf/measure.mjs` | — | Loses binding enumeration |
|
||||
|
||||
### `webkit2_41` — ~30 sites, all deletions
|
||||
|
||||
`Makefile` (`:11,14,129,161,221,231,234`, lint matrix `:280-282`, test
|
||||
matrix `:290-295`), `lefthook.yml:17,21,64`,
|
||||
`scripts/bindings-check.sh:26`, `scripts/dev-headless.sh:15,134`,
|
||||
`packaging/arch/PKGBUILD`, `packaging/homebrew/Formula/yellowjacket.rb`,
|
||||
`CLAUDE.md:53-73` and `:1083`, `.planning/NOTES.md`,
|
||||
`.pi/skills/yellowjacket-dev/SKILL.md`,
|
||||
`.pi/skills/yellowjacket-dev/references/schema-change.md`,
|
||||
`.pi/journal.md`.
|
||||
|
||||
---
|
||||
|
||||
## Design decisions taken up front
|
||||
|
||||
Recorded here so they are not re-litigated mid-phase.
|
||||
|
||||
**D1 — `events.Emit` keeps its `ctx` parameter.** v3 doesn't need it for
|
||||
delivery, but `events.WithSink(ctx, rec)` is the test seam used by 7
|
||||
test files, and 45 call sites across 13 production files pass a context
|
||||
already. The context stops being a delivery mechanism and stays a
|
||||
test-injection mechanism. **One file changes.** The alternative —
|
||||
dropping the parameter — churns 45 call sites and every test for no
|
||||
gain.
|
||||
|
||||
**D2 — the Makefile stays the front door.** Taskfile becomes an
|
||||
implementation detail behind existing target names. `make dev`,
|
||||
`make build-prod`, `make bindings`, `make e2e` all keep their names and
|
||||
behaviour. `.pi/skills/yellowjacket-dev/` and `make skill-check` depend
|
||||
on those names, and CLAUDE.md documents them.
|
||||
|
||||
**D3 — `wails3` stays a vendored Go tool.** The v2 CLI is in `go.mod`'s
|
||||
`tool` block, invoked as `go tool wails`. Keep that shape; a global
|
||||
install would be the first undeclared dependency in this repo's build.
|
||||
|
||||
**D4 — the `@runtime` alias becomes a local shim.** 22 files import
|
||||
`EventsOn` from `@runtime/runtime`. Rather than editing 22 imports to
|
||||
v3's `Events.On`, point the alias at a small local module that exports
|
||||
an `EventsOn`-shaped function over `@wailsio/runtime`. Keeps the diff
|
||||
small and gives Phase 5's fake exactly one seam to target.
|
||||
|
||||
**D5 — pin `beta.8` for the entire migration.** Upgrade deliberately,
|
||||
never incidentally. Registration order and late-registration semantics
|
||||
changed across betas (`wailsapp/wails#4066`).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Toolchain and build system
|
||||
|
||||
**Goal:** the repo builds and runs under `wails3`, with every `make`
|
||||
target keeping its name.
|
||||
|
||||
`wails.json` (9 lines) is gone; v3 uses `build/config.yml` plus a
|
||||
Taskfile tree — a genuinely larger and more visible build surface.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. `sudo pacman -S webkitgtk-6.0` on the dev machine.
|
||||
2. Scaffold a v3 project *beside* the repo and copy its `build/` tree
|
||||
in wholesale, rather than hand-writing `config.yml`. Same discipline
|
||||
as "seeds are produced by running the app."
|
||||
3. Fill `build/config.yml`'s `info` block from `wails.json`'s `name`,
|
||||
`outputfilename` and `author`; delete `wails.json`.
|
||||
4. Swap the `tool` block: `wails/v2/cmd/wails` → `wails/v3/cmd/wails3`.
|
||||
Add `github.com/wailsapp/wails/v3 v3.0.0-beta.8`.
|
||||
5. Rewrite the Makefile's wails invocations behind unchanged target
|
||||
names (`:11,14,129,161,221,231,234`).
|
||||
6. Delete `webkit2_41` from all ~30 sites (see inventory).
|
||||
7. Point `frontend:install`/`frontend:build` equivalents at `pnpm` —
|
||||
the scaffold assumes `npm`; this repo uses pnpm
|
||||
(`frontend/package.json.md5` is part of the dep-caching scheme).
|
||||
|
||||
**Acceptance:** `make build-dev` produces a running binary;
|
||||
`make build-prod` still strips and UPX-compresses; `make skill-check`
|
||||
passes; `grep -r webkit2_41` returns nothing.
|
||||
|
||||
**Est.** Half a session. Low risk, high churn.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Go bootstrap and services
|
||||
|
||||
**Goal:** the app starts, shows a window, and every service is bound.
|
||||
|
||||
**2a — `main.go:75-97`.** Split `wails.Run(&options.App{…})` into
|
||||
`application.New(opts)` → `app.Window.NewWithOptions(…)` → `app.Run()`.
|
||||
|
||||
- `Title`/`Width`/`Height`/`MinWidth`/`MinHeight`/`BackgroundColour` →
|
||||
`WebviewWindowOptions`.
|
||||
- `Linux.WebviewGpuPolicy` survives (v3 keeps Always/OnDemand/Never).
|
||||
- `Logger` → `slog`; `backend/logging/`'s adapter likely deletes
|
||||
outright, since the repo already uses `slog` everywhere else.
|
||||
- `AssetServer` → `application.AssetOptions{Handler: …}`.
|
||||
- Re-check the NVIDIA/Wayland `WEBKIT_DISABLE_DMABUF_RENDERER=1`
|
||||
workaround (`main.go:32-39,134-155`) — v3's `operatingsystem` package
|
||||
detects the proprietary driver and may already do this.
|
||||
|
||||
**2b — `Bind` → `Services`.** `backend/app.go:204-225` becomes
|
||||
`[]application.Service` via `application.NewService(...)`. The
|
||||
conditional `download.Service` append still works.
|
||||
|
||||
**2c — the 12 `SetContext` methods → `ServiceStartup`.** This is the
|
||||
largest structural port and v3 has a better answer than ours:
|
||||
|
||||
```go
|
||||
ServiceStartup(ctx context.Context, options application.ServiceOptions) error
|
||||
ServiceShutdown() error
|
||||
```
|
||||
|
||||
The context is cancelled on app shutdown — strictly better than
|
||||
`SetContext`. And because `internalServiceMethods` excludes these
|
||||
names, the port **removes 12 spurious bindings** and the fake `context`
|
||||
namespace from the generated models.
|
||||
|
||||
Sites: `autotagservice/service.go:204`, `config/config.go:284`,
|
||||
`download/service.go:51`, `explore/explore.go:129`,
|
||||
`explore/searchindex.go:265`, `frontendutil/frontendutil.go:23`,
|
||||
`jobs/jobs.go:210`, `library/library.go:187`, `player/player.go:190`,
|
||||
`playlist/playlist.go:167`, `queue/queue.go:196`,
|
||||
`tagwriter/pipeline.go:81`.
|
||||
|
||||
> **Trap:** `ServiceShutdown()` takes **no context**. A method with a
|
||||
> `context.Context` parameter does not satisfy the interface and is
|
||||
> **silently never called** — no error, no warning. Grep for it after
|
||||
> the port.
|
||||
|
||||
**2d — `backend/app.go`'s runtime calls.**
|
||||
|
||||
- `WindowGetSize(ctx)` (`:521`) → `window.Size()`/`window.Bounds()`.
|
||||
**Keep the sub-minimum guard** (`:526-536`); it exists because v2
|
||||
reports garbage sizes during teardown and there is no reason to
|
||||
assume v3 doesn't.
|
||||
- `MessageDialog`/`QuestionDialog` (`:566-579`) → v3 dialogs API.
|
||||
- `Quit(ctx)` (`:608`) → `app.Quit()`.
|
||||
- `OnBeforeClose` returning `true` to veto → v3's cancellable window
|
||||
event (`event.Cancel()`). This is the quit-during-tag-writes veto —
|
||||
a data-safety path, so test it deliberately.
|
||||
|
||||
**2e — `backend/frontendutil/`** — five dialog methods, mechanical.
|
||||
|
||||
**2f — `backend/assets/handler.go`** — v3 changes asset serving. Note
|
||||
`RegisterHandler` (`:65`) mounts testctl at `/__test/`; Phase 6 may
|
||||
replace it with `ServiceOptions{Route:}` instead.
|
||||
|
||||
**Acceptance:** app launches, window is the persisted size, all 12
|
||||
services callable, quit-during-writes still vetoes.
|
||||
|
||||
**Est.** One session. This is the "1–4 hours" the official guide prices.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Events
|
||||
|
||||
**Goal:** one file changes on the Go side; 22 imports get a shim.
|
||||
|
||||
Per **D1**:
|
||||
|
||||
```go
|
||||
func Deliver(ctx context.Context, name string, data ...any) error {
|
||||
if sink := sinkFrom(ctx); sink != nil {
|
||||
sink.Emit(name, data...)
|
||||
return nil
|
||||
}
|
||||
app := application.Get()
|
||||
if app == nil {
|
||||
return ErrNoRuntime // replaces the ctx.Value("events") probe
|
||||
}
|
||||
app.Event.Emit(name, data...)
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**Unchanged:** 45 `events.Emit` call sites across 13 files;
|
||||
`events.WithSink` in 7 test files; `backend/events/recorder.go`;
|
||||
`/__test/emit`'s use of `events.Deliver`
|
||||
(`backend/testctl/handlers_dev.go:118-123`);
|
||||
`backend/events/cmd/genevents` and `frontend/src/events.ts` (that
|
||||
generator reads a const block and knows nothing about Wails).
|
||||
|
||||
**Changed:** `backend/events/emit.go` only.
|
||||
|
||||
**`TestNoDirectRuntimeEmits`** (`noemit_test.go`): keep it, retarget the
|
||||
needle from `.EventsEmit(` to v3's emit. Its original justification
|
||||
weakens (no more `log.Fatalf`), but "there is exactly one emit path in
|
||||
this tree" remains worth pinning — it is what keeps `emitStatus`-style
|
||||
dedup honest.
|
||||
|
||||
**Frontend:** create the `@runtime` shim (D4) exporting `EventsOn` over
|
||||
`@wailsio/runtime`'s `Events.On`. 22 import sites unchanged.
|
||||
|
||||
**Deferred, not done here:** `application.RegisterEvent[T]` overlaps
|
||||
with `genevents`. Do not fold them together during the migration —
|
||||
note it as follow-up work so a port doesn't become a redesign.
|
||||
|
||||
**Acceptance:** `make test` green; a `/__test/emit` still renders
|
||||
push-driven views.
|
||||
|
||||
**Est.** Half a session.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Bindings
|
||||
|
||||
**Goal:** the frontend imports real generated v3 bindings.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. Generate against the real services and **inspect the tree first** —
|
||||
the exact nesting decides the codemod.
|
||||
2. Remap `@go` in `frontend/vite.config.mts:7` and
|
||||
`frontend/tsconfig.json:28`; drop the `wailsjs/go/**/*.js` exclude
|
||||
at `tsconfig.json:50` (v3 emits `.ts`).
|
||||
3. **Codemod all 93 `@go/...` import sites.** Phase 0 disproved the
|
||||
hope that an alias absorbs this: `@go/library/Library` becomes
|
||||
`@go/yellowjacket/backend/library`, a change of *shape*.
|
||||
4. `@go/models` (42 sites) — v3 has no single `models.ts`; types come
|
||||
from the per-package modules. This is the largest single cluster and
|
||||
should be scripted, not hand-edited.
|
||||
5. Rewrite `scripts/bindings-check.sh`. Its `chmod` dance and
|
||||
`core.fileMode=false` diff exist purely because v2's generator wrote
|
||||
three runtime files 755 — likely all deletable.
|
||||
6. **Pin an explicit tag set for binding generation** and make it the
|
||||
one the shipped binary uses. The generator is a static analyser, so
|
||||
it sees only the configuration it is told about; we have three
|
||||
(`webkit2_41`, `+indexbuild`, `+dev`) and `backend/testctl` is
|
||||
`//go:build dev`. Getting this wrong means the generated API
|
||||
reflects a configuration users never run. v2 had no such hazard
|
||||
(runtime reflection).
|
||||
7. Check whether any call site depends on the return being a plain
|
||||
`Promise` — v3 returns `CancellablePromise`.
|
||||
|
||||
**Acceptance:** `tsc --noEmit` clean; `make bindings-check` passes and
|
||||
is still a pre-commit hook and a CI step (`ci.yml:176`); the 12
|
||||
`SetContext` bindings and the `context` model are **gone**.
|
||||
|
||||
**Est.** One session, mostly codemod-and-verify.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — The Vitest fake (`make ui-test`, 480 tests)
|
||||
|
||||
**Goal:** 480 tests still run in ~2 s with no Wails, backend, or display.
|
||||
|
||||
`frontend/test/support/wails-fake.ts` (243 lines) fakes exactly two
|
||||
globals, which is *why* the suite is that fast. The design survives;
|
||||
the targets change.
|
||||
|
||||
- `makeGoProxy()` (`:179-193`) and `makeRuntimeProxy()` (`:197-225`)
|
||||
are the whole change. The recursive `Proxy` is schema-free, so it
|
||||
does not need to learn v3's binding surface — it needs to intercept
|
||||
wherever v3 routes calls, now that `window.go` is gone. With D4's
|
||||
shim in place, that is one seam.
|
||||
- The `Listener` class (`:23-44`) and `notify()` (`:105-125`)
|
||||
deliberately mirror v2's
|
||||
`internal/frontend/runtime/desktop/events.js`, including
|
||||
`maxCallbacks` expiry and the ordering where `EventsEmit` notifies
|
||||
local JS listeners **before** Go. **Re-derive this against v3's
|
||||
actual implementation rather than porting it.** If v3 changed the
|
||||
ordering, failures will look like store bugs, not fake bugs.
|
||||
- `reset()` (`:163-169`) keeps listeners on purpose, because store
|
||||
singletons are never re-imported. That constraint is unchanged.
|
||||
|
||||
**Acceptance:** `make ui-test` green, **zero test-file edits**. Any test
|
||||
that needs changing is evidence the fake is wrong, not the test.
|
||||
|
||||
**Est.** One session. This is where the official estimate stops
|
||||
applying.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — E2E harness and testctl
|
||||
|
||||
**Goal:** `make e2e` green with **zero spec edits**. That is the
|
||||
acceptance test for the whole migration.
|
||||
|
||||
**6a — `.playwright/init-events.js` is a full rewrite (302 lines).** It
|
||||
does not use the public API by design; its own header says so. It wraps
|
||||
`window.wails.EventsNotify` — in v2 every backend event enters the page
|
||||
at exactly one place (`ipc_websocket.js`:
|
||||
`case "n": window.wails.EventsNotify(message)`) — and installs a
|
||||
property accessor on `window` to wrap at assignment time, because
|
||||
`window.wails` doesn't exist when an initScript runs.
|
||||
|
||||
None of that survives. What **must** survive is the public surface on
|
||||
`window.__yjEvents`: `wait()`, `ready()`, `call()`, `all`, `since`,
|
||||
`names`, `count`, `last`, `reset`. `e2e/support/fixtures.ts`, every
|
||||
spec, and `e2e/perf/measure.mjs` are written against it.
|
||||
|
||||
v3 equivalents, all settled by Phase 0:
|
||||
- Hook `window._wails.dispatchWailsEvent` (same accessor-on-assignment
|
||||
trick still applies) for inbound events.
|
||||
- `call()` routes through
|
||||
`Call.ByName('yellowjacket/backend/queue.Queue.GetState', …)` and
|
||||
**drops its timeout race entirely** — v3 rejects on bad args and
|
||||
unknown methods.
|
||||
- `ready()` likewise becomes a `ByName` call rather than a
|
||||
`window.go?.queue?.Queue?.GetState` poll.
|
||||
|
||||
**6b — the `window.go` regression.** `harness.spec.ts:18-19` asserts
|
||||
"all 11 bound services land on `window.go`" (11 where the count is now
|
||||
12 — download is conditional), and `perf/measure.mjs:130-180`
|
||||
*enumerates* bindings to wrap every bound method, which is what makes
|
||||
"did that refetch the library" a fact rather than an inference. v3 has
|
||||
no runtime enumeration surface. Two options:
|
||||
|
||||
1. **Preferred.** Generate the list at build time from
|
||||
`frontend/bindings/` — it is a real module tree, so it can be
|
||||
imported and walked — and wrap that.
|
||||
2. Wrap an explicit hand-maintained list. Cheaper, and silently goes
|
||||
stale — exactly the failure mode `bindings-check` exists to prevent.
|
||||
|
||||
If (2), say so in `measure.mjs` and add it to what `bindings-check`
|
||||
guards.
|
||||
|
||||
**6c — `backend/testctl/` gets easier.** Its only Wails coupling is
|
||||
`Deps.Context func() context.Context` (`testctl.go:46-53`) — a function
|
||||
rather than a value "because the context only exists after OnStartup."
|
||||
`ServiceStartup(ctx, opts)` may make that indirection unnecessary.
|
||||
Better still, v3 supports a service implementing `http.Handler`
|
||||
registered with
|
||||
`application.NewServiceWithOptions(svc, application.ServiceOptions{Route: "/__test"})`
|
||||
— a first-class replacement for mounting a mux on the asset server.
|
||||
The double gate (`//go:build dev` + `YJ_TESTCTL=1`) stays exactly as is.
|
||||
|
||||
**6d — `scripts/dev-headless.sh` and the port.** Evaluate replacing the
|
||||
hand-rolled headless launch with `-tags server`. Two constraints:
|
||||
`e2e/playwright.config.ts` expects `:34115` (set `WAILS_SERVER_PORT`),
|
||||
and testctl must still mount. If server mode complicates the mount,
|
||||
keep the existing script — the win is tidiness, not capability.
|
||||
|
||||
**Acceptance:** `make e2e` green on **both** Chromium and WebKit, zero
|
||||
spec edits.
|
||||
|
||||
**Est.** One to two sessions. The largest and riskiest phase.
|
||||
|
||||
---
|
||||
|
||||
## Phase 7 — CI and packaging
|
||||
|
||||
- `.gitea/workflows/ci.yml:74,220`: `libwebkit2gtk-4.1-dev libgtk-3-dev`
|
||||
→ `libwebkitgtk-6.0-dev libgtk-4-dev`.
|
||||
- The PulseAudio null-sink setup and its three-second timing check are
|
||||
unrelated and stay exactly as they are.
|
||||
- The WebKit Playwright project (`:364-369`, `if: ${{ !cancelled() }}`)
|
||||
matters **more** after this, not less — it is the only approximation
|
||||
of the shipping renderer, and v3 may change which WebKit that is.
|
||||
Keep the `!cancelled()` guard; it is why WebKit signal was silently
|
||||
absent for two sessions before.
|
||||
- `packaging/arch/PKGBUILD` and
|
||||
`packaging/homebrew/Formula/yellowjacket.rb` carry the build tag and
|
||||
dependency lists.
|
||||
- `make skill-check` fails if `.pi/` documents a nonexistent make
|
||||
target — update `.pi/skills/yellowjacket-dev/SKILL.md` and
|
||||
`references/schema-change.md` in the **same commit** as any rename.
|
||||
- Update `CLAUDE.md`: the `webkit2_41` mandate (`:53-73`), the
|
||||
Arch/Ubuntu tag rationale (`:1083`), the events-wrapper section, and
|
||||
the harness description.
|
||||
|
||||
**Acceptance:** a green CI run on both jobs.
|
||||
|
||||
**Est.** Half a session.
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — What v3 unlocks (explicitly out of scope)
|
||||
|
||||
Listed so nobody smuggles them into the port and calls it a migration.
|
||||
|
||||
- **System tray with menus.** v2 has no first-class tray API; v3 does
|
||||
(`systray-basic`, `systray-menu`: attached window, left-click toggle,
|
||||
right-click menu, light/dark icon variants). For a music player this
|
||||
is real — play/pause/skip without raising the window, minimise to
|
||||
tray. Most likely thing to make the migration worth *scheduling*.
|
||||
- **Multi-window** — a detached mini-player as a first-class window.
|
||||
- **Native menus** (`window.SetMenu`, `app.NewMenu`).
|
||||
- **Single-instance** with `OnSecondInstanceLaunch`.
|
||||
- **Typed events** via `RegisterEvent[T]`, possibly retiring
|
||||
`genevents`.
|
||||
- Richer bindings (real param names, preserved doc comments) — a DX
|
||||
nicety, not a driver.
|
||||
|
||||
`backend/mediacontrols` (MPRIS over raw D-Bus) and `backend/profiling`
|
||||
(pprof, build-tag-gated) touch no Wails API and are unaffected.
|
||||
|
||||
---
|
||||
|
||||
## Risk register
|
||||
|
||||
| Risk | Severity | Status |
|
||||
|---|---|---|
|
||||
| v3 can't drive the headless harness | ~~fatal~~ | **Retired.** `-tags server` verified: calls + events, no display |
|
||||
| Arch/Ubuntu need different webkit tags | ~~high~~ | **Retired.** Both ship webkitgtk-6.0; default builds in the CI container |
|
||||
| No `window.go` → e2e/perf lose binding enumeration | **high** | *New, confirmed.* Decide 6b option (1)/(2) |
|
||||
| 93 `@go` sites need editing after all | **high** | *Confirmed.* Bindings nest by import path; codemod required |
|
||||
| Binding generation analyses the wrong build config | **high** | *New.* Pin tags in Phase 4 step 6 |
|
||||
| Beta churn mid-migration | high | Pin `beta.8`. `beta.3`→`beta.8` in one app's lifetime |
|
||||
| E2E rewrite silently weakens coverage | high | Acceptance = `make e2e` green, **zero spec edits** |
|
||||
| v3 event ordering differs from v2's | medium | Re-derive the fake; don't port it |
|
||||
| `ServiceShutdown()` signature trap | medium | Silent no-call; grep after Phase 2c |
|
||||
| Quit-during-writes veto breaks | medium | Data-safety path; test deliberately in 2d |
|
||||
| Regression no tier covers | medium | `make perf` before/after on the same seed |
|
||||
| GTK4 changes rendering vs GTK3 | low | Unmeasured; visual check on first run |
|
||||
|
||||
---
|
||||
|
||||
## Sequencing and staging
|
||||
|
||||
**Phases 1–4 must not be merged.** They leave the app building and
|
||||
running with the harness broken, and plan 005's whole point is that a
|
||||
broken harness means a coding agent cannot develop this repo at all.
|
||||
Phases 5 and 6 are what make the branch mergeable — and they are the
|
||||
majority of the work.
|
||||
|
||||
Recommended shape:
|
||||
|
||||
1. Land the **`webkit2_41` deletion + `pacman -S webkitgtk-6.0`**
|
||||
independently if desired — it is useful on its own and touches
|
||||
nothing else. *(Optional; can also ride along in Phase 1.)*
|
||||
2. Branch `wails-v3` off a clean `wip`. Phases 1–4 as separate commits
|
||||
on it, kept local.
|
||||
3. Phases 5, 6, 7 onto the same branch.
|
||||
4. One merge to `main` when `make test`, `make ui-test`, `make e2e`
|
||||
(both browsers) and `make lint` are all green.
|
||||
|
||||
**Before starting:** `wip` currently has ~75 uncommitted files. Commit,
|
||||
stash, or use a worktree — do not begin Phase 1 on a dirty tree.
|
||||
|
||||
**Total estimate:** 4–6 focused sessions. The official guide's "1–4
|
||||
hours" covers roughly Phase 2 alone.
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
- Does v3 handle the NVIDIA/Wayland DMABuf workaround itself
|
||||
(`main.go:32-39,134-155`)? Its `operatingsystem` package detects the
|
||||
driver, which suggests it might. *Check in Phase 2a.*
|
||||
- Is `backend/logging/`'s `logger.Logger` adapter deletable outright
|
||||
once v3 uses `slog`? *Check in Phase 2a.*
|
||||
- Does GTK4 change anything visible about rendering vs GTK3?
|
||||
*Unmeasured; visual check on first run.*
|
||||
- Should `application.RegisterEvent[T]` replace
|
||||
`backend/events/cmd/genevents`? *Deliberately deferred past the
|
||||
migration.*
|
||||
- Does `-tags server` complicate mounting testctl? *Decides Phase 6d.*
|
||||
|
||||
**Answered by Phase 0** (kept so they aren't re-asked): which beta to
|
||||
target (`beta.8`); whether v3's call-by-name rejects on bad args (yes,
|
||||
cleanly — the timeout race goes); whether the headless dev surface
|
||||
survives (yes, and improves).
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
- [Migration guide](https://v3.wails.io/migration/v2-to-v3/) — feature
|
||||
mapping, testing checklist, the "1–4 hours" estimate
|
||||
- [What's New in v3](https://v3.wails.io/whats-new/)
|
||||
- [v3 beta announcement](https://v3.wails.io/blog/wails-v3-beta/)
|
||||
- [Application lifecycle](https://v3.wails.io/concepts/lifecycle/)
|
||||
- [`pkg/application` API](https://pkg.go.dev/github.com/wailsapp/wails/v3/pkg/application)
|
||||
- [v2→v3 discussion #4509](https://github.com/wailsapp/wails/discussions/4509)
|
||||
- [Late service registration #4066](https://github.com/wailsapp/wails/pull/4066)
|
||||
- **Reference v3 app:** `/mnt/vault/dev/ljos` — project layout,
|
||||
`build/config.yml`, Taskfile scaffold, `application.Service`,
|
||||
`SingleInstanceOptions`. **Caveat:** it deliberately uses no generated
|
||||
bindings and no events (its frontend talks HTTP to a separate
|
||||
server), so it models Phase 1 well and Phases 3–6 not at all.
|
||||
- Local Phase 0 artifacts (scratchpad, ephemeral): scaffolded `spike/`
|
||||
app, `q2.mjs`/`q2b.mjs` browser probes, `q3_test.go` emit-safety
|
||||
tests.
|
||||
@@ -0,0 +1,183 @@
|
||||
# 010 — Owned albums, offline
|
||||
|
||||
**Status:** not started — and **much smaller than when it was written**
|
||||
**Branch:** none yet
|
||||
**Created:** 2026-08-13
|
||||
**Depends on:** nothing
|
||||
**Related:** the `AlbumReleasesFailed` fix that prompted it, and the
|
||||
tag-derived completeness that landed after it (same session)
|
||||
|
||||
---
|
||||
|
||||
## What already shipped, and what it leaves
|
||||
|
||||
The common case is solved without this plan. `GetAlbumCompleteness`
|
||||
reads the "5/12" denominator off the files' own tags — persisted to
|
||||
`release_group_recordings.total_tracks`, having been extracted at every
|
||||
scan since forever and discarded — and an album that is **MBID-matched
|
||||
and complete** now opens with **no catalog call at all**. Identity from
|
||||
the MBID, tracklist from the tags; those were the two things the browse
|
||||
was being spent on.
|
||||
|
||||
So the set this plan still has to serve is not "albums you own a track
|
||||
of". It is:
|
||||
|
||||
- albums that are genuinely **incomplete** (the catalog is the only way
|
||||
to say *which* tracks are missing — tags give the count, not the
|
||||
names), and
|
||||
- albums whose tags **never declared a total**, where completeness is
|
||||
unknowable locally and the catalog is the only source.
|
||||
|
||||
On a well-tagged library that is a small minority, which changes the
|
||||
economics below considerably: the run is shorter, and the rate limiter
|
||||
contention that dominates this design is proportionally less severe.
|
||||
Re-measure before building — the answer may now be "the prefetch is
|
||||
enough".
|
||||
|
||||
---
|
||||
|
||||
## The problem
|
||||
|
||||
Opening an album detail page for an album **you already own** hits
|
||||
MusicBrainz. Every time it is not in the response cache, which for most
|
||||
of a library is every time, because nothing warms that cache except a
|
||||
capped prefetch on the artist page.
|
||||
|
||||
The user's framing: *this is a classic example of an album we should
|
||||
have had locally.*
|
||||
|
||||
## Why we do not have it, despite the discography backfill
|
||||
|
||||
`BackfillLibraryDiscographies` / `EnsureArtistDiscography`
|
||||
(`backend/explore/searchindex.go:301`, `:397`) do less than the name
|
||||
suggests. Per artist, `indexOneArtist` fetches:
|
||||
|
||||
- `fetchTopReleaseGroups` — capped at `indexMaxRGs` (50)
|
||||
- `fetchTopRecordings` — capped at `indexMaxRecs` (200)
|
||||
|
||||
and writes them as **flat `explore_index` rows**. There is no release
|
||||
group → tracklist relation anywhere in the index, and no release-level
|
||||
rows at all. `explore_index` recordings carry `caa_release_mbid` and
|
||||
`release_name`, which name the release used for cover art — not a
|
||||
tracklist.
|
||||
|
||||
So "we have full discographies for library artists" means *we know
|
||||
which albums the artist made, offline*. It has never meant we know
|
||||
what is on any of them.
|
||||
|
||||
The only store of release-level catalog data in the app is `http_cache`
|
||||
under `mb:browse:releases:<rg>` (90-day TTL, `musicbrainz.go:27`),
|
||||
populated **only** by a live `BrowseReleases` with
|
||||
`Includes: ["recordings", "media"]` at `MaxLimit` — the most expensive
|
||||
call the app makes to MusicBrainz. It is warmed by exactly one thing:
|
||||
`PrefetchReleases` (`explore.go:746`), capped at 8, called only when an
|
||||
artist page renders.
|
||||
|
||||
An album opened from the library grid therefore always browses live.
|
||||
|
||||
## What to build
|
||||
|
||||
**A post-scan backfill that warms the release cache for release groups
|
||||
that are owned but not known-complete** — bounded, resumable, and
|
||||
shaped exactly like `BackfillLibraryDiscographies`, which is the proven
|
||||
pattern for this in the codebase.
|
||||
|
||||
The scoping rule is the user's and it is the right one: not "every
|
||||
album by every artist in the library" (50 release groups per artist,
|
||||
mostly never opened) but albums with owned tracks — narrowed further,
|
||||
now, to the ones a local answer cannot already cover. The query gains
|
||||
one clause: skip release groups whose `GetAlbumCompleteness` reports
|
||||
`complete`.
|
||||
|
||||
Sketch:
|
||||
|
||||
1. A query for release groups with ≥1 owned track and no warm release
|
||||
cache entry. `release_groups.mbid` is the key; the owned-track join
|
||||
is `audio_files → recordings → release_group_recordings`, the same
|
||||
shape `unenrichedLibraryArtistMBIDs` already uses one table over.
|
||||
2. Order by owned-track count descending, so the albums the user has
|
||||
most of are warmed first — same reasoning as the discography
|
||||
backfill's ordering, same benefit if a run is cut short.
|
||||
3. Run through `releasesSF`, so it never double-fetches a release group
|
||||
an interactive open is already handling.
|
||||
4. Bound a run (`discogBackfillMaxPerRun` has a value to copy) and make
|
||||
it resumable: the resume marker is the response cache itself —
|
||||
`BrowseReleasesCached` already answers "is this one done", so unlike
|
||||
the discography path this needs **no new flag column**.
|
||||
5. Trigger it where `BackfillLibraryDiscographies` is triggered, and
|
||||
register it with `jobs` so it has progress, pause and cancel like
|
||||
every other long-running operation.
|
||||
|
||||
### The rate limiter is the whole design constraint
|
||||
|
||||
One shared `NewRateLimiter()` at 1 req/s (`explore.go:84`) serves this,
|
||||
`PrefetchReleases`, and every interactive browse. A backfill over a
|
||||
few thousand owned albums is *hours* of wall clock at that rate — which
|
||||
is fine for a background job, and not fine if it starves the album page
|
||||
the user is looking at right now.
|
||||
|
||||
That is the real work in this plan, and it is not the query:
|
||||
|
||||
- Interactive browses need to **jump the queue**. Today they cannot;
|
||||
there is one limiter and it is FIFO.
|
||||
- `PrefetchReleases`' cap of 8 was sized when nothing else competed for
|
||||
the limiter. Revisit it in the same change.
|
||||
- The 60 s fallback the `AlbumReleasesFailed` fix installed is sized
|
||||
for today's contention. If a backfill can queue behind it, that
|
||||
number is wrong again — which is an argument for priority, not for a
|
||||
bigger number.
|
||||
|
||||
Do not start the query until the priority question has an answer.
|
||||
|
||||
## The alternative that was considered and rejected
|
||||
|
||||
**Project release-group tracklists in the dump build and ship them in
|
||||
the artifact.** The data is there: `canonical_musicbrainz_data.csv`
|
||||
carries `release_mbid` *and* `recording_mbid`
|
||||
(`dumpcatalog.go:520`), and `release_to_rg` already maps release →
|
||||
release group. It is derivable from bytes the index build already
|
||||
streams, with no new API surface at all, and it would work offline on
|
||||
first launch with no per-user backfill.
|
||||
|
||||
It is rejected **for this plan** because the artifact is built
|
||||
centrally and is byte-identical for every user, so "albums the user
|
||||
owns a track of" cannot be a filter on it. Shipping tracklists for the
|
||||
whole catalog means per-recording rows against a ~900 MB artifact
|
||||
budget (~426 B/row measured), and gating on a popularity floor means it
|
||||
is absent for exactly the obscure albums a local backfill would have
|
||||
covered.
|
||||
|
||||
Worse than absent, in fact — and this is the argument that actually
|
||||
kills it. The floor is not one number over artists; it is a **per
|
||||
artist track budget** (`dumpcatalog.go:58-89`): 50 tracks for a tier-A
|
||||
artist, 25 for tier B, 12 for tier C. A projected tracklist would
|
||||
therefore be *whichever* of an album's tracks survived that budget,
|
||||
with nothing marking the rest as absent — so the album page would count
|
||||
owned against a truncated denominator and render "Play 7 of 9" for a
|
||||
twelve-track album. That is a confident lie, where the honest states
|
||||
this plan's alternative produces (complete / incomplete / unknown) are
|
||||
at worst silent.
|
||||
|
||||
Note that `markLibraryArtists` (`dumpcatalog.go:246`) already grants
|
||||
every library artist full coverage — 500 tracks, 100 release groups —
|
||||
by reading the local library, so the per-user tailoring this option
|
||||
supposedly cannot have does exist in code. It is a no-op in the CI
|
||||
build (empty library), and reaching it means a **local** dump build:
|
||||
the ~205 GB, half-a-day download the entire artifact design exists to
|
||||
avoid. Whoever finds that function next should read this paragraph
|
||||
before getting excited about it.
|
||||
|
||||
Worth revisiting if the artifact ever gains per-user tailoring, or if a
|
||||
measurement shows the row count is smaller than feared. Note it also
|
||||
yields the *canonical* tracklist rather than MusicBrainz's full version
|
||||
list, so the versions dropdown would still browse live when opened.
|
||||
|
||||
## Done when
|
||||
|
||||
- Opening an owned album that has never been opened before renders its
|
||||
catalog tracklist with no network call, after one backfill run.
|
||||
- An interactive browse issued while the backfill is running is not
|
||||
delayed by it.
|
||||
- The backfill appears in the jobs indicator, and can be paused and
|
||||
cancelled there.
|
||||
- A second run after a completed one does approximately nothing.
|
||||
Reference in New Issue
Block a user