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:
2026-08-13 16:17:48 -04:00
co-authored by Claude Opus 5
parent 4efd17d477
commit dcc40b1781
90 changed files with 7136 additions and 541 deletions
@@ -0,0 +1,685 @@
# 009 — Wails v3 migration
**Status:** Phase 0 complete — **go**. Phases 17 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 14 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 "14 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 14 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 14 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:** 46 focused sessions. The official guide's "14
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 "14 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 36 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.
+117
View File
@@ -689,6 +689,123 @@ grouped so the caller keeps the tracklist's order. It exists rather
than a lookup by track id because **`MBTrack.LocalID` is declared and
nothing in the backend ever writes it**.
**How much of an album is here is a question the files can answer.**
`ownership()` above counts the *displayed* tracklist, which for a
library copy is a tautology — every local track is `inLibrary: true`,
so owned always equals total and "do I have all of this" had no local
answer. The album page therefore asked MusicBrainz, and
`BrowseReleases` is the most expensive call the app makes: releases
plus every version's full tracklist, on a 1 req/s limiter shared with
`PrefetchReleases`, which fires up to eight when an artist page
renders.
The denominator was already on disk. `metadata` has read the "5/12"
totals off every file since forever (`m.Track()`, `m.Disc()`) and
discarded them; they persist to
`release_group_recordings.total_tracks` now, and
`GetAlbumCompleteness` sums them. **A complete, MBID-matched album
makes no catalog call at all** — identity from the MBID, tracklist from
the tags, which between them are what the browse was being spent on.
Three things about it are load-bearing. **Totals are declared per
disc**, so the expectation is a sum over discs and not one number, and
a disc whose files declared nothing leaves the whole album unknowable
rather than being covered by the discs that did. **Unknown is a third
state and must render as neither** — a great deal of any untagged
library has no total, and a ring drawn from its absence would mark most
of a library incomplete on no evidence; `Known` is what guards that,
and the badge falls back to the plain tick. And **complete is `>=`,
not `==`**, because bonus and hidden tracks routinely put a folder over
its declared total and that is a complete album, not a broken one.
Owned counts *distinct track numbers* for the same reason in reverse:
this app detects duplicates, and counting two files of track 3 twice
would report a short album as complete.
What tags cannot give is *which* tracks are missing, only how many — so
an incomplete album still browses, and that is now the exception rather
than every album load. Two smaller consequences: existing databases
read "unknown" until a rescan repopulates the column (which degrades to
exactly the old behaviour, so nothing breaks), and our own `tagwriter`
writes track and disc *numbers* but not totals, so autotagging a folder
currently degrades the field this rests on.
**The absence is what gets marked, not the presence.** The tracklist
put a green tick against every owned track and a legend underneath
explaining the tick — a positive mark on the *common* case, so an album
you own outright wore a column of circles and a key for them. It is the
streaming-service treatment now: rows not in the library are **dimmed
in place**, and nothing marks the ones that are. Two things follow.
Dimming is a colour, so it cannot be the only signal — the row carries
`aria-disabled`, which is what reaches anyone not seeing it. And the
dimmed rows are why the `loading` banner could go: tracks arriving
dimmed reads as the album filling in, so a line of text about the
page's own plumbing earns nothing. `unavailable` survives because it is
not about plumbing — it says rows may be missing from the page
altogether, which nothing on screen can show. (`explore-artist-details`
still uses `loading`; it has no equivalent per-row signal.)
**A partly-owned album draws the release, not the part.** Once the tags
say nine of twelve, `buildLibraryEntry` shows the *catalog's* twelve
with three dimmed, rather than the nine on disk — the missing tracks
are the useful information and a tracklist trimmed to what is owned
cannot show them. It is guarded on `completeness.known` rather than on
"fewer tracks than the cluster", which would swap a catalog tracklist
in for every album whose tags simply never declared a total. A
side-effect worth knowing: this is what finally makes `ownership()`
say something true here, since counting the displayed tracklist of a
library-only entry could only ever produce "9 of 9".
**A dropdown is only a choice if the choices differ.** The version
selector tested `versionEntries.length`, but a release group routinely
has several releases — reissues, regional pressings, a remaster — whose
tracklists are identical, and the synthetic "Your Library" entry is
often a third name for the same one, so the control appeared with every
option showing the same rows. `distinctTracklistCount()` is the real
test. It deliberately does **not** use `fingerprint()`, which keys on
recording MBIDs alone: a library entry built from untagged files has
none, so every such tracklist fingerprints to the same run of empty
strings and compares equal to every other. It falls back to the title,
which is what lets a local copy be recognised as the same tracklist the
catalog is describing.
**Say which version you own, not that you own one.** A synthetic "Your
Library" entry used to stand in for the matching release, which hid the
thing worth knowing: you could see that you owned *a* version but not
*which*, while the real release — its date, country and release count —
sat underneath under a different name. The matching release carries
`inLibrary` and is marked (★ **and** the words "in your library", since
a `<select>` cannot be styled per option and a bare glyph is the
unexplained symbol the tracklist's green ticks were). The synthetic
survives only where there is nothing to mark: local files matching no
release, or the no-local-album overlap guess.
**A merged cluster shows the order the most releases agree on.**
`mergeNearDuplicateClusters` folds by track *set*, so a resequenced
pressing — same songs, different running order — merges correctly. But
the survivor was whichever release came first in the browse response,
which is meaningless ordering. On a real album one 2021 pressing
arrived ahead of eleven 2013 ones, so the cluster wore the 2021 running
order; the user's files then matched no cluster **fingerprint**, and
the page both called their copy unlinked to MusicBrainz and offered a
second "version" whose only difference was an ordering almost nothing
was pressed in. `withConsensusRepresentative` re-picks by how many
releases share each exact ordering, earliest date breaking the tie —
**and moves the cluster's fingerprint with it**, since that is what the
library match is tested against. Note the consequence for the version
list: a resequence is not a separate version, because the merge folds
it before any of this runs.
**A slow catalog fetch is not a failed one.** The same page used to
reach `unavailable` — "No catalog details for this album right now" —
from a **12-second timer**, which is the only signal it had, because
`ensureReleasesAsync` emitted `AlbumReleasesReady` on success and
nothing at all on failure. Against a browse queued behind eight
prefetches at 1 req/s, that reported healthy fetches as catalog
failures on correctly-matched albums. `AlbumReleasesFailed` is the
missing half; `catalogFailed` is the only route to `unavailable` now,
and the timer is a 60 s backstop for a genuine hang rather than the
verdict.
**Ask for what the caller uses, once.** "Play this artist" resolved
file paths with one `GetAlbumTracks` per album, sequentially, and every
one of the four sites doing that asked for whole track rows to read
+13
View File
@@ -368,6 +368,11 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
// Wire queue (created in NewYellowJacketApp for Wails binding)
yj.queue.SetContext(ctx)
yj.queue.SetPlayer(yj.player)
yj.queue.SetFallbackSource(&queueFallbackAdapter{
config: yj.appConfig,
playlist: yj.playlist,
explore: yj.explore,
})
yj.queue.RestoreState()
// Wire cross-cutting rescan hooks so the library can
@@ -407,6 +412,11 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
// no-op once every owned artist is covered.
yj.explore.BackfillLibraryDiscographies()
// Resolve any release-group MBIDs the scan could only find a
// release-level tag for (see updateMBIDs). Same shape as the
// discography backfill above: background, bounded, resumable.
yj.explore.BackfillReleaseGroupMBIDs()
// Start (or resume) the dump-based index build. Skips
// itself once the one-time import has completed, so this
// is cheap on every startup.
@@ -635,6 +645,9 @@ func (yj *YellowJacketApp) OnDomReady(ctx context.Context) {
// discography (e.g. a prior run was capped or interrupted).
// Cheap no-op once every owned artist is covered.
yj.explore.BackfillLibraryDiscographies()
// Same continuation for release-group MBID resolution.
yj.explore.BackfillReleaseGroupMBIDs()
}
// Kick off the autotag prefetch worker so any unscored
+59
View File
@@ -93,6 +93,13 @@ func SyntheticTrackGroupKey(parentGroupKey string, audioFileID int64) string {
// genuine multi-disc release still separates correctly, since its
// disc-2-and-up tracks carry an explicit non-zero, non-one disc
// number.
//
// This is the single-file fallback used where a whole directory's
// disc tags aren't available (e.g. maybeRebindTaggingGroup, which
// rebinds one changed file at a time). Where a directory's full set
// of raw disc numbers IS available, prefer ResolveDirectoryDiscNumbers
// instead — a hardcoded "1" is the wrong guess for an untagged track
// sitting alongside siblings that all agree on disc 2.
func normalizeDiscNumber(discNumber int) int {
if discNumber <= 0 {
return 1
@@ -100,3 +107,55 @@ func normalizeDiscNumber(discNumber int) int {
return discNumber
}
// ResolveDirectoryDiscNumbers returns, for one directory's files, the
// disc number each should use when computing its GroupKey.
//
// normalizeDiscNumber's fixed "fold untagged to disc 1" is only a
// safe guess when the caller has no other evidence. Given the whole
// directory's raw disc tags at once, a better guess is available: if
// every file that DOES carry an explicit disc number agrees on the
// same value, an untagged sibling is almost certainly the same disc
// — a partially re-tagged rip, not a stray track from a different
// one — so it folds to that value instead of a hardcoded 1. If the
// directory's explicit disc numbers disagree, it's a genuine
// multi-disc release with no per-disc subfolders, and there's no
// single disc to guess for the untagged ones, so they fall back to
// normalizeDiscNumber's default.
//
// rawDiscNumbers must be in the same order as the files they belong
// to; the returned slice mirrors that order 1:1.
func ResolveDirectoryDiscNumbers(rawDiscNumbers []int) []int {
consensus := 0
ambiguous := false
for _, d := range rawDiscNumbers {
if d <= 0 {
continue
}
switch {
case consensus == 0:
consensus = d
case consensus != d:
ambiguous = true
}
}
fallback := 1
if consensus > 0 && !ambiguous {
fallback = consensus
}
out := make([]int, len(rawDiscNumbers))
for i, d := range rawDiscNumbers {
if d <= 0 {
out[i] = fallback
} else {
out[i] = d
}
}
return out
}
+66
View File
@@ -111,6 +111,72 @@ func TestGroupKey_UntaggedDiscFoldsIntoDiscOne(t *testing.T) {
}
}
func TestResolveDirectoryDiscNumbers_UntaggedFoldsToConsensus(t *testing.T) {
t.Parallel()
// A folder that's really disc 2, partially re-tagged: untagged
// tracks should join disc 2, not fall back to a hardcoded disc 1.
got := autotag.ResolveDirectoryDiscNumbers([]int{2, 0, 2, 0})
want := []int{2, 2, 2, 2}
if !equalInts(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestResolveDirectoryDiscNumbers_AllUntaggedFallsBackToOne(t *testing.T) {
t.Parallel()
got := autotag.ResolveDirectoryDiscNumbers([]int{0, 0, 0})
want := []int{1, 1, 1}
if !equalInts(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestResolveDirectoryDiscNumbers_GenuineMultiDiscKeepsExplicitValues(t *testing.T) {
t.Parallel()
// Explicit disagreement (disc 1 and disc 2 both present, no
// subfolders) means there's no single disc to guess for the
// untagged track — it falls back to normalizeDiscNumber's default
// rather than being assigned to either disc.
got := autotag.ResolveDirectoryDiscNumbers([]int{1, 1, 2, 2, 0})
want := []int{1, 1, 2, 2, 1}
if !equalInts(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestResolveDirectoryDiscNumbers_PreservesExplicitValuesEvenWhenUnanimous(t *testing.T) {
t.Parallel()
// Every file already agrees on disc 3 — nothing to resolve, but
// the explicit values must pass through unchanged.
got := autotag.ResolveDirectoryDiscNumbers([]int{3, 3, 3})
want := []int{3, 3, 3}
if !equalInts(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func equalInts(a, b []int) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestGroupKey_AmbiguityBoundary(t *testing.T) {
t.Parallel()
+80 -74
View File
@@ -75,51 +75,93 @@ func trackAlbumTags(tracks []LocalTrack) []string {
return out
}
// TrackCluster is a set of local tracks sharing a non-empty (album,
// album-artist) tag pair — a candidate sub-album hiding inside a
// mixed-bag folder.
// TrackCluster is a set of local tracks whose album (and album-artist)
// tags are close enough to describe the same release — a candidate
// sub-album hiding inside a mixed-bag folder.
type TrackCluster struct {
AlbumName string
AlbumArtist string
Tracks []LocalTrack
}
// ClusterByAlbumArtist groups tracks by normalized (album tag,
// album-artist tag) and returns the clusters with at least
// clusterMinSize members, in first-seen order (the caller typically
// passes tracks already ordered by disc/track/path, so this stays
// deterministic run to run). Tracks with no album tag, or whose
// cluster never reaches clusterMinSize, are omitted — they belong in
// the leftover folder, not a synthetic group of their own.
func ClusterByAlbumArtist(tracks []LocalTrack) []TrackCluster {
type key struct{ album, artist string }
// clusterFuzzyThreshold is the maximum stringDist between a track's
// album tag (and, separately, its album-artist tag) and the tags that
// started a cluster for the two to be considered the same album.
// Tight enough to keep genuinely different albums by the same artist
// apart, loose enough to absorb the kind of typo, dropped diacritic,
// or stray whitespace that exact Normalize()-equality clustering used
// to split into separate clusters — the same distance function
// candidate scoring already uses to decide two titles describe the
// same release (rank.go's albumTitleFit/artistCreditFit), applied to
// the same question here: do these two tags name the same thing.
const clusterFuzzyThreshold = 0.15
index := make(map[key]int, 4) //nolint:mnd
// clusterTracks groups tracks into candidate sub-albums: a track
// joins the first existing cluster whose founding track's album tag
// is within clusterFuzzyThreshold (in stringDist terms), and whose
// album-artist tag either also matches or is empty on either side —
// same "empty means unknown, not a mismatch" contract as
// artistCreditFit — or else it starts a new cluster. Tracks with no
// album tag are left unassigned (memberOf entry -1).
//
// Comparing only against the cluster's founding track, not a running
// centroid or every member, keeps this O(tracks × clusters) and
// deterministic in first-seen order — the order ClusterByAlbumArtist
// and SplitPlan's callers already depend on (they pass tracks ordered
// by disc/track/path).
func clusterTracks(tracks []LocalTrack) (clusters []TrackCluster, memberOf []int) {
type rep struct{ album, artist string }
var clusters []TrackCluster
var reps []rep
for _, t := range tracks {
album := Normalize(t.AlbumTag)
if album == "" {
continue
}
memberOf = make([]int, len(tracks))
k := key{album: album, artist: Normalize(t.AlbumArtistTag)}
if i, ok := index[k]; ok {
clusters[i].Tracks = append(clusters[i].Tracks, t)
for i, t := range tracks {
if Normalize(t.AlbumTag) == "" {
memberOf[i] = -1
continue
}
index[k] = len(clusters)
joined := -1
for ci, r := range reps {
artistMatches := t.AlbumArtistTag == "" || r.artist == "" ||
stringDist(t.AlbumArtistTag, r.artist) <= clusterFuzzyThreshold
if artistMatches && stringDist(t.AlbumTag, r.album) <= clusterFuzzyThreshold {
joined = ci
break
}
}
if joined < 0 {
joined = len(clusters)
reps = append(reps, rep{album: t.AlbumTag, artist: t.AlbumArtistTag})
clusters = append(clusters, TrackCluster{
AlbumName: t.AlbumTag,
AlbumArtist: t.AlbumArtistTag,
Tracks: []LocalTrack{t},
})
}
clusters[joined].Tracks = append(clusters[joined].Tracks, t)
memberOf[i] = joined
}
return clusters, memberOf
}
// ClusterByAlbumArtist groups tracks by album/album-artist tag
// similarity (see clusterTracks) and returns the clusters with at
// least clusterMinSize members, in first-seen order. Tracks with no
// album tag, or whose cluster never reaches clusterMinSize, are
// omitted — they belong in the leftover folder, not a synthetic group
// of their own.
func ClusterByAlbumArtist(tracks []LocalTrack) []TrackCluster {
clusters, _ := clusterTracks(tracks)
out := clusters[:0]
for _, c := range clusters {
@@ -134,55 +176,19 @@ func ClusterByAlbumArtist(tracks []LocalTrack) []TrackCluster {
// SplitPlan returns the full set of synthetic groups a mixed-bag
// folder should be torn into: ClusterByAlbumArtist's tag-matched
// sub-albums, plus a one-track cluster for every track that didn't
// share an (album, album-artist) pair with anything else in the
// folder. Unlike ClusterByAlbumArtist alone — which leaves
// unclustered tracks behind in the parent group, where they'd still
// get folded into whatever partial-album match the scorer finds for
// the rest of the pile — this guarantees every track leaves the
// parent, so a folder of entirely unrelated singles (no two tracks
// share an album tag) still gets torn apart instead of being scored
// as one bogus album with a pile of "extra" tracks. Each singleton's
// evidence-scaled score (rank.go) keeps it appropriately humble on
// its own — it just no longer drags an unrelated release's score
// down, or gets dragged down by one.
// end up sharing a cluster with anything else in the folder. Unlike
// ClusterByAlbumArtist alone — which leaves unclustered tracks behind
// in the parent group, where they'd still get folded into whatever
// partial-album match the scorer finds for the rest of the pile —
// this guarantees every track leaves the parent, so a folder of
// entirely unrelated singles (no two tracks share an album tag) still
// gets torn apart instead of being scored as one bogus album with a
// pile of "extra" tracks. Each singleton's evidence-scaled score
// (rank.go) keeps it appropriately humble on its own — it just no
// longer drags an unrelated release's score down, or gets dragged
// down by one.
func SplitPlan(tracks []LocalTrack) []TrackCluster {
type key struct{ album, artist string }
index := make(map[key]int, 4) //nolint:mnd
var clusters []TrackCluster
// memberOf[i] is 1+the cluster index track i was assigned to (by
// album/artist tag match), or 0 if it never matched anything.
// Tracked by slice position rather than any LocalTrack field —
// AudioFileID/FilePath are frequently zero-valued in this
// package's own tests and would collide, wrongly treating
// distinct untagged tracks as duplicates of one another.
memberOf := make([]int, len(tracks))
for i, t := range tracks {
album := Normalize(t.AlbumTag)
if album == "" {
continue
}
k := key{album: album, artist: Normalize(t.AlbumArtistTag)}
if ci, ok := index[k]; ok {
clusters[ci].Tracks = append(clusters[ci].Tracks, t)
memberOf[i] = ci + 1
continue
}
index[k] = len(clusters)
memberOf[i] = len(clusters) + 1
clusters = append(clusters, TrackCluster{
AlbumName: t.AlbumTag,
AlbumArtist: t.AlbumArtistTag,
Tracks: []LocalTrack{t},
})
}
clusters, memberOf := clusterTracks(tracks)
// Clusters that never reached clusterMinSize don't survive as a
// group; their sole member falls through to the singleton pass
@@ -198,7 +204,7 @@ func SplitPlan(tracks []LocalTrack) []TrackCluster {
}
for i, t := range tracks {
if ci := memberOf[i] - 1; ci >= 0 {
if ci := memberOf[i]; ci >= 0 {
if _, ok := keptIndex[ci]; ok {
continue
}
+78
View File
@@ -140,6 +140,84 @@ func TestClusterByAlbumArtist_FindsSubAlbums(t *testing.T) {
}
}
func TestClusterByAlbumArtist_TypoVariantsMergeIntoOneCluster(t *testing.T) {
t.Parallel()
// A dropped diacritic and a stray trailing space are the kind of
// noise exact Normalize()-equality clustering used to treat as
// two different albums, splitting one real album across clusters
// even though a candidate search on either would land on the same
// release. Fuzzy clustering absorbs both into one cluster.
tracks := []LocalTrack{
{
Title: "Song A",
Artist: "Sigur Ros",
AlbumTag: "Agaetis Byrjun",
AlbumArtistTag: "Sigur Ros",
},
{
Title: "Song B",
Artist: "Sigur Ros",
AlbumTag: "Ágætis byrjun",
AlbumArtistTag: "Sigur Ros",
},
{
Title: "Song C",
Artist: "Sigur Ros",
AlbumTag: "Agaetis Byrjun ",
AlbumArtistTag: "Sigur Ros",
},
}
clusters := ClusterByAlbumArtist(tracks)
if len(clusters) != 1 {
t.Fatalf(
"expected typo variants to merge into 1 cluster, got %d: %+v",
len(clusters),
clusters,
)
}
if len(clusters[0].Tracks) != 3 { //nolint:mnd
t.Fatalf("expected all 3 tracks in the merged cluster, got %d", len(clusters[0].Tracks))
}
}
func TestClusterByAlbumArtist_DifferentAlbumsBySameArtistStaySeparate(t *testing.T) {
t.Parallel()
// Fuzzy clustering must not blur genuinely different albums by
// the same artist into one cluster just because they share an
// artist tag — the threshold has to stay tight enough for this.
tracks := []LocalTrack{
{
Title: "Song A",
Artist: "Radiohead",
AlbumTag: "OK Computer",
AlbumArtistTag: "Radiohead",
},
{
Title: "Song B",
Artist: "Radiohead",
AlbumTag: "OK Computer",
AlbumArtistTag: "Radiohead",
},
{Title: "Song C", Artist: "Radiohead", AlbumTag: "Kid A", AlbumArtistTag: "Radiohead"},
{Title: "Song D", Artist: "Radiohead", AlbumTag: "Kid A", AlbumArtistTag: "Radiohead"},
}
clusters := ClusterByAlbumArtist(tracks)
if len(clusters) != 2 { //nolint:mnd
t.Fatalf(
"expected OK Computer and Kid A to stay separate, got %d clusters: %+v",
len(clusters),
clusters,
)
}
}
func TestClusterByAlbumArtist_NoAlbumTagStaysUnclustered(t *testing.T) {
t.Parallel()
+106
View File
@@ -35,6 +35,7 @@ type Config struct {
loaded bool // true once Load() succeeds
Library *library.Config `toml:"Library"`
Theme *theme.Config `toml:"Theme"`
General *GeneralConfig `toml:"General"`
Window *WindowConfig `toml:"Window"`
TrackList *tracklist.Config `toml:"TrackList"`
Favorites *favorites.Config `toml:"Favorites"`
@@ -84,6 +85,12 @@ func (c *Config) Validate() error {
}
}
if c.General != nil {
if err := c.General.Validate(); err != nil {
configErrs = errors.Join(configErrs, err)
}
}
if c.TrackList != nil {
if err := c.TrackList.Validate(); err != nil {
configErrs = errors.Join(configErrs, err)
@@ -240,6 +247,12 @@ func (c *Config) applyDefaults() {
c.Theme.ApplyDefaults()
if c.General == nil {
c.General = &GeneralConfig{}
}
c.General.ApplyDefaults()
if c.TrackList == nil {
c.TrackList = &tracklist.Config{}
}
@@ -505,6 +518,99 @@ func (c *Config) emitThemeChanged() {
)
}
// GetDefaultPage returns the view the app opens to on launch.
func (c *Config) GetDefaultPage() string {
if c.General == nil {
return string(DefaultDefaultPage)
}
return string(c.General.DefaultPage)
}
// SetDefaultPage validates and saves a new launch page.
func (c *Config) SetDefaultPage(page string) error {
if c.General == nil {
c.General = &GeneralConfig{}
c.General.ApplyDefaults()
}
c.General.DefaultPage = DefaultPage(page)
if err := c.General.Validate(); err != nil {
return fmt.Errorf(
"invalid default page: %w", err,
)
}
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config: %w", err,
)
}
events.Emit(
c.ctx,
events.GeneralConfigChanged,
map[string]any{
"DefaultPage": string(c.General.DefaultPage),
},
)
c.logger.Info(
"default page updated",
"page", page,
)
return nil
}
// GetQueueFallback returns what plays, if anything, once the queue
// runs out.
func (c *Config) GetQueueFallback() string {
if c.General == nil {
return string(DefaultQueueFallback)
}
return string(c.General.QueueFallback)
}
// SetQueueFallback validates and saves a new queue-fallback mode.
func (c *Config) SetQueueFallback(mode string) error {
if c.General == nil {
c.General = &GeneralConfig{}
c.General.ApplyDefaults()
}
c.General.QueueFallback = QueueFallback(mode)
if err := c.General.Validate(); err != nil {
return fmt.Errorf(
"invalid queue fallback: %w", err,
)
}
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config: %w", err,
)
}
events.Emit(
c.ctx,
events.GeneralConfigChanged,
map[string]any{
"QueueFallback": string(c.General.QueueFallback),
},
)
c.logger.Info(
"queue fallback updated",
"mode", mode,
)
return nil
}
// GetTrackListColumns returns the configured track-list columns.
func (c *Config) GetTrackListColumns() []tracklist.Column {
if c.TrackList == nil {
+85
View File
@@ -0,0 +1,85 @@
package config
import (
"errors"
"fmt"
)
// DefaultPage identifies which view the app opens to on launch.
type DefaultPage string
// Valid DefaultPage values, matching the frontend's top-level route ids.
const (
DefaultPageHome DefaultPage = "home"
DefaultPageTracks DefaultPage = "tracks"
DefaultPageAlbums DefaultPage = "albums"
DefaultPageArtists DefaultPage = "artists"
DefaultPageGenres DefaultPage = "genres"
DefaultPagePlaylists DefaultPage = "playlists"
DefaultPageExplore DefaultPage = "explore"
DefaultPageDownloads DefaultPage = "downloads"
DefaultPageAutotag DefaultPage = "autotag"
DefaultPageJobs DefaultPage = "jobs"
)
// DefaultDefaultPage is the launch page for a fresh install.
const DefaultDefaultPage = DefaultPageHome
var errUnknownDefaultPage = errors.New("unknown default page")
// QueueFallback identifies what plays, if anything, once the queue
// runs out with nothing left to auto-advance to.
type QueueFallback string
// Valid QueueFallback values.
const (
QueueFallbackStop QueueFallback = "stop"
QueueFallbackFavorites QueueFallback = "favorites"
QueueFallbackDynamicMix QueueFallback = "dynamicMix"
)
// DefaultQueueFallback is the fallback behavior for a fresh install.
const DefaultQueueFallback = QueueFallbackFavorites
var errUnknownQueueFallback = errors.New("unknown queue fallback")
// GeneralConfig holds general application preferences that don't
// belong to a more specific subsystem.
type GeneralConfig struct {
DefaultPage DefaultPage `toml:"DefaultPage"`
QueueFallback QueueFallback `toml:"QueueFallback"`
}
// ApplyDefaults fills zero-value fields with sensible defaults.
func (c *GeneralConfig) ApplyDefaults() {
if c.DefaultPage == "" {
c.DefaultPage = DefaultDefaultPage
}
if c.QueueFallback == "" {
c.QueueFallback = DefaultQueueFallback
}
}
// Validate checks that all values are well-formed.
func (c *GeneralConfig) Validate() error {
c.ApplyDefaults()
switch c.DefaultPage {
case DefaultPageHome, DefaultPageTracks, DefaultPageAlbums, DefaultPageArtists,
DefaultPageGenres, DefaultPagePlaylists, DefaultPageExplore, DefaultPageDownloads,
DefaultPageAutotag, DefaultPageJobs:
// Valid.
default:
return fmt.Errorf("%w: %q", errUnknownDefaultPage, c.DefaultPage)
}
switch c.QueueFallback {
case QueueFallbackStop, QueueFallbackFavorites, QueueFallbackDynamicMix:
// Valid.
default:
return fmt.Errorf("%w: %q", errUnknownQueueFallback, c.QueueFallback)
}
return nil
}
@@ -0,0 +1 @@
ALTER TABLE tagging_items ADD COLUMN album_artist_conflict INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,3 @@
ALTER TABLE queue ADD COLUMN source_type TEXT NOT NULL DEFAULT '';
ALTER TABLE queue ADD COLUMN source_id INTEGER NOT NULL DEFAULT 0;
ALTER TABLE queue ADD COLUMN source_label TEXT NOT NULL DEFAULT '';
@@ -0,0 +1 @@
ALTER TABLE release_groups ADD COLUMN pending_release_mbid TEXT;
@@ -0,0 +1 @@
ALTER TABLE release_group_recordings ADD COLUMN total_tracks INTEGER;
+30
View File
@@ -0,0 +1,30 @@
-- Queries backing the dynamic-mix queue fallback (backend/explore/mix.go):
-- expanding a seed selection into a candidate pool by artist similarity
-- and genre overlap, restricted to what is actually in the library.
-- name: GetFilePathsByArtistMBID :many
SELECT DISTINCT af.file_path
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
JOIN artists a ON a.id = aca.artist_id
WHERE a.mbid = ?;
-- name: GetGenreNamesByFilePath :many
SELECT DISTINCT g.name
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN recordings r ON rg.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
WHERE af.file_path = ?;
-- name: GetArtistByFilePath :one
SELECT COALESCE(a.name, '') AS artist_name, COALESCE(a.mbid, '') AS artist_mbid
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
JOIN artists a ON a.id = aca.artist_id
WHERE af.file_path = ?
LIMIT 1;
+2 -2
View File
@@ -1,10 +1,10 @@
-- name: GetQueueState :one
SELECT source_playlist_id, current_position, shuffle_mode, repeat_mode, shuffle_order
SELECT current_position, shuffle_mode, repeat_mode, shuffle_order, source_type, source_id, source_label
FROM queue WHERE id = 1;
-- name: UpdateQueueState :exec
UPDATE queue
SET source_playlist_id = ?, current_position = ?, shuffle_mode = ?, repeat_mode = ?, shuffle_order = ?
SET current_position = ?, shuffle_mode = ?, repeat_mode = ?, shuffle_order = ?, source_type = ?, source_id = ?, source_label = ?
WHERE id = 1;
-- name: UpdateQueuePosition :exec
@@ -1,8 +1,26 @@
-- name: CreateReleaseGroupRecording :one
INSERT INTO release_group_recordings (release_group_id, recording_id, track_number, disc_number)
VALUES (?, ?, ?, ?)
INSERT INTO release_group_recordings (
release_group_id, recording_id, track_number, disc_number, total_tracks
)
VALUES (?, ?, ?, ?, ?)
RETURNING *;
-- name: GetAlbumCompleteness :one
WITH discs AS (
SELECT
COALESCE(rgr.disc_number, 1) AS disc,
MAX(COALESCE(rgr.total_tracks, 0)) AS declared,
COUNT(DISTINCT COALESCE(rgr.track_number, -rgr.recording_id)) AS owned
FROM release_group_recordings rgr
WHERE rgr.release_group_id = ?
GROUP BY COALESCE(rgr.disc_number, 1)
)
SELECT
CAST(COALESCE(SUM(owned), 0) AS INTEGER) AS owned,
CAST(COALESCE(SUM(declared), 0) AS INTEGER) AS expected,
CAST(COALESCE(SUM(CASE WHEN declared = 0 THEN 1 ELSE 0 END), 0) AS INTEGER) AS discs_untotalled
FROM discs;
-- name: GetReleaseGroupRecording :one
SELECT * FROM release_group_recordings
WHERE id = ? LIMIT 1;
@@ -10,7 +10,27 @@ ON CONFLICT(group_key) DO UPDATE SET
WHEN tagging_items.album_name = '' THEN excluded.album_name
ELSE tagging_items.album_name
END,
-- Tracks real consensus, not first-write-wins: stays set only
-- while every track that has contributed a non-empty value agrees.
-- A later track with a *different* non-empty value clears it back
-- to '' and latches album_artist_conflict, since a single
-- disagreeing tag means the folder no longer has one authoritative
-- album-artist -- IsMixedBag (backend/autotag) treats a non-empty
-- value here as trusted, so leaving a stale first-seen value in
-- place would let one track's tag silently blind mixed-bag
-- detection for the whole folder. The latch (rather than just
-- clearing the text column) stops a later track from coincidentally
-- repeating an already-disputed value and resurrecting trust in it.
album_artist_conflict = CASE
WHEN tagging_items.album_artist_conflict = 1 THEN 1
WHEN tagging_items.album_artist != '' AND excluded.album_artist != ''
AND tagging_items.album_artist != excluded.album_artist THEN 1
ELSE 0
END,
album_artist = CASE
WHEN tagging_items.album_artist_conflict = 1 THEN ''
WHEN tagging_items.album_artist != '' AND excluded.album_artist != ''
AND tagging_items.album_artist != excluded.album_artist THEN ''
WHEN tagging_items.album_artist = '' THEN excluded.album_artist
ELSE tagging_items.album_artist
END;
+7
View File
@@ -5,6 +5,13 @@ CREATE TABLE IF NOT EXISTS queue (
shuffle_mode BOOLEAN NOT NULL DEFAULT false,
repeat_mode TEXT NOT NULL DEFAULT 'off',
shuffle_order TEXT,
-- source_playlist_id above is unused dead weight (nothing has ever
-- written it a nonzero value); source_type/source_id/source_label
-- below are its generalized replacement, covering albums, playlists,
-- smart playlists, genres and artists rather than playlists alone.
source_type TEXT NOT NULL DEFAULT '',
source_id INTEGER NOT NULL DEFAULT 0,
source_label TEXT NOT NULL DEFAULT '',
FOREIGN KEY(source_playlist_id) REFERENCES playlists(id) ON DELETE SET NULL
);
@@ -4,6 +4,12 @@ CREATE TABLE IF NOT EXISTS release_group_recordings (
recording_id INTEGER NOT NULL,
track_number INTEGER,
disc_number INTEGER,
-- The denominator the file's own tag declared: the 12 in "5/12", per
-- disc. Read off every file at scan and, until now, discarded — so
-- "do I have all of this album" had no local answer and the album
-- page asked MusicBrainz. NULL means the tag did not say, which is
-- a third state and not the same as zero.
total_tracks INTEGER,
FOREIGN KEY(release_group_id) REFERENCES release_groups(id),
FOREIGN KEY(recording_id) REFERENCES recordings(id)
);
@@ -5,7 +5,7 @@ CREATE TABLE IF NOT EXISTS "release_groups" (
album_artist_credit_id INTEGER,
year INTEGER,
total_tracks INTEGER,
total_discs INTEGER, mbid TEXT, original_year INTEGER,
total_discs INTEGER, mbid TEXT, original_year INTEGER, pending_release_mbid TEXT,
FOREIGN KEY(cover_art_id) REFERENCES cover_art(id),
FOREIGN KEY(album_artist_credit_id) REFERENCES artist_credit(id),
UNIQUE(name, album_artist_credit_id)
@@ -39,6 +39,17 @@ CREATE TABLE IF NOT EXISTS tagging_items (
-- first time; append-only from the second migration on.
synthetic INTEGER NOT NULL DEFAULT 0,
parent_group_key TEXT NOT NULL DEFAULT '',
-- album_artist_conflict latches to 1 the first time two tracks
-- added to this group carry different non-empty album_artist tags,
-- and never resets. Without it, UpsertTaggingItemOnTrackAdd's
-- consensus tracking on album_artist can't tell "no non-empty
-- value contributed yet" apart from "conflicting values were
-- observed and it was cleared" — both look like '' — so a later
-- track that happens to repeat an earlier, already-disputed value
-- would wrongly resurrect trust in it. See IsMixedBag
-- (backend/autotag/mixedbag.go), which trusts a non-empty
-- album_artist unconditionally.
album_artist_conflict INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY(library_id) REFERENCES libraries(id)
);
+103
View File
@@ -0,0 +1,103 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: mix.sql
package sqlcgen
import (
"context"
"database/sql"
)
const getArtistByFilePath = `-- name: GetArtistByFilePath :one
SELECT COALESCE(a.name, '') AS artist_name, COALESCE(a.mbid, '') AS artist_mbid
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
JOIN artists a ON a.id = aca.artist_id
WHERE af.file_path = ?
LIMIT 1
`
type GetArtistByFilePathRow struct {
ArtistName string
ArtistMbid string
}
func (q *Queries) GetArtistByFilePath(ctx context.Context, filePath string) (GetArtistByFilePathRow, error) {
row := q.db.QueryRowContext(ctx, getArtistByFilePath, filePath)
var i GetArtistByFilePathRow
err := row.Scan(&i.ArtistName, &i.ArtistMbid)
return i, err
}
const getFilePathsByArtistMBID = `-- name: GetFilePathsByArtistMBID :many
SELECT DISTINCT af.file_path
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
JOIN artist_credit_artist aca ON aca.credit_id = ac.id
JOIN artists a ON a.id = aca.artist_id
WHERE a.mbid = ?
`
// Queries backing the dynamic-mix queue fallback (backend/explore/mix.go):
// expanding a seed selection into a candidate pool by artist similarity
// and genre overlap, restricted to what is actually in the library.
func (q *Queries) GetFilePathsByArtistMBID(ctx context.Context, mbid sql.NullString) ([]string, error) {
rows, err := q.db.QueryContext(ctx, getFilePathsByArtistMBID, mbid)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var file_path string
if err := rows.Scan(&file_path); err != nil {
return nil, err
}
items = append(items, file_path)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getGenreNamesByFilePath = `-- name: GetGenreNamesByFilePath :many
SELECT DISTINCT g.name
FROM genres g
JOIN recording_genres rg ON g.id = rg.genre_id
JOIN recordings r ON rg.recording_id = r.id
JOIN audio_files af ON af.recording_id = r.id
WHERE af.file_path = ?
`
func (q *Queries) GetGenreNamesByFilePath(ctx context.Context, filePath string) ([]string, error) {
rows, err := q.db.QueryContext(ctx, getGenreNamesByFilePath, filePath)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
items = append(items, name)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+6
View File
@@ -267,6 +267,9 @@ type Queue struct {
ShuffleMode bool
RepeatMode string
ShuffleOrder sql.NullString
SourceType string
SourceID int64
SourceLabel string
}
type QueueTrack struct {
@@ -305,6 +308,7 @@ type ReleaseGroup struct {
TotalDiscs sql.NullInt64
Mbid sql.NullString
OriginalYear sql.NullInt64
PendingReleaseMbid sql.NullString
}
type ReleaseGroupRecording struct {
@@ -313,6 +317,7 @@ type ReleaseGroupRecording struct {
RecordingID int64
TrackNumber sql.NullInt64
DiscNumber sql.NullInt64
TotalTracks sql.NullInt64
}
type ReleaseToRg struct {
@@ -363,6 +368,7 @@ type TaggingItem struct {
CreatedAt time.Time
Synthetic int64
ParentGroupKey string
AlbumArtistConflict int64
}
type TrackMetadatum struct {
+14 -6
View File
@@ -20,27 +20,31 @@ func (q *Queries) ClearQueueTracks(ctx context.Context) error {
}
const getQueueState = `-- name: GetQueueState :one
SELECT source_playlist_id, current_position, shuffle_mode, repeat_mode, shuffle_order
SELECT current_position, shuffle_mode, repeat_mode, shuffle_order, source_type, source_id, source_label
FROM queue WHERE id = 1
`
type GetQueueStateRow struct {
SourcePlaylistID sql.NullInt64
CurrentPosition int64
ShuffleMode bool
RepeatMode string
ShuffleOrder sql.NullString
SourceType string
SourceID int64
SourceLabel string
}
func (q *Queries) GetQueueState(ctx context.Context) (GetQueueStateRow, error) {
row := q.db.QueryRowContext(ctx, getQueueState)
var i GetQueueStateRow
err := row.Scan(
&i.SourcePlaylistID,
&i.CurrentPosition,
&i.ShuffleMode,
&i.RepeatMode,
&i.ShuffleOrder,
&i.SourceType,
&i.SourceID,
&i.SourceLabel,
)
return i, err
}
@@ -200,25 +204,29 @@ func (q *Queries) UpdateQueuePosition(ctx context.Context, currentPosition int64
const updateQueueState = `-- name: UpdateQueueState :exec
UPDATE queue
SET source_playlist_id = ?, current_position = ?, shuffle_mode = ?, repeat_mode = ?, shuffle_order = ?
SET current_position = ?, shuffle_mode = ?, repeat_mode = ?, shuffle_order = ?, source_type = ?, source_id = ?, source_label = ?
WHERE id = 1
`
type UpdateQueueStateParams struct {
SourcePlaylistID sql.NullInt64
CurrentPosition int64
ShuffleMode bool
RepeatMode string
ShuffleOrder sql.NullString
SourceType string
SourceID int64
SourceLabel string
}
func (q *Queries) UpdateQueueState(ctx context.Context, arg UpdateQueueStateParams) error {
_, err := q.db.ExecContext(ctx, updateQueueState,
arg.SourcePlaylistID,
arg.CurrentPosition,
arg.ShuffleMode,
arg.RepeatMode,
arg.ShuffleOrder,
arg.SourceType,
arg.SourceID,
arg.SourceLabel,
)
return err
}
@@ -11,9 +11,11 @@ import (
)
const createReleaseGroupRecording = `-- name: CreateReleaseGroupRecording :one
INSERT INTO release_group_recordings (release_group_id, recording_id, track_number, disc_number)
VALUES (?, ?, ?, ?)
RETURNING id, release_group_id, recording_id, track_number, disc_number
INSERT INTO release_group_recordings (
release_group_id, recording_id, track_number, disc_number, total_tracks
)
VALUES (?, ?, ?, ?, ?)
RETURNING id, release_group_id, recording_id, track_number, disc_number, total_tracks
`
type CreateReleaseGroupRecordingParams struct {
@@ -21,6 +23,7 @@ type CreateReleaseGroupRecordingParams struct {
RecordingID int64
TrackNumber sql.NullInt64
DiscNumber sql.NullInt64
TotalTracks sql.NullInt64
}
func (q *Queries) CreateReleaseGroupRecording(ctx context.Context, arg CreateReleaseGroupRecordingParams) (ReleaseGroupRecording, error) {
@@ -29,6 +32,7 @@ func (q *Queries) CreateReleaseGroupRecording(ctx context.Context, arg CreateRel
arg.RecordingID,
arg.TrackNumber,
arg.DiscNumber,
arg.TotalTracks,
)
var i ReleaseGroupRecording
err := row.Scan(
@@ -37,6 +41,7 @@ func (q *Queries) CreateReleaseGroupRecording(ctx context.Context, arg CreateRel
&i.RecordingID,
&i.TrackNumber,
&i.DiscNumber,
&i.TotalTracks,
)
return i, err
}
@@ -85,8 +90,38 @@ func (q *Queries) DeleteReleaseGroupRecordingsByRecording(ctx context.Context, r
return err
}
const getAlbumCompleteness = `-- name: GetAlbumCompleteness :one
WITH discs AS (
SELECT
COALESCE(rgr.disc_number, 1) AS disc,
MAX(COALESCE(rgr.total_tracks, 0)) AS declared,
COUNT(DISTINCT COALESCE(rgr.track_number, -rgr.recording_id)) AS owned
FROM release_group_recordings rgr
WHERE rgr.release_group_id = ?
GROUP BY COALESCE(rgr.disc_number, 1)
)
SELECT
CAST(COALESCE(SUM(owned), 0) AS INTEGER) AS owned,
CAST(COALESCE(SUM(declared), 0) AS INTEGER) AS expected,
CAST(COALESCE(SUM(CASE WHEN declared = 0 THEN 1 ELSE 0 END), 0) AS INTEGER) AS discs_untotalled
FROM discs
`
type GetAlbumCompletenessRow struct {
Owned int64
Expected int64
DiscsUntotalled int64
}
func (q *Queries) GetAlbumCompleteness(ctx context.Context, releaseGroupID int64) (GetAlbumCompletenessRow, error) {
row := q.db.QueryRowContext(ctx, getAlbumCompleteness, releaseGroupID)
var i GetAlbumCompletenessRow
err := row.Scan(&i.Owned, &i.Expected, &i.DiscsUntotalled)
return i, err
}
const getRecordingReleaseGroups = `-- name: GetRecordingReleaseGroups :many
SELECT id, release_group_id, recording_id, track_number, disc_number FROM release_group_recordings
SELECT id, release_group_id, recording_id, track_number, disc_number, total_tracks FROM release_group_recordings
WHERE recording_id = ?
`
@@ -105,6 +140,7 @@ func (q *Queries) GetRecordingReleaseGroups(ctx context.Context, recordingID int
&i.RecordingID,
&i.TrackNumber,
&i.DiscNumber,
&i.TotalTracks,
); err != nil {
return nil, err
}
@@ -120,7 +156,7 @@ func (q *Queries) GetRecordingReleaseGroups(ctx context.Context, recordingID int
}
const getReleaseGroupRecording = `-- name: GetReleaseGroupRecording :one
SELECT id, release_group_id, recording_id, track_number, disc_number FROM release_group_recordings
SELECT id, release_group_id, recording_id, track_number, disc_number, total_tracks FROM release_group_recordings
WHERE id = ? LIMIT 1
`
@@ -133,12 +169,13 @@ func (q *Queries) GetReleaseGroupRecording(ctx context.Context, id int64) (Relea
&i.RecordingID,
&i.TrackNumber,
&i.DiscNumber,
&i.TotalTracks,
)
return i, err
}
const getReleaseGroupRecordings = `-- name: GetReleaseGroupRecordings :many
SELECT id, release_group_id, recording_id, track_number, disc_number FROM release_group_recordings
SELECT id, release_group_id, recording_id, track_number, disc_number, total_tracks FROM release_group_recordings
WHERE release_group_id = ?
ORDER BY disc_number, track_number
`
@@ -158,6 +195,7 @@ func (q *Queries) GetReleaseGroupRecordings(ctx context.Context, releaseGroupID
&i.RecordingID,
&i.TrackNumber,
&i.DiscNumber,
&i.TotalTracks,
); err != nil {
return nil, err
}
@@ -23,7 +23,7 @@ func (q *Queries) CountReleaseGroupRecordings(ctx context.Context, releaseGroupI
const createReleaseGroup = `-- name: CreateReleaseGroup :one
INSERT INTO release_groups (name) VALUES (?)
RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year
RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year, pending_release_mbid
`
func (q *Queries) CreateReleaseGroup(ctx context.Context, name string) (ReleaseGroup, error) {
@@ -39,6 +39,7 @@ func (q *Queries) CreateReleaseGroup(ctx context.Context, name string) (ReleaseG
&i.TotalDiscs,
&i.Mbid,
&i.OriginalYear,
&i.PendingReleaseMbid,
)
return i, err
}
@@ -47,7 +48,7 @@ const createReleaseGroupFull = `-- name: CreateReleaseGroupFull :one
INSERT INTO release_groups (
name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs
) VALUES (?, ?, ?, ?, ?, ?)
RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year
RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year, pending_release_mbid
`
type CreateReleaseGroupFullParams struct {
@@ -79,6 +80,7 @@ func (q *Queries) CreateReleaseGroupFull(ctx context.Context, arg CreateReleaseG
&i.TotalDiscs,
&i.Mbid,
&i.OriginalYear,
&i.PendingReleaseMbid,
)
return i, err
}
@@ -432,7 +434,7 @@ func (q *Queries) GetAllAlbumsWithDetailsByLibrary(ctx context.Context, libraryI
}
const getAllReleaseGroups = `-- name: GetAllReleaseGroups :many
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year FROM release_groups
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year, pending_release_mbid FROM release_groups
ORDER BY name
`
@@ -455,6 +457,7 @@ func (q *Queries) GetAllReleaseGroups(ctx context.Context) ([]ReleaseGroup, erro
&i.TotalDiscs,
&i.Mbid,
&i.OriginalYear,
&i.PendingReleaseMbid,
); err != nil {
return nil, err
}
@@ -502,7 +505,7 @@ func (q *Queries) GetOrphanedReleaseGroupIDs(ctx context.Context) ([]int64, erro
}
const getReleaseGroup = `-- name: GetReleaseGroup :one
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year FROM release_groups
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year, pending_release_mbid FROM release_groups
WHERE id = ? LIMIT 1
`
@@ -519,12 +522,13 @@ func (q *Queries) GetReleaseGroup(ctx context.Context, id int64) (ReleaseGroup,
&i.TotalDiscs,
&i.Mbid,
&i.OriginalYear,
&i.PendingReleaseMbid,
)
return i, err
}
const getReleaseGroupByNameAndArtist = `-- name: GetReleaseGroupByNameAndArtist :one
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year FROM release_groups
SELECT id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year, pending_release_mbid FROM release_groups
WHERE name = ? AND album_artist_credit_id = ? LIMIT 1
`
@@ -546,6 +550,7 @@ func (q *Queries) GetReleaseGroupByNameAndArtist(ctx context.Context, arg GetRel
&i.TotalDiscs,
&i.Mbid,
&i.OriginalYear,
&i.PendingReleaseMbid,
)
return i, err
}
@@ -606,7 +611,7 @@ VALUES (?, ?, ?)
ON CONFLICT(name, album_artist_credit_id) DO UPDATE SET
album_artist_credit_id = COALESCE(excluded.album_artist_credit_id, release_groups.album_artist_credit_id),
year = COALESCE(excluded.year, release_groups.year)
RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year
RETURNING id, name, cover_art_id, album_artist_credit_id, year, total_tracks, total_discs, mbid, original_year, pending_release_mbid
`
type UpsertReleaseGroupParams struct {
@@ -628,6 +633,7 @@ func (q *Queries) UpsertReleaseGroup(ctx context.Context, arg UpsertReleaseGroup
&i.TotalDiscs,
&i.Mbid,
&i.OriginalYear,
&i.PendingReleaseMbid,
)
return i, err
}
@@ -200,7 +200,7 @@ func (q *Queries) GetRecordingReleaseGroupID(ctx context.Context, recordingID in
}
const getTaggingItem = `-- name: GetTaggingItem :one
SELECT group_key, library_id, track_count, album_name, album_artist, disc_number, best_match_release_mbid, score, last_checked_at, status, cleared_at, created_at, synthetic, parent_group_key FROM tagging_items
SELECT group_key, library_id, track_count, album_name, album_artist, disc_number, best_match_release_mbid, score, last_checked_at, status, cleared_at, created_at, synthetic, parent_group_key, album_artist_conflict FROM tagging_items
WHERE group_key = ?
LIMIT 1
`
@@ -223,6 +223,7 @@ func (q *Queries) GetTaggingItem(ctx context.Context, groupKey string) (TaggingI
&i.CreatedAt,
&i.Synthetic,
&i.ParentGroupKey,
&i.AlbumArtistConflict,
)
return i, err
}
@@ -859,7 +860,27 @@ ON CONFLICT(group_key) DO UPDATE SET
WHEN tagging_items.album_name = '' THEN excluded.album_name
ELSE tagging_items.album_name
END,
-- Tracks real consensus, not first-write-wins: stays set only
-- while every track that has contributed a non-empty value agrees.
-- A later track with a *different* non-empty value clears it back
-- to '' and latches album_artist_conflict, since a single
-- disagreeing tag means the folder no longer has one authoritative
-- album-artist -- IsMixedBag (backend/autotag) treats a non-empty
-- value here as trusted, so leaving a stale first-seen value in
-- place would let one track's tag silently blind mixed-bag
-- detection for the whole folder. The latch (rather than just
-- clearing the text column) stops a later track from coincidentally
-- repeating an already-disputed value and resurrecting trust in it.
album_artist_conflict = CASE
WHEN tagging_items.album_artist_conflict = 1 THEN 1
WHEN tagging_items.album_artist != '' AND excluded.album_artist != ''
AND tagging_items.album_artist != excluded.album_artist THEN 1
ELSE 0
END,
album_artist = CASE
WHEN tagging_items.album_artist_conflict = 1 THEN ''
WHEN tagging_items.album_artist != '' AND excluded.album_artist != ''
AND tagging_items.album_artist != excluded.album_artist THEN ''
WHEN tagging_items.album_artist = '' THEN excluded.album_artist
ELSE tagging_items.album_artist
END
+76
View File
@@ -333,6 +333,82 @@ func TestListPendingFolders_SampleFilePathUsesIndex(t *testing.T) {
}
}
// TestUpsertTaggingItemOnTrackAdd_AlbumArtistTracksConsensus guards
// against regressing to first-write-wins: a folder's album_artist
// must reflect whether every contributing track actually agreed, not
// just whichever track happened to be scanned first. IsMixedBag
// (backend/autotag) trusts a non-empty album_artist unconditionally,
// so a stale first-seen value here would silently defeat mixed-bag
// detection for the rest of the folder's tracks.
func TestUpsertTaggingItemOnTrackAdd_AlbumArtistTracksConsensus(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
upsert := func(t *testing.T, groupKey, albumArtist string) {
t.Helper()
if err := db.Queries.UpsertTaggingItemOnTrackAdd(
db.Ctx, sqlcgen.UpsertTaggingItemOnTrackAddParams{
GroupKey: groupKey,
LibraryID: 0,
AlbumName: "",
AlbumArtist: albumArtist,
DiscNumber: 0,
},
); err != nil {
t.Fatalf("upsert: %v", err)
}
}
albumArtist := func(t *testing.T, groupKey string) string {
t.Helper()
return scalarString(t, db,
`SELECT album_artist FROM tagging_items WHERE group_key = ?`, groupKey,
)
}
// Every contributing track agrees: the value sticks.
upsert(t, "agree", "Artist One")
upsert(t, "agree", "Artist One")
upsert(t, "agree", "Artist One")
if got := albumArtist(t, "agree"); got != "Artist One" {
t.Errorf("unanimous album_artist = %q, want %q", got, "Artist One")
}
// A later track disagrees: the value must clear, not freeze on
// whichever track was scanned first.
upsert(t, "disagree", "Artist One")
upsert(t, "disagree", "Artist Two")
upsert(t, "disagree", "Artist One")
if got := albumArtist(t, "disagree"); got != "" {
t.Errorf("disagreeing album_artist = %q, want empty (no consensus)", got)
}
// An untagged track (empty AlbumArtist) must not overwrite an
// established consensus value, nor count as disagreement.
upsert(t, "partial-tags", "Artist One")
upsert(t, "partial-tags", "")
upsert(t, "partial-tags", "Artist One")
if got := albumArtist(t, "partial-tags"); got != "Artist One" {
t.Errorf("partial-tags album_artist = %q, want %q", got, "Artist One")
}
// Once cleared by disagreement, a later untagged track must not
// resurrect a stale value.
upsert(t, "cleared-stays-cleared", "Artist One")
upsert(t, "cleared-stays-cleared", "Artist Two")
upsert(t, "cleared-stays-cleared", "")
if got := albumArtist(t, "cleared-stays-cleared"); got != "" {
t.Errorf("cleared-stays-cleared album_artist = %q, want empty", got)
}
}
func TestGetTaggingItemAndListAudioFilesInGroup(t *testing.T) {
t.Parallel()
+11
View File
@@ -41,6 +41,7 @@ const (
const (
LibraryConfigChanged = "LibraryConfigChanged"
ThemeConfigChanged = "ThemeConfigChanged"
GeneralConfigChanged = "GeneralConfigChanged"
TrackListConfigChanged = "TrackListConfigChanged"
FavoritesConfigChanged = "FavoritesConfigChanged"
ShortcutsConfigChanged = "ShortcutsConfigChanged"
@@ -153,6 +154,16 @@ const (
// the initial request having blocked on a live MusicBrainz browse.
AlbumReleasesReady = "AlbumReleasesReady"
// AlbumReleasesFailed fires (payload: release-group MBID string) when
// that same background browse returns an error, so the album page can
// say the catalog did not answer at the moment it did not answer.
//
// Without it the only signal is the absence of AlbumReleasesReady,
// which a slow browse and a failed one produce alike — leaving a
// timer to guess between them, and a page queued behind the
// prefetch's rate limiter to be reported as a failure.
AlbumReleasesFailed = "AlbumReleasesFailed"
// DownloadProvidersChanged fires after a download client is added,
// edited, enabled/disabled or removed, so the settings page and any
// open download picker re-read the provider list.
+89 -2
View File
@@ -55,6 +55,12 @@ type Service struct {
// already in flight) into one MusicBrainz browse + one
// AlbumReleasesReady event.
releasesSF singleflight.Group
// mixMu guards mix, the in-progress dynamic-mix queue-fallback
// session (see mix.go). There is only ever one — this is a
// single-user desktop app with one queue.
mixMu sync.Mutex
mix *mixSession
}
// NewExploreService creates a Service backed by the given
@@ -238,6 +244,79 @@ func (e *Service) BackfillLibraryDiscographies() {
go e.index.BackfillLibraryDiscographies(e.ctx)
}
// releaseGroupMBIDBackfillMaxPerRun bounds how many pending release
// MBIDs a single run resolves, mirroring discogBackfillMaxPerRun.
const releaseGroupMBIDBackfillMaxPerRun = 500
// BackfillReleaseGroupMBIDs resolves release groups whose scan only
// found a release-level MBID (MUSICBRAINZ_ALBUMID — many taggers write
// this instead of, or in addition to, MUSICBRAINZ_RELEASEGROUPID) into
// the release-group MBID everything else on the album page is keyed
// by. Bounded and resumable, in the background: a scan can't afford a
// live MusicBrainz call, so `library.updateMBIDs` stashes the release
// MBID in `pending_release_mbid` instead, and this is what resolves it
// — the same "defer the network call out of the scan path" shape as
// BackfillLibraryDiscographies.
func (e *Service) BackfillReleaseGroupMBIDs() {
go e.backfillReleaseGroupMBIDs(e.ctx)
}
func (e *Service) backfillReleaseGroupMBIDs(ctx context.Context) {
rows, err := e.db.QueryContext(
"SELECT id, pending_release_mbid FROM release_groups "+
"WHERE (mbid IS NULL OR mbid = '') "+
"AND pending_release_mbid IS NOT NULL AND pending_release_mbid != '' "+
"LIMIT ?",
releaseGroupMBIDBackfillMaxPerRun,
)
if err != nil {
e.logger.Warn("release-group mbid backfill: query failed", "error", err)
return
}
type pendingRow struct {
id int64
releaseMBID string
}
var pending []pendingRow
for rows.Next() {
var p pendingRow
if err := rows.Scan(&p.id, &p.releaseMBID); err == nil {
pending = append(pending, p)
}
}
_ = rows.Close()
for _, p := range pending {
if ctx.Err() != nil {
return
}
release, err := e.mb.LookupRelease(ctx, p.releaseMBID)
if err != nil || release.ReleaseGroupMBID == "" {
// Left alone rather than cleared: LookupRelease caches its
// answer (success or a release with no group) for 7 days,
// so a retry on the next run is cheap, and a future rescan
// that finds a real release-group tag still wins normally.
continue
}
_, err = e.db.ExecContext(
"UPDATE release_groups SET mbid = ?, pending_release_mbid = NULL "+
"WHERE id = ? AND (mbid IS NULL OR mbid = '')",
release.ReleaseGroupMBID, p.id,
)
if err != nil {
e.logger.Warn("release-group mbid backfill: update failed", "error", err)
}
}
}
// InvalidateLibrarySync clears the "ready" markers guarding the gated
// library-sync steps so they re-run on the next launch. Call after a
// mutation that changes owned content outside a scan (e.g. removing a
@@ -649,10 +728,18 @@ func (e *Service) ensureReleasesAsync(releaseGroupMBID string) {
go func() {
_, _, _ = e.releasesSF.Do(releaseGroupMBID, func() (any, error) {
_, err := e.mb.BrowseReleases(e.ctx, releaseGroupMBID)
if err == nil {
events.Emit(e.ctx, events.AlbumReleasesReady, releaseGroupMBID)
if err != nil {
e.logger.Warn("explore: background browse releases failed",
"releaseGroupMBID", releaseGroupMBID,
"error", err,
)
events.Emit(e.ctx, events.AlbumReleasesFailed, releaseGroupMBID)
return nil, nil
}
events.Emit(e.ctx, events.AlbumReleasesReady, releaseGroupMBID)
return nil, nil
})
}()
+242
View File
@@ -0,0 +1,242 @@
package explore
import (
"context"
"database/sql"
"math/rand/v2"
)
// mixBatchSize is how many tracks one GenerateMix call returns.
const mixBatchSize = 30
// mixGenreBoost is added to a candidate's similarity weight for each
// genre it shares with the seed, biasing the pick toward tracks that
// match on both artist and tag rather than artist alone.
const mixGenreBoost = 0.5
// mixSimilarArtistsPerSeed caps how many similar artists are expanded
// per distinct seed artist, so a seed with an unusually long tail in
// similar_artist_map doesn't turn one fallback trigger into hundreds
// of queries.
const mixSimilarArtistsPerSeed = 15
// mixSession is a dynamic mix in progress: the seed it was built from
// (fixed for the life of the session, so successive batches don't
// drift away from what the session started as) and what it has
// already handed out, so a batch doesn't repeat a track that just
// played.
type mixSession struct {
seedPaths []string
played map[string]bool
}
// GenerateMix returns the next batch of tracks for a dynamic-mix queue
// fallback, built by expanding the seed's artists to their similar
// artists (weighted by how often each appears in the seed, sharpened
// by shared genre tags) and restricting candidates to what is actually
// in the library — a queue can only play files that exist.
//
// continuing extends the current mix session — regenerating from its
// original seed rather than seedPaths — instead of starting a fresh
// one. Pass false whenever the queue that just exhausted was not
// itself a mix batch (a real selection just ran out); pass true when
// it was (the mix keeps going indefinitely). label names the batch
// after its most-represented seed artist, for the "Playing from" UI.
func (e *Service) GenerateMix(
ctx context.Context,
seedPaths []string,
continuing bool,
) (paths []string, label string, err error) {
e.mixMu.Lock()
defer e.mixMu.Unlock()
if !continuing || e.mix == nil {
e.mix = &mixSession{seedPaths: seedPaths, played: map[string]bool{}}
}
seed := e.mix.seedPaths
if len(seed) == 0 {
return nil, "", nil
}
artistCounts, topArtistName, genres := e.mixSeedProfile(ctx, seed)
if len(artistCounts) == 0 {
return nil, "", nil
}
candidates := e.mixCandidates(ctx, artistCounts, genres, seed, e.mix.played)
// The session has played through everything this seed can offer —
// rather than dead-ending an "indefinite" mix, start handing out
// repeats.
if len(candidates) == 0 && len(e.mix.played) > 0 {
candidates = e.mixCandidates(ctx, artistCounts, genres, seed, nil)
}
if len(candidates) == 0 {
return nil, "", nil
}
picked := weightedSample(candidates, mixBatchSize)
for _, p := range picked {
e.mix.played[p] = true
}
if topArtistName != "" {
label = "a mix inspired by " + topArtistName
} else {
label = "a dynamic mix"
}
return picked, label, nil
}
// mixSeedProfile tallies the seed's artists by frequency, its genre
// tags, and names the most-represented artist for the UI label.
func (e *Service) mixSeedProfile(
ctx context.Context,
seedPaths []string,
) (artistCounts map[string]int, topArtistName string, genres map[string]bool) {
artistCounts = map[string]int{}
artistNames := map[string]string{}
genres = map[string]bool{}
for _, p := range seedPaths {
artist, err := e.db.ReadQueries.GetArtistByFilePath(ctx, p)
if err == nil && artist.ArtistMbid != "" {
artistCounts[artist.ArtistMbid]++
artistNames[artist.ArtistMbid] = artist.ArtistName
}
names, err := e.db.ReadQueries.GetGenreNamesByFilePath(ctx, p)
if err == nil {
for _, g := range names {
genres[g] = true
}
}
}
var topCount int
for mbid, count := range artistCounts {
if count > topCount {
topCount = count
topArtistName = artistNames[mbid]
}
}
return artistCounts, topArtistName, genres
}
// mixCandidates builds the weighted pool of library tracks to draw a
// batch from: every owned track by a similar artist, weighted by that
// artist's similarity score times how often the seed artist it came
// from appears in the seed, boosted for a shared genre tag, excluding
// the seed itself and anything already excluded (typically what the
// mix has already played).
func (e *Service) mixCandidates(
ctx context.Context,
artistCounts map[string]int,
seedGenres map[string]bool,
seedPaths []string,
exclude map[string]bool,
) map[string]float64 {
excludeSeed := make(map[string]bool, len(seedPaths))
for _, p := range seedPaths {
excludeSeed[p] = true
}
candidates := map[string]float64{}
for seedArtistMBID, count := range artistCounts {
similar, err := e.SimilarArtists(seedArtistMBID)
if err != nil {
continue
}
if len(similar) > mixSimilarArtistsPerSeed {
similar = similar[:mixSimilarArtistsPerSeed]
}
for _, s := range similar {
if s.ArtistMBID == "" {
continue
}
paths, err := e.db.ReadQueries.GetFilePathsByArtistMBID(
ctx,
sql.NullString{String: s.ArtistMBID, Valid: true},
)
if err != nil {
continue
}
weight := s.Score * float64(count)
for _, p := range paths {
if excludeSeed[p] || exclude[p] {
continue
}
if names, err := e.db.ReadQueries.GetGenreNamesByFilePath(ctx, p); err == nil {
for _, g := range names {
if seedGenres[g] {
weight += mixGenreBoost
break
}
}
}
candidates[p] += weight
}
}
}
return candidates
}
// weightedSample picks up to n distinct keys from weights without
// replacement, biased toward higher weight (roulette-wheel selection).
// A key with zero or negative weight is never picked.
func weightedSample(weights map[string]float64, n int) []string {
type entry struct {
key string
weight float64
}
pool := make([]entry, 0, len(weights))
var total float64
for k, w := range weights {
if w <= 0 {
continue
}
pool = append(pool, entry{k, w})
total += w
}
picked := make([]string, 0, min(n, len(pool)))
for len(picked) < n && len(pool) > 0 {
r := rand.Float64() * total
idx := 0
for i, e := range pool {
r -= e.weight
if r <= 0 {
idx = i
break
}
}
picked = append(picked, pool[idx].key)
total -= pool[idx].weight
pool = append(pool[:idx], pool[idx+1:]...)
}
return picked
}
+267
View File
@@ -0,0 +1,267 @@
package explore
import (
"context"
"fmt"
"log/slog"
"testing"
"yellowjacket/backend/database"
)
// seedMixTrack inserts one owned track by the given artist (creating
// the artist/artist_credit/recording/audio_file chain as needed),
// tagged with the given genres.
func seedMixTrack(
t *testing.T,
db *database.DB,
id int,
artistName, artistMBID string,
genreNames ...string,
) string {
t.Helper()
fp := fmt.Sprintf("/music/%s/track%d.mp3", artistName, id)
_, err := db.ExecContext(
"INSERT INTO artists (id, name, mbid) VALUES (?, ?, ?) "+
"ON CONFLICT(name) DO NOTHING",
id, artistName, artistMBID,
)
if err != nil {
t.Fatalf("insert artist: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO artist_credit (id, text) VALUES (?, ?) "+
"ON CONFLICT(text) DO NOTHING",
id, artistName,
)
if err != nil {
t.Fatalf("insert artist_credit: %v", err)
}
_, err = db.ExecContext(
"INSERT OR IGNORE INTO artist_credit_artist (artist_id, credit_id) VALUES (?, ?)",
id, id,
)
if err != nil {
t.Fatalf("insert artist_credit_artist: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO recordings (id, name, artist_credit_id) VALUES (?, ?, ?)",
id, fmt.Sprintf("Track %d", id), id,
)
if err != nil {
t.Fatalf("insert recording: %v", err)
}
_, err = db.ExecContext(
"INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) "+
"VALUES (?, ?, 180000, 0, ?)",
id, fp, id,
)
if err != nil {
t.Fatalf("insert audio_file: %v", err)
}
for _, g := range genreNames {
var genreID int64
row := db.QueryRowWriter(
"INSERT INTO genres (name) VALUES (?) "+
"ON CONFLICT(name) DO UPDATE SET name = name RETURNING id",
g,
)
if err := row.Scan(&genreID); err != nil {
t.Fatalf("upsert genre %q: %v", g, err)
}
_, err = db.ExecContext(
"INSERT OR IGNORE INTO recording_genres (recording_id, genre_id) VALUES (?, ?)",
id, genreID,
)
if err != nil {
t.Fatalf("insert recording_genre: %v", err)
}
}
return fp
}
// seedSimilarArtist records a pre-computed similarity row, as the
// Tier 4 index build / lazy LB fetch would.
func seedSimilarArtist(
t *testing.T,
db *database.DB,
sourceMBID, similarMBID, similarName string,
score int,
) {
t.Helper()
_, err := db.ExecContext(
"INSERT INTO similar_artist_map "+
"(source_artist_mbid, similar_artist_mbid, similar_artist_name, score) "+
"VALUES (?, ?, ?, ?)",
sourceMBID, similarMBID, similarName, score,
)
if err != nil {
t.Fatalf("insert similar_artist_map row: %v", err)
}
}
func newMixTestService(db *database.DB) *Service {
return &Service{db: db, logger: slog.Default()}
}
func TestGenerateMix_ExpandsToSimilarLibraryArtists(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
e := newMixTestService(db)
seedPath := seedMixTrack(t, db, 1, "Seed Artist", "mbid-seed")
similarPath := seedMixTrack(t, db, 2, "Similar Artist", "mbid-similar")
unrelatedPath := seedMixTrack(t, db, 3, "Unrelated Artist", "mbid-unrelated")
seedSimilarArtist(t, db, "mbid-seed", "mbid-similar", "Similar Artist", 90)
paths, label, err := e.GenerateMix(context.Background(), []string{seedPath}, false)
if err != nil {
t.Fatalf("GenerateMix: %v", err)
}
if len(paths) != 1 || paths[0] != similarPath {
t.Errorf("paths: got %v, want [%q]", paths, similarPath)
}
for _, p := range paths {
if p == unrelatedPath {
t.Error("mix included a track by an artist with no recorded similarity")
}
}
if label == "" {
t.Error("label: got empty string, want a seed-derived label")
}
}
func TestGenerateMix_BoostsSharedGenre(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
e := newMixTestService(db)
seedPath := seedMixTrack(t, db, 1, "Seed Artist", "mbid-seed", "Shoegaze")
matchingGenrePath := seedMixTrack(t, db, 2, "Similar A", "mbid-similar-a", "Shoegaze")
differentGenrePath := seedMixTrack(t, db, 3, "Similar B", "mbid-similar-b", "Ambient")
// Same base similarity score for both, so genre is what breaks the tie.
seedSimilarArtist(t, db, "mbid-seed", "mbid-similar-a", "Similar A", 50)
seedSimilarArtist(t, db, "mbid-seed", "mbid-similar-b", "Similar B", 50)
candidates := e.mixCandidates(
context.Background(),
map[string]int{"mbid-seed": 1},
map[string]bool{"Shoegaze": true},
[]string{seedPath},
nil,
)
if candidates[matchingGenrePath] <= candidates[differentGenrePath] {
t.Errorf(
"weight: shared-genre candidate (%v) should outweigh the other (%v)",
candidates[matchingGenrePath], candidates[differentGenrePath],
)
}
}
func TestGenerateMix_ExcludesAlreadyPlayed(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
e := newMixTestService(db)
seedPath := seedMixTrack(t, db, 1, "Seed Artist", "mbid-seed")
similarPath := seedMixTrack(t, db, 2, "Similar Artist", "mbid-similar")
seedSimilarArtist(t, db, "mbid-seed", "mbid-similar", "Similar Artist", 90)
ctx := context.Background()
first, _, err := e.GenerateMix(ctx, []string{seedPath}, false)
if err != nil {
t.Fatalf("first GenerateMix: %v", err)
}
if len(first) != 1 || first[0] != similarPath {
t.Fatalf("first batch: got %v, want [%q]", first, similarPath)
}
// Continuing the same session, with nothing new to offer: rather
// than dead-ending, it should replay from the pool instead of
// returning nothing.
second, _, err := e.GenerateMix(ctx, nil, true)
if err != nil {
t.Fatalf("second GenerateMix: %v", err)
}
if len(second) != 1 || second[0] != similarPath {
t.Errorf(
"second batch: got %v, want [%q] (replayed after exhausting the pool)",
second, similarPath,
)
}
}
func TestGenerateMix_ContinuingIgnoresNewSeed(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
e := newMixTestService(db)
originalSeed := seedMixTrack(t, db, 1, "Seed Artist", "mbid-seed")
_ = seedMixTrack(t, db, 2, "Similar Artist", "mbid-similar")
unrelatedSeed := seedMixTrack(t, db, 3, "Other Artist", "mbid-other")
similarToUnrelated := seedMixTrack(t, db, 4, "Other Similar", "mbid-other-similar")
seedSimilarArtist(t, db, "mbid-seed", "mbid-similar", "Similar Artist", 90)
seedSimilarArtist(t, db, "mbid-other", "mbid-other-similar", "Other Similar", 90)
ctx := context.Background()
if _, _, err := e.GenerateMix(ctx, []string{originalSeed}, false); err != nil {
t.Fatalf("GenerateMix: %v", err)
}
// A second, unrelated seed passed while "continuing" is ignored —
// the mix stays anchored to what it started with.
paths, _, err := e.GenerateMix(ctx, []string{unrelatedSeed}, true)
if err != nil {
t.Fatalf("GenerateMix (continuing): %v", err)
}
for _, p := range paths {
if p == similarToUnrelated {
t.Error("continuing mix drifted to the newly passed seed instead of the original")
}
}
}
func TestGenerateMix_NoSeedArtistDataReturnsNothing(t *testing.T) {
t.Parallel()
db := database.NewTestDB(t)
e := newMixTestService(db)
// A file path with no matching audio_files row at all.
paths, label, err := e.GenerateMix(context.Background(), []string{"/nowhere.mp3"}, false)
if err != nil {
t.Fatalf("GenerateMix: %v", err)
}
if len(paths) != 0 || label != "" {
t.Errorf("got (%v, %q), want (nil, \"\")", paths, label)
}
}
+4
View File
@@ -623,6 +623,10 @@ func convertRelease(r musicbrainzws2.Release) MBRelease {
ArtistCredit: r.ArtistCredit.String(),
}
if r.ReleaseGroup != nil {
rel.ReleaseGroupMBID = string(r.ReleaseGroup.ID)
}
for _, m := range r.Media {
// Skip video media outright — DVD/Blu-ray bonus discs
// inflate track counts and wreck track-count-based scoring
+6
View File
@@ -88,6 +88,12 @@ type MBRelease struct {
Status string `json:"status"`
ArtistCredit string `json:"artistCredit,omitempty"`
Tracks []MBTrack `json:"tracks,omitempty"`
// ReleaseGroupMBID is the parent release group's MBID. Empty unless
// the lookup requested the "release-groups" include (LookupRelease
// does); used to resolve a release-level MBID (what many taggers
// write) back to the release-group MBID everything else on the
// album page is keyed by.
ReleaseGroupMBID string `json:"releaseGroupMbid,omitempty"`
}
// MBRecording is a Wails-friendly projection of a MusicBrainz
+252
View File
@@ -0,0 +1,252 @@
package library
import (
"testing"
)
// track is one row of release_group_recordings as the scan would write
// it: a position on a disc, and whatever total the file's tag declared
// (0 meaning the tag did not say).
type track struct {
recordingID int
disc int
number int
total int
}
// stageAlbum writes an album's tracks straight into
// release_group_recordings. The completeness query reads only that
// table, so this exercises the arithmetic without standing up a scan.
func stageAlbum(t *testing.T, lib *Library, albumID int, tracks []track) {
t.Helper()
// The foreign keys are enforced, so the album and its recordings
// have to exist before they can be linked.
if _, err := lib.db.ExecContext(
`INSERT INTO artist_credit (id, text) VALUES (1, 'Test Artist')`,
); err != nil {
t.Fatalf("staging artist credit: %v", err)
}
if _, err := lib.db.ExecContext(
`INSERT INTO release_groups (id, name, album_artist_credit_id)
VALUES (?, ?, 1)`,
albumID, "Test Album",
); err != nil {
t.Fatalf("staging album: %v", err)
}
for _, tr := range tracks {
if _, err := lib.db.ExecContext(
`INSERT INTO recordings (id, name, artist_credit_id) VALUES (?, ?, 1)`,
tr.recordingID, "Test Track",
); err != nil {
t.Fatalf("staging recording %d: %v", tr.recordingID, err)
}
}
for _, tr := range tracks {
var total any
if tr.total > 0 {
total = tr.total
}
var number any
if tr.number > 0 {
number = tr.number
}
_, err := lib.db.ExecContext(
`INSERT INTO release_group_recordings
(release_group_id, recording_id, track_number, disc_number, total_tracks)
VALUES (?, ?, ?, ?, ?)`,
albumID, tr.recordingID, number, tr.disc, total,
)
if err != nil {
t.Fatalf("staging track %d: %v", tr.recordingID, err)
}
}
}
// disc builds a run of tracks on one disc, each declaring the same
// total — which is what a correctly tagged rip looks like.
func disc(discNum, firstRecordingID, held, declared int) []track {
out := make([]track, 0, held)
for i := range held {
out = append(out, track{
recordingID: firstRecordingID + i,
disc: discNum,
number: i + 1,
total: declared,
})
}
return out
}
func TestGetAlbumCompleteness(t *testing.T) {
t.Parallel()
cases := []struct {
name string
tracks []track
wantOwned int
wantExpected int
wantKnown bool
wantComplete bool
}{
{
name: "every track present",
tracks: disc(1, 100, 12, 12),
wantOwned: 12,
wantExpected: 12,
wantKnown: true,
wantComplete: true,
},
{
name: "three tracks short",
tracks: disc(1, 200, 9, 12),
wantOwned: 9,
wantExpected: 12,
wantKnown: true,
wantComplete: false,
},
{
// A bonus track puts the folder over its declared total.
// That is a complete album, not a broken one — which is
// why Complete is >= and not ==.
name: "bonus track over the declared total",
tracks: disc(1, 300, 13, 12),
wantOwned: 13,
wantExpected: 12,
wantKnown: true,
wantComplete: true,
},
{
// The whole reason Known exists: an untagged rip declares
// no total, and a ring drawn from that would mark most of
// an untagged library incomplete on no evidence.
name: "no totals declared at all",
tracks: []track{
{recordingID: 400, disc: 1, number: 1},
{recordingID: 401, disc: 1, number: 2},
},
wantOwned: 2,
wantExpected: 0,
wantKnown: false,
wantComplete: false,
},
{
// Totals are per disc, so the expectation is a sum and not
// a single number — the bug this shape exists to catch is
// reading one disc's "10" as the whole album's.
name: "two discs, one short",
tracks: append(disc(1, 500, 10, 10), disc(2, 600, 2, 5)...),
wantOwned: 12,
wantExpected: 15,
wantKnown: true,
wantComplete: false,
},
{
name: "two discs, both complete",
tracks: append(disc(1, 700, 10, 10), disc(2, 800, 5, 5)...),
wantOwned: 15,
wantExpected: 15,
wantKnown: true,
wantComplete: true,
},
{
// One disc ripped by a tagger that wrote totals, one by a
// tagger that did not. The album's total is unknowable —
// the disc that did declare cannot stand in for the one
// that did not.
name: "one disc untotalled",
tracks: append(
disc(1, 900, 10, 10),
track{recordingID: 950, disc: 2, number: 1},
),
wantOwned: 11,
wantExpected: 10,
wantKnown: false,
wantComplete: false,
},
{
// This app detects duplicates, so it must not be fooled by
// them: two files of track 3 are one track held, and
// counting both would report a short album as complete.
name: "a duplicated track counts once",
tracks: append(
disc(1, 1000, 5, 6),
track{recordingID: 1099, disc: 1, number: 3, total: 6},
),
wantOwned: 5,
wantExpected: 6,
wantKnown: true,
wantComplete: false,
},
{
// Untotalled *and* unnumbered: the fallback keys off the
// recording id, so these must not collapse into one.
name: "unnumbered tracks stay distinct",
tracks: []track{
{recordingID: 1100, disc: 1},
{recordingID: 1101, disc: 1},
{recordingID: 1102, disc: 1},
},
wantOwned: 3,
wantExpected: 0,
wantKnown: false,
wantComplete: false,
},
}
for i, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
albumID := i + 1
stageAlbum(t, lib, albumID, tc.tracks)
got, err := lib.GetAlbumCompleteness(int64(albumID))
if err != nil {
t.Fatalf("GetAlbumCompleteness: %v", err)
}
if got.Owned != tc.wantOwned {
t.Errorf("owned = %d, want %d", got.Owned, tc.wantOwned)
}
if got.Expected != tc.wantExpected {
t.Errorf("expected = %d, want %d", got.Expected, tc.wantExpected)
}
if got.Known != tc.wantKnown {
t.Errorf("known = %v, want %v", got.Known, tc.wantKnown)
}
if got.Complete != tc.wantComplete {
t.Errorf("complete = %v, want %v", got.Complete, tc.wantComplete)
}
})
}
}
// An album with no rows at all must not read as "complete" by virtue of
// holding everything it knows about, which is nothing.
func TestGetAlbumCompleteness_EmptyAlbum(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
got, err := lib.GetAlbumCompleteness(999)
if err != nil {
t.Fatalf("GetAlbumCompleteness: %v", err)
}
if got.Known || got.Complete || got.Owned != 0 {
t.Errorf("empty album reported %+v, want zero and unknown", got)
}
}
+38
View File
@@ -0,0 +1,38 @@
package library
import "testing"
func TestIsWithinDir(t *testing.T) {
t.Parallel()
cases := []struct {
name string
path string
dir string
want bool
}{
{"root contains anything", "Artist/Album/01.mp3", ".", true},
{"root contains itself", ".", ".", true},
{"same directory", "Artist/Album", "Artist/Album", true},
{"direct child file", "Artist/Album/01.mp3", "Artist/Album", true},
{"nested subdirectory", "Artist/Album/CD1/01.mp3", "Artist/Album", true},
{"sibling not contained", "Artist/OtherAlbum", "Artist/Album", false},
{
"prefix-colliding sibling not contained",
"Artist/Album2/01.mp3", "Artist/Album",
false,
},
{"parent not contained in child", "Artist", "Artist/Album", false},
{"unrelated tree", "Other/Thing", "Artist/Album", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := isWithinDir(tc.path, tc.dir); got != tc.want {
t.Errorf("isWithinDir(%q, %q) = %v, want %v", tc.path, tc.dir, got, tc.want)
}
})
}
}
+190 -18
View File
@@ -373,6 +373,7 @@ func (l *Library) scanInternal(
workChan := make(chan scanWork, 100)
resultChan := make(chan importResult, 100)
dirDoneChan := make(chan dirClosed, 100)
var added, skipped, updated atomic.Int64
@@ -396,6 +397,24 @@ func (l *Library) scanInternal(
close(workChan)
}()
// stack tracks the directories the walk currently has open, so
// that once one is fully enumerated (see isWithinDir) its total
// scanWork count can be reported to the DB writer as a single
// dirClosed event — see the dirClosed doc comment for why.
var stack []*openDir
closeDirsNotContaining := func(path string) {
for len(stack) > 0 && !isWithinDir(path, stack[len(stack)-1].relPath) {
top := stack[len(stack)-1]
stack = stack[:len(stack)-1]
select {
case dirDoneChan <- dirClosed{dir: top.absDir, expected: top.expected}:
case <-scanCtx.Done():
}
}
}
walkErr := fs.WalkDir(
os.DirFS(basePath),
".",
@@ -409,7 +428,14 @@ func (l *Library) scanInternal(
return nil // continue walking
}
closeDirsNotContaining(path)
if d.IsDir() {
stack = append(stack, &openDir{
relPath: path,
absDir: filepath.Join(basePath, path),
})
return nil
}
@@ -467,6 +493,9 @@ func (l *Library) scanInternal(
contentChanged: contentChanged,
modTime: diskModTime,
}:
if len(stack) > 0 {
stack[len(stack)-1].expected++
}
case <-scanCtx.Done():
return scanCtx.Err()
}
@@ -510,6 +539,9 @@ func (l *Library) scanInternal(
fileType: fileType,
modTime: diskModTime,
}:
if len(stack) > 0 {
stack[len(stack)-1].expected++
}
case <-scanCtx.Done():
return scanCtx.Err()
}
@@ -526,6 +558,24 @@ func (l *Library) scanInternal(
),
)
}
// Close whatever's left on the stack, root included — the walk
// ended (normally or via cancellation) without another path
// ever coming along to trigger closeDirsNotContaining for
// these. isWithinDir treats "." (root) as containing every
// path, so closeDirsNotContaining itself can never pop it;
// unwind directly instead.
for len(stack) > 0 {
top := stack[len(stack)-1]
stack = stack[:len(stack)-1]
select {
case dirDoneChan <- dirClosed{dir: top.absDir, expected: top.expected}:
case <-scanCtx.Done():
}
}
close(dirDoneChan)
}()
// --- Thumbnail worker pool (async, decoupled from DB writer) ---
@@ -624,19 +674,98 @@ func (l *Library) scanInternal(
batch = batch[:0]
}
for result := range resultChan {
// pending buffers extracted results by directory (keyed the
// same way GroupKey's caller derives it, filepath.Dir on the
// absolute path) until that directory's dirClosed event says
// no more are coming — see the dirClosed doc comment. Only
// then can ResolveDirectoryDiscNumbers see the whole
// directory's disc tags at once instead of each file
// guessing from its own tag alone.
pending := make(map[string][]importResult)
expected := make(map[string]int)
dirClosedSeen := make(map[string]bool)
resolveAndBatch := func(dir string) {
results := pending[dir]
delete(pending, dir)
delete(expected, dir)
delete(dirClosedSeen, dir)
if len(results) == 0 {
return
}
discs := make([]int, len(results))
for i, r := range results {
if r.tags != nil {
discs[i] = r.tags.DiscNumber
}
}
resolved := autotag.ResolveDirectoryDiscNumbers(discs)
for i := range results {
if results[i].tags != nil {
results[i].tags.DiscNumber = resolved[i]
}
// Thread library ID into each result for saveAudioFile.
result.libraryID = libraryID
results[i].libraryID = libraryID
batch = append(batch, results[i])
}
if len(batch) >= scanBatchSize {
flushBatch()
}
}
rc, dc := resultChan, dirDoneChan
for rc != nil || dc != nil {
select {
case result, ok := <-rc:
if !ok {
rc = nil
continue
}
if !dbStarted {
dbStartVal = time.Now()
dbStarted = true
}
batch = append(batch, result)
if len(batch) >= scanBatchSize {
flushBatch()
dir := filepath.Dir(result.absolutePath)
pending[dir] = append(pending[dir], result)
if dirClosedSeen[dir] && len(pending[dir]) >= expected[dir] {
resolveAndBatch(dir)
}
case d, ok := <-dc:
if !ok {
dc = nil
continue
}
expected[d.dir] = d.expected
dirClosedSeen[d.dir] = true
if len(pending[d.dir]) >= d.expected {
resolveAndBatch(d.dir)
}
}
}
// Anything still buffered here belongs to a directory whose
// expected count was never reached — an extraction failure
// (see Phase 3: a failed file is warned-and-dropped, never
// reaching resultChan) or a dirClosed event lost to
// cancellation. Flush it anyway so no extracted file is
// silently dropped; it just resolves from whatever subset of
// the directory's disc tags actually arrived.
for dir := range pending {
resolveAndBatch(dir)
}
flushBatch()
@@ -1207,6 +1336,43 @@ type importResult struct {
modTime int64 // mtime baseline to persist (Unix seconds)
}
// dirClosed reports that the walk has fully enumerated a directory's
// audio files and will never enqueue another scanWork for it — expected
// is exactly how many scanWork items were sent for it. The DB writer
// uses this to know when it has every file it's going to get for that
// directory, so it can resolve disc-number consensus across the whole
// directory (autotag.ResolveDirectoryDiscNumbers) instead of each file
// guessing in isolation.
type dirClosed struct {
dir string
expected int
}
// openDir is one frame of the walk goroutine's directory stack — see
// isWithinDir and its use in scanInternal's walk phase. relPath is
// the fs.WalkDir-relative path (slash-separated, root as "."), used
// only to detect when the walk has moved on to something outside this
// directory; absDir is the OS-native absolute path, which is what
// dirClosed reports and what the DB writer's importResult.absolutePath
// values key against via filepath.Dir.
type openDir struct {
relPath string
absDir string
expected int
}
// isWithinDir reports whether the fs.WalkDir-relative path is dir
// itself or something inside it. dir == "." (the library root) is
// always within, since fs.WalkDir's root path is "." and nothing on
// this stack can ever be outside the tree being walked.
func isWithinDir(path, dir string) bool {
if dir == "." {
return true
}
return path == dir || strings.HasPrefix(path, dir+"/")
}
// extractAudioMetadata reads and extracts metadata from an audio file.
// It opens the file once, extracting both tags and duration in a
// single pass, and records per-file timing in the shared metrics.
@@ -1428,7 +1594,7 @@ func (l *Library) saveAudioFile(
GroupKey: groupKey,
LibraryID: result.libraryID,
AlbumName: tags.Album,
AlbumArtist: resolveAlbumArtistName(tags),
AlbumArtist: tags.AlbumArtist,
DiscNumber: int64(tags.DiscNumber),
},
); err != nil {
@@ -1669,6 +1835,7 @@ func (l *Library) processMetadata(
RecordingID: recording.ID,
TrackNumber: toNullInt64(tags.TrackNumber),
DiscNumber: toNullInt64(tags.DiscNumber),
TotalTracks: toNullInt64(tags.TotalTracks),
},
)
if err != nil {
@@ -1718,6 +1885,22 @@ func (l *Library) updateMBIDs(
"UPDATE release_groups SET mbid = ? WHERE id = ? AND (mbid IS NULL OR mbid = '')",
tags.ReleaseGroupMBID, releaseGroupID,
)
} else if tags.ReleaseMBID != "" && releaseGroupID > 0 {
// Many taggers write MUSICBRAINZ_ALBUMID (a specific release)
// but not MUSICBRAINZ_RELEASEGROUPID (the abstract release
// group everything else on this page is keyed by) — without
// this, a genuinely MBID-tagged album shows as "library only"
// forever. A scan can't afford a live MusicBrainz call to
// resolve release->release-group here, so the release MBID is
// stashed for `explore.Service.BackfillReleaseGroupMBIDs` to
// resolve in the background, the same way discography
// enrichment is deferred out of the scan path.
_, _ = tx.ExecContext(l.ctx,
"UPDATE release_groups SET pending_release_mbid = ? "+
"WHERE id = ? AND (mbid IS NULL OR mbid = '') "+
"AND (pending_release_mbid IS NULL OR pending_release_mbid = '')",
tags.ReleaseMBID, releaseGroupID,
)
}
// Recording MBID.
@@ -1729,17 +1912,6 @@ func (l *Library) updateMBIDs(
}
}
// resolveAlbumArtistName returns the album-artist tag for tagging-
// group bookkeeping, falling back to the track artist when the
// album-artist field is empty.
func resolveAlbumArtistName(tags *metadata.TrackMetadata) string {
if tags.AlbumArtist != "" {
return tags.AlbumArtist
}
return tags.Artist
}
// maybeRebindTaggingGroup recomputes the group key from the freshly
// extracted metadata and, if it differs from the row's current
// group_key, migrates the track: decrement the old group's count
@@ -1780,7 +1952,7 @@ func (l *Library) maybeRebindTaggingGroup(
GroupKey: newKey,
LibraryID: result.libraryID,
AlbumName: tags.Album,
AlbumArtist: resolveAlbumArtistName(tags),
AlbumArtist: tags.AlbumArtist,
DiscNumber: int64(tags.DiscNumber),
},
); err != nil {
+51
View File
@@ -302,6 +302,57 @@ func (l *Library) SearchTracks(
return tracks, nil
}
// AlbumCompleteness says how much of an album is present, as the files
// themselves claim.
//
// Known is the part that matters: a tag that never declared a total is
// not the same as a total that is unmet, and rendering the two alike
// would put an "incomplete" mark on most of an untagged library. When
// Known is false, Expected means nothing and the caller must say
// nothing.
type AlbumCompleteness struct {
Owned int `json:"owned"`
Expected int `json:"expected"`
Known bool `json:"known"`
Complete bool `json:"complete"`
}
// GetAlbumCompleteness answers "do I have all of this album" from the
// tags read at scan time, with no network.
//
// The album page used to ask MusicBrainz, because the only track total
// it had was the length of whatever tracklist it was already showing —
// which for a library copy is a tautology. The denominator in a file's
// "5/12" is a real answer and it is already on disk; this is where it
// gets read.
//
// Complete is deliberately >= rather than ==: bonus and hidden tracks
// routinely put a folder over its declared total, and that is a
// complete album, not a broken one.
func (l *Library) GetAlbumCompleteness(albumID int64) (AlbumCompleteness, error) {
row, err := l.db.ReadQueries.GetAlbumCompleteness(l.ctx, albumID)
if err != nil {
l.logger.Error("could not read album completeness",
"albumID", albumID, "error", err,
)
return AlbumCompleteness{}, fmt.Errorf(
"could not get album completeness: %w", err,
)
}
// A disc whose files all declared nothing leaves the album's total
// unknowable — the discs that did declare cannot stand in for it.
known := row.DiscsUntotalled == 0 && row.Expected > 0
return AlbumCompleteness{
Owned: int(row.Owned),
Expected: int(row.Expected),
Known: known,
Complete: known && row.Owned >= row.Expected,
}, nil
}
// GetAlbumTracks returns all tracks for a given album (release group), ordered by disc and track number.
func (l *Library) GetAlbumTracks(albumID int64) ([]Track, error) {
rows, err := l.db.ReadQueries.GetAudioFilesByReleaseGroup(l.ctx, albumID)
+233
View File
@@ -0,0 +1,233 @@
package library
import (
"io"
"log/slog"
"os"
"path/filepath"
"testing"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/tagwriter"
"yellowjacket/internal/testfixtures"
)
// copyFile copies an untagged real MP3 fixture (decodable, so
// metadata extraction and duration decoding both work exactly as
// they would on a real library file) to path.
func copyFile(t *testing.T, src, dst string) {
t.Helper()
in, err := os.Open(src)
if err != nil {
t.Fatalf("open fixture %s: %v", src, err)
}
defer func() { _ = in.Close() }()
out, err := os.Create(dst)
if err != nil {
t.Fatalf("create %s: %v", dst, err)
}
defer func() { _ = out.Close() }()
if _, err := io.Copy(out, in); err != nil {
t.Fatalf("copy fixture to %s: %v", dst, err)
}
}
// writeTestTrack copies a real, untagged MP3 fixture to path and,
// when discNumber is non-zero, stamps a disc-number tag onto it via
// the same tagwriter path the app itself uses to write tags — a
// discNumber of 0 leaves the file untagged, exactly like a track
// whose disc frame was never set.
func writeTestTrack(t *testing.T, path string, discNumber int) {
t.Helper()
m := testfixtures.Load(t)
blank := m.Abs("unsorted/no-tags-at-all.mp3")
copyFile(t, blank, path)
if discNumber == 0 {
return
}
if err := tagwriter.WriteFileTags(
slog.Default(), path,
tagwriter.TagChanges{tagwriter.FieldDiscNumber: discNumber},
); err != nil {
t.Fatalf("write disc tag on %s: %v", path, err)
}
}
// scanTestGroupKeys creates a library row at root, runs a real
// synchronous scan of it, and returns the group_key each resulting
// audio_files row landed on, keyed by absolute file path.
func scanTestGroupKeys(t *testing.T, lib *Library, root string) map[string]string {
t.Helper()
library, err := lib.db.Queries.CreateLibrary(lib.ctx, sqlcgen.CreateLibraryParams{
Name: root,
Path: root,
})
if err != nil {
t.Fatalf("create library: %v", err)
}
metrics := lib.scanInternal(library.ID, library.Name, library.Path)
if metrics == nil {
t.Fatal("scanInternal returned nil metrics")
}
rows, err := lib.db.Queries.GetAudioFilesByLibrary(lib.ctx, library.ID)
if err != nil {
t.Fatalf("list audio files: %v", err)
}
got := make(map[string]string, len(rows))
for _, r := range rows {
got[r.FilePath] = r.GroupKey
}
return got
}
// TestScan_PartialDiscTaggingWithinOneFolderDoesNotFragment guards the
// fix for a real-world bug: a folder where only some tracks carry an
// explicit disc tag (common when files were ripped or re-tagged at
// different times) must not split into two tagging groups for what is
// really one single-disc album. Before directory-batched disc
// resolution, each file resolved its own group_key from only its own
// tag, so an untagged track always folded to disc 1 regardless of
// what its siblings said — fragmenting a real disc 2 whenever even one
// of its tracks lacked the tag.
func TestScan_PartialDiscTaggingWithinOneFolderDoesNotFragment(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
root := t.TempDir()
dir := filepath.Join(root, "Artist", "Album")
if err := os.MkdirAll(dir, 0o750); err != nil {
t.Fatalf("mkdir: %v", err)
}
track1 := filepath.Join(dir, "01.mp3")
track2 := filepath.Join(dir, "02.mp3")
track3 := filepath.Join(dir, "03.mp3")
writeTestTrack(t, track1, 2) // explicit disc 2
writeTestTrack(t, track2, 0) // untagged
writeTestTrack(t, track3, 2) // explicit disc 2
keys := scanTestGroupKeys(t, lib, root)
if len(keys) != 3 { //nolint:mnd
t.Fatalf("expected 3 audio files, got %d: %+v", len(keys), keys)
}
if keys[track1] != keys[track2] || keys[track1] != keys[track3] {
t.Errorf(
"expected all three tracks to share one group_key, got %+v",
keys,
)
}
}
// TestScan_GenuineMultiDiscFolderStillSplits is the flip side of the
// partial-tagging fix: a folder with no per-disc subfolders where the
// explicit disc tags genuinely disagree (a real two-disc release
// dumped flat) must still separate into two groups — directory-wide
// consensus must not paper over an actual multi-disc release just
// because it shares one directory.
func TestScan_GenuineMultiDiscFolderStillSplits(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
root := t.TempDir()
dir := filepath.Join(root, "Artist", "Album")
if err := os.MkdirAll(dir, 0o750); err != nil {
t.Fatalf("mkdir: %v", err)
}
disc1TrackA := filepath.Join(dir, "1-01.mp3")
disc1TrackB := filepath.Join(dir, "1-02.mp3")
disc2TrackA := filepath.Join(dir, "2-01.mp3")
disc2TrackB := filepath.Join(dir, "2-02.mp3")
writeTestTrack(t, disc1TrackA, 1)
writeTestTrack(t, disc1TrackB, 1)
writeTestTrack(t, disc2TrackA, 2) //nolint:mnd
writeTestTrack(t, disc2TrackB, 2) //nolint:mnd
keys := scanTestGroupKeys(t, lib, root)
if len(keys) != 4 { //nolint:mnd
t.Fatalf("expected 4 audio files, got %d: %+v", len(keys), keys)
}
if keys[disc1TrackA] != keys[disc1TrackB] {
t.Errorf("disc 1 tracks should share a group_key, got %+v", keys)
}
if keys[disc2TrackA] != keys[disc2TrackB] {
t.Errorf("disc 2 tracks should share a group_key, got %+v", keys)
}
if keys[disc1TrackA] == keys[disc2TrackA] {
t.Errorf("disc 1 and disc 2 must not share a group_key, got %+v", keys)
}
}
// TestScan_MultipleDirectoriesDoNotCrossContaminate scans two
// unrelated folders — one partially disc-tagged, one fully untagged —
// in a single pass, guarding against the directory-batching buffer in
// the DB writer mixing up which files belong to which directory.
func TestScan_MultipleDirectoriesDoNotCrossContaminate(t *testing.T) {
t.Parallel()
lib, _ := setupTestLibrary(t)
root := t.TempDir()
albumA := filepath.Join(root, "Artist", "Album A")
albumB := filepath.Join(root, "Artist", "Album B")
for _, d := range []string{albumA, albumB} {
if err := os.MkdirAll(d, 0o750); err != nil {
t.Fatalf("mkdir: %v", err)
}
}
aTrack1 := filepath.Join(albumA, "01.mp3")
aTrack2 := filepath.Join(albumA, "02.mp3")
bTrack1 := filepath.Join(albumB, "01.mp3")
bTrack2 := filepath.Join(albumB, "02.mp3")
writeTestTrack(t, aTrack1, 2) //nolint:mnd
writeTestTrack(t, aTrack2, 0)
writeTestTrack(t, bTrack1, 0)
writeTestTrack(t, bTrack2, 0)
keys := scanTestGroupKeys(t, lib, root)
if len(keys) != 4 { //nolint:mnd
t.Fatalf("expected 4 audio files, got %d: %+v", len(keys), keys)
}
if keys[aTrack1] != keys[aTrack2] {
t.Errorf("Album A's two tracks should share a group_key: %+v", keys)
}
if keys[bTrack1] != keys[bTrack2] {
t.Errorf("Album B's two tracks should share a group_key: %+v", keys)
}
if keys[aTrack1] == keys[bTrack1] {
t.Errorf("Album A and Album B must not share a group_key: %+v", keys)
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ func (q *Queue) emitQueueChanged() {
CurrentIndex: q.currentIndex,
ShuffleMode: q.shuffleMode,
RepeatMode: q.repeatMode,
SourcePlaylistID: q.sourcePlaylistID,
Source: q.source,
}
// Ensure tracks is never nil in JSON.
+6 -6
View File
@@ -78,7 +78,7 @@ func TestEmit_SetQueuePushesFullState(t *testing.T) {
q, db, rec := setupRecordedQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 2, false)
q.SetQueue(paths, 2, false, Source{})
if _, ok := rec.Wait(events.QueueChanged, waitFor); !ok {
t.Fatalf("no QueueChanged after SetQueue; got %v", rec.Names())
@@ -103,7 +103,7 @@ func TestEmit_ClearSendsEmptyNotNilTrackList(t *testing.T) {
q, db, rec := setupRecordedQueue(t)
paths := seedAudioFiles(t, db, 3)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
rec.Reset()
q.Clear()
@@ -156,7 +156,7 @@ func TestEmit_ToggleShuffleReportsBothModes(t *testing.T) {
t.Parallel()
q, db, rec := setupRecordedQueue(t)
q.SetQueue(seedAudioFiles(t, db, 5), 0, false)
q.SetQueue(seedAudioFiles(t, db, 5), 0, false, Source{})
rec.Reset()
q.ToggleShuffle()
@@ -190,7 +190,7 @@ func TestEmit_AddTrackSendsDeltaNotSnapshot(t *testing.T) {
q, db, rec := setupRecordedQueue(t)
paths := seedAudioFiles(t, db, 4)
q.SetQueue(paths[:3], 0, false)
q.SetQueue(paths[:3], 0, false, Source{})
if _, ok := rec.Wait(events.QueueChanged, waitFor); !ok {
t.Fatalf("no QueueChanged after SetQueue; got %v", rec.Names())
@@ -223,7 +223,7 @@ func TestEmit_RemoveTracksReportsPositions(t *testing.T) {
q, db, rec := setupRecordedQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
if _, ok := rec.Wait(events.QueueChanged, waitFor); !ok {
t.Fatalf("no QueueChanged after SetQueue; got %v", rec.Names())
@@ -251,7 +251,7 @@ func TestEmit_NextPushesIndexOnly(t *testing.T) {
q, db, rec := setupRecordedQueue(t)
paths := seedAudioFiles(t, db, 3)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
if _, ok := rec.Wait(events.QueueChanged, waitFor); !ok {
t.Fatalf("no QueueChanged after SetQueue; got %v", rec.Names())
+213
View File
@@ -0,0 +1,213 @@
package queue
import (
"context"
"sync"
"testing"
"time"
)
// fakeFallbackSource records every call and returns whatever was
// configured, optionally gated by a channel so a test can control
// exactly when resolution completes (to exercise the staleness check).
type fakeFallbackSource struct {
mu sync.Mutex
calls []FallbackContext
paths []string
source Source
err error
// gate, if set, blocks ResolveFallback until closed.
gate chan struct{}
}
func (f *fakeFallbackSource) ResolveFallback(
_ context.Context,
fctx FallbackContext,
) ([]string, Source, error) {
if f.gate != nil {
<-f.gate
}
f.mu.Lock()
f.calls = append(f.calls, fctx)
f.mu.Unlock()
return f.paths, f.source, f.err
}
func (f *fakeFallbackSource) callCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.calls)
}
func (f *fakeFallbackSource) lastContext() FallbackContext {
f.mu.Lock()
defer f.mu.Unlock()
return f.calls[len(f.calls)-1]
}
// waitUntil polls cond until it's true or fails the test after a
// short deadline, naming what it was waiting for on timeout.
func waitUntil(t *testing.T, cond func() bool, what string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for: %s", what)
}
func TestFallback_TriggersOnNaturalFinish(t *testing.T) {
t.Parallel()
q, db := setupTestQueue(t)
seedPaths := seedAudioFiles(t, db, 1)
fallbackPaths := seedAudioFiles(t, db, 6)[1:] // distinct from seed
fake := &fakeFallbackSource{
paths: fallbackPaths,
source: Source{Type: "playlist", ID: 9, Label: "Favorites"},
}
q.SetFallbackSource(fake)
q.SetQueue(seedPaths, 0, false, Source{Type: "album", ID: 1, Label: "Seed Album"})
q.OnPlaybackFinished()
waitUntil(t, func() bool { return fake.callCount() == 1 }, "fallback to be resolved")
waitUntil(t, func() bool {
return q.GetState().Source == fake.source
}, "queue to adopt the fallback source")
state := q.GetState()
if got := len(state.Tracks); got != len(fallbackPaths) {
t.Errorf("track count: got %d, want %d", got, len(fallbackPaths))
}
ctx := fake.lastContext()
if ctx.PreviousSource.Type != "album" {
t.Errorf("previous source type: got %q, want %q", ctx.PreviousSource.Type, "album")
}
if len(ctx.SeedPaths) != 1 || ctx.SeedPaths[0] != seedPaths[0] {
t.Errorf("seed paths: got %v, want %v", ctx.SeedPaths, seedPaths)
}
}
func TestFallback_TriggersOnNextPastEnd(t *testing.T) {
t.Parallel()
q, db := setupTestQueue(t)
seedPaths := seedAudioFiles(t, db, 1)
fallbackPaths := seedAudioFiles(t, db, 6)[1:]
fake := &fakeFallbackSource{
paths: fallbackPaths,
source: Source{Type: "dynamicMix", Label: "a mix"},
}
q.SetFallbackSource(fake)
q.SetQueue(seedPaths, 0, false, Source{})
q.Next() // already at the only/last track: exhausts
waitUntil(t, func() bool { return fake.callCount() == 1 }, "fallback to be resolved")
waitUntil(t, func() bool {
return len(q.GetState().Tracks) == len(fallbackPaths)
}, "queue to adopt the fallback tracks")
}
func TestFallback_TriggersOnCurrentTrackRemoved(t *testing.T) {
t.Parallel()
q, db := setupTestQueue(t)
seedPaths := seedAudioFiles(t, db, 1)
fallbackPaths := seedAudioFiles(t, db, 6)[1:]
fake := &fakeFallbackSource{
paths: fallbackPaths,
source: Source{Type: "playlist", Label: "Favorites"},
}
q.SetFallbackSource(fake)
q.SetQueue(seedPaths, 0, false, Source{})
q.RemoveTrack(0) // removes the only (currently playing) track
waitUntil(t, func() bool { return fake.callCount() == 1 }, "fallback to be resolved")
waitUntil(t, func() bool {
return len(q.GetState().Tracks) == len(fallbackPaths)
}, "queue to adopt the fallback tracks")
}
func TestFallback_EmptyResultLeavesQueueExhausted(t *testing.T) {
t.Parallel()
q, db := setupTestQueue(t)
seedPaths := seedAudioFiles(t, db, 1)
fake := &fakeFallbackSource{paths: nil, source: Source{}} // "stop"
q.SetFallbackSource(fake)
q.SetQueue(seedPaths, 0, false, Source{})
q.OnPlaybackFinished()
waitUntil(t, func() bool { return fake.callCount() == 1 }, "fallback to be resolved")
// Give a wrongly-applied fallback a moment to (not) land.
time.Sleep(20 * time.Millisecond)
state := q.GetState()
if len(state.Tracks) != 1 {
t.Errorf("track count: got %d, want 1 (queue unchanged)", len(state.Tracks))
}
if state.CurrentIndex != -1 {
t.Errorf("currentIndex: got %d, want -1 (still exhausted)", state.CurrentIndex)
}
}
func TestFallback_StaleResolutionDiscarded(t *testing.T) {
t.Parallel()
q, db := setupTestQueue(t)
seedPaths := seedAudioFiles(t, db, 1)
stalePaths := seedAudioFiles(t, db, 6)[1:4]
freshPaths := seedAudioFiles(t, db, 9)[6:9]
gate := make(chan struct{})
fake := &fakeFallbackSource{
paths: stalePaths,
source: Source{Type: "dynamicMix", Label: "stale"},
gate: gate,
}
q.SetFallbackSource(fake)
q.SetQueue(seedPaths, 0, false, Source{})
q.OnPlaybackFinished() // starts resolving, blocked on gate
time.Sleep(20 * time.Millisecond) // let the goroutine reach the gate
// Something else claims the queue before the stale resolution lands.
q.SetQueue(freshPaths, 0, false, Source{Type: "album", Label: "fresh"})
close(gate) // let the stale resolution finish and try to apply
waitUntil(t, func() bool {
state := q.GetState()
if state.Source.Label == "stale" {
t.Fatal("stale fallback was applied")
}
return state.Source.Label == "fresh"
}, "the fresh queue to survive the stale fallback")
}
+7 -11
View File
@@ -369,22 +369,16 @@ func (q *Queue) persistState() {
}
}
sourcePlaylistID := sql.NullInt64{}
if q.sourcePlaylistID > 0 {
sourcePlaylistID = sql.NullInt64{
Int64: q.sourcePlaylistID,
Valid: true,
}
}
err := q.db.Queries.UpdateQueueState(
q.db.Ctx,
sqlcgen.UpdateQueueStateParams{
SourcePlaylistID: sourcePlaylistID,
CurrentPosition: int64(q.currentIndex),
ShuffleMode: q.shuffleMode,
RepeatMode: string(q.repeatMode),
ShuffleOrder: shuffleOrderJSON,
SourceType: q.source.Type,
SourceID: q.source.ID,
SourceLabel: q.source.Label,
},
)
if err != nil {
@@ -426,8 +420,10 @@ func (q *Queue) RestoreState() {
q.shuffleMode = state.ShuffleMode
q.repeatMode = RepeatMode(state.RepeatMode)
if state.SourcePlaylistID.Valid {
q.sourcePlaylistID = state.SourcePlaylistID.Int64
q.source = Source{
Type: state.SourceType,
ID: state.SourceID,
Label: state.SourceLabel,
}
// Restore shuffle order.
+10 -5
View File
@@ -11,7 +11,7 @@ func TestSaveState_RestoreState_Roundtrip(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 2, false)
q.SetQueue(paths, 2, false, Source{Type: "album", ID: 7, Label: "Abbey Road"})
// Change modes so we test all fields.
q.CycleRepeat() // off -> all
@@ -68,6 +68,11 @@ func TestSaveState_RestoreState_Roundtrip(t *testing.T) {
t.Errorf("repeatMode: got %q, want %q", s2.RepeatMode, s1.RepeatMode)
}
// Source.
if s2.Source != s1.Source {
t.Errorf("source: got %+v, want %+v", s2.Source, s1.Source)
}
// ShuffleOrder.
q.mu.Lock()
q2.mu.Lock()
@@ -113,7 +118,7 @@ func TestSaveState_RestoreState_SingleTrack(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 1)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
q.SaveState()
q2 := NewQueue(slog.Default(), db)
@@ -140,7 +145,7 @@ func TestSaveState_RestoreState_PreservesTrackOrder(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 10)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
q.SaveState()
q2 := NewQueue(slog.Default(), db)
@@ -182,11 +187,11 @@ func TestSaveState_OverwritesPreviousState(t *testing.T) {
paths := seedAudioFiles(t, db, 8)
// First save: 5 tracks.
q.SetQueue(paths[:5], 0, false)
q.SetQueue(paths[:5], 0, false, Source{})
q.SaveState()
// Second save: 3 different tracks.
q.SetQueue(paths[5:8], 0, false)
q.SetQueue(paths[5:8], 0, false, Source{})
q.SaveState()
q2 := NewQueue(slog.Default(), db)
+6 -6
View File
@@ -76,7 +76,7 @@ func TestPlaybackFailed_EmittedForAMissingFile(t *testing.T) {
paths := seedAudioFiles(t, db, 3)
loader.fails[paths[1]] = true
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
q.PlayIndex(1)
failure := failureOf(t, rec)
@@ -100,7 +100,7 @@ func TestPlaybackFailed_AutoAdvanceSkipsPastIt(t *testing.T) {
paths := seedAudioFiles(t, db, 3)
loader.fails[paths[1]] = true
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
q.Play()
// The first track finished: auto-advance lands on the missing
@@ -124,7 +124,7 @@ func TestPlaybackFailed_NextSkipsPastIt(t *testing.T) {
loader.fails[paths[1]] = true
loader.fails[paths[2]] = true
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
q.Next()
if got := q.GetState().CurrentIndex; got != 3 {
@@ -145,7 +145,7 @@ func TestPlaybackFailed_WholeQueueUnplayableStopsOnce(t *testing.T) {
loader.fails[p] = true
}
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
q.repeatMode = RepeatAll
rec.Reset()
@@ -179,7 +179,7 @@ func TestQueueExhausted_KeepsTheFinishedTrackLoaded(t *testing.T) {
q, db, _, loader := setupFailingQueue(t)
paths := seedAudioFiles(t, db, 1)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
q.Play()
q.OnPlaybackFinished()
@@ -203,7 +203,7 @@ func TestQueueExhausted_UnloadsWhenTheTrackIsRemoved(t *testing.T) {
q, db, _, loader := setupFailingQueue(t)
paths := seedAudioFiles(t, db, 1)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
q.RemoveTrack(0)
// Nothing left to show, so the bar clears.
+2 -2
View File
@@ -23,7 +23,7 @@ func TestRecordPlay_EmitsPlayCountNotMetadataChanged(t *testing.T) {
q, db, rec := setupRecordedQueue(t)
paths := seedAudioFiles(t, db, 2)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
rec.Reset()
q.recordPlay(1)
@@ -77,7 +77,7 @@ func TestRecordPlay_ReportsTheStoredCount(t *testing.T) {
q, db, rec := setupRecordedQueue(t)
paths := seedAudioFiles(t, db, 1)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
rec.Reset()
q.recordPlay(1)
+100 -5
View File
@@ -78,6 +78,26 @@ type TrackLoader interface {
UnloadTrack()
}
// FallbackSource resolves what should auto-play, if anything, once the
// queue is exhausted. Implemented outside this package (see app.go) so
// the queue does not need to know about config, playlists or
// similarity data.
type FallbackSource interface {
// ResolveFallback returns the tracks to auto-play next, or an empty
// slice if the configured mode is "stop" or nothing is available.
ResolveFallback(ctx context.Context, fctx FallbackContext) ([]string, Source, error)
}
// FallbackContext is what a FallbackSource needs to decide whether to
// continue an existing fallback (a dynamic mix keeps extending itself)
// or resolve fresh.
type FallbackContext struct {
// PreviousSource is the source of the queue that just exhausted.
PreviousSource Source
// SeedPaths are that queue's track paths, in order.
SeedPaths []string
}
// Track represents a track in the queue with its metadata.
type Track struct {
ID int64 `json:"id"`
@@ -99,7 +119,17 @@ type State struct {
CurrentIndex int `json:"currentIndex"`
ShuffleMode bool `json:"shuffleMode"`
RepeatMode RepeatMode `json:"repeatMode"`
SourcePlaylistID int64 `json:"sourcePlaylistId"`
Source Source `json:"source"`
}
// Source describes the collection a queue was built from — an album, a
// playlist, a genre, an artist — so the frontend can offer to navigate
// back to it. An empty Type means the queue has no single source (the
// whole library, or one ad-hoc track).
type Source struct {
Type string `json:"type"`
ID int64 `json:"id"`
Label string `json:"label"`
}
// IndexChanged is the payload for the QueueIndexChanged event.
@@ -138,6 +168,7 @@ type Queue struct {
logger *slog.Logger
db *database.DB
player TrackLoader
fallbackSource FallbackSource
mu sync.Mutex
tracks []Track
@@ -145,7 +176,7 @@ type Queue struct {
shuffleMode bool
repeatMode RepeatMode
shuffleOrder []int
sourcePlaylistID int64
source Source
// setQueueGen is incremented each time SetQueue is called. Background
// goroutines check this to detect if they have been superseded.
@@ -174,6 +205,13 @@ func (q *Queue) SetPlayer(player TrackLoader) {
q.player = player
}
// SetFallbackSource provides the queue with what to auto-play, if
// anything, once it runs out. A nil source (the default) leaves
// today's behavior: the queue just goes idle.
func (q *Queue) SetFallbackSource(fs FallbackSource) {
q.fallbackSource = fs
}
// SetQueue replaces the entire queue with new tracks and starts playing.
// When shuffleStart is true and shuffle mode is active, a random first
// track is chosen instead of the one at startIndex. This is intended for
@@ -187,6 +225,7 @@ func (q *Queue) SetQueue(
filePaths []string,
startIndex int,
shuffleStart bool,
source Source,
) {
defer profiling.TimeOp(q.logger, "queue.SetQueue")()
@@ -228,7 +267,7 @@ func (q *Queue) SetQueue(
}
q.tracks = tracks
q.sourcePlaylistID = 0
q.source = source
q.shuffleOrder = nil
// Find the start track within the initial batch.
@@ -1139,7 +1178,7 @@ func (q *Queue) GetState() State {
CurrentIndex: q.currentIndex,
ShuffleMode: q.shuffleMode,
RepeatMode: q.repeatMode,
SourcePlaylistID: q.sourcePlaylistID,
Source: q.source,
}
}
@@ -1155,7 +1194,7 @@ func (q *Queue) Clear() {
q.tracks = nil
q.currentIndex = -1
q.shuffleOrder = nil
q.sourcePlaylistID = 0
q.source = Source{}
if q.player != nil {
q.player.UnloadTrack()
@@ -1301,6 +1340,11 @@ func (q *Queue) handleCurrentTrackRemoved() {
// bar blanking while the queue panel still lists what just played
// (H-18). When the current track was removed from the queue, or the
// queue was cleared, there is nothing left to show and it does.
//
// Called with q.mu already held by every caller — so the fallback
// playlist (if any) is only kicked off here, not resolved: resolving
// one can mean library/similarity queries, which must not run under
// this lock. See resolveFallback.
func (q *Queue) onQueueExhausted(unload bool) {
q.logger.Info("Queue exhausted", "unload", unload)
@@ -1312,6 +1356,57 @@ func (q *Queue) onQueueExhausted(unload bool) {
q.emitIndexChanged()
q.persistState()
if q.fallbackSource != nil {
prevSource := q.source
seedPaths := pathsOf(q.tracks)
gen := q.setQueueGen.Add(1)
go q.resolveFallback(gen, prevSource, seedPaths)
}
}
// resolveFallback runs outside q.mu — the fallback source may do
// library/similarity lookups — and, if it finds something, replaces
// the queue via the ordinary SetQueue path. gen guards against a user
// starting something else (or another exhaustion) while this was
// still resolving: SetQueue itself bumps setQueueGen again, so a stale
// result here is simply discarded.
func (q *Queue) resolveFallback(
gen int64,
prevSource Source,
seedPaths []string,
) {
paths, source, err := q.fallbackSource.ResolveFallback(
q.ctx,
FallbackContext{PreviousSource: prevSource, SeedPaths: seedPaths},
)
if err != nil {
q.logger.Error("Failed to resolve fallback playlist", "err", err)
return
}
if len(paths) == 0 {
return
}
if q.setQueueGen.Load() != gen {
return
}
q.SetQueue(paths, 0, false, source)
}
// pathsOf returns the file paths of a track list, in order.
func pathsOf(tracks []Track) []string {
paths := make([]string, len(tracks))
for i, t := range tracks {
paths[i] = t.FilePath
}
return paths
}
// CompactAfterLibraryRemoval reloads queue state from the database
+56 -13
View File
@@ -88,7 +88,7 @@ func TestSetQueue_PopulatesTracks(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
state := q.GetState()
if got := len(state.Tracks); got != 5 {
@@ -100,13 +100,56 @@ func TestSetQueue_PopulatesTracks(t *testing.T) {
}
}
func TestSetQueue_RecordsSource(t *testing.T) {
t.Parallel()
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
source := Source{Type: "playlist", ID: 42, Label: "Road Trip"}
q.SetQueue(paths, 0, false, source)
if got := q.GetState().Source; got != source {
t.Errorf("source: got %+v, want %+v", got, source)
}
}
func TestSetQueue_ReplacesPriorSource(t *testing.T) {
t.Parallel()
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 0, false, Source{Type: "album", ID: 1, Label: "First"})
q.SetQueue(paths, 0, false, Source{Type: "genre", Label: "Jazz"})
want := Source{Type: "genre", Label: "Jazz"}
if got := q.GetState().Source; got != want {
t.Errorf("source: got %+v, want %+v", got, want)
}
}
func TestClear_ResetsSource(t *testing.T) {
t.Parallel()
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 0, false, Source{Type: "album", ID: 1, Label: "Some Album"})
q.Clear()
if got := q.GetState().Source; got != (Source{}) {
t.Errorf("source after Clear: got %+v, want zero value", got)
}
}
func TestSetQueue_WithStartIndex(t *testing.T) {
t.Parallel()
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 2, false)
q.SetQueue(paths, 2, false, Source{})
state := q.GetState()
if state.CurrentIndex != 2 {
@@ -123,7 +166,7 @@ func TestSetQueue_WithShuffleStart(t *testing.T) {
// Enable shuffle mode first.
q.ToggleShuffle()
q.SetQueue(paths, 0, true)
q.SetQueue(paths, 0, true, Source{})
state := q.GetState()
if !state.ShuffleMode {
@@ -145,7 +188,7 @@ func TestAddTrack_AppendsToQueue(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 4)
q.SetQueue(paths[:3], 0, false)
q.SetQueue(paths[:3], 0, false, Source{})
q.AddTrack(paths[3])
state := q.GetState()
@@ -165,7 +208,7 @@ func TestInsertTracksAt_BeforeCurrentIndex(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 7)
q.SetQueue(paths[:5], 2, false)
q.SetQueue(paths[:5], 2, false, Source{})
// Insert 2 tracks at index 1 (before currentIndex=2).
q.InsertTracksAt(paths[5:7], 1)
@@ -187,7 +230,7 @@ func TestInsertTracksAt_AfterCurrentIndex(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 7)
q.SetQueue(paths[:5], 2, false)
q.SetQueue(paths[:5], 2, false, Source{})
// Insert 2 tracks at index 3 (after currentIndex=2).
q.InsertTracksAt(paths[5:7], 3)
@@ -205,7 +248,7 @@ func TestMoveQueueTracks_ForwardMove(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
// Move track at index 1 to index 3.
q.MoveQueueTracks([]int{1}, 3)
@@ -224,7 +267,7 @@ func TestMoveQueueTracks_BackwardMove(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
// Move track at index 3 to index 1.
q.MoveQueueTracks([]int{3}, 1)
@@ -242,7 +285,7 @@ func TestMoveQueueTracks_MoveCurrentTrack(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 2, false)
q.SetQueue(paths, 2, false, Source{})
// Move the current track (index 2) to index 4.
q.MoveQueueTracks([]int{2}, 4)
@@ -261,7 +304,7 @@ func TestRemoveTrack_RemovesCorrectTrack(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
q.RemoveTrack(2)
@@ -284,7 +327,7 @@ func TestRemoveTrack_RemoveCurrentTrack(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 2, false)
q.SetQueue(paths, 2, false, Source{})
q.RemoveTrack(2)
@@ -308,7 +351,7 @@ func TestClear_EmptiesQueue(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
q.Clear()
state := q.GetState()
@@ -327,7 +370,7 @@ func TestToggleShuffle_TogglesMode(t *testing.T) {
q, db := setupTestQueue(t)
paths := seedAudioFiles(t, db, 5)
q.SetQueue(paths, 0, false)
q.SetQueue(paths, 0, false, Source{})
// Toggle on.
q.ToggleShuffle()
+78
View File
@@ -0,0 +1,78 @@
package backend
import (
"context"
"yellowjacket/backend/config"
"yellowjacket/backend/explore"
"yellowjacket/backend/playlist"
"yellowjacket/backend/queue"
)
// queueFallbackAdapter implements queue.FallbackSource, translating
// the queue's generic "what plays next" question into the configured
// mode plus whichever service (playlist or explore) can answer it —
// so the queue package itself never needs to import config, playlist
// or explore.
type queueFallbackAdapter struct {
config *config.Config
playlist *playlist.Service
explore *explore.Service
}
// ResolveFallback implements queue.FallbackSource.
//
// A dynamic mix in progress is not interrupted by a mode change —
// once started, it keeps extending itself regardless of what the
// configured mode currently says — everything else (a real selection
// running out, or a Favorites fallback finishing) resolves fresh
// according to the configured mode exactly once.
func (a *queueFallbackAdapter) ResolveFallback(
ctx context.Context,
fctx queue.FallbackContext,
) ([]string, queue.Source, error) {
continuing := fctx.PreviousSource.Type == "dynamicMix"
mode := config.QueueFallback(a.config.GetQueueFallback())
if continuing {
mode = config.QueueFallbackDynamicMix
}
switch mode {
case config.QueueFallbackFavorites:
return a.resolveFavorites()
case config.QueueFallbackDynamicMix:
return a.resolveDynamicMix(ctx, fctx.SeedPaths, continuing)
case config.QueueFallbackStop:
return nil, queue.Source{}, nil
default:
return nil, queue.Source{}, nil
}
}
func (a *queueFallbackAdapter) resolveFavorites() ([]string, queue.Source, error) {
paths, err := a.playlist.GetDefaultPlaylistTrackPaths()
if err != nil || len(paths) == 0 {
return nil, queue.Source{}, err
}
info, err := a.playlist.GetDefaultPlaylistInfo()
if err != nil {
return nil, queue.Source{}, err
}
return paths, queue.Source{Type: "playlist", ID: info.ID, Label: info.Name}, nil
}
func (a *queueFallbackAdapter) resolveDynamicMix(
ctx context.Context,
seedPaths []string,
continuing bool,
) ([]string, queue.Source, error) {
paths, label, err := a.explore.GenerateMix(ctx, seedPaths, continuing)
if err != nil || len(paths) == 0 {
return nil, queue.Source{}, err
}
return paths, queue.Source{Type: "dynamicMix", Label: label}, nil
}
+26 -2
View File
@@ -41,6 +41,7 @@ import { queueStore } from '@store/queue-store';
import { searchStore } from '@store/search-store';
import * as Player from '@go/player/Player';
import * as Queue from '@go/queue/Queue';
import { GetDefaultPage } from '@go/config/Config';
// Importing the theme store triggers initialization: it fetches the saved
// theme from the backend and applies CSS custom properties to :root.
import '@store/theme-store';
@@ -150,11 +151,16 @@ let currentDetailEl: HTMLElement | null = null;
/** Navigation history stack for back-button support in detail views. */
const navStack: Array<{ view: string; [key: string]: any }> = [];
/** The current navigation detail (so we can push it onto the stack). */
let currentNavDetail: { view: string; [key: string]: any } = { view: 'tracks' };
let currentNavDetail: { view: string; [key: string]: any } = { view: 'home' };
// Seed the cache with the default track-list rendered in index.html.
const mainContent = document.getElementById('main-content');
// Seed the cache with the default track-list rendered in index.html —
// otherwise the very first navigation (to whatever GetDefaultPage
// resolves to) creates and shows a second view while this one, never
// tracked as currentViewEl, is never hidden: two visible primary views
// splitting the main panel between them regardless of which is
// selected.
if (mainContent) {
const initialTrackList = mainContent.querySelector('track-list');
@@ -412,6 +418,24 @@ document.addEventListener('navigate-back', () => {
}
});
// Navigate to the user's configured launch page. Falls back to 'home'
// if the backend call fails, matching the config's own default.
GetDefaultPage()
.then((view) => {
document.dispatchEvent(new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: { view: view || 'home' },
}));
})
.catch(() => {
document.dispatchEvent(new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: { view: 'home' },
}));
});
// Queue panel toggle
const queueButton = document.getElementById('queue-button');
const queuePanel = document.getElementById('queue-panel') as HTMLElement | null;
+1 -1
View File
@@ -1 +1 @@
db9e9335c200a37f58ae820ffcfee304
4c7307b19277ad67893efb16b59e71a7
@@ -974,12 +974,19 @@ export class ArtistsView
if (filePaths.length === 0) return;
const artist = this.artists.find(
(a) => a.ID === this.contextMenuArtistId,
);
switch (action) {
case 'play':
queueStore.setQueue(
filePaths,
0,
true,
artist
? { type: 'artist', id: artist.ID, label: artist.Name }
: undefined,
);
break;
case 'add-to-queue':
@@ -276,6 +276,27 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
color: var(--yj-text-primary, #fff);
}
.folders-refresh-trigger {
display: flex;
align-items: center;
font-size: 0.95rem;
}
.folders-refresh-trigger:disabled {
cursor: default;
opacity: 0.6;
}
.folders-refresh-trigger wa-icon.spinning {
animation: folders-refresh-spin 0.8s linear infinite;
}
@keyframes folders-refresh-spin {
to {
transform: rotate(360deg);
}
}
.folders-menu {
position: absolute;
top: calc(100% - 2px);
@@ -1291,6 +1312,21 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
}, 500);
}
/** Manual "refresh" button — re-fetches the folder list (and,
* via reconcileSelection, the current folder's candidates if it
* fell out of the list) without restarting the whole queue the
* way startQueue's StartAutotagQueue call would. */
private async onRefreshFolders(): Promise<void> {
if (this.foldersLoading) return;
this.foldersLoading = true;
try {
await this.loadFolders();
await this.reconcileSelection();
} finally {
this.foldersLoading = false;
}
}
/* ── Apply-job event handlers ── */
private updateApplyJob(groupKey: string, patch: Partial<ApplyJobState>): void {
@@ -2068,6 +2104,14 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
${this.sectionChevron('pending')}
<span>Pending (${pending.length})</span>
</button>
<button class="folders-menu-trigger folders-refresh-trigger"
title="Refresh the folder list"
?disabled=${this.foldersLoading}
@click=${() => void this.onRefreshFolders()}>
<wa-icon
class=${this.foldersLoading ? 'spinning' : ''}
name="arrow-rotate-right"></wa-icon>
</button>
${completed.length > 0 ? html`
<button class="folders-menu-trigger"
title="Queue actions"
@@ -13,6 +13,10 @@ import {
import {
GetScanConcurrency,
SetScanConcurrency,
GetDefaultPage,
SetDefaultPage,
GetQueueFallback,
SetQueueFallback,
} from '@go/config/Config';
import { GetIndexStatus } from '@go/explore/Service';
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
@@ -83,6 +87,8 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
@state() private removingLibraryId: number | null = null;
@state() private activeMenuId: number | null = null;
@state() private concurrencyMode = 'auto';
@state() private defaultPage = 'home';
@state() private queueFallback = 'favorites';
@state() private indexStatus: explore.IndexStatus | null = null;
/** Three states, not one: the panel used to say "Loading status…"
* for the entire session, because the only thing that ever set
@@ -881,13 +887,17 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
private async loadLibraries(): Promise<void> {
try {
const [libs, mode] = await Promise.all([
const [libs, mode, defaultPage, queueFallback] = await Promise.all([
GetAllLibrariesWithTrackCounts(),
GetScanConcurrency(),
GetDefaultPage(),
GetQueueFallback(),
]);
this.libraries = libs ?? [];
this.concurrencyMode = mode;
this.defaultPage = defaultPage;
this.queueFallback = queueFallback;
} catch (err) {
console.error(
@@ -1065,6 +1075,54 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
}
}
private handleDefaultPageChange = (
e: CustomEvent<ConfigFieldChangeEvent>,
): void => {
const page = String(e.detail.value);
SetDefaultPage(page)
.then(() => {
this.defaultPage = page;
notificationStore.transient({
tone: 'success',
key: 'default-page',
text: 'Launch page saved.',
});
})
.catch((err: unknown) => {
console.error('Failed to save launch page:', err);
notificationStore.transient({
key: 'default-page',
text: `Could not save the launch page. ${describeError(err)}`,
detail: String(err),
});
});
};
private handleQueueFallbackChange = (
e: CustomEvent<ConfigFieldChangeEvent>,
): void => {
const mode = String(e.detail.value);
SetQueueFallback(mode)
.then(() => {
this.queueFallback = mode;
notificationStore.transient({
tone: 'success',
key: 'queue-fallback',
text: 'Queue fallback saved.',
});
})
.catch((err: unknown) => {
console.error('Failed to save queue fallback:', err);
notificationStore.transient({
key: 'queue-fallback',
text: `Could not save the queue fallback. ${describeError(err)}`,
detail: String(err),
});
});
};
private handleConcurrencyChange = (
e: CustomEvent<ConfigFieldChangeEvent>,
): void => {
@@ -1361,6 +1419,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
once, if ever — was first and the only expanded one.
-->
${this.renderLibrarySection()}
${this.renderGeneralSection()}
${this.renderNowPlayingSection()}
${this.renderThemeSection()}
${this.renderTrackListSection()}
@@ -1517,6 +1576,57 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
`;
}
// --- General section ---
private renderGeneralSection() {
return html`
<config-section
heading="General"
description="General app behaviour."
>
<config-field
.schema=${{
key: 'defaultPage',
label: 'Launch Page',
description:
'The page the app opens to on launch.',
type: 'select' as const,
options: [
{ value: 'home', label: 'Home' },
{ value: 'tracks', label: 'Tracks' },
{ value: 'albums', label: 'Albums' },
{ value: 'artists', label: 'Artists' },
{ value: 'genres', label: 'Genres' },
{ value: 'playlists', label: 'Playlists' },
{ value: 'explore', label: 'Explore' },
{ value: 'downloads', label: 'Downloads' },
{ value: 'autotag', label: 'Autotag' },
{ value: 'jobs', label: 'Jobs' },
],
}}
.value=${this.defaultPage}
@config-change=${this.handleDefaultPageChange}
></config-field>
<config-field
.schema=${{
key: 'queueFallback',
label: 'When the Queue Ends',
description:
'What plays, if anything, once the queue runs out.',
type: 'select' as const,
options: [
{ value: 'favorites', label: 'Play Favorites' },
{ value: 'dynamicMix', label: 'Start a Dynamic Mix' },
{ value: 'stop', label: 'Stop' },
],
}}
.value=${this.queueFallback}
@config-change=${this.handleQueueFallbackChange}
></config-field>
</config-section>
`;
}
// --- Theme section ---
private renderThemeSection() {
@@ -21,6 +21,7 @@ import { SearchController } from '@store/controllers/search-controller';
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
import { RovingGridController } from '@utils/roving-grid';
import { queueStore } from '@store/queue-store';
import type { QueueSource } from '@store/queue-store';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
@@ -986,6 +987,11 @@ export class CoverGrid
return null;
}
/** The queue source recorded when a full album starts playing. */
private albumSource(album: library.Album): QueueSource {
return { type: 'album', id: album.ID, label: album.Name };
}
/* ====================================================================
* Delegated album event handlers
* ==================================================================== */
@@ -1066,7 +1072,7 @@ export class CoverGrid
this.selectedAlbums = new Set();
this.closeDropdown();
queueStore.setQueue(filePaths, 0, true);
queueStore.setQueue(filePaths, 0, true, this.albumSource(hit.album));
};
private onGridAlbumKeydown = (
@@ -1232,7 +1238,17 @@ export class CoverGrid
if (filePaths.length === 0) return;
this.selectedTracks = new Set();
queueStore.setQueue(filePaths, index);
const album = this.albums.find(
(a) => a.ID === this.expandedAlbumId,
);
queueStore.setQueue(
filePaths,
index,
false,
album ? this.albumSource(album) : undefined,
);
};
private onTrackContextMenu = (
@@ -1439,6 +1455,7 @@ export class CoverGrid
private async onContextMenuAction(action: string) {
let filePaths: string[];
let source: QueueSource | undefined;
if (this.contextMenuTarget.kind === 'track') {
filePaths =
@@ -1446,19 +1463,35 @@ export class CoverGrid
this.selectedTracks,
this.expandedTracks,
);
const album = this.albums.find(
(a) => a.ID === this.expandedAlbumId,
);
source = album ? this.albumSource(album) : undefined;
} else {
filePaths =
await this.selMgr.getContextMenuAlbumFilePaths(
this.contextMenuAlbumId,
this.selectedAlbums,
);
// A single targeted album has an unambiguous source; a
// multi-album selection does not.
if (this.selectedAlbums.size <= 1) {
const album = this.albums.find(
(a) => a.ID === this.contextMenuAlbumId,
);
source = album ? this.albumSource(album) : undefined;
}
}
if (filePaths.length === 0) return;
switch (action) {
case 'play':
queueStore.setQueue(filePaths, 0, true);
queueStore.setQueue(filePaths, 0, true, source);
break;
case 'add-to-queue':
queueStore.addTracksToQueue(filePaths);
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
import { avatarBackground } from '@utils/avatar-color';
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { customElement, property, state, query } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import { designTokens } from '../../styles/tokens.css';
import {
LookupArtist,
@@ -29,16 +30,36 @@ import { downloadStore } from '../../store/download-store';
import '@awesome.me/webawesome/dist/components/button/button.js';
import { trackLink, exploreLinkStyles } from '../../utils/explore-link';
import { describeError } from '../../utils/describe-error';
import { GetAlbumsByArtist } from '@go/library/Library';
import {
GetAlbumsByArtist,
GetFilePathsByAlbums,
GetFilePathsByRecordingMBIDs,
} from '@go/library/Library';
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '../library-status-indicator/library-status-indicator.js';
import '../catalog-scope-notice/catalog-scope-notice.js';
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
import { queueStore } from '../../store/queue-store';
import type { QueueSource } from '../../store/queue-store';
import { notificationStore } from '../../store/notification-store';
import '../notifications/inline-notice';
import {
ContextMenuController,
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
/* ── Constants ── */
/** The region the artist header's own failures are rendered in. */
export const ExploreArtistRegion = 'explore-artist';
/** Desired section order for grouping release types. */
const TYPE_ORDER = ['Albums', 'EP', 'Single', 'Other Albums'];
@@ -75,7 +96,7 @@ function formatListenCount(count: number): string {
/* ── Component ── */
@customElement('explore-artist-details')
export class ExploreArtistDetails extends LitElement {
export class ExploreArtistDetails extends LitElement implements ContextMenuHost {
/* ── Public attributes ── */
@property({ type: String, attribute: 'artist-mbid' })
@@ -125,11 +146,38 @@ export class ExploreArtistDetails extends LitElement {
@state() private similarExpanded = false;
private libraryMBIDs = new Set<string>();
/* ── Track context menu ── */
private ctxMenu = new ContextMenuController(this);
/** The top track the open context menu applies to. */
@state() private ctxMenuTrack: LBTopRecording | null = null;
@query('#track-context-menu')
private contextMenuPopup!: WaPopup;
// -- ContextMenuHost interface --
// No playlist submenu here, for the same reason as the album page:
// every action resolves one recording's file lazily by MBID.
getContextMenuPopup(): WaPopup | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup(): WaPopup | undefined {
return undefined;
}
onContextMenuClose(): void {
this.ctxMenuTrack = null;
}
/* ── Styles ── */
static override styles = [
designTokens,
exploreLinkStyles,
contextMenuStyles,
css`
:host {
display: flex;
@@ -307,6 +355,10 @@ export class ExploreArtistDetails extends LitElement {
transition: background 0.1s ease;
}
.track-item.owned {
cursor: pointer;
}
.track-item:hover {
background: var(
--yj-bg-overlay,
@@ -314,6 +366,19 @@ export class ExploreArtistDetails extends LitElement {
);
}
.track-item:focus-visible {
outline: 2px solid var(--yj-accent-text, #ffd43b);
outline-offset: -2px;
}
.artist-play-actions {
margin-top: 10px;
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.track-rank {
width: 24px;
text-align: right;
@@ -1731,6 +1796,209 @@ export class ExploreArtistDetails extends LitElement {
}
}
/* ── Playback ── */
/**
* Local album ids for every release group this page already knows
* is owned. `releaseGroups` holds the artist's full discography, so
* this covers everything the "Play library tracks" button promises
* — not just what is currently expanded on screen.
*/
private ownedLocalAlbumIds(): number[] {
const ids = new Set<number>();
for (const rg of this.releaseGroups) {
if (rg.localId && rg.localId > 0) ids.add(rg.localId);
}
return [...ids];
}
/**
* File paths for every track this page can show is owned, across
* the whole discography. Each id came from a release group the
* backend or the library cache already cross-referenced, and
* `GetFilePathsByAlbums` only ever returns files that actually
* exist for that local album — so this cannot pull in a track the
* user does not have, even when the catalog release itself is only
* partially owned.
*/
private async libraryFilePaths(): Promise<string[]> {
const albumIds = this.ownedLocalAlbumIds();
if (albumIds.length === 0) return [];
const libraryID = libraryStore.getSelectedLibraryId() ?? 0;
const byAlbum = await GetFilePathsByAlbums(albumIds, libraryID);
const paths: string[] = [];
for (const id of albumIds) paths.push(...(byAlbum[id] ?? []));
return paths;
}
/**
* The local artist id for "Playing from" purposes — the `local-
* artist-id` navigation attribute when the caller had one, else a
* lookup by MBID against the cached library artists (the same
* cross-reference `hydrateFromCache`/`checkLibrary` already use).
*/
private resolveLocalArtistId(): number {
if (this.localArtistId > 0) return this.localArtistId;
if (!this.artistMBID) return 0;
for (const a of libraryStore.cachedArtists ?? []) {
if (a.MBID === this.artistMBID) return a.ID;
}
return 0;
}
private queueSource(): QueueSource | undefined {
const id = this.resolveLocalArtistId();
if (id === 0) return undefined;
return { type: 'artist', id, label: this.displayName };
}
/** Play every owned track by this artist, optionally shuffled. */
private async playLibraryTracks(shuffle: boolean): Promise<void> {
try {
const paths = await this.libraryFilePaths();
if (paths.length === 0) {
notificationStore.inline(ExploreArtistRegion, {
text: 'None of this artists tracks could be found in your library.',
});
return;
}
if (shuffle && !queueStore.getState().shuffleMode) {
queueStore.toggleShuffle();
}
queueStore.setQueue(paths, 0, shuffle, this.queueSource());
} catch (error) {
console.error('Could not play artist:', error);
notificationStore.inline(ExploreArtistRegion, {
text: describeError(error, 'Could not play this artists library tracks.'),
});
}
}
/**
* File path for one top track, resolved by recording MBID — the
* same key `inLibrary`/`localId` were set from. Works whether or
* not the containing release itself matched a local album.
*/
private async trackFilePath(track: LBTopRecording): Promise<string | null> {
if (!(track.inLibrary || track.localId) || !track.recordingMbid) return null;
const libraryID = libraryStore.getSelectedLibraryId() ?? 0;
const byMBID = await GetFilePathsByRecordingMBIDs([track.recordingMbid], libraryID);
return byMBID[track.recordingMbid]?.[0] ?? null;
}
private async playTrack(track: LBTopRecording): Promise<void> {
try {
const path = await this.trackFilePath(track);
if (!path) {
notificationStore.inline(ExploreArtistRegion, {
text: 'This track could not be found in your library.',
});
return;
}
queueStore.setQueue([path], 0, false, this.queueSource());
} catch (error) {
console.error('Could not play track:', error);
notificationStore.inline(ExploreArtistRegion, {
text: describeError(error, 'Could not play this track.'),
});
}
}
private async queueTrackNext(track: LBTopRecording): Promise<void> {
const path = await this.trackFilePath(track);
if (path) queueStore.playNext(path);
}
private async addTrackToQueue(track: LBTopRecording): Promise<void> {
const path = await this.trackFilePath(track);
if (path) queueStore.addToQueue(path);
}
private isTrackOwned(track: LBTopRecording): boolean {
return Boolean(track.inLibrary || track.localId);
}
private onTrackRowDblClick(track: LBTopRecording): void {
if (!this.isTrackOwned(track)) return;
void this.playTrack(track);
}
private onTrackRowKeydown(e: KeyboardEvent, track: LBTopRecording): void {
if (isContextMenuKey(e)) {
e.preventDefault();
this.ctxMenuTrack = track;
this.ctxMenu.openFrom(e.currentTarget as HTMLElement);
return;
}
if ((e.key === 'Enter' || e.key === ' ') && this.isTrackOwned(track)) {
e.preventDefault();
void this.playTrack(track);
}
}
private onTrackContextMenu(e: MouseEvent, track: LBTopRecording): void {
e.preventDefault();
e.stopPropagation();
this.ctxMenuTrack = track;
this.ctxMenu.openAt(e.clientX, e.clientY);
}
private onContextMenuAction(action: 'play' | 'add-to-queue' | 'play-next'): void {
const track = this.ctxMenuTrack;
this.ctxMenu.close();
if (!track || !this.isTrackOwned(track)) return;
switch (action) {
case 'play':
void this.playTrack(track);
break;
case 'add-to-queue':
void this.addTrackToQueue(track);
break;
case 'play-next':
void this.queueTrackNext(track);
break;
}
}
private viewTrackOnMusicBrainz(): void {
const track = this.ctxMenuTrack;
this.ctxMenu.close();
if (!track?.recordingMbid) return;
window.open(`https://musicbrainz.org/recording/${track.recordingMbid}`, '_blank', 'noopener');
}
/* ── Navigation ── */
private navigateBack() {
@@ -1910,6 +2178,7 @@ export class ExploreArtistDetails extends LitElement {
${this.artist?.popularity && this.artist.popularity > 0
? html`<span class="artist-meta">${formatListenCount(this.artist.popularity)} plays on ListenBrainz</span>`
: nothing}
${this.renderPlayLibraryAction()}
${this.renderFollowAction()}
</div>
</div>
@@ -1922,6 +2191,85 @@ export class ExploreArtistDetails extends LitElement {
${this.renderTopSection()} ${this.renderDiscography()}
${this.renderSimilarArtists()}
</div>
<inline-notice
region=${ExploreArtistRegion}
testid="artist-action-message"
></inline-notice>
${this.renderTrackContextMenu()}
`;
}
/**
* The artist-page equivalent of the album page's Play button: play
* everything by this artist that is actually in the library. Only
* rendered when at least one release group is owned — an artist
* page with nothing local has nothing for this button to do.
*/
private renderPlayLibraryAction() {
if (this.ownedLocalAlbumIds().length === 0) return nothing;
return html`
<div class="artist-play-actions">
<wa-button
size="small"
appearance="filled"
data-testid="artist-play-library"
@click=${() => void this.playLibraryTracks(false)}
>
<wa-icon slot="start" name="play"></wa-icon>
Play library tracks
</wa-button>
<wa-button
size="small"
appearance="outlined"
data-testid="artist-shuffle-library"
@click=${() => void this.playLibraryTracks(true)}
>
<wa-icon slot="start" name="shuffle"></wa-icon>
Shuffle
</wa-button>
</div>
`;
}
private renderTrackContextMenu() {
const track = this.ctxMenuTrack;
return html`
<wa-popup
id="track-context-menu"
placement="bottom-start"
flip
shift
.active=${this.ctxMenu.contextMenuOpen}
>
${this.ctxMenu.contextMenuOpen && track
? html`
<div class="context-menu-panel" role="menu" aria-label="Track actions">
${this.isTrackOwned(track)
? html`
<wa-dropdown-item @click=${() => this.onContextMenuAction('play')}>
<wa-icon slot="icon" name="play"></wa-icon>
Play
</wa-dropdown-item>
<wa-dropdown-item @click=${() => this.onContextMenuAction('add-to-queue')}>
<wa-icon slot="icon" name="plus"></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item @click=${() => this.onContextMenuAction('play-next')}>
<wa-icon slot="icon" name="forward-step"></wa-icon>
Play Next
</wa-dropdown-item>
`
: nothing}
<wa-dropdown-item @click=${() => this.viewTrackOnMusicBrainz()}>
<wa-icon slot="icon" name="globe"></wa-icon>
View on MusicBrainz
</wa-dropdown-item>
</div>
`
: nothing}
</wa-popup>
`;
}
@@ -2091,7 +2439,17 @@ export class ExploreArtistDetails extends LitElement {
<div class="track-list">
${tracks.map(
(t, i) => html`
<div class="track-item">
<div
class=${classMap({ 'track-item': true, owned: this.isTrackOwned(t) })}
tabindex="0"
role="button"
aria-label=${this.isTrackOwned(t)
? `Play “${t.trackName}`
: `${t.trackName} — not in your library`}
@dblclick=${() => this.onTrackRowDblClick(t)}
@contextmenu=${(e: MouseEvent) => this.onTrackContextMenu(e, t)}
@keydown=${(e: KeyboardEvent) => this.onTrackRowKeydown(e, t)}
>
<span class="track-rank">${i + 1}</span>
<div class="track-art">
${(() => {
@@ -1,15 +1,19 @@
import { avatarBackground } from '@utils/avatar-color';
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query as litQuery } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import '@components/page-header/page-header';
import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css';
import { SearchLocal, SearchLyrics, GetThumbnail, GetThumbnails, GetArtistImageURL, GetExploreShelves, RecordSearchClick } from '@go/explore/Service';
import { GetFilePathsByAlbums, GetFilePathsByRecordingMBIDs } from '@go/library/Library';
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import { libraryStore } from '../../store/library-store';
import { exploreCache, ARTIST_IMAGE_CACHE_LIMIT } from '../../store/explore-cache';
import { queueStore } from '../../store/queue-store';
import { notificationStore } from '../../store/notification-store';
import '../notifications/inline-notice';
import { artistLink, trackLink, exploreLinkStyles } from '../../utils/explore-link';
import { describeError } from '../../utils/describe-error';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
@@ -19,6 +23,28 @@ import { explore } from '@go/models';
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
import { registerCacheProbe } from '../../utils/cache-stats';
import { LRUMap } from '../../utils/lru-map';
import {
ContextMenuController,
contextMenuStyles,
isContextMenuKey,
} from '@utils/context-menu-controller.js';
import type { ContextMenuHost } from '@utils/context-menu-controller.js';
import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
/** The region explore's own action failures (play/queue) are rendered in. */
export const ExploreRegion = 'explore';
/**
* A context-menu target: an album card or a track/recording. `localId`
* is present only when owned — that's what gates the playback items,
* while `mbid` (always present) is what "View on MusicBrainz" uses, so
* a catalog-only card still gets a menu with somewhere useful to go.
*/
type ExploreMenuTarget =
| { kind: 'album'; mbid: string; localId?: number; title: string }
| { kind: 'recording'; mbid: string; localId?: number; title: string };
type ThumbnailRequest = explore.ThumbnailRequest;
type MBSearchResult = explore.MBSearchResult;
type LyricsResult = explore.LyricsResult;
@@ -106,7 +132,7 @@ function getArtistAlbumArt(artistName: string): string {
}
@customElement('explore-view')
export class ExploreView extends ViewLifecycleMixin(LitElement) {
export class ExploreView extends ViewLifecycleMixin(LitElement) implements ContextMenuHost {
/* ── State ── */
@state() private searchQuery = '';
@@ -163,12 +189,38 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) {
@litQuery('input') private inputEl!: HTMLInputElement;
/* ── Card/track context menu ── */
private ctxMenu = new ContextMenuController(this);
@state() private ctxMenuTarget: ExploreMenuTarget | null = null;
@litQuery('#explore-context-menu')
private contextMenuPopup!: WaPopup;
// -- ContextMenuHost interface --
// No playlist submenu — same reason as the album/artist detail
// pages: every action here resolves its one file lazily.
getContextMenuPopup(): WaPopup | undefined {
return this.contextMenuPopup;
}
getPlaylistSubmenuPopup(): WaPopup | undefined {
return undefined;
}
onContextMenuClose(): void {
this.ctxMenuTarget = null;
}
/* ── Styles ── */
static override styles = [
designTokens,
srOnly,
exploreLinkStyles,
contextMenuStyles,
css`
:host {
display: block;
@@ -648,6 +700,16 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) {
transition: background 0.1s ease;
}
.track-item.owned {
cursor: pointer;
}
.album-card:focus-visible,
.track-item:focus-visible {
outline: 2px solid var(--yj-accent-text, #ffd43b);
outline-offset: -2px;
}
.track-item:hover {
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.04));
}
@@ -1043,6 +1105,237 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) {
queueStore.setQueue([hit.filePath], 0);
}
/* ── Playback: album cards and track rows ── */
/**
* File paths for an owned album's tracks, keyed by its local album
* id — the only album key resolved on this page without a further
* fetch. `GetFilePathsByAlbums` only returns files that actually
* exist for that local album, so this can never pull in a track
* from a release the user does not fully own.
*/
private async albumFilePaths(localId: number): Promise<string[]> {
const libraryID = libraryStore.getSelectedLibraryId() ?? 0;
const byAlbum = await GetFilePathsByAlbums([localId], libraryID);
return byAlbum[localId] ?? [];
}
private async recordingFilePath(mbid: string): Promise<string | null> {
const libraryID = libraryStore.getSelectedLibraryId() ?? 0;
const byMBID = await GetFilePathsByRecordingMBIDs([mbid], libraryID);
return byMBID[mbid]?.[0] ?? null;
}
private async playAlbum(rg: MBReleaseGroup, shuffle: boolean): Promise<void> {
if (!rg.localId) return;
try {
const paths = await this.albumFilePaths(rg.localId);
if (paths.length === 0) {
notificationStore.inline(ExploreRegion, {
text: 'None of these tracks could be found in your library.',
});
return;
}
if (shuffle && !queueStore.getState().shuffleMode) {
queueStore.toggleShuffle();
}
queueStore.setQueue(paths, 0, shuffle, { type: 'album', id: rg.localId, label: rg.title });
} catch (error) {
console.error('Could not play album:', error);
notificationStore.inline(ExploreRegion, {
text: describeError(error, 'Could not play this album.'),
});
}
}
private async queueAlbum(rg: MBReleaseGroup): Promise<void> {
if (!rg.localId) return;
const paths = await this.albumFilePaths(rg.localId);
if (paths.length > 0) queueStore.addTracksToQueue(paths);
}
private async playRecording(mbid: string): Promise<void> {
try {
const path = await this.recordingFilePath(mbid);
if (!path) {
notificationStore.inline(ExploreRegion, {
text: 'This track could not be found in your library.',
});
return;
}
queueStore.setQueue([path], 0);
} catch (error) {
console.error('Could not play track:', error);
notificationStore.inline(ExploreRegion, {
text: describeError(error, 'Could not play this track.'),
});
}
}
private async queueRecordingNext(mbid: string): Promise<void> {
const path = await this.recordingFilePath(mbid);
if (path) queueStore.playNext(path);
}
private async addRecordingToQueue(mbid: string): Promise<void> {
const path = await this.recordingFilePath(mbid);
if (path) queueStore.addToQueue(path);
}
private onAlbumCardDblClick(rg: MBReleaseGroup): void {
if (!rg.localId) return;
void this.playAlbum(rg, false);
}
private onRecordingRowDblClick(r: { mbid: string; inLibrary: boolean; localId?: number }): void {
if (!r.inLibrary && !r.localId) return;
void this.playRecording(r.mbid);
}
private onCardKeydown(
e: KeyboardEvent,
onActivate: () => void,
target?: ExploreMenuTarget,
): void {
if (target && isContextMenuKey(e)) {
e.preventDefault();
this.ctxMenuTarget = target;
this.ctxMenu.openFrom(e.currentTarget as HTMLElement);
return;
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onActivate();
}
}
private onExploreContextMenu(e: MouseEvent, target: ExploreMenuTarget): void {
e.preventDefault();
e.stopPropagation();
this.ctxMenuTarget = target;
this.ctxMenu.openAt(e.clientX, e.clientY);
}
private onContextMenuAction(action: 'play' | 'add-to-queue' | 'play-next'): void {
const target = this.ctxMenuTarget;
this.ctxMenu.close();
if (!target || !target.localId) return;
if (target.kind === 'album') {
const localId = target.localId;
const rg = { localId, title: target.title } as MBReleaseGroup;
switch (action) {
case 'play':
void this.playAlbum(rg, false);
break;
case 'add-to-queue':
void this.queueAlbum(rg);
break;
case 'play-next':
void this.albumFilePaths(localId).then((paths) => {
if (paths.length > 0) queueStore.playTracksNext(paths);
});
break;
}
return;
}
switch (action) {
case 'play':
void this.playRecording(target.mbid);
break;
case 'add-to-queue':
void this.addRecordingToQueue(target.mbid);
break;
case 'play-next':
void this.queueRecordingNext(target.mbid);
break;
}
}
/**
* Explore's cards carry an MBID whether or not the user owns them,
* so this is the one action that works on a catalog-only card —
* it needs no file, and it's the same URL scheme for a release
* group or a recording.
*/
private viewOnMusicBrainz(): void {
const target = this.ctxMenuTarget;
this.ctxMenu.close();
if (!target?.mbid) return;
const entity = target.kind === 'album' ? 'release-group' : 'recording';
window.open(`https://musicbrainz.org/${entity}/${target.mbid}`, '_blank', 'noopener');
}
private renderExploreContextMenu() {
const target = this.ctxMenuTarget;
const owned = Boolean(target?.localId);
return html`
<wa-popup
id="explore-context-menu"
placement="bottom-start"
flip
shift
.active=${this.ctxMenu.contextMenuOpen}
>
${this.ctxMenu.contextMenuOpen && target
? html`
<div class="context-menu-panel" role="menu" aria-label="${target.title} actions">
${owned
? html`
<wa-dropdown-item @click=${() => this.onContextMenuAction('play')}>
<wa-icon slot="icon" name="play"></wa-icon>
Play
</wa-dropdown-item>
<wa-dropdown-item @click=${() => this.onContextMenuAction('add-to-queue')}>
<wa-icon slot="icon" name="plus"></wa-icon>
Add to Queue
</wa-dropdown-item>
<wa-dropdown-item @click=${() => this.onContextMenuAction('play-next')}>
<wa-icon slot="icon" name="forward-step"></wa-icon>
Play Next
</wa-dropdown-item>
`
: nothing}
<wa-dropdown-item @click=${() => this.viewOnMusicBrainz()}>
<wa-icon slot="icon" name="globe"></wa-icon>
View on MusicBrainz
</wa-dropdown-item>
</div>
`
: nothing}
</wa-popup>
`;
}
/* ── Thumbnail Loading ── */
private thumbnailBatchPending = false;
@@ -1468,6 +1761,11 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) {
</div>`
: nothing}
${this.renderBody()}
<inline-notice
region=${ExploreRegion}
testid="explore-action-message"
></inline-notice>
${this.renderExploreContextMenu()}
`;
}
@@ -1790,18 +2088,33 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) {
const artURL = this.thumbnailCache.get(rg.mbid) || '';
const year = extractYear(rg.firstReleaseDate);
const owned = Boolean(rg.localId);
return html`
<div
class="album-card"
class=${classMap({ 'album-card': true, owned })}
@click=${() => this.navigateToAlbum(rg)}
@dblclick=${() => this.onAlbumCardDblClick(rg)}
@contextmenu=${(e: MouseEvent) =>
this.onExploreContextMenu(e, {
kind: 'album',
mbid: rg.mbid,
localId: rg.localId,
title: rg.title,
})}
role="button"
tabindex="0"
@keydown=${(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.navigateToAlbum(rg);
}
}}
@keydown=${(e: KeyboardEvent) =>
this.onCardKeydown(
e,
() => this.navigateToAlbum(rg),
{
kind: 'album',
mbid: rg.mbid,
localId: rg.localId,
title: rg.title,
},
)}
>
<div class="album-art-container">
${artURL
@@ -1853,7 +2166,30 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) {
<div class="track-list">
${recordings.map(
(r) => html`
<div class="track-item">
<div
class=${classMap({ 'track-item': true, owned: Boolean(r.inLibrary || r.localId) })}
role="button"
tabindex="0"
@dblclick=${() => this.onRecordingRowDblClick(r)}
@contextmenu=${(e: MouseEvent) =>
this.onExploreContextMenu(e, {
kind: 'recording',
mbid: r.mbid,
localId: r.localId,
title: r.title,
})}
@keydown=${(e: KeyboardEvent) =>
this.onCardKeydown(
e,
() => this.onRecordingRowDblClick(r),
{
kind: 'recording',
mbid: r.mbid,
localId: r.localId,
title: r.title,
},
)}
>
<div class="track-info">
<div class="track-title">
${trackLink(r.title, r.releaseName ?? '', r.releaseGroupMbid ?? '', r.mbid)}
@@ -311,6 +311,7 @@ export class GenreDetails extends LitElement {
</div>`
: html`<track-list
.externalTracks=${this.tracks}
.queueSource=${{ type: 'genre', id: 0, label: this.genreName }}
></track-list>`}
</div>
`;
@@ -985,7 +985,11 @@ export class GenresView
switch (action) {
case 'play':
queueStore.setQueue(filePaths, 0, true);
queueStore.setQueue(filePaths, 0, true, {
type: 'genre',
id: 0,
label: this.contextMenuGenreName ?? '',
});
break;
case 'add-to-queue':
queueStore.addTracksToQueue(
@@ -385,7 +385,11 @@ export class HomeView extends ViewLifecycleMixin(LitElement) {
if (paths.length === 0) return;
queueStore.setQueue(paths, 0, true);
queueStore.setQueue(paths, 0, true, {
type: 'album',
id: album.ID,
label: album.Name,
});
} catch (err) {
console.error('Could not play that album:', err);
}
@@ -6,12 +6,20 @@ import '@awesome.me/webawesome/dist/components/icon/icon.js';
* Library status for an entity (artist, album, or track).
*
* - `in-library`: the entity is already in the user's local library.
* - `partial`: some of it is here and the rest is known to be missing
* — an album whose files declare twelve tracks where nine are held.
* Only ever correct when that total is *known*; see `owned` /
* `expected` below.
* - `queued`: the entity has been handed off to a download client but
* hasn't arrived yet. Reserved for future download-client plumbing.
* - `not-in-library` (default): the entity is not owned and has not
* been requested.
*/
export type LibraryStatus = 'in-library' | 'queued' | 'not-in-library';
export type LibraryStatus =
| 'in-library'
| 'partial'
| 'queued'
| 'not-in-library';
/**
* Tri-state library status indicator: a small circular badge embedded
@@ -65,6 +73,22 @@ export class LibraryStatusIndicator extends LitElement {
@property({ type: Number })
size = 20;
/**
* How many of `expected` are held, for `status="partial"`.
*
* These are only meaningful when the caller *knows* the total. A
* tag that never declared one is a third state, and a caller with
* no total must pass `in-library`, not a ring at 0% — most of an
* untagged library would otherwise wear an incompleteness mark
* nothing in the data supports.
*/
@property({ type: Number })
owned = 0;
/** The declared track total behind `owned`. */
@property({ type: Number })
expected = 0;
static override styles = css`
:host {
display: inline-flex;
@@ -86,6 +110,34 @@ export class LibraryStatusIndicator extends LitElement {
--indicator-fg: #000;
}
/* The ring draws its own arc, so the badge behind it stays
* empty rather than taking a fill that would show through. */
:host([status='partial']) {
--indicator-bg: transparent;
--indicator-fg: #f5a623;
}
svg {
width: 100%;
height: 100%;
/* Start the arc at twelve o'clock; SVG angles start east. */
transform: rotate(-90deg);
}
circle {
fill: none;
stroke-width: 3;
}
.ring-track {
stroke: rgba(255, 255, 255, 0.18);
}
.ring-fill {
stroke: #f5a623;
stroke-linecap: round;
}
:host([status='not-in-library']) {
--indicator-bg: rgba(255, 255, 255, 0.08);
--indicator-fg: rgba(255, 255, 255, 0.65);
@@ -136,6 +188,13 @@ export class LibraryStatusIndicator extends LitElement {
}
}
/** The held fraction, clamped — extras do not overfill the ring. */
private fraction(): number {
if (this.expected <= 0) return 0;
return Math.min(1, Math.max(0, this.owned / this.expected));
}
private tooltip(): string {
const kind =
this.entityType === 'album'
@@ -148,6 +207,10 @@ export class LibraryStatusIndicator extends LitElement {
switch (this.status) {
case 'in-library':
return `${capitalize(kind)}${name} is in your library`;
case 'partial':
// The count is the whole point — a ring alone says
// "some" to a sighted user and nothing to anyone else.
return `${this.owned} of ${this.expected} tracks of ${kind}${name} are in your library`;
case 'queued':
return `${capitalize(kind)}${name} is queued for download`;
default:
@@ -167,12 +230,39 @@ export class LibraryStatusIndicator extends LitElement {
return html`
<span class="badge" role="img" title=${title} aria-label=${title}>
${this.iconName()
${this.status === 'partial'
? this.renderRing()
: this.iconName()
? html`<wa-icon name=${this.iconName()} aria-hidden="true"></wa-icon>`
: nothing}
</span>
`;
}
/**
* The progress arc. Drawn as a stroked circle rather than a conic
* gradient so the ring keeps a constant width at every `size` and
* the arc's ends stay round.
*/
private renderRing() {
const radius = 8;
const circumference = 2 * Math.PI * radius;
const offset = circumference * (1 - this.fraction());
return html`
<svg viewBox="0 0 20 20" aria-hidden="true">
<circle class="ring-track" cx="10" cy="10" r=${radius}></circle>
<circle
class="ring-fill"
cx="10"
cy="10"
r=${radius}
stroke-dasharray=${circumference}
stroke-dashoffset=${offset}
></circle>
</svg>
`;
}
}
function capitalize(s: string): string {
@@ -8,7 +8,13 @@ import {
trackLink,
exploreLinkStyles,
} from '@utils/explore-link';
import {
describeQueueSource,
isQueueSourceNavigable,
navigateToQueueSource,
} from '@utils/queue-source-link';
import { PlayerController } from '@store/controllers/player-controller';
import { QueueController } from '@store/controllers/queue-controller';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { designTokens } from '../../styles/tokens.css';
import { srOnly } from '../../styles/sr-only.css';
@@ -34,6 +40,7 @@ type ScrollMode = 'hover' | 'always' | 'never';
@customElement('now-playing')
export class NowPlaying extends LitElement {
private player = new PlayerController(this);
private queue = new QueueController(this);
private favCtrl = new FavoritesController(this);
@state()
@@ -231,6 +238,22 @@ export class NowPlaying extends LitElement {
text-overflow: ellipsis;
}
.track-source {
font-size: var(--yj-text-xs, 0.75rem);
color: var(--yj-text-tertiary, #666);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track-source.navigable {
cursor: pointer;
}
.track-source.navigable:hover {
text-decoration: underline;
}
.scroll-content {
display: inline-block;
white-space: nowrap;
@@ -420,6 +443,21 @@ export class NowPlaying extends LitElement {
>
<span class="scroll-content">${artistLink(track.artist, track.artistMbid) || 'Unknown Artist'}</span>
</span>
${describeQueueSource(this.queue.source)
? html`
<span
class="track-source ${isQueueSourceNavigable(this.queue.source) ? 'navigable' : ''}"
data-testid="now-playing-source"
@click=${(e: MouseEvent) => {
if (!isQueueSourceNavigable(this.queue.source)) return;
navigateToQueueSource(
e.currentTarget as EventTarget,
this.queue.source,
);
}}
>${describeQueueSource(this.queue.source)}</span>
`
: nothing}
</div>
${track.filePath
? html`
@@ -330,7 +330,7 @@ export class PlaylistDetails
if (filePaths.length === 0) return;
queueStore.setQueue(filePaths, 0, true);
queueStore.setQueue(filePaths, 0, true, { type: 'playlist', id: this.playlistId, label: this.playlistName });
}
private handleTrackClick(
@@ -396,7 +396,7 @@ export class PlaylistDetails
(t) => t.FilePath,
);
queueStore.setQueue(filePaths, trackIndex);
queueStore.setQueue(filePaths, trackIndex, false, { type: 'playlist', id: this.playlistId, label: this.playlistName });
}
private handleTrackContextMenu(
@@ -449,7 +449,7 @@ export class PlaylistDetails
switch (action) {
case 'play':
queueStore.setQueue(filePaths, 0, true);
queueStore.setQueue(filePaths, 0, true, { type: 'playlist', id: this.playlistId, label: this.playlistName });
break;
case 'add-to-queue':
queueStore.addTracksToQueue(filePaths);
@@ -12,6 +12,11 @@ import '@awesome.me/webawesome/dist/components/popup/popup.js';
import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js';
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import { QueueController } from '@store/controllers/queue-controller';
import {
describeQueueSource,
isQueueSourceNavigable,
navigateToQueueSource,
} from '@utils/queue-source-link';
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
import '@components/playlist-picker/playlist-picker.js';
import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js';
@@ -285,12 +290,35 @@ export class QueuePanel
flex-shrink: 0;
}
.header-title {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.header h3 {
margin: 0;
font-size: var(--yj-text-lg);
font-weight: 600;
}
.queue-source {
font-size: var(--yj-text-xs, 0.75rem);
color: var(--yj-text-tertiary, #666);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.queue-source.navigable {
cursor: pointer;
}
.queue-source.navigable:hover {
text-decoration: underline;
}
.header-actions {
display: flex;
align-items: center;
@@ -1678,7 +1706,23 @@ export class QueuePanel
${this.moveAnnouncement}
</div>
<div class="header">
<div class="header-title">
<h3>Queue</h3>
${describeQueueSource(this.queue.source)
? html`
<span
class="queue-source ${isQueueSourceNavigable(this.queue.source) ? 'navigable' : ''}"
@click=${(e: MouseEvent) => {
if (!isQueueSourceNavigable(this.queue.source)) return;
navigateToQueueSource(
e.currentTarget as EventTarget,
this.queue.source,
);
}}
>${describeQueueSource(this.queue.source)}</span>
`
: nothing}
</div>
<div class="header-actions">
<button
class="header-action-button"
@@ -221,6 +221,10 @@ export class AppSidebar extends LitElement {
'yj-drag-active',
this.onDragActive as EventListener,
);
document.addEventListener(
'navigate',
this.onGlobalNavigate as EventListener,
);
}
override disconnectedCallback() {
@@ -242,6 +246,10 @@ export class AppSidebar extends LitElement {
'yj-drag-active',
this.onDragActive as EventListener,
);
document.removeEventListener(
'navigate',
this.onGlobalNavigate as EventListener,
);
this.clearDragHoverTimer();
}
@@ -357,6 +365,19 @@ export class AppSidebar extends LitElement {
private static readonly DROP_VIEWS: Set<View> =
new Set(['playlists']);
/** Keeps the highlighted nav item in sync with navigation that
* originates outside the sidebar itself (e.g. the launch-page
* dispatch in index.ts). */
private onGlobalNavigate = (
e: CustomEvent<{ view?: string }>,
) => {
const view = e.detail.view;
if (view && this.navItems.some((item) => item.id === view)) {
this.activeView = view as View;
}
};
private onDragActive = (
e: CustomEvent<DragActiveDetail>,
) => {
@@ -736,7 +736,7 @@ export class SmartPlaylistDetails
if (filePaths.length === 0) return;
queueStore.setQueue(filePaths, 0, false);
queueStore.setQueue(filePaths, 0, false, { type: 'smartPlaylist', id: this.playlistId, label: this.playlistName });
}
private handleShuffle() {
@@ -746,7 +746,7 @@ export class SmartPlaylistDetails
if (filePaths.length === 0) return;
queueStore.setQueue(filePaths, 0, true);
queueStore.setQueue(filePaths, 0, true, { type: 'smartPlaylist', id: this.playlistId, label: this.playlistName });
}
private async handleRefresh() {
@@ -871,7 +871,7 @@ export class SmartPlaylistDetails
(t) => t.FilePath,
);
queueStore.setQueue(filePaths, trackIndex);
queueStore.setQueue(filePaths, trackIndex, false, { type: 'smartPlaylist', id: this.playlistId, label: this.playlistName });
}
private handleTrackContextMenu(
@@ -918,7 +918,7 @@ export class SmartPlaylistDetails
switch (action) {
case 'play':
queueStore.setQueue(filePaths, 0, true);
queueStore.setQueue(filePaths, 0, true, { type: 'smartPlaylist', id: this.playlistId, label: this.playlistName });
break;
case 'add-to-queue':
queueStore.addTracksToQueue(filePaths);
@@ -25,6 +25,7 @@ import type { SortOption } from '@components/page-header/page-header';
import { TrackListController } from '@store/controllers/tracklist-controller';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { queueStore } from '@store/queue-store';
import type { QueueSource } from '@store/queue-store';
import { LibraryController } from '@store/controllers/library-controller';
import {
COLUMN_DEFS,
@@ -122,6 +123,29 @@ export class TrackList
@property({ type: Array, attribute: false })
externalTracks?: library.Track[];
/**
* What a host embedding this list (e.g. `genre-details`) should say
* a queue built from it came from. Unset when this list is showing
* the whole library — the one case with no host to ask, and where
* `effectiveQueueSource` supplies "All Tracks" itself.
*/
@property({ attribute: false })
queueSource?: QueueSource;
/**
* The library's own top-level Tracks view has no host to name a
* source — it *is* the source. Anything embedding this list with
* `externalTracks` is expected to set `queueSource` itself; if it
* doesn't, the queue is left undescribed rather than mislabeled.
*/
private get effectiveQueueSource(): QueueSource | undefined {
if (this.queueSource) return this.queueSource;
return this.externalTracks
? undefined
: { type: 'tracks', id: 0, label: 'All Tracks' };
}
/**
* Loading, empty and failed are three different things, and this
* list used to render all three as a permanent “Loading tracks…”
@@ -1198,7 +1222,7 @@ export class TrackList
if (filePaths.length === 0) return;
queueStore.setQueue(filePaths, 0, true);
queueStore.setQueue(filePaths, 0, true, this.effectiveQueueSource);
};
override willUpdate(
@@ -1528,7 +1552,7 @@ export class TrackList
private onTrackRowDblClick(track: library.Track) {
this.selection.clear();
queueStore.setQueue([track.FilePath], 0);
queueStore.setQueue([track.FilePath], 0, false, this.effectiveQueueSource);
}
private onTrackContextMenu(e: MouseEvent, track: library.Track) {
@@ -1600,7 +1624,7 @@ export class TrackList
switch (action) {
case 'play':
queueStore.setQueue(filePaths, 0, true);
queueStore.setQueue(filePaths, 0, true, this.effectiveQueueSource);
break;
case 'add-to-queue':
queueStore.addTracksToQueue(filePaths);
+2
View File
@@ -20,6 +20,7 @@ export const Events = {
// Config events
LibraryConfigChanged: "LibraryConfigChanged",
ThemeConfigChanged: "ThemeConfigChanged",
GeneralConfigChanged: "GeneralConfigChanged",
TrackListConfigChanged: "TrackListConfigChanged",
FavoritesConfigChanged: "FavoritesConfigChanged",
ShortcutsConfigChanged: "ShortcutsConfigChanged",
@@ -90,6 +91,7 @@ export const Events = {
ArtistDiscographyReady: "ArtistDiscographyReady",
ArtistSimilarReady: "ArtistSimilarReady",
AlbumReleasesReady: "AlbumReleasesReady",
AlbumReleasesFailed: "AlbumReleasesFailed",
DownloadProvidersChanged: "DownloadProvidersChanged",
DownloadsChanged: "DownloadsChanged",
RequestsChanged: "RequestsChanged",
@@ -1,5 +1,10 @@
import type { ReactiveController, ReactiveControllerHost } from 'lit';
import type { QueueState, QueueTrack, RepeatMode } from '../queue-store';
import type {
QueueSource,
QueueState,
QueueTrack,
RepeatMode,
} from '../queue-store';
import { queueStore } from '../queue-store';
/**
@@ -67,6 +72,10 @@ export class QueueController implements ReactiveController {
return this.state.repeatMode;
}
get source(): QueueSource {
return this.state.source;
}
// ===================================================================
// ACTIONS
// ===================================================================
@@ -87,8 +96,9 @@ export class QueueController implements ReactiveController {
filePaths: string[],
startIndex: number,
shuffleStart = false,
source?: QueueSource,
): void {
queueStore.setQueue(filePaths, startIndex, shuffleStart);
queueStore.setQueue(filePaths, startIndex, shuffleStart, source);
}
addToQueue(filePath: string): void {
+19 -4
View File
@@ -19,12 +19,26 @@ export interface QueueTrack {
export type RepeatMode = 'off' | 'all' | 'one';
/**
* Describes the collection a queue was built from an album, a
* playlist, a genre, an artist so the UI can offer to navigate back
* to it. An empty `type` means the queue has no single source (the
* whole library, or one ad-hoc track).
*/
export interface QueueSource {
type: string;
id: number;
label: string;
}
export const EMPTY_QUEUE_SOURCE: QueueSource = { type: '', id: 0, label: '' };
export interface QueueState {
tracks: QueueTrack[];
currentIndex: number;
shuffleMode: boolean;
repeatMode: RepeatMode;
sourcePlaylistId: number;
source: QueueSource;
}
// Delta event payloads (mirror Go structs in backend/queue/queue.go).
@@ -63,7 +77,7 @@ class QueueStore {
currentIndex: -1,
shuffleMode: false,
repeatMode: 'off',
sourcePlaylistId: 0,
source: EMPTY_QUEUE_SOURCE,
};
private subscribers = new Set<Subscriber>();
@@ -86,7 +100,7 @@ class QueueStore {
currentIndex: queueState.currentIndex,
shuffleMode: queueState.shuffleMode,
repeatMode: queueState.repeatMode,
sourcePlaylistId: queueState.sourcePlaylistId,
source: queueState.source ?? EMPTY_QUEUE_SOURCE,
};
this.notify();
});
@@ -221,8 +235,9 @@ class QueueStore {
filePaths: string[],
startIndex: number,
shuffleStart = false,
source: QueueSource = EMPTY_QUEUE_SOURCE,
): void {
void Queue.SetQueue(filePaths, startIndex, shuffleStart).catch(
void Queue.SetQueue(filePaths, startIndex, shuffleStart, source).catch(
reportBindingFailure('Queue.SetQueue'),
);
}
+87
View File
@@ -0,0 +1,87 @@
/**
* Turns a queue's `Source` into a "Playing from: X" link that navigates
* back to the album, playlist, smart playlist, genre, artist or the
* library's own Tracks view that a queue was built from dispatching
* the same `navigate` CustomEvent `explore-link.ts` uses, since every
* primary/detail view already listens for it (see `frontend/index.ts`).
* Kept separate from `explore-link.ts` rather than reusing its helpers:
* the destinations and attributes differ per source type, and there is
* no MBID/local-id fallback dance to share a queue source always
* carries a local id (`tracks` is the one exception, needing none).
*/
import type { QueueSource } from '../store/queue-store';
/** Fire a navigate event from the clicked element. */
function navigate(target: EventTarget, detail: Record<string, unknown>): void {
target.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail,
}),
);
}
/** Builds the `navigate` event detail for each source type. */
const SOURCE_NAVIGATE_DETAIL: Record<
string,
(source: QueueSource) => Record<string, unknown>
> = {
tracks: () => ({ view: 'tracks' }),
album: (source) => ({
view: 'explore-album-details',
localAlbumId: source.id,
albumName: source.label,
}),
playlist: (source) => ({
view: 'playlist-details',
playlistId: source.id,
playlistName: source.label,
}),
smartPlaylist: (source) => ({
view: 'smart-playlist-details',
playlistId: source.id,
playlistName: source.label,
}),
genre: (source) => ({
view: 'genre-details',
genreName: source.label,
}),
artist: (source) => ({
view: 'artist-details',
artistId: source.id,
artistName: source.label,
}),
};
/**
* Whether a source has somewhere to navigate back to. A dynamic mix
* does not it was synthesized, not fetched from a real page so it
* still describes itself (below) but should render as plain text
* rather than a dead link.
*/
export function isQueueSourceNavigable(source: QueueSource): boolean {
return source.type in SOURCE_NAVIGATE_DETAIL;
}
/**
* The text to show for a queue's source, or null when there is none
* so callers can conditionally render without duplicating that check.
*/
export function describeQueueSource(source: QueueSource): string | null {
if (source.type === '' || !source.label) return null;
return `Playing from ${source.label}`;
}
/** Navigate to the collection a queue was built from. */
export function navigateToQueueSource(
target: EventTarget,
source: QueueSource,
): void {
const buildDetail = SOURCE_NAVIGATE_DETAIL[source.type];
if (!buildDetail) return;
navigate(target, buildDetail(source));
}
+32 -7
View File
@@ -2,7 +2,7 @@
* An album page you can play from.
*
* `H-13`: no Play, no Shuffle, no Add to queue on the album header, and
* green ticks with no legend. The reason it is not simply "add three
* green ticks with no explanation. The reason it is not simply "add three
* buttons" is that this is a **catalog** page the album on it may be
* entirely the user's, partly theirs, or not theirs at all and a Play
* button that plays 7 of a release's 40 tracks under a label saying
@@ -19,7 +19,7 @@ import type { LitElement } from 'lit';
import '@components/explore-album-details/explore-album-details';
import { stub, flush, resetHarness, calls } from '@test/support/harness';
import { fixture, shadow, text } from '@test/support/render';
import { fixture, shadow, shadowAll, text } from '@test/support/render';
type Version = {
key: string;
@@ -145,22 +145,47 @@ describe('the album headers primary action', () => {
});
});
describe('the ticks have a legend', () => {
/**
* How a track that is not in the library reads.
*
* It used to be a green tick against the ones that were, plus a legend
* explaining the tick a positive mark on the common case, which put a
* column of circles down an album you own outright. The comparison that
* settled it is a streaming service dimming what it cannot play: the
* *absence* is the exception, so the absence is what gets marked.
*
* Dimming is a colour, though, so it cannot be the only signal.
* `aria-disabled` is what carries it to anyone not seeing the page.
*/
describe('a track the library does not have', () => {
beforeEach(() => {
resetHarness();
stub('library.Library.GetAlbumTracks', []);
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
});
it('names the symbol when at least one track carries it', async () => {
it('is dimmed, and the owned ones are not', async () => {
const el = await withVersion(3, 12);
const rows = shadowAll(el, '.track-row');
expect(text(el, '.tracklist-legend')).toContain('in your library');
expect(rows).toHaveLength(12);
expect(rows.filter((r) => r.classList.contains('unowned'))).toHaveLength(9);
expect(rows.filter((r) => r.classList.contains('owned'))).toHaveLength(3);
});
it('does not explain a symbol that is not on screen', async () => {
const el = await withVersion(0, 12);
it('says so without relying on the colour', async () => {
const el = await withVersion(3, 12);
const rows = shadowAll(el, '.track-row');
expect(rows[0]?.getAttribute('aria-disabled')).toBe('false');
expect(rows[11]?.getAttribute('aria-disabled')).toBe('true');
expect(rows[11]?.getAttribute('aria-label')).toContain('not in your library');
});
it('no longer marks the owned ones with a badge', async () => {
const el = await withVersion(3, 12);
expect(shadowAll(el, '.track-row library-status-indicator')).toHaveLength(0);
expect(shadow(el, '.tracklist-legend')).toBeNull();
});
});
@@ -0,0 +1,256 @@
/**
* What the album page claims about the catalog while it is waiting.
*
* The scope notice said "No catalog details for this album right now"
* on albums that were matched correctly and whose catalog data arrived
* a few seconds later. The cause was that *not having an answer yet*
* and *having been told there is no answer* were the same state: the
* page inferred a failure from a deadline, and the deadline was 12 s
* against a browse that waits on a 1 req/s limiter shared with
* `PrefetchReleases`, which fires up to eight of them when an artist
* page renders.
*
* So the rule under test is that `unavailable` is only ever reached by
* something *telling* the page the catalog did not answer
* `AlbumReleasesFailed`, or an empty result after the background fetch
* reported itself done.
*
* A fetch that is merely slow says *nothing at all*. It used to say
* "showing what your library has while the full album details load",
* which is a sentence about the page's own plumbing; the dimmed rows in
* the tracklist carry that information without a banner, so tracks
* arriving dimmed reads as the album filling in.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/explore-album-details/explore-album-details';
import { stub, emit, flush, resetHarness, calls } from '@test/support/harness';
import { fixture, shadow } from '@test/support/render';
const MBID = 'rg-0001';
/** The scope the notice is currently being rendered with. */
function scope(el: LitElement): string | null {
return shadow(el, 'catalog-scope-notice')?.getAttribute('scope') ?? null;
}
/**
* An album page mid-flight: the release group resolves, but
* `BrowseReleases` returns empty, which is what the local-first backend
* path does on a cold cache while it fetches in the background.
*/
async function coldAlbum(): Promise<LitElement> {
const el = await fixture<LitElement>('explore-album-details', {
releaseGroupMBID: MBID,
albumName: 'Glass Harbour',
});
await flush();
await el.updateComplete;
return el;
}
describe('what the album page says while the catalog is still coming', () => {
beforeEach(() => {
resetHarness();
stub('explore.Service.BrowseReleases', []);
stub('explore.Service.LookupReleaseGroup', {
mbid: MBID,
title: 'Glass Harbour',
artistCredit: 'Tideline',
});
stub('explore.Service.GetThumbnail', '');
stub('library.Library.GetAlbumTracks', []);
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('library.Library.GetAlbumCompleteness', {
owned: 0,
expected: 0,
known: false,
complete: false,
});
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
});
it('does not call a slow fetch a failure', async () => {
const el = await coldAlbum();
// No event either way yet — the background browse is still queued,
// and `catalog` is the silent scope: the notice renders nothing.
expect(scope(el)).toBe('catalog');
});
it('says the catalog is unavailable when the browse reports failing', async () => {
const el = await coldAlbum();
emit('AlbumReleasesFailed', MBID);
await flush();
await el.updateComplete;
expect(scope(el)).toBe('unavailable');
});
it('ignores a failure for a different release group', async () => {
const el = await coldAlbum();
emit('AlbumReleasesFailed', 'rg-9999');
await flush();
await el.updateComplete;
expect(scope(el)).toBe('catalog');
});
it('says unavailable when the catalog answers with nothing', async () => {
const el = await coldAlbum();
// The background fetch reported done, and the re-fetch it prompts
// still comes back empty: the catalog answered, and the answer was
// that it has no releases for this group.
emit('AlbumReleasesReady', MBID);
await flush();
await el.updateComplete;
expect(scope(el)).toBe('unavailable');
});
it('goes quiet once the releases actually arrive', async () => {
const el = await coldAlbum();
stub('explore.Service.BrowseReleases', [
{
mbid: 'rel-1',
title: 'Glass Harbour',
date: '2019-04-01',
tracks: [
{
position: 1,
discNumber: 1,
title: 'Track 1',
length: 200000,
mbid: 'rec-1',
inLibrary: false,
},
],
},
]);
emit('AlbumReleasesReady', MBID);
await flush();
await el.updateComplete;
// `catalog` is the silent scope — the notice renders nothing.
expect(scope(el)).toBe('catalog');
});
});
/**
* The album you already own in full.
*
* Identity comes from the MBID and the tracklist from the files' own
* "5/12" denominators, so between them there is nothing left for a
* browse to answer and the browse was the expensive part, waiting on
* a 1 req/s limiter behind up to eight queued prefetches.
*/
describe('an album the library already holds in full', () => {
beforeEach(() => {
resetHarness();
stub('explore.Service.BrowseReleases', []);
stub('explore.Service.LookupReleaseGroup', {
mbid: MBID,
title: 'Glass Harbour',
artistCredit: 'Tideline',
});
stub('explore.Service.GetThumbnail', '');
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
stub('library.Library.GetAlbumTracks', [
{ TrackName: 'Track 1', TrackNumber: 1, DiscNumber: 1, TrackLength: '3:20' },
]);
});
it('never asks the catalog', async () => {
stub('library.Library.GetAlbumCompleteness', {
owned: 12,
expected: 12,
known: true,
complete: true,
});
const el = await fixture<LitElement>('explore-album-details', {
releaseGroupMBID: MBID,
localAlbumId: 7,
albumName: 'Glass Harbour',
});
await flush();
await el.updateComplete;
expect(calls('explore.Service.BrowseReleases')).toHaveLength(0);
// And says nothing about it, because nothing is missing.
expect(scope(el)).toBe('catalog');
});
it('still asks when tracks are missing', async () => {
stub('library.Library.GetAlbumCompleteness', {
owned: 9,
expected: 12,
known: true,
complete: false,
});
const el = await fixture<LitElement>('explore-album-details', {
releaseGroupMBID: MBID,
localAlbumId: 7,
albumName: 'Glass Harbour',
});
await flush();
await el.updateComplete;
expect(calls('explore.Service.BrowseReleases').length).toBeGreaterThan(0);
});
it('still asks when the tags never declared a total', async () => {
// Unknown is not incomplete. The catalog is the only way to learn
// the total here, so this is exactly when it is worth asking.
stub('library.Library.GetAlbumCompleteness', {
owned: 9,
expected: 0,
known: false,
complete: false,
});
const el = await fixture<LitElement>('explore-album-details', {
releaseGroupMBID: MBID,
localAlbumId: 7,
albumName: 'Glass Harbour',
});
await flush();
await el.updateComplete;
expect(calls('explore.Service.BrowseReleases').length).toBeGreaterThan(0);
});
it('marks a partly-held album with a ring, and a full one with a tick', async () => {
stub('library.Library.GetAlbumCompleteness', {
owned: 9,
expected: 12,
known: true,
complete: false,
});
const el = await fixture<LitElement>('explore-album-details', {
releaseGroupMBID: MBID,
localAlbumId: 7,
albumName: 'Glass Harbour',
});
await flush();
await el.updateComplete;
const badge = shadow(el, 'library-status-indicator');
expect(badge?.getAttribute('status')).toBe('partial');
});
});
@@ -0,0 +1,424 @@
/**
* When the version dropdown is a choice, and when it is furniture.
*
* A release group routinely has several releases reissues, regional
* pressings, a remaster whose tracklists are word for word identical,
* and the synthetic "Your Library" entry is often a third name for the
* same one. Counting *entries* offered a control whose every option
* showed the same rows. The test is distinct tracklists.
*
* The second rule here is about an album you own part of: the page
* draws the *release*, with the tracks you are missing dimmed in place,
* because the missing ones are the information and a tracklist trimmed
* to what is on disk cannot show them at all.
*/
import { describe, expect, it, beforeEach } from 'vitest';
import type { LitElement } from 'lit';
import '@components/explore-album-details/explore-album-details';
import { stub, flush, resetHarness } from '@test/support/harness';
import { fixture, shadow, shadowAll } from '@test/support/render';
const MBID = 'rg-0001';
function track(n: number, owned = false) {
return {
position: n,
discNumber: 1,
title: `Track ${n}`,
length: 200000,
mbid: `rec-${n}`,
inLibrary: owned,
};
}
function release(mbid: string, date: string, trackCount: number, owned = 0) {
return {
mbid,
title: 'Glass Harbour',
date,
status: 'Official',
tracks: Array.from({ length: trackCount }, (_, i) =>
track(i + 1, i < owned),
),
};
}
async function albumWith(
releases: unknown[],
completeness: Record<string, unknown>,
localTracks: unknown[] = [],
): Promise<LitElement> {
stub('explore.Service.BrowseReleases', releases);
stub('library.Library.GetAlbumCompleteness', completeness);
stub('library.Library.GetAlbumTracks', localTracks);
const el = await fixture<LitElement>('explore-album-details', {
releaseGroupMBID: MBID,
localAlbumId: 7,
albumName: 'Glass Harbour',
});
await flush();
await el.updateComplete;
return el;
}
const UNKNOWN = { owned: 0, expected: 0, known: false, complete: false };
describe('the version dropdown', () => {
beforeEach(() => {
resetHarness();
stub('explore.Service.LookupReleaseGroup', {
mbid: MBID,
title: 'Glass Harbour',
artistCredit: 'Tideline',
});
stub('explore.Service.GetThumbnail', '');
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
});
it('stays hidden when every release has the same tracklist', async () => {
const el = await albumWith(
[
release('rel-1', '2019-04-01', 10),
release('rel-2', '2020-09-01', 10),
release('rel-3', '2021-01-01', 10),
],
UNKNOWN,
);
expect(shadow(el, '#version-select')).toBeNull();
});
it('appears when a release actually differs', async () => {
const el = await albumWith(
[release('rel-1', '2019-04-01', 10), release('rel-2', '2020-09-01', 14)],
UNKNOWN,
);
expect(shadow(el, '#version-select')).not.toBeNull();
});
it('stays hidden for a single release', async () => {
const el = await albumWith([release('rel-1', '2019-04-01', 10)], UNKNOWN);
expect(shadow(el, '#version-select')).toBeNull();
});
/**
* An untagged library copy against the catalog's copy of the very
* same album. This is the one that reached the running app: keys
* were `mbid || title` *per track*, which only helps when both sides
* lack ids so the local ten (no MBIDs) and the catalog's identical
* ten (with MBIDs) never compared equal, and every owned album grew
* a dropdown the moment its catalog data landed.
*/
it('counts an untagged copy and its catalog twin as one tracklist', async () => {
resetHarness();
stub('explore.Service.LookupReleaseGroup', {
mbid: MBID,
title: 'Glass Harbour',
artistCredit: 'Tideline',
});
stub('explore.Service.GetThumbnail', '');
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
stub('library.Library.GetAlbumCompleteness', UNKNOWN);
stub(
'library.Library.GetAlbumTracks',
Array.from({ length: 10 }, (_, i) => ({
TrackName: `Track ${i + 1}`,
TrackNumber: i + 1,
DiscNumber: 1,
TrackLength: '210000',
RecordingMBID: '',
})),
);
stub('explore.Service.BrowseReleases', [release('rel-1', '2019-04-01', 10)]);
const el = await fixture<LitElement>('explore-album-details', {
releaseGroupMBID: MBID,
localAlbumId: 7,
albumName: 'Glass Harbour',
});
await flush();
await el.updateComplete;
expect(shadow(el, '#version-select')).toBeNull();
});
/**
* The case that prompted the rule, reported from the running app: a
* local album with no release-group MBID at all. `hydrateLocalOnly`
* synthesises a release from the files, so the entries come out as
* "Your Library" *and* the cluster built from the very same tracks
* two entries, one tracklist, and under the old length test a
* dropdown whose both options were the same ten songs.
*/
it('stays hidden for a local album with no MBID', async () => {
resetHarness();
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
stub('library.Library.GetAlbumCompleteness', {
owned: 10,
expected: 10,
known: true,
complete: true,
});
// No RecordingMBID on any of them, which is what an untagged rip
// looks like and why the fingerprint fallback matters.
stub(
'library.Library.GetAlbumTracks',
Array.from({ length: 10 }, (_, i) => ({
TrackName: `Track ${i + 1}`,
TrackNumber: i + 1,
DiscNumber: 1,
TrackLength: '3:30',
RecordingMBID: '',
})),
);
const el = await fixture<LitElement>('explore-album-details', {
localAlbumId: 7,
albumName: 'Melophobia',
});
await flush();
await el.updateComplete;
expect(shadow(el, '#version-select')).toBeNull();
// The tracklist is still there — this hides a control, not content.
expect(shadowAll(el, '.track-row')).toHaveLength(10);
});
});
describe('an album the library holds part of', () => {
beforeEach(() => {
resetHarness();
stub('explore.Service.LookupReleaseGroup', {
mbid: MBID,
title: 'Glass Harbour',
artistCredit: 'Tideline',
});
stub('explore.Service.GetThumbnail', '');
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
});
it('draws the whole release, with the missing tracks dimmed', async () => {
const el = await albumWith(
[release('rel-1', '2019-04-01', 12, 9)],
{ owned: 9, expected: 12, known: true, complete: false },
[
{ TrackName: 'Track 1', TrackNumber: 1, DiscNumber: 1, TrackLength: '3:20' },
],
);
const rows = shadowAll(el, '.track-row');
// Twelve rows, not the nine on disk.
expect(rows).toHaveLength(12);
expect(rows.filter((r) => r.classList.contains('unowned'))).toHaveLength(3);
});
it('does not swap in a catalog tracklist when the total is unknown', async () => {
// Without a declared total there is no evidence the local copy is
// short, and preferring the catalog here would quietly replace
// every untagged album's tracklist with a guess.
const el = await albumWith(
[release('rel-1', '2019-04-01', 12, 2)],
UNKNOWN,
[
{ TrackName: 'Track 1', TrackNumber: 1, DiscNumber: 1, TrackLength: '3:20' },
{ TrackName: 'Track 2', TrackNumber: 2, DiscNumber: 1, TrackLength: '4:10' },
],
);
expect(shadowAll(el, '.track-row')).toHaveLength(2);
});
});
/**
* Which version you own, by name.
*
* There used to be a synthetic "Your Library" entry standing in for the
* matching release, which hid the thing worth knowing: you could see
* that you owned *a* version but not *which*, while the real release
* with its date, country and release count sat underneath under a
* different name. The release is marked instead.
*/
describe('the version you own', () => {
const OWNED_TRACKS = Array.from({ length: 10 }, (_, i) => ({
TrackName: `Track ${i + 1}`,
TrackNumber: i + 1,
DiscNumber: 1,
TrackLength: '210000',
RecordingMBID: `rec-${i + 1}`,
}));
/** A deluxe edition: a genuinely different track *set*, so it stays
* its own version rather than being folded as a near-duplicate. */
const DELUXE = {
mbid: 'rel-deluxe',
title: 'Glass Harbour (Deluxe)',
date: '2014-05-01',
status: 'Official',
tracks: Array.from({ length: 13 }, (_, i) => ({
position: i + 1,
discNumber: 1,
title: `Track ${i + 1}`,
length: 200000,
mbid: `rec-${i + 1}`,
inLibrary: i < 10,
})),
};
beforeEach(() => {
resetHarness();
stub('explore.Service.LookupReleaseGroup', {
mbid: MBID,
title: 'Glass Harbour',
artistCredit: 'Tideline',
});
stub('explore.Service.GetThumbnail', '');
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
});
async function twoVersions(): Promise<LitElement> {
return albumWith(
[release('rel-2013', '2013-10-08', 10), DELUXE],
UNKNOWN,
OWNED_TRACKS,
);
}
const optionTexts = (el: LitElement) =>
shadowAll(el, '#version-select option').map((o) =>
(o.textContent ?? '').trim().replace(/\s+/g, ' '),
);
it('names the release rather than calling it "Your Library"', async () => {
const options = optionTexts(await twoVersions());
expect(options).toHaveLength(2);
expect(options.some((o) => o.startsWith('Your Library'))).toBe(false);
expect(options.some((o) => o.includes('2013-10-08'))).toBe(true);
});
it('marks the owned one, in words as well as a glyph', async () => {
const owned = optionTexts(await twoVersions()).filter((o) =>
o.includes('in your library'),
);
expect(owned).toHaveLength(1);
expect(owned[0]).toContain('2013-10-08');
expect(owned[0]).toContain('\u2605');
});
it('selects the owned one by default', async () => {
const el = await twoVersions();
const select = shadow<HTMLSelectElement>(el, '#version-select');
expect(select?.value).toBe('cluster:rel-2013');
// Ten rows, not the deluxe's thirteen.
expect(shadowAll(el, '.track-row')).toHaveLength(10);
});
it('says which one it is under the dropdown', async () => {
const el = await twoVersions();
expect(shadow(el, '.version-meta')?.textContent).toContain(
'the version in your library',
);
});
it('still falls back to a synthetic when nothing matches', async () => {
// Local files that are not any known release: there is no version
// name to mark, so the stand-in is still the honest answer.
const el = await albumWith(
[release('rel-2013', '2013-10-08', 10), DELUXE],
UNKNOWN,
OWNED_TRACKS.slice(0, 4),
);
expect(
optionTexts(el).some((o) => o.startsWith('Your Library')),
).toBe(true);
});
});
/**
* Which pressing a merged cluster shows.
*
* Near-duplicates are folded by track *set*, so a resequenced pressing
* same songs, different running order merges correctly. But the
* survivor used to be whichever release came first in the browse
* response, which is meaningless ordering: on the album that prompted
* this, one 2021 pressing arrived ahead of eleven 2013 ones and the
* cluster wore the 2021 running order. The user's own files then
* matched no cluster fingerprint, so the page called their copy
* unlinked to MusicBrainz *and* offered a second version whose only
* difference was an ordering almost nothing was pressed in.
*/
describe('a merged cluster', () => {
const resequenced = {
mbid: 'rel-2021',
title: 'Glass Harbour',
date: '2021',
status: 'Official',
tracks: [10, 2, 3, 4, 5, 6, 7, 8, 9, 1].map((n, i) => ({
position: i + 1,
discNumber: 1,
title: `Track ${n}`,
length: 200000,
mbid: `rec-${n}`,
inLibrary: true,
})),
};
beforeEach(() => {
resetHarness();
stub('explore.Service.LookupReleaseGroup', {
mbid: MBID,
title: 'Glass Harbour',
artistCredit: 'Tideline',
});
stub('explore.Service.GetThumbnail', '');
stub('library.Library.GetAllLibrariesWithTrackCounts', []);
stub('library.Library.GetFilePathsByRecordingMBIDs', {});
});
it('shows the order the most releases agree on, not the first seen', async () => {
const el = await albumWith(
// The outlier first, exactly as the real browse returned it.
[
resequenced,
...Array.from({ length: 11 }, (_, i) =>
release(`rel-2013-${i}`, '2013-10-08', 10),
),
],
UNKNOWN,
Array.from({ length: 10 }, (_, i) => ({
TrackName: `Track ${i + 1}`,
TrackNumber: i + 1,
DiscNumber: 1,
TrackLength: '210000',
RecordingMBID: `rec-${i + 1}`,
})),
);
// The consensus order, so the library copy is recognised as it...
const titles = shadowAll(el, '.track-row .track-title').map((t) =>
t.textContent?.trim(),
);
expect(titles[0]).toBe('Track 1');
// ...and there is one version, so no dropdown at all.
expect(shadow(el, '#version-select')).toBeNull();
});
});
@@ -137,6 +137,7 @@ describe('home view', () => {
['/music/1.mp3', '/music/2.mp3'],
0,
true,
{ type: 'album', id: 1, label: 'Kid A' },
]);
});
@@ -0,0 +1,79 @@
/**
* The badge on cards, rows and the album title.
*
* The `partial` state was added so an album you hold nine tracks of
* looks different from one you hold all twelve of. The risk it carries
* is that a ring is a *claim about a total*, and most of an untagged
* library has no total so the rules under test are that the arc
* reflects the real fraction, that extras do not overfill it, and that
* the count reaches a screen reader rather than only an eye.
*/
import { describe, expect, it } from 'vitest';
import '@components/library-status-indicator/library-status-indicator';
import { fixture, shadow } from '@test/support/render';
/** The stroke-dashoffset the arc was drawn with, as a fraction filled. */
function filledFraction(el: Element): number {
const arc = shadow(el, '.ring-fill');
const dash = Number(arc?.getAttribute('stroke-dasharray'));
const offset = Number(arc?.getAttribute('stroke-dashoffset'));
return (dash - offset) / dash;
}
describe('the library status badge', () => {
it('draws no ring unless it is partial', async () => {
const el = await fixture('library-status-indicator', {
status: 'in-library',
});
expect(shadow(el, '.ring-fill')).toBeNull();
expect(shadow(el, 'wa-icon')?.getAttribute('name')).toBe('check');
});
it('fills the arc to the held fraction', async () => {
const el = await fixture('library-status-indicator', {
status: 'partial',
owned: 9,
expected: 12,
});
expect(filledFraction(el)).toBeCloseTo(0.75, 5);
});
it('does not overfill on bonus tracks', async () => {
const el = await fixture('library-status-indicator', {
status: 'partial',
owned: 13,
expected: 12,
});
expect(filledFraction(el)).toBeCloseTo(1, 5);
});
it('does not divide by a total it was never given', async () => {
const el = await fixture('library-status-indicator', {
status: 'partial',
owned: 3,
expected: 0,
});
expect(filledFraction(el)).toBe(0);
});
it('says the count, not just the shape', async () => {
const el = await fixture('library-status-indicator', {
status: 'partial',
owned: 9,
expected: 12,
entityType: 'album',
label: 'Glass Harbour',
});
const name = shadow(el, '.badge')?.getAttribute('aria-label') ?? '';
expect(name).toContain('9 of 12');
expect(name).toContain('Glass Harbour');
});
});
+130 -2
View File
@@ -54,13 +54,17 @@ function queueTrack(n: number, title: string): QueueTrack {
};
}
function setQueue(tracks: QueueTrack[], currentIndex = 0): void {
function setQueue(
tracks: QueueTrack[],
currentIndex = 0,
source = { type: '', id: 0, label: '' },
): void {
emit(Events.QueueChanged, {
tracks,
currentIndex,
shuffleMode: false,
repeatMode: 'off',
sourcePlaylistId: 0,
source,
});
}
@@ -287,6 +291,77 @@ describe('<now-playing>', () => {
expect(await mountScrolling(true)).not.toContain('will-scroll');
});
it('shows no source line when the queue has no known source', async () => {
const el = await fixture('now-playing');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 10 });
setQueue([queueTrack(1, 'Ashes to Ashes')]);
await flush();
await el.updateComplete;
expect(shadow(el, '[data-testid="now-playing-source"]')).toBeNull();
});
it('names where the queue came from, and navigates back to it', async () => {
const el = await fixture('now-playing');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 11 });
setQueue([queueTrack(1, 'Ashes to Ashes')], 0, {
type: 'album',
id: 7,
label: 'Scary Monsters',
});
await flush();
await el.updateComplete;
expect(text(el, '[data-testid="now-playing-source"]')).toBe(
'Playing from Scary Monsters',
);
let detail: unknown;
el.addEventListener('navigate', (e) => {
detail = (e as CustomEvent).detail;
});
shadow<HTMLElement>(el, '[data-testid="now-playing-source"]')?.click();
expect(detail).toEqual({
view: 'explore-album-details',
localAlbumId: 7,
albumName: 'Scary Monsters',
});
});
it('names a dynamic mix as text, not a dead link', async () => {
const el = await fixture('now-playing');
emit(Events.TrackChanged, { ...TRACK, trackChangeId: 12 });
setQueue([queueTrack(1, 'Ashes to Ashes')], 0, {
type: 'dynamicMix',
id: 0,
label: 'a dynamic mix',
});
await flush();
await el.updateComplete;
const sourceEl = shadow<HTMLElement>(
el,
'[data-testid="now-playing-source"]',
);
expect(sourceEl?.textContent).toBe('Playing from a dynamic mix');
expect(sourceEl?.classList.contains('navigable')).toBe(false);
let navigated = false;
el.addEventListener('navigate', () => {
navigated = true;
});
sourceEl?.click();
expect(navigated).toBe(false);
});
it('looks the way it did last time', async () => {
const el = await fixture('now-playing');
@@ -377,6 +452,59 @@ describe('<queue-panel>', () => {
// @lit-labs/virtualizer, which keeps re-measuring, so
// toMatchScreenshot never gets two identical frames and fails with
// "could not capture a stable screenshot" rather than a real diff.
it('names where the queue came from, and navigates back to it', async () => {
const el = await fixture('queue-panel', { open: true });
setQueue([queueTrack(1, 'First')], 0, {
type: 'playlist',
id: 3,
label: 'Road Trip',
});
await flush();
await el.updateComplete;
expect(text(el, '.queue-source')).toBe('Playing from Road Trip');
let detail: unknown;
el.addEventListener('navigate', (e) => {
detail = (e as CustomEvent).detail;
});
shadow<HTMLElement>(el, '.queue-source')?.click();
expect(detail).toEqual({
view: 'playlist-details',
playlistId: 3,
playlistName: 'Road Trip',
});
});
it('names a dynamic mix as text, not a dead link', async () => {
const el = await fixture('queue-panel', { open: true });
setQueue([queueTrack(1, 'First')], 0, {
type: 'dynamicMix',
id: 0,
label: 'a dynamic mix',
});
await flush();
await el.updateComplete;
const sourceEl = shadow<HTMLElement>(el, '.queue-source');
expect(sourceEl?.textContent).toBe('Playing from a dynamic mix');
expect(sourceEl?.classList.contains('navigable')).toBe(false);
let navigated = false;
el.addEventListener('navigate', () => {
navigated = true;
});
sourceEl?.click();
expect(navigated).toBe(false);
});
it('keeps rendering rows after the virtualizer settles', async () => {
const el = await fixture('queue-panel', { open: true });
+36 -4
View File
@@ -40,7 +40,7 @@ function sync(tracks: QueueTrack[], currentIndex = 0): void {
currentIndex,
shuffleMode: false,
repeatMode: 'off',
sourcePlaylistId: 0,
source: { type: '', id: 0, label: '' },
});
}
@@ -57,7 +57,23 @@ describe('queue store: full-state sync', () => {
currentIndex: 1,
shuffleMode: false,
repeatMode: 'off',
sourcePlaylistId: 0,
source: { type: '', id: 0, label: '' },
});
});
it('carries a non-empty source through from the backend', () => {
emit(Events.QueueChanged, {
tracks: [track(1)],
currentIndex: 0,
shuffleMode: false,
repeatMode: 'off',
source: { type: 'album', id: 7, label: 'Abbey Road' },
});
expect(queueStore.getState().source).toEqual({
type: 'album',
id: 7,
label: 'Abbey Road',
});
});
@@ -67,7 +83,7 @@ describe('queue store: full-state sync', () => {
currentIndex: -1,
shuffleMode: false,
repeatMode: 'off',
sourcePlaylistId: 0,
source: { type: '', id: 0, label: '' },
});
expect(queueStore.getState().tracks).toEqual([]);
@@ -244,13 +260,29 @@ describe('queue store: subscriber notification', () => {
});
describe('queue store: actions reach the backend', () => {
it('forwards setQueue with its default shuffleStart', () => {
it('forwards setQueue with its default shuffleStart and no source', () => {
queueStore.setQueue(['/a.mp3', '/b.mp3'], 1);
expect(lastArgs('queue.Queue.SetQueue')).toEqual([
['/a.mp3', '/b.mp3'],
1,
false,
{ type: '', id: 0, label: '' },
]);
});
it('forwards setQueue with the given source', () => {
queueStore.setQueue(['/a.mp3'], 0, true, {
type: 'playlist',
id: 7,
label: 'Road Trip',
});
expect(lastArgs('queue.Queue.SetQueue')).toEqual([
['/a.mp3'],
0,
true,
{ type: 'playlist', id: 7, label: 'Road Trip' },
]);
});
@@ -0,0 +1,103 @@
import { describe, expect, it, vi } from 'vitest';
import {
describeQueueSource,
isQueueSourceNavigable,
navigateToQueueSource,
} from '@utils/queue-source-link';
import type { QueueSource } from '@store/queue-store';
describe('describeQueueSource', () => {
it('returns null for an empty source', () => {
expect(describeQueueSource({ type: '', id: 0, label: '' })).toBeNull();
});
it('describes a navigable source', () => {
expect(
describeQueueSource({ type: 'album', id: 1, label: 'Kid A' }),
).toBe('Playing from Kid A');
});
it('describes a dynamic mix, which has no page to navigate to', () => {
expect(
describeQueueSource({ type: 'dynamicMix', id: 0, label: 'a dynamic mix' }),
).toBe('Playing from a dynamic mix');
});
});
describe('isQueueSourceNavigable', () => {
it.each([
['album', true],
['playlist', true],
['smartPlaylist', true],
['genre', true],
['artist', true],
['dynamicMix', false],
['', false],
] as const)('%s -> %s', (type, expected) => {
expect(isQueueSourceNavigable({ type, id: 0, label: '' })).toBe(expected);
});
});
describe('navigateToQueueSource', () => {
function fireOn(source: QueueSource): unknown {
const target = document.createElement('div');
let detail: unknown;
target.addEventListener('navigate', (e) => {
detail = (e as CustomEvent).detail;
});
navigateToQueueSource(target, source);
return detail;
}
it('builds the album navigate detail', () => {
expect(fireOn({ type: 'album', id: 7, label: 'Scary Monsters' })).toEqual(
{ view: 'explore-album-details', localAlbumId: 7, albumName: 'Scary Monsters' },
);
});
it('builds the playlist navigate detail', () => {
expect(fireOn({ type: 'playlist', id: 3, label: 'Road Trip' })).toEqual({
view: 'playlist-details',
playlistId: 3,
playlistName: 'Road Trip',
});
});
it('builds the smart playlist navigate detail', () => {
expect(
fireOn({ type: 'smartPlaylist', id: 4, label: 'Recently Added' }),
).toEqual({
view: 'smart-playlist-details',
playlistId: 4,
playlistName: 'Recently Added',
});
});
it('builds the genre navigate detail, which has no numeric id', () => {
expect(fireOn({ type: 'genre', id: 0, label: 'Jazz' })).toEqual({
view: 'genre-details',
genreName: 'Jazz',
});
});
it('builds the artist navigate detail', () => {
expect(fireOn({ type: 'artist', id: 5, label: 'Björk' })).toEqual({
view: 'artist-details',
artistId: 5,
artistName: 'Björk',
});
});
it('does nothing for a source with no destination', () => {
const dispatch = vi.fn();
const target = { dispatchEvent: dispatch } as unknown as EventTarget;
navigateToQueueSource(target, { type: 'dynamicMix', id: 0, label: 'a mix' });
expect(dispatch).not.toHaveBeenCalled();
});
});
+8
View File
@@ -4,6 +4,8 @@ import {download} from '../models';
import {tracklist} from '../models';
import {context} from '../models';
export function GetDefaultPage():Promise<string>;
export function GetDownloadPreferences():Promise<download.AutoDownloadPrefs>;
export function GetFavoritesIconStyle():Promise<string>;
@@ -14,6 +16,8 @@ export function GetLibraryDirectory():Promise<string>;
export function GetPinDefaultPlaylist():Promise<boolean>;
export function GetQueueFallback():Promise<string>;
export function GetScanConcurrency():Promise<string>;
export function GetShortcuts():Promise<Record<string, string>>;
@@ -32,6 +36,8 @@ export function Save():Promise<void>;
export function SetContext(arg1:context.Context):Promise<void>;
export function SetDefaultPage(arg1:string):Promise<void>;
export function SetDownloadPreferences(arg1:download.AutoDownloadPrefs):Promise<void>;
export function SetFavoritesIconStyle(arg1:string):Promise<void>;
@@ -42,6 +48,8 @@ export function SetLibraryDirectory(arg1:string):Promise<void>;
export function SetPinDefaultPlaylist(arg1:boolean):Promise<void>;
export function SetQueueFallback(arg1:string):Promise<void>;
export function SetScanConcurrency(arg1:string):Promise<void>;
export function SetShortcut(arg1:string,arg2:string):Promise<void>;
+16
View File
@@ -2,6 +2,10 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function GetDefaultPage() {
return window['go']['config']['Config']['GetDefaultPage']();
}
export function GetDownloadPreferences() {
return window['go']['config']['Config']['GetDownloadPreferences']();
}
@@ -22,6 +26,10 @@ export function GetPinDefaultPlaylist() {
return window['go']['config']['Config']['GetPinDefaultPlaylist']();
}
export function GetQueueFallback() {
return window['go']['config']['Config']['GetQueueFallback']();
}
export function GetScanConcurrency() {
return window['go']['config']['Config']['GetScanConcurrency']();
}
@@ -58,6 +66,10 @@ export function SetContext(arg1) {
return window['go']['config']['Config']['SetContext'](arg1);
}
export function SetDefaultPage(arg1) {
return window['go']['config']['Config']['SetDefaultPage'](arg1);
}
export function SetDownloadPreferences(arg1) {
return window['go']['config']['Config']['SetDownloadPreferences'](arg1);
}
@@ -78,6 +90,10 @@ export function SetPinDefaultPlaylist(arg1) {
return window['go']['config']['Config']['SetPinDefaultPlaylist'](arg1);
}
export function SetQueueFallback(arg1) {
return window['go']['config']['Config']['SetQueueFallback'](arg1);
}
export function SetScanConcurrency(arg1) {
return window['go']['config']['Config']['SetScanConcurrency'](arg1);
}
+5 -1
View File
@@ -1,8 +1,8 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {explore} from '../models';
import {time} from '../models';
import {context} from '../models';
import {time} from '../models';
import {jobs} from '../models';
export function AdoptPausedIndexBuild():Promise<void>;
@@ -11,6 +11,8 @@ export function BackfillLibraryDiscographies():Promise<void>;
export function BackfillLibraryLyrics():Promise<void>;
export function BackfillReleaseGroupMBIDs():Promise<void>;
export function BrowseReleaseGroups(arg1:string):Promise<Array<explore.MBReleaseGroup>>;
export function BrowseReleases(arg1:string):Promise<Array<explore.MBRelease>>;
@@ -25,6 +27,8 @@ export function CoverArtGroupURL(arg1:string):Promise<string>;
export function CoverArtURL(arg1:string):Promise<string>;
export function GenerateMix(arg1:context.Context,arg2:Array<string>,arg3:boolean):Promise<Array<string>>;
export function GetArtistImageCached(arg1:string):Promise<string>;
export function GetArtistImageCachedPath(arg1:string):Promise<string>;
+8
View File
@@ -14,6 +14,10 @@ export function BackfillLibraryLyrics() {
return window['go']['explore']['Service']['BackfillLibraryLyrics']();
}
export function BackfillReleaseGroupMBIDs() {
return window['go']['explore']['Service']['BackfillReleaseGroupMBIDs']();
}
export function BrowseReleaseGroups(arg1) {
return window['go']['explore']['Service']['BrowseReleaseGroups'](arg1);
}
@@ -42,6 +46,10 @@ export function CoverArtURL(arg1) {
return window['go']['explore']['Service']['CoverArtURL'](arg1);
}
export function GenerateMix(arg1, arg2, arg3) {
return window['go']['explore']['Service']['GenerateMix'](arg1, arg2, arg3);
}
export function GetArtistImageCached(arg1) {
return window['go']['explore']['Service']['GetArtistImageCached'](arg1);
}
+2
View File
@@ -17,6 +17,8 @@ export function CancelScan():Promise<void>;
export function FullRescan():Promise<library.ScanMetrics>;
export function GetAlbumCompleteness(arg1:number):Promise<library.AlbumCompleteness>;
export function GetAlbumTracks(arg1:number):Promise<Array<library.Track>>;
export function GetAlbumTracksByLibrary(arg1:number,arg2:number):Promise<Array<library.Track>>;
+4
View File
@@ -26,6 +26,10 @@ export function FullRescan() {
return window['go']['library']['Library']['FullRescan']();
}
export function GetAlbumCompleteness(arg1) {
return window['go']['library']['Library']['GetAlbumCompleteness'](arg1);
}
export function GetAlbumTracks(arg1) {
return window['go']['library']['Library']['GetAlbumTracks'](arg1);
}
+38 -2
View File
@@ -1155,6 +1155,7 @@ export namespace explore {
status: string;
artistCredit?: string;
tracks?: MBTrack[];
releaseGroupMbid?: string;
static createFrom(source: any = {}) {
return new MBRelease(source);
@@ -1169,6 +1170,7 @@ export namespace explore {
this.status = source["status"];
this.artistCredit = source["artistCredit"];
this.tracks = this.convertValues(source["tracks"], MBTrack);
this.releaseGroupMbid = source["releaseGroupMbid"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
@@ -1678,6 +1680,24 @@ export namespace library {
this.ReleaseYear = source["ReleaseYear"];
}
}
export class AlbumCompleteness {
owned: number;
expected: number;
known: boolean;
complete: boolean;
static createFrom(source: any = {}) {
return new AlbumCompleteness(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.owned = source["owned"];
this.expected = source["expected"];
this.known = source["known"];
this.complete = source["complete"];
}
}
export class Artist {
ID: number;
Name: string;
@@ -2275,6 +2295,22 @@ export namespace playlist {
export namespace queue {
export class Source {
type: string;
id: number;
label: string;
static createFrom(source: any = {}) {
return new Source(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.type = source["type"];
this.id = source["id"];
this.label = source["label"];
}
}
export class Track {
id: number;
audioFileId: number;
@@ -2312,7 +2348,7 @@ export namespace queue {
currentIndex: number;
shuffleMode: boolean;
repeatMode: string;
sourcePlaylistId: number;
source: Source;
static createFrom(source: any = {}) {
return new State(source);
@@ -2324,7 +2360,7 @@ export namespace queue {
this.currentIndex = source["currentIndex"];
this.shuffleMode = source["shuffleMode"];
this.repeatMode = source["repeatMode"];
this.sourcePlaylistId = source["sourcePlaylistId"];
this.source = this.convertValues(source["source"], Source);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
+3 -1
View File
@@ -45,8 +45,10 @@ export function SaveState():Promise<void>;
export function SetContext(arg1:context.Context):Promise<void>;
export function SetFallbackSource(arg1:queue.FallbackSource):Promise<void>;
export function SetPlayer(arg1:queue.TrackLoader):Promise<void>;
export function SetQueue(arg1:Array<string>,arg2:number,arg3:boolean):Promise<void>;
export function SetQueue(arg1:Array<string>,arg2:number,arg3:boolean,arg4:queue.Source):Promise<void>;
export function ToggleShuffle():Promise<void>;
+6 -2
View File
@@ -86,12 +86,16 @@ export function SetContext(arg1) {
return window['go']['queue']['Queue']['SetContext'](arg1);
}
export function SetFallbackSource(arg1) {
return window['go']['queue']['Queue']['SetFallbackSource'](arg1);
}
export function SetPlayer(arg1) {
return window['go']['queue']['Queue']['SetPlayer'](arg1);
}
export function SetQueue(arg1, arg2, arg3) {
return window['go']['queue']['Queue']['SetQueue'](arg1, arg2, arg3);
export function SetQueue(arg1, arg2, arg3, arg4) {
return window['go']['queue']['Queue']['SetQueue'](arg1, arg2, arg3, arg4);
}
export function ToggleShuffle() {