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