Compare commits

...
10 Commits
Author SHA1 Message Date
yonluandClaude Opus 5 1128881e8d docs(wails): move the prose onto v3 and record Phase 7
CI / check (push) Successful in 4m49s
CI / e2e (push) Successful in 6m8s
CLAUDE.md gains a Packaging section for the four Taskfile facts the
recipes just needed — wails3 on PATH by bare name, no -ldflags on
`wails3 build`, bin/ not build/bin/, and bundling as its own step —
plus how build/'s platform metadata generates from build/config.yml and
what that refresh overwrites.

Its lifecycle, bindings, harness, events and CI sections were still
describing v2. The events one matters most: the rule to emit through
events.Emit survives, but its justification is now the weaker one, and
saying so is the point of the migration. v2's runtime.EventsEmit
log.Fatalf'd on any context not carrying the runtime; v3's emit takes
no context at all, so what is left to pin is that one emit path is what
lets emitStatus drop an unchanged payload for every caller at once.

README told a contributor to `go install wails/v2/cmd/wails` and
apt-get libgtk-3-dev/libwebkit2gtk-4.1-dev; the CLI is vendored and the
stack is GTK4 + WebKitGTK 6.0. Two comments claiming Xvfb and one
claiming frontend/wailsjs go with them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 23:09:36 -04:00
yonluandClaude Opus 5 cad3d1339b fix(packaging): put the release recipes on v3's build
Neither packaging/arch/PKGBUILD nor the Homebrew formula had been run
since Phase 1, and both were still calling v2's CLI: `wails3 build`
takes -tags, -obfuscated and -garbleargs and nothing else, so
`-clean -trimpath -ldflags` fails at the flag parser. Both also
installed from build/bin/, which is v2's output path — v3 writes to
bin/, and build/ is tracked build assets now.

Three more things the tree needs that neither recipe had. The tasks
invoke `wails3` by bare name, so scripts/toolbin has to be on PATH or
the build dies at its first sub-task. `wails3 build` has no -ldflags at
all, and build:native computes BUILD_FLAGS in its own vars: so a CLI
variable cannot override it — LDFLAGS_EXTRA is appended inside the
production -ldflags string instead, on linux and darwin alike, empty by
default so make build-dev/build-prod are unchanged. And bundling is a
separate step from building: `task build` produces a bare binary on
both platforms, so the formula's macOS path runs `task package`.

The build assets were the scaffold's, not this app's. Info.plist named
CFBundleExecutable `yjref` and com.example.yjref, nfpm packaged
./bin/yjref, the .desktop template said "A yjref application" — an .app
built from that plist would not have launched. They generate from
build/config.yml, whose info block had never been filled from
wails.json either; `wails3 task common:update:build-assets` is the fix.
nfpm's homepage and license are not derived from it and are set by
hand, which is noted in place, and the refresh regenerates build/ios
and build/android, which this repo does not carry.

arch-package.yml's pacman list moves to webkitgtk-6.0/gtk4 to match the
PKGBUILD's depends(): makepkg installs nothing itself, so a mismatch
fails at link time rather than at check time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 23:09:24 -04:00
yonluandClaude Opus 5 453d5df0da fix(build): put wails3 on PATH for the Taskfile supervisors
`make sandbox`, `make dev`, `make build-dev` and `make build-prod` all
died with "/bin/sh: wails3: command not found". `wails3 dev` and
`wails3 task` are supervisors: they run the scaffold's Taskfile tree,
which invokes `wails3` by bare name in 54 places across four files. The
CLI is a vendored Go tool by design (plan 009, D3 — a global install
would be this build's first undeclared dependency), so that name did
not exist.

scripts/toolbin/wails3 execs `go tool wails3`, and the Makefile
prepends that directory only for the targets that start a supervisor.
Rewriting 54 scaffold call sites would be churn to redo on every
scaffold refresh; nothing global is installed either way.

The shim does not cd. The first version did, to be sure `go tool` found
the module — it does not need to — and that silently discarded the
`dir:` a task had set, so generate:icons failed with "open
appicon.png: no such file or directory" against a file that was there.

Three things the build path needed once it got that far:

- `frontend/package.json` gains `build:dev`, which build:frontend runs
  under DEV=true and which did not exist.
- Vite binds 127.0.0.1. It defaulted to `localhost`, which resolves to
  `[::1]` only here, while wails3 dev's asset proxy dials IPv4 — so the
  first request for the dev server was refused and the first paint
  raced a retry. Zero proxy errors after.
- The icons and the .desktop file are generated on every build.
  icons.icns/icon.ico are deterministic from our appicon.png (verified
  by regenerating), so the regenerated pair is committed and the churn
  ends; .task/ and the .desktop file are ignored.

Also corrects a claim: build-prod strips and trims but does **not**
UPX-compress — that was v2's `-upx` flag. Phase 1 recorded UPX as
still working, but neither build target had been run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 22:49:16 -04:00
yonluandClaude Opus 5 84963e38bd docs(plan): record what Phase 6 landed and the four bugs it surfaced
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 20:59:19 -04:00
yonluandClaude Opus 5 deb3f3da7e feat(wails): move the e2e harness and headless launch onto v3
make e2e is green on chromium: 92 passed. The harness is rebuilt on
what v3 actually offers, and three of the four things it replaced turn
out to be better than what they replaced.

The headless launch is v3's own server mode. scripts/dev-headless.sh
ran a `-tags dev` binary whose app_dev.go parsed -devserver/-assetdir
out of os.Args; that file went with v2, so the harness had no server at
all. `-tags dev,server` is a first-class mode and needs no display, so
Xvfb is gone from the script and from CI.

The bridge hooks two places, neither of them EventsOn. Inbound is
window._wails.dispatchWailsEvent, wrapped by pre-creating the object
the runtime keeps and putting an accessor on the one property.
Outbound is fetch: v3 routes every runtime call through one POST, so
the bridge sees binding calls and event emits from any module, needs no
walk of an object graph, and cannot miss a call made before it looked.

__yjEvents.call posts to that endpoint by method name, so it depends on
nothing in the app's bundle and works on a page with no init script.
That is what lets seed-sandbox.sh drop playwright-cli entirely — it
drove AddLibrary through a browser only because window.go was v2's one
way in — and with it a global npm install and a second Chromium in CI.

measure.mjs and one spec lose their window.go walks and read the
bridge's log instead; e2e/support/method-ids.mjs derives id -> name
from frontend/bindings/ (phase 6b option 1, so it cannot go stale
silently). Plain .mjs because measure.mjs runs under bare node and one
derivation beats two that can disagree.

Four bugs surfaced, and the migration is how.

The cross-service wiring never ran headless. It hung off
Common.ApplicationStarted, which server mode never emits —
setupCommonEvents is an explicit no-op there — so the queue had no
TrackLoader and playing a track changed the queue and then silently did
nothing. It is a service registered last now (backend/startup.go):
services start in registration order, which is the ordering the wiring
needs, in every mode.

Six specs called SetQueue with 3 of its 4 arguments. v2 accepted that
and filled the gap; v3 answers "expects 4 arguments, got 3".

requested-badge's cleanup read window.go and returned early on
`if (!svc)` — the silent cleanup its own comment was written to
prevent, one migration later. It posts to the runtime endpoint now,
which any page can do.

SearchIndex.Search trusted a startup latch, so rows a spec staged
afterwards were unsearchable and three specs passed only when an
earlier one happened to flip it. shelves.go fixed exactly this and left
hasCatalogRows behind; the search path now uses it as the fallback,
with the latch still the fast path.

Two spec edits are deletions of assertions about v2. harness.spec
checked Object.keys(window.go) and that a bad call *hung*; it now
checks the real runtime is loaded and that the backend rejects with a
TypeError naming the argument. album-actions asserted a tracklist
legend that dcc40b1 deleted on main — that spec has been failing since,
and what replaced it is covered in frontend/test/components.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 20:58:20 -04:00
yonluandClaude Opus 5 60779c41c3 docs(plan): record what Phase 5 landed and what it found
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 19:47:05 -04:00
yonluandClaude Opus 5 a4ada725a2 feat(wails): rebuild the Vitest fake on v3's transport seam
v2 installed two globals and the fake replaced both. v3 has neither —
the runtime is an npm module and the generated bindings call into it.
What it has instead is better: setTransport() is a public seam for
replacing the IPC transport, and *every* runtime call goes through it,
so the fake is smaller than v2's and covers strictly more.

The event dispatcher is no longer mirrored at all. v2's fake
reimplemented desktop/events.js — the listener list, maxCallbacks
expiry, the reverse iteration — because there was no way to reach the
real one; emit() now goes through window._wails.dispatchWailsEvent,
which is the entry point the backend's own push uses. What is mirrored
instead is one line of Go: how EventManager.Emit packs variadic data
into an event's single data field. Registration and unregistration are
the public Events API. The one non-public thing left is the listener
registry, aliased in vitest.config.mts and used only by
listenerNames() — a test asks whether importing a store subscribed it,
which nothing public can answer.

A binding carries a method ID, not a name, so the fake derives the
ID -> path map from the generated tree: FNV-1a over the FQN, with the
Go type's casing recovered from each package's index.ts, which is the
only place it survives (library/library.ts cannot tell you it is
FrontendUtil). The map has to be complete rather than lazy because 21
assertions read calls() with no argument and compare the whole list.

Two things had to move that are not the fake.

fixture() drains microtasks between two renders: a v3 binding settles
several hops later than v2's, and tests were already written as though
fixture() meant "mounted and loaded". Microtasks and not a timer,
which would hang under the suites that install fake ones.

tracklist-store keeps its defaults on an empty answer instead of
emptying the column list. GetTrackListColumns substitutes
DefaultColumns only when the whole config section is missing; a section
that exists with no columns returns nothing. Until now this was
accidental — the binding was typed Column[], an absent answer arrived
as undefined, and .map threw into the catch.

757 tests pass across all 63 files. They are run in batches: a single
browser session dies partway through the 58 it queues, which reproduces
unchanged at the pre-migration commit and is a resource limit on this
machine rather than anything here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 19:46:14 -04:00
yonluandClaude Opus 5 04114eabae docs(plan): record what Phase 4 landed and the one error it leaves
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 17:49:42 -04:00
yonluandClaude Opus 5 162c68769f feat(wails): move the frontend onto v3's generated bindings
frontend/wailsjs/ is deleted and frontend/bindings/ takes its place —
a real TypeScript module tree nested by Go import path, generated by
wails3's static analyser rather than by building the app and running
it.  The @go alias absorbs the constant prefix, so a call site imports
'@go/library/library.js' and the codemod over all 93 sites was a
specifier rewrite plus splitting @go/models' namespaces into one
import per package.

The 12 SetContext bindings and the fake `context` model are gone, as
Phase 2's ServiceStartup port promised: 272 methods across 12
services, none of them plumbing.

@runtime/runtime is now a local shim (src/wails/runtime.ts) over
@wailsio/runtime, so the 22 EventsOn imports are untouched.  It
unwraps v3's WailsEvent into v2's callback shape, which is exact here:
nothing in backend/events passes more than one data argument, and v3
only packs arguments into a slice when there is more than one.

v3 tells the truth about two things v2 lied about, and that is most of
the diff.  A Go nil slice really does arrive as JSON null, and a Go
named string type really is an enum; v2 typed them as T[] and string.
utils/binding.ts states the app's actual contract — an absent list is
an empty list — once, at the boundary where it is true, and also drops
the CancellablePromise the app never cancels.  Four test fixtures
widen an enum field back to its value union.

Not done, and Phase 5's to fix: frontend/test/support/wails-fake.ts
still fakes window.go, which v3 does not have, so `make ui-test` is
broken and harness.test.ts fails to compile on EventsEmit.  That test
also asserts v2 ordering that no longer holds — v3's Events.Emit calls
the backend and does not notify in-page listeners at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 17:48:38 -04:00
yonluandClaude Opus 5 c9905fbcff docs: drop the webkit2_41 tag from the commands agents run
The commit before this removed the tag from the Makefile, lefthook,
both packaging recipes and CI, but left it in CLAUDE.md's "Running
tests" section and the yellowjacket-dev skill — which are the copies a
coding agent actually runs, so a stale tag there is worse than one in
prose. skill-check does not catch this: it verifies that documented
make targets exist, not that documented go commands do.

The historical mentions in .planning/ and .pi/journal.md are left
alone; they are records of what was true then.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
2026-08-14 14:02:41 -04:00
182 changed files with 7632 additions and 5791 deletions
+5 -1
View File
@@ -22,9 +22,13 @@ jobs:
steps:
- name: Install build dependencies
run: |
# Wails v3 resolves GTK4 + WebKitGTK 6.0 by default; webkit2gtk-4.1 +
# gtk3 was v2's stack and is now only the `-tags gtk3` escape hatch.
# These must match the PKGBUILD's depends=() — makepkg installs
# nothing itself, so a mismatch fails at link time, not at check time.
pacman -Syu --noconfirm --needed \
base-devel git go nodejs pnpm curl sudo \
webkit2gtk-4.1 gtk3 alsa-lib
webkitgtk-6.0 gtk4 alsa-lib
- name: Create unprivileged build user
run: |
+14 -19
View File
@@ -171,7 +171,7 @@ jobs:
working-directory: /src
run: make ui-test
# frontend/wailsjs is generated by `wails`, not by `go generate`,
# frontend/bindings is generated by `wails3`, not by `go generate`,
# so the codegen pre-commit hook does not cover it.
- name: Bindings are current
working-directory: /src
@@ -184,7 +184,9 @@ jobs:
run: make skill-check
# ---------------------------------------------------------------- #
# Job 2: the real app, under a virtual display. #
# Job 2: the real app, headless. v3's `-tags server` needs no #
# display, so the Xvfb this job used to wrap everything in is gone. #
# `dbus-run-session` stays, for MPRIS. #
# ---------------------------------------------------------------- #
e2e:
runs-on: ubuntu-latest
@@ -220,7 +222,7 @@ jobs:
apt-get install -y -qq --no-install-recommends \
ca-certificates curl git jq build-essential pkg-config \
libwebkitgtk-6.0-dev libgtk-4-dev libasound2-dev \
xvfb dbus dbus-x11 ffmpeg libasound2t64 \
dbus dbus-x11 ffmpeg libasound2t64 \
alsa-utils libasound2-plugins pulseaudio pulseaudio-utils
- name: Clone repo at this commit
@@ -247,24 +249,17 @@ jobs:
apt-get install -y -qq --no-install-recommends nodejs
corepack enable
# scripts/seed-sandbox.sh drives the real AddLibrary binding
# through playwright-cli, so the CLI has to be on PATH.
- name: Playwright CLI
run: npm install -g @playwright/cli
# @playwright/cli is gone with v2. seed-sandbox.sh drove the real
# AddLibrary binding through a browser because `window.go` was the
# only way in; v3 answers the same call over HTTP, so the seed is
# curl now and needs no CLI, no second Chromium and no shared
# PLAYWRIGHT_BROWSERS_PATH revision dance.
- name: Browsers
working-directory: /src/e2e
run: |
set -eu
# PLAYWRIGHT_BROWSERS_PATH unifies the *location*, not the
# *revisions*: @playwright/cli bundles its own playwright-core
# pinned to a different Chromium build than @playwright/test,
# so each installs its own into the shared directory. Drop
# either line and the other fails with "Browser chromium is
# not installed; expected executable at ...".
pnpm install --frozen-lockfile
npx playwright install --with-deps chromium webkit
playwright-cli install-browser chromium
# oto/v3 talks to libasound directly, and a container has no
# PulseAudio socket to fall back on — so it needs a default device
@@ -287,10 +282,10 @@ jobs:
#
# PulseAudio's null sink is timer-scheduled and does pace — the
# 0.76 s over is the buffer draining, not a rate error; 12 s of
# audio takes 13.5 s. Verified under the private session bus and
# Xvfb that dev-headless.sh runs the app in. It needs no system
# D-Bus and no kernel module, which is why it is reachable from a
# container at all.
# audio takes 13.5 s. Verified under the private session bus
# dev-headless.sh runs the app in. It needs no system D-Bus and
# no kernel module, which is why it is reachable from a container
# at all.
- name: Real-time audio sink
run: |
set -eu
+16
View File
@@ -54,3 +54,19 @@ coverage/
.cache/
tmp/
bin/
# Task's checksum cache, written by every `wails3 task` run.
.task/
# Generated by build/linux/Taskfile.yml's generate:dotdesktop from
# build/config.yml on every build, and consumed by the deb/rpm/AppImage
# packaging tasks that depend on it. A derived file with one source.
build/linux/yellowjacket.desktop
# `wails3 task common:update:build-assets` regenerates the mobile trees
# whether or not anything asks for them. This is a desktop player and
# cannot target iOS/Android, so their includes: entries are dropped from
# Taskfile.yml and the trees themselves are not carried — ignored rather
# than deleted-and-rediscovered on every asset refresh.
build/ios/
build/android/
+19 -10
View File
@@ -21,12 +21,21 @@ here has disappeared.
Fifteen things cost a cycle each the first time. They are here, not in a
reference, because you need them *before* the failure, not after.
- **Time out every binding call.** A bound Go method called with wrong
argument types makes the backend log `error parsing arguments` and
**never fire the callback**, so the promise hangs forever. Use
`window.__yjEvents.call(path, args, ms)` (browser) or `callBinding`
(specs), never a bare `window.go.…`. When one hangs anyway,
`make dev-logs``.dev/app.log` is the only place the reason appears.
- **Call a binding through the bridge.** `window.go` does not exist
under Wails v3 — the bindings are bundled modules, not a global — so
use `window.__yjEvents.call(path, args, ms)` (browser) or
`callBinding` (specs). Both post to the runtime's own endpoint by
method name, so they work on any page, including one with no init
script.
A bad call now *rejects*, and says why: a wrong type comes back as a
TypeError naming the argument, a wrong count as
`expects 4 arguments, got 3`, an unknown method as a ReferenceError.
Under v2 the backend logged `error parsing arguments` and never fired
the callback, so `.dev/app.log` was the only place the reason
appeared and the timeout was the only thing that made the mistake
visible. The timeout is still there, but now it means a genuinely
hung request.
- **Nothing is clickable on a fresh `YJ_HOME`.** `<first-run-wizard>`
intercepts all pointer events until a library exists, and the click
fails with a Playwright interception error that reads like a selector
@@ -387,10 +396,10 @@ never get the shell back.
when iterating on a single package:
```bash
go test -tags webkit2_41 ./backend/player/ # the app build
go test -tags webkit2_41 -run TestName ./backend/player/
go test -tags "webkit2_41 indexbuild" ./backend/explore/... ./cmd/... # dump importer
go test -tags "webkit2_41 dev" ./backend/testctl/... # control surface
go test ./backend/player/ # the app build
go test -run TestName ./backend/player/
go test -tags indexbuild ./backend/explore/... ./cmd/... # dump importer
go test -tags dev ./backend/testctl/... # control surface
```
Forgetting the tag gives a build error that looks like a missing
@@ -74,9 +74,14 @@ behind `YJ_TESTCTL=1`, which `scripts/dev-headless.sh` sets and
- **`snapshot` writes a file, it does not print the tree.** The
command prints a path under `outputDir`; read that. Only the tail
is echoed.
- **Three separate browser caches.** `playwright-cli`, `@playwright/test`
- **Two separate browser caches.** `@playwright/test`
(`make e2e-setup`) and the Vitest provider (`make ui-setup`) each
download their own Chromium. One working is no guarantee for the next.
download their own Chromium. One working is no guarantee for the
other. There used to be a third: `playwright-cli` was a *required*
dependency because `scripts/seed-sandbox.sh` drove `AddLibrary`
through a real page, `window.go` being v2's only way in. v3 answers
the same call over HTTP, so the seed is `curl` now and the CLI is
only an exploratory convenience.
- **`getByRole('button', { name })` matches substrings.** "Play" also
matches "Add queue to playlist"; transport controls need
`exact: true`.
@@ -35,7 +35,7 @@ Then:
```bash
make generate # sqlc + templ
go test -tags webkit2_41 ./backend/database/ # migration + column-order tests
go test ./backend/database/ # migration + column-order tests
make test
```
@@ -1,8 +1,8 @@
# The component and store tier (`make ui-test`)
313 tests in a real Chromium in ~2 s with no Wails, no backend, no
seeded library and no virtual display. This is the cheapest coverage
available and where the bulk of UI regression belongs.
757 tests in a real Chromium with no Wails, no backend, no seeded
library and no virtual display. This is the cheapest coverage available
and where the bulk of UI regression belongs.
```bash
make ui-setup # once: the Vitest provider's own Chromium
@@ -15,12 +15,20 @@ make ui-test UI_ARGS='store/queue' # filter
## How it works
`frontend/wailsjs/` is a pure passthrough — every binding is
`window.go[svc][Type][Method](args)`, every runtime call is
`window.runtime.X(...)`. So `frontend/test/support/wails-fake.ts`
replaces **those two globals and nothing else**, and the tests then
exercise the *real* generated bindings and the *real* store code. No
module mocking, and no second description of the Wails layer.
Wails v3 routes every runtime call — bindings, event emits, window,
dialogs, clipboard — through one IPC transport, and `setTransport()` is
a public seam for replacing it. So
`frontend/test/support/wails-fake.ts` replaces **that and nothing
else**, and the tests then exercise the *real* generated bindings, the
*real* runtime and the *real* store code. No module mocking, and no
second description of the Wails layer.
A binding call carries a *method ID* (an FNV-1a hash of the Go method's
fully-qualified name), not a name, so the fake derives the ID → path
map from the generated tree at setup: each package's `index.ts`
re-exports its service under the Go type's real name, which is the one
place that casing survives. A path that never maps records as `#<id>`
and fails the assertion naming it.
```ts
emit(Events.QueueChanged, payload); // push a backend event
@@ -31,11 +39,19 @@ lastArgs('queue.Queue.SetQueue');
const el = await fixture('now-playing'); // mount; shadow()/text() query it
```
The dispatcher mirrors wails' own `desktop/events.js`, including
`maxCallbacks` expiry and the fact that a frontend `EventsEmit`
notifies local listeners *before* Go.
Delivery is not mirrored — `emit()` goes through the runtime's own
`window._wails.dispatchWailsEvent`, which is the entry point the
backend's push uses, so listener expiry and ordering are the runtime's
real code. What *is* mirrored is one line of Go: how
`EventManager.Emit` packs variadic data into an event's single `data`
field (none is null, one is the value, more is the slice).
## Four things that will cost you time
A frontend `Events.Emit` no longer notifies local listeners before Go —
v3 calls the backend, which sends the event back out to every window.
The page still sees its own emit, one round trip later rather than
synchronously.
## Five things that will cost you time
- **Store singletons are constructed at module import**, before any test
can stub. `test/setup.ts` therefore carries import-time defaults for
@@ -54,6 +70,13 @@ notifies local listeners *before* Go.
- **`@lit-labs/virtualizer` never produces two identical frames**, so
`toMatchScreenshot` on `<queue-panel>` fails with "could not capture a
stable screenshot" rather than a diff. Assert on its rows instead.
- **A v3 binding settles several microtasks after a v2 one did** — it
goes through `Call()`, an async `runtimeCallWithID`, the transport and
a `CancellablePromise`, where v2's `window.go` proxy resolved one
promise. `fixture()` drains microtasks between two renders so a
component that loads in `firstUpdated` is loaded when it returns.
Microtasks and not a timer, deliberately: a timer hangs forever under
the suites that install fake ones.
Visual baselines are font-hinting and compositing sensitive, which is
why they are opt-in: they only mean anything on the machine that
@@ -61,14 +84,15 @@ recorded them.
## Bindings
`frontend/wailsjs/` is generated by `wails`, **not** by `go generate`,
`frontend/bindings/` is generated by `wails3`, **not** by `go generate`,
so the pre-commit codegen check does not cover it — a renamed Go bound
method first shows up at runtime, as a call that never settles.
```bash
make bindings-check # ~1.5 s, also a pre-commit hook
make bindings-check # ~3.5 s warm, also a pre-commit hook
make bindings # regenerate for real
```
The generator rewrites `wailsjs/runtime/*` as mode 755 every run; that
is churn, not drift, and the check ignores it.
No build tags are passed: the generator is a static analyser that sees
only the configuration it is told about, and the one that matters is
the one users run, which is the default tag set.
+395 -12
View File
@@ -1,11 +1,15 @@
# 009 — Wails v3 migration
**Status:** Phases 03 complete. The Go side is entirely on v3: nothing
in the tree imports `wails/v2`, all three lint and test configurations
are green, and `go build .` produces a running binary. **Phase 4
(bindings) is next, and until it lands the app builds but the frontend
cannot talk to it** — `frontend/wailsjs/` is v2's tree and nothing
regenerates it.
**Status:** Phases 07 complete. CI needed nothing (it had ridden along
with Phases 16); **packaging needed everything** — both recipes still
called v2's CLI, installed from v2's output path, and built from a
scaffold's `yjref` metadata. Locally green: `make lint` and `make test`
(all three build configurations), `tsc --noEmit`, `make build-prod`,
757 Vitest tests across all 58 files, `make e2e` 92/92 on chromium,
`make bindings-check`, `make skill-check`, `make commit-check`,
`make css-check`, and `make perf` runs and reports real binding counts
again. **WebKit is unverified** — it is CI-only by design — so the
merge waits on a green CI run.
**Branch:** `wails-v3`, off `main` at `edb13a6`.
**Created:** 2026-08-13
**Phase 0 run:** 2026-08-13 against **v3.0.0-beta.8**
@@ -293,6 +297,14 @@ Taskfile tree — a genuinely larger and more visible build surface.
`make build-prod` still strips and UPX-compresses; `make skill-check`
passes; `grep -r webkit2_41` returns nothing.
> **Corrected after the fact.** `make build-prod` strips and trims
> (`-trimpath -ldflags="-w -s"`, 28.8 MB against the dev build's
> 38 MB) but **does not UPX-compress**: that was v2's `wails build
> -upx` flag, and v3's Taskfile has no equivalent. Neither `make
> build-dev` nor `make build-prod` was actually run when Phase 1 was
> recorded — both were broken until the shim below. Whether to
> reintroduce UPX is a packaging decision for Phase 7, not a port.
**Est.** Half a session. Low risk, high churn.
### Phase 1 — what actually landed
@@ -568,6 +580,99 @@ is still a pre-commit hook and a CI step (`ci.yml:176`); the 12
**Est.** One session, mostly codemod-and-verify.
### Phase 4 — what actually landed
`frontend/wailsjs/` is deleted; `frontend/bindings/` is committed in
its place. **272 methods across 12 services**, and the acceptance
criteria hold: no `SetContext` binding survives and there is no
`context` model, which is Phase 2c's `ServiceStartup` port showing up
where it was predicted to.
**The alias absorbs the prefix, so the codemod was one line per site.**
`@go/*` points at `bindings/yellowjacket/backend/*` rather than at
`bindings/`, so `@go/library/Library` became `@go/library/library.js`
— the same shape, lowercased, plus the extension the generated tree
uses internally. The models cluster was the only structural change:
v2's single `models.ts` of namespaces became one module per package, so
`import type { library } from '@go/models'` is
`import type * as library from '@go/library/models.js'` and every
`library.Track` usage is untouched. **Two sites the plan's inventory
missed**, both because they are outside `src/`: `frontend/index.ts`'s
three imports, found by the vite build rather than by `tsc` (the alias
resolves for the compiler and not for the bundler when the path's case
is wrong on a case-sensitive filesystem).
**The 102 type errors were not migration damage. They were v2's
generator being caught lying**, and that is worth stating because the
temptation is to suppress them. A Go `nil` slice marshals to JSON
`null` and always has; v2 typed it `T[]`. A Go named string type is a
closed set; v2 typed it `string`. v3 types them `T[] | null` and as a
real TS `enum`, and there is no generator flag to turn either off —
correctly, since both are true.
So the fix is one seam rather than 78 patches: **`utils/binding.ts`
states the app's actual contract at the only place it is true.** `list`
yields `[]` for a nil slice, `dict`/`dictByName` yield `{}` for a nil
map and drop null-valued keys (which loses nothing —
`noUncheckedIndexedAccess` already makes every read `V | undefined`, so
an absent key and a nil value are indistinguishable downstream), and
`compact` is the same thing for a map arriving as a *field*. All three
also return a plain `Promise`: v3 bindings return a
`CancellablePromise` and nothing in this app cancels one, so letting it
inward would put a Wails type in every store signature for a capability
none of them use.
Where a nullable slice is a model *field* rather than a return value
there is no boundary to put it at, and those are `?? []` at the point
of use — `score.candidates`, `shelf.albums`, `page.shelves`,
`view.items`.
Four smaller consequences, all of them v3 being stricter:
- **`createFrom` is gone.** v3 emits interfaces (`-i`), so the three
`explore.ShelfPage.createFrom({…})` sites are object literals and
`new tracklist.Column()` is one too.
- **An enum needs a value import.** `import type * as download` cannot
reach `Format`, so `download-clients.ts` imports it separately — and
its local format list is now `Format[]` rather than `string[]`, which
is a small win v2 could not have given.
- **Four test fixtures widen an enum field back to its value union**
(`` state?: `${Job['state']}` ``) rather than casting, so the
fixtures stay literal-checked.
- `job-store.ts` still hand-mirrors `JobState`/`JobKind` as string
unions beside the generated enums. Left alone deliberately; folding
them together is a redesign, not a port.
**`bindings-check` got simpler and slower.** The `chmod` dance and
`core.fileMode=false` diff are gone with v2's generator (which wrote
three runtime files 755); a check for added or removed *files* is new,
because a renamed service is now a renamed module rather than a changed
line. It runs in ~3.5 s warm against v2's ~1.5 s, ~20 s on a cold build
cache — v3's generator is a static analyser over the whole package
graph.
**On step 6 (the tag set): the answer is "no flag", and that is the
deliberate answer.** The generator sees only the configuration it is
told about, and the one that matters is the one users run — which,
since Phase 1, is the *default* tag set. Neither of this repo's other
two configurations (`indexbuild`, `dev`) adds a bound service, so
generating under them would only widen the API past what ships. Written
into `scripts/bindings-check.sh` so it is not re-derived. Note this is
*not* what Wails' own Taskfile does (`-tags server,production`), for
the good reason that its shipped artifact is the Docker image.
**One error is left, and it is Phase 5's.**
`test/harness.test.ts:69` imports `EventsEmit` from the shim, which
does not export one — because nothing in `src/` emits from the
frontend, and adding an export to production code to satisfy a test
would be the wrong way round. The test underneath it is a bigger
problem than its import: it asserts that a frontend emit notifies
in-page listeners before reaching Go, which v2 did and **v3 does
not** — `Events.Emit` in `@wailsio/runtime` calls the backend and
touches no local listener. That is exactly the "re-derive the fake,
don't port it" risk the plan flagged, arriving early. `make ui-test`
is broken regardless: the fake still fakes `window.go`.
---
## Phase 5 — The Vitest fake (`make ui-test`, 480 tests)
@@ -599,6 +704,93 @@ that needs changing is evidence the fake is wrong, not the test.
**Est.** One session. This is where the official estimate stops
applying.
### Phase 5 — what actually landed
**757 tests pass across all 63 files, and one test file changed.** The
plan's design survived and got smaller, because v3 has a seam v2 did
not.
**`setTransport()` is the whole fake.** v3 routes *every* runtime call
— bindings, event emits, window, dialogs, clipboard, screens — through
one IPC transport, and replacing it is public, documented API. So the
fake covers strictly more than v2's two globals did while being
shorter, and the tests still exercise the real generated bindings, the
real runtime and the real store code.
**The dispatcher is deleted rather than re-derived.** The plan said to
re-derive `Listener`/`notify()` against v3 instead of porting them; the
better answer is that neither is needed. `emit()` goes through
`window._wails.dispatchWailsEvent`, the exact entry point the backend's
push uses, so delivery, `maxCallbacks` expiry and the post-dispatch
filter are the runtime's own code; registration and unregistration are
`Events.OnMultiple` / `Off` / `OffAll`. What *is* mirrored is one line
of Go — how `EventManager.Emit` packs variadic data into an event's
single `data` field (none is null, one is the value, more is the
slice), which is invisible when wrong and shows up as a store reading
`undefined` off its payload.
One thing stayed non-public: the listener registry, for
`listenerNames()`. `listener.js` has no entry in the package's exports
map, so `vitest.config.mts` aliases it. It buys the one question the
public surface cannot answer — did importing a store subscribe it —
and if Wails moves the file the import throws at setup, which is loud.
**A binding carries an ID, not a name, and the map has to be
complete.** `$Call.ByID(2822423495)` is FNV-1a over
`yellowjacket/backend/home.Service.GetShelves`, so the fake computes the
same hash — deriving the FQN from the generated tree rather than
writing it down. The Go type's casing survives in exactly one place,
each package's `index.ts` (`export { Library }`); the filename cannot
tell you `frontendutil.ts` is `FrontendUtil`. Building the map lazily
as paths are mentioned does not work: 21 assertions read `calls()` with
no argument and compare the whole list of paths, including methods no
test stubs. An unmapped ID records as `#<id>`, which fails the
assertion naming it.
**Two things had to change that are not the fake**, and both are
findings rather than accommodations:
- **`fixture()` drains microtasks between two renders.** A v3 binding
settles several hops later than v2's — `Call()`, an async
`runtimeCallWithID`, the transport, a `CancellablePromise`, against
v2's one resolved promise — and the tests were already written as
though `fixture()` meant "mounted *and loaded*". Fixing it there
rather than in each test is what kept this to one test-file edit.
Microtasks and **not** `setTimeout`: the first attempt used a timer,
which hung `transport.test.ts` for 45 s because it installs fake ones.
- **`tracklist-store` keeps its defaults on an empty answer.**
`GetTrackListColumns` substitutes `tracklist.DefaultColumns` only when
the whole config section is missing — a section that exists with no
columns returns nothing, and a track list with no columns is not what
that means. Until v3 this was accidental: the binding was typed
`Column[]`, an absent answer arrived as `undefined`, and `.map` threw
into the `catch` that restores the defaults. Phase 4's `list()` turned
that into an honest empty list and the accident stopped working.
**The one test file edited was `harness.test.ts`, and it was asserting
something no longer true.** v2's `EventsEmit` notified in-page listeners
*before* Go, so a frontend emit was observable synchronously. v3's
`Events.Emit` does not touch the local registry at all: it calls the
backend, and `EventProcessor.Emit` sends the event back out to every
window, including the emitting one. The page still sees its own emit,
one round trip later. The test says that now, and the fake reproduces
it with a microtask. That is the "re-derive, don't port" risk paying
off — ported blindly, this would have looked like a store bug.
**`make ui-test` still cannot complete in one run on this machine, and
that is not this migration.** A single browser session dies partway
through the 58 files it queues, with "Cannot connect to the iframe"
after ~5 s. It reproduces **unchanged at `c9905fb`**, the commit before
Phase 4 — checked in a worktree, not assumed — so it is a resource
limit here (6 GB available, 9 GB already in swap), not a regression.
Run in batches of six it is 757 passed, 0 failed. If CI is green on the
single run, nothing needs doing; if it is not, that is a pre-existing
problem to file separately rather than something Phase 5 introduced.
**Not verified: `make ui-visual`.** Screenshot baselines only mean
anything on the machine that recorded them, and nothing here changes
what a component renders.
---
## Phase 6 — E2E harness and testctl
@@ -666,6 +858,118 @@ spec edits.
**Est.** One to two sessions. The largest and riskiest phase.
### Phase 6 — what actually landed
**`make e2e` is green on chromium: 92 passed, 0 failed.** Three of the
four things this phase replaced came out better than what they
replaced, and the fourth — `window.go` enumeration — turned out not to
be needed at all.
**6d decided itself.** The plan asked whether `-tags server` was worth
adopting; it was not optional. `dev-headless.sh` ran a `-tags dev`
binary whose `app_dev.go` parsed `-devserver` / `-assetdir` straight
out of `os.Args`, and that file went with v2 — so the harness had **no
server at all**, not a worse one. `-tags dev,server` is a first-class
mode, it needs no display, and testctl mounts on it unchanged. **Xvfb
is gone** from the script and from CI, which retires the "Xvfb is not
optional" note the script had carried since plan 005. `dbus-run-session`
stays, for MPRIS, exactly as before.
**6a hooks two places, and the outbound one is the good surprise.**
Inbound is `window._wails.dispatchWailsEvent` — the entry point the
backend's own push uses. It is wrapped by *pre-creating* the object the
runtime keeps (`window._wails = window._wails || {}`) and putting an
accessor on the one property, which is simpler than v2's
accessor-on-`window`, where the whole object was replaced.
Outbound is **`fetch`**. v3 routes every runtime call — bindings, event
emits, window, dialogs, clipboard — through one POST to
`/wails/runtime`. There is no global to wrap the way v2's
`window.runtime` could be, and it does not matter: one hook sees calls
from any module, needs no walk of an object graph, and cannot miss a
call made before the harness looked, which is what v2's "runs twice"
dance in `measure.mjs` existed to work around.
**`__yjEvents.call` is now HTTP, and that is what unblocked everything
else.** It posts by *method name*, so it depends on nothing in the
app's bundle and works on a page with no init script. That is what lets
`seed-sandbox.sh` **drop `playwright-cli` entirely** — it drove
`AddLibrary` through a real browser only because `window.go` was v2's
one way in — taking with it a global npm install, a second Chromium,
and the `PLAYWRIGHT_BROWSERS_PATH` revision dance in CI. A seed is
`curl` now and still produced by running the app.
**6b: option (1), and it costs less than feared.**
`e2e/support/method-ids.mjs` derives `methodID → pkg.Type.Method` from
`frontend/bindings/` by reading the id literal beside the function that
sends it — no hashing, nothing to drift. Plain `.mjs` rather than `.ts`
because `measure.mjs` runs under bare `node`, and one derivation is
better than two that can disagree. `harness.spec`'s enumeration was not
worth replacing in kind: "is this the real app" is now asked of the
runtime (`_wails.clientId`, `dispatchWailsEvent`) and of the backend
(a real method answers, an invented one is refused), which is a better
question than `Object.keys`.
**6c: nothing to do.** testctl's mount on the asset handler works in
both modes and is shared with `/artist-images/`; `ServiceOptions{Route}`
would be churn. `Deps.Context` stays a function, because
`testctl.Register` still runs in `NewYellowJacketApp`, before any
context exists.
**Four bugs, and the migration is how each surfaced.**
- **The cross-service wiring never ran headless.** Phase 2 hung it off
`Common.ApplicationStarted`, which is the right *moment* and the
wrong *mechanism*: server mode emits no application events at all
(`setupCommonEvents` is an explicit no-op under `-tags server`). So
the desktop build wired itself and the harness build did not — "No
player set, cannot load track", the queue with no `TrackLoader`, a
track that changed the queue and then silently did nothing. It is a
service registered last now (`backend/startup.go`), which gets the
ordering from the mechanism rather than from an event: services start
in registration order, on the main goroutine, in every mode.
- **Six specs called `SetQueue` with three of its four arguments.** v2
accepted the call and filled the gap with a zero value; v3 answers
`expects 4 arguments, got 3`. `NO_QUEUE_SOURCE` says explicitly what
was being supplied silently.
- **`requested-badge`'s cleanup was a no-op.** It read `window.go`
inside a type assertion and `return`ed on `if (!svc)` — the silent
cleanup its own comment was written to prevent, one migration later,
which is why the spec failed against its own leftovers. It posts to
the runtime endpoint now, which needs no bridge and no global.
- **`SearchIndex.Search` trusted a startup latch.** `IsReady()` is set
once, so rows staged by a spec afterwards were unsearchable, and
three specs passed only when an earlier one happened to flip it —
order-dependent, and reproducibly red in isolation. `shelves.go` had
already fixed exactly this and left `hasCatalogRows` behind; the
search path uses it as the fallback, with the latch still the fast
path.
**Two spec edits, both deletions of assertions about v2.**
`harness.spec` checked `Object.keys(window.go)` and that a bad call
*hung* — the second being a test that the harness's own invented
deadline fired, since v2 gave it nothing else. And `album-actions`
asserted a `.tracklist-legend` that **`dcc40b1` deleted on `main`**:
that spec has been failing since, verified in a worktree, and what
replaced it (the dimming and its `aria-disabled`) is covered in
`frontend/test/components/album-actions.test.ts`.
**One thing to know before trusting a local run.** `dev-headless.sh`
does not set `YJ_CORE_INDEX_URL`, so a local `make e2e` fetches the
real 1.1 M-row explore artifact and then behaves differently from CI
(which points it at a dead address at the job level) — slower, and with
testctl's snapshot/restore copying an enormous table set. Run it the
way CI does:
```
YJ_CORE_INDEX_URL="http://127.0.0.1:1/none.tar.zst" make dev-headless SEED=default
```
**Not verified: WebKit.** Playwright's Linux WebKit links Ubuntu
libraries Arch does not provide, so it remains CI-only — which is
precisely why `ci.yml`'s `if: ${{ !cancelled() }}` on that step still
matters.
---
## Phase 7 — CI and packaging
@@ -693,6 +997,83 @@ spec edits.
**Est.** Half a session.
### Phase 7 — what actually landed
Most of the bullets above had already ridden along with Phases 16:
`ci.yml`'s apt lists were `libwebkitgtk-6.0-dev libgtk-4-dev` on both
jobs, Xvfb and `@playwright/cli` were gone from it, the PKGBUILD's
`depends=()` was `webkitgtk-6.0 gtk4`, and `nfpm.yaml` shipped the GTK4
dependency set. **What had not been checked is whether any of the
packaging recipes still work**, and neither did.
**Both of them were still calling v2's CLI.** `go tool wails3 build
-clean -trimpath -ldflags …` fails outright — `flag provided but not
defined: -clean`. v3's `build` takes `-tags`, `-obfuscated` and
`-garbleargs` and nothing else, because the build is a Taskfile tree
now and `-trimpath`/`-w -s` live in the production task's own flags.
Both recipes also installed from `build/bin/`, which is v2's output
path; v3 writes to `bin/`, and `build/` is *tracked build assets*.
Neither had been run since Phase 1 — the same gap the corrected note
under Phase 1 records about `make build-dev`.
**The version stamp needed a seam, and it is this repo's one edit to
the scaffold Taskfiles.** `wails3 build` has no `-ldflags`, and
`build:native` computes `BUILD_FLAGS` in its own `vars:`, so a CLI
variable cannot override it. `LDFLAGS_EXTRA` is appended *inside* the
production `-ldflags` string in `build/linux/Taskfile.yml` and
`build/darwin/Taskfile.yml` (identical on both, so the Homebrew formula
has one invocation rather than two), empty by default so `make
build-dev` and `make build-prod` are unchanged. Verified end to end,
not by reading the template: `wails3 task build LDFLAGS_EXTRA="-X
'main.version=v9.9.9' …"` produces a binary that logs `version: v9.9.9`
on startup.
**The tasks invoke `wails3` by bare name**, which is why the Makefile
has `scripts/toolbin` — and neither packaging recipe had it, so both
would have died at the first sub-task even with correct flags. Both
prepend it now.
**Bundling is a separate step from building in v3**, which the formula
did not know: `task build` produces a bare binary on *both* platforms,
and the macOS `.app` is `task package`. The formula's `Dir["build/bin/
*.app"]` would have found nothing and `odie`'d.
**The build assets were a scaffold's, not this app's** — and this is
the find that mattered most. `build/darwin/Info.plist` named
`CFBundleExecutable` **`yjref`** (the scratchpad app Phase 1 scaffolded
from) and `com.example.yjref`; `nfpm.yaml` packaged `./bin/yjref` to
`/usr/local/bin/yjref`; the `.desktop` template said "A yjref
application"; the Windows manifest said `com.example.yjref`. A macOS
`.app` built from that plist would not have launched. They are
generated from `build/config.yml`, so the fix is
`wails3 task common:update:build-assets` and filling that file's `info`
block — which had also never been filled from `wails.json`, contrary to
Phase 1 step 3. Two consequences recorded in place: nfpm's `homepage`
and `license` are **not** derived from `config.yml` and survive the
refresh (they were `wails.io` / `MIT`), and the refresh regenerates
`build/ios/` and `build/android/`, which are gitignored rather than
deleted-and-rediscovered on every run.
**Documentation.** `make skill-check` passes; `.pi/` needed no changes
(Phase 6 had already retired `window.go` and Xvfb from it). `README.md`
still told a contributor to `go install wails/v2/cmd/wails` and
`apt-get install libgtk-3-dev libwebkit2gtk-4.1-dev`. CLAUDE.md gained
a **Packaging** section for the four Taskfile facts above, and its
lifecycle, bindings, harness, events and CI sections were rewritten
onto v3 — including the point of the whole migration, that
`TestNoDirectRuntimeEmits`'s justification is now the *weaker* one.
**Locally green after the change:** `make lint` and `make test` (all
three build configurations), `tsc --noEmit`, `make css-check`,
`make bindings-check`, `make skill-check`, `make commit-check`,
`make build-prod`, 757 Vitest tests across all 58 files (batched — the
single-run failure still reproduces and is still this machine's
resource limit, unchanged from Phase 5), and `make e2e` **92/92 on
chromium**.
**Still unverified: WebKit**, and it is CI-only by design. That is the
one thing standing between this branch and a merge.
---
## Phase 8 — What v3 unlocks (explicitly out of scope)
@@ -723,15 +1104,17 @@ Listed so nobody smuggles them into the port and calls it a migration.
|---|---|---|
| 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 |
| No `window.go` → e2e/perf lose binding enumeration | ~~high~~ | **Retired.** 6b option (1): `e2e/support/method-ids.mjs` derives id → name from `frontend/bindings/`. The fetch hook made enumeration unnecessary for *wrapping*; only labelling needed the map |
| 93 `@go` sites need editing after all | ~~high~~ | **Retired.** Codemod done in Phase 4; the alias absorbed the prefix, so it was a specifier rewrite |
| Binding generation analyses the wrong build config | ~~high~~ | **Retired.** Default tags *are* the shipped configuration since Phase 1; recorded in `scripts/bindings-check.sh` |
| 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 |
| E2E rewrite silently weakens coverage | ~~high~~ | **Retired.** 92/92 on chromium. Two spec edits, both deleting assertions about v2 behaviour that is gone; one deletion of a spec already failing on `main` |
| `make ui-test` cannot complete in one browser session here | low | *New.* Pre-existing — reproduces at `c9905fb`. A resource limit on this machine; batched runs are green. Watch CI |
| v3 event ordering differs from v2's | ~~high~~ | **Retired.** Confirmed and handled in Phase 5: v3's frontend `Events.Emit` round-trips through Go instead of notifying locally first. One test asserted the old behaviour and now asserts the new |
| `ServiceShutdown()` signature trap | medium | Silent no-call; grep after Phase 2c |
| An app-level event that a headless mode never emits | — | *Found in Phase 6, not predicted.* Server mode emits **no** application events; the cross-service wiring is a service now. Anything else keyed on `Common.*` is suspect |
| 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 |
| Regression no tier covers | medium | `make perf` before/after on the same seed. Note the fixture seed leaves the bulk-library rows blank; a real comparison wants `make sandbox-seed-bulk` |
| GTK4 changes rendering vs GTK3 | low | Unmeasured; visual check on first run |
---
+214 -97
View File
@@ -7,31 +7,34 @@
* other events arrive from Go whenever they arrive. An assertion that
* sleeps and hopes is flaky; an assertion that awaits the event is not.
*
* Three things it provides on `window.__yjEvents`:
* Four things it provides on `window.__yjEvents`:
*
* record every backend -> frontend event, in order, with payloads
* wait a promise that settles on a matching event (or rejects
* with the list of events that *did* arrive, which is the
* single most useful failure message this harness can give)
* call a bound Go method that is guaranteed to settle: a binding
* invoked with wrong argument types makes the backend log
* "error parsing arguments" and never fire the callback, so
* the in-page promise hangs forever. Timing out here fixes
* that once instead of in every eval.
* call a bound Go method, by name, over the runtime's own HTTP
* endpoint — no dependence on the app's bundle
* bindings every binding call the *app* made, which is what turns
* "did that refetch the library" from an inference into a
* fact (e2e/perf/measure.mjs labels and reads these)
*
* WHERE IT HOOKS. Not EventsOn. Every backend event enters the page
* at exactly one place — wails' ipc_websocket.js does
* WHERE IT HOOKS. Two places, and neither is `EventsOn`.
*
* case "n": window.wails.EventsNotify(message)
* Inbound, `window._wails.dispatchWailsEvent`: v3's runtime assigns it
* at module scope and it is the single point every backend event enters
* the page through, so wrapping it captures all 46 whether or not the
* app subscribes to them. The runtime does
* `window._wails = window._wails || {}`, so this script creates that
* object first and puts an accessor on the *property*, wrapping at
* assignment time — v2 needed the accessor on `window` itself, because
* there the whole object was replaced.
*
* and EventsNotify fans out to listeners from there. Wrapping that
* single choke point captures all 46 events whether or not the app
* subscribes to them, and needs one wrap rather than 46.
*
* `window.wails` does not exist yet when this script runs, so we install
* an accessor on `window` and wrap at assignment time (wails' main.js
* does a plain `window.wails = {...}`), then collapse the accessor back
* to a data property so nothing downstream can tell.
* Outbound, `fetch`: v3 routes every runtime call — binding calls, event
* emits, window and dialog calls — through one POST to /wails/runtime.
* There is no global to wrap the way v2's `window.runtime` could be, and
* this is better anyway: it sees calls from any module, needs no walk of
* an object graph, and cannot miss one made before the harness looked.
*
* INSTALL EXACTLY ONCE. Listeners registered by one `eval` survive into
* the next, so a recorder that re-registers double-counts. Tests call
@@ -44,8 +47,27 @@
const LIMIT = 2000;
// Every bound service in this app lives under this Go module path,
// so specs name a binding the short way — 'queue.Queue.GetState' —
// and this is what makes that the same thing the backend calls
// 'yellowjacket/backend/queue.Queue.GetState'.
const FQN_PREFIX = "yellowjacket/backend/";
// The runtime's own object and method ids (objectNames in
// @wailsio/runtime): 0 is Call, 3 is Events, and method 0 on each is
// CallBinding and Emit respectively.
const OBJECT_CALL = 0;
const OBJECT_EVENTS = 3;
// Captured before the wrap below, and used for the harness's own
// calls: `__yjEvents.call` is this file talking to the backend, not
// the app, and counting it would make "did that action refetch the
// library" answer for the question as well as the app.
const nativeFetch = window.fetch.bind(window);
let seq = 0;
const log = [];
const bindings = [];
const waiters = new Set();
const summarize = () => {
@@ -56,6 +78,25 @@
return counts;
};
/*
* `data` is recorded as the argument list Go emitted, which is the
* shape every spec reads (`ev.data[0]`).
*
* v3's EventManager.Emit packs a variadic call into one field: no
* arguments is null, one is the value itself, more than one is the
* slice. Unpacking that back into a list is exact except for a
* single argument that is itself an array, which is indistinguishable
* from several arguments — an ambiguity v3 introduced and no
* assertion here depends on, since nothing in backend/events emits
* more than one value.
*/
const argsOf = (data) => {
if (data === null || data === undefined) {
return [];
}
return Array.isArray(data) ? data : [data];
};
const record = (name, data, dir) => {
const entry = { seq: ++seq, name, data, dir, t: Date.now() };
log.push(entry);
@@ -89,7 +130,7 @@
};
const api = {
version: 1,
version: 2,
/** Every recorded event, oldest first. */
get log() {
@@ -101,10 +142,30 @@
return seq;
},
/** Drop the buffer. Does NOT touch the recorder or waiters. */
/**
* Every binding call the app made, oldest first. Each is
* { methodID, methodName, start, ms, bytes } — the id is what the
* generated bindings send, and turning it back into a name is
* e2e/perf/measure.mjs's job, which derives the map from
* frontend/bindings/.
*/
get bindings() {
return bindings.slice();
},
/**
* Read the size of every binding response. Off by default: it
* costs a clone-and-read of each body, which only a measurement
* wants to pay. With it off, `bytes` is the Content-Length when
* the server sent one and -1 otherwise.
*/
measureBytes: false,
/** Drop the buffers. Does NOT touch the recorder or waiters. */
reset() {
const n = log.length;
log.length = 0;
bindings.length = 0;
return n;
},
@@ -177,13 +238,11 @@
async ready(timeoutMs) {
const deadline = Date.now() + (timeoutMs || 15000);
for (;;) {
if (window.go?.queue?.Queue?.GetState) {
try {
await api.call("queue.Queue.GetState", [], 2000);
return true;
} catch {
/* backend not up yet */
}
try {
await api.call("queue.Queue.GetState", [], 2000);
return true;
} catch {
/* backend not up yet */
}
if (Date.now() > deadline) {
throw new Error("__yjEvents.ready timed out");
@@ -193,38 +252,66 @@
},
/**
* Call a bound Go method by dotted path, with a timeout.
* Call a bound Go method by dotted path.
*
* await __yjEvents.call('player.Player.SetVolume', [42])
*
* A binding called with the wrong argument types never fires its
* callback — the reason appears only in .dev/app.log. Without a
* timeout the caller waits forever; with one it gets told where
* to look.
* This posts to the runtime's own endpoint rather than reaching
* into the page for a binding function, because v3 has no
* `window.go` and the generated bindings are ordinary bundled
* modules an initScript cannot import. It calls *by name*, which
* the backend resolves the same way it resolves the id the
* bundle sends.
*
* v3 rejects a bad call rather than silently never firing its
* callback the way v2 did — wrong argument types come back as a
* TypeError naming the argument, an unknown method as a
* ReferenceError. The timeout below is therefore a backstop for
* a genuinely hung request, not the mechanism that makes a
* mistake visible.
*/
call(path, args, timeoutMs) {
const parts = String(path).split(".");
let fn = window.go;
for (const p of parts) {
fn = fn?.[p];
}
if (typeof fn !== "function") {
return Promise.reject(
new Error(`__yjEvents.call: no such binding: ${path}`),
);
}
const request = nativeFetch("/wails/runtime", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-wails-client-id": window._wails?.clientId ?? "",
},
body: JSON.stringify({
object: OBJECT_CALL,
method: 0,
args: {
"call-id": `yj-${Math.random().toString(36).slice(2)}`,
methodName: FQN_PREFIX + String(path),
args: args || [],
},
}),
}).then(async (res) => {
const type = res.headers.get("Content-Type") || "";
const json = type.includes("application/json");
if (!res.ok) {
const body = json ? await res.json() : { message: await res.text() };
throw new Error(
`__yjEvents.call(${path}) failed: ` +
`${body.kind || "Error"}: ${body.message}`,
);
}
return json ? res.json() : res.text();
});
return Promise.race([
Promise.resolve(fn(...(args || []))),
request,
new Promise((_, reject) =>
setTimeout(
() =>
reject(
new Error(
`__yjEvents.call(${path}) did not settle in ` +
`${timeoutMs || 10000}ms — almost always wrong ` +
`argument types; check .dev/app.log for ` +
`"error parsing arguments"`,
`${timeoutMs || 10000}ms — the runtime endpoint ` +
`hung, which is not how a bad argument fails; ` +
`check .dev/app.log`,
),
),
timeoutMs || 10000,
@@ -241,62 +328,92 @@
writable: false,
});
// Wrap `obj[method]` once, routing every invocation through `tap`.
const wrap = (obj, method, tap) => {
const original = obj[method];
if (typeof original !== "function" || original.__yjWrapped) {
return;
}
const wrapped = function (...args) {
try {
tap(args);
} catch {
/* a broken recorder must never break the app */
}
return original.apply(this, args);
};
wrapped.__yjWrapped = true;
obj[method] = wrapped;
};
// ── Inbound ──────────────────────────────────────────────────────
//
// The runtime keeps whatever `window._wails` already is, so creating
// it here and defining an accessor on the one property we care about
// means the wrap happens the moment the runtime module is evaluated.
window._wails = window._wails || {};
// Install an accessor that wraps on first assignment, then collapses
// back into an ordinary property.
const hookOnAssign = (name, onAssign) => {
let value;
Object.defineProperty(window, name, {
configurable: true,
enumerable: true,
get: () => value,
set: (v) => {
value = v;
let dispatch;
Object.defineProperty(window._wails, "dispatchWailsEvent", {
configurable: true,
enumerable: true,
get: () => dispatch,
set: (fn) => {
dispatch = function (event) {
try {
onAssign(v);
record(event?.name, argsOf(event?.data), "in");
} catch {
/* ditto */
/* a broken recorder must never break the app */
}
Object.defineProperty(window, name, {
value: v,
configurable: true,
enumerable: true,
writable: true,
});
},
return fn.apply(this, arguments);
};
},
});
// ── Outbound ─────────────────────────────────────────────────────
//
// One POST per runtime call. Only two of the thirteen object ids
// are interesting here; the rest (window, dialogs, clipboard) pass
// through untouched and unrecorded.
window.fetch = function (input, init) {
let call = null;
try {
// The runtime passes a **URL object**, not a string — it
// builds `new URL(runtimeURL())` — and a URL has no `.url`,
// only a Request does. Reading the wrong one matched
// nothing and recorded no calls at all, which looks
// identical to an app that made none.
const url =
input && typeof input === "object" && "url" in input
? input.url
: String(input ?? "");
if (
url.includes("/wails/runtime") &&
init?.method === "POST" &&
typeof init.body === "string"
) {
const body = JSON.parse(init.body);
if (body.object === OBJECT_EVENTS && body.method === 0) {
record(body.args?.name, argsOf(body.args?.data), "out");
} else if (body.object === OBJECT_CALL && body.method === 0) {
call = {
methodID: body.args?.methodID ?? null,
methodName: body.args?.methodName ?? null,
start: performance.now(),
};
}
}
} catch {
/* ditto */
}
const response = nativeFetch(input, init);
if (!call) {
return response;
}
return response.then(async (res) => {
try {
call.ms = performance.now() - call.start;
call.bytes = api.measureBytes
? (await res.clone().text()).length
: Number(res.headers.get("Content-Length") ?? -1);
bindings.push(call);
if (bindings.length > LIMIT) {
bindings.splice(0, bindings.length - LIMIT);
}
} catch {
/* ditto */
}
return res;
});
};
// Inbound: every backend -> frontend event.
hookOnAssign("wails", (w) => {
wrap(w, "EventsNotify", ([message]) => {
const parsed = JSON.parse(message);
record(parsed.name, parsed.data, "in");
});
});
// Outbound: events the frontend emits, so a flow that round-trips
// through Go is legible from one buffer.
hookOnAssign("runtime", (r) => {
wrap(r, "EventsEmit", (args) => {
record(args[0], args.slice(1), "out");
});
});
})();
+165 -37
View File
@@ -32,14 +32,14 @@ make sandbox-seed-bulk # Same, from the bulk library (minutes; it is a real scan
make perf LABEL=<n> # Measure a running app; writes .dev/perf/<n>.json
make perf-compare BEFORE=<a> AFTER=<b> # Print the before/after table
make build-dev # Debug build with symbols
make build-prod # Production build (stripped, UPX-compressed)
make build-prod # Production build (stripped and trimmed; no UPX)
make generate # Run code generators (sqlc + templ via go generate)
make e2e # Playwright smoke suite against a running dev-headless app
make e2e-setup # Install the e2e runner + its browser (once)
make ui-test # Vitest component/store suite in a real browser (no app)
make ui-visual # Same, including toMatchScreenshot comparisons
make ui-setup # Install the Vitest provider's own Chromium (once)
make bindings-check # Fail if frontend/wailsjs is stale vs the Go bindings
make bindings-check # Fail if frontend/bindings is stale vs the Go bindings
make skill-check # Fail if .pi/ documents a make target that doesn't exist
make commit-check # Fail if a commit subject is not a Conventional Commit
make lint # golangci-lint v2 (strict), all three build configurations
@@ -50,12 +50,15 @@ make setup # Install go tools, frontend deps, git hooks (lefthook)
### Running tests
All Go test commands require the `-tags webkit2_41` build tag:
Go test commands need no build tag for the app configuration. The
`webkit2_41` tag every command here used to carry is gone with wails
v2: v3 builds against GTK4 + WebKitGTK 6.0 by default, which both Arch
and ubuntu:24.04 ship.
```bash
go test -tags webkit2_41 ./... # All tests
go test -tags webkit2_41 ./backend/player/ # Single package
go test -tags webkit2_41 -run TestName ./backend/player/ # Single test
go test ./... # All tests
go test ./backend/player/ # Single package
go test -run TestName ./backend/player/ # Single test
```
The central index builder is behind a second tag and is **not** covered
@@ -63,14 +66,14 @@ by the command above — `make test` runs both passes, but a manual run
needs it spelled out:
```bash
go test -tags "webkit2_41 indexbuild" ./backend/explore/... ./cmd/...
go test -tags indexbuild ./backend/explore/... ./cmd/...
```
`backend/testctl` is behind a third tag and needs its own pass too
(`make test` runs all three):
```bash
go test -tags "webkit2_41 dev" ./backend/testctl/...
go test -tags dev ./backend/testctl/...
```
Audio playback integration tests require `YELLOWJACKET_INTEGRATION=1`.
@@ -84,9 +87,12 @@ through `internal/testfixtures`, selecting files by *case*
path, and skip themselves when it has not been generated.
The app itself can be run without a blocking window — `make
dev-headless` — and driven with `playwright-cli` against the dev server
on `:34115`, which is the real app with real bindings on `window.go`,
bridged to the same Go backend a desktop window would use.
dev-headless` — and driven with `playwright-cli` against it on
`:34115`. That is wails v3's first-class `-tags server` mode: the real
app, the real bindings, the same Go backend a desktop window would use,
served over HTTP with **no display at all**. The Xvfb this used to
require is gone, from the script and from CI; `dbus-run-session` stays,
for MPRIS.
**The operational half of all this lives in the
`yellowjacket-dev` skill** (`.pi/skills/yellowjacket-dev/`): which tier
@@ -103,8 +109,16 @@ phase 3:
backend event on `window.__yjEvents`. Half this app is push-driven,
so assertions **await an event, not a timeout**:
`await window.__yjEvents.wait('LibraryScanComplete', {timeoutMs: 60000})`.
It also provides `ready()` and `call('queue.Queue.GetState', [])`,
which times out instead of hanging.
It also provides `ready()` and `call('queue.Queue.GetState', [])`.
Both hook v3's own seams rather than an internal: inbound is
`window._wails.dispatchWailsEvent`, the entry point the backend's push
uses, and outbound is **`fetch`** — v3 routes every runtime call
through one POST to `/wails/runtime`, so one hook sees calls from any
module and cannot miss one made before the harness looked. `call()`
posts by method name, so it depends on nothing in the app's bundle and
works on a page with no init script — which is why `seed-sandbox.sh`
is `curl` now and needs no browser. It no longer races a timeout
either: v3 rejects bad arguments and unknown methods cleanly.
- **The dev-only control surface**, `backend/testctl`, mounted at
`/__test/` on the same port: `health`, `db/snapshot`, `db/restore`,
`emit` (force any backend event, which renders push-driven views
@@ -130,18 +144,29 @@ meaningless against the seed's one empty playlist, so it builds ten
It wraps every bound Go method, so "did that refetch the library" is a
fact rather than an inference. It is not a spec and does not run in CI.
**The cheapest tier needs none of that.** `make ui-test` runs 672
Vitest tests in a real Chromium in ~2 s with no Wails, no backend, no
seeded library and no virtual display, because `frontend/wailsjs/` is a
pure passthrough to `window.go` / `window.runtime` and
`frontend/test/support/wails-fake.ts` replaces just those two globals —
so the tests exercise the real generated bindings and the real store
code.
**The cheapest tier needs none of that.** `make ui-test` runs 757
Vitest tests in a real Chromium with no Wails, no backend, no seeded
library and no virtual display, because **v3 routes every runtime call
— bindings, event emits, window, dialogs, clipboard — through one IPC
transport**, and `frontend/test/support/wails-fake.ts` replaces it via
`setTransport()`, which is public documented API. So the fake covers
strictly more than v2's two globals did while being shorter, and the
tests exercise the real generated bindings, the real runtime and the
real store code. A binding carries an **ID**, not a name
(`$Call.ByID(2822423495)` is FNV-1a over
`yellowjacket/backend/home.Service.GetShelves`), so the fake derives
that map from the generated tree rather than writing it down.
**`frontend/wailsjs/` is generated by `wails`, not `go generate`**, so
**`frontend/bindings/` is generated by `wails3`, not `go generate`**, so
the pre-commit codegen check does not cover it. `make bindings-check`
(~1.5 s, also a pre-commit hook) regenerates it and fails on a dirty
tree; `make bindings` regenerates it for real.
(~3.5 s warm, ~20 s on a cold build cache, also a pre-commit hook)
regenerates it and fails on a dirty tree; `make bindings` regenerates it
for real. It is slower than v2's because v3's generator is a **static
analyser** over the whole package graph rather than runtime reflection
— which is also why the tag set it runs under matters, and why it is
the *default* one: that is the configuration users run, and neither
`indexbuild` nor `dev` adds a bound service. See
`scripts/bindings-check.sh`.
**Seeds are produced by running the app**, never by hand-writing a
`config.toml` and DB rows — the same discipline `sql/schemas/` gets,
@@ -151,7 +176,46 @@ See `.planning/plans/completed/005-agent-development-harness.md`.
## Architecture
**Wails app lifecycle** (`main.go``backend/app.go`): `YellowJacketApp` is the root struct bound to Wails. Its methods are callable from the frontend. Lifecycle hooks: `OnStartup` (init audio), `OnDomReady` (start library scan), `OnBeforeClose` (save window state), `OnShutdown` (persist player/queue state).
**Wails app lifecycle** (`main.go``backend/app.go`): `main.go` is
`application.New(opts)``app.Window.NewWithOptions(…)``app.Run()`.
Each bound service takes its context from `ServiceStartup(ctx,
application.ServiceOptions{})` — v3 calls it on every service, in
registration order, on the main goroutine — and gives it back in
`ServiceShutdown()`. That context is **cancelled on app shutdown**,
which `SetContext` never was.
Three things about it are load-bearing.
**`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.
**The cross-service wiring is a service, not an event.** v3 has no
`OnStartup`/`OnDomReady` option, and the obvious replacement —
`app.Event.OnApplicationEvent(events.Common.ApplicationStarted, …)`
is the right *moment* and the wrong *mechanism*: **server mode emits no
application events at all** (`setupCommonEvents` is an explicit no-op
under `-tags server`), so the desktop build wired itself and the
headless harness did not. `backend/startup.go` is registered last
instead, which takes the ordering from the mechanism rather than from
an event and therefore holds in every mode. Anything else keyed on
`Common.*` is suspect for the same reason.
**The quit veto is asynchronous now.** v2's `MessageDialog` blocked and
returned the button; v3's `Show()` returns immediately and the answer
arrives on a `Button.OnClick` callback, so `ShouldQuit` cannot ask and
answer in one call — it vetoes, shows the dialog, and calls
`app.Quit()` from the callback. `quitConfirmed` is what stops that
second `Quit()` asking again; `quitAsking` stops a second close attempt
stacking dialogs. Window state moved off that path entirely, onto a
`Common.WindowClosing` hook, because the size has to be read while the
window still exists and `OnShutdown` has neither a context nor a
window.
`internalServiceMethods` auto-excludes `ServiceStartup`,
`ServiceShutdown`, `ServiceName` and `ServeHTTP` from bindings, so this
shape **removed** 12 spurious bindings and the bogus `context` model
rather than renaming them.
**Backend packages** (under `backend/`):
- `player` — Audio playback via beep. `BufferedStreamer` provides a ring buffer for smooth seeking.
@@ -441,7 +505,11 @@ that invents its own flat layout agrees with the bug.
keeping only `explore.ArtistImageKeepNames()` and refusing an empty
keep set for the reason the covers sweep refuses an empty live set.
**Frontend** (`frontend/`): Lit 3.2 web components + Web Awesome UI library + HTMX. State management via singleton reactive stores in `src/store/`. Wails bindings auto-generated in `frontend/wailsjs/` — don't edit by hand.
**Frontend** (`frontend/`): Lit 3.2 web components + Web Awesome UI library + HTMX. State management via singleton reactive stores in `src/store/`. Wails bindings auto-generated as TypeScript in `frontend/bindings/`, nested by Go import path — don't edit by hand. The `@go` alias absorbs the constant prefix, so a call site imports `@go/library/library.js`.
**One seam states what the generated types get wrong, rather than 78 patches.** v3's generator is honest where v2's lied: a Go `nil` slice marshals to JSON `null` and always has (v2 typed it `T[]`), and a Go named string type is a closed set (v2 typed it `string`). There is no flag to turn either off, correctly. So `utils/binding.ts` states the app's actual contract at the only place it is true — `list` yields `[]` for a nil slice, `dict`/`dictByName` yield `{}` for a nil map and drop null-valued keys (which loses nothing: `noUncheckedIndexedAccess` already makes every read `V | undefined`), and `compact` is the same for a map arriving as a *field*. Where a nullable slice is a model field there is no boundary to put it at, and those are `?? []` at the point of use.
All three also return a **plain `Promise`**: v3 bindings return a `CancellablePromise` and nothing in this app cancels one, so letting it inward would put a Wails type in every store signature for a capability none of them use.
**A view is a chunk, and three components are not.** `index.ts` holds a
loader table (`VIEW_LOADERS`, `DETAIL_LOADERS`) and `await`s a view's
@@ -1424,11 +1492,26 @@ on every change, or `scrollToIndex` from calling `scrollIntoView()` on
something invisible.
Emit through **`events.Emit(ctx, name, data...)`**, never
`runtime.EventsEmit` — wails `log.Fatalf`s (unrecoverably) on any
context that does not carry its runtime, which includes every
`context.Background()`, so a direct call cannot run under test and can
kill the app from a background worker. `TestNoDirectRuntimeEmits` fails
the build on a direct call anywhere outside `backend/events`.
`app.Event.Emit`. `TestNoDirectRuntimeEmits` fails the build on a
direct call anywhere outside `backend/events`.
**Its justification changed with v3 and is now the weaker one.** Under
v2 this was a safety rule: `runtime.EventsEmit` `log.Fatalf`'d —
unrecoverably, taking the process down — on any context not carrying
the runtime, which includes every `context.Background()`, so a
background worker could kill the app by emitting. That is gone. v3's
emit takes **no context at all**, and `application.Get()` with no app
running returns `nil` rather than dying, so `Deliver` is
`if app == nil { return ErrNoRuntime }` where it used to probe the
**v2-private context key** `ctx.Value("events")`. What remains worth
pinning is narrower and still real: one emit path is what lets
`emitStatus` drop an unchanged payload for every caller at once.
**`events.Emit` keeps its `ctx` anyway, and it is now purely a test
seam.** Delivery does not go through it; `events.WithSink(ctx, rec)`
does, which is how a service is asserted on in-process
(`backend/queue/emit_test.go` is the model). Dropping the parameter
would have churned 45 call sites and every test for no gain.
That wrapper is what makes services testable in-process: install a
recorder with `events.WithSink(ctx, rec)` and assert on the payload the
@@ -1486,8 +1569,10 @@ Two jobs, both in an `ubuntu:24.04` container:
`make lint` and `make test` (three build configurations each),
`tsc --noEmit`, `make ui-test`, `make bindings-check`,
`make skill-check`.
- **`e2e`** — under Xvfb and a private D-Bus: fixtures, a seed built by
running the app, `make dev-headless`, then the Playwright suite
- **`e2e`** — under a private D-Bus and **no display at all**, since
v3's `-tags server` is a real headless mode: fixtures, a seed built by
running the app (`curl` against the runtime endpoint — no browser),
`make dev-headless`, then the Playwright suite
against **both** Chromium and WebKit. Playwright's Linux WebKit links
Ubuntu 24.04 libraries that Arch does not provide, so CI is the only
place it can run, and it is the closest available approximation of
@@ -1518,8 +1603,51 @@ And
`YJ_CORE_INDEX_URL` points at a dead address so no run fetches the real
explore artifact, matching what `scripts/seed-sandbox.sh` already does.
**`make lint`'s tag sets must stay identical to `make test`'s.**
Without `webkit2_41` wails resolves `webkit2gtk-4.0`, which Arch still
ships and Ubuntu 24.04 does not — so a mismatch lints a configuration
that only builds on one developer's distro, and says nothing about what
ships.
**`make lint`'s tag sets must stay identical to `make test`'s**, or
lint is checking configurations nothing builds. There are three, and
the app's is now the *default* tag set: v3 resolves GTK4 +
WebKitGTK 6.0, which Arch and ubuntu:24.04 both ship, so the
`webkit2_41` tag that used to be mandatory everywhere is gone. A
machine without `webkitgtk-6.0` can still build with `-tags gtk3`, but
that is an escape hatch, not what CI or a release builds.
## Packaging
**The Makefile is the front door and `Taskfile.yml` is an
implementation detail behind it.** `make dev`, `make build-dev`,
`make build-prod`, `make bindings` and `make e2e` all keep their names;
what changed underneath is that a build is now a Taskfile tree
(`Taskfile.yml` → `build/<platform>/Taskfile.yml`) rather than one
`wails build` invocation, and `wails3` is still a **vendored Go tool**
(`go tool wails3`), never a global install.
Four things about that tree bite anything outside the Makefile, and all
four bit the packaging recipes:
- **The tasks invoke `wails3` by bare name**, in 54 places across the
scaffold files. `scripts/toolbin/wails3` puts that name on PATH
pointing back at the vendored tool; without it a build dies at its
first sub-task with `wails3: command not found`. The Makefile
prepends it, and so must `packaging/arch/PKGBUILD` and the Homebrew
formula.
- **`wails3 build` has no `-ldflags`, `-trimpath` or `-clean`** — those
were v2's. `-trimpath` and `-w -s` are already in the production
task's own flags; the version stamp goes through **`LDFLAGS_EXTRA`**,
this repo's one edit to the scaffold Taskfiles (linux and darwin
alike), passed as `wails3 task build LDFLAGS_EXTRA="-X 'main.version=…'"`.
- **The output is `bin/`, not v2's `build/bin/`.** `build/` is *tracked
build assets* now.
- **Bundling is a separate step from building.** `task build` produces
a bare binary on every platform; the macOS `.app` is `task package`.
**`build/`'s platform metadata is generated from `build/config.yml`.**
`wails3 task common:update:build-assets` rewrites `Info.plist`, the
`.desktop` template, `nfpm.yaml` and the Windows manifest from that
one file — so a hand edit to any of them is lost on the next refresh,
and the two fields it does *not* own (nfpm's `homepage` and `license`)
say so in place. That refresh also regenerates `build/ios/` and
`build/android/`, which this repo does not carry: they are gitignored
rather than deleted-and-rediscovered, and their `includes:` entries
are dropped from `Taskfile.yml`. `build/config.yml`'s `version` is the
*metadata* version and is not what the app reports — `main.version` is
stamped at link time from the packaging recipe's git-derived version.
+26 -24
View File
@@ -7,17 +7,25 @@ LDFLAGS := -X 'main.version=$(VERSION)' -X 'main.commit=$(COMMIT)'
# point elsewhere (or unset it there to share the real user dirs).
DEV_YJ_HOME ?= $(HOME)/.local/share/yellowjacket-dev
# `wails3 dev` and `wails3 task` run the scaffold's Taskfile tree, which
# invokes `wails3` by bare name. The CLI is a vendored Go tool, so the
# name only exists on PATH via this shim -- see scripts/toolbin/wails3.
# Without it every supervisor target dies with
# "/bin/sh: wails3: command not found" at its first sub-task.
TOOLBIN := $(CURDIR)/scripts/toolbin
dev: setup generate clean
if [ -f .env ]; then set -a; . ./.env; set +a; fi; : "$${YJ_HOME:=$(DEV_YJ_HOME)}"; export YJ_HOME; go tool wails3 dev -config ./build/config.yml
if [ -f .env ]; then set -a; . ./.env; set +a; fi; : "$${YJ_HOME:=$(DEV_YJ_HOME)}"; export YJ_HOME; PATH="$(TOOLBIN):$$PATH" go tool wails3 dev -config ./build/config.yml
dev-debug: setup generate clean
if [ -f .env ]; then set -a; . ./.env; set +a; fi; : "$${YJ_HOME:=$(DEV_YJ_HOME)}"; export YJ_HOME; YJ_LOG_LEVEL=debug go tool wails3 dev -config ./build/config.yml
if [ -f .env ]; then set -a; . ./.env; set +a; fi; : "$${YJ_HOME:=$(DEV_YJ_HOME)}"; export YJ_HOME; YJ_LOG_LEVEL=debug PATH="$(TOOLBIN):$$PATH" go tool wails3 dev -config ./build/config.yml
# ── Headless harness (plan 005) ──────────────────────────────────────
# The same dev server `make dev` runs, minus the blocking GTK window:
# Xvfb gives it the display it insists on, and the script returns once
# :34115 answers. This is the only entry point an agent can use, since
# every other one blocks the terminal forever.
# The same app `make dev` runs, minus the window: v3's `-tags server`
# is a first-class headless mode that needs no display at all, so the
# Xvfb this used to require is gone. The script returns once :34115
# answers. This is the only entry point an agent can use, since every
# other one blocks the terminal forever.
dev-headless: ## Start the app headless in the background (SEED=<name> to seed)
@./scripts/dev-headless.sh $(if $(SEED),--seed $(SEED),) $(HEADLESS_ARGS)
@@ -100,14 +108,9 @@ ui-visual-update: ## Re-record the screenshot baselines
ui-setup: ## Install the Vitest browser provider's own Chromium (once)
@cd frontend && pnpm install && npx playwright install chromium
# Bindings are generated by `wails`, NOT by `go generate`, so the
# Bindings are generated by `wails3`, NOT by `go generate`, so the
# pre-commit codegen check does not cover them: a renamed Go struct
# field currently surfaces at runtime, in a window.
#
# NOTE (plan 009 phase 4): this still checks v2's frontend/wailsjs,
# which nothing regenerates now that the v2 CLI is gone. It is
# excluded from the pre-commit hook until the frontend moves to
# frontend/bindings.
# field would otherwise surface at runtime, inside a window.
bindings-check: ## Fail if the generated bindings are stale
@./scripts/bindings-check.sh
@@ -130,14 +133,13 @@ commit-check: ## Fail if a commit subject is not a Conventional Commit
@./scripts/commit-check.sh $(if $(RANGE),--range $(RANGE))
# v3 generates TypeScript into frontend/bindings/, nested by Go import
# path, rather than v2's frontend/wailsjs/. The 93 frontend import
# sites still point at the old tree, so this writes the new one beside
# it until plan 009 phase 4 moves them; the chmod dance that used to
# follow is gone with v2's generator, which wrote its runtime files 755.
# path, rather than v2's frontend/wailsjs/. The `@go` alias absorbs the
# constant prefix, so a call site imports '@go/library/library.js'.
#
# The tag set is pinned deliberately: the generator is a static
# analyser, so it only sees the configuration it is told about, and the
# one that matters is the one users run.
# No -f flag: the tag set is the default one, deliberately, because the
# generator is a static analyser that sees only the configuration it is
# told about and the one that matters is the one users run. See
# scripts/bindings-check.sh for why the other two do not apply.
bindings: ## Regenerate frontend/bindings from the bound Go services
go tool wails3 generate bindings -clean=true -ts -i
@@ -168,7 +170,7 @@ fresh-install: setup generate clean
case "$$(findmnt -no FSTYPE -T "$$YJ_HOME" 2>/dev/null)" in \
tmpfs|ramfs) echo "==> WARNING: $$YJ_HOME is RAM-backed; the search index import needs ~6GB of real disk. Set FRESH_HOME_BASE to a disk-backed path." ;; \
esac; \
go tool wails3 dev -config ./build/config.yml
PATH="$(TOOLBIN):$$PATH" go tool wails3 dev -config ./build/config.yml
# Named, persistent sandboxes: `make sandbox foo` runs dev against
# $(FRESH_HOME_BASE)/yellowjacket-sandbox-foo, creating it on first use
@@ -228,7 +230,7 @@ sandbox-%: setup generate clean
case "$$(findmnt -no FSTYPE -T "$$YJ_HOME" 2>/dev/null)" in \
tmpfs|ramfs) echo "==> WARNING: $$YJ_HOME is RAM-backed; the search index import needs ~6GB of real disk. Set FRESH_HOME_BASE to a disk-backed path." ;; \
esac; \
go tool wails3 dev -config ./build/config.yml
PATH="$(TOOLBIN):$$PATH" go tool wails3 dev -config ./build/config.yml
sandboxes: ## List existing named sandboxes
@ls -d "$(SANDBOX_DIR)"-* 2>/dev/null \
@@ -238,10 +240,10 @@ sandboxes: ## List existing named sandboxes
.PHONY: sandbox sandbox-rm sandboxes
build-dev: generate
go tool wails3 task build DEV=true
PATH="$(TOOLBIN):$$PATH" go tool wails3 task build DEV=true
build-prod: generate
go tool wails3 task build
PATH="$(TOOLBIN):$$PATH" go tool wails3 task build
build-frontend:
cd frontend && pnpm install && pnpm build
+14 -5
View File
@@ -78,16 +78,25 @@ YellowJacket is built with [Go](https://go.dev/) and a
| Go | 1.25+ |
| Node.js | 22+ |
| pnpm | 10+ |
| Wails CLI | v2 (`go install github.com/wailsapp/wails/v2/cmd/wails@latest`) |
| Wails CLI | v3 — vendored, no install needed (`go tool wails3`) |
On Linux, install the system libraries Wails needs:
The Wails v3 CLI resolves from the `tool` block in `go.mod`, so there is nothing
to install globally; `make setup` fetches it with the rest of the tooling.
On Linux, install the system libraries Wails needs. v3 builds against GTK4 +
WebKitGTK 6.0 by default:
```bash
sudo apt-get install libasound2-dev libgtk-3-dev libwebkit2gtk-4.1-dev
sudo apt-get install libasound2-dev libgtk-4-dev libwebkitgtk-6.0-dev # Debian/Ubuntu
sudo pacman -S alsa-lib gtk4 webkitgtk-6.0 # Arch
```
macOS and Windows need no extra system packages. Run `wails doctor` to check your
environment.
A machine without `webkitgtk-6.0` can still build with `-tags gtk3` against the
older WebKit2GTK 4.1 stack, but that is an escape hatch, not what CI or a
release builds.
macOS and Windows need no extra system packages. Run `go tool wails3 doctor` to
check your environment.
**Build**
+9
View File
@@ -247,6 +247,15 @@ func NewYellowJacketApp(
)
}
// Last, deliberately: services start in registration order, so this
// runs once every service above has taken its context. See
// startup.go for why the wiring is a service rather than an
// application-event hook.
yjApp.Services = append(
yjApp.Services,
application.NewService(&startupService{app: yjApp}),
)
return yjApp, nil
}
+12 -1
View File
@@ -1414,7 +1414,18 @@ func (si *SearchIndex) ExactMatches(query string, perCategory int) []SearchIndex
// by relevance (popularity-blended). Returns nil when the index
// hasn't finished its initial build.
func (si *SearchIndex) Search(ctx context.Context, query string, limit int) []SearchIndexResult {
if !si.IsReady() {
// IsReady is latched once at startup, so it is right in the app and
// wrong for anything that fills the table afterwards — including the
// e2e suite staging a catalog, which is how search gets tested in
// CI, where the artifact URL points at a dead address on purpose.
// shelves.go learned this already; the search path did not, and the
// symptom was three specs that passed only when an earlier one
// happened to flip the flag first.
//
// The latch stays as the fast path — once it is true nothing can
// make it false — and the probe is one indexed `SELECT 1 … LIMIT 1`
// on the only path that could otherwise answer "no catalog" wrongly.
if !si.IsReady() && !si.hasCatalogRows(ctx) {
return nil
}
+53
View File
@@ -0,0 +1,53 @@
package backend
import (
"context"
"github.com/wailsapp/wails/v3/pkg/application"
)
// startupService is the cross-service wiring, wearing a service's
// clothes so the runtime starts it like everything else.
//
// The wiring belongs to no single service — it is the hooks, adapters
// and callbacks that make one package drive another — so v2 put it in
// OnStartup and the first v3 port hung it off
// events.Common.ApplicationStarted, which fires after every service's
// own ServiceStartup and is therefore the right *moment*.
//
// It is the wrong *mechanism*, because server mode emits no
// application events at all: v3's setupCommonEvents is an explicit
// no-op under `-tags server` ("server mode has no platform-specific
// events to map"). So the desktop build wired itself and the headless
// build did not, which showed up as "No player set, cannot load track"
// — the queue had no TrackLoader, so a track played from the UI
// changed the queue and then silently did nothing.
//
// Registering last is what preserves the ordering the wiring depends
// on: services start in registration order, on the main goroutine,
// before the platform run loop (application.Run's startup closure), so
// every service this touches has taken its context by the time this
// runs. A service is also the honest description of what this is —
// something with a lifecycle the app owns — and it costs no bindings,
// since ServiceStartup and ServiceShutdown are excluded from them.
type startupService struct {
app *YellowJacketApp
}
// ServiceStartup runs the app-level wiring.
//
// OnDomReady no longer means the DOM is ready — nothing in v3 offers
// that — and it does not need to: what it does is start the soft
// rescan and report a startup failure, neither of which wants a
// frontend. The frontend drives its own state synchronisation by
// calling EmitCurrentState once its stores are listening, which is
// what makes the rename harmless.
func (s *startupService) ServiceStartup(
ctx context.Context,
_ application.ServiceOptions,
) error {
s.app.OnStartup(ctx)
s.app.OnDomReady(ctx)
return nil
}
+7 -2
View File
@@ -4,14 +4,19 @@
version: '3'
# This information is used to generate the build assets.
# The version here is the one baked into the platform *metadata* assets
# (Info.plist, the .desktop file, nfpm). It is not what the app reports:
# main.version is stamped at link time from the packaging recipes, which
# derive it from the git tag. Keep it in step with packaging/arch/PKGBUILD's
# pkgver and the Homebrew formula's version when cutting a release.
info:
companyName: "yellowjacket" # The name of the company
productName: "yellowjacket" # The name of the application
productIdentifier: "app.yellowjacket" # The unique product identifier
description: "A cross-platform desktop music player" # The application description
description: "Cross-platform desktop music player — local library, MusicBrainz explore & auto-tag" # The application description
copyright: "(c) 2026, yellowjacket" # Copyright text
comments: "yj@yellowjacket.app" # Comments
version: "0.0.1" # The application version
version: "1.3.0" # The application version
# cfBundleIconName: "appicon" # The macOS icon name in Assets.car icon bundles (optional)
# # Should match the name of your .icon file without the extension
# # If not set and Assets.car exists, defaults to "appicon"
+32 -31
View File
@@ -1,34 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>My Product</string>
<key>CFBundleExecutable</key>
<string>yjref</string>
<key>CFBundleIdentifier</key>
<string>com.example.yjref</string>
<key>CFBundleVersion</key>
<string>0.1.0</string>
<key>CFBundleGetInfoString</key>
<string>This is a comment</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleIconFile</key>
<string>icons</string>
<key>CFBundleIconName</key>
<string>appicon</string>
<key>LSMinimumSystemVersion</key>
<string>12.0.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>© 2026, My Company</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
<dict>
<key>CFBundleExecutable</key>
<string>yellowjacket</string>
<key>CFBundleGetInfoString</key>
<string>yj@yellowjacket.app</string>
<key>CFBundleIconFile</key>
<string>icons</string>
<key>CFBundleIconName</key>
<string>appicon</string>
<key>CFBundleIdentifier</key>
<string>app.yellowjacket</string>
<key>CFBundleName</key>
<string>yellowjacket</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.3.0</string>
<key>CFBundleVersion</key>
<string>1.3.0</string>
<key>LSMinimumSystemVersion</key>
<string>12.0.0</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>(c) 2026, yellowjacket</string>
</dict>
</plist>
+27 -26
View File
@@ -1,29 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>My Product</string>
<key>CFBundleExecutable</key>
<string>yjref</string>
<key>CFBundleIdentifier</key>
<string>com.example.yjref</string>
<key>CFBundleVersion</key>
<string>0.1.0</string>
<key>CFBundleGetInfoString</key>
<string>This is a comment</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleIconFile</key>
<string>icons</string>
<key>CFBundleIconName</key>
<string>appicon</string>
<key>LSMinimumSystemVersion</key>
<string>12.0.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>© 2026, My Company</string>
</dict>
<dict>
<key>CFBundleExecutable</key>
<string>yellowjacket</string>
<key>CFBundleGetInfoString</key>
<string>yj@yellowjacket.app</string>
<key>CFBundleIconFile</key>
<string>icons</string>
<key>CFBundleIconName</key>
<string>appicon</string>
<key>CFBundleIdentifier</key>
<string>app.yellowjacket</string>
<key>CFBundleName</key>
<string>yellowjacket</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.3.0</string>
<key>CFBundleVersion</key>
<string>1.3.0</string>
<key>LSMinimumSystemVersion</key>
<string>12.0.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>(c) 2026, yellowjacket</string>
</dict>
</plist>
+4 -1
View File
@@ -43,7 +43,10 @@ tasks:
cmds:
- '{{if eq .OBFUSCATED "true"}}garble {{.GARBLE_ARGS}} build{{else}}go build{{end}} {{.BUILD_FLAGS}} -o "{{.OUTPUT}}"'
vars:
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if or .EXTRA_TAGS (eq .OBFUSCATED "true")}}-tags {{if eq .OBFUSCATED "true"}}wails_obfuscated{{if .EXTRA_TAGS}},{{end}}{{end}}{{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
# LDFLAGS_EXTRA — see build/linux/Taskfile.yml for why this exists.
# Kept identical on both platforms so the Homebrew formula, which
# builds on either, has one invocation rather than two.
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if or .EXTRA_TAGS (eq .OBFUSCATED "true")}}-tags {{if eq .OBFUSCATED "true"}}wails_obfuscated{{if .EXTRA_TAGS}},{{end}}{{end}}{{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s{{if .LDFLAGS_EXTRA}} {{.LDFLAGS_EXTRA}}{{end}}"{{end}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
env:
Binary file not shown.
+9 -1
View File
@@ -59,7 +59,15 @@ tasks:
cmds:
- '{{if eq .OBFUSCATED "true"}}garble {{.GARBLE_ARGS}} build{{else}}go build{{end}} {{.BUILD_FLAGS}} -o {{.OUTPUT}}'
vars:
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if or .EXTRA_TAGS (eq .OBFUSCATED "true")}}-tags {{if eq .OBFUSCATED "true"}}wails_obfuscated{{if .EXTRA_TAGS}},{{end}}{{end}}{{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
# LDFLAGS_EXTRA is this repo's one edit to the scaffold: `wails3
# build` has no -ldflags flag of its own, and the packaging recipes
# (packaging/arch/PKGBUILD, the Homebrew formula) need to stamp
# main.version / main.commit. Pass it as a task variable:
# wails3 task build LDFLAGS_EXTRA="-X 'main.version=v1.3.0'"
# It is appended inside the production -ldflags string, so the
# default -w -s still applies. Empty by default, which is what
# keeps `make build-dev` / `make build-prod` unchanged.
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if or .EXTRA_TAGS (eq .OBFUSCATED "true")}}-tags {{if eq .OBFUSCATED "true"}}wails_obfuscated{{if .EXTRA_TAGS}},{{end}}{{end}}{{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s{{if .LDFLAGS_EXTRA}} {{.LDFLAGS_EXTRA}}{{end}}"{{end}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
env:
+5 -5
View File
@@ -1,13 +1,13 @@
[Desktop Entry]
Version=1.0
Name=My Product
Comment=A yjref application
Name=yellowjacket
Comment=Cross-platform desktop music player — local library, MusicBrainz explore & auto-tag
# The Exec line includes %u to pass the URL to the application
Exec=/usr/local/bin/yjref %u
Exec=/usr/local/bin/yellowjacket %u
Terminal=false
Type=Application
Icon=yjref
Icon=yellowjacket
Categories=Utility;
StartupWMClass=yjref
StartupWMClass=yellowjacket
+14 -11
View File
@@ -3,26 +3,29 @@
#
# The lines below are called `modelines`. See `:help modeline`
name: "yjref"
name: "yellowjacket"
arch: ${GOARCH}
platform: "linux"
version: "0.1.0"
version: "1.3.0"
section: "default"
priority: "extra"
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
description: "A yjref application"
vendor: "My Company"
homepage: "https://wails.io"
license: "MIT"
description: "Cross-platform desktop music player — local library, MusicBrainz explore & auto-tag"
vendor: "yellowjacket"
# homepage and license are NOT derived from build/config.yml, so
# `wails3 task common:update:build-assets` leaves them alone — everything
# above them is regenerated from that file and hand edits there are lost.
homepage: "https://git.ljones.me/yonlu/yellowjacket"
license: "custom" # see the repository; packaging/arch/PKGBUILD says the same
release: "1"
contents:
- src: "./bin/yjref"
dst: "/usr/local/bin/yjref"
- src: "./bin/yellowjacket"
dst: "/usr/local/bin/yellowjacket"
- src: "./build/appicon.png"
dst: "/usr/share/icons/hicolor/128x128/apps/yjref.png"
- src: "./build/linux/yjref.desktop"
dst: "/usr/share/applications/yjref.desktop"
dst: "/usr/share/icons/hicolor/128x128/apps/yellowjacket.png"
- src: "./build/linux/yellowjacket.desktop"
dst: "/usr/share/applications/yellowjacket.desktop"
# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+)
depends:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 11 KiB

+7 -7
View File
@@ -1,15 +1,15 @@
{
"fixed": {
"file_version": "0.1.0"
"file_version": "1.3.0"
},
"info": {
"0000": {
"ProductVersion": "0.1.0",
"CompanyName": "My Company",
"FileDescription": "A yjref application",
"LegalCopyright": "© 2026, My Company",
"ProductName": "My Product",
"Comments": "This is a comment"
"ProductVersion": "1.3.0",
"CompanyName": "yellowjacket",
"FileDescription": "Cross-platform desktop music player — local library, MusicBrainz explore & auto-tag",
"LegalCopyright": "(c) 2026, yellowjacket",
"ProductName": "yellowjacket",
"Comments": "yj@yellowjacket.app"
}
}
}
+5 -5
View File
@@ -5,19 +5,19 @@
!include "FileFunc.nsh"
!ifndef INFO_PROJECTNAME
!define INFO_PROJECTNAME "yjref"
!define INFO_PROJECTNAME "yellowjacket"
!endif
!ifndef INFO_COMPANYNAME
!define INFO_COMPANYNAME "My Company"
!define INFO_COMPANYNAME "yellowjacket"
!endif
!ifndef INFO_PRODUCTNAME
!define INFO_PRODUCTNAME "My Product"
!define INFO_PRODUCTNAME "yellowjacket"
!endif
!ifndef INFO_PRODUCTVERSION
!define INFO_PRODUCTVERSION "0.1.0"
!define INFO_PRODUCTVERSION "1.3.0"
!endif
!ifndef INFO_COPYRIGHT
!define INFO_COPYRIGHT "© 2026, My Company"
!define INFO_COPYRIGHT "(c) 2026, yellowjacket"
!endif
!ifndef PRODUCT_EXECUTABLE
!define PRODUCT_EXECUTABLE "${INFO_PROJECTNAME}.exe"
+1 -1
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="com.example.yjref" version="0.1.0" processorArchitecture="*"/>
<assemblyIdentity type="win32" name="app.yellowjacket" version="1.3.0" processorArchitecture="*"/>
<dependency>
<dependentAssembly>
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
+74 -62
View File
@@ -56,6 +56,8 @@ import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from '@playwright/test';
import { methodIDs } from '../support/method-ids.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = resolve(HERE, '../..');
const OUT_DIR = resolve(REPO, '.dev/perf');
@@ -63,6 +65,16 @@ const BRIDGE = resolve(REPO, '.playwright/init-events.js');
const BASE_URL = process.env.YJ_URL ?? 'http://localhost:34115';
/**
* methodID -> 'pkg.Type.Method', derived from frontend/bindings/.
*
* A binding call carries only the id, so this is what turns a
* measurement's "which bindings did that provoke" back into names. It
* is derived rather than written down for the reason plan 009 phase 6b
* gives: a hand-maintained list goes stale silently.
*/
const METHOD_NAMES = Object.fromEntries(methodIDs());
// The browse script visited for the heap measurement. Deliberately the
// views the audit named as retaining: explore (two unbounded caches),
// artists and genres (per-frame work), settings (the 3 s ticker).
@@ -123,64 +135,41 @@ function parseArgs(argv) {
/* -------------------------------------------------------------------- */
/**
* Wrap every bound Go method so a measurement can say which bindings a
* user action provoked and how much they returned.
* Say which bindings a user action provoked, and how much they
* returned.
*
* Post-hoc wrapping is safe because `frontend/wailsjs` looks its target
* up at call time (`window['go']['library']['Library']['GetAllTracks']()`),
* so a store holding an imported wrapper still lands here.
* This used to walk `window.go` and wrap every bound method in place,
* which worked because v2's generated stubs looked their target up at
* call time. v3 has no such object — the bindings are bundled modules
* — so `.playwright/init-events.js` records every call off the single
* POST v3 routes them all through, and this reads that log. It is
* strictly better: it needs no walk, sees calls from any module, and
* cannot miss one made before a wrapper was installed, which is what
* the old "runs twice" dance was working around.
*
* `bytes` is the response size, so `measureBytes` is turned on here —
* only a measurement wants to pay for a clone-and-read of every body.
*/
const INSTRUMENT = `() => {
// Runs twice: once as an initScript (before window.go exists, which
// is the only moment early enough to catch the long-task observer's
// first entries) and once after the bridge reports ready. So the
// state is created at most once and the *walk* happens every time —
// getting that backwards silently measures zero binding calls.
const first = !window.__yjPerf;
const INSTRUMENT = `(names) => {
if (window.__yjPerf) return;
if (first) {
const calls = [];
window.__yjPerf = {
calls,
reset: () => { calls.length = 0; },
since: (t) => calls.filter((c) => c.start >= t),
longtasks: [],
};
}
window.__yjEvents.measureBytes = true;
const calls = window.__yjPerf.calls;
const wrap = (obj, key, path) => {
const fn = obj[key];
if (typeof fn !== 'function' || fn.__yjPerfWrapped) return;
const wrapped = function (...args) {
const start = performance.now();
let out;
try { out = fn.apply(this, args); } catch (e) { throw e; }
return Promise.resolve(out).then((v) => {
let bytes = 0;
try { bytes = JSON.stringify(v ?? null).length; } catch { bytes = -1; }
calls.push({ path, start, ms: performance.now() - start, bytes });
return v;
});
};
wrapped.__yjPerfWrapped = true;
obj[key] = wrapped;
window.__yjPerf = {
get calls() {
return window.__yjEvents.bindings.map((c) => ({
path: names[c.methodID] || ('#' + c.methodID),
methodID: c.methodID,
start: c.start,
ms: c.ms,
bytes: c.bytes,
}));
},
reset: () => { window.__yjEvents.reset(); },
since: (t) => window.__yjPerf.calls.filter((c) => c.start >= t),
longtasks: [],
};
const walk = (obj, prefix, depth) => {
if (!obj || depth > 4) return;
for (const key of Object.keys(obj)) {
const v = obj[key];
if (typeof v === 'function') wrap(obj, key, prefix + key);
else if (v && typeof v === 'object') walk(v, prefix + key + '.', depth + 1);
}
};
walk(window.go, '', 0);
if (!first) return;
// Long tasks are the honest form of "the app stalls": a 25 MB JSON
// parse on the main thread shows up here and nowhere else.
try {
@@ -352,7 +341,15 @@ async function measureTrackChange(page) {
const paths = (tracks ?? []).slice(0, 4).map((t) => t.FilePath);
if (paths.length < 2) return { error: 'library too small to measure' };
await ev.call('queue.Queue.SetQueue', [paths, 0, false], 15000);
await ev.call(
'queue.Queue.SetQueue',
// The fourth argument is the queue's source; these are
// ad-hoc tracks, so it is the empty one. v3 rejects a
// call with the wrong argument count where v2 filled the
// gap with a zero value.
[paths, 0, false, { type: '', id: 0, label: '' }],
15000,
);
await ev.call('queue.Queue.PlayIndex', [0], 15000);
// Settle mid-track before starting to record. Starting playback
@@ -1046,14 +1043,15 @@ const SCROLL_SETTLE_MS = 260;
async function measureScroll(page) {
// -- M3: the track list, with the Art column staged on. --
const priorColumns = await page.evaluate(async () => {
const prior = await window.go.config.Config.GetTrackListColumns();
const ev = window.__yjEvents;
const prior = await ev.call('config.Config.GetTrackListColumns', [], 15000);
await window.go.config.Config.SetTrackListColumns(
await ev.call('config.Config.SetTrackListColumns', [
[{ id: 'albumArt' }, { id: 'trackName' },
{ id: 'artistName' }, { id: 'trackLength' }],
);
], 15000);
return prior.map((c) => ({ id: c.id }));
return (prior ?? []).map((c) => ({ id: c.id }));
});
const scrollView = async (view, tag) => {
@@ -1141,7 +1139,9 @@ async function measureScroll(page) {
const artists = await scrollView('artists', 'artists-view');
await page.evaluate(
(cols) => window.go.config.Config.SetTrackListColumns(cols),
(cols) => window.__yjEvents.call(
'config.Config.SetTrackListColumns', [cols], 15000,
),
priorColumns,
);
@@ -1605,7 +1605,15 @@ async function measurePlayerBarPass(page) {
if (paths.length < 2) return { error: 'library too small to measure' };
await ev.call('queue.Queue.SetQueue', [paths, 0, false], 15000);
await ev.call(
'queue.Queue.SetQueue',
// The fourth argument is the queue's source; these are
// ad-hoc tracks, so it is the empty one. v3 rejects a
// call with the wrong argument count where v2 filled the
// gap with a zero value.
[paths, 0, false, { type: '', id: 0, label: '' }],
15000,
);
await ev.call('queue.Queue.PlayIndex', [0], 15000);
await ev.call('player.Player.Pause', [], 5000).catch(() => {});
await new Promise((r) => setTimeout(r, 600));
@@ -1804,7 +1812,9 @@ async function run(label) {
const browser = await chromium.launch();
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
await context.addInitScript({ path: BRIDGE });
await context.addInitScript(`(${INSTRUMENT})()`);
await context.addInitScript(
`(${INSTRUMENT})(${JSON.stringify(METHOD_NAMES)})`,
);
const page = await context.newPage();
const client = await context.newCDPSession(page);
@@ -1813,8 +1823,10 @@ async function run(label) {
const t0 = Date.now();
await page.goto(BASE_URL, { waitUntil: 'load' });
await page.evaluate(() => window.__yjEvents.ready(30000));
// Wrapping runs before `window.go` exists; re-run now that it does.
await page.evaluate(`(${INSTRUMENT})()`);
// No second instrumentation pass. The old one existed because
// wrapping had to happen after `window.go` appeared, yet the long
// task observer had to start before it; the bridge now records every
// binding call from the initScript onward, so one pass does both.
const report = {
label,
+2 -2
View File
@@ -1,8 +1,8 @@
import { defineConfig, devices } from '@playwright/test';
/**
* These specs drive the *real* application: the Wails dev server on
* :34115 serves the real frontend with real bindings on `window.go`,
* These specs drive the *real* application: Wails v3's server mode on
* :34115 serves the real frontend with the real generated bindings,
* bridged to the same Go backend a desktop window would use. Nothing
* here is mocked.
*
+8 -10
View File
@@ -16,6 +16,14 @@ import type { Page } from '@playwright/test';
* none, so Play was wired, labelled correctly, clicked cleanly and
* queued **nothing**. Every component test still passed.
*/
// There is no test for the tracklist legend, and there should not be:
// `dcc40b1` inverted the mark — rows *not* in the library are dimmed in
// place and nothing marks the ones that are — and deleted
// `.tracklist-legend` with it. The spec asserting it survived that
// commit and has been failing on main ever since. What replaced it
// (the dimming, and the `aria-disabled` that carries it to anyone not
// seeing the page) is covered at the component tier, in
// frontend/test/components/album-actions.test.ts.
test.describe('playing an album from its page', () => {
test.beforeEach(async ({ app }) => {
await openFirstAlbum(app);
@@ -55,16 +63,6 @@ test.describe('playing an album from its page', () => {
await expect.poll(() => queueLength(app)).toBe(before * 2);
});
test('the ticks against the tracks have a legend', async ({ app }) => {
// `H-13` calls them unexplained. They were never *unlabelled* — the
// indicator has carried a title and an aria-label reading
// "Track “X” is in your library" all along — but a sighted user
// scanning the page got a column of green circles and no key.
await expect(
app.locator('explore-album-details').locator('.tracklist-legend'),
).toContainText('in your library');
});
test('the ticks are badges, not keyboard stops', async ({ app }) => {
// Every one of them was a <button> whose click handler was a
// stopPropagation() and a comment saying to wire up the download
+25 -10
View File
@@ -15,12 +15,18 @@ import {
*/
test.describe('harness', () => {
test('the app is the real app, not a mock', async ({ app }) => {
// All 11 bound services land on window.go through the dev server.
const services = await app.evaluate(() => Object.keys(window.go));
// v2 landed every bound service on `window.go`, so "is this real"
// could be asked of an object. v3 has no such global — the
// bindings are ordinary bundled modules — so the question is asked
// of the runtime instead, which is a better question anyway: the
// real runtime is loaded, and it answers for real methods and
// refuses invented ones.
const runtime = await app.evaluate(() => ({
dispatch: typeof window._wails?.dispatchWailsEvent,
client: typeof window._wails?.clientId,
}));
expect(services).toEqual(
expect.arrayContaining(['queue', 'player', 'library', 'explore']),
);
expect(runtime).toEqual({ dispatch: 'function', client: 'string' });
const state = await callBinding<{ tracks: unknown[] }>(
app,
@@ -28,6 +34,12 @@ test.describe('harness', () => {
);
expect(state).toHaveProperty('tracks');
const unknown = await app
.evaluate(() => window.__yjEvents.call('queue.Queue.Nope', [], 2_000))
.catch((err: Error) => err.message);
expect(unknown).toContain('unknown bound method');
});
test('backend events are recorded, in order, with payloads', async ({
@@ -58,10 +70,12 @@ test.describe('harness', () => {
});
test('a binding called with wrong types fails fast', async ({ app }) => {
// player.UserVolume is an int. Passing a float makes the backend
// log "error parsing arguments" and never fire the callback; without
// a timeout the promise never settles and the spec hangs until the
// suite gives up.
// player.UserVolume is an int. Under v2 a float made the backend
// log "error parsing arguments" and never fire the callback, so
// this asserted that the harness's own timeout fired — the failure
// was visible only because the harness invented a deadline. v3
// answers 422 with a TypeError naming the argument, so the
// assertion is now on the backend's own words.
const failure = await app.evaluate(async () => {
try {
await window.__yjEvents.call(
@@ -76,7 +90,8 @@ test.describe('harness', () => {
}
});
expect(failure).toContain('did not settle');
expect(failure).toContain('TypeError');
expect(failure).toContain('player.UserVolume');
});
test('the control surface is mounted and seeded', async ({ testctl }) => {
+25 -25
View File
@@ -1,4 +1,13 @@
import { test, expect, resetEvents, callBinding } from '../support/fixtures.js';
import type { Page } from '@playwright/test';
import {
test,
expect,
resetEvents,
callBinding,
bindingCalls,
NO_QUEUE_SOURCE,
} from '../support/fixtures.js';
/**
* Finishing a track is cheap, and does not disturb the user.
@@ -19,23 +28,17 @@ import { test, expect, resetEvents, callBinding } from '../support/fixtures.js';
/** Long enough for a fixture track (26 s) to finish by itself. */
const FINISH_TIMEOUT = 60_000;
/** Instrument the library bindings so "was anything refetched" is a fact. */
const COUNT_LIBRARY_CALLS = `(() => {
const w = window;
if (w.__yjCalls) { w.__yjCalls.length = 0; return; }
w.__yjCalls = [];
const lib = w.go.library.Library;
for (const key of Object.keys(lib)) {
const fn = lib[key];
if (typeof fn !== 'function' || fn.__counted) continue;
const wrapped = function (...args) {
w.__yjCalls.push(key);
return fn.apply(this, args);
};
wrapped.__counted = true;
lib[key] = wrapped;
}
})()`;
/**
* "Was anything refetched" is a fact, not an inference.
*
* This used to wrap every method on `window.go.library.Library` in
* place. v3 has no such object, and does not need one: the harness
* bridge records every binding call off the single POST they all go
* through, so the question is answered by reading that log rather than
* by instrumenting a target first. `resetEvents` clears it.
*/
const libraryCalls = async (app: Page): Promise<string[]> =>
(await bindingCalls(app)).filter((c) => c.startsWith('library.Library.'));
/**
* Select rows by dispatching on the row rather than clicking it.
@@ -82,8 +85,7 @@ test.describe('a finished track', () => {
expect(paths.length).toBeGreaterThanOrEqual(2);
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false]);
await app.evaluate(COUNT_LIBRARY_CALLS);
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false, NO_QUEUE_SOURCE]);
await resetEvents(app);
await callBinding(app, 'queue.Queue.PlayIndex', [0]);
@@ -111,12 +113,10 @@ test.describe('a finished track', () => {
'a play emitted the retag event, which invalidates every cache',
).toBe(0);
const refetched = await app.evaluate(
() => (window as unknown as { __yjCalls: string[] }).__yjCalls,
);
const refetched = await libraryCalls(app);
expect(
refetched.filter((c) => c.startsWith('GetAll')),
refetched.filter((c) => c.startsWith('library.Library.GetAll')),
'a play refetched a collection',
).toEqual([]);
@@ -136,7 +136,7 @@ test.describe('a finished track', () => {
const paths = await app.evaluate(firstPaths, 2);
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false]);
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false, NO_QUEUE_SOURCE]);
await resetEvents(app);
await callBinding(app, 'queue.Queue.PlayIndex', [0]);
+3 -1
View File
@@ -7,6 +7,7 @@ import {
resetEvents,
waitForEvent,
LONG_TRACK,
NO_QUEUE_SOURCE,
} from '../support/fixtures.js';
import type { Page } from '@playwright/test';
@@ -161,6 +162,7 @@ test.describe('a finished queue keeps its context', () => {
[rows[0].file_path],
0,
false,
NO_QUEUE_SOURCE,
]);
await waitForEvent(app, 'QueueChanged');
await callBinding(app, 'queue.Queue.Play');
@@ -199,7 +201,7 @@ test.describe('a track that will not play says so', () => {
try {
await callBinding(app, 'queue.Queue.Clear');
await resetEvents(app);
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false]);
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false, NO_QUEUE_SOURCE]);
await waitForEvent(app, 'QueueChanged');
await callBinding(app, 'queue.Queue.Play');
+7 -2
View File
@@ -1,4 +1,9 @@
import { test, expect, callBinding } from '../support/fixtures.js';
import {
test,
expect,
callBinding,
NO_QUEUE_SOURCE,
} from '../support/fixtures.js';
import type { Page } from '@playwright/test';
/**
@@ -35,7 +40,7 @@ async function queueFourAndOpen(app: Page): Promise<string[]> {
return (tracks as { FilePath: string }[]).slice(0, 4).map((t) => t.FilePath);
});
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false]);
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false, NO_QUEUE_SOURCE]);
// A closed panel renders no list at all, so there is no row to focus.
await app.locator('#queue-button').click();
+8 -2
View File
@@ -1,4 +1,10 @@
import { test, expect, callBinding, waitForEvent } from '../support/fixtures.js';
import {
test,
expect,
callBinding,
waitForEvent,
NO_QUEUE_SOURCE,
} from '../support/fixtures.js';
import type { Page } from '@playwright/test';
/**
@@ -64,7 +70,7 @@ async function playTheLongOne(app: Page): Promise<void> {
expect(paths.length).toBeGreaterThan(0);
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false]);
await callBinding(app, 'queue.Queue.SetQueue', [paths, 0, false, NO_QUEUE_SOURCE]);
await waitForEvent(app, 'TrackChanged');
// The scroll cycle is armed 1500 ms after the geometry is measured,
+33 -23
View File
@@ -199,39 +199,49 @@ function addRequest(
}
/**
* Drop any request for this album, through the raw binding.
* Drop any request for this album, over the runtime endpoint.
*
* Deliberately not `callBinding`: that goes through `window.__yjEvents`,
* which only exists on a page the `app` fixture created — a bare
* `browser.newPage()` has no init script, so the bridge is undefined and
* the cleanup throws where nobody is looking. The first version of this
* did exactly that and left the request behind, which failed the *next*
* run of this same spec.
* Deliberately not `callBinding`: that goes through
* `window.__yjEvents`, which only exists on a page the `app` fixture
* created — a bare `browser.newPage()` has no init script, so the
* bridge is undefined and the cleanup throws where nobody is looking.
* The first version of this did exactly that and left the request
* behind, which failed the *next* run of this same spec.
*
* v2's answer was `window.go`, which every page had. v3 has no such
* global, and the version of this that kept reading it did not throw —
* it returned early on `if (!svc)`, which is the same silent cleanup
* with a different cause. A POST to `/wails/runtime` needs neither:
* it is the same request the bundle makes, and any page can make it.
*/
async function clearRequest(page: import('@playwright/test').Page) {
await page.evaluate(async (mbid) => {
const go = (
window as unknown as {
go?: {
download?: {
Service?: {
ListRequests(): Promise<{ id: number; mbid: string }[]>;
RemoveRequest(id: number): Promise<void>;
};
};
};
}
).go;
const call = async (method: string, args: unknown[]) => {
const res = await fetch('/wails/runtime', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
object: 0,
method: 0,
args: {
'call-id': `clear-${method}`,
methodName: `yellowjacket/backend/${method}`,
args,
},
}),
});
const svc = go?.download?.Service;
if (!res.ok) throw new Error(`${method}: ${await res.text()}`);
if (!svc) return;
return res.json();
};
const rows = (await svc.ListRequests()) ?? [];
const rows: { id: number; mbid: string }[] =
(await call('download.Service.ListRequests', [])) ?? [];
for (const row of rows) {
if (row.mbid?.toLowerCase() === mbid.toLowerCase()) {
await svc.RemoveRequest(row.id);
await call('download.Service.RemoveRequest', [row.id]);
}
}
}, MBID);
+49 -6
View File
@@ -3,12 +3,26 @@ import { dirname, resolve } from 'node:path';
import { test as base, expect, type Page } from '@playwright/test';
import { nameOf } from './method-ids.mjs';
const here = dirname(fileURLToPath(import.meta.url));
/** The same bridge `playwright-cli` loads, so an exploratory session and
* a committed spec see an identical page. */
const INIT_SCRIPT = resolve(here, '../../.playwright/init-events.js');
/**
* The fourth argument to `queue.Queue.SetQueue`, for a queue with no
* single source — the whole library, or a handful of ad-hoc tracks,
* which is what every spec here builds.
*
* It is passed explicitly because v3 rejects a call with the wrong
* argument count (`expects 4 arguments, got 3`) where v2 accepted one
* and filled the gap with a zero value. The specs had been three-arg
* since the parameter was added; nothing said so.
*/
export const NO_QUEUE_SOURCE = { type: '', id: 0, label: '' };
/**
* The 90-second fixture track (`cmd/gentestdata`, case `edge-lengths`).
*
@@ -61,12 +75,13 @@ export async function eventNames(
}
/**
* Call a bound Go method with a timeout.
* Call a bound Go method by name, over the runtime's own endpoint.
*
* Wrong argument types make the backend log "error parsing arguments"
* and never fire the callback, so an unguarded call hangs until the
* whole spec times out with no clue why. This fails in seconds and
* says where to look.
* v3 rejects a bad call rather than never firing its callback the way
* v2 did: a wrong argument type comes back as a TypeError naming the
* argument, a wrong count as `expects 4 arguments, got 3`, an unknown
* method as a ReferenceError. The timeout is a backstop for a hung
* request, not the mechanism that makes a mistake visible.
*/
export async function callBinding<T = unknown>(
page: Page,
@@ -81,6 +96,21 @@ export async function callBinding<T = unknown>(
) as Promise<T>;
}
/**
* The binding calls the *app* made, newest last, as `pkg.Type.Method`.
*
* This is what replaces v2's trick of wrapping `window.go` in place:
* `.playwright/init-events.js` records every call off the one POST v3
* routes them all through, and `method-ids.ts` names them from the
* generated tree. It sees calls from any module and cannot miss one
* made before a wrapper was installed.
*/
export async function bindingCalls(page: Page): Promise<string[]> {
const calls = await page.evaluate(() => window.__yjEvents.bindings);
return calls.map(nameOf);
}
/** Thin client for the dev-only /__test/ surface (backend/testctl). */
export class TestCtl {
constructor(private readonly baseURL: string) {}
@@ -166,7 +196,20 @@ declare global {
): Promise<YjEvent>;
ready(timeoutMs?: number): Promise<boolean>;
call(path: string, args?: unknown[], timeoutMs?: number): Promise<any>;
/** Every binding call the app itself made; see init-events.js. */
bindings: {
methodID: number | null;
methodName: string | null;
start: number;
ms: number;
bytes: number;
}[];
measureBytes: boolean;
};
/** The v3 runtime's own namespace, installed by @wailsio/runtime. */
_wails?: {
dispatchWailsEvent?: (event: unknown) => void;
clientId?: string;
};
go: Record<string, Record<string, Record<string, (...a: any[]) => any>>>;
}
}
+110
View File
@@ -0,0 +1,110 @@
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
/**
* Turns the method ids a binding call carries back into the names a
* spec (or a measurement) wants to read.
*
* Plain JavaScript, not TypeScript, because `e2e/perf/measure.mjs` runs
* under bare `node` and both it and the specs need exactly this map —
* and one derivation with a `.mjs` extension is better than two that
* can disagree.
*
* v2 put every bound method on `window.go`, so a harness could walk
* that object to wrap or enumerate them. v3 has no such surface: the
* generated bindings are ordinary bundled modules calling
* `$Call.ByID(<fnv hash of the fully-qualified name>)`, and what
* reaches the wire — and therefore what `.playwright/init-events.js`
* can record — is the number.
*
* Plan 009 phase 6b named two ways to get the list back. This is the
* preferred one: derive it from `frontend/bindings/`, which is a real
* generated tree and is already gated by `make bindings-check`. The
* alternative — a hand-maintained list — goes stale silently, which is
* the failure mode that check exists to prevent.
*
* Nothing here hashes anything. The generated source carries the id as
* a literal beside the function that sends it, so this reads the two
* together rather than recomputing one from the other and hoping the
* hash still matches.
*/
const here = dirname(fileURLToPath(import.meta.url));
const BINDINGS_ROOT = resolve(here, '../../frontend/bindings/yellowjacket');
/** `export function Name(…) { return $Call.ByID(123, …) }` */
const BOUND_METHOD = /export function (\w+)\([\s\S]*?\$Call\.ByID\((\d+)/g;
/** `import * as Library from "./library.js";` — the only place the Go
* type's casing survives; the file is `library.ts`, and
* `frontendutil.ts` cannot tell you it is `FrontendUtil`. */
const SERVICE_EXPORT = /import \* as (\w+) from "\.\/([\w.]+)\.js"/g;
function* walk(dir) {
for (const entry of readdirSync(dir)) {
const path = join(dir, entry);
if (statSync(path).isDirectory()) yield* walk(path);
else if (entry.endsWith('.ts')) yield path;
}
}
let cached;
/**
* methodIDs maps a binding's method id to `pkg.Type.Method` — the same
* short path `callBinding` takes, so a spec never has to know that the
* backend calls it `yellowjacket/backend/queue.Queue.GetState`.
*/
export function methodIDs() {
if (cached) return cached;
const byID = new Map();
for (const file of walk(BINDINGS_ROOT)) {
if (!file.endsWith('index.ts')) continue;
const dir = dirname(file);
const pkg = dir.split('/').pop() ?? '';
const index = readFileSync(file, 'utf8');
for (const [, typeName, base] of index.matchAll(SERVICE_EXPORT)) {
let source;
try {
source = readFileSync(join(dir, `${base}.ts`), 'utf8');
} catch {
continue; // a models-only re-export, which binds nothing
}
for (const [, method, id] of source.matchAll(BOUND_METHOD)) {
byID.set(Number(id), `${pkg}.${typeName}.${method}`);
}
}
}
if (byID.size === 0) {
throw new Error(
`method-ids: no bound methods under ${BINDINGS_ROOT}; ` +
`run 'make bindings'`,
);
}
cached = byID;
return byID;
}
/** Names one recorded binding call, or `#<id>` if the map has no entry
* — which fails the assertion naming it rather than passing quietly. */
export function nameOf(call) {
if (call.methodName) {
return call.methodName.replace(/^yellowjacket\/backend\//, '');
}
return call.methodID === null
? '#unknown'
: (methodIDs().get(call.methodID) ?? `#${call.methodID}`);
}
+8 -2
View File
@@ -8,7 +8,13 @@
"noEmit": true,
"skipLibCheck": true,
"types": ["node"],
"allowImportingTsExtensions": true
"allowImportingTsExtensions": true,
"allowJs": true
},
"include": ["specs/**/*.ts", "support/**/*.ts", "playwright.config.ts"]
"include": [
"specs/**/*.ts",
"support/**/*.ts",
"support/**/*.mjs",
"playwright.config.ts"
]
}
@@ -0,0 +1,9 @@
//@ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Create as $Create } from "@wailsio/runtime";
Object.freeze($Create.Events);
@@ -0,0 +1,2 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
+3 -2
View File
@@ -1,5 +1,6 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {home} from '../models';
export function GetShelves():Promise<Array<home.Shelf>>;
export {
Duration
} from "./models.js";
+38
View File
@@ -0,0 +1,38 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* A Duration represents the elapsed time between two instants
* as an int64 nanosecond count. The representation limits the
* largest representable duration to approximately 290 years.
*/
export enum Duration {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = 0,
minDuration = -9223372036854775808,
maxDuration = 9223372036854775807,
/**
* Common durations. There is no definition for units of Day or larger
* to avoid confusion across daylight savings time zone transitions.
*
* To count the number of units in a [Duration], divide:
*
* second := time.Second
* fmt.Print(int64(second/time.Millisecond)) // prints 1000
*
* To convert an integer number of units to a Duration, multiply:
*
* seconds := 10
* fmt.Print(time.Duration(seconds)*time.Second) // prints 10s
*/
Nanosecond = 1,
Microsecond = 1000,
Millisecond = 1000000,
Second = 1000000000,
Minute = 60000000000,
Hour = 3600000000000,
};
@@ -0,0 +1,19 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import * as Service from "./service.js";
export {
Service
};
export type {
AlignmentView,
ApplyResultView,
CandidateView,
FailureView,
LocalTrackView,
PendingItem,
ScoreBreakdownView,
ScoreView,
SearchHitView
} from "./models.js";
@@ -0,0 +1,187 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* AlignmentView mirrors autotag.TrackAlignment. LocalIndex of -1
* means "candidate has this track, folder doesn't" (status=missing).
*/
export interface AlignmentView {
"localIndex": number;
"localTitle": string;
"localLengthMillis": number;
"candidatePosition": number;
"candidateDiscNumber": number;
"candidateTitle": string;
"candidateMbid": string;
"candidateLength": number;
"titleScore": number;
"lengthDeltaMs": number;
"trackNumberOk": boolean;
"status": string;
}
/**
* ApplyResultView mirrors autotag.ApplyResult for the frontend.
*/
export interface ApplyResultView {
"groupKey": string;
"succeeded": number;
"failed": number;
"failures": FailureView[] | null;
}
/**
* CandidateView mirrors autotag.Candidate + its alignments. The
* CoverArtURL is populated eagerly only for the top-ranked
* candidate (eager network fetch); the frontend calls
* GetCandidateCoverArt to lazy-load art for the others when the
* user selects them. Date is this release's own date (re-issue
* year for remasters); OriginalDate is the release-group's
* first-release-date (the original album year).
*/
export interface CandidateView {
"releaseMbid": string;
"releaseGroupMbid": string;
"title": string;
"artistCredit": string;
"date": string;
"originalDate": string;
"country": string;
"status": string;
"primaryType": string;
"trackCount": number;
"score": number;
"breakdown": ScoreBreakdownView;
"source": string;
"provenance": string;
"coverArtUrl": string;
"alignments": AlignmentView[] | null;
}
/**
* FailureView carries one failed track for the frontend's toast.
*/
export interface FailureView {
"filePath": string;
"error": string;
}
/**
* LocalTrackView mirrors autotag.LocalTrack.
*/
export interface LocalTrackView {
"audioFileId": number;
"filePath": string;
"title": string;
"artist": string;
"trackNumber": number;
"discNumber": number;
"lengthMillis": number;
"recordingMbid": string;
}
/**
* PendingItem is a projection of tagging_items that's safe to hand
* to the frontend. Score is dereferenced to 0 when NULL so TS sees
* a plain number.
*/
export interface PendingItem {
"groupKey": string;
"libraryId": number;
"libraryName": string;
/**
* FolderSubPath is the album folder's path relative to its
* library root (e.g. "Beatles/Abbey Road"). Empty when the
* path can't be derived (no audio files yet, missing library
* row, etc.). Populated by ListPendingFolders and
* GetPendingFolder; not present on rows returned by other
* list/get queries that haven't been extended.
*/
"folderSubPath": string;
"trackCount": number;
"albumName": string;
"albumArtist": string;
"discNumber": number;
"bestMatchReleaseMbid": string;
"score": number;
"status": string;
/**
* Synthetic marks a group SplitMixedFolder carved out of a
* bigger folder by matching tags rather than a directory — the
* review UI labels these distinctly since several may share the
* same FolderSubPath.
*/
"synthetic": boolean;
/**
* LikelyMixedBag is a cheap SQL-side approximation of autotag.
* IsMixedBag, computed for the whole library in one pass by
* ListPendingFolders (see ListLikelyMixedBagGroupKeys) rather
* than hydrating every group's tracks in Go. It's a badge hint,
* not a guarantee — ScoreView.MixedBag (computed from the real
* track list when a folder is opened) is the authoritative check
* that gates the SplitMixedFolder action itself.
*/
"likelyMixedBag": boolean;
}
/**
* ScoreBreakdownView mirrors autotag.ScoreBreakdown for display.
* Each field is in [0, 1].
*/
export interface ScoreBreakdownView {
"titleAvg": number;
"lengthAvg": number;
"artistFit": number;
"albumFit": number;
"trackCountFit": number;
"releaseMeta": number;
"evidence": number;
}
/**
* ScoreView is the Wails-friendly projection of autotag.GroupScore.
*/
export interface ScoreView {
"groupKey": string;
"localTracks": LocalTrackView[] | null;
"candidates": CandidateView[] | null;
/**
* Recommendation is the qualitative confidence tier for the
* ranked list: "none", "low", "medium", or "strong". Unlike the
* raw score it accounts for ambiguity (a rival release group
* scoring nearly as high) and alignment defects.
*/
"recommendation": string;
/**
* MixedBag is true when this group's tracks look like an
* unrelated pile rather than one release (autotag.IsMixedBag) —
* the review UI offers SplitMixedFolder when set. Always false
* for a group that's already Synthetic; a split group doesn't
* get split again.
*/
"mixedBag": boolean;
/**
* Synthetic mirrors PendingItem.Synthetic for the currently
* open group.
*/
"synthetic": boolean;
}
/**
* SearchHitView is one in-app MusicBrainz search result surfaced to
* the review UI. Kind is "releasegroup" or "recording" so the
* frontend knows which resolve path SelectSearchCandidate must take.
*/
export interface SearchHitView {
"mbid": string;
"kind": string;
"title": string;
"artist": string;
"detail": string;
}
@@ -0,0 +1,283 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* Service is the Wails-bound surface for the review UI. All
* exported methods become TypeScript stubs that the frontend calls.
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as jobs$0 from "../jobs/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
/**
* AckLibraryWarning records that the user has seen the first-
* time-apply irreversibility warning for this library.
*/
export function AckLibraryWarning(libraryID: number): $CancellablePromise<void> {
return $Call.ByID(1523853598, libraryID);
}
/**
* Apply runs the apply pipeline for the given group + chosen
* release MBID. Uses the cached candidate list from the last
* GetCandidates / GetCandidatesForPasteURL call so the user
* always applies the exact release they saw in the UI.
* Empty releaseMBID picks the top-scored candidate.
*/
export function Apply(groupKey: string, releaseMBID: string): $CancellablePromise<$models.ApplyResultView | null> {
return $Call.ByID(2015906074, groupKey, releaseMBID);
}
/**
* ApplyAsync dispatches an Apply for the given group in a
* background goroutine and returns immediately. Progress is
* reported to the frontend via three Wails events:
*
* - AutotagApplyStarted {groupKey, total}
* - AutotagApplyProgress {groupKey, current, total, succeeded, failed}
* - AutotagApplyFinished {groupKey, succeeded, failed, error}
*
* The candidate list and locals are snapshot synchronously before
* returning so a concurrent GetCandidates can't shuffle the
* selected release out from under the running job. Returns
* ErrApplyInFlight when a job for the same groupKey is already
* running; otherwise nil. Job-level errors land in the Finished
* event payload, not the return value.
*/
export function ApplyAsync(groupKey: string, releaseMBID: string): $CancellablePromise<void> {
return $Call.ByID(2246879134, groupKey, releaseMBID);
}
/**
* ClearCompletedEntries marks every confirmed item in the given
* library (or all libraries when libraryID = 0) as cleared. The
* rows stay in the table — so a re-scan of the same folder
* won't bounce them back into 'pending' — but they no longer
* appear in the review queue. Returns the number of rows
* affected so the frontend can show a quick toast.
*/
export function ClearCompletedEntries(libraryID: number): $CancellablePromise<void> {
return $Call.ByID(1606786204, libraryID);
}
/**
* GetCandidateCoverArt is the lazy-load entrypoint for cover art
* when the user picks a non-top candidate. Goes through the
* CAA-only chain (disk cache → network) — never the local
* library-by-name index, since that index conflates embedded ID3
* art with externally-fetched art and the embedded path produces
* stale or wrong-version covers for autotag review.
*/
export function GetCandidateCoverArt(releaseMBID: string, releaseGroupMBID: string): $CancellablePromise<string> {
return $Call.ByID(1410264131, releaseMBID, releaseGroupMBID);
}
/**
* GetCandidates runs the scorer for a group and returns the full
* candidate list (ordered best-first) along with the local tracks.
* Caches the scored candidate list so Apply can operate on the
* exact release the user saw — even if a concurrent rescore would
* have shuffled the ranking. Also writes the top candidate's
* score back to the tagging_items row so the sidebar's match-%
* pill and the score-desc list sort reflect the freshly-computed
* ranking instead of the scanner's pre-review estimate.
*/
export function GetCandidates(groupKey: string): $CancellablePromise<$models.ScoreView | null> {
return $Call.ByID(873001524, groupKey);
}
/**
* GetCandidatesForPasteURL resolves a MusicBrainz release-or-
* release-group URL into a ScoreView by fetching the pasted entity
* from MB, running track alignment against the local group, and
* prepending a fully-scored candidate to the existing list. The
* pasted candidate's provenance is "paste" so the UI can label it.
*/
export function GetCandidatesForPasteURL(groupKey: string, mbReleaseURL: string): $CancellablePromise<$models.ScoreView | null> {
return $Call.ByID(2470223543, groupKey, mbReleaseURL);
}
/**
* GetLocalCoverArt returns a data-URI for the artwork associated
* with the group's local files, so the review UI can show "what the
* folder already looks like" beside the fetched candidate art.
* It first checks each file for embedded ID3/Vorbis pictures, then
* falls back to a sidecar cover image (cover.jpg, folder.png, …)
* sitting in the album directory. Untagged folders carry one or the
* other far more often than they have a DB release-group cover, so we
* read the files directly. Returns "" when nothing is found.
*/
export function GetLocalCoverArt(groupKey: string): $CancellablePromise<string> {
return $Call.ByID(2603982821, groupKey);
}
/**
* GetNextPending returns the next pending tagging item after the
* internal cursor, or nil when the queue is empty. Advances the
* cursor on success.
*/
export function GetNextPending(): $CancellablePromise<$models.PendingItem | null> {
return $Call.ByID(1751929138);
}
/**
* GetPendingFolder returns the tagging item for a specific group
* key — used by the sidebar after the user clicks an entry, so
* the header can render even when the cursor-based queue iteration
* hasn't visited that key yet.
*/
export function GetPendingFolder(groupKey: string): $CancellablePromise<$models.PendingItem | null> {
return $Call.ByID(3455545217, groupKey);
}
/**
* LeaveAsIs is the "local tags are correct, don't rescore"
* acknowledgement — bumps status to 'confirmed' without touching
* any files.
*/
export function LeaveAsIs(groupKey: string): $CancellablePromise<void> {
return $Call.ByID(3897348801, groupKey);
}
/**
* ListPendingFolders returns every tagging item in the given
* library (or all libraries when libraryID = 0) that has not been
* explicitly cleared. Includes items in all review states —
* pending, skipped, and confirmed — so the sidebar can group
* them into sections (the user wanted skipped/completed to stay
* visible at the bottom rather than vanish). Sorted by score
* descending so the UI surfaces the most confident matches first
* (NULL scores fall to the bottom); the frontend re-groups by
* status for display.
*/
export function ListPendingFolders(libraryID: number): $CancellablePromise<$models.PendingItem[] | null> {
return $Call.ByID(617511590, libraryID);
}
/**
* RetagGroup flips a group back to 'pending' so the user can
* re-review after an apply or skip. Drops the durably-cached
* candidate list too, so the next open recomputes against fresh
* MusicBrainz data rather than reusing the stale stored result.
*/
export function RetagGroup(groupKey: string): $CancellablePromise<void> {
return $Call.ByID(2552507548, groupKey);
}
/**
* SearchCandidates runs an in-app MusicBrainz search — the "suggest a
* new candidate" escape hatch when the automatic cascade misses.
* kind is "recording" (title + artist, for singletons) or anything
* else (album + artist release-group search). Returns lightweight
* hits; SelectSearchCandidate resolves the picked one into a scored
* candidate.
*/
export function SearchCandidates(kind: string, query: string, artist: string): $CancellablePromise<$models.SearchHitView[] | null> {
return $Call.ByID(4062823000, kind, query, artist);
}
/**
* SelectSearchCandidate resolves a picked search hit into a fully-
* scored candidate and splices it to the top of the group's candidate
* list — the same shape as the paste-URL path, so Apply works
* unchanged. A "recording" hit is resolved to a representative
* release first.
*/
export function SelectSearchCandidate(groupKey: string, kind: string, mbid: string): $CancellablePromise<$models.ScoreView | null> {
return $Call.ByID(2540832563, groupKey, kind, mbid);
}
/**
* SetJobRegistry wires the background job registry so an apply reports
* progress and offers a cancel like every other long-running operation.
*
* Before this, apply was a bare goroutine whose progress lived in a
* component field that navigation discarded, with no cancel and no
* record of where it stopped (errors.C3). Everything routed through the
* registry gets progress, cancel and the global indicator for free; the
* three subsystems that lacked them were the three that were not
* registered.
*/
export function SetJobRegistry(reg: jobs$0.Registry | null): $CancellablePromise<void> {
return $Call.ByID(3755409662, reg);
}
/**
* Skip marks the current group as skipped — it stays in the
* queue but renders in the "Skipped" section at the bottom of
* the sidebar, separate from pending items, so the user can
* revisit it later if they change their mind.
*/
export function Skip(groupKey: string): $CancellablePromise<void> {
return $Call.ByID(1248497081, groupKey);
}
/**
* SplitMixedFolder is the "this folder is a pile of unrelated
* tracks" escape hatch: it partitions the group's local tracks via
* autotag.SplitPlan — tag-matched sub-albums (see
* autotag.ClusterByAlbumArtist) plus a one-track cluster for every
* track that didn't share an (album, album-artist) pair with
* anything else — and carves each piece out into its own synthetic
* tagging group, reassigning just those audio_files rows (no files
* move on disk). Every track leaves the original group; nothing is
* left behind to be scored as "extra tracks" of whichever piece
* happens to match first. The synthetic groups are scored with
* relaxed missing-track handling (rank.go, recommend.go), since
* they're expected to be an incomplete subset of whatever release
* they belong to.
*
* Returns the resulting PendingItems — the leftover original group
* first (if anything remains in it), then the new synthetic groups
* — so the frontend can splice them into the sidebar without a full
* reload. Errors with errNothingToSplit when the folder is already
* one coherent unit (SplitPlan produces a single cluster covering
* every track); callers should treat that as "nothing to show", not
* a failure.
*/
export function SplitMixedFolder(groupKey: string): $CancellablePromise<$models.PendingItem[] | null> {
return $Call.ByID(3640008069, groupKey);
}
/**
* StartAutotagQueue resets the cursor for a new review session
* and kicks off the background prefetch worker so sidebar pills
* populate while the user reviews the first folder. Cancels any
* previous prefetch (library filter may have changed). Pass
* libraryID=0 to cover all libraries.
*/
export function StartAutotagQueue(libraryID: number): $CancellablePromise<void> {
return $Call.ByID(568310002, libraryID);
}
/**
* StartBackgroundPrefetch kicks off (or restarts) the prefetch
* worker over all libraries. Called from app startup hooks so
* unscored pending items get their match-% backfilled while the
* user does other things — by the time they navigate to the
* autotag page, the sidebar pills are already populated.
* Idempotent: items with an existing score are skipped, so it's
* safe to call after every library scan and on every app launch.
*/
export function StartBackgroundPrefetch(): $CancellablePromise<void> {
return $Call.ByID(428378477);
}
/**
* WritesInFlight reports whether an apply is currently rewriting tags
* on disk. Quitting mid-apply leaves a folder half-retagged, so the app
* asks before closing (errors.p4).
*/
export function WritesInFlight(): $CancellablePromise<boolean> {
return $Call.ByID(1561223347);
}
@@ -0,0 +1,236 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* Config represents the application configuration.
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as download$0 from "../download/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as tracklist$0 from "../tracklist/models.js";
/**
* GetDefaultPage returns the view the app opens to on launch.
*/
export function GetDefaultPage(): $CancellablePromise<string> {
return $Call.ByID(3209119019);
}
/**
* GetDownloadPreferences returns the configured auto-download
* guardrails.
*/
export function GetDownloadPreferences(): $CancellablePromise<download$0.AutoDownloadPrefs> {
return $Call.ByID(4104118319);
}
/**
* GetFavoritesIconStyle returns the configured icon style.
*/
export function GetFavoritesIconStyle(): $CancellablePromise<string> {
return $Call.ByID(3222927230);
}
/**
* GetFavoritesPlaylistID returns the configured default playlist ID.
*/
export function GetFavoritesPlaylistID(): $CancellablePromise<number> {
return $Call.ByID(4116868495);
}
/**
* GetLibraryDirectory returns the currently configured library directory path.
*/
export function GetLibraryDirectory(): $CancellablePromise<string> {
return $Call.ByID(2123565657);
}
/**
* GetPinDefaultPlaylist returns whether the default playlist
* is pinned to the top of the playlist view.
*/
export function GetPinDefaultPlaylist(): $CancellablePromise<boolean> {
return $Call.ByID(3818283301);
}
/**
* GetQueueFallback returns what plays, if anything, once the queue
* runs out.
*/
export function GetQueueFallback(): $CancellablePromise<string> {
return $Call.ByID(1504773860);
}
/**
* GetScanConcurrency returns the configured scan concurrency mode.
*/
export function GetScanConcurrency(): $CancellablePromise<string> {
return $Call.ByID(3233312385);
}
/**
* GetShortcuts returns the current shortcut bindings map.
*/
export function GetShortcuts(): $CancellablePromise<{ [_ in string]?: string } | null> {
return $Call.ByID(1616717634);
}
/**
* GetThemeAccentColor returns the configured accent colour.
*/
export function GetThemeAccentColor(): $CancellablePromise<string> {
return $Call.ByID(4264374057);
}
/**
* GetThemeBackgroundShade returns the configured background shade.
*/
export function GetThemeBackgroundShade(): $CancellablePromise<string> {
return $Call.ByID(578772653);
}
/**
* GetTrackListColumns returns the configured track-list columns.
*/
export function GetTrackListColumns(): $CancellablePromise<tracklist$0.Column[] | null> {
return $Call.ByID(3426289065);
}
/**
* Load reads and parses the config file from disk.
*/
export function Load(): $CancellablePromise<void> {
return $Call.ByID(2408891113);
}
/**
* ResetShortcuts resets all shortcuts to defaults.
*/
export function ResetShortcuts(): $CancellablePromise<void> {
return $Call.ByID(3197898083);
}
/**
* Save writes the config to disk. Refuses to write if the config
* was never successfully loaded — prevents overwriting user config
* with defaults during abnormal startup/shutdown sequences.
*/
export function Save(): $CancellablePromise<void> {
return $Call.ByID(1988945736);
}
/**
* SetDefaultPage validates and saves a new launch page.
*/
export function SetDefaultPage(page: string): $CancellablePromise<void> {
return $Call.ByID(2714957423, page);
}
/**
* SetDownloadPreferences saves new auto-download guardrails. This only
* persists them; the download package cannot depend on config (config
* already depends on download for UserConfig), so making the change
* live without a restart is the caller's job — the frontend settings
* save calls this and download.Service.SetPreferences in the same
* action, and app.go's initDownloadRuntime applies the saved value to
* the running Manager at startup.
*/
export function SetDownloadPreferences(prefs: download$0.AutoDownloadPrefs): $CancellablePromise<void> {
return $Call.ByID(2655203755, prefs);
}
/**
* SetFavoritesIconStyle validates and saves a new icon style.
*/
export function SetFavoritesIconStyle(style: string): $CancellablePromise<void> {
return $Call.ByID(812590378, style);
}
/**
* SetFavoritesPlaylistID saves a new default playlist ID.
*/
export function SetFavoritesPlaylistID(id: number): $CancellablePromise<void> {
return $Call.ByID(2117079163, id);
}
/**
* SetLibraryDirectory validates and saves a new library directory,
* then emits the LibraryConfigChanged event so listeners (e.g. the
* Library scanner) can react.
*/
export function SetLibraryDirectory(dir: string): $CancellablePromise<void> {
return $Call.ByID(1352505021, dir);
}
/**
* SetPinDefaultPlaylist saves whether the default playlist
* should be pinned to the top of the playlist view.
*/
export function SetPinDefaultPlaylist(pin: boolean): $CancellablePromise<void> {
return $Call.ByID(372446849, pin);
}
/**
* SetQueueFallback validates and saves a new queue-fallback mode.
*/
export function SetQueueFallback(mode: string): $CancellablePromise<void> {
return $Call.ByID(2824429312, mode);
}
/**
* SetScanConcurrency validates and saves a new scan concurrency
* mode. The change takes effect on the next scan.
*/
export function SetScanConcurrency(mode: string): $CancellablePromise<void> {
return $Call.ByID(742893445, mode);
}
/**
* SetShortcut saves a single shortcut binding.
*/
export function SetShortcut(action: string, key: string): $CancellablePromise<void> {
return $Call.ByID(275130705, action, key);
}
/**
* SetShortcuts saves the entire shortcut bindings map.
*/
export function SetShortcuts(bindings: { [_ in string]?: string } | null): $CancellablePromise<void> {
return $Call.ByID(4073898118, bindings);
}
/**
* SetThemeAccentColor validates and saves a new accent colour.
*/
export function SetThemeAccentColor(color: string): $CancellablePromise<void> {
return $Call.ByID(3536665861, color);
}
/**
* SetThemeBackgroundShade validates and saves a new background shade.
*/
export function SetThemeBackgroundShade(shade: string): $CancellablePromise<void> {
return $Call.ByID(2709290065, shade);
}
/**
* SetTrackListColumns validates and saves a new column layout.
*/
export function SetTrackListColumns(columns: tracklist$0.Column[] | null): $CancellablePromise<void> {
return $Call.ByID(4226159685, columns);
}
/**
* Validate returns errors if there is a breaking issue with the config.
*/
export function Validate(): $CancellablePromise<void> {
return $Call.ByID(3087982155);
}
@@ -1,7 +1,7 @@
// @ts-check
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function GetShelves() {
return window['go']['home']['Service']['GetShelves']();
}
import * as Config from "./config.js";
export {
Config
};
@@ -0,0 +1,6 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export type {
Library
} from "./models.js";
@@ -0,0 +1,10 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export interface Library {
"ID": number;
"Name": string;
"Path": string;
"CreatedAt": string;
"AutotagWarningAcked": number;
}
@@ -0,0 +1,38 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import * as Service from "./service.js";
export {
Service
};
export {
Entity,
Format,
Kind,
Protocol,
RequestScope,
RequestState,
State
} from "./models.js";
export type {
AutoDownloadPrefs,
Candidate,
CandidateFile,
Caps,
Config,
Descriptor,
DownloadItem,
DownloadView,
ExpectedTrack,
Field,
MatchScore,
QualityScore,
Reconciler,
Request,
RequestInput,
SearchRequest,
StartResult,
Summary
} from "./models.js";
@@ -0,0 +1,732 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* AutoDownloadPrefs gates and scores what AutoPickable may choose
* without asking. Zero values are permissive: no size window and no
* format restriction.
*/
export interface AutoDownloadPrefs {
/**
* MinSizeMB and MaxSizeMB bound what auto-pick will grab. Zero
* means no bound on that side. A candidate outside the window is
* filtered out of auto-pick entirely, not merely scored down — a
* tiny "sampler" torrent or a boxset ten times the expected size is
* usually the wrong thing entirely, not a worse copy of the right
* thing.
*/
"minSizeMb": number;
"maxSizeMb": number;
/**
* PreferredSizeMB nudges the score toward a target size within the
* min/max window (a lossless rip and a heavily-padded lossless rip
* can both pass the window). Zero disables the nudge; sizeFit then
* returns a neutral value that does not affect ranking.
*/
"preferredSizeMb": number;
/**
* AllowedFormats restricts auto-pick to candidates whose audio
* files are all in one of these formats. Empty means no
* restriction.
*/
"allowedFormats": Format[] | null;
}
/**
* Candidate is one acquirable thing a provider found: a Soulseek user's
* folder, a torrent, a YouTube playlist. Providers fill the descriptive
* fields; the ranker fills Match, Quality and Score.
*/
export interface Candidate {
/**
* ID is unique within the provider that produced it, and is what
* gets handed back to Grab.
*/
"id": string;
"providerId": number;
"kind": Kind;
/**
* Protocol determines which transport can fetch this.
*/
"protocol": Protocol;
/**
* Descriptive.
*/
"title": string;
"artist"?: string;
/**
* peer username, indexer name, channel
*/
"origin"?: string;
"files": CandidateFile[] | null;
"totalSize": number;
/**
* Health is the provider's own availability signal, normalized to
* 0..1: seeder count for torrents, free upload slots and queue
* length for Soulseek. 0.5 when the provider has no signal.
*/
"health": number;
/**
* Scores, filled by the ranker.
*/
"match": MatchScore;
"quality": QualityScore;
"score": number;
}
/**
* CandidateFile is one file inside a candidate. Soulseek and torrent
* results give paths and sizes but no tags, so Format and duration are
* inferred from the path and size where possible.
*/
export interface CandidateFile {
"path": string;
"size": number;
"format": Format;
/**
* kbps, 0 when unknown
*/
"bitrate"?: number;
"isAudio": boolean;
/**
* expected track position
*/
"matchedTo"?: number;
}
/**
* Caps declares which roles a provider fills and which optional
* behaviours it supports. The frontend renders controls from this
* rather than switching on Kind, so a provider that gains resume
* support later needs no frontend change.
*/
export interface Caps {
/**
* Roles.
*/
"canSearch": boolean;
"canTransport": boolean;
"canDelegate": boolean;
/**
* CanList marks a provider that keeps a persistent wanted list of
* its own, which the reconciler mirrors this app's list into.
*/
"canList": boolean;
/**
* Optional behaviours.
*/
"canResume": boolean;
"canCancel": boolean;
"reportsSize": boolean;
/**
* Protocols this provider can transport. Empty for providers that
* only fetch their own search results.
*/
"transports": Protocol[] | null;
}
/**
* Config is a provider's stored settings. Secret values are not held
* here — they live in the secrets store keyed by provider ID, so a
* config blob can be logged or shown in the UI without redaction.
*/
export interface Config {
"id": number;
"kind": Kind;
"name": string;
"enabled": boolean;
"priority": number;
"settings": { [_ in string]?: string } | null;
/**
* SetSecrets names which of the descriptor's secret fields already
* have a stored value, without exposing it. Populated only when a
* Config is built for the frontend (see Service.withSecretFlags);
* empty when read from or written to the store.
*/
"setSecrets"?: { [_ in string]?: boolean } | null;
}
/**
* Descriptor is the static, instance-independent description of a
* provider kind: what it is called, what it can do, and which settings
* it needs. The settings page renders its form from this, so a new
* provider gets a config UI without any frontend work.
*/
export interface Descriptor {
"kind": Kind;
"name": string;
/**
* Summary is one line explaining what connecting this gets you.
*/
"summary": string;
/**
* Caps are the kind's inherent capabilities, before configuration.
*/
"caps": Caps;
/**
* Fields are the settings the user must supply.
*/
"fields": Field[] | null;
/**
* RequiresExternal names the software the user must run themselves
* (a slskd daemon, a Lidarr instance), or is empty for providers
* that need nothing but a binary on PATH.
*/
"requiresExternal"?: string;
}
/**
* DownloadItem is one grab attempt, as stored.
*
* deliberate: distinguishes it from download.Download (the attempt) and
* download.Request (the durable record) at every call site, which a bare
* "Item" would not.
*/
export interface DownloadItem {
"id": string;
"downloadId": string;
"providerId": number;
"transportId"?: number;
"externalId"?: string;
"candidate": Candidate;
"state": State;
"bytesDone": number;
"bytesTotal": number;
"error"?: string;
"createdAt": string;
"updatedAt": string;
}
/**
* DownloadView is one row of the downloads list.
*
* would stop reading as "one row of the Downloads list" the moment this
* package also has a Requests list — see RequestInput/Request nearby.
*/
export interface DownloadView {
"id": string;
/**
* Anchors. Any may be empty; all empty means free-text.
*/
"releaseMbid"?: string;
"releaseGroupMbid"?: string;
/**
* RecordingMBID anchors a single-track download. Its Expected holds
* exactly that one track, which is what lets a track download be
* scored — and therefore auto-picked — on the same footing as an
* album.
*/
"recordingMbid"?: string;
/**
* RequestID links back to the durable Request row this download was
* raised for or attached to, or 0 for a free-text download with
* nothing stable to attach to. The reconciler and manual anchored
* downloads both write the outcome back through it.
*/
"requestId"?: number;
/**
* Source records where the download came from, for the downloads
* list. Empty means "manual".
*/
"source"?: string;
/**
* Display and query text. Artist/Album are what searches are built
* from; Query overrides them when the user typed something raw.
*/
"artist": string;
"album": string;
"query"?: string;
/**
* Expected is the tracklist the anchor resolves to, used for
* completeness scoring and for the autotag match at import. Empty
* for free-text downloads.
*/
"expected"?: ExpectedTrack[] | null;
/**
* LibraryID is the library imported files belong to.
*/
"libraryId": number;
"createdAt": string;
"state": State;
"error"?: string;
"items": DownloadItem[] | null;
}
/**
* Entity says what a request's MBID names, and is the only type
* distinction the durable request list makes.
*/
export enum Entity {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
/**
* Request entity types.
*
* EntityArtist is a subscription rather than a thing to fetch: it
* is never satisfied, and each reconcile expands the artist's
* discography into child requests.
*/
EntityArtist = "artist",
/**
* EntityReleaseGroup is an album in the abstract — any release of
* it satisfies the request, which is what a user means by "I want
* this album".
*/
EntityReleaseGroup = "release-group",
/**
* EntityRelease is one specific edition, used when the user picked
* a particular pressing.
*/
EntityRelease = "release",
/**
* EntityRecording is a single track.
*/
EntityRecording = "recording",
};
/**
* ExpectedTrack is one track of the release the user asked for.
*/
export interface ExpectedTrack {
"position": number;
"discNumber": number;
"title": string;
"artist": string;
"lengthMillis": number;
}
/**
* Field describes one provider setting for the settings form.
*/
export interface Field {
"key": string;
"label": string;
"placeholder"?: string;
"help"?: string;
/**
* Secret marks a value stored in the secrets store rather than the
* provider config row, and rendered as a password input.
*/
"secret": boolean;
/**
* Path marks a value that names a local filesystem directory, so
* the settings form can offer a native folder picker beside the
* text input rather than making the user type or paste it.
*/
"path": boolean;
"required": boolean;
"default"?: string;
}
/**
* Format is a normalized audio container/codec name.
*/
export enum Format {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
/**
* Audio formats, ordered by the quality ranking in formatRank.
*/
FormatUnknown = "",
FormatFLAC = "flac",
FormatALAC = "alac",
FormatWAV = "wav",
FormatMP3 = "mp3",
FormatAAC = "aac",
FormatOGG = "ogg",
FormatOpus = "opus",
FormatWMA = "wma",
};
/**
* Kind identifies a provider implementation. It is stored in the
* database and used to look up the constructor in the registry, so
* values are stable strings and never renamed.
*/
export enum Kind {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
/**
* Provider kinds.
*/
KindSlskd = "slskd",
KindYtDlp = "yt-dlp",
KindLidarr = "lidarr",
KindProwlarr = "prowlarr",
KindQBittorrent = "qbittorrent",
KindSABnzbd = "sabnzbd",
/**
* KindFake is an in-memory provider used by tests. It is never
* offered in the UI.
*/
KindFake = "fake",
};
/**
* MatchScore answers "is this the release the user asked for?" It is
* deliberately separate from QualityScore: a perfect match at 128kbps
* and a mediocre match in FLAC are different failures, and collapsing
* them into one number makes the ranking impossible to explain.
*/
export interface MatchScore {
/**
* Overall is 0..1.
*/
"overall": number;
/**
* filenames vs expected titles
*/
"titleFit": number;
/**
* path/origin vs expected artist
*/
"artistFit": number;
/**
* folder name vs album title
*/
"albumFit": number;
/**
* audio files vs expected count
*/
"completeness": number;
/**
* Anchored records whether an MBID drove this score. Unanchored
* matches are capped, because there is nothing to be right about.
*/
"anchored": boolean;
}
/**
* Protocol is how a candidate's bytes are moved. Search-only providers
* report it so the pipeline can pick a compatible transport; providers
* that transport their own results use ProtocolDirect.
*/
export enum Protocol {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
/**
* Transport protocols.
*
* ProtocolDirect means the finding provider also does the fetch.
*/
ProtocolDirect = "direct",
ProtocolTorrent = "torrent",
ProtocolUsenet = "usenet",
};
/**
* QualityScore answers "is this a good copy?".
*/
export interface QualityScore {
/**
* Overall is 0..1.
*/
"overall": number;
/**
* FLAC > V0 > 320 > lower
*/
"formatRank": number;
"bitrate": number;
/**
* seeders, free slots
*/
"health": number;
/**
* user's per-provider preference
*/
"priority": number;
/**
* closeness to the preferred download size
*/
"sizeFit": number;
/**
* Mixed marks a candidate whose files are not all the same format,
* which usually means a hand-assembled folder rather than a rip.
*/
"mixed": boolean;
}
/**
* Reconciler works the request list.
*/
export interface Reconciler {
}
/**
* Request is one row of the durable request list.
*/
export interface Request {
"id": number;
"mbid": string;
"entity": Entity;
"libraryId": number;
/**
* Artist and Title are display cache only. Matching always uses
* the MBID.
*/
"artist": string;
"title": string;
"scope": RequestScope;
/**
* Secondary includes compilations, live albums and remixes in an
* artist request's expansion.
*/
"secondary": boolean;
"state": RequestState;
/**
* ParentID is set on requests the reconciler derived from an artist
* subscription. A request the user pinned directly has none, so
* removing the artist leaves it alone.
*/
"parentId"?: number;
"attempts": number;
"lastError"?: string;
"lastTriedAt"?: string;
"nextTryAt"?: string;
/**
* ExternalIDs maps provider row ID (as a string, because JSON
* object keys are strings) to that provider's own identifier for
* this request. Only set for providers that keep a persistent
* list of their own.
*/
"externalIds"?: { [_ in string]?: string } | null;
"createdAt": string;
"updatedAt": string;
}
/**
* RequestInput is what the frontend submits to request something. It
* is one MBID and the type of thing it names, because that is
* genuinely all a durable request is.
*/
export interface RequestInput {
"mbid": string;
"entity": string;
"libraryId": number;
/**
* Artist and Title are display text only, and optional: the
* reconciler fills them in from the catalog when the caller has
* nothing but an MBID.
*/
"artist": string;
"title": string;
/**
* Scope and Secondary apply to artist requests.
*/
"scope": string;
"secondary": boolean;
}
/**
* RequestScope applies to artist requests only.
*/
export enum RequestScope {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
/**
* Artist request scopes.
*
* ScopeFuture takes only releases first published after the artist
* was added. Default, because subscribing to an artist should not
* silently queue their entire back catalogue.
*/
ScopeFuture = "future",
/**
* ScopeAll backfills the whole discography as well.
*/
ScopeAll = "all",
};
/**
* RequestState is where a request sits. There is deliberately no
* "failed": an attempt can fail, a request cannot. A request that has
* tried and not found anything is still wanted, with attempts and
* last_error recording why it is taking a while.
*/
export enum RequestState {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
/**
* Request states.
*
* RequestStateWanted is the active state: due for another attempt
* when its backoff elapses.
*/
RequestStateWanted = "wanted",
/**
* RequestStateSatisfied means the library owns it. How it got
* there — downloaded here, ripped, bought elsewhere — does not
* matter.
*/
RequestStateSatisfied = "satisfied",
/**
* RequestStatePaused is the user saying "keep this on the list but
* stop trying".
*/
RequestStatePaused = "paused",
};
/**
* SearchRequest is what the frontend submits to start a download.
*/
export interface SearchRequest {
"libraryId": number;
"releaseMbid": string;
"releaseGroupMbid": string;
"artist": string;
"album": string;
"query": string;
"expected": ExpectedTrack[] | null;
}
/**
* StartResult is what the picker needs after a search.
*/
export interface StartResult {
"downloadId": string;
"candidates": Candidate[] | null;
/**
* AutoPicked reports that the pipeline already chose and is
* downloading, so the picker should show progress rather than a
* list of choices.
*/
"autoPicked": boolean;
}
/**
* State is the lifecycle position of a download item.
*/
export enum State {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
/**
* Download item states. Searching through Importing are live;
* Complete, Cancelled and Failed are terminal.
*/
StateSearching = "searching",
StateFound = "found",
StateQueued = "queued",
StateGrabbing = "grabbing",
StateVerifying = "verifying",
StateTagging = "tagging",
StateImporting = "importing",
StateComplete = "complete",
StateCancelled = "cancelled",
StateFailed = "failed",
};
/**
* Summary reports what a pass did, for logging and for the UI.
*/
export interface Summary {
/**
* Expanded is how many child requests artist subscriptions produced.
*/
"expanded": number;
/**
* Satisfied is how many requests the library turned out to own.
*/
"satisfied": number;
/**
* Attempted is how many requests were searched for.
*/
"attempted": number;
/**
* Started is how many of those found a clear enough winner to
* download unattended.
*/
"started": number;
/**
* Synced is how many requests were pushed to an external list.
*/
"synced": number;
/**
* Waiting is how many requests are on the list and still being
* looked for. A pass that did nothing is the normal case, and the
* UI can only say so honestly if it knows the list was not empty.
*/
"waiting": number;
/**
* NoProviders reports that nothing could be searched because no
* download client is enabled — the one "nothing happened" the user
* can actually fix.
*/
"noProviders": boolean;
}
@@ -0,0 +1,207 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* Service is the frontend-facing surface of the download subsystem.
* Its methods are bound into Wails and called from TypeScript, so
* signatures use plain types and return errors the UI can render.
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
/**
* AddProvider creates a provider and stores any secret settings
* separately. Secrets arrive in the same map as ordinary settings
* because that is what the form submits; they are split out here and
* never written to the provider row.
*/
export function AddProvider(kind: string, name: string, settings: { [_ in string]?: string } | null): $CancellablePromise<number> {
return $Call.ByID(2479360532, kind, name, settings);
}
/**
* AddRequest puts something on the request list and asks for a
* reconcile pass, so the user sees something happen rather than
* waiting six hours for the next scheduled one.
*/
export function AddRequest(req: $models.RequestInput): $CancellablePromise<number> {
return $Call.ByID(2621441882, req);
}
/**
* Cancel aborts a live download.
*/
export function Cancel(downloadID: string): $CancellablePromise<void> {
return $Call.ByID(1726338100, downloadID);
}
/**
* Candidates returns the ranked candidates of a live download, so the
* picker can be reopened without searching again.
*/
export function Candidates(downloadID: string): $CancellablePromise<$models.Candidate[] | null> {
return $Call.ByID(2436454548, downloadID);
}
/**
* ClearFinished removes terminal downloads from the list.
*/
export function ClearFinished(): $CancellablePromise<void> {
return $Call.ByID(3757713449);
}
/**
* ClearSatisfiedRequests drops everything already owned.
*/
export function ClearSatisfiedRequests(): $CancellablePromise<void> {
return $Call.ByID(2681765363);
}
/**
* DeleteProvider removes a provider and its credentials.
*/
export function DeleteProvider(id: number): $CancellablePromise<void> {
return $Call.ByID(495153424, id);
}
/**
* ImportExternalRequests adopts a provider's own list — "import the
* artists Lidarr is already monitoring".
*/
export function ImportExternalRequests(providerID: number, libraryID: number): $CancellablePromise<number> {
return $Call.ByID(4055326022, providerID, libraryID);
}
/**
* IsRequested answers the Explore pages' question — should this album
* show "want" or "wanted?" — without making them load the whole list.
*/
export function IsRequested(mbid: string, libraryID: number): $CancellablePromise<boolean> {
return $Call.ByID(3695628158, mbid, libraryID);
}
/**
* ListDownloads returns recent downloads, newest first.
*/
export function ListDownloads(limit: number): $CancellablePromise<$models.DownloadView[] | null> {
return $Call.ByID(2505337995, limit);
}
/**
* ListProviders returns the user's configured download clients.
*/
export function ListProviders(): $CancellablePromise<$models.Config[] | null> {
return $Call.ByID(913508028);
}
/**
* ListRequests returns the whole durable request list.
*/
export function ListRequests(): $CancellablePromise<$models.Request[] | null> {
return $Call.ByID(3593452892);
}
/**
* PauseRequest stops attempts without forgetting the request.
*/
export function PauseRequest(id: number, paused: boolean): $CancellablePromise<void> {
return $Call.ByID(2652842089, id, paused);
}
/**
* Pick starts the transfer for the candidate the user chose.
*/
export function Pick(downloadID: string, candidateID: string): $CancellablePromise<void> {
return $Call.ByID(40308223, downloadID, candidateID);
}
/**
* ProviderKinds returns every provider type that can be added, with the
* settings each one needs. The settings page renders its forms from
* this, so a new adapter needs no frontend change.
*/
export function ProviderKinds(): $CancellablePromise<$models.Descriptor[] | null> {
return $Call.ByID(2902376358);
}
/**
* ReconcileRequests runs a pass now and reports what it did. This
* backs the "check now" button, so it runs synchronously: the user
* pressed it and is waiting for an answer.
*/
export function ReconcileRequests(): $CancellablePromise<$models.Summary> {
return $Call.ByID(593439868);
}
/**
* RemoveRequest takes something off the list. Removing an artist takes
* its derived albums with it, by cascade; an album the user pinned
* themselves has no parent and survives.
*/
export function RemoveRequest(id: number): $CancellablePromise<void> {
return $Call.ByID(2423963479, id);
}
/**
* SetPreferences pushes the auto-download guardrails straight into the
* running Manager, without persisting them. Persistence is
* config.Config's job (GetDownloadPreferences/SetDownloadPreferences);
* this package cannot depend on config, since config already depends on
* download for UserConfig. The frontend settings save is expected to
* call the config setter and this method in the same action, the way
* UpdateProvider already achieves "live without a restart" by touching
* storage and the running Manager together.
*/
export function SetPreferences(prefs: $models.AutoDownloadPrefs): $CancellablePromise<void> {
return $Call.ByID(3789717356, prefs);
}
/**
* SetReconciler wires the request-list loop. Optional: without it the
* request list still stores and lists requests, it just never acts on
* them.
*/
export function SetReconciler(r: $models.Reconciler | null): $CancellablePromise<void> {
return $Call.ByID(2390832784, r);
}
/**
* StartDownload searches for a release and either auto-picks a clear
* winner or returns ranked candidates for the user to choose from.
*
* When the search carries a MusicBrainz anchor, it also resolves or
* creates the durable Request the anchor names and attaches it, via
* ensureRequest — so a manual download that fails or finds nothing
* right now leaves a durable record behind instead of just vanishing,
* and the reconciler picks it up on its normal schedule exactly as if
* the user had explicitly added it to the request list.
*/
export function StartDownload(req: $models.SearchRequest): $CancellablePromise<$models.StartResult> {
return $Call.ByID(1191306198, req);
}
/**
* TestProvider backs the "test connection" button. It builds the
* provider from its stored config and asks it to check itself, so the
* result reflects exactly what a real search would use.
*/
export function TestProvider(id: number): $CancellablePromise<void> {
return $Call.ByID(2688601475, id);
}
/**
* UpdateProvider saves changes to a provider. A secret field left
* blank keeps its stored value rather than clearing it — the form does
* not echo secrets back, so an empty box means "unchanged", not
* "delete".
*/
export function UpdateProvider(id: number, name: string, enabled: boolean, priority: number, settings: { [_ in string]?: string } | null): $CancellablePromise<void> {
return $Call.ByID(42359606, id, name, enabled, priority, settings);
}
@@ -0,0 +1,35 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import * as Service from "./service.js";
export {
Service
};
export {
ShelfKind
} from "./models.js";
export type {
AlbumCompleteFunc,
IndexStatus,
LBSimilarArtist,
LBTopRecording,
LBTopReleaseGroup,
LyricsResult,
MBArtist,
MBRecording,
MBRelease,
MBReleaseGroup,
MBSearchResult,
MBTrack,
MusicBrainzClient,
RateLimiter,
Shelf,
ShelfPage,
ThumbnailRequest,
TierStatus,
TopResult,
TrackLyrics,
TrackThumbnailRequest
} from "./models.js";
@@ -0,0 +1,469 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* AlbumCompleteFunc answers "is the local album with this id complete",
* i.e. do its files declare a track total the library actually has.
*/
export type AlbumCompleteFunc = any;
/**
* IndexStatus is the full index build status, exposed to the frontend.
*/
export interface IndexStatus {
"building": boolean;
"ready": boolean;
/**
* RFC3339 timestamp of last complete build
*/
"lastBuilt"?: string;
"tiers": TierStatus[] | null;
"artists": number;
"recordings": number;
"releaseGroups": number;
"totalRows": number;
}
/**
* LBSimilarArtist represents a similar artist from the
* ListenBrainz labs API.
*/
export interface LBSimilarArtist {
"artistMbid": string;
"name": string;
"score": number;
}
/**
* LBTopRecording represents a popular recording from the
* ListenBrainz popularity API.
*
* JSON tags use camelCase for Wails→frontend serialization.
* The API response uses snake_case, so we unmarshal into
* lbTopRecordingWire first, then convert.
*/
export interface LBTopRecording {
"recordingMbid": string;
"artistName": string;
"trackName": string;
"totalListenCount": number;
"caaReleaseMbid": string;
/**
* ReleaseGroupMBID is resolved from CAAReleaseMBID so a top-track
* row can link to its album page with the track highlighted.
*/
"releaseGroupMbid"?: string;
"releaseName": string;
/**
* milliseconds (from LB API)
*/
"length": number;
"inLibrary": boolean;
"localId"?: number;
}
/**
* LBTopReleaseGroup represents a popular release group from the
* ListenBrainz popularity API.
*/
export interface LBTopReleaseGroup {
"releaseGroupMbid": string;
"title": string;
"artistName": string;
"type": string;
"date": string;
"totalListenCount": number;
"caaReleaseMbid": string;
"inLibrary": boolean;
"localId"?: number;
}
/**
* LyricsResult is a single lyric-search hit, mapped from the DB layer
* into the camelCase shape the frontend consumes.
*/
export interface LyricsResult {
"recordingId": number;
"filePath": string;
"lengthMs": number;
"title": string;
"artist": string;
"album": string;
}
/**
* MBArtist is a Wails-friendly projection of a MusicBrainz artist.
*/
export interface MBArtist {
"mbid": string;
"name": string;
"sortName": string;
"englishName"?: string;
"type": string;
"country": string;
"disambiguation": string;
"score": number;
/**
* raw LB listen count (0 if unknown)
*/
"popularity": number;
"listenerCount": number;
/**
* true if the user owns music by this artist
*/
"inLibrary": boolean;
/**
* local artist row ID for navigation
*/
"localId"?: number;
}
/**
* MBRecording is a Wails-friendly projection of a MusicBrainz
* recording.
*/
export interface MBRecording {
"mbid": string;
"title": string;
"length": number;
"artistCredit": string;
/**
* for linking the artist to its detail page
*/
"artistMbid"?: string;
"score": number;
/**
* raw LB listen count (0 if unknown)
*/
"popularity": number;
"listenerCount": number;
/**
* parent release, for album navigation
*/
"caaReleaseMbid"?: string;
/**
* ReleaseGroupMBID is resolved from CAAReleaseMBID so a track can
* link to its album page with the track highlighted, matching how
* tracks behave everywhere else.
*/
"releaseGroupMbid"?: string;
/**
* album title
*/
"releaseName"?: string;
/**
* true if the user owns this recording
*/
"inLibrary": boolean;
/**
* local recording row ID
*/
"localId"?: number;
}
/**
* MBRelease is a Wails-friendly projection of a MusicBrainz release.
*/
export interface MBRelease {
"mbid": string;
"title": string;
"date": string;
"country": string;
"status": string;
"artistCredit"?: string;
"tracks"?: MBTrack[] | null;
/**
* 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;
}
/**
* MBReleaseGroup is a Wails-friendly projection of a MusicBrainz
* release group.
*/
export interface MBReleaseGroup {
"mbid": string;
"title": string;
"primaryType": string;
"secondaryTypes"?: string[] | null;
"firstReleaseDate": string;
"artistCredit": string;
/**
* for linking the artist to its detail page
*/
"artistMbid"?: string;
/**
* raw LB listen count (0 if unknown)
*/
"popularity": number;
"listenerCount": number;
/**
* true if the user owns this album
*/
"inLibrary": boolean;
/**
* local release_group row ID
*/
"localId"?: number;
}
/**
* MBSearchResult aggregates the three searchable entity types
* returned by the MusicBrainz search API.
*/
export interface MBSearchResult {
"artists"?: MBArtist[] | null;
"releaseGroups"?: MBReleaseGroup[] | null;
"recordings"?: MBRecording[] | null;
"topResults"?: TopResult[] | null;
}
/**
* MBTrack is a Wails-friendly projection of a MusicBrainz track.
*/
export interface MBTrack {
"position": number;
"discNumber": number;
"title": string;
"length": number;
"mbid": string;
"inLibrary": boolean;
"localId"?: number;
}
/**
* MusicBrainzClient wraps the musicbrainzws2 library with a local
* response cache. Every API call checks the cache first and stores
* successful responses for future hits.
*
* A proactive rate limiter gates all outgoing requests at 1 req/sec
* to avoid triggering MusicBrainz 429 responses. The underlying
* musicbrainzws2.Client still retries on 429 as a safety net, but
* the limiter should prevent most rate-limit hits.
*/
export interface MusicBrainzClient {
}
/**
* RateLimiter enforces a maximum request rate using a token bucket.
* MusicBrainz requires ≤1 request per second and rejects ALL
* requests (not just excess) when the rate is exceeded, so callers
* block proactively via Wait rather than retrying reactively.
*
* A limiter may carry a second, slower **background lane** (see
* WithBackgroundLane). A caller marked by WithBackgroundPriority is
* paced by that lane *and* yields to interactive callers: while any
* interactive Wait is outstanding, background waits do not take a
* token at all. This is what keeps a multi-thousand-request backfill
* from putting the album page the user is looking at right now behind
* hours of queued work.
*
* RateLimiter is safe for concurrent use.
*/
export interface RateLimiter {
}
/**
* Shelf is one horizontal row on the Explore page.
*
* A shelf carries albums or artists, never both: they route to
* different pages and render as different cards, and a row that is
* sometimes one and sometimes the other is two components pretending to
* be one.
*/
export interface Shelf {
"id": string;
"kind": ShelfKind;
/**
* Title is the row heading.
*/
"title": string;
/**
* Subtitle says why these are here. As on Home it is not
* decoration: without it a shelf is indistinguishable from a
* random grid.
*/
"subtitle": string;
"albums"?: MBReleaseGroup[] | null;
"artists"?: MBArtist[] | null;
}
/**
* ShelfKind identifies what a shelf is built from, so the frontend can
* pick an icon and a spec can assert on a shelf without matching
* display copy.
*/
export enum ShelfKind {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
/**
* Shelf kinds.
*/
ShelfPopularAlbums = "popular-albums",
ShelfPopularArtists = "popular-artists",
ShelfMoreFromOwned = "more-from-owned",
};
/**
* ShelfPage is what Explore renders before a query.
*
* State exists because "no shelves" has three different causes here and
* the page must not present them identically — a blank panel is the bug
* this whole feature is fixing, and a blank panel that says nothing
* about why is the same bug with more code behind it.
*/
export interface ShelfPage {
"shelves": Shelf[] | null;
/**
* State is one of:
*
* "ready" — the catalog is here and the shelves are below.
* "building" — it is being fetched or built right now.
* "no-index" — there is no catalog. Search still works over
* whatever is in the index, which may be nothing;
* the page says so and points at Settings.
*/
"state": string;
}
/**
* ThumbnailRequest is a single item in a batch thumbnail request.
*/
export interface ThumbnailRequest {
"mbid": string;
"albumName": string;
"artistName": string;
}
/**
* TierStatus represents the state of a single index tier.
*/
export interface TierStatus {
"name": string;
/**
* "pending", "running", "complete", "error", "skipped"
*/
"state": string;
"total": number;
"completed": number;
"error"?: string;
/**
* Detail is a human-readable progress line for stages whose raw
* completed/total numbers say little on their own — the listens
* stream reports "42.3 / 205.1 GB · 18 MB/s · ~3h20m left" here.
*/
"detail"?: string;
}
/**
* TopResult represents a single top-result card shown above the
* categorized search lists. Computed by intent scoring after all
* reranking is complete.
*/
export interface TopResult {
/**
* "artist", "release_group", "recording"
*/
"entityType": string;
"mbid": string;
"name": string;
/**
* for tracks/albums
*/
"artistCredit"?: string;
/**
* for linking the artist subtitle
*/
"artistMbid"?: string;
"intentScore": number;
/**
* Artist-specific
* "Group", "Person"
*/
"artistType"?: string;
"country"?: string;
/**
* Album-specific
*/
"primaryType"?: string;
"year"?: string;
/**
* Track-specific. ReleaseGroupMBID is resolved (from CAAReleaseMBID)
* so a track click can open its album page with the track highlighted,
* matching how tracks behave everywhere else. ReleaseName is the album
* title used for the album page header.
*/
"length"?: number;
"caaReleaseMbid"?: string;
"releaseGroupMbid"?: string;
"releaseName"?: string;
/**
* Library status — populated from index cross-reference columns.
*/
"inLibrary": boolean;
}
/**
* TrackLyrics is the stored or freshly-fetched lyrics for one track.
* Source is "embedded" (from the file's tags / library DB), "lrclib"
* (fetched on demand), or "" when none are available.
*/
export interface TrackLyrics {
"plain": string;
"synced": string;
"instrumental": boolean;
"source": string;
}
/**
* TrackThumbnailRequest is a single item in a batch track thumbnail
* request. Either ReleaseMBID or ReleaseGroupMBID may be empty;
* the proxy tries whichever is present.
*/
export interface TrackThumbnailRequest {
/**
* stable key used in the returned map
*/
"key": string;
"releaseMbid": string;
"releaseGroupMbid": string;
"albumName": string;
"artistName": string;
}
@@ -0,0 +1,604 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* Service is the Wails-bound service for the explore feature.
* It owns the lifecycle of all explore-related components: the
* MusicBrainz client, ListenBrainz client, rate limiter, and
* response cache. Its exported methods form the binding surface
* that the frontend calls via generated TypeScript stubs.
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as time$0 from "../../../time/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as jobs$0 from "../jobs/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
/**
* AdoptPausedIndexBuild re-registers a build paused in a previous
* session so it appears in the jobs panel, still paused.
*/
export function AdoptPausedIndexBuild(): $CancellablePromise<void> {
return $Call.ByID(3754928399);
}
/**
* BackfillLibraryDiscographies enriches owned artists that have not had
* their discography fetched yet, in the background. Idempotent and
* bounded — the query only returns unenriched artists and each is marked
* discog_fetched on success, so this is cheap (an empty query) once every
* owned artist is covered and safe to call on every scan and launch.
*/
export function BackfillLibraryDiscographies(): $CancellablePromise<void> {
return $Call.ByID(3678857155);
}
/**
* BackfillLibraryLyrics fetches lyrics from LRCLIB for library tracks
* that don't have them, in the background. Idempotent and bounded —
* each recording is tried once (a miss is cached), and a run stops
* after a fixed number of passes, resuming on the next launch.
*/
export function BackfillLibraryLyrics(): $CancellablePromise<void> {
return $Call.ByID(4204427482);
}
/**
* 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.
*/
export function BackfillReleaseGroupMBIDs(): $CancellablePromise<void> {
return $Call.ByID(2594807082);
}
/**
* BrowseReleaseGroups fetches release groups for a given artist MBID.
* Checks the local index first for instant results, then fetches from
* MusicBrainz for complete data (secondary types, precise dates).
* Also adds results to the search index (Tier 5: organic growth).
*/
export function BrowseReleaseGroups(artistMBID: string): $CancellablePromise<$models.MBReleaseGroup[] | null> {
return $Call.ByID(404562912, artistMBID);
}
/**
* BrowseReleases fetches releases for a given release group MBID.
*
* Local-first, non-blocking: a warm response cache is served instantly;
* on a miss the request does NOT block on a live MusicBrainz browse
* (which pulls every version's full tracklist and can take seconds).
* Instead it kicks off a background fetch and returns empty — the
* AlbumReleasesReady event signals the caller to re-fetch once the cache
* is warm.
*/
export function BrowseReleases(releaseGroupMBID: string): $CancellablePromise<$models.MBRelease[] | null> {
return $Call.ByID(2551207897, releaseGroupMBID);
}
/**
* CAALimiter returns the shared Cover Art Archive rate limiter.
* Consumers must respect it for any fresh CAA HTTP GETs.
*/
export function CAALimiter(): $CancellablePromise<$models.RateLimiter | null> {
return $Call.ByID(1239092428);
}
/**
* CheckLibraryMBIDs returns which of the given MBIDs exist in the
* local music library. Returns a map of MBID → entity type
* ("artist", "release_group", "recording").
*
* It has no frontend caller — `downloadcatalog.go` is the one consumer,
* asking about a single MBID at a time.
*/
export function CheckLibraryMBIDs(mbids: string[] | null): $CancellablePromise<{ [_ in string]?: string } | null> {
return $Call.ByID(3168338597, mbids);
}
/**
* CoreCatalogImported reports whether a prebuilt catalog artifact has
* been merged into this index.
*/
export function CoreCatalogImported(): $CancellablePromise<boolean> {
return $Call.ByID(186364153);
}
/**
* CoverArtGroupURL returns the Cover Art Archive URL for a release
* group's front cover at the default 250px size. This is the
* correct endpoint for search results, which return release group
* MBIDs rather than individual release MBIDs.
*/
export function CoverArtGroupURL(releaseGroupMBID: string): $CancellablePromise<string> {
return $Call.ByID(2102228641, releaseGroupMBID);
}
/**
* CoverArtURL returns the Cover Art Archive URL for a release's
* front cover at the default 250px size.
*/
export function CoverArtURL(releaseMBID: string): $CancellablePromise<string> {
return $Call.ByID(3231496554, releaseMBID);
}
/**
* 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.
*/
export function GenerateMix(seedPaths: string[] | null, continuing: boolean): $CancellablePromise<[string[] | null, string]> {
return $Call.ByID(4293623744, seedPaths, continuing);
}
/**
* GetArtistImageCached returns a base64 data URL for the artist's
* photo ONLY if it's already on disk — no MB/Wikidata resolution
* or Wikimedia fetch. Returns "" if not cached.
*/
export function GetArtistImageCached(artistMBID: string): $CancellablePromise<string> {
return $Call.ByID(670428351, artistMBID);
}
/**
* GetArtistImageCachedPath returns the asset-handler URL path for
* the artist's cached medium thumbnail, e.g.
* "/artist-images/b1/b10bbbfc-.../primary_md.jpg". No base64, no
* network calls — just a disk existence check. Returns "" if no
* image is cached.
*/
export function GetArtistImageCachedPath(artistMBID: string): $CancellablePromise<string> {
return $Call.ByID(589683480, artistMBID);
}
/**
* GetArtistImageURL returns a base64 data URL for the artist's
* photo. Cached on disk — first call resolves via MB/Wikidata and
* fetches from Wikimedia Commons, subsequent calls are instant.
* Returns "" if no image is available.
*/
export function GetArtistImageURL(artistMBID: string): $CancellablePromise<string> {
return $Call.ByID(1780431074, artistMBID);
}
/**
* GetArtistImagesCachedPaths resolves artist portraits for many MBIDs
* in one call, returning MBID → asset-handler path for the medium
* thumbnail. Disk existence checks only: no MusicBrainz, no Wikidata,
* no Wikimedia, no network of any kind. MBIDs with no cached portrait
* are omitted rather than returned empty.
*
* It exists because the resolving entry point (GetArtistImageURL) was
* being used where a cache check belonged: a page rendering a dozen
* search results paid a full MB → Wikidata → Wikipedia → Wikimedia
* resolution for every artist whose portrait was already on disk. Its
* predecessor could not serve that — it keyed on artist *name* through
* the library's own MBID map, so it only ever answered for artists the
* user already owned, which on a catalog search is nearly none of them.
*
* Paths rather than base64: a portrait is ~128 kB as a data URL, and
* the asset handler serves the same bytes without crossing the IPC
* boundary or being retained by a JS string.
*/
export function GetArtistImagesCachedPaths(mbids: string[] | null): $CancellablePromise<{ [_ in string]?: string } | null> {
return $Call.ByID(2104788604, mbids);
}
/**
* GetArtistMBID returns the MusicBrainz ID for a local library
* artist by name, or "" if not found or no MBID tagged.
*/
export function GetArtistMBID(artistName: string): $CancellablePromise<string> {
return $Call.ByID(944736774, artistName);
}
/**
* GetArtistPlayCount returns the total LB listen count for an artist.
* Returns 0 if unknown.
*/
export function GetArtistPlayCount(artistMBID: string): $CancellablePromise<number> {
return $Call.ByID(143535277, artistMBID);
}
/**
* GetCandidateThumbnail returns CAA-only cover art for an autotag
* candidate, skipping the library-by-name index so embedded ID3
* art on the user's existing files doesn't pollute the candidate
* preview. Disk cache → network on RG → network on release.
*/
export function GetCandidateThumbnail(releaseMBID: string, releaseGroupMBID: string): $CancellablePromise<string> {
return $Call.ByID(1946932424, releaseMBID, releaseGroupMBID);
}
/**
* GetExploreShelves builds the page Explore shows before a query.
*
* One call rather than one per shelf, for `home`'s reason: the shelves
* share nothing expensive, but the page has nothing useful to render
* until it knows which rows exist, and rows that pop in one at a time
* reflow under the cursor.
*/
export function GetExploreShelves(): $CancellablePromise<$models.ShelfPage> {
return $Call.ByID(144329614);
}
/**
* GetIndexStatus returns the current search index build status.
*/
export function GetIndexStatus(): $CancellablePromise<$models.IndexStatus> {
return $Call.ByID(3481063523);
}
/**
* GetLibrarySimilarArtists returns similar artists to the given
* MBID that are also in the user's local library. Uses the
* pre-computed similar_artist_map table (populated during Tier 4
* index build) joined with the artists table. No API calls.
*
* The artists table allows multiple rows with the same MBID
* (different artist credits like "A feat. B" that resolve to the
* same MB artist), so we use EXISTS instead of JOIN to avoid
* duplicating similar_artist_map rows.
*/
export function GetLibrarySimilarArtists(artistMBID: string): $CancellablePromise<$models.LBSimilarArtist[] | null> {
return $Call.ByID(104313157, artistMBID);
}
/**
* GetThumbnail returns a base64 data URL for the release group's
* cover art. Checks local library art first (by album+artist
* name), then disk cache, then Cover Art Archive.
* Returns "" if no cover art is available.
*/
export function GetThumbnail(releaseGroupMBID: string, albumName: string, artistName: string): $CancellablePromise<string> {
return $Call.ByID(4046192225, releaseGroupMBID, albumName, artistName);
}
/**
* GetThumbnails fetches multiple thumbnails in one call and returns
* a map of MBID → base64 data URL. Entries with no art are omitted.
* GetThumbnails returns ONLY cached/local art instantly — no network
* fetches. For items missing from the cache, the frontend should
* call GetThumbnail() individually so results stream in rather than
* blocking on a batch.
*/
export function GetThumbnails(requests: $models.ThumbnailRequest[] | null): $CancellablePromise<{ [_ in string]?: string } | null> {
return $Call.ByID(3124819542, requests);
}
/**
* GetTrackLyrics returns lyrics for a recording. If the library
* already has them (from embedded tags) they're returned as-is;
* otherwise it fetches from LRCLIB, persists them (updating the FTS
* index), and returns them. Never returns an error to the frontend —
* a miss just yields an empty result.
*/
export function GetTrackLyrics(recordingID: number): $CancellablePromise<$models.TrackLyrics> {
return $Call.ByID(1131284622, recordingID);
}
/**
* GetTrackThumbnail returns cover art for a track. Accepts both
* the track's CAA release MBID and the resolved parent release
* group MBID (either may be empty). Tries the RG first to reuse
* discography cache; falls back to the release-level CAA endpoint
* when the RG isn't known — useful when the track's preferred CAA
* release doesn't belong to any RG currently in the index.
*/
export function GetTrackThumbnail(releaseMBID: string, releaseGroupMBID: string, albumName: string, artistName: string): $CancellablePromise<string> {
return $Call.ByID(4191599666, releaseMBID, releaseGroupMBID, albumName, artistName);
}
/**
* GetTrackThumbnails returns ONLY cached/local art for track
* requests, keyed by the caller-provided Key so callers can map
* results back to rows in their UI.
*/
export function GetTrackThumbnails(requests: $models.TrackThumbnailRequest[] | null): $CancellablePromise<{ [_ in string]?: string } | null> {
return $Call.ByID(2383043155, requests);
}
/**
* IndexBaselineSeries returns the incremental listens series the index's
* popularity is caught up to. A change across a refresh means new data
* was folded in.
*/
export function IndexBaselineSeries(): $CancellablePromise<number> {
return $Call.ByID(3317566669);
}
/**
* IndexImportComplete reports whether the dump import has finished all
* of its stages. Distinct from IsIndexReady, which only means the index
* holds enough rows to answer queries — a partially imported index is
* ready but not complete. Used by the headless builder to decide
* whether another run is needed.
*/
export function IndexImportComplete(): $CancellablePromise<boolean> {
return $Call.ByID(3872948181);
}
/**
* IndexLastImported returns when the dump import last completed, or the
* zero time if it never has.
*/
export function IndexLastImported(): $CancellablePromise<string> {
return $Call.ByID(1889359471);
}
/**
* InvalidateIndexDiscographies clears the discography build
* timestamp so the next index build re-runs Tiers 2-4. Call
* after a library rescan that may have populated new MBIDs.
*/
export function InvalidateIndexDiscographies(): $CancellablePromise<void> {
return $Call.ByID(2573191521);
}
/**
* 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
* library), which would otherwise leave stale in_library flags and
* orphaned lyric-index rows.
*/
export function InvalidateLibrarySync(): $CancellablePromise<void> {
return $Call.ByID(2446309672);
}
/**
* IsIndexReady returns true once the index has been populated.
*/
export function IsIndexReady(): $CancellablePromise<boolean> {
return $Call.ByID(1366461294);
}
/**
* LookupArtist fetches a single MusicBrainz artist by MBID.
* Checks the local index first — has name, type, country,
* disambiguation, sort_name for indexed artists. Falls back to
* MB API for unknown artists and backfills the index for next time.
*/
export function LookupArtist(mbid: string): $CancellablePromise<$models.MBArtist | null> {
return $Call.ByID(3986196952, mbid);
}
/**
* LookupReleaseGroup fetches a single MusicBrainz release group by MBID.
*/
export function LookupReleaseGroup(mbid: string): $CancellablePromise<$models.MBReleaseGroup | null> {
return $Call.ByID(2946174711, mbid);
}
/**
* MusicBrainz returns the shared cached MB client so other services
* (e.g. autotag) can reuse it without spinning up a second limiter.
*/
export function MusicBrainz(): $CancellablePromise<$models.MusicBrainzClient | null> {
return $Call.ByID(3453528034);
}
/**
* PopulateLocalCrossReferences updates the local_*_id columns on
* explore_index after a library scan.
*/
export function PopulateLocalCrossReferences(): $CancellablePromise<void> {
return $Call.ByID(1058376024);
}
/**
* PopulateLocalCrossReferencesIfNeeded runs the library→index sync only
* when it has not run since the last library change. Use it on the
* unchanged-library launch path so the write-heavy re-sync is skipped in
* steady state; the scan-completion path calls the unconditional form.
*/
export function PopulateLocalCrossReferencesIfNeeded(): $CancellablePromise<void> {
return $Call.ByID(814782146);
}
/**
* PrefetchReleases warms the local response cache for a set of release
* groups in the background so opening any of them is instant. Called from
* the artist page once its top-releases / discography render — album
* navigation almost always originates there. Already-cached groups are
* skipped; a cap bounds how many live fetches a single artist view can
* trigger so the MusicBrainz rate limiter isn't flooded.
* A release group the user owns *completely* is skipped outright: since
* tag-derived completeness landed, such an album opens with no catalog
* call at all — identity from its MBID, tracklist from its own files —
* so warming the most expensive request in the app on its behalf buys
* nothing. The skip is not merely an optimisation; those slots go to
* albums that will actually need the browse.
*/
export function PrefetchReleases(releaseGroupMBIDs: string[] | null): $CancellablePromise<void> {
return $Call.ByID(306872972, releaseGroupMBIDs);
}
/**
* PrepareIndexRebuild clears the completion marker so the next build
* re-imports from the newest published dump.
*/
export function PrepareIndexRebuild(): $CancellablePromise<void> {
return $Call.ByID(2772457219);
}
/**
* RebuildLyricsIndex rebuilds the FTS lyrics index from the current
* library. Cheap; safe to call after every scan.
*/
export function RebuildLyricsIndex(): $CancellablePromise<void> {
return $Call.ByID(300335776);
}
/**
* RebuildLyricsIndexIfNeeded rebuilds the lyrics FTS only when it has not
* been built since the last library change. The backfill keeps the index
* in sync incrementally thereafter, so on an unchanged library the full
* rebuild is redundant; the scan-completion path calls the unconditional
* form.
*/
export function RebuildLyricsIndexIfNeeded(): $CancellablePromise<void> {
return $Call.ByID(1447291626);
}
/**
* RecordSearchClick records that the user clicked a search result.
* Called from the frontend when any search result is clicked.
*/
export function RecordSearchClick(query: string, mbid: string, entityType: string): $CancellablePromise<void> {
return $Call.ByID(824485756, query, mbid, entityType);
}
/**
* RefreshIndexNow folds newly published incremental listens dumps into
* the index synchronously. Pass 0 to bypass the cadence gate.
*/
export function RefreshIndexNow(minInterval: time$0.Duration): $CancellablePromise<void> {
return $Call.ByID(4165220556, minInterval);
}
/**
* RefreshListenCounts folds any newly-published incremental listen dumps
* into the index's popularity numbers, in the background. No-op when
* offline, when a full build is running, when there is no baseline
* import, or when the last refresh was within the weekly cadence. Fully
* local — downloads the small daily dumps but makes no ListenBrainz API
* calls.
*/
export function RefreshListenCounts(): $CancellablePromise<void> {
return $Call.ByID(3813092323);
}
/**
* ResolveReleaseGroupMBIDs takes a list of CAA release MBIDs (from
* recording metadata) and returns a map of release MBID → release
* group MBID. The frontend uses this to fetch track cover art via
* the parent release group, reusing whatever cache exists for the
* album already.
*/
export function ResolveReleaseGroupMBIDs(caaReleaseMBIDs: string[] | null): $CancellablePromise<{ [_ in string]?: string } | null> {
return $Call.ByID(325093206, caaReleaseMBIDs);
}
/**
* SearchLocal queries only the local FTS5 index and returns fully
* ranked results instantly with no network calls. This is the
* primary interactive search path: now that the index is populated
* from the MetaBrainz dumps it covers essentially every popular
* entity, so the frontend drives search entirely from here. The
* old MusicBrainz network pipeline (Search) is retained for a future
* opt-in "search online" affordance but is no longer called on the
* hot path.
*
* Returns nil if the index has no hits for the query, so the caller
* can fall back to whatever owned-library matches it already has.
*/
export function SearchLocal(query: string): $CancellablePromise<$models.MBSearchResult | null> {
return $Call.ByID(189423736, query);
}
/**
* SearchLyrics finds library tracks whose lyrics contain the given
* fragment, ranked by relevance. Pure local FTS — no network.
*/
export function SearchLyrics(query: string): $CancellablePromise<$models.LyricsResult[] | null> {
return $Call.ByID(4153164053, query);
}
/**
* SetAlbumComplete injects the completeness check. It is injected
* rather than imported because `library` and `explore` do not depend on
* each other in either direction today, and one prefetch heuristic is
* not a reason to introduce that edge — the alternative, re-deriving
* "complete" from SQL here, would be a second definition of it.
*/
export function SetAlbumComplete(fn: $models.AlbumCompleteFunc): $CancellablePromise<void> {
return $Call.ByID(942474493, fn);
}
/**
* SetJobRegistry wires the background job registry into the search
* index so its build reports progress and controls to the frontend.
*/
export function SetJobRegistry(reg: jobs$0.Registry | null): $CancellablePromise<void> {
return $Call.ByID(4291900709, reg);
}
/**
* SimilarArtists returns artists similar to the given artist MBID.
*/
export function SimilarArtists(artistMBID: string): $CancellablePromise<$models.LBSimilarArtist[] | null> {
return $Call.ByID(2048211426, artistMBID);
}
/**
* StartIndexBuild kicks off the background search index build.
* Call this after the library scan completes so the indexer doesn't
* starve the scan for DB access.
*/
export function StartIndexBuild(): $CancellablePromise<void> {
return $Call.ByID(1444396415);
}
/**
* StopIndexBuild cancels the background search index build.
* Call before a full rescan to free the DB for the scan.
*/
export function StopIndexBuild(): $CancellablePromise<void> {
return $Call.ByID(3056245285);
}
/**
* TopRecordingsForArtist returns the most-listened recordings for an
* artist. Serves instantly from the local index when available; when the
* artist isn't indexed yet it returns empty immediately and fetches the
* discography in the background, emitting ArtistDiscographyReady so the
* caller can re-fetch — the request never blocks on a live fetch.
*/
export function TopRecordingsForArtist(artistMBID: string): $CancellablePromise<$models.LBTopRecording[] | null> {
return $Call.ByID(2077579960, artistMBID);
}
/**
* TopReleaseGroupsForArtist returns the most-listened release groups for
* an artist. Same non-blocking contract as TopRecordingsForArtist: index
* hit is instant, a miss kicks off a background discography fetch and
* returns empty, and ArtistDiscographyReady signals when to re-fetch.
*/
export function TopReleaseGroupsForArtist(artistMBID: string): $CancellablePromise<$models.LBTopReleaseGroup[] | null> {
return $Call.ByID(602197643, artistMBID);
}
/**
* WaitForIndexIdle blocks until no index build or artist indexing
* goroutine is running. Does not cancel a running build.
*/
export function WaitForIndexIdle(): $CancellablePromise<void> {
return $Call.ByID(3200929511);
}
@@ -0,0 +1,47 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* FrontendUtil provides frontend-bound Go functions.
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
/**
* DirectoryPicker opens a directory selection dialog.
*
* v3 has no separate directory dialog: it is the file dialog told to
* choose directories and not files.
*/
export function DirectoryPicker(): $CancellablePromise<string> {
return $Call.ByID(3245034282);
}
/**
* ImageFilePicker opens a file selection dialog filtered to image
* files (JPEG, PNG). Returns the selected file path, or empty
* string if the user cancelled.
*/
export function ImageFilePicker(): $CancellablePromise<string> {
return $Call.ByID(3408786006);
}
/**
* PlaylistFilePicker opens a file selection dialog filtered
* to M3U/M3U8 playlist files. Multiple files may be selected.
*/
export function PlaylistFilePicker(): $CancellablePromise<string[] | null> {
return $Call.ByID(2858498591);
}
/**
* ReadFile reads a file from disk and returns its contents.
* Used by the frontend to read cover art image files selected
* via ImageFilePicker.
*/
export function ReadFile(path: string): $CancellablePromise<string | null> {
return $Call.ByID(3350710867, path);
}
@@ -0,0 +1,7 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import * as FrontendUtil from "./frontendutil.js";
export {
FrontendUtil
};
@@ -0,0 +1,15 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import * as Service from "./service.js";
export {
Service
};
export {
Kind
} from "./models.js";
export type {
Shelf
} from "./models.js";
@@ -0,0 +1,53 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as library$0 from "../library/models.js";
/**
* Kind identifies what a shelf is built from, so the frontend can pick
* an icon and the e2e suite can assert on a shelf without matching
* display copy.
*/
export enum Kind {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
/**
* Shelf kinds.
*/
KindRecentlyPlayed = "recently-played",
KindRecentlyAdded = "recently-added",
KindMostPlayed = "most-played",
KindUnplayed = "unplayed",
KindStale = "stale",
KindArtist = "artist",
KindGenre = "genre",
KindRandom = "random",
};
/**
* Shelf is one horizontal row on the home page.
*/
export interface Shelf {
/**
* ID is stable within a response, for list keying.
*/
"id": string;
"kind": Kind;
/**
* Title is the row heading.
*/
"title": string;
/**
* Subtitle says why these albums are here. It is not decoration:
* without it a shelf is indistinguishable from a random grid.
*/
"subtitle": string;
"albums": library$0.Album[] | null;
}
@@ -0,0 +1,27 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* Service builds the home page's shelves.
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
/**
* GetShelves returns the home page's rows, in display order.
*
* It is a single call rather than one per shelf because the shelves
* share an album lookup and because the page has nothing useful to
* render until it knows which rows exist — a page that pops rows in one
* at a time reflows under the user's cursor.
*/
export function GetShelves(): $CancellablePromise<$models.Shelf[] | null> {
return $Call.ByID(2822423495);
}
@@ -0,0 +1,22 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import * as Service from "./service.js";
export {
Service
};
export {
Kind,
Level,
State
} from "./models.js";
export type {
Caps,
Job,
LogEntry,
Registry,
Stage,
Stat
} from "./models.js";
@@ -0,0 +1,159 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* Caps describes which controls a job supports. The frontend renders
* buttons from these rather than switching on Kind, so a job that gains
* pause support later needs no frontend change.
*/
export interface Caps {
"pausable": boolean;
"cancellable": boolean;
}
/**
* Job is the frontend-facing snapshot of a single background job.
*/
export interface Job {
"id": string;
"kind": Kind;
"title": string;
"subtitle"?: string;
"state": State;
"phase"?: string;
/**
* Current/Total drive the progress bar. Total == 0 means the job
* is indeterminate and the frontend should render a spinner.
*/
"current": number;
"total": number;
"caps": Caps;
"stages": Stage[] | null;
"stats": Stat[] | null;
"error"?: string;
/**
* unix milliseconds
*/
"startedAt": number;
"updatedAt": number;
"endedAt"?: number;
"logCount": number;
"warnCount": number;
"errorCount": number;
}
/**
* Kind identifies the subsystem that owns a job. The frontend uses it
* to pick an icon and to route "view details" to the right panel.
*/
export enum Kind {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
/**
* Job kinds.
*/
KindLibraryScan = "library-scan",
KindIndexBuild = "index-build",
KindDownload = "download",
KindAutotagApply = "autotag-apply",
/**
* KindCatalogEnrich is background catalog work for content the user
* already owns — the discography backfills. It is distinct from
* KindIndexBuild because the two differ in what cancelling costs:
* an index build discards hours of downloading and the frontend
* confirms before stopping one, where a backfill is resumable per
* artist and stopping it is free.
*/
KindCatalogEnrich = "catalog-enrich",
};
/**
* Level is the severity of a job log entry.
*/
export enum Level {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
/**
* Log levels.
*/
LevelInfo = "info",
LevelWarn = "warn",
LevelError = "error",
};
/**
* LogEntry is one line of a job's output log.
*/
export interface LogEntry {
/**
* unix milliseconds
*/
"time": number;
"level": Level;
"message": string;
"detail"?: string;
}
/**
* Registry owns every known job and pushes coalesced snapshots to the
* frontend. It is safe for concurrent use.
*/
export interface Registry {
}
/**
* Stage is one named sub-step of a multi-stage job, such as an index
* build tier. Jobs with a single linear phase leave Stages empty.
*/
export interface Stage {
"name": string;
/**
* pending, running, complete, error, skipped
*/
"state": string;
"current": number;
"total": number;
"error"?: string;
}
/**
* Stat is a display-only key/value pair shown in the job detail panel
* (e.g. "Added" / "1,204").
*/
export interface Stat {
"label": string;
"value": string;
}
/**
* State is the lifecycle position of a job.
*/
export enum State {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
/**
* Job states. Queued, Running, Paused and Pausing are live; Complete,
* Cancelled and Error are terminal.
*/
StateQueued = "queued",
StateRunning = "running",
StatePausing = "pausing",
StatePaused = "paused",
StateCancelling = "cancelling",
StateComplete = "complete",
StateCancelled = "cancelled",
StateError = "error",
};
@@ -0,0 +1,68 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* Service is the Wails-bound facade over the registry. It deliberately
* exposes only the read and control surface — producers get a *Handle
* through the registry instead, so the frontend cannot invent jobs.
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
/**
* CancelJob abandons a job.
*/
export function CancelJob(id: string): $CancellablePromise<void> {
return $Call.ByID(680141523, id);
}
/**
* ClearFinishedJobs removes every terminal job from the list.
*/
export function ClearFinishedJobs(): $CancellablePromise<void> {
return $Call.ByID(4219744941);
}
/**
* DismissJob removes a single finished job from the list.
*/
export function DismissJob(id: string): $CancellablePromise<void> {
return $Call.ByID(1904307161, id);
}
/**
* GetJobLog returns the retained log tail for one job.
*/
export function GetJobLog(id: string): $CancellablePromise<$models.LogEntry[] | null> {
return $Call.ByID(441057229, id);
}
/**
* GetJobs returns every known job — active first by registration order,
* including recently finished ones so the panel can show outcomes.
*/
export function GetJobs(): $CancellablePromise<$models.Job[] | null> {
return $Call.ByID(3979035366);
}
/**
* PauseJob asks the owning subsystem to pause a job. Returns
* immediately; the job reports "paused" once it actually stops.
*/
export function PauseJob(id: string): $CancellablePromise<void> {
return $Call.ByID(4094353111, id);
}
/**
* ResumeJob continues a paused job.
*/
export function ResumeJob(id: string): $CancellablePromise<void> {
return $Call.ByID(3937701508, id);
}
@@ -0,0 +1,25 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import * as Library from "./library.js";
export {
Library
};
export type {
Album,
AlbumCompleteness,
Artist,
GenreWithCount,
Info,
RemovalHooks,
RemovalImpact,
RemovalResult,
RemovalSummary,
RescanHooks,
ScanHooks,
ScanMetrics,
ScanWarning,
Track,
TrackMBIDs
} from "./models.js";
@@ -0,0 +1,454 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* Library manages scanning and querying the music collection.
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as sqlcgen$0 from "../database/sql/sqlcgen/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as jobs$0 from "../jobs/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
/**
* AcquirePipelineLock acquires the pipeline mutex for a tag write
* operation. The caller must call ReleasePipelineLock when done.
* If a scan is currently in progress, AcquirePipelineLock blocks
* until it completes (and vice versa).
*/
export function AcquirePipelineLock(): $CancellablePromise<void> {
return $Call.ByID(2056761494);
}
/**
* AddLibrary creates a new library from a directory path, emits a
* LibraryAdded event, and starts an asynchronous scan.
*/
export function AddLibrary(path: string): $CancellablePromise<sqlcgen$0.Library | null> {
return $Call.ByID(4258719739, path);
}
/**
* CancelAllScans cancels the current scan and clears the entire
* queue so no further libraries are scanned.
*/
export function CancelAllScans(): $CancellablePromise<void> {
return $Call.ByID(2826471660);
}
/**
* CancelCurrentScan cancels only the currently scanning library.
* The next queued library (if any) starts automatically when the
* current scan's goroutine completes.
*/
export function CancelCurrentScan(): $CancellablePromise<void> {
return $Call.ByID(2190880081);
}
/**
* CancelScan cancels an in-progress scan. Returns immediately;
* scan goroutines stop at their next checkpoint.
*
* Deprecated: Use CancelCurrentScan or CancelAllScans for
* queue-aware cancellation.
*/
export function CancelScan(): $CancellablePromise<void> {
return $Call.ByID(4160132270);
}
/**
* FullRescan clears the queue and player, wipes all library data
* (database records and cover art files), and performs a fresh
* scan of every library in the database. The returned ScanMetrics
* reflects the last library scanned; clear-phase durations are
* folded into its totals.
*/
export function FullRescan(): $CancellablePromise<$models.ScanMetrics | null> {
return $Call.ByID(378891236);
}
/**
* 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.
*/
export function GetAlbumCompleteness(albumID: number): $CancellablePromise<$models.AlbumCompleteness> {
return $Call.ByID(2895787150, albumID);
}
/**
* GetAlbumTracks returns all tracks for a given album (release group), ordered by disc and track number.
*/
export function GetAlbumTracks(albumID: number): $CancellablePromise<$models.Track[] | null> {
return $Call.ByID(300451334, albumID);
}
/**
* GetAlbumTracksByLibrary returns tracks for the given album,
* scoped to the given library.
*/
export function GetAlbumTracksByLibrary(albumID: number, libraryID: number): $CancellablePromise<$models.Track[] | null> {
return $Call.ByID(3304485554, albumID, libraryID);
}
/**
* GetAlbumsByArtist returns all albums where the given artist is the album artist.
*/
export function GetAlbumsByArtist(artistID: number): $CancellablePromise<$models.Album[] | null> {
return $Call.ByID(1456840721, artistID);
}
/**
* GetAlbumsByArtistByLibrary returns albums for the given artist
* that have tracks in the given library.
*/
export function GetAlbumsByArtistByLibrary(artistID: number, libraryID: number): $CancellablePromise<$models.Album[] | null> {
return $Call.ByID(2809291, artistID, libraryID);
}
/**
* GetAllAlbums returns all albums with cover art and artist info for the cover grid.
*/
export function GetAllAlbums(): $CancellablePromise<$models.Album[] | null> {
return $Call.ByID(2015458954);
}
/**
* GetAllAlbumsByLibrary returns albums that have tracks in the given library.
*/
export function GetAllAlbumsByLibrary(libraryID: number): $CancellablePromise<$models.Album[] | null> {
return $Call.ByID(4023050470, libraryID);
}
/**
* GetAllArtists returns artists that are credited as album artists, ordered by name.
*/
export function GetAllArtists(): $CancellablePromise<$models.Artist[] | null> {
return $Call.ByID(2529088294);
}
/**
* GetAllArtistsByLibrary returns artists that have albums with tracks
* in the given library.
*/
export function GetAllArtistsByLibrary(libraryID: number): $CancellablePromise<$models.Artist[] | null> {
return $Call.ByID(1170594642, libraryID);
}
/**
* GetAllGenresWithCounts returns all genres with their track counts.
*/
export function GetAllGenresWithCounts(): $CancellablePromise<$models.GenreWithCount[] | null> {
return $Call.ByID(602231298);
}
/**
* GetAllGenresWithCountsByLibrary returns genres with track counts
* scoped to the given library.
*/
export function GetAllGenresWithCountsByLibrary(libraryID: number): $CancellablePromise<$models.GenreWithCount[] | null> {
return $Call.ByID(772684334, libraryID);
}
/**
* GetAllLibrariesWithTrackCounts returns all libraries with their
* audio file counts. Typically 1-5 libraries so the loop is trivial.
*/
export function GetAllLibrariesWithTrackCounts(): $CancellablePromise<$models.Info[] | null> {
return $Call.ByID(3420301148);
}
/**
* GetAllTracks returns an array of track structs of every file in the library.
*/
export function GetAllTracks(): $CancellablePromise<$models.Track[] | null> {
return $Call.ByID(2991050010);
}
/**
* GetAllTracksByLibrary returns tracks scoped to a specific library.
*/
export function GetAllTracksByLibrary(libraryID: number): $CancellablePromise<$models.Track[] | null> {
return $Call.ByID(3882999766, libraryID);
}
/**
* GetFilePathsByAlbums returns the file paths of every track in the
* given albums, grouped by album id.
*
* "Play this artist", "play these albums" and the album drag cache each
* resolved paths with one binding call per album, sequentially, and each
* asked for whole track rows to read one field off them (perf.m2). This
* is that question asked once. The result is grouped rather than
* flattened because the caller owns the ordering — an album list is
* sorted by name, not by id — and because the drag cache stores it per
* album.
*
* A library id of 0 means "every library", matching the caller's
* selected-library filter being unset.
*/
export function GetFilePathsByAlbums(albumIDs: number[] | null, libraryID: number): $CancellablePromise<{ [_ in `${number}`]?: string[] | null } | null> {
return $Call.ByID(1692993358, albumIDs, libraryID);
}
/**
* GetFilePathsByGenres returns the file paths of every track tagged with
* the given genres, grouped by genre name. See GetFilePathsByAlbums —
* same finding, same shape, and the caller still owns the de-duplication
* across genres because it owns the order.
*/
export function GetFilePathsByGenres(genreNames: string[] | null, libraryID: number): $CancellablePromise<{ [_ in string]?: string[] | null } | null> {
return $Call.ByID(1180707302, genreNames, libraryID);
}
/**
* GetFilePathsByRecordingMBIDs returns the file paths of every track
* whose recording MBID is in mbids, grouped by MBID.
*
* This is the catalog side of GetFilePathsByAlbums. An Explore album
* page knows what the user owns as a set of recording MBIDs and nothing
* else: that is exactly how the backend decides a track's InLibrary
* flag (markReleasesInLibrary → CheckMBIDs), and MBTrack.LocalID is a
* declared field that nothing writes, so there is no id to ask by.
*
* Grouped rather than flattened for the same two reasons as its
* siblings — the caller owns the order (the tracklist's, not the
* database's), and one recording can have more than one file, which is
* what this app's duplicate detection exists for.
*
* A library id of 0 means "every library".
*/
export function GetFilePathsByRecordingMBIDs(mbids: string[] | null, libraryID: number): $CancellablePromise<{ [_ in string]?: string[] | null } | null> {
return $Call.ByID(2789061644, mbids, libraryID);
}
/**
* GetRemovalImpact returns pre-removal counts for the confirmation
* dialog. All queries are read-only.
*/
export function GetRemovalImpact(libraryID: number): $CancellablePromise<$models.RemovalImpact | null> {
return $Call.ByID(2715588999, libraryID);
}
/**
* GetScanQueueLength returns the number of libraries waiting in the
* scan queue (excludes the currently scanning library).
*/
export function GetScanQueueLength(): $CancellablePromise<number> {
return $Call.ByID(2739382973);
}
/**
* GetTrackMBIDs returns the MusicBrainz IDs for the track at the
* given file path. Returns empty strings for entities without MBIDs.
*/
export function GetTrackMBIDs(filePath: string): $CancellablePromise<$models.TrackMBIDs> {
return $Call.ByID(56752473, filePath);
}
/**
* GetTracksByGenre returns all tracks tagged with the given genre.
*/
export function GetTracksByGenre(genreName: string): $CancellablePromise<$models.Track[] | null> {
return $Call.ByID(1674220245, genreName);
}
/**
* GetTracksByGenreByLibrary returns tracks tagged with the given
* genre, scoped to the given library.
*/
export function GetTracksByGenreByLibrary(genreName: string, libraryID: number): $CancellablePromise<$models.Track[] | null> {
return $Call.ByID(4240564783, genreName, libraryID);
}
/**
* IsScanActive returns whether a scan is currently running.
*/
export function IsScanActive(): $CancellablePromise<boolean> {
return $Call.ByID(1365062070);
}
/**
* IsScanPaused returns whether the scan is currently paused.
*/
export function IsScanPaused(): $CancellablePromise<boolean> {
return $Call.ByID(2930130958);
}
/**
* LibraryPath resolves a library's root directory by id.
*/
export function LibraryPath(id: number): $CancellablePromise<string> {
return $Call.ByID(1798172787, id);
}
/**
* PauseScan pauses an in-progress scan. Workers block at their
* next pause checkpoint until ResumeScan is called.
*/
export function PauseScan(): $CancellablePromise<void> {
return $Call.ByID(3522392788);
}
/**
* QueuedLibraryNames returns the display names of libraries waiting
* in the scan queue, in FIFO order.
*/
export function QueuedLibraryNames(): $CancellablePromise<string[] | null> {
return $Call.ByID(2036951097);
}
/**
* ReleasePipelineLock releases the pipeline mutex after a tag write.
*/
export function ReleasePipelineLock(): $CancellablePromise<void> {
return $Call.ByID(2053843147);
}
/**
* RemoveFromLibrary deletes the database rows for the given file paths
* and records each path as excluded, so the next scan does not import
* it again. **It does not touch the files on disk** — that is the
* promise the confirmation dialog makes, and the reason this operation
* is safe to put one keystroke from a focused row.
*
* The exclusion is not an enhancement. Without it the next scan finds
* the file, sees no row, and imports it again — a button that undoes
* itself, which is worse than no button.
*/
export function RemoveFromLibrary(filePaths: string[] | null): $CancellablePromise<$models.RemovalResult | null> {
return $Call.ByID(825509452, filePaths);
}
/**
* RemoveLibrary atomically removes a library and all its data,
* performing orphan cleanup, phantom metadata conversion, FTS5
* rebuild, and queue compaction. Returns a summary of what was removed.
*/
export function RemoveLibrary(id: number): $CancellablePromise<$models.RemovalSummary | null> {
return $Call.ByID(4110443412, id);
}
/**
* RenameLibrary validates and updates a library's display name.
*/
export function RenameLibrary(id: number, newName: string): $CancellablePromise<void> {
return $Call.ByID(3949052986, id, newName);
}
/**
* RestorePausedScans adopts scans that were paused when the app last
* shut down back into the job registry, still paused. Call during
* startup before SoftScanAllLibraries so the soft scan does not restart
* a library the user deliberately paused.
*/
export function RestorePausedScans(): $CancellablePromise<void> {
return $Call.ByID(4243712755);
}
/**
* ResumeScan unblocks a paused scan.
*/
export function ResumeScan(): $CancellablePromise<void> {
return $Call.ByID(3079702539);
}
/**
* ScanAllLibraries queries all libraries from the database and queues
* each one for scanning. Existing dedup logic ensures no duplicates.
*/
export function ScanAllLibraries(): $CancellablePromise<void> {
return $Call.ByID(266098306);
}
/**
* ScanLibrary queues a scan for the library with the given database ID.
* If no scan is active the library is scanned immediately; otherwise it
* is appended to the queue. Duplicate requests (same library already
* scanning or already queued) are silently ignored.
*/
export function ScanLibrary(id: number): $CancellablePromise<void> {
return $Call.ByID(2994459743, id);
}
/**
* SearchTracks performs an FTS5 full-text search and returns
* matching tracks with full metadata.
*/
export function SearchTracks(query: string): $CancellablePromise<$models.Track[] | null> {
return $Call.ByID(3848515709, query);
}
/**
* SearchTracksByLibrary performs an FTS5 search scoped to a specific
* library and returns matching tracks with full metadata.
*/
export function SearchTracksByLibrary(query: string, libraryID: number): $CancellablePromise<$models.Track[] | null> {
return $Call.ByID(152384327, query, libraryID);
}
/**
* SetJobRegistry wires the background job registry so scans report
* progress, logs, and pause/cancel controls to the frontend.
*/
export function SetJobRegistry(reg: jobs$0.Registry | null): $CancellablePromise<void> {
return $Call.ByID(4271773525, reg);
}
/**
* SetRemovalHooks provides optional hooks for cross-cutting
* orchestration during RemoveLibrary.
*/
export function SetRemovalHooks(h: $models.RemovalHooks): $CancellablePromise<void> {
return $Call.ByID(3190933207, h);
}
/**
* SetRescanHooks provides optional hooks for cross-cutting
* orchestration during FullRescan.
*/
export function SetRescanHooks(h: $models.RescanHooks): $CancellablePromise<void> {
return $Call.ByID(1944064333, h);
}
/**
* SetScanHooks provides optional hooks for cross-cutting
* orchestration after each library scan.
*/
export function SetScanHooks(h: $models.ScanHooks): $CancellablePromise<void> {
return $Call.ByID(520513414, h);
}
/**
* SoftScanAllLibraries performs a lightweight launch-time scan.
* First it claims any orphaned tracks (library_id=0) left over from
* the pre-multi-library schema. Then for each library it compares
* the number of audio files on disk against the track count in the
* database. Only libraries where the counts differ (files added or
* removed since last scan) are queued for a full scan. Libraries
* that are unchanged are silently skipped — no progress bar, no
* scan events.
*/
export function SoftScanAllLibraries(): $CancellablePromise<void> {
return $Call.ByID(1401473402);
}
@@ -0,0 +1,315 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as time$0 from "../../../time/models.js";
/**
* Album represents an album for the cover grid display.
*
* Year is the album's preferred display year — the release-group's
* original-release-date (MusicBrainz first-release-date) when known,
* falling back to the file-tag year. ReleaseYear is the file-tag
* year of the specific release in the library; for a 2010 remaster
* of a 1973 album, Year=1973 and ReleaseYear=2010.
*/
export interface Album {
"ID": number;
"Name": string;
"ArtistName": string;
"ArtistMBID": string;
"MBID": string;
"CoverArtPath": string;
"CoverArtSmall": string;
"CoverArtMedium": string;
"CoverArtLarge": string;
"Year": number;
"ReleaseYear": number;
}
/**
* 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.
*/
export interface AlbumCompleteness {
"owned": number;
"expected": number;
"known": boolean;
"complete": boolean;
}
/**
* Artist represents an artist in the library.
*/
export interface Artist {
"ID": number;
"Name": string;
"MBID": string;
"ImageSmall": string;
"ImageMedium": string;
"ImageLarge": string;
}
/**
* GenreWithCount holds a genre name and its associated track count.
*/
export interface GenreWithCount {
"Name": string;
"TrackCount": number;
}
/**
* Info contains library metadata enriched with track count
* for the frontend settings UI.
*/
export interface Info {
"id": number;
"name": string;
"path": string;
"trackCount": number;
}
/**
* RemovalHooks contains callbacks invoked during library removal.
* These break circular dependencies between the library, player,
* and queue packages.
*/
export interface RemovalHooks {
/**
* StopPlayback stops the currently-playing track.
*/
"StopPlayback": any;
/**
* CompactQueue reloads queue state after cascade deletes.
*/
"CompactQueue": any;
/**
* PostRemove runs after the removal commits, for cross-cutting
* invalidation (e.g. clearing library-sync "ready" markers).
*/
"PostRemove": any;
}
/**
* RemovalImpact contains pre-removal counts for the confirmation dialog.
*/
export interface RemovalImpact {
"trackCount": number;
"playlistsAffected": number;
"queueItemCount": number;
}
/**
* RemovalResult reports what one RemoveFromLibrary call did.
*/
export interface RemovalResult {
/**
* TracksRemoved is how many audio_files rows were deleted. It can
* be lower than len(filePaths) if a path was already gone.
*/
"tracksRemoved": number;
/**
* PathsExcluded is how many paths the scanner will now skip.
*/
"pathsExcluded": number;
}
/**
* RemovalSummary contains post-removal counts for the toast notification.
*/
export interface RemovalSummary {
"tracksDeleted": number;
"artistsRemoved": number;
"albumsRemoved": number;
"genresRemoved": number;
"playlistsAffected": number;
"queueItemsRemoved": number;
}
/**
* RescanHooks holds optional callbacks that run before and after
* the library-clear-and-scan phase of a full rescan. The app
* layer sets these to coordinate cross-cutting concerns (e.g.
* clearing the queue, restoring playlists) without the library
* needing to know about those packages.
*/
export interface RescanHooks {
/**
* PreClear runs before library data is wiped
* (e.g. clear queue and stop playback).
*/
"PreClear": any;
/**
* PostScan runs after the scan completes
* (e.g. restore playlists from M3U8 files).
*/
"PostScan": any;
}
/**
* ScanHooks contains callbacks invoked after a library scan
* completes. The app layer wires these so the library package
* does not depend on the playlist package directly.
*/
export interface ScanHooks {
/**
* RepopulatePlaylists re-imports tracks for playlists that
* lost their playlist_tracks rows (e.g., from a pre-fix
* FullRescan). Runs before ResolvePhantoms.
*/
"RepopulatePlaylists": any;
/**
* ResolvePhantoms re-links phantom playlist tracks whose
* files now exist in the library after scanning.
*/
"ResolvePhantoms": any;
/**
* OnAllScansComplete runs after ALL queued scans finish
* (queue drained).
*/
"OnAllScansComplete": any;
}
/**
* ScanMetrics holds timing and count data collected during a library scan.
* Worker-pool fields are protected by a mutex; DB-writer fields are
* single-threaded and use plain addition.
*/
export interface ScanMetrics {
/**
* Top-level phases (wall-clock).
*/
"total": time$0.Duration;
"loadExisting": time$0.Duration;
"walkDuration": time$0.Duration;
"extractionWallClock": time$0.Duration;
"dbWritesWallClock": time$0.Duration;
"orphanCleanup": time$0.Duration;
"postScanVariants": time$0.Duration;
/**
* Per-format extraction (cumulative across workers).
*/
"formatExtraction": { [_ in string]?: number } | null;
"formatCount": { [_ in string]?: number } | null;
/**
* Sub-operation cumulative times (across workers).
*/
"tagExtraction": time$0.Duration;
"durationExtraction": time$0.Duration;
/**
* DB sub-operations (cumulative, single-threaded DB writer).
*/
"batchCommits": time$0.Duration;
"coverArtSave": time$0.Duration;
/**
* Thumbnail generation (async worker pool).
*/
"thumbnailWallClock": time$0.Duration;
"thumbnailGeneration": time$0.Duration;
"thumbnailSmall": time$0.Duration;
"thumbnailMedium": time$0.Duration;
"thumbnailLarge": time$0.Duration;
/**
* Full-rescan-specific phases.
*/
"clearQueue": time$0.Duration;
"clearDatabase": time$0.Duration;
"clearCoverFiles": time$0.Duration;
/**
* File counts.
*/
"added": number;
"updated": number;
"skipped": number;
"removed": number;
/**
* Cancelled is true when the scan was stopped via CancelScan.
*/
"cancelled": boolean;
/**
* Library identification.
* library that was scanned
*/
"libraryId": number;
/**
* display name of scanned library
*/
"libraryName": string;
/**
* Non-fatal issues encountered during scanning.
*/
"warnings": ScanWarning[] | null;
}
/**
* ScanWarning represents a non-fatal issue encountered during scanning.
*/
export interface ScanWarning {
"filePath": string;
"phase": string;
"err": string;
}
/**
* Track represents a playable audio file in the library.
*/
export interface Track {
"TrackName": string;
"ArtistName": string;
"TrackLength": string;
"FilePath": string;
"TrackNumber": number;
"DiscNumber": number;
"Album": string;
"Genre": string[] | null;
"Year": number;
"Composer": string;
"FileType": string;
"SampleRate": number;
"BitDepth": number;
"Channels": number;
"Bitrate": number;
"FileSize": number;
"PlayCount": number;
"LastPlayed": string;
"RecordingMBID": string;
"ArtistMBID": string;
"ReleaseGroupMBID": string;
"CoverArtPath": string;
"CoverArtSmall": string;
"CoverArtMedium": string;
"CoverArtLarge": string;
}
/**
* TrackMBIDs holds MusicBrainz identifiers for a track, resolved
* from the recording, release group, and artist tables.
*/
export interface TrackMBIDs {
"recordingMbid": string;
"releaseGroupMbid": string;
"artistMbid": string;
}
@@ -0,0 +1,6 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export type {
Handler
} from "./models.js";
@@ -0,0 +1,7 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* Handler manages the OS media control integration.
*/
export type Handler = any;
@@ -0,0 +1,16 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import * as Player from "./player.js";
export {
Player
};
export {
State,
UserVolume
} from "./models.js";
export type {
TrackInfo
} from "./models.js";
@@ -0,0 +1,61 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* State represents the current playback state.
*/
export enum State {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
/**
* Playback state values.
*/
Playing = "playing",
Paused = "paused",
Stopped = "stopped",
};
/**
* TrackInfo contains metadata and playback state for the currently
* loaded track. It is emitted as the payload of the TrackChanged
* event and serialized as camelCase JSON to match the frontend
* TrackInfo interface in player-store.ts.
*/
export interface TrackInfo {
"fileName": string;
"filePath": string;
"state": State;
"title": string;
"artist": string;
"album": string;
"coverArt": string;
"coverArtSmall": string;
"coverArtMedium": string;
"coverArtLarge": string;
"trackLength": number;
"seekPosition": number;
"trackChangeId": number;
"artistMbid": string;
"releaseGroupMbid": string;
"recordingMbid": string;
}
/**
* UserVolume represents volume on a user-facing scale (0-100).
*/
export enum UserVolume {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = 0,
/**
* User volume range bounds.
*/
MinUserVol = 0,
MaxUserVol = 100,
DefaultUserVol = 50,
};
@@ -0,0 +1,182 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* Player handles audio playback and state management.
*
* Lock ordering: always acquire p.mu BEFORE speaker.Lock().
* The beep playback-finished callback dispatches to a new goroutine
* so it never holds p.mu while the speaker lock is held.
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as mediacontrols$0 from "../mediacontrols/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
/**
* ChangeVolume adjusts the volume by a relative amount.
*/
export function ChangeVolume(deltaVolume: number): $CancellablePromise<void> {
return $Call.ByID(742861629, deltaVolume);
}
/**
* CurrentPosition returns the playback position as a percentage
* (0-100).
*/
export function CurrentPosition(): $CancellablePromise<number> {
return $Call.ByID(2723620683);
}
/**
* CurrentPositionSeconds returns the current playback position in
* display seconds.
*/
export function CurrentPositionSeconds(): $CancellablePromise<number> {
return $Call.ByID(1423359008);
}
/**
* EmitCurrentState pushes the current player state to the frontend.
* This is intended to be called after the frontend is ready to
* receive events, separately from RestoreState which does the heavy
* lifting during OnStartup.
*/
export function EmitCurrentState(): $CancellablePromise<void> {
return $Call.ByID(2143346946);
}
/**
* GetCurrentTrackInfo returns information about the currently
* loaded track.
*/
export function GetCurrentTrackInfo(): $CancellablePromise<$models.TrackInfo> {
return $Call.ByID(2615351053);
}
/**
* InitSpeaker initializes the audio output device. This is
* separated from NewPlayer so the player struct can be created
* before wails.Run (for binding registration) while deferring
* hardware initialization to OnStartup.
*/
export function InitSpeaker(): $CancellablePromise<void> {
return $Call.ByID(3317123670);
}
/**
* IsPlaying reports whether the player is currently playing audio.
*/
export function IsPlaying(): $CancellablePromise<boolean> {
return $Call.ByID(1219303535);
}
/**
* LoadFile opens and decodes an audio file for playback.
*/
export function LoadFile(filePath: string): $CancellablePromise<void> {
return $Call.ByID(2214941041, filePath);
}
/**
* MuteToggle toggles the mute state.
*/
export function MuteToggle(): $CancellablePromise<void> {
return $Call.ByID(2840614678);
}
/**
* Muted reports whether playback is currently silenced.
*/
export function Muted(): $CancellablePromise<boolean> {
return $Call.ByID(2954670878);
}
/**
* Pause pauses the current playback.
*/
export function Pause(): $CancellablePromise<void> {
return $Call.ByID(1402758839);
}
/**
* Play starts or resumes audio playback.
*/
export function Play(): $CancellablePromise<void> {
return $Call.ByID(3327176373);
}
/**
* RestoreState loads the persisted player state from the database.
*/
export function RestoreState(): $CancellablePromise<void> {
return $Call.ByID(318202560);
}
/**
* SaveState persists the current player state to the database and
* waits for the write. This is called during shutdown to capture the
* final state, which is the one case that cannot be deferred.
*/
export function SaveState(): $CancellablePromise<void> {
return $Call.ByID(3845072215);
}
/**
* Seek jumps to a specific position in seconds.
*/
export function Seek(targetSeconds: number): $CancellablePromise<void> {
return $Call.ByID(829056707, targetSeconds);
}
/**
* SetMediaControls provides an OS media controls handler. When set,
* the player pushes metadata, playback state, volume, and seek
* notifications to the OS media overlay.
*/
export function SetMediaControls(h: mediacontrols$0.Handler): $CancellablePromise<void> {
return $Call.ByID(1896286877, h);
}
/**
* SetPlaybackFinishedHandler sets a callback invoked when a track
* finishes naturally. This allows the queue to drive auto-advance
* without circular imports.
*/
export function SetPlaybackFinishedHandler(handler: any): $CancellablePromise<void> {
return $Call.ByID(2023377546, handler);
}
/**
* SetVolume sets the playback volume (0-100), emits a
* VolumeChanged event, and persists the new level.
*/
export function SetVolume(desiredVolume: $models.UserVolume): $CancellablePromise<void> {
return $Call.ByID(1375836663, desiredVolume);
}
/**
* TrackLengthInSeconds returns the duration of the current track.
*/
export function TrackLengthInSeconds(): $CancellablePromise<number> {
return $Call.ByID(3749851426);
}
/**
* UnloadTrack tears down the current track, releasing the file and
* streamer chain. The player returns to the initial "no track
* loaded" state and emits events so the frontend clears its
* current-track display.
*/
export function UnloadTrack(): $CancellablePromise<void> {
return $Call.ByID(3127425467);
}
@@ -0,0 +1,19 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import * as Service from "./service.js";
export {
Service
};
export type {
CandidateTrack,
DuplicateCheckResult,
DuplicateTrackInfo,
FavoritesConfigProvider,
PhantomMatch,
PhantomSearchResult,
Summary,
Track,
WithTracks
} from "./models.js";
@@ -0,0 +1,103 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* CandidateTrack represents a potential library match for a
* phantom track.
*/
export interface CandidateTrack {
"FilePath": string;
"Title": string;
"Artist": string;
"Album": string;
"Duration": string;
"Score": number;
}
/**
* DuplicateCheckResult contains the outcome of checking for
* duplicate tracks in a playlist.
*/
export interface DuplicateCheckResult {
"Duplicates": DuplicateTrackInfo[] | null;
"Unique": string[] | null;
}
/**
* DuplicateTrackInfo holds metadata for a track that already
* exists in a playlist.
*/
export interface DuplicateTrackInfo {
"FilePath": string;
"Title": string;
"Artist": string;
"Album": string;
"Duration": string;
}
/**
* FavoritesConfigProvider is a narrow interface for reading and
* writing the default-playlist configuration.
*/
export type FavoritesConfigProvider = any;
/**
* PhantomMatch represents a high-confidence pairing of a phantom
* track to a library track.
*/
export interface PhantomMatch {
"PhantomPath": string;
"PhantomTitle": string;
"Candidate": CandidateTrack;
}
/**
* PhantomSearchResult contains auto-matched pairs and remaining
* unmatched phantom paths for a batch search operation.
*/
export interface PhantomSearchResult {
"AutoMatched": PhantomMatch[] | null;
"Unmatched": string[] | null;
}
/**
* Summary is a lightweight representation of a playlist for the
* picker UI.
*/
export interface Summary {
"ID": number;
"Name": string;
"CreatedAt": string;
"UpdatedAt": string;
"IsSmart": boolean;
}
/**
* Track represents a track within a playlist, including its
* metadata.
*/
export interface Track {
"ID": number;
"Position": number;
"FilePath": string;
"Title": string;
"Artist": string;
"Album": string;
"CoverArtPath": string;
"CoverArtSmall": string;
"CoverArtMedium": string;
"CoverArtLarge": string;
"Duration": string;
"Phantom": boolean;
"ArtistMBID": string;
"ReleaseGroupMBID": string;
"RecordingMBID": string;
}
/**
* WithTracks contains a playlist summary and all its tracks.
*/
export interface WithTracks {
"Summary": Summary;
"Tracks": Track[] | null;
}
@@ -0,0 +1,336 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* Service manages playlist operations.
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as library$0 from "../library/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
/**
* AddToDefaultPlaylist adds multiple tracks to the default
* playlist, skipping any that are already present.
*/
export function AddToDefaultPlaylist(filePaths: string[] | null): $CancellablePromise<void> {
return $Call.ByID(1493850977, filePaths);
}
/**
* AddTracksToPlaylist adds one or more tracks to an existing
* playlist.
*/
export function AddTracksToPlaylist(playlistID: number, filePaths: string[] | null): $CancellablePromise<void> {
return $Call.ByID(2843242868, playlistID, filePaths);
}
/**
* CreatePlaylist creates a new empty playlist with the given name.
*/
export function CreatePlaylist(name: string): $CancellablePromise<$models.Summary> {
return $Call.ByID(4080218878, name);
}
/**
* CreatePlaylistWithTracks creates a new playlist and populates
* it with tracks.
*/
export function CreatePlaylistWithTracks(name: string, filePaths: string[] | null): $CancellablePromise<$models.Summary> {
return $Call.ByID(4079964334, name, filePaths);
}
/**
* CreateSmartPlaylist creates a new smart playlist with the given
* name and JSON rule set. The rules are validated before storage.
*/
export function CreateSmartPlaylist(name: string, rulesJSON: string): $CancellablePromise<$models.Summary> {
return $Call.ByID(2020825359, name, rulesJSON);
}
/**
* DeletePlaylist deletes a playlist and its M3U8 file.
* If the deleted playlist was the default, a new default
* playlist is automatically created.
*/
export function DeletePlaylist(playlistID: number): $CancellablePromise<void> {
return $Call.ByID(1998239209, playlistID);
}
/**
* EnsureDefaultPlaylist verifies the configured default playlist
* exists in the database. If the playlist is missing or no ID
* has been configured yet, a new playlist named "Favorites" is
* created and the config is updated.
*/
export function EnsureDefaultPlaylist(): $CancellablePromise<void> {
return $Call.ByID(3280319649);
}
/**
* EvaluateSmartPlaylist loads the rule set for a smart playlist
* from the database and evaluates it against the track library,
* returning the matching tracks.
*/
export function EvaluateSmartPlaylist(playlistID: number): $CancellablePromise<library$0.Track[] | null> {
return $Call.ByID(2192251014, playlistID);
}
/**
* FindDuplicateTracksInPlaylist checks which of the given file
* paths already exist in the specified playlist. Returns metadata
* for each duplicate and a list of non-duplicate file paths.
*/
export function FindDuplicateTracksInPlaylist(playlistID: number, filePaths: string[] | null): $CancellablePromise<$models.DuplicateCheckResult> {
return $Call.ByID(57159613, playlistID, filePaths);
}
/**
* FindPhantomMatches searches the library for matches for the
* given phantom file paths. High-confidence matches are returned
* as auto-matched pairs; the rest remain in the unmatched list.
*/
export function FindPhantomMatches(playlistID: number, phantomPaths: string[] | null): $CancellablePromise<$models.PhantomSearchResult> {
return $Call.ByID(2112903901, playlistID, phantomPaths);
}
/**
* GetAllPlaylists returns all playlists ordered by most recently
* updated.
*/
export function GetAllPlaylists(): $CancellablePromise<$models.Summary[] | null> {
return $Call.ByID(1751004770);
}
/**
* GetAllPlaylistsWithTracks returns all playlists with their
* tracks in a single call, merging phantom tracks from M3U8 files.
*/
export function GetAllPlaylistsWithTracks(): $CancellablePromise<$models.WithTracks[] | null> {
return $Call.ByID(1602859466);
}
/**
* GetDefaultPlaylistInfo returns the ID and name of the default
* playlist for display in the frontend.
*/
export function GetDefaultPlaylistInfo(): $CancellablePromise<$models.Summary> {
return $Call.ByID(845855111);
}
/**
* GetDefaultPlaylistTrackPaths returns the file paths of all
* tracks in the default playlist.
*/
export function GetDefaultPlaylistTrackPaths(): $CancellablePromise<string[] | null> {
return $Call.ByID(135237414);
}
/**
* GetPhantomCandidates returns scored candidate matches for a
* single phantom track.
*/
export function GetPhantomCandidates(playlistID: number, phantomPath: string): $CancellablePromise<$models.CandidateTrack[] | null> {
return $Call.ByID(904105323, playlistID, phantomPath);
}
/**
* GetPlaylistTracks returns all tracks in a playlist with full
* metadata, merging phantom tracks from the M3U8 file.
*/
export function GetPlaylistTracks(playlistID: number): $CancellablePromise<$models.Track[] | null> {
return $Call.ByID(3997481994, playlistID);
}
/**
* GetSmartPlaylistRules returns the raw JSON rule string for an
* existing smart playlist. This is used when the user opens the
* rule editor for an existing smart playlist.
*/
export function GetSmartPlaylistRules(playlistID: number): $CancellablePromise<string> {
return $Call.ByID(997403286, playlistID);
}
/**
* GetSmartPlaylistTracks returns the persisted snapshot of a smart
* playlist as regular playlist tracks (with resolved cover art and
* phantom entries), identical to a normal playlist. If the playlist
* has never been materialized — e.g. it predates snapshot support —
* it is evaluated and stored on first access.
*/
export function GetSmartPlaylistTracks(playlistID: number): $CancellablePromise<$models.Track[] | null> {
return $Call.ByID(2517005223, playlistID);
}
/**
* ImportPlaylist imports a playlist from an external M3U/M3U8
* file. It creates a new playlist in the DB, resolves tracks
* against the library, and saves an M3U8 file.
*/
export function ImportPlaylist(filePath: string): $CancellablePromise<$models.Summary> {
return $Call.ByID(3295216919, filePath);
}
/**
* ImportPlaylists imports multiple playlists from external M3U/M3U8
* files. Each file is imported sequentially using ImportPlaylist.
* Errors from individual imports are collected; partial success is
* possible. Returns the summaries of successfully imported playlists
* and the first error encountered (if any).
*/
export function ImportPlaylists(filePaths: string[] | null): $CancellablePromise<$models.Summary[] | null> {
return $Call.ByID(2505276524, filePaths);
}
/**
* MaterializeUnmaterializedSmartPlaylists evaluates and snapshots any
* smart playlist that has never been materialized (smart_snapshot_at
* IS NULL) — e.g. playlists created before creation-time
* materialization existed. It runs once at startup and is idempotent:
* once every smart playlist has a snapshot it becomes a no-op. Errors
* on individual playlists are logged and skipped so one bad rule set
* doesn't block the rest.
*/
export function MaterializeUnmaterializedSmartPlaylists(): $CancellablePromise<void> {
return $Call.ByID(3018316369);
}
/**
* PreviewSmartPlaylist evaluates a rule set from raw JSON without
* requiring a saved playlist. This powers live preview in the rule
* editor — the frontend sends rules as they are being edited and
* receives matching tracks immediately.
*/
export function PreviewSmartPlaylist(rulesJSON: string): $CancellablePromise<library$0.Track[] | null> {
return $Call.ByID(3006325439, rulesJSON);
}
/**
* RefreshSmartPlaylist re-evaluates a smart playlist's rules against
* the current library and replaces its persisted membership in
* playlist_tracks with the result. This is the only path that
* re-evaluates a smart playlist — opening one otherwise reads the
* stored snapshot. Triggered on rule save and by the manual Refresh
* button.
*/
export function RefreshSmartPlaylist(playlistID: number): $CancellablePromise<void> {
return $Call.ByID(2225480718, playlistID);
}
/**
* RemoveFromDefaultPlaylist removes multiple tracks from the
* default playlist.
*/
export function RemoveFromDefaultPlaylist(filePaths: string[] | null): $CancellablePromise<void> {
return $Call.ByID(619411935, filePaths);
}
/**
* RemovePhantomTracks removes phantom entries from a playlist's
* M3U8 file. Since phantom tracks have no DB rows, only the
* M3U8 file is modified.
*/
export function RemovePhantomTracks(playlistID: number, phantomPaths: string[] | null): $CancellablePromise<void> {
return $Call.ByID(823798913, playlistID, phantomPaths);
}
/**
* RemoveTracksFromPlaylist removes multiple tracks from a playlist
* by their playlist_track IDs.
*/
export function RemoveTracksFromPlaylist(playlistID: number, trackIDs: number[] | null): $CancellablePromise<void> {
return $Call.ByID(2519855654, playlistID, trackIDs);
}
/**
* RenamePlaylist renames a playlist and updates its M3U8 file.
*/
export function RenamePlaylist(playlistID: number, newName: string): $CancellablePromise<void> {
return $Call.ByID(1835045264, playlistID, newName);
}
/**
* RepopulateFromM3U re-imports tracks for playlists that have zero
* playlist_tracks rows but still have a corresponding M3U8 file.
* This recovers from a FullRescan that deleted playlist tracks
* before the ON DELETE SET NULL fix was in place. Each M3U8 entry
* is resolved against the audio_files table; unresolved entries
* become phantom tracks with metadata preserved from the M3U8.
*/
export function RepopulateFromM3U(): $CancellablePromise<void> {
return $Call.ByID(3399290718);
}
/**
* ResolvePhantomTracks replaces phantom entries in a playlist
* with real library tracks. The matches map keys are phantom
* absolute paths and values are resolved absolute paths.
*/
export function ResolvePhantomTracks(playlistID: number, matches: { [_ in string]?: string } | null): $CancellablePromise<void> {
return $Call.ByID(1239473037, playlistID, matches);
}
/**
* ResolvePhantomTracksAfterScan re-links phantom playlist tracks
* whose files now exist in the library. It iterates each playlist
* that has phantoms, reads its M3U8 file, resolves each entry
* against the current audio_files table using multi-root path
* resolution, and updates matching phantom playlist_tracks. This
* handles both pre-existing phantoms (created before migration 7,
* with NULL phantom_file_path) and new ones.
*/
export function ResolvePhantomTracksAfterScan(): $CancellablePromise<void> {
return $Call.ByID(3836161100);
}
/**
* RestoreAllPlaylists restores playlist tracks from M3U8 files.
* This is called after a full library rescan to repopulate
* playlist_tracks from the surviving M3U8 files.
*/
export function RestoreAllPlaylists(): $CancellablePromise<void> {
return $Call.ByID(453794624);
}
/**
* SearchLibrary searches the entire library by a free-text query
* for manual phantom resolution.
*/
export function SearchLibrary(query: string): $CancellablePromise<$models.CandidateTrack[] | null> {
return $Call.ByID(3912116995, query);
}
/**
* SetFavoritesConfig sets the provider used to read and write
* the default-playlist configuration.
*/
export function SetFavoritesConfig(provider: $models.FavoritesConfigProvider): $CancellablePromise<void> {
return $Call.ByID(2117418507, provider);
}
/**
* ToggleDefaultPlaylistTrack adds or removes a single track
* from the default playlist. Returns true if the track is now
* in the playlist (was added), false if it was removed.
*/
export function ToggleDefaultPlaylistTrack(filePath: string): $CancellablePromise<boolean> {
return $Call.ByID(450278306, filePath);
}
/**
* UpdateSmartPlaylistRules updates the rule set for an existing
* smart playlist. Returns an error if the playlist does not exist
* or is not a smart playlist.
*/
export function UpdateSmartPlaylistRules(playlistID: number, rulesJSON: string): $CancellablePromise<void> {
return $Call.ByID(2746792411, playlistID, rulesJSON);
}
@@ -0,0 +1,19 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import * as Queue from "./queue.js";
export {
Queue
};
export {
RepeatMode
} from "./models.js";
export type {
FallbackSource,
Source,
State,
Track,
TrackLoader
} from "./models.js";
@@ -0,0 +1,72 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* 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.
*/
export type FallbackSource = any;
/**
* RepeatMode represents the queue repeat behavior.
*/
export enum RepeatMode {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
/**
* Repeat mode values.
*/
RepeatOff = "off",
RepeatAll = "all",
RepeatOne = "one",
};
/**
* 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).
*/
export interface Source {
"type": string;
"id": number;
"label": string;
}
/**
* State is the full state emitted to the frontend.
*/
export interface State {
"tracks": Track[] | null;
"currentIndex": number;
"shuffleMode": boolean;
"repeatMode": RepeatMode;
"source": Source;
}
/**
* Track represents a track in the queue with its metadata.
*/
export interface Track {
"id": number;
"audioFileId": number;
"filePath": string;
"position": number;
"title": string;
"artist": string;
"album": string;
"coverArtPath": string;
"artistMbid": string;
"releaseGroupMbid": string;
"recordingMbid": string;
}
/**
* TrackLoader is the interface the queue uses to tell the player to load a file.
*/
export type TrackLoader = any;
@@ -0,0 +1,219 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* Queue manages an ordered list of tracks for playback.
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
/**
* AddTrack appends a track to the end of the queue.
* If the queue was empty, it loads the added track in a paused state.
*/
export function AddTrack(filePath: string): $CancellablePromise<void> {
return $Call.ByID(3566864127, filePath);
}
/**
* AddTracks appends multiple tracks to the end of the queue.
* If the queue was empty, it loads the first added track in a paused state.
*/
export function AddTracks(filePaths: string[] | null): $CancellablePromise<void> {
return $Call.ByID(980962916, filePaths);
}
/**
* Clear removes all tracks from the queue, stops playback, and
* resets the queue state. It persists the cleared state and
* notifies the frontend.
*/
export function Clear(): $CancellablePromise<void> {
return $Call.ByID(4166243766);
}
/**
* CompactAfterLibraryRemoval reloads queue state from the database
* after a library removal has cascade-deleted queue_tracks rows.
* It resets currentIndex to 0 (or -1 if empty), clears shuffleOrder,
* unloads the current track if it was removed, and emits QueueChanged.
*/
export function CompactAfterLibraryRemoval(): $CancellablePromise<void> {
return $Call.ByID(3620379645);
}
/**
* CycleRepeat cycles through repeat modes: off -> all -> one -> off.
*/
export function CycleRepeat(): $CancellablePromise<void> {
return $Call.ByID(3510519482);
}
/**
* EmitCurrentState emits the current queue state to the frontend.
* This is called after the frontend DOM is ready.
*/
export function EmitCurrentState(): $CancellablePromise<void> {
return $Call.ByID(1245022280);
}
/**
* GetState returns the current queue state for the frontend.
*/
export function GetState(): $CancellablePromise<$models.State> {
return $Call.ByID(3472178852);
}
/**
* InsertNext inserts a track right after the currently playing track.
* If the queue was empty, it loads the inserted track in a paused state.
*/
export function InsertNext(filePath: string): $CancellablePromise<void> {
return $Call.ByID(1498463573, filePath);
}
/**
* InsertNextTracks inserts multiple tracks as a contiguous block after the current track.
* If the queue was empty, it loads the first inserted track in a paused state.
*/
export function InsertNextTracks(filePaths: string[] | null): $CancellablePromise<void> {
return $Call.ByID(2824705517, filePaths);
}
/**
* InsertTracksAt inserts multiple tracks at the given index.
* If the queue was empty, it loads the first inserted track in a paused state.
*/
export function InsertTracksAt(filePaths: string[] | null, index: number): $CancellablePromise<void> {
return $Call.ByID(2550945669, filePaths, index);
}
/**
* MoveQueueTracks moves tracks at the given indices to a new position
* as a contiguous block. The toIndex is the target position in the
* original (pre-move) array.
*/
export function MoveQueueTracks(fromIndices: number[] | null, toIndex: number): $CancellablePromise<void> {
return $Call.ByID(2149753655, fromIndices, toIndex);
}
/**
* Next advances to the next track. If the player was paused, the next
* track is loaded but not played. In RepeatOne mode, the current track
* is replayed instead of advancing.
*/
export function Next(): $CancellablePromise<void> {
return $Call.ByID(1968784044);
}
/**
* OnPlaybackFinished is called when a track finishes playing naturally.
* This drives the auto-advance behavior and records the play.
*/
export function OnPlaybackFinished(): $CancellablePromise<void> {
return $Call.ByID(2184869763);
}
/**
* Play handles a play request by either resuming the current track or
* starting playback from the beginning of the queue. When a track is
* already active (currentIndex != -1) the player is told to resume;
* otherwise playback starts from the first track (or a random one when
* shuffle is enabled).
*/
export function Play(): $CancellablePromise<void> {
return $Call.ByID(3197379127);
}
/**
* PlayIndex jumps to and plays the track at the given index.
*/
export function PlayIndex(index: number): $CancellablePromise<void> {
return $Call.ByID(200440797, index);
}
/**
* Previous goes to the previous track (or restarts current if >3s in).
* If the player was paused, the track is loaded but not played.
* In RepeatOne mode, the current track is replayed instead of navigating.
*/
export function Previous(): $CancellablePromise<void> {
return $Call.ByID(708701476);
}
/**
* RemoveTrack removes a track at the given position from the queue.
*/
export function RemoveTrack(position: number): $CancellablePromise<void> {
return $Call.ByID(6811080, position);
}
/**
* RemoveTracks removes multiple tracks at the given positions from the queue.
* Positions are deduplicated, validated, and removed in descending order so
* that indices remain stable during removal.
*/
export function RemoveTracks(positions: number[] | null): $CancellablePromise<void> {
return $Call.ByID(1587232097, positions);
}
/**
* RestoreState loads the queue state from the database.
*/
export function RestoreState(): $CancellablePromise<void> {
return $Call.ByID(1652105446);
}
/**
* SaveState persists the queue state to the database. Unlike every
* other write here it waits for the writer: its callers are shutdown
* and the tests, both of which need the row to exist on return.
*/
export function SaveState(): $CancellablePromise<void> {
return $Call.ByID(2913531465);
}
/**
* 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.
*/
export function SetFallbackSource(fs: $models.FallbackSource): $CancellablePromise<void> {
return $Call.ByID(1344903240, fs);
}
/**
* SetPlayer provides the queue with a reference to the player for auto-advance.
*/
export function SetPlayer(player: $models.TrackLoader): $CancellablePromise<void> {
return $Call.ByID(2806250342, player);
}
/**
* 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
* "Play All" type actions where no specific track was selected.
* It uses a two-phase approach: the first batch of tracks (up to
* initialBatchSize) is resolved immediately so playback begins and the
* queue panel is populated without delay. The remaining tracks are then
* resolved in the background. A generation counter ensures stale
* background work is discarded if SetQueue is called again.
*/
export function SetQueue(filePaths: string[] | null, startIndex: number, shuffleStart: boolean, source: $models.Source): $CancellablePromise<void> {
return $Call.ByID(1990450094, filePaths, startIndex, shuffleStart, source);
}
/**
* ToggleShuffle toggles shuffle mode on/off.
*/
export function ToggleShuffle(): $CancellablePromise<void> {
return $Call.ByID(612647562);
}
@@ -0,0 +1,13 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import * as TagWriter from "./tagwriter.js";
export {
TagWriter
};
export type {
BatchFailure,
BatchResult,
TagChanges
} from "./models.js";
@@ -0,0 +1,28 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* BatchFailure records a single track that failed during a batch write.
*/
export interface BatchFailure {
"filePath": string;
"error": string;
}
/**
* BatchResult summarises the outcome of a batch tag write.
*/
export interface BatchResult {
"total": number;
"succeeded": number;
"failed": number;
"cancelled": boolean;
"failures": BatchFailure[] | null;
}
/**
* TagChanges is a diff map of field name → new value. Only changed
* fields are present. Callers specify changed fields; unchanged
* fields are left as-is in the file.
*/
export type TagChanges = { [_ in string]?: any } | null;
@@ -0,0 +1,80 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* TagWriter orchestrates the complete tag writing pipeline:
* file write → DB sync → event emission.
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import { Call as $Call, CancellablePromise as $CancellablePromise } from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as $models from "./models.js";
/**
* BatchWriteTrackTags applies the same TagChanges to every file in
* filePaths. It processes tracks sequentially, emits a
* BatchWriteProgress event after each track, and continues past
* individual failures. Returns a BatchResult summarising outcomes.
*/
export function BatchWriteTrackTags(filePaths: string[] | null, changes: $models.TagChanges): $CancellablePromise<$models.BatchResult> {
return $Call.ByID(2772510056, filePaths, changes);
}
/**
* CancelBatchWrite signals the in-progress batch write to stop after
* the current track completes.
*/
export function CancelBatchWrite(): $CancellablePromise<void> {
return $Call.ByID(3075727604);
}
/**
* WriteTrackTags is the single entry point for writing metadata to
* a track's audio file and synchronising all changes to the
* database. It accepts a track ID (audio_file.id) and a diff map
* of changed fields.
*
* The pipeline:
* 1. Look up track from DB.
* 2. Detect audio format.
* 3. Acquire pipeline lock (mutual exclusion with scan).
* 4. Stop player if this file is currently playing.
* 5. Write tags to file (format-specific writer).
* 6. Sync database (entity relink, FTS5, orphan cleanup).
* 7. Emit TrackMetadataChanged event.
*/
export function WriteTrackTags(trackID: number, changes: $models.TagChanges): $CancellablePromise<void> {
return $Call.ByID(1881385878, trackID, changes);
}
/**
* WriteTrackTagsByPath resolves a file path to its audio_file.id and
* delegates to WriteTrackTags. This is the frontend-facing entry
* point since the frontend identifies tracks by FilePath.
*/
export function WriteTrackTagsByPath(filePath: string, changes: $models.TagChanges): $CancellablePromise<void> {
return $Call.ByID(3526432184, filePath, changes);
}
/**
* WriteUntrackedFileTags writes tags to a file that is not in the
* library, skipping every step of the full pipeline that assumes it is:
* no audio_file lookup, no database sync, no event.
*
* This exists for the download import path, which tags files while they
* are still in the staging directory. Tagging before the move is what
* makes the import atomic from the library's point of view — the
* scanner only ever sees a finished, correctly tagged file, instead of
* ingesting a mislabelled one and being corrected afterwards.
*
* Callers are responsible for ensuring the file is not in a library
* path; using this on a tracked file would leave the database stale.
*/
export function WriteUntrackedFileTags(filePath: string, changes: $models.TagChanges): $CancellablePromise<void> {
return $Call.ByID(3625602876, filePath, changes);
}
@@ -0,0 +1,10 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export {
ColumnID
} from "./models.js";
export type {
Column
} from "./models.js";
@@ -0,0 +1,41 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* Column represents a visible column in the track list.
*/
export interface Column {
"id": ColumnID;
}
/**
* ColumnID identifies a displayable column in the track list.
*/
export enum ColumnID {
/**
* The Go zero value for the underlying type of the enum.
*/
$zero = "",
/**
* Valid column identifiers.
*/
ColTrackName = "trackName",
ColArtistName = "artistName",
ColTrackLength = "trackLength",
ColAlbum = "album",
ColGenre = "genre",
ColYear = "year",
ColComposer = "composer",
ColTrackNumber = "trackNumber",
ColDiscNumber = "discNumber",
ColFilePath = "filePath",
ColFileType = "fileType",
ColSampleRate = "sampleRate",
ColBitDepth = "bitDepth",
ColChannels = "channels",
ColBitrate = "bitrate",
ColFileSize = "fileSize",
ColPlayCount = "playCount",
ColAlbumArt = "albumArt",
};
+3 -3
View File
@@ -39,9 +39,9 @@ import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
import { registerBundledIcons } from './src/icons';
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';
import * as Player from '@go/player/player.js';
import * as Queue from '@go/queue/queue.js';
import { GetDefaultPage } from '@go/config/config.js';
// 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';
+2
View File
@@ -2,12 +2,14 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"build:dev": "vite build --mode development",
"preview": "vite preview"
},
"dependencies": {
"@awesome.me/webawesome": "^3.2.1",
"@lit-labs/signals": "^0.2.0",
"@lit-labs/virtualizer": "^2.1.1",
"@wailsio/runtime": "3.0.0-beta.8",
"lit": "^3.2.1"
},
"devDependencies": {
+8
View File
@@ -17,6 +17,9 @@ importers:
'@lit-labs/virtualizer':
specifier: ^2.1.1
version: 2.1.1
'@wailsio/runtime':
specifier: 3.0.0-beta.8
version: 3.0.0-beta.8
lit:
specifier: ^3.2.1
version: 3.3.2
@@ -543,6 +546,9 @@ packages:
'@vscode/web-custom-data@0.4.13':
resolution: {integrity: sha512-2ZUIRfhofZ/npLlf872EBnPmn27Kt4M2UssmQIfnJvgGgMYZJ5fvtHEDnttBBf2hnVtBgNCqZMVHJA+wsFVqTA==}
'@wailsio/runtime@3.0.0-beta.8':
resolution: {integrity: sha512-c9PZJcOR9z1a6cxtBS2q5cygNajlxDE8Oxv/vvcTFYRbZSoOGBszq4uzlkPTOd0Bot1xkM81jnpaBgKWRpBh2g==}
ajv@8.18.0:
resolution: {integrity: sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==}
@@ -1734,6 +1740,8 @@ snapshots:
'@vscode/web-custom-data@0.4.13': {}
'@wailsio/runtime@3.0.0-beta.8': {}
ajv@8.18.0:
dependencies:
fast-deep-equal: 3.1.3
@@ -4,13 +4,13 @@ import {
property,
state,
} from 'lit/decorators.js';
import { library } from '@go/models';
import * as library from '@go/library/models.js';
import { LibraryController } from '@store/controllers/library-controller';
import {
GetArtistImageURL,
GetArtistImageCachedPath,
GetArtistMBID,
} from '@go/explore/Service';
} from '@go/explore/service.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@components/cover-grid/cover-grid.js';
import { designTokens } from '../../styles/tokens.css';
@@ -14,8 +14,8 @@ import {
GetAlbumsByArtist,
GetAlbumsByArtistByLibrary,
GetFilePathsByAlbums,
} from '@go/library/Library';
import { library } from '@go/models';
} from '@go/library/library.js';
import * as library from '@go/library/models.js';
import { LibraryController } from '@store/controllers/library-controller';
import { libraryStore } from '@store/library-store';
import { SearchController } from '@store/controllers/search-controller';
@@ -36,6 +36,7 @@ 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 '@components/playlist-picker/playlist-picker.js';
import { dict, list } from '@utils/binding';
/** Pixels to change card width per scroll tick. */
const ZOOM_STEP = 16;
@@ -1055,18 +1056,21 @@ export class ArtistsView
const libId =
this.libraryCtrl.selectedLibraryId;
const albums = libId !== null
? await GetAlbumsByArtistByLibrary(
artist.ID,
libId,
)
: await GetAlbumsByArtist(artist.ID);
const albums = await list(
libId !== null
? GetAlbumsByArtistByLibrary(
artist.ID,
libId,
)
: GetAlbumsByArtist(artist.ID),
);
const byAlbum =
await GetFilePathsByAlbums(
const byAlbum = await dict(
GetFilePathsByAlbums(
albums.map((a) => a.ID),
libId ?? 0,
);
),
);
const allPaths: string[] = [];
@@ -17,8 +17,8 @@ import {
ClearCompletedEntries,
SearchCandidates,
SelectSearchCandidate,
} from '@go/autotagservice/Service';
import type { autotagservice } from '@go/models';
} from '@go/autotagservice/service.js';
import type * as autotagservice from '@go/autotagservice/models.js';
import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import { inlineDiff, normalizeStrict, isCosmeticDiff } from '../../utils/text-diff';
@@ -29,6 +29,7 @@ import { nameDialogsIn } from '../../utils/name-dialog';
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
import { confirmAction } from '../confirm-dialog/confirm-dialog';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
import { list } from '@utils/binding';
type PendingItem = autotagservice.PendingItem;
type ScoreView = autotagservice.ScoreView;
@@ -1483,8 +1484,10 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
this.searchError = '';
this.searchRan = true;
try {
this.searchResults = await SearchCandidates(
this.searchKind, query, this.searchArtist.trim(),
this.searchResults = await list(
SearchCandidates(
this.searchKind, query, this.searchArtist.trim(),
),
);
} catch (err) {
console.error('autotag: candidate search failed', err);
@@ -1755,12 +1758,12 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
}
private topScore(): number {
if (!this.score || this.score.candidates.length === 0) return 0;
return this.score.candidates[0]?.score ?? 0;
if (!this.score || (this.score.candidates ?? []).length === 0) return 0;
return (this.score.candidates ?? [])[0]?.score ?? 0;
}
private async onApply(): Promise<void> {
if (!this.current || !this.score || this.score.candidates.length === 0) return;
if (!this.current || !this.score || (this.score.candidates ?? []).length === 0) return;
if (!this.hasLibraryWarningBeenAcked(this.current.libraryId)) {
await this.confirmWarningThenApply();
@@ -1773,7 +1776,7 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
private async executeApply(): Promise<void> {
if (!this.current || !this.score) return;
const cand = this.score.candidates[this.selectedCandidateIdx];
const cand = (this.score.candidates ?? [])[this.selectedCandidateIdx];
if (!cand) {
this.errorMessage = 'No candidate selected.';
return;
@@ -1781,7 +1784,7 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
const groupKey = this.current.groupKey;
const mbid = cand.releaseMbid || cand.releaseGroupMbid;
const total = this.score.localTracks.length;
const total = (this.score.localTracks ?? []).length;
// Pre-mark the folder as running so the sidebar icon flips
// to the progress ring immediately — the Started event will
@@ -1955,16 +1958,16 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
*/
private currentCandidate(): CandidateView | null {
if (!this.score) return null;
return this.score.candidates[this.selectedCandidateIdx] ?? null;
return (this.score.candidates ?? [])[this.selectedCandidateIdx] ?? null;
}
private async selectCandidateByIdx(idx: number): Promise<void> {
if (!this.score || idx < 0 || idx >= this.score.candidates.length) return;
if (!this.score || idx < 0 || idx >= (this.score.candidates ?? []).length) return;
this.selectedCandidateIdx = idx;
// Lazy-load cover art for non-top candidates. The backend
// populates art for the top one eagerly; everything else
// arrives empty until the user picks it.
const cand = this.score.candidates[idx]!;
const cand = (this.score.candidates ?? [])[idx]!;
if (!cand.coverArtUrl) {
try {
const url = await GetCandidateCoverArt(
@@ -2252,7 +2255,7 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
if (!cluster || cluster.candidates.length <= 1) return nothing;
const editions = cluster.candidates
.map((c) => ({ cand: c, idx: this.score?.candidates.indexOf(c) ?? -1 }))
.map((c) => ({ cand: c, idx: (this.score?.candidates ?? []).indexOf(c) ?? -1 }))
.filter((e) => e.idx >= 0);
return html`
@@ -2316,11 +2319,11 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
// the score forgives, the UI mutes.
const SUBTLE_LENGTH_MAX_MS = 5000;
for (const a of cand.alignments) {
for (const a of (cand.alignments ?? [])) {
if (a.status === 'matched' || a.status === 'mismatched') {
paired++;
const local = a.localIndex >= 0
? this.score?.localTracks[a.localIndex] ?? null
? (this.score?.localTracks ?? [])[a.localIndex] ?? null
: null;
if (local) {
if (local.title !== a.candidateTitle) {
@@ -2374,7 +2377,7 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
missingTitles.push(a.candidateTitle || '(untitled)');
} else if (a.status === 'unmatched') {
const local = a.localIndex >= 0
? this.score?.localTracks[a.localIndex] ?? null
? (this.score?.localTracks ?? [])[a.localIndex] ?? null
: null;
extraTitles.push(a.localTitle || local?.title || '(untitled)');
}
@@ -2659,7 +2662,7 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
}
private renderLowConfidenceBanner(clusters: VersionCluster[]) {
const top = this.score?.candidates[0];
const top = (this.score?.candidates ?? [])[0];
if (!top) return nothing;
if (top.score >= LOW_CONFIDENCE_THRESHOLD) return nothing;
if (clusters.length < 2) return nothing;
@@ -2695,18 +2698,18 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
* renderMatchDetails, so each column shows its own values plainly.
*/
private renderComparison(cand: CandidateView, clusters: VersionCluster[] = []) {
const locals = this.score?.localTracks ?? [];
const locals = (this.score?.localTracks ?? []) ?? [];
// localIndex -> its alignment, so a folder row knows whether it
// paired and (if so) how confidently.
const alignByLocal = new Map<number, AlignmentView>();
for (const a of cand.alignments) {
for (const a of (cand.alignments ?? [])) {
if (a.localIndex >= 0) alignByLocal.set(a.localIndex, a);
}
// Candidate side: every alignment that has a candidate track
// (paired or missing-from-folder), in candidate order.
const candRows = cand.alignments
const candRows = (cand.alignments ?? [])
.filter((a) => a.status !== 'unmatched' && a.candidatePosition > 0)
.slice()
.sort((x, y) =>
@@ -2856,7 +2859,7 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
${clusters.map((cluster) => {
const best = cluster.candidates[cluster.bestIdx]!;
const active = cluster.candidates.includes(activeCand);
const idx = this.score?.candidates.indexOf(best) ?? -1;
const idx = (this.score?.candidates ?? []).indexOf(best) ?? -1;
return html`
<div class="cand-chip ${active ? 'selected' : ''}"
title=${cluster.label}
@@ -2949,7 +2952,7 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
`;
}
if (!this.score || this.score.candidates.length === 0) {
if (!this.score || (this.score.candidates ?? []).length === 0) {
return html`
<div class="main">
<div class="empty">
@@ -2965,7 +2968,7 @@ export class AutotagView extends ViewLifecycleMixin(LitElement) {
return html`<div class="main"><div class="empty">Candidate index out of range.</div></div>`;
}
const clusters = this.clusterVersions(this.score.candidates);
const clusters = this.clusterVersions((this.score.candidates ?? []));
return html`
<div class="main">
@@ -2,14 +2,14 @@ import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { EventsOn } from '@runtime/runtime';
import type { explore } from '@go/models';
import type * as explore from '@go/explore/models.js';
import {
AddLibrary,
RenameLibrary,
RemoveLibrary,
GetRemovalImpact,
GetAllLibrariesWithTrackCounts,
} from '@go/library/Library';
} from '@go/library/library.js';
import {
GetScanConcurrency,
SetScanConcurrency,
@@ -17,17 +17,17 @@ import {
SetDefaultPage,
GetQueueFallback,
SetQueueFallback,
} from '@go/config/Config';
import { GetIndexStatus } from '@go/explore/Service';
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
} from '@go/config/config.js';
import { GetIndexStatus } from '@go/explore/service.js';
import { DirectoryPicker } from '@go/frontendutil/frontendutil.js';
import { notificationStore } from '@store/notification-store';
import { describeError, explainError } from '@utils/describe-error';
import type { library } from '@go/models';
import type * as library from '@go/library/models.js';
import { ThemeController } from '@store/controllers/theme-controller';
import { TrackListController } from '@store/controllers/tracklist-controller';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { GetAllPlaylists } from '@go/playlist/Service';
import type { playlist } from '@go/models';
import { GetAllPlaylists } from '@go/playlist/service.js';
import type * as playlist from '@go/playlist/models.js';
import { Events } from '../../events';
import {
SHORTCUT_CATEGORIES,
@@ -49,6 +49,7 @@ import './shortcut-capture';
import { confirmAction } from '../confirm-dialog/confirm-dialog';
import { shortcutsStore } from '../../store/shortcuts-store';
import { ShortcutsController } from '../../store/controllers/shortcuts-controller';
import { list } from '@utils/binding';
const SCROLL_STORAGE_KEY = 'yj-now-playing-scroll-mode';
const SCROLL_CHANGE_EVENT = 'yj-scroll-mode-changed';
@@ -1012,9 +1013,9 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
const ok = await confirmAction({
title: 'Remove library',
message: `Remove “${libName}”?`,
impact: `This deletes ${impact.trackCount} tracks, affects `
+ `${impact.playlistsAffected} playlists and removes `
+ `${impact.queueItemCount} queue items.`,
impact: `This deletes ${impact?.trackCount ?? 0} tracks, affects `
+ `${impact?.playlistsAffected ?? 0} playlists and removes `
+ `${impact?.queueItemCount ?? 0} queue items.`,
confirmLabel: 'Remove',
danger: true,
});
@@ -1187,8 +1188,7 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
private async loadPlaylists(): Promise<void> {
try {
this.playlists =
await GetAllPlaylists();
this.playlists = await list(GetAllPlaylists());
} catch (err) {
console.error(
'Failed to load playlists:',
@@ -1456,10 +1456,10 @@ export class ConfigPage extends ViewLifecycleMixin(LitElement) {
<span class="index-stat">updated ${this.timeAgo(s.lastBuilt)}</span>`
: nothing}
</div>
${s.tiers?.length > 0 && s.tiers.some((t) => t.state === 'running' || t.state === 'pending' || t.state === 'error')
${(s.tiers?.length ?? 0) > 0 && (s.tiers ?? []).some((t) => t.state === 'running' || t.state === 'pending' || t.state === 'error')
? html`
<div class="index-tiers">
${s.tiers.map(
${(s.tiers ?? []).map(
(t) => html`
<div class="index-tier">
<span class="tier-icon">${this.tierIcon(t.state)}</span>
@@ -14,10 +14,12 @@ import type {
ProviderField,
} from '@store/download-store';
import { downloadStore } from '@store/download-store';
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
import { GetDownloadPreferences, SetDownloadPreferences } from '@go/config/Config';
import { SetPreferences } from '@go/download/Service';
import type { download } from '@go/models';
import { DirectoryPicker } from '@go/frontendutil/frontendutil.js';
import { GetDownloadPreferences, SetDownloadPreferences } from '@go/config/config.js';
import { SetPreferences } from '@go/download/service.js';
import type * as download from '@go/download/models.js';
import { Format } from '@go/download/models.js';
import { compact } from '@utils/binding';
import { describeError, explainError } from '@utils/describe-error';
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
import './config-section';
@@ -28,15 +30,15 @@ import './config-section';
* deliberately excluded — it names "no format detected", not a format a
* user could opt into.
*/
const AUTO_DOWNLOAD_FORMATS: { value: string; label: string }[] = [
{ value: 'flac', label: 'FLAC' },
{ value: 'alac', label: 'ALAC' },
{ value: 'wav', label: 'WAV' },
{ value: 'mp3', label: 'MP3' },
{ value: 'aac', label: 'AAC' },
{ value: 'ogg', label: 'OGG' },
{ value: 'opus', label: 'Opus' },
{ value: 'wma', label: 'WMA' },
const AUTO_DOWNLOAD_FORMATS: { value: Format; label: string }[] = [
{ value: Format.FormatFLAC, label: 'FLAC' },
{ value: Format.FormatALAC, label: 'ALAC' },
{ value: Format.FormatWAV, label: 'WAV' },
{ value: Format.FormatMP3, label: 'MP3' },
{ value: Format.FormatAAC, label: 'AAC' },
{ value: Format.FormatOGG, label: 'OGG' },
{ value: Format.FormatOpus, label: 'Opus' },
{ value: Format.FormatWMA, label: 'WMA' },
];
/**
@@ -607,7 +609,7 @@ export class DownloadClients extends LitElement {
// Secrets are never sent back to the frontend, so their fields
// start blank; a blank secret on save means "leave it alone"
// rather than "clear it".
this.draft = { ...(provider.settings ?? {}) };
this.draft = compact(provider.settings);
}
private cancelEdit = () => {
@@ -748,7 +750,7 @@ export class DownloadClients extends LitElement {
}
}
private toggleFormat(format: string, checked: boolean): void {
private toggleFormat(format: Format, checked: boolean): void {
const current = this.prefs.allowedFormats ?? [];
const allowedFormats = checked
? [...current, format]
@@ -1,7 +1,7 @@
import { LitElement, html, svg, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
import type { library } from '@go/models';
import type * as library from '@go/library/models.js';
import { PlayerController } from '@store/controllers/player-controller';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { formatMilliseconds } from '@utils/time';
@@ -2,9 +2,10 @@ import {
GetAlbumTracks,
GetAlbumTracksByLibrary,
GetFilePathsByAlbums,
} from '@go/library/Library';
} from '@go/library/library.js';
import { libraryStore } from '@store/library-store';
import type { library } from '@go/models';
import { dict, list } from '@utils/binding';
import type * as library from '@go/library/models.js';
import type { CoverArtUrls } from '@components/track-details/track-details.js';
/**
@@ -58,9 +59,11 @@ export class AlbumSelectionManager {
const libId =
libraryStore.getSelectedLibraryId();
return libId !== null
? GetAlbumTracksByLibrary(albumId, libId)
: GetAlbumTracks(albumId);
return list(
libId !== null
? GetAlbumTracksByLibrary(albumId, libId)
: GetAlbumTracks(albumId),
);
}
/**
@@ -85,7 +88,7 @@ export class AlbumSelectionManager {
const libId =
libraryStore.getSelectedLibraryId();
return GetFilePathsByAlbums(ids, libId ?? 0);
return dict(GetFilePathsByAlbums(ids, libId ?? 0));
}
// ================================================================
@@ -1,4 +1,4 @@
import type { library } from '@go/models';
import type * as library from '@go/library/models.js';
/**
* Discriminated context menu target so we know whether the
@@ -14,8 +14,8 @@ import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
import {
GetAlbumTracks,
GetAlbumTracksByLibrary,
} from '@go/library/Library';
import { library } from '@go/models';
} from '@go/library/library.js';
import * as library from '@go/library/models.js';
import { LibraryController } from '@store/controllers/library-controller';
import { SearchController } from '@store/controllers/search-controller';
import { ViewLifecycleMixin } from '@utils/view-lifecycle';
@@ -74,6 +74,7 @@ import type {
GridEntry,
SortDirection,
} from './cover-grid-types.js';
import { list } from '@utils/binding';
@customElement('cover-grid')
export class CoverGrid
@@ -933,12 +934,14 @@ export class CoverGrid
const libId =
this.libraryCtrl.selectedLibraryId;
const tracks = libId !== null
? await GetAlbumTracksByLibrary(
album.ID,
libId,
)
: await GetAlbumTracks(album.ID);
const tracks = await list(
libId !== null
? GetAlbumTracksByLibrary(
album.ID,
libId,
)
: GetAlbumTracks(album.ID),
);
if (this.expandedAlbumId === album.ID) {
this.expandedTracks = tracks;
@@ -1,6 +1,6 @@
import type { LitElement } from 'lit';
import type { LitVirtualizer } from '@lit-labs/virtualizer';
import type { library } from '@go/models';
import type * as library from '@go/library/models.js';
import type { LibraryController } from '@store/controllers/library-controller';
import type { GridEntry } from './cover-grid-types.js';
@@ -7,7 +7,7 @@ import '@awesome.me/webawesome/dist/components/callout/callout.js';
import { designTokens } from '../../styles/tokens.css';
import type { DownloadCandidate } from '@store/download-store';
import { downloadStore } from '@store/download-store';
import type { download } from '@go/models';
import type * as download from '@go/download/models.js';
import './candidate-row';
import { explainError } from '@utils/describe-error';
import { nameDialogsIn } from '@utils/name-dialog';
@@ -686,7 +686,7 @@ export class DownloadsView extends ViewLifecycleMixin(LitElement) {
/** Provider/progress summary for a download's second line. */
private downloadDetail(view: DownloadRecord): string {
const providers = [
...new Set(view.items.map((item) => item.candidate?.origin).filter(Boolean)),
...new Set((view.items ?? []).map((item) => item.candidate?.origin).filter(Boolean)),
];
const parts: string[] = [];
@@ -3,7 +3,7 @@ import { customElement, state, query } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '@awesome.me/webawesome/dist/components/switch/switch.js';
import { AddTracksToPlaylist } from '@go/playlist/Service';
import { AddTracksToPlaylist } from '@go/playlist/service.js';
import { formatMilliseconds } from '@utils/time';
import { nameDialogsIn } from '@utils/name-dialog';
@@ -6,15 +6,16 @@ import {
LookupReleaseGroup,
BrowseReleases,
GetThumbnail,
} from '@go/explore/Service';
} from '@go/explore/service.js';
import {
GetAlbumTracks,
GetAlbumCompleteness,
GetFilePathsByAlbums,
GetFilePathsByRecordingMBIDs,
} from '@go/library/Library';
import { library } from '@go/models';
import type { download, explore } from '@go/models';
} from '@go/library/library.js';
import * as library from '@go/library/models.js';
import type * as download from '@go/download/models.js';
import type * as explore from '@go/explore/models.js';
type MBReleaseGroup = explore.MBReleaseGroup;
type MBRelease = explore.MBRelease;
type MBTrack = explore.MBTrack;
@@ -46,6 +47,7 @@ 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';
import { dict, dictByName } from '@utils/binding';
/**
* The region the album header's own failures are rendered in.
@@ -2248,9 +2250,8 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
// to an empty set and the Play button silently does nothing.
// That is exactly what the first version of this did.
if (this.localAlbumId > 0) {
const byAlbum = await GetFilePathsByAlbums(
[this.localAlbumId],
libraryID,
const byAlbum = await dict(
GetFilePathsByAlbums([this.localAlbumId], libraryID),
);
return byAlbum[this.localAlbumId] ?? [];
@@ -2266,7 +2267,9 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
if (mbids.length === 0) return [];
const byMBID = await GetFilePathsByRecordingMBIDs(mbids, libraryID);
const byMBID = await dictByName(
GetFilePathsByRecordingMBIDs(mbids, libraryID),
);
// Walked in tracklist order rather than flattened, because the
// grouping is what lets the caller keep its own order. A
@@ -2349,7 +2352,9 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
if (!track.inLibrary || !track.mbid) return null;
const libraryID = libraryStore.getSelectedLibraryId() ?? 0;
const byMBID = await GetFilePathsByRecordingMBIDs([track.mbid], libraryID);
const byMBID = await dictByName(
GetFilePathsByRecordingMBIDs([track.mbid], libraryID),
);
return byMBID[track.mbid]?.[0] ?? null;
}

Some files were not shown because too many files have changed in this diff Show More