Compare commits
232
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7be4a02e31 | ||
|
|
ad9c25a5a2 | ||
|
|
a83a127e31 | ||
|
|
e049a71458 | ||
|
|
0c944f2382 | ||
|
|
75525b67e4 | ||
|
|
85768dc489 | ||
|
|
1a221a40d3 | ||
|
|
0821deb877 | ||
|
|
31ada14111 | ||
|
|
20139394f3 | ||
|
|
eb139cf872 | ||
|
|
ae82fd2233 | ||
|
|
3c3197df4b | ||
|
|
e16bd245bd | ||
|
|
887a9324b4 | ||
|
|
fcb484ead5 | ||
|
|
48de41cd69 | ||
|
|
66a6ee63ab | ||
|
|
10660c8168 | ||
|
|
441b67daaa | ||
|
|
026f26bdf6 | ||
|
|
73dc80bdc9 | ||
|
|
760021ea5a | ||
|
|
63ec068add | ||
|
|
a2ff0aed4c | ||
|
|
12e75ee24c | ||
|
|
792e87298b | ||
|
|
266e7032dd | ||
|
|
d6b48fb3ac | ||
|
|
3bf27e3fd5 | ||
|
|
185eb1b125 | ||
|
|
b3556d825c | ||
|
|
bf4f352117 | ||
|
|
1062b7c0bc | ||
|
|
48abecb830 | ||
|
|
e1c07438e9 | ||
|
|
6e563f3846 | ||
|
|
186f6a5839 | ||
|
|
590a0d86dd | ||
|
|
36af7090d9 | ||
|
|
3e142f8c35 | ||
|
|
3d375adab1 | ||
|
|
e3d492e130 | ||
|
|
e6f30b6e43 | ||
|
|
351798fd66 | ||
|
|
40984f6086 | ||
|
|
786d9c6110 | ||
|
|
0019310ca4 | ||
|
|
1940cb548f | ||
|
|
37e3373db9 | ||
|
|
8d5d8af297 | ||
|
|
9ce79ee416 | ||
|
|
b3a0814f24 | ||
|
|
2c576fa1e8 | ||
|
|
544dbdb4db | ||
|
|
087eb77875 | ||
|
|
6fb7b5ea11 | ||
|
|
369810e06b | ||
|
|
e51cb13662 | ||
|
|
3d65da0529 | ||
|
|
52cbef27c4 | ||
|
|
c03c0b8ec4 | ||
|
|
8c48105ca3 | ||
|
|
1c4d6ca9a1 | ||
|
|
6bf832a4ba | ||
|
|
4f8257ef72 | ||
|
|
b505959934 | ||
|
|
d0250a2133 | ||
|
|
de2b324e20 | ||
|
|
2c78b58207 | ||
|
|
a9852c18a0 | ||
|
|
409bfd5e89 | ||
|
|
0eeef6048e | ||
|
|
4fc0cdeab7 | ||
|
|
eb059a3d71 | ||
|
|
dcabec8b1d | ||
|
|
b3737d30af | ||
|
|
0bfa2136be | ||
|
|
b1cdef8769 | ||
|
|
d661836347 | ||
|
|
28eecf0a97 | ||
|
|
e8690476bd | ||
|
|
7e0be8fa30 | ||
|
|
1b05dde382 | ||
|
|
29299d17da | ||
|
|
57fbbdf0d2 | ||
|
|
df2e9ea777 | ||
|
|
c99c8efa11 | ||
|
|
904786b941 | ||
|
|
b6651310ea | ||
|
|
da38b865fc | ||
|
|
ced537ecf2 | ||
|
|
e14a34fccf | ||
|
|
78576b8da9 | ||
|
|
01706c6053 | ||
|
|
f7dc76c955 | ||
|
|
ed975019dc | ||
|
|
0c7f34ab90 | ||
|
|
a7a33527c4 | ||
|
|
0c6ca72cf1 | ||
|
|
68468e5378 | ||
|
|
6fbb62730d | ||
|
|
48b37f6301 | ||
|
|
66182f82cd | ||
|
|
18aba34c08 | ||
|
|
b98840ee37 | ||
|
|
dd17a4d8eb | ||
|
|
e7748f1fd5 | ||
|
|
1128881e8d | ||
|
|
cad3d1339b | ||
|
|
453d5df0da | ||
|
|
84963e38bd | ||
|
|
deb3f3da7e | ||
|
|
60779c41c3 | ||
|
|
a4ada725a2 | ||
|
|
04114eabae | ||
|
|
162c68769f | ||
|
|
c9905fbcff | ||
|
|
4471db3aef | ||
|
|
f47b2db308 | ||
|
|
e7873bded3 | ||
|
|
edb13a6f39 | ||
|
|
20fbf28f2a | ||
|
|
878cf4b561 | ||
|
|
dc890d1fcc | ||
|
|
dcc40b1781 | ||
|
|
c94c97f604 | ||
|
|
4801ba4480 | ||
|
|
40bc968cf8 | ||
|
|
e61b7456df | ||
|
|
979c6e83ed | ||
|
|
48f7795687 | ||
|
|
c400f681c2 | ||
|
|
451b46e63c | ||
|
|
d33dfb2264 | ||
|
|
41a4dd7148 | ||
|
|
6d97e3c872 | ||
|
|
acbe7c4676 | ||
|
|
91bab4e73e | ||
|
|
f1c46b6a8e | ||
|
|
4efd17d477 | ||
|
|
254646da5e | ||
|
|
9d420cda0a | ||
|
|
2b41c27616 | ||
|
|
f00d0c4655 | ||
|
|
b7831e3f15 | ||
|
|
7410109884 | ||
|
|
0b7ffd5679 | ||
|
|
49b1194333 | ||
|
|
fd32ce71d2 | ||
|
|
533c084f8a | ||
|
|
31144e5dc7 | ||
|
|
8af26fee94 | ||
|
|
6d0e46d537 | ||
|
|
11b4aaef6a | ||
|
|
0a0da0c19c | ||
|
|
1e4a4e6f8e | ||
|
|
cad673ee3d | ||
|
|
65c1b4fd53 | ||
|
|
dddf54ba0c | ||
|
|
f5621bf7c5 | ||
|
|
f854076d95 | ||
|
|
71324b561a | ||
|
|
287b6445fa | ||
|
|
d681a7223e | ||
|
|
0a25bca128 | ||
|
|
2c460bbcb7 | ||
|
|
425dd7c158 | ||
|
|
bddfd37a5c | ||
|
|
1aa1598ecb | ||
|
|
4615afe7f7 | ||
|
|
5111a6c8ab | ||
|
|
24887d6840 | ||
|
|
a150b24e71 | ||
|
|
63d11c3f9c | ||
|
|
862e8a0468 | ||
|
|
c13a920487 | ||
|
|
a3b35a4dab | ||
|
|
1ed4167634 | ||
|
|
7912cdf23f | ||
|
|
9f03b3ff94 | ||
|
|
3269da3e92 | ||
|
|
b7dc368d7c | ||
|
|
cee19d7ef9 | ||
|
|
e9ca16362f | ||
|
|
9e92721bb7 | ||
|
|
1ac919d228 | ||
|
|
5830b1ba17 | ||
|
|
2518385330 | ||
|
|
c8bc6db9fa | ||
|
|
559e1ed077 | ||
|
|
4ae6e13391 | ||
|
|
7d9e0bf2fb | ||
|
|
795f40acee | ||
|
|
5fb9a0d246 | ||
|
|
ca0f724e20 | ||
|
|
fbf1eff8f6 | ||
|
|
7acb197daf | ||
|
|
69ad558a44 | ||
|
|
9e0e4d5bb8 | ||
|
|
0cf710cf47 | ||
|
|
a37acfcf84 | ||
|
|
952c25c3d3 | ||
|
|
1d335c5180 | ||
|
|
df11ef23f4 | ||
|
|
55aa3ea5b0 | ||
|
|
fcf2fe509e | ||
|
|
da564f9659 | ||
|
|
7de1b4edc1 | ||
|
|
ff687f0bd9 | ||
|
|
62bb40fc4d | ||
|
|
ba35858208 | ||
|
|
7c3c0e25b9 | ||
|
|
0ca37a31a6 | ||
|
|
c48123f7a3 | ||
|
|
213640c9a8 | ||
|
|
ccacd67a21 | ||
|
|
5ca6cad45a | ||
|
|
65333857e2 | ||
|
|
cbd82a5a74 | ||
|
|
e190fd75b9 | ||
|
|
d0d86f85d5 | ||
|
|
e7950006c5 | ||
|
|
01bc5f2094 | ||
|
|
f15846f8fb | ||
|
|
aead8eaef4 | ||
|
|
0001135f3a | ||
|
|
08da4f2774 | ||
|
|
d3fc2b9237 | ||
|
|
a181a98ce3 | ||
|
|
16886c92cf |
@@ -0,0 +1,420 @@
|
||||
---
|
||||
name: playwright-cli
|
||||
description: Automate browser interactions, test web pages and work with Playwright tests.
|
||||
allowed-tools: Bash(playwright-cli:*) Bash(npx:*) Bash(npm:*)
|
||||
---
|
||||
|
||||
# Browser Automation with playwright-cli
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# open new browser
|
||||
playwright-cli open
|
||||
# navigate to a page
|
||||
playwright-cli goto https://playwright.dev
|
||||
# interact with the page using refs from the snapshot
|
||||
playwright-cli click e15
|
||||
playwright-cli type "page.click"
|
||||
playwright-cli press Enter
|
||||
# take a screenshot (rarely used, as snapshot is more common)
|
||||
playwright-cli screenshot
|
||||
# close the browser
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Core
|
||||
|
||||
```bash
|
||||
playwright-cli open
|
||||
# open and navigate right away
|
||||
playwright-cli open https://example.com/
|
||||
playwright-cli goto https://playwright.dev
|
||||
playwright-cli type "search query"
|
||||
playwright-cli click e3
|
||||
playwright-cli dblclick e7
|
||||
# --submit presses Enter after filling the element
|
||||
playwright-cli fill e5 "user@example.com" --submit
|
||||
playwright-cli drag e2 e8
|
||||
# drop files or data onto an element (from outside the page)
|
||||
playwright-cli drop e4 --path=./image.png
|
||||
playwright-cli drop e4 --data="text/plain=hello world"
|
||||
playwright-cli hover e4
|
||||
playwright-cli select e9 "option-value"
|
||||
playwright-cli upload ./document.pdf
|
||||
playwright-cli check e12
|
||||
playwright-cli uncheck e12
|
||||
playwright-cli snapshot
|
||||
# search the snapshot for text or a regexp, returns matching nodes with surrounding context
|
||||
playwright-cli find "Sign in"
|
||||
playwright-cli find --regex "Sign (in|up)"
|
||||
# wrap the regexp in slashes to add flags, e.g. /i for case-insensitive
|
||||
playwright-cli find --regex "/sign (in|up)/i"
|
||||
playwright-cli eval "document.title"
|
||||
playwright-cli eval "el => el.textContent" e5
|
||||
# get element id, class, or any attribute not visible in the snapshot
|
||||
playwright-cli eval "el => el.id" e5
|
||||
playwright-cli eval "el => el.getAttribute('data-testid')" e5
|
||||
playwright-cli dialog-accept
|
||||
playwright-cli dialog-accept "confirmation text"
|
||||
playwright-cli dialog-dismiss
|
||||
playwright-cli resize 1920 1080
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
### Navigation
|
||||
|
||||
```bash
|
||||
playwright-cli go-back
|
||||
playwright-cli go-forward
|
||||
playwright-cli reload
|
||||
```
|
||||
|
||||
### Keyboard
|
||||
|
||||
```bash
|
||||
playwright-cli press Enter
|
||||
playwright-cli press ArrowDown
|
||||
playwright-cli keydown Shift
|
||||
playwright-cli keyup Shift
|
||||
```
|
||||
|
||||
### Mouse
|
||||
|
||||
```bash
|
||||
playwright-cli mousemove 150 300
|
||||
playwright-cli mousedown
|
||||
playwright-cli mousedown right
|
||||
playwright-cli mouseup
|
||||
playwright-cli mouseup right
|
||||
playwright-cli mousewheel 0 100
|
||||
```
|
||||
|
||||
### Save as
|
||||
|
||||
```bash
|
||||
playwright-cli screenshot
|
||||
playwright-cli screenshot e5
|
||||
playwright-cli screenshot --filename=page.png
|
||||
playwright-cli screenshot --hires
|
||||
playwright-cli pdf --filename=page.pdf
|
||||
```
|
||||
|
||||
### Tabs
|
||||
|
||||
```bash
|
||||
playwright-cli tab-list
|
||||
playwright-cli tab-new
|
||||
playwright-cli tab-new https://example.com/page
|
||||
playwright-cli tab-close
|
||||
playwright-cli tab-close 2
|
||||
playwright-cli tab-select 0
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
```bash
|
||||
playwright-cli state-save
|
||||
playwright-cli state-save auth.json
|
||||
playwright-cli state-load auth.json
|
||||
|
||||
# Cookies
|
||||
playwright-cli cookie-list
|
||||
playwright-cli cookie-list --domain=example.com
|
||||
playwright-cli cookie-get session_id
|
||||
playwright-cli cookie-set session_id abc123
|
||||
playwright-cli cookie-set session_id abc123 --domain=example.com --httpOnly --secure
|
||||
playwright-cli cookie-delete session_id
|
||||
playwright-cli cookie-clear
|
||||
|
||||
# LocalStorage
|
||||
playwright-cli localstorage-list
|
||||
playwright-cli localstorage-get theme
|
||||
playwright-cli localstorage-set theme dark
|
||||
playwright-cli localstorage-delete theme
|
||||
playwright-cli localstorage-clear
|
||||
|
||||
# SessionStorage
|
||||
playwright-cli sessionstorage-list
|
||||
playwright-cli sessionstorage-get step
|
||||
playwright-cli sessionstorage-set step 3
|
||||
playwright-cli sessionstorage-delete step
|
||||
playwright-cli sessionstorage-clear
|
||||
```
|
||||
|
||||
### Network
|
||||
|
||||
```bash
|
||||
playwright-cli route "**/*.jpg" --status=404
|
||||
playwright-cli route "https://api.example.com/**" --body='{"mock": true}'
|
||||
playwright-cli route-list
|
||||
playwright-cli unroute "**/*.jpg"
|
||||
playwright-cli unroute
|
||||
```
|
||||
|
||||
### DevTools
|
||||
|
||||
```bash
|
||||
playwright-cli console
|
||||
playwright-cli console warning
|
||||
playwright-cli requests
|
||||
playwright-cli request 5
|
||||
playwright-cli run-code "async page => await page.context().grantPermissions(['geolocation'])"
|
||||
playwright-cli run-code --filename=script.js
|
||||
playwright-cli tracing-start
|
||||
playwright-cli tracing-stop
|
||||
playwright-cli video-start video.webm
|
||||
playwright-cli video-chapter "Chapter Title" --description="Details" --duration=2000
|
||||
playwright-cli video-stop
|
||||
|
||||
# annotate each subsequent action (click, type, ...) with a callout naming the action and highlighting the target
|
||||
playwright-cli video-show-actions --duration=600 --position=top-right
|
||||
playwright-cli video-hide-actions
|
||||
|
||||
# launch the dashboard for UI review / design feedback — user annotates the page, you receive the annotated screenshot, snapshot, and notes
|
||||
playwright-cli show --annotate
|
||||
|
||||
# generate a Playwright locator for an element from its ref or selector
|
||||
playwright-cli generate-locator e5 --raw
|
||||
|
||||
# show a persistent highlight overlay for an element, optionally with a custom style
|
||||
playwright-cli highlight e5
|
||||
playwright-cli highlight e5 --style="outline: 3px dashed red"
|
||||
# hide a single element highlight, or all page highlights when no target is given
|
||||
playwright-cli highlight e5 --hide
|
||||
playwright-cli highlight --hide
|
||||
```
|
||||
|
||||
## Raw output
|
||||
|
||||
The global `--raw` option strips page status, generated code, and snapshot sections from the output, returning only the result value. Use it to pipe command output into other tools. Commands that don't produce output return nothing.
|
||||
|
||||
```bash
|
||||
playwright-cli --raw eval "JSON.stringify(performance.timing)" | jq '.loadEventEnd - .navigationStart'
|
||||
playwright-cli --raw eval "JSON.stringify([...document.querySelectorAll('a')].map(a => a.href))" > links.json
|
||||
playwright-cli --raw snapshot > before.yml
|
||||
playwright-cli click e5
|
||||
playwright-cli --raw snapshot > after.yml
|
||||
diff before.yml after.yml
|
||||
TOKEN=$(playwright-cli --raw cookie-get session_id)
|
||||
playwright-cli --raw localstorage-get theme
|
||||
```
|
||||
|
||||
For structured output wrapping every reply as JSON, pass --json
|
||||
```bash
|
||||
playwright-cli list --json
|
||||
```
|
||||
|
||||
## Open parameters
|
||||
```bash
|
||||
# Use specific browser when creating session
|
||||
playwright-cli open --browser=chrome
|
||||
playwright-cli open --browser=firefox
|
||||
playwright-cli open --browser=webkit
|
||||
playwright-cli open --browser=msedge
|
||||
|
||||
# Emulate a generic mobile device (Pixel 10 for Chromium, iPhone 17 for WebKit).
|
||||
# Prefer this when a mobile layout is acceptable: mobile pages are usually
|
||||
# lighter, so snapshots are smaller and cheaper.
|
||||
playwright-cli open --mobile
|
||||
playwright-cli open --device="iPhone 15"
|
||||
|
||||
# Use persistent profile (by default profile is in-memory)
|
||||
playwright-cli open --persistent
|
||||
# Use persistent profile with custom directory
|
||||
playwright-cli open --profile=/path/to/profile
|
||||
|
||||
# Connect to browser via Playwright Extension
|
||||
playwright-cli attach --extension=chrome
|
||||
|
||||
# Connect to a running Chrome or Edge by channel name
|
||||
playwright-cli attach --cdp=chrome
|
||||
playwright-cli attach --cdp=msedge
|
||||
|
||||
# Connect to a running browser via CDP endpoint
|
||||
playwright-cli attach --cdp=http://localhost:9222
|
||||
|
||||
# Start with config file
|
||||
playwright-cli open --config=my-config.json
|
||||
|
||||
# Close the browser
|
||||
playwright-cli close
|
||||
# Detach from an attached browser (leaves the external browser running)
|
||||
playwright-cli -s=msedge detach
|
||||
# Delete user data for the default session
|
||||
playwright-cli delete-data
|
||||
```
|
||||
|
||||
## URLs with `&` on Windows
|
||||
|
||||
On Windows, `cmd.exe` and PowerShell treat `&` as a command separator, so URLs with multiple query parameters get truncated before `playwright-cli` runs. Escape `&` with `^&` in `cmd.exe`, or use `--%` in PowerShell:
|
||||
|
||||
```batch
|
||||
playwright-cli goto "https://example.com/?a=1^&b=2"
|
||||
```
|
||||
|
||||
```powershell
|
||||
playwright-cli --% goto "https://example.com/?a=1&b=2"
|
||||
```
|
||||
|
||||
## Snapshots
|
||||
|
||||
After each command, playwright-cli provides a snapshot of the current browser state.
|
||||
|
||||
```bash
|
||||
> playwright-cli goto https://example.com
|
||||
### Page
|
||||
- Page URL: https://example.com/
|
||||
- Page Title: Example Domain
|
||||
### Snapshot
|
||||
[Snapshot](.playwright-cli/page-2026-02-14T19-22-42-679Z.yml)
|
||||
```
|
||||
|
||||
You can also take a snapshot on demand using `playwright-cli snapshot` command. All the options below can be combined as needed.
|
||||
|
||||
```bash
|
||||
# default - save to a file with timestamp-based name
|
||||
playwright-cli snapshot
|
||||
|
||||
# save to file, use when snapshot is a part of the workflow result
|
||||
playwright-cli snapshot --filename=after-click.yaml
|
||||
|
||||
# snapshot an element instead of the whole page
|
||||
playwright-cli snapshot "#main"
|
||||
|
||||
# limit snapshot depth for efficiency, take a partial snapshot afterwards
|
||||
playwright-cli snapshot --depth=4
|
||||
playwright-cli snapshot e34
|
||||
|
||||
# include each element's bounding box as [box=x,y,width,height]
|
||||
playwright-cli snapshot --boxes
|
||||
|
||||
# search a large snapshot instead of capturing it all — returns matching nodes
|
||||
# with 3 lines of context around each match (like grep -C)
|
||||
playwright-cli find "Add to cart"
|
||||
playwright-cli find --regex "\\$[0-9]+\\.[0-9]{2}"
|
||||
```
|
||||
|
||||
## Targeting elements
|
||||
|
||||
By default, use refs from the snapshot to interact with page elements.
|
||||
|
||||
```bash
|
||||
# get snapshot with refs
|
||||
playwright-cli snapshot
|
||||
|
||||
# interact using a ref
|
||||
playwright-cli click e15
|
||||
```
|
||||
|
||||
You can also use css selectors or Playwright locators.
|
||||
|
||||
```bash
|
||||
# css selector
|
||||
playwright-cli click "#main > button.submit"
|
||||
|
||||
# role locator
|
||||
playwright-cli click "getByRole('button', { name: 'Submit' })"
|
||||
|
||||
# test id
|
||||
playwright-cli click "getByTestId('submit-button')"
|
||||
```
|
||||
|
||||
## Browser Sessions
|
||||
|
||||
```bash
|
||||
# create new browser session named "mysession" with persistent profile
|
||||
playwright-cli -s=mysession open example.com --persistent
|
||||
# same with manually specified profile directory (use when requested explicitly)
|
||||
playwright-cli -s=mysession open example.com --profile=/path/to/profile
|
||||
playwright-cli -s=mysession click e6
|
||||
playwright-cli -s=mysession close # stop a named browser
|
||||
playwright-cli -s=mysession delete-data # delete user data for persistent session
|
||||
|
||||
playwright-cli list
|
||||
# Close all browsers
|
||||
playwright-cli close-all
|
||||
# Forcefully kill all browser processes
|
||||
playwright-cli kill-all
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
If global `playwright-cli` command is not available, try a local version via `npx playwright cli`:
|
||||
|
||||
```bash
|
||||
npx --no-install playwright --version
|
||||
```
|
||||
|
||||
When local version is available, use `npx playwright cli` in all commands. Otherwise, install `playwright-cli` as a global command:
|
||||
|
||||
```bash
|
||||
npm install -g @playwright/cli@latest
|
||||
```
|
||||
|
||||
## Example: Form submission
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com/form
|
||||
playwright-cli snapshot
|
||||
|
||||
playwright-cli fill e1 "user@example.com"
|
||||
playwright-cli fill e2 "password123"
|
||||
playwright-cli click e3
|
||||
playwright-cli snapshot
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
## Example: Multi-tab workflow
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli tab-new https://example.com/other
|
||||
playwright-cli tab-list
|
||||
playwright-cli tab-select 0
|
||||
playwright-cli snapshot
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
## Example: Debugging with DevTools
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli click e4
|
||||
playwright-cli fill e7 "test"
|
||||
playwright-cli console
|
||||
playwright-cli requests
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli tracing-start
|
||||
playwright-cli click e4
|
||||
playwright-cli fill e7 "test"
|
||||
playwright-cli tracing-stop
|
||||
playwright-cli close
|
||||
```
|
||||
|
||||
## Example: Interactive session
|
||||
|
||||
Ask the user for UI review or design feedback. The user draws boxes on the live page and types comments; you receive the annotated screenshot, the snapshot of the marked region, and the user's notes. Use this whenever the user asks for "UI review", "design feedback", or to "ask the user what they think / want / mean":
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli show --annotate
|
||||
```
|
||||
|
||||
## Specific tasks
|
||||
|
||||
* **Running and Debugging Playwright tests** [references/playwright-tests.md](references/playwright-tests.md)
|
||||
* **Request mocking** [references/request-mocking.md](references/request-mocking.md)
|
||||
* **Running Playwright code** [references/running-code.md](references/running-code.md)
|
||||
* **Browser session management** [references/session-management.md](references/session-management.md)
|
||||
* **Storage state (cookies, localStorage)** [references/storage-state.md](references/storage-state.md)
|
||||
* **Test generation (plan / generate / heal)** [references/test-generation.md](references/test-generation.md)
|
||||
* **Tracing** [references/tracing.md](references/tracing.md)
|
||||
* **Video recording** [references/video-recording.md](references/video-recording.md)
|
||||
* **Inspecting element attributes** [references/element-attributes.md](references/element-attributes.md)
|
||||
@@ -0,0 +1,23 @@
|
||||
# Inspecting Element Attributes
|
||||
|
||||
When the snapshot doesn't show an element's `id`, `class`, `data-*` attributes, or other DOM properties, use `eval` to inspect them.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
playwright-cli snapshot
|
||||
# snapshot shows a button as e7 but doesn't reveal its id or data attributes
|
||||
|
||||
# get the element's id
|
||||
playwright-cli eval "el => el.id" e7
|
||||
|
||||
# get all CSS classes
|
||||
playwright-cli eval "el => el.className" e7
|
||||
|
||||
# get a specific attribute
|
||||
playwright-cli eval "el => el.getAttribute('data-testid')" e7
|
||||
playwright-cli eval "el => el.getAttribute('aria-label')" e7
|
||||
|
||||
# get a computed style property
|
||||
playwright-cli eval "el => getComputedStyle(el).display" e7
|
||||
```
|
||||
@@ -0,0 +1,39 @@
|
||||
# Running Playwright Tests
|
||||
|
||||
To run Playwright tests, use the `npx playwright test` command, or a package manager script. To avoid opening the interactive html report, use `PLAYWRIGHT_HTML_OPEN=never` environment variable.
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test
|
||||
|
||||
# Run all tests through a custom npm script
|
||||
PLAYWRIGHT_HTML_OPEN=never npm run special-test-command
|
||||
```
|
||||
|
||||
# Debugging Playwright Tests
|
||||
|
||||
To debug a failing Playwright test, run it with `--debug=cli` option. This command will pause the test at the start and print the debugging instructions.
|
||||
|
||||
**IMPORTANT**: run the command in the background and check the output until "Debugging Instructions" is printed. Make sure to stop the command after you have finished.
|
||||
|
||||
Once instructions containing a session name are printed, use `playwright-cli` to attach the session and explore the page.
|
||||
|
||||
```bash
|
||||
# Run the test
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test --debug=cli
|
||||
# ...
|
||||
# ... debugging instructions for "tw-abcdef" session ...
|
||||
# ...
|
||||
|
||||
# Attach to the test
|
||||
playwright-cli attach tw-abcdef
|
||||
```
|
||||
|
||||
Keep the test running in the background while you explore and look for a fix.
|
||||
The test is paused at the start, so you should step over or pause at a particular location
|
||||
where the problem is most likely to be.
|
||||
|
||||
Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code.
|
||||
This code appears in the output and can be copied directly into the test. Most of the time, a specific locator or an expectation should be updated, but it could also be a bug in the app. Use your judgement.
|
||||
|
||||
After fixing the test, stop the background test run. Rerun to check that test passes.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Request Mocking
|
||||
|
||||
Intercept, mock, modify, and block network requests.
|
||||
|
||||
## CLI Route Commands
|
||||
|
||||
```bash
|
||||
# Mock with custom status
|
||||
playwright-cli route "**/*.jpg" --status=404
|
||||
|
||||
# Mock with JSON body
|
||||
playwright-cli route "**/api/users" --body='[{"id":1,"name":"Alice"}]' --content-type=application/json
|
||||
|
||||
# Mock with custom headers
|
||||
playwright-cli route "**/api/data" --body='{"ok":true}' --header="X-Custom: value"
|
||||
|
||||
# Remove headers from requests
|
||||
playwright-cli route "**/*" --remove-header=cookie,authorization
|
||||
|
||||
# List active routes
|
||||
playwright-cli route-list
|
||||
|
||||
# Remove a route or all routes
|
||||
playwright-cli unroute "**/*.jpg"
|
||||
playwright-cli unroute
|
||||
```
|
||||
|
||||
## URL Patterns
|
||||
|
||||
```
|
||||
**/api/users - Exact path match
|
||||
**/api/*/details - Wildcard in path
|
||||
**/*.{png,jpg,jpeg} - Match file extensions
|
||||
**/search?q=* - Match query parameters
|
||||
```
|
||||
|
||||
## Advanced Mocking with run-code
|
||||
|
||||
For conditional responses, request body inspection, response modification, or delays:
|
||||
|
||||
### Conditional Response Based on Request
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.route('**/api/login', route => {
|
||||
const body = route.request().postDataJSON();
|
||||
if (body.username === 'admin') {
|
||||
route.fulfill({ body: JSON.stringify({ token: 'mock-token' }) });
|
||||
} else {
|
||||
route.fulfill({ status: 401, body: JSON.stringify({ error: 'Invalid' }) });
|
||||
}
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
### Modify Real Response
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.route('**/api/user', async route => {
|
||||
const response = await route.fetch();
|
||||
const json = await response.json();
|
||||
json.isPremium = true;
|
||||
await route.fulfill({ response, json });
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
### Simulate Network Failures
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.route('**/api/offline', route => route.abort('internetdisconnected'));
|
||||
}"
|
||||
# Options: connectionrefused, timedout, connectionreset, internetdisconnected
|
||||
```
|
||||
|
||||
### Delayed Response
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.route('**/api/slow', async route => {
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
route.fulfill({ body: JSON.stringify({ data: 'loaded' }) });
|
||||
});
|
||||
}"
|
||||
```
|
||||
@@ -0,0 +1,241 @@
|
||||
# Running Custom Playwright Code
|
||||
|
||||
Use `run-code` to execute arbitrary Playwright code for advanced scenarios not covered by CLI commands.
|
||||
|
||||
## Syntax
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
// Your Playwright code here
|
||||
// Access page.context() for browser context operations
|
||||
}"
|
||||
```
|
||||
|
||||
You can also load the function from a file:
|
||||
|
||||
```bash
|
||||
playwright-cli run-code --filename=./my-script.js
|
||||
```
|
||||
|
||||
|
||||
The code must be a single function expression, it is wrapped in `(...)` and evaluated.
|
||||
import/export/require syntax is not supported.
|
||||
|
||||
## Geolocation
|
||||
|
||||
```bash
|
||||
# Grant geolocation permission and set location
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().grantPermissions(['geolocation']);
|
||||
await page.context().setGeolocation({ latitude: 37.7749, longitude: -122.4194 });
|
||||
}"
|
||||
|
||||
# Set location to London
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().grantPermissions(['geolocation']);
|
||||
await page.context().setGeolocation({ latitude: 51.5074, longitude: -0.1278 });
|
||||
}"
|
||||
|
||||
# Clear geolocation override
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().clearPermissions();
|
||||
}"
|
||||
```
|
||||
|
||||
## Permissions
|
||||
|
||||
```bash
|
||||
# Grant multiple permissions
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().grantPermissions([
|
||||
'geolocation',
|
||||
'notifications',
|
||||
'camera',
|
||||
'microphone'
|
||||
]);
|
||||
}"
|
||||
|
||||
# Grant permissions for specific origin
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().grantPermissions(['clipboard-read'], {
|
||||
origin: 'https://example.com'
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
## Media Emulation
|
||||
|
||||
```bash
|
||||
# Emulate dark color scheme
|
||||
playwright-cli run-code "async page => {
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
}"
|
||||
|
||||
# Emulate light color scheme
|
||||
playwright-cli run-code "async page => {
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
}"
|
||||
|
||||
# Emulate reduced motion
|
||||
playwright-cli run-code "async page => {
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
}"
|
||||
|
||||
# Emulate print media
|
||||
playwright-cli run-code "async page => {
|
||||
await page.emulateMedia({ media: 'print' });
|
||||
}"
|
||||
```
|
||||
|
||||
## Wait Strategies
|
||||
|
||||
```bash
|
||||
# Wait for network idle
|
||||
playwright-cli run-code "async page => {
|
||||
await page.waitForLoadState('networkidle');
|
||||
}"
|
||||
|
||||
# Wait for specific element
|
||||
playwright-cli run-code "async page => {
|
||||
await page.locator('.loading').waitFor({ state: 'hidden' });
|
||||
}"
|
||||
|
||||
# Wait for function to return true
|
||||
playwright-cli run-code "async page => {
|
||||
await page.waitForFunction(() => window.appReady === true);
|
||||
}"
|
||||
|
||||
# Wait with timeout
|
||||
playwright-cli run-code "async page => {
|
||||
await page.locator('.result').waitFor({ timeout: 10000 });
|
||||
}"
|
||||
```
|
||||
|
||||
## Frames and Iframes
|
||||
|
||||
```bash
|
||||
# Work with iframe
|
||||
playwright-cli run-code "async page => {
|
||||
const frame = page.locator('iframe#my-iframe').contentFrame();
|
||||
await frame.locator('button').click();
|
||||
}"
|
||||
|
||||
# Get all frames
|
||||
playwright-cli run-code "async page => {
|
||||
const frames = page.frames();
|
||||
return frames.map(f => f.url());
|
||||
}"
|
||||
```
|
||||
|
||||
## File Downloads
|
||||
|
||||
```bash
|
||||
# Handle file download
|
||||
playwright-cli run-code "async page => {
|
||||
const downloadPromise = page.waitForEvent('download');
|
||||
await page.getByRole('link', { name: 'Download' }).click();
|
||||
const download = await downloadPromise;
|
||||
await download.saveAs('./downloaded-file.pdf');
|
||||
return download.suggestedFilename();
|
||||
}"
|
||||
```
|
||||
|
||||
## Clipboard
|
||||
|
||||
```bash
|
||||
# Read clipboard (requires permission)
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().grantPermissions(['clipboard-read']);
|
||||
return await page.evaluate(() => navigator.clipboard.readText());
|
||||
}"
|
||||
|
||||
# Write to clipboard
|
||||
playwright-cli run-code "async page => {
|
||||
await page.evaluate(text => navigator.clipboard.writeText(text), 'Hello clipboard!');
|
||||
}"
|
||||
```
|
||||
|
||||
## Page Information
|
||||
|
||||
```bash
|
||||
# Get page title
|
||||
playwright-cli run-code "async page => {
|
||||
return await page.title();
|
||||
}"
|
||||
|
||||
# Get current URL
|
||||
playwright-cli run-code "async page => {
|
||||
return page.url();
|
||||
}"
|
||||
|
||||
# Get page content
|
||||
playwright-cli run-code "async page => {
|
||||
return await page.content();
|
||||
}"
|
||||
|
||||
# Get viewport size
|
||||
playwright-cli run-code "async page => {
|
||||
return page.viewportSize();
|
||||
}"
|
||||
```
|
||||
|
||||
## JavaScript Execution
|
||||
|
||||
```bash
|
||||
# Execute JavaScript and return result
|
||||
playwright-cli run-code "async page => {
|
||||
return await page.evaluate(() => {
|
||||
return {
|
||||
userAgent: navigator.userAgent,
|
||||
language: navigator.language,
|
||||
cookiesEnabled: navigator.cookieEnabled
|
||||
};
|
||||
});
|
||||
}"
|
||||
|
||||
# Pass arguments to evaluate
|
||||
playwright-cli run-code "async page => {
|
||||
const multiplier = 5;
|
||||
return await page.evaluate(m => document.querySelectorAll('li').length * m, multiplier);
|
||||
}"
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
```bash
|
||||
# Try-catch in run-code
|
||||
playwright-cli run-code "async page => {
|
||||
try {
|
||||
await page.getByRole('button', { name: 'Submit' }).click({ timeout: 1000 });
|
||||
return 'clicked';
|
||||
} catch (e) {
|
||||
return 'element not found';
|
||||
}
|
||||
}"
|
||||
```
|
||||
|
||||
## Complex Workflows
|
||||
|
||||
```bash
|
||||
# Login and save state
|
||||
playwright-cli run-code "async page => {
|
||||
await page.goto('https://example.com/login');
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
|
||||
await page.getByRole('textbox', { name: 'Password' }).fill('secret');
|
||||
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||
await page.waitForURL('**/dashboard');
|
||||
await page.context().storageState({ path: 'auth.json' });
|
||||
return 'Login successful';
|
||||
}"
|
||||
|
||||
# Scrape data from multiple pages
|
||||
playwright-cli run-code "async page => {
|
||||
const results = [];
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await page.goto(\`https://example.com/page/\${i}\`);
|
||||
const items = await page.locator('.item').allTextContents();
|
||||
results.push(...items);
|
||||
}
|
||||
return results;
|
||||
}"
|
||||
```
|
||||
@@ -0,0 +1,225 @@
|
||||
# Browser Session Management
|
||||
|
||||
Run multiple isolated browser sessions concurrently with state persistence.
|
||||
|
||||
## Named Browser Sessions
|
||||
|
||||
Use `-s` flag to isolate browser contexts:
|
||||
|
||||
```bash
|
||||
# Browser 1: Authentication flow
|
||||
playwright-cli -s=auth open https://app.example.com/login
|
||||
|
||||
# Browser 2: Public browsing (separate cookies, storage)
|
||||
playwright-cli -s=public open https://example.com
|
||||
|
||||
# Commands are isolated by browser session
|
||||
playwright-cli -s=auth fill e1 "user@example.com"
|
||||
playwright-cli -s=public snapshot
|
||||
```
|
||||
|
||||
## Browser Session Isolation Properties
|
||||
|
||||
Each browser session has independent:
|
||||
- Cookies
|
||||
- LocalStorage / SessionStorage
|
||||
- IndexedDB
|
||||
- Cache
|
||||
- Browsing history
|
||||
- Open tabs
|
||||
|
||||
## Browser Session Commands
|
||||
|
||||
```bash
|
||||
# List all browser sessions
|
||||
playwright-cli list
|
||||
|
||||
# Stop a browser session (close the browser)
|
||||
playwright-cli close # stop the default browser
|
||||
playwright-cli -s=mysession close # stop a named browser
|
||||
|
||||
# Stop all browser sessions
|
||||
playwright-cli close-all
|
||||
|
||||
# Forcefully kill all daemon processes (for stale/zombie processes)
|
||||
playwright-cli kill-all
|
||||
|
||||
# Delete browser session user data (profile directory)
|
||||
playwright-cli delete-data # delete default browser data
|
||||
playwright-cli -s=mysession delete-data # delete named browser data
|
||||
```
|
||||
|
||||
## Environment Variable
|
||||
|
||||
Set a default browser session name via environment variable:
|
||||
|
||||
```bash
|
||||
export PLAYWRIGHT_CLI_SESSION="mysession"
|
||||
playwright-cli open example.com # Uses "mysession" automatically
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Concurrent Scraping
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Scrape multiple sites concurrently
|
||||
|
||||
# Start all browsers
|
||||
playwright-cli -s=site1 open https://site1.com &
|
||||
playwright-cli -s=site2 open https://site2.com &
|
||||
playwright-cli -s=site3 open https://site3.com &
|
||||
wait
|
||||
|
||||
# Take snapshots from each
|
||||
playwright-cli -s=site1 snapshot
|
||||
playwright-cli -s=site2 snapshot
|
||||
playwright-cli -s=site3 snapshot
|
||||
|
||||
# Cleanup
|
||||
playwright-cli close-all
|
||||
```
|
||||
|
||||
### A/B Testing Sessions
|
||||
|
||||
```bash
|
||||
# Test different user experiences
|
||||
playwright-cli -s=variant-a open "https://app.com?variant=a"
|
||||
playwright-cli -s=variant-b open "https://app.com?variant=b"
|
||||
|
||||
# Compare
|
||||
playwright-cli -s=variant-a screenshot
|
||||
playwright-cli -s=variant-b screenshot
|
||||
```
|
||||
|
||||
### Persistent Profile
|
||||
|
||||
By default, browser profile is kept in memory only. Use `--persistent` flag on `open` to persist the browser profile to disk:
|
||||
|
||||
```bash
|
||||
# Use persistent profile (auto-generated location)
|
||||
playwright-cli open https://example.com --persistent
|
||||
|
||||
# Use persistent profile with custom directory
|
||||
playwright-cli open https://example.com --profile=/path/to/profile
|
||||
```
|
||||
|
||||
## Attaching to a Running Browser
|
||||
|
||||
Use `attach` to connect to a browser that is already running, instead of launching a new one.
|
||||
|
||||
### Attach by channel name
|
||||
|
||||
Connect to a running Chrome or Edge instance by its channel name. The browser must have remote debugging enabled — navigate to `chrome://inspect/#remote-debugging` in the target browser and check "Allow remote debugging for this browser instance".
|
||||
|
||||
```bash
|
||||
# Attach to Chrome
|
||||
playwright-cli attach --cdp=chrome
|
||||
|
||||
# Attach to Chrome Canary
|
||||
playwright-cli attach --cdp=chrome-canary
|
||||
|
||||
# Attach to Microsoft Edge
|
||||
playwright-cli attach --cdp=msedge
|
||||
|
||||
# Attach to Edge Dev
|
||||
playwright-cli attach --cdp=msedge-dev
|
||||
```
|
||||
|
||||
Supported channels: `chrome`, `chrome-beta`, `chrome-dev`, `chrome-canary`, `msedge`, `msedge-beta`, `msedge-dev`, `msedge-canary`.
|
||||
|
||||
When `--session` is not provided, the session is named after the channel (e.g. `--cdp=msedge` creates a session called `msedge`), so parallel attaches to Chrome and Edge don't collide on `default`. Pass `--session=<name>` to override.
|
||||
|
||||
### Attach via CDP endpoint
|
||||
|
||||
Connect to a browser that exposes a Chrome DevTools Protocol endpoint:
|
||||
|
||||
```bash
|
||||
playwright-cli attach --cdp=http://localhost:9222
|
||||
```
|
||||
|
||||
### Attach via browser extension
|
||||
|
||||
Connect to a browser with the Playwright extension installed:
|
||||
|
||||
```bash
|
||||
playwright-cli attach --extension
|
||||
```
|
||||
|
||||
### Detach
|
||||
|
||||
Tear down an attached session without affecting the external browser:
|
||||
|
||||
```bash
|
||||
# Detach the default attached session
|
||||
playwright-cli detach
|
||||
|
||||
# Detach a specific attached session
|
||||
playwright-cli -s=msedge detach
|
||||
```
|
||||
|
||||
`detach` only works on sessions created via `attach`. For sessions created via `open`, use `close`.
|
||||
|
||||
## Default Browser Session
|
||||
|
||||
When `-s` is omitted, commands use the default browser session:
|
||||
|
||||
```bash
|
||||
# These use the same default browser session
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli snapshot
|
||||
playwright-cli close # Stops default browser
|
||||
```
|
||||
|
||||
## Browser Session Configuration
|
||||
|
||||
Configure a browser session with specific settings when opening:
|
||||
|
||||
```bash
|
||||
# Open with config file
|
||||
playwright-cli open https://example.com --config=.playwright/my-cli.json
|
||||
|
||||
# Open with specific browser
|
||||
playwright-cli open https://example.com --browser=firefox
|
||||
|
||||
# Open in headed mode
|
||||
playwright-cli open https://example.com --headed
|
||||
|
||||
# Open with persistent profile
|
||||
playwright-cli open https://example.com --persistent
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Name Browser Sessions Semantically
|
||||
|
||||
```bash
|
||||
# GOOD: Clear purpose
|
||||
playwright-cli -s=github-auth open https://github.com
|
||||
playwright-cli -s=docs-scrape open https://docs.example.com
|
||||
|
||||
# AVOID: Generic names
|
||||
playwright-cli -s=s1 open https://github.com
|
||||
```
|
||||
|
||||
### 2. Always Clean Up
|
||||
|
||||
```bash
|
||||
# Stop browsers when done
|
||||
playwright-cli -s=auth close
|
||||
playwright-cli -s=scrape close
|
||||
|
||||
# Or stop all at once
|
||||
playwright-cli close-all
|
||||
|
||||
# If browsers become unresponsive or zombie processes remain
|
||||
playwright-cli kill-all
|
||||
```
|
||||
|
||||
### 3. Delete Stale Browser Data
|
||||
|
||||
```bash
|
||||
# Remove old browser data to free disk space
|
||||
playwright-cli -s=oldsession delete-data
|
||||
```
|
||||
@@ -0,0 +1,275 @@
|
||||
# Storage Management
|
||||
|
||||
Manage cookies, localStorage, sessionStorage, and browser storage state.
|
||||
|
||||
## Storage State
|
||||
|
||||
Save and restore complete browser state including cookies and storage.
|
||||
|
||||
### Save Storage State
|
||||
|
||||
```bash
|
||||
# Save to auto-generated filename (storage-state-{timestamp}.json)
|
||||
playwright-cli state-save
|
||||
|
||||
# Save to specific filename
|
||||
playwright-cli state-save my-auth-state.json
|
||||
```
|
||||
|
||||
### Restore Storage State
|
||||
|
||||
```bash
|
||||
# Load storage state from file
|
||||
playwright-cli state-load my-auth-state.json
|
||||
|
||||
# Reload page to apply cookies
|
||||
playwright-cli open https://example.com
|
||||
```
|
||||
|
||||
### Storage State File Format
|
||||
|
||||
The saved file contains:
|
||||
|
||||
```json
|
||||
{
|
||||
"cookies": [
|
||||
{
|
||||
"name": "session_id",
|
||||
"value": "abc123",
|
||||
"domain": "example.com",
|
||||
"path": "/",
|
||||
"expires": 1893456000,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Lax"
|
||||
}
|
||||
],
|
||||
"origins": [
|
||||
{
|
||||
"origin": "https://example.com",
|
||||
"localStorage": [
|
||||
{ "name": "theme", "value": "dark" },
|
||||
{ "name": "user_id", "value": "12345" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Cookies
|
||||
|
||||
### List All Cookies
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-list
|
||||
```
|
||||
|
||||
### Filter Cookies by Domain
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-list --domain=example.com
|
||||
```
|
||||
|
||||
### Filter Cookies by Path
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-list --path=/api
|
||||
```
|
||||
|
||||
### Get Specific Cookie
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-get session_id
|
||||
```
|
||||
|
||||
### Set a Cookie
|
||||
|
||||
```bash
|
||||
# Basic cookie
|
||||
playwright-cli cookie-set session abc123
|
||||
|
||||
# Cookie with options
|
||||
playwright-cli cookie-set session abc123 --domain=example.com --path=/ --httpOnly --secure --sameSite=Lax
|
||||
|
||||
# Cookie with expiration (Unix timestamp)
|
||||
playwright-cli cookie-set remember_me token123 --expires=1893456000
|
||||
```
|
||||
|
||||
### Delete a Cookie
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-delete session_id
|
||||
```
|
||||
|
||||
### Clear All Cookies
|
||||
|
||||
```bash
|
||||
playwright-cli cookie-clear
|
||||
```
|
||||
|
||||
### Advanced: Multiple Cookies or Custom Options
|
||||
|
||||
For complex scenarios like adding multiple cookies at once, use `run-code`:
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.context().addCookies([
|
||||
{ name: 'session_id', value: 'sess_abc123', domain: 'example.com', path: '/', httpOnly: true },
|
||||
{ name: 'preferences', value: JSON.stringify({ theme: 'dark' }), domain: 'example.com', path: '/' }
|
||||
]);
|
||||
}"
|
||||
```
|
||||
|
||||
## Local Storage
|
||||
|
||||
### List All localStorage Items
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-list
|
||||
```
|
||||
|
||||
### Get Single Value
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-get token
|
||||
```
|
||||
|
||||
### Set Value
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-set theme dark
|
||||
```
|
||||
|
||||
### Set JSON Value
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-set user_settings '{"theme":"dark","language":"en"}'
|
||||
```
|
||||
|
||||
### Delete Single Item
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-delete token
|
||||
```
|
||||
|
||||
### Clear All localStorage
|
||||
|
||||
```bash
|
||||
playwright-cli localstorage-clear
|
||||
```
|
||||
|
||||
### Advanced: Multiple Operations
|
||||
|
||||
For complex scenarios like setting multiple values at once, use `run-code`:
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.evaluate(() => {
|
||||
localStorage.setItem('token', 'jwt_abc123');
|
||||
localStorage.setItem('user_id', '12345');
|
||||
localStorage.setItem('expires_at', Date.now() + 3600000);
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
## Session Storage
|
||||
|
||||
### List All sessionStorage Items
|
||||
|
||||
```bash
|
||||
playwright-cli sessionstorage-list
|
||||
```
|
||||
|
||||
### Get Single Value
|
||||
|
||||
```bash
|
||||
playwright-cli sessionstorage-get form_data
|
||||
```
|
||||
|
||||
### Set Value
|
||||
|
||||
```bash
|
||||
playwright-cli sessionstorage-set step 3
|
||||
```
|
||||
|
||||
### Delete Single Item
|
||||
|
||||
```bash
|
||||
playwright-cli sessionstorage-delete step
|
||||
```
|
||||
|
||||
### Clear sessionStorage
|
||||
|
||||
```bash
|
||||
playwright-cli sessionstorage-clear
|
||||
```
|
||||
|
||||
## IndexedDB
|
||||
|
||||
### List Databases
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
return await page.evaluate(async () => {
|
||||
const databases = await indexedDB.databases();
|
||||
return databases;
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
### Delete Database
|
||||
|
||||
```bash
|
||||
playwright-cli run-code "async page => {
|
||||
await page.evaluate(() => {
|
||||
indexedDB.deleteDatabase('myDatabase');
|
||||
});
|
||||
}"
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Authentication State Reuse
|
||||
|
||||
```bash
|
||||
# Step 1: Login and save state
|
||||
playwright-cli open https://app.example.com/login
|
||||
playwright-cli snapshot
|
||||
playwright-cli fill e1 "user@example.com"
|
||||
playwright-cli fill e2 "password123"
|
||||
playwright-cli click e3
|
||||
|
||||
# Save the authenticated state
|
||||
playwright-cli state-save auth.json
|
||||
|
||||
# Step 2: Later, restore state and skip login
|
||||
playwright-cli state-load auth.json
|
||||
playwright-cli open https://app.example.com/dashboard
|
||||
# Already logged in!
|
||||
```
|
||||
|
||||
### Save and Restore Roundtrip
|
||||
|
||||
```bash
|
||||
# Set up authentication state
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli eval "() => { document.cookie = 'session=abc123'; localStorage.setItem('user', 'john'); }"
|
||||
|
||||
# Save state to file
|
||||
playwright-cli state-save my-session.json
|
||||
|
||||
# ... later, in a new session ...
|
||||
|
||||
# Restore state
|
||||
playwright-cli state-load my-session.json
|
||||
playwright-cli open https://example.com
|
||||
# Cookies and localStorage are restored!
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Never commit storage state files containing auth tokens
|
||||
- Add `*.auth-state.json` to `.gitignore`
|
||||
- Delete state files after automation completes
|
||||
- Use environment variables for sensitive data
|
||||
- By default, sessions run in-memory mode which is safer for sensitive operations
|
||||
@@ -0,0 +1,433 @@
|
||||
# Test generation (plan → generate → heal)
|
||||
|
||||
End-to-end workflow for authoring and maintaining Playwright tests with `playwright-cli`. Every `playwright-cli` action emits the equivalent Playwright TypeScript, and that generated code is the raw material for every test. The sections below can be used independently:
|
||||
|
||||
- **How generation works** — the core mechanic everything else relies on: actions become TypeScript, plus how to add assertions.
|
||||
- **Plan** — explore the app, produce a spec file describing what to test.
|
||||
- **Generate** — turn a spec into Playwright test files. Update the spec if it's vague or stale.
|
||||
- **Heal** — diagnose failing tests, fix the code, reconcile the spec with reality.
|
||||
|
||||
Plan / generate / heal lean on the same mechanic: run `npx playwright test --debug=cli` in the background, then `playwright-cli attach tw-XXXX` to drive the paused page interactively. See [playwright-tests.md](playwright-tests.md) for the debug/attach mechanics.
|
||||
|
||||
---
|
||||
|
||||
## 0. How generation works
|
||||
|
||||
Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code. This code appears in the output and can be copied directly into your test files.
|
||||
|
||||
```bash
|
||||
# Start a session
|
||||
playwright-cli open https://example.com/login
|
||||
|
||||
# Take a snapshot to see elements
|
||||
playwright-cli snapshot
|
||||
# Output shows: e1 [textbox "Email"], e2 [textbox "Password"], e3 [button "Sign In"]
|
||||
|
||||
# Fill form fields - generates code automatically
|
||||
playwright-cli fill e1 "user@example.com"
|
||||
# Ran Playwright code:
|
||||
# await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
|
||||
|
||||
playwright-cli fill e2 "password123"
|
||||
# Ran Playwright code:
|
||||
# await page.getByRole('textbox', { name: 'Password' }).fill('password123');
|
||||
|
||||
playwright-cli click e3
|
||||
# Ran Playwright code:
|
||||
# await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
```
|
||||
|
||||
### Building a test file
|
||||
|
||||
Collect the generated code into a Playwright test:
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('login flow', async ({ page }) => {
|
||||
// Generated code from playwright-cli session:
|
||||
await page.goto('https://example.com/login');
|
||||
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
|
||||
await page.getByRole('textbox', { name: 'Password' }).fill('password123');
|
||||
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||
|
||||
// Add assertions
|
||||
await expect(page).toHaveURL(/.*dashboard/);
|
||||
});
|
||||
```
|
||||
|
||||
### Use semantic locators
|
||||
|
||||
The generated code uses role-based locators when possible, which are more resilient:
|
||||
|
||||
```typescript
|
||||
// Generated (good - semantic)
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Avoid (fragile - CSS selectors)
|
||||
await page.locator('#submit-btn').click();
|
||||
```
|
||||
|
||||
### Explore before recording
|
||||
|
||||
Take snapshots to understand the page structure before recording actions:
|
||||
|
||||
```bash
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli snapshot
|
||||
# Review the element structure
|
||||
playwright-cli click e5
|
||||
```
|
||||
|
||||
### Add assertions manually
|
||||
|
||||
Generated code captures actions but not assertions. Add expectations in your test using one of the recommended matchers:
|
||||
|
||||
- `toBeVisible()` — element is rendered and visible
|
||||
- `toHaveText(text)` — element text content matches
|
||||
- `toHaveValue(value) / toBeEmpty()` — input/select value matches
|
||||
- `toBeChecked() / toBeUnchecked()` — checkbox state matches
|
||||
- `toMatchAriaSnapshot(snapshot)` — page (or locator) matches a partial accessibility snapshot
|
||||
|
||||
Use `playwright-cli generate-locator <target>` to produce the locator expression for the assertion, and the snapshot/eval commands to capture the expected value.
|
||||
|
||||
When asserting text content, make sure that generated locator does not contain text from the element itself. `getByTestId()` or `getByLabel()` usually work well with asserting text. When locator is text-based, prefer `toBeVisible()` instead.
|
||||
|
||||
Snapshot to be matched does not have to contain all the information - only capture what's necessary for the assertion. You can use regular expressions for unstable values.
|
||||
|
||||
```bash
|
||||
# Get a stable locator for an element ref to use in the assertion
|
||||
playwright-cli --raw generate-locator e5
|
||||
# getByRole('button', { name: 'Submit' })
|
||||
|
||||
# Capture expected text content for toHaveText
|
||||
playwright-cli --raw eval "el => el.textContent" e5
|
||||
|
||||
# Capture expected input value for toHaveValue/toBeEmpty
|
||||
playwright-cli --raw eval "el => el.value" e5
|
||||
|
||||
# Capture expected aria snapshot for toMatchAriaSnapshot/toBeChecked
|
||||
# (whole page, or use a ref to scope to a region)
|
||||
playwright-cli --raw snapshot
|
||||
playwright-cli --raw snapshot e5
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Generated action
|
||||
await page.getByRole('button', { name: 'Submit' }).click();
|
||||
|
||||
// Manual assertions using the outputs above:
|
||||
await expect(page.getByRole('alert', { name: 'Success' })).toBeVisible();
|
||||
await expect(page.getByTestId('main-header')).toHaveText('Welcome, user');
|
||||
await expect(page.getByRole('textbox', { name: 'Email' })).toHaveValue('user@example.com');
|
||||
await expect(page.getByRole('checkbox', { name: 'Enable notifications' })).toBeChecked();
|
||||
|
||||
// toMatchAriaSnapshot on the whole page, finds a matching region
|
||||
await expect(page).toMatchAriaSnapshot(`
|
||||
- heading "Welcome, user"
|
||||
- link /\\d+ new messages?/
|
||||
- button "Sign out"
|
||||
`);
|
||||
|
||||
// toMatchAriaSnapshot scoped to a region
|
||||
await expect(page.getByRole('navigation')).toMatchAriaSnapshot(`
|
||||
- link "Home"
|
||||
- link /\\d+ new messages?/
|
||||
- link "Profile"
|
||||
`);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Planning
|
||||
|
||||
Goal: produce a spec file (e.g. `specs/<feature>.plan.md`) that enumerates the scenarios to test. **Always** write the spec to a file.
|
||||
|
||||
### 1.1 Prerequisite: workspace
|
||||
|
||||
Check the workspace has Playwright installed before anything else:
|
||||
|
||||
```bash
|
||||
# Either of these confirms a workspace:
|
||||
test -f playwright.config.ts || test -f playwright.config.js
|
||||
npx --no-install playwright --version
|
||||
```
|
||||
|
||||
If there is no Playwright install, bootstrap one and let the user pick the defaults:
|
||||
|
||||
```bash
|
||||
npm init playwright@latest
|
||||
```
|
||||
|
||||
### 1.2 Prerequisite: seed test
|
||||
|
||||
A **seed test** is a minimal test that lands the page in the state every scenario starts from: navigation to the app, any required login, feature flags, etc. Scenarios assume a fresh start *after* the seed. `--debug=cli` pauses *inside* this test, so the seed is where every planning and generation session begins.
|
||||
|
||||
Minimum viable seed:
|
||||
|
||||
```ts
|
||||
// tests/seed.spec.ts
|
||||
import { test } from '@playwright/test';
|
||||
|
||||
test('seed', async ({ page }) => {
|
||||
await page.goto('https://example.com/');
|
||||
});
|
||||
```
|
||||
|
||||
Preferred — push navigation into a fixture so scenario tests reuse it:
|
||||
|
||||
```ts
|
||||
// tests/fixtures.ts
|
||||
import { test as baseTest } from '@playwright/test';
|
||||
export { expect } from '@playwright/test';
|
||||
|
||||
export const test = baseTest.extend({
|
||||
page: async ({ page }, use) => {
|
||||
await page.goto('https://example.com/');
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// tests/seed.spec.ts
|
||||
import { test } from './fixtures';
|
||||
|
||||
test('seed', async ({ page }) => {
|
||||
// Fixture already navigates. This empty body tells agents where to start.
|
||||
});
|
||||
```
|
||||
|
||||
If no seed exists, create one that at least navigates to the app.
|
||||
|
||||
### 1.3 Explore the app
|
||||
|
||||
Launch the app via the seed in the background and attach:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/seed.spec.ts --debug=cli
|
||||
# wait for "Debugging Instructions" and the session name tw-XXXX
|
||||
playwright-cli attach tw-XXXX
|
||||
```
|
||||
|
||||
Resume so the seed runs, then probe the app:
|
||||
|
||||
```bash
|
||||
playwright-cli resume # resume so that seed test runs fully
|
||||
playwright-cli snapshot # inventory of interactive elements
|
||||
playwright-cli click e5 # follow a flow
|
||||
playwright-cli eval "location.href" # read URL / state
|
||||
playwright-cli show --annotate # ask the user to point at something
|
||||
```
|
||||
|
||||
Map out:
|
||||
|
||||
- Interactive surfaces (forms, buttons, lists, filters, modals).
|
||||
- Primary user journeys end-to-end.
|
||||
- Edge cases: empty states, validation errors, very long input, boundary values.
|
||||
- Persistence: reload, local/session storage, URL fragments.
|
||||
- Navigation: which controls change the URL, back/forward behaviour.
|
||||
|
||||
**Important**: Do not just open the app url with playwright-cli, always go through the test to capture any custom setup done there.
|
||||
**Important**: Stop the background test when done exploring.
|
||||
|
||||
### 1.4 Write the spec file
|
||||
|
||||
Save under `specs/<feature>.plan.md`. Use this structure:
|
||||
|
||||
```markdown
|
||||
# <Feature> Test Plan
|
||||
|
||||
## Application Overview
|
||||
|
||||
<One paragraph describing what the feature does and why it matters.>
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### 1. <Group Name>
|
||||
|
||||
**Seed:** `tests/seed.spec.ts`
|
||||
|
||||
#### 1.1. <kebab-case-scenario-name>
|
||||
|
||||
**File:** `tests/<group>/<kebab-case-scenario-name>.spec.ts`
|
||||
|
||||
**Steps:**
|
||||
1. <Concrete user step>
|
||||
- expect: <observable outcome>
|
||||
- expect: <another observable outcome>
|
||||
2. <Next step>
|
||||
- expect: <outcome>
|
||||
|
||||
#### 1.2. <next-scenario>
|
||||
...
|
||||
|
||||
### 2. <Next Group>
|
||||
|
||||
**Seed:** `tests/seed.spec.ts`
|
||||
...
|
||||
```
|
||||
|
||||
Guidelines:
|
||||
|
||||
- Each scenario is independent and starts from the seed's fresh state — never chain scenarios.
|
||||
- Scenario names are kebab-case and match the test file name (`should-add-single-todo` → `should-add-single-todo.spec.ts`).
|
||||
- Cover happy path, edge cases, validation, negative flows, persistence.
|
||||
- Write steps at the user level ("Type 'Buy milk' into the input"), not the API level ("call `fill`").
|
||||
- Put observable outcomes in `- expect:` bullets; each becomes an assertion during generation.
|
||||
|
||||
---
|
||||
|
||||
## 2. Generate
|
||||
|
||||
Goal: take a spec file and produce Playwright test files. Optionally update the spec if it has drifted.
|
||||
|
||||
### 2.1 Inputs
|
||||
|
||||
- **Spec file**, e.g. `specs/basic-operations.plan.md`.
|
||||
- **Target**: either a single scenario (e.g. `1.2`), a whole group (`1`), or all.
|
||||
- **Seed file**, read from the `**Seed:**` line of the scenario's group.
|
||||
|
||||
### 2.2 Generate one scenario
|
||||
|
||||
For each target scenario, in sequence (never in parallel — scenarios share the seed session):
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test <seed-file> --debug=cli # background
|
||||
playwright-cli attach tw-XXXX
|
||||
# resume
|
||||
```
|
||||
|
||||
**Do not** just open the app url with playwright-cli, always go through the test to capture any custom setup done there.
|
||||
|
||||
Walk the scenario's `Steps:` one by one with `playwright-cli`, treating the spec as the plan and the live app as the source of truth. If a step is vague ("click the button" — which button?), references an element that no longer exists, or contradicts the app's actual behaviour, use your judgement: update the spec to match what the app really does, then keep going. Editing the spec mid-generation is expected.
|
||||
|
||||
Every action prints the equivalent Playwright TypeScript (see [How generation works](#0-how-generation-works)):
|
||||
|
||||
```bash
|
||||
playwright-cli snapshot # find refs
|
||||
playwright-cli fill e3 "John Doe" # -> page.getByRole('textbox', {...}).fill(...)
|
||||
playwright-cli press Enter
|
||||
playwright-cli click e7
|
||||
```
|
||||
|
||||
For each `- expect:` bullet, add an explicit assertion. See [How generation works](#0-how-generation-works) for details.
|
||||
|
||||
Collect the generated code and write the test file at the path given in the spec:
|
||||
|
||||
```ts
|
||||
// spec: specs/basic-operations.plan.md
|
||||
// seed: tests/seed.spec.ts
|
||||
import { test, expect } from './fixtures'; // or '@playwright/test' if no fixtures file
|
||||
|
||||
test.describe('Signing in and out', () => {
|
||||
test('should sign in', async ({ page }) => {
|
||||
// 1. Navigate to the application
|
||||
// (handled by the seed fixture)
|
||||
|
||||
// 2. Type 'John Doe' into the username field
|
||||
await page.getByRole('textbox', { name: 'username' }).fill('John Doe');
|
||||
|
||||
// 3. Type password
|
||||
await page.getByRole('textbox', { name: 'password' }).fill('TestPassword');
|
||||
|
||||
// 4. Press Enter to submit
|
||||
await page.getByRole('textbox', { name: 'password' }).press('Enter');
|
||||
|
||||
await expect(page.getByRole('heading')).toContainText('Welcome, John Doe!');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- **One test per file.** File path, describe name, and test name come verbatim from the spec (minus the ordinal).
|
||||
- Prefix each numbered step with a `// N. <step text>` comment before its actions.
|
||||
- Use the describe group name verbatim from the spec (no `1.` ordinal).
|
||||
- Import from `./fixtures` if the project has one; otherwise `@playwright/test`.
|
||||
- **Important**: close the CLI session and stop the background test before moving to the next scenario.
|
||||
|
||||
### 2.3 Generate multiple scenarios
|
||||
|
||||
Loop 2.2 over the targeted scenarios one at a time, restarting the seed between each so every test starts from a clean page. This is safe to parallelise due to unique generated session names - just make sure each test run is stopped.
|
||||
|
||||
### 2.4 Run generated tests
|
||||
|
||||
After generation, run the new tests once:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/<group>/<scenario>.spec.ts
|
||||
```
|
||||
|
||||
Any failure goes to Section 3.
|
||||
|
||||
---
|
||||
|
||||
## 3. Heal
|
||||
|
||||
Goal: fix failing tests, and update the spec if the app's intended behaviour changed.
|
||||
|
||||
### 3.1 Find failing tests
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test
|
||||
```
|
||||
|
||||
Record the list of failing `<file>:<line>` entries and process them one at a time. Do not attempt parallel fixes — shared state and the single CLI session make that fragile.
|
||||
|
||||
### 3.2 Debug one failure
|
||||
|
||||
Run the single failing test in debug mode in the background, then attach:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/<group>/<scenario>.spec.ts:<line> --debug=cli
|
||||
# wait for "Debugging Instructions" and the tw-XXXX session name
|
||||
playwright-cli attach tw-XXXX
|
||||
```
|
||||
|
||||
The test is paused at the start. Step forward or run to until just before the failing action or assertion, then diagnose:
|
||||
|
||||
```bash
|
||||
playwright-cli snapshot # did the element change / move / rename?
|
||||
playwright-cli console # app-side errors?
|
||||
playwright-cli requests # failed request? wrong payload?
|
||||
playwright-cli show --annotate # ask the user to point somewhere
|
||||
```
|
||||
|
||||
Common causes: selector drift, new wrapper element, label/ARIA rename, timing (transition, async load), assertion text updated in the app, test data leaking between runs.
|
||||
|
||||
Rehearse the corrected interaction with `playwright-cli` — the generated code in the output is what you paste back into the test.
|
||||
|
||||
### 3.3 Apply the fix
|
||||
|
||||
Edit the test file: update the locator, assertion, step order, or inputs to match the corrected behaviour. Stop the background debug run. Rerun the single test to confirm green.
|
||||
|
||||
Never skip hooks or add sleeps as a fix. Never use `networkidle`.
|
||||
|
||||
### 3.4 Reconcile with the spec
|
||||
|
||||
Open the spec referenced by the `// spec:` header in the test file and locate the scenario that matches the test.
|
||||
|
||||
- **Fix was purely technical** (locator drift, better assertion shape) and the spec's user-level behaviour still matches the app → leave the spec alone.
|
||||
- **Fix changed user-visible steps, inputs, order, or expected outcomes** that the spec describes → update the spec to match reality. Keep the scenario id and file path stable; only the step / expect lines change.
|
||||
- **Unclear whether the app change is intentional** (spec is stale) **or a regression** (test was right, app is wrong) → **stop and ask the user**. Provide:
|
||||
- the scenario id (e.g. `2.3`),
|
||||
- the spec lines that no longer match,
|
||||
- the observed app behaviour (quote a snapshot excerpt or a concrete outcome).
|
||||
|
||||
Only after the user answers, either update the spec (intentional change) or file/flag the test as covering a bug (regression).
|
||||
|
||||
### 3.5 Iteration and giving up
|
||||
|
||||
- Fix failures one at a time; rerun after each.
|
||||
- If after thorough investigation you are confident the test is correct but the app is wrong *and* the user has confirmed it's a bug: mark the test `test.fixme(...)` with a comment pointing at the user's decision or issue link. Never silently skip.
|
||||
|
||||
---
|
||||
|
||||
## Cross-references
|
||||
|
||||
| For... | See |
|
||||
|---|---|
|
||||
| `--debug=cli` / attach mechanics | [playwright-tests.md](playwright-tests.md) |
|
||||
| Mocking requests during exploration/generation | [request-mocking.md](request-mocking.md) |
|
||||
| Managing the CLI browser session | [session-management.md](session-management.md) |
|
||||
@@ -0,0 +1,139 @@
|
||||
# Tracing
|
||||
|
||||
Capture detailed execution traces for debugging and analysis. Traces include DOM snapshots, screenshots, network activity, and console logs.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```bash
|
||||
# Start trace recording
|
||||
playwright-cli tracing-start
|
||||
|
||||
# Perform actions
|
||||
playwright-cli open https://example.com
|
||||
playwright-cli click e1
|
||||
playwright-cli fill e2 "test"
|
||||
|
||||
# Stop trace recording
|
||||
playwright-cli tracing-stop
|
||||
```
|
||||
|
||||
## Trace Output Files
|
||||
|
||||
When you start tracing, Playwright creates a `traces/` directory with several files:
|
||||
|
||||
### `trace-{timestamp}.trace`
|
||||
|
||||
**Action log** - The main trace file containing:
|
||||
- Every action performed (clicks, fills, navigations)
|
||||
- DOM snapshots before and after each action
|
||||
- Screenshots at each step
|
||||
- Timing information
|
||||
- Console messages
|
||||
- Source locations
|
||||
|
||||
### `trace-{timestamp}.network`
|
||||
|
||||
**Network log** - Complete network activity:
|
||||
- All HTTP requests and responses
|
||||
- Request headers and bodies
|
||||
- Response headers and bodies
|
||||
- Timing (DNS, connect, TLS, TTFB, download)
|
||||
- Resource sizes
|
||||
- Failed requests and errors
|
||||
|
||||
### `resources/`
|
||||
|
||||
**Resources directory** - Cached resources:
|
||||
- Images, fonts, stylesheets, scripts
|
||||
- Response bodies for replay
|
||||
- Assets needed to reconstruct page state
|
||||
|
||||
## What Traces Capture
|
||||
|
||||
| Category | Details |
|
||||
|----------|---------|
|
||||
| **Actions** | Clicks, fills, hovers, keyboard input, navigations |
|
||||
| **DOM** | Full DOM snapshot before/after each action |
|
||||
| **Screenshots** | Visual state at each step |
|
||||
| **Network** | All requests, responses, headers, bodies, timing |
|
||||
| **Console** | All console.log, warn, error messages |
|
||||
| **Timing** | Precise timing for each operation |
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Debugging Failed Actions
|
||||
|
||||
```bash
|
||||
playwright-cli tracing-start
|
||||
playwright-cli open https://app.example.com
|
||||
|
||||
# This click fails - why?
|
||||
playwright-cli click e5
|
||||
|
||||
playwright-cli tracing-stop
|
||||
# Open trace to see DOM state when click was attempted
|
||||
```
|
||||
|
||||
### Analyzing Performance
|
||||
|
||||
```bash
|
||||
playwright-cli tracing-start
|
||||
playwright-cli open https://slow-site.com
|
||||
playwright-cli tracing-stop
|
||||
|
||||
# View network waterfall to identify slow resources
|
||||
```
|
||||
|
||||
### Capturing Evidence
|
||||
|
||||
```bash
|
||||
# Record a complete user flow for documentation
|
||||
playwright-cli tracing-start
|
||||
|
||||
playwright-cli open https://app.example.com/checkout
|
||||
playwright-cli fill e1 "4111111111111111"
|
||||
playwright-cli fill e2 "12/25"
|
||||
playwright-cli fill e3 "123"
|
||||
playwright-cli click e4
|
||||
|
||||
playwright-cli tracing-stop
|
||||
# Trace shows exact sequence of events
|
||||
```
|
||||
|
||||
## Trace vs Video vs Screenshot
|
||||
|
||||
| Feature | Trace | Video | Screenshot |
|
||||
|---------|-------|-------|------------|
|
||||
| **Format** | .trace file | .webm video | .png/.jpeg image |
|
||||
| **DOM inspection** | Yes | No | No |
|
||||
| **Network details** | Yes | No | No |
|
||||
| **Step-by-step replay** | Yes | Continuous | Single frame |
|
||||
| **File size** | Medium | Large | Small |
|
||||
| **Best for** | Debugging | Demos | Quick capture |
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Start Tracing Before the Problem
|
||||
|
||||
```bash
|
||||
# Trace the entire flow, not just the failing step
|
||||
playwright-cli tracing-start
|
||||
playwright-cli open https://example.com
|
||||
# ... all steps leading to the issue ...
|
||||
playwright-cli tracing-stop
|
||||
```
|
||||
|
||||
### 2. Clean Up Old Traces
|
||||
|
||||
Traces can consume significant disk space:
|
||||
|
||||
```bash
|
||||
# Remove traces older than 7 days
|
||||
find .playwright-cli/traces -mtime +7 -delete
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- Traces add overhead to automation
|
||||
- Large traces can consume significant disk space
|
||||
- Some dynamic content may not replay perfectly
|
||||
@@ -0,0 +1,143 @@
|
||||
# Video Recording
|
||||
|
||||
Capture browser automation sessions as video for debugging, documentation, or verification. Produces WebM (VP8/VP9 codec).
|
||||
|
||||
## Basic Recording
|
||||
|
||||
```bash
|
||||
# Open browser first
|
||||
playwright-cli open
|
||||
|
||||
# Start recording
|
||||
playwright-cli video-start demo.webm
|
||||
|
||||
# Add a chapter marker for section transitions
|
||||
playwright-cli video-chapter "Getting Started" --description="Opening the homepage" --duration=2000
|
||||
|
||||
# Navigate and perform actions
|
||||
playwright-cli goto https://example.com
|
||||
playwright-cli snapshot
|
||||
playwright-cli click e1
|
||||
|
||||
# Add another chapter
|
||||
playwright-cli video-chapter "Filling Form" --description="Entering test data" --duration=2000
|
||||
playwright-cli fill e2 "test input"
|
||||
|
||||
# Stop and save
|
||||
playwright-cli video-stop
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Descriptive Filenames
|
||||
|
||||
```bash
|
||||
# Include context in filename
|
||||
playwright-cli video-start recordings/login-flow-2024-01-15.webm
|
||||
playwright-cli video-start recordings/checkout-test-run-42.webm
|
||||
```
|
||||
|
||||
### 2. Record entire hero scripts.
|
||||
|
||||
When recording a video for the user or as a proof of work, it is best to create a code snippet and execute it with run-code.
|
||||
It allows inserting appropriate pauses between the actions and annotating the video. There are new Playwright APIs for that.
|
||||
|
||||
1) Perform scenario using CLI and take note of all locators and actions. You'll need those locators to request their bounding boxes for highlight.
|
||||
2) Create a file with the intended script for video (below). Use pressSequentially w/ delay for nice typing, make reasonable pauses.
|
||||
3) Use playwright-cli run-code --filename your-script.js
|
||||
|
||||
**Important**: Overlays are `pointer-events: none` — they do not interfere with page interactions. You can safely keep sticky overlays visible while clicking, filling, or performing any actions on the page.
|
||||
|
||||
```js
|
||||
async page => {
|
||||
await page.screencast.start({ path: 'video.webm', size: { width: 1280, height: 800 } });
|
||||
await page.goto('https://demo.playwright.dev/todomvc');
|
||||
|
||||
// Show a chapter card — blurs the page and shows a dialog.
|
||||
// Blocks until duration expires, then auto-removes.
|
||||
// Use this for simple use cases, but always feel free to hand-craft your own beautiful
|
||||
// overlay via await page.screencast.showOverlay().
|
||||
await page.screencast.showChapter('Adding Todo Items', {
|
||||
description: 'We will add several items to the todo list.',
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
// Perform action
|
||||
await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Walk the dog', { delay: 60 });
|
||||
await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter');
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Show next chapter
|
||||
await page.screencast.showChapter('Verifying Results', {
|
||||
description: 'Checking the item appeared in the list.',
|
||||
duration: 2000,
|
||||
});
|
||||
|
||||
// Add a sticky annotation that stays while you perform actions.
|
||||
// Overlays are pointer-events: none, so they won't block clicks.
|
||||
const annotation = await page.screencast.showOverlay(`
|
||||
<div style="position: absolute; top: 8px; right: 8px;
|
||||
padding: 6px 12px; background: rgba(0,0,0,0.7);
|
||||
border-radius: 8px; font-size: 13px; color: white;">
|
||||
✓ Item added successfully
|
||||
</div>
|
||||
`);
|
||||
|
||||
// Perform more actions while the annotation is visible
|
||||
await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Buy groceries', { delay: 60 });
|
||||
await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter');
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// Remove the annotation when done
|
||||
await annotation.dispose();
|
||||
|
||||
// You can also highlight relevant locators and provide contextual annotations.
|
||||
const bounds = await page.getByText('Walk the dog').boundingBox();
|
||||
await page.screencast.showOverlay(`
|
||||
<div style="position: absolute;
|
||||
top: ${bounds.y}px;
|
||||
left: ${bounds.x}px;
|
||||
width: ${bounds.width}px;
|
||||
height: ${bounds.height}px;
|
||||
border: 1px solid red;">
|
||||
</div>
|
||||
<div style="position: absolute;
|
||||
top: ${bounds.y + bounds.height + 5}px;
|
||||
left: ${bounds.x + bounds.width / 2}px;
|
||||
transform: translateX(-50%);
|
||||
padding: 6px;
|
||||
background: #808080;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
color: white;">Check it out, it is right above this text
|
||||
</div>
|
||||
`, { duration: 2000 });
|
||||
|
||||
await page.screencast.stop();
|
||||
}
|
||||
```
|
||||
|
||||
Embrace creativity, overlays are powerful.
|
||||
|
||||
### Overlay API Summary
|
||||
|
||||
| Method | Use Case |
|
||||
|--------|----------|
|
||||
| `page.screencast.showChapter(title, { description?, duration?, styleSheet? })` | Full-screen chapter card with blurred backdrop — ideal for section transitions |
|
||||
| `page.screencast.showOverlay(html, { duration? })` | Custom HTML overlay — use for callouts, labels, highlights |
|
||||
| `disposable.dispose()` | Remove a sticky overlay added without duration |
|
||||
| `page.screencast.hideOverlays()` / `page.screencast.showOverlays()` | Temporarily hide/show all overlays |
|
||||
|
||||
## Tracing vs Video
|
||||
|
||||
| Feature | Video | Tracing |
|
||||
|---------|-------|---------|
|
||||
| Output | WebM file | Trace file (viewable in Trace Viewer) |
|
||||
| Shows | Visual recording | DOM snapshots, network, console, actions |
|
||||
| Use case | Demos, documentation | Debugging, analysis |
|
||||
| Size | Larger | Smaller |
|
||||
|
||||
## Limitations
|
||||
|
||||
- Recording adds slight overhead to automation
|
||||
- Large recordings can consume significant disk space
|
||||
@@ -0,0 +1,474 @@
|
||||
name: Build & publish the Android APK
|
||||
|
||||
# The fifth workflow, and the second that publishes. It builds a signed
|
||||
# arm64-v8a APK on every version tag and puts it in
|
||||
# Gitea's *generic* package registry, which — unlike the repository — is
|
||||
# readable without credentials. That is what lets an Obtainium client
|
||||
# poll a plain URL with no token and no public mirror of the source.
|
||||
#
|
||||
# **Why its own file rather than a job in ci.yml.** `ci.yml` runs on
|
||||
# every branch push and is the workflow that gates; this one runs on
|
||||
# tags only, takes tens of minutes on a cold cache, and the runner has
|
||||
# capacity 1. Hanging it off the gate would put every push behind an
|
||||
# SDK download.
|
||||
#
|
||||
# **Why it is keyed on the tag.** The ljos pipeline this is modelled on
|
||||
# computes a version in CI and cuts the release itself, then gates the
|
||||
# Android job on `needs.release.outputs.version != ''` with an
|
||||
# `always()` whose absence silently kills the manual path. This repo
|
||||
# has no release automation — tags are pushed by hand and
|
||||
# homebrew-formula.yml already keys on `v*` — so the tag *is* the
|
||||
# version and none of that machinery, or its failure modes, is needed.
|
||||
#
|
||||
# It deliberately does **not** carry `continue-on-error`. In ljos the
|
||||
# Android job shared a pipeline with a server deploy that must never go
|
||||
# red over a phone build; here it is standalone and can neither delay
|
||||
# nor redden anything, so a release step that fails silently would be
|
||||
# strictly worse than one that fails visibly.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to build (default: the latest v* tag)"
|
||||
required: false
|
||||
|
||||
concurrency:
|
||||
group: android-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
apk:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
container:
|
||||
image: ubuntu:24.04
|
||||
# /cache/tool holds the Go toolchain ci.yml already downloads.
|
||||
# The other three are this workflow's own and are ~4 GB between
|
||||
# them, which is most of its wall clock on a cold run:
|
||||
# android-sdk the SDK, the NDK and the platform (~2 GB)
|
||||
# gradle GRADLE_USER_HOME — the wrapper distribution and
|
||||
# the AGP dependency graph (~700 MB)
|
||||
# pnpm-store shared with ci.yml
|
||||
# Every path must be inside the runner's `valid_volumes` allowlist:
|
||||
# a directory outside it makes the job **fail to start**, rather
|
||||
# than silently skipping the mount.
|
||||
volumes:
|
||||
- /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool
|
||||
- /home/logan/docker/gitea/data/runner/cache/android-sdk:/cache/android-sdk
|
||||
- /home/logan/docker/gitea/data/runner/cache/gradle:/cache/gradle
|
||||
- /home/logan/docker/gitea/data/runner/cache/pnpm-store:/cache/pnpm-store
|
||||
env:
|
||||
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
SHA: ${{ github.sha }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
GO_VERSION: '1.25.0'
|
||||
npm_config_store_dir: /cache/pnpm-store
|
||||
# The Go half wants the NDK; the Gradle half wants a platform.
|
||||
ANDROID_HOME: /cache/android-sdk
|
||||
ANDROID_SDK_ROOT: /cache/android-sdk
|
||||
GRADLE_USER_HOME: /cache/gradle
|
||||
# Pinned, not "whatever sdkmanager installs": newer NDKs have
|
||||
# broken the Wails Android build before, and r26d is what plan
|
||||
# 015 phase 0 was verified against.
|
||||
NDK_VERSION: 26.3.11579264
|
||||
# The registry package name. Obtainium watches
|
||||
# <server>/api/packages/<owner>/generic/yellowjacket-android/latest/yellowjacket.apk
|
||||
PACKAGE_NAME: yellowjacket-android
|
||||
|
||||
steps:
|
||||
# libgtk-4-dev and libwebkitgtk-6.0-dev are here even though
|
||||
# nothing in this job builds a desktop app: `wails3` is the task
|
||||
# runner the whole Android build goes through, and the CLI links
|
||||
# the GTK/WebKit bindings, so `go tool wails3` cannot compile
|
||||
# without them. libasound2-dev is oto's `pkg-config -- alsa`
|
||||
# probe, for the same reason (the *Android* build uses oboe, not
|
||||
# ALSA — this is the host toolchain only).
|
||||
- name: System packages
|
||||
run: |
|
||||
set -eu
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends \
|
||||
ca-certificates curl git jq unzip zip \
|
||||
build-essential pkg-config \
|
||||
libwebkitgtk-6.0-dev libgtk-4-dev libasound2-dev \
|
||||
openjdk-21-jdk-headless
|
||||
|
||||
# By hand rather than actions/checkout: that is a JS action and
|
||||
# needs node inside the container before any step has installed
|
||||
# it. Same approach as the other four workflows.
|
||||
- name: Clone repo at this commit
|
||||
run: |
|
||||
set -eu
|
||||
git clone --quiet \
|
||||
"https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src
|
||||
git -C /src checkout --quiet --detach "$SHA"
|
||||
git config --global --add safe.directory /src
|
||||
git -C /src log --oneline -1
|
||||
|
||||
# A tag push carries the version in its own name. A manual run has
|
||||
# no tag, so it takes the input or falls back to the latest v* tag,
|
||||
# which is what a hand-triggered rebuild wants anyway.
|
||||
- name: Resolve the version
|
||||
id: version
|
||||
working-directory: /src
|
||||
run: |
|
||||
set -eu
|
||||
v="${{ inputs.version }}"
|
||||
if [ -z "$v" ]; then
|
||||
case "$REF_NAME" in
|
||||
v*) v="$REF_NAME" ;;
|
||||
*) v=$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || echo "v0.0.0") ;;
|
||||
esac
|
||||
fi
|
||||
v="${v#v}"
|
||||
|
||||
# v0.0.0 is semantic-release's version floor, not a shipment —
|
||||
# see the bootstrap step in release.yml. It is skipped cleanly
|
||||
# rather than failing the guard below, because a 45-minute red
|
||||
# run against a tag that was never meant to ship is noise, and
|
||||
# this is the most expensive of the four workflows a tag fires.
|
||||
if [ "$v" = "0.0.0" ]; then
|
||||
echo "v0.0.0 is the version floor, not a release; nothing to build"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Android orders releases by an integer and refuses anything
|
||||
# not greater than what is installed. 1.3.1 -> 10301, which
|
||||
# increases as long as minor and patch stay below 100.
|
||||
IFS=. read -r maj min pat <<EOF
|
||||
$v
|
||||
EOF
|
||||
code=$(( ${maj:-0} * 10000 + ${min:-0} * 100 + ${pat:-0} ))
|
||||
if [ "$code" -le 0 ]; then
|
||||
echo "refusing to build version '$v' (versionCode $code)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "version=$v" >> "$GITHUB_OUTPUT"
|
||||
echo "code=$code" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=v$v" >> "$GITHUB_OUTPUT"
|
||||
echo "building $v (versionCode $code)"
|
||||
|
||||
# Releases restarted at 0.0.1 when they became automatic (plan
|
||||
# 017), so versionCode restarted at 1 — *below* the 10300 an
|
||||
# installed 1.3.0 build carries. Android refuses a downgrade
|
||||
# outright, and the only remedy is an uninstall, which takes the
|
||||
# user's library with it. Said here because this is the file
|
||||
# that computes the number.
|
||||
if [ "$code" -lt 10600 ]; then
|
||||
echo
|
||||
echo "note: versionCode $code is below the 10600 that v1.6.0 shipped."
|
||||
echo " An existing install must be removed before this one will"
|
||||
echo " install, and that removal takes its library with it."
|
||||
fi
|
||||
|
||||
- name: Go toolchain
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
run: |
|
||||
set -eu
|
||||
if [ ! -x /cache/tool/go/bin/go ] || ! /cache/tool/go/bin/go version | grep -q "$GO_VERSION"; then
|
||||
mkdir -p /cache/tool && rm -rf /cache/tool/go
|
||||
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | tar -C /cache/tool -xz
|
||||
fi
|
||||
echo "/cache/tool/go/bin" >> "$GITHUB_PATH"
|
||||
/cache/tool/go/bin/go version
|
||||
|
||||
- name: Node toolchain
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
run: |
|
||||
set -eu
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
apt-get install -y -qq --no-install-recommends nodejs
|
||||
corepack enable
|
||||
node --version
|
||||
|
||||
# Idempotent by directory check. sdkmanager is itself idempotent
|
||||
# but still spends minutes verifying, so the guards are what make
|
||||
# this cheap on every run after the first.
|
||||
- name: Android SDK and NDK (cached)
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p "$ANDROID_HOME/cmdline-tools"
|
||||
|
||||
if [ ! -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then
|
||||
echo "command line tools: installing"
|
||||
cd /tmp
|
||||
curl -fsSL -o tools.zip \
|
||||
https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip
|
||||
unzip -q tools.zip
|
||||
rm -rf "$ANDROID_HOME/cmdline-tools/latest"
|
||||
mv cmdline-tools "$ANDROID_HOME/cmdline-tools/latest"
|
||||
else
|
||||
echo "command line tools: cached"
|
||||
fi
|
||||
|
||||
export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH"
|
||||
yes | sdkmanager --licenses >/dev/null 2>&1 || true
|
||||
|
||||
install_if_missing() {
|
||||
if [ -d "$ANDROID_HOME/$2" ]; then
|
||||
echo "$1: cached"
|
||||
else
|
||||
echo "$1: installing"
|
||||
yes | sdkmanager --install "$1" >/dev/null
|
||||
fi
|
||||
}
|
||||
# android-35 matches compileSdk/targetSdk in
|
||||
# build/android/app/build.gradle. No system image and no
|
||||
# emulator: this job builds, it does not run.
|
||||
install_if_missing "platform-tools" "platform-tools"
|
||||
install_if_missing "platforms;android-35" "platforms/android-35"
|
||||
install_if_missing "build-tools;34.0.0" "build-tools/34.0.0"
|
||||
install_if_missing "ndk;${NDK_VERSION}" "ndk/${NDK_VERSION}"
|
||||
|
||||
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/${NDK_VERSION}" >> "$GITHUB_ENV"
|
||||
du -sh "$ANDROID_HOME" || true
|
||||
|
||||
# **Signing is not optional past the first install.** Android
|
||||
# refuses to update an app whose signing key changed and the only
|
||||
# remedy is an uninstall, which takes the user's library with it.
|
||||
# build.gradle falls back to the *debug* keystore when these are
|
||||
# absent, and that key differs between every machine and every
|
||||
# runner — so publishing an unsigned build is a decision to
|
||||
# reinstall by hand for ever. Fail instead.
|
||||
# **Signing is not optional past the first install.** Android
|
||||
# refuses to update an app whose signing key changed and the only
|
||||
# remedy is an uninstall, which takes the user's library with it.
|
||||
# build.gradle falls back to the *debug* keystore when these are
|
||||
# absent, and that key differs between every machine and every
|
||||
# runner — so publishing an unsigned build is a decision to
|
||||
# reinstall by hand for ever. Fail instead.
|
||||
#
|
||||
# Decode, check and build are one step on purpose. Splitting them
|
||||
# would mean either handing the password to a later step through
|
||||
# `$GITHUB_ENV` — where the `env:` dump is only masked for values
|
||||
# that are *verbatim* a secret, so a trimmed one could print in
|
||||
# clear — or repeating the trimming logic in both.
|
||||
- name: Build the signed APK
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
working-directory: /src
|
||||
env:
|
||||
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_B64 }}
|
||||
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
|
||||
KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
YJ_VERSION: ${{ steps.version.outputs.version }}
|
||||
YJ_VERSION_CODE: ${{ steps.version.outputs.code }}
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
if [ -z "${KEYSTORE_B64:-}" ]; then
|
||||
echo "ANDROID_KEYSTORE_B64 is not set."
|
||||
echo
|
||||
echo "Building without it signs with the debug key, and every future"
|
||||
echo "update then fails with a signature mismatch. See"
|
||||
echo "docs/android-release.md for the keytool command and the secrets."
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${KEYSTORE_PASSWORD:-}" ]; then
|
||||
echo "ANDROID_KEYSTORE_PASSWORD is not set — see docs/android-release.md" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The path is decided here rather than composed in an `env:`
|
||||
# block: `${{ env.HOME }}` evaluates to an empty string in
|
||||
# Gitea's expression context, which turns "$HOME/x.jks" into
|
||||
# "/x.jks" — reported by Gradle as a missing file, a minute in.
|
||||
keystore="${RUNNER_TEMP:-/tmp}/yellowjacket-release.jks"
|
||||
printf '%s' "$KEYSTORE_B64" | base64 -d > "$keystore"
|
||||
chmod 600 "$keystore"
|
||||
|
||||
# **A secret pasted into a web form very often carries a
|
||||
# trailing newline**, and a password is compared byte for byte.
|
||||
# Trim CR and LF from all three, and say so when it mattered —
|
||||
# "the keystore did not open" with a correct password is an
|
||||
# unpleasant thing to debug blind.
|
||||
pass=$(printf '%s' "$KEYSTORE_PASSWORD" | tr -d '\r\n')
|
||||
if [ "${#pass}" -ne "${#KEYSTORE_PASSWORD}" ]; then
|
||||
echo "note: stripped newline(s) from ANDROID_KEYSTORE_PASSWORD"
|
||||
fi
|
||||
alias_want=$(printf '%s' "${KEY_ALIAS:-yellowjacket}" | tr -d '\r\n')
|
||||
keypass=$(printf '%s' "${KEY_PASSWORD:-$pass}" | tr -d '\r\n')
|
||||
|
||||
# Describe the artifact before trying to open it. A truncated
|
||||
# or mis-pasted base64 yields a file that is the wrong size or
|
||||
# has no keystore header at all, and that is a different
|
||||
# problem from a wrong password.
|
||||
size=$(stat -c %s "$keystore")
|
||||
magic=$(od -An -N4 -tx1 "$keystore" | tr -s ' ' | sed 's/^ //')
|
||||
echo "keystore: $size bytes, first four bytes: $magic"
|
||||
|
||||
# The fingerprint of the decoded file, so "is the secret the
|
||||
# keystore I have locally?" is answerable without guessing.
|
||||
# A hash of a *public* certificate store gives nothing away,
|
||||
# and the alternative is comparing byte counts by eye.
|
||||
#
|
||||
# sha256sum ~/path/to/yellowjacket-release.jks
|
||||
#
|
||||
# A password that is right for one keystore and wrong for
|
||||
# another is indistinguishable from a wrong password, and this
|
||||
# is the line that distinguishes them.
|
||||
echo " sha256: $(sha256sum "$keystore" | cut -d' ' -f1)"
|
||||
case "$magic" in
|
||||
"30 82"*) echo " header: PKCS12 (keytool's default since JDK 9)" ;;
|
||||
"fe ed fe ed") echo " header: legacy JKS" ;;
|
||||
*) echo " WARNING: not a keystore header. Is the secret the base64 of the .jks?" ;;
|
||||
esac
|
||||
|
||||
# Open it here rather than letting Gradle discover the problem
|
||||
# at :app:validateSigningRelease, a minute of build time in and
|
||||
# reported as a missing file rather than a bad password.
|
||||
if ! keytool -list -keystore "$keystore" -storepass "$pass" >/tmp/ks.txt 2>/tmp/ks.err; then
|
||||
echo "the keystore did not open with ANDROID_KEYSTORE_PASSWORD." >&2
|
||||
echo " password length after trimming: ${#pass}" >&2
|
||||
sed 's/^/ keytool: /' /tmp/ks.err | head -5 >&2
|
||||
echo >&2
|
||||
|
||||
# A password pasted *with its shell quotes* is the one
|
||||
# remaining cause that looks identical to a wrong password:
|
||||
# the secret is two characters longer than the password and
|
||||
# nothing in the error says so. Naming it is safe --
|
||||
# stripping the quotes and carrying on would not be, since a
|
||||
# password may legitimately contain them.
|
||||
unquoted=$(printf '%s' "$pass" | sed "s/^['\"]//;s/['\"]$//")
|
||||
if [ "$unquoted" != "$pass" ] &&
|
||||
keytool -list -keystore "$keystore" -storepass "$unquoted" >/dev/null 2>&1; then
|
||||
echo " ** it opens with the surrounding quotes removed. **" >&2
|
||||
echo " Re-paste ANDROID_KEYSTORE_PASSWORD without them." >&2
|
||||
echo >&2
|
||||
fi
|
||||
echo "Check it locally with the same two values:" >&2
|
||||
echo " printf %s \"\$SECRET_B64\" | base64 -d > /tmp/k.jks" >&2
|
||||
echo " keytool -list -keystore /tmp/k.jks -storepass '<password>'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "keystore opens with the supplied password"
|
||||
|
||||
# And check the alias now, for the same reason. It defaults to
|
||||
# `yellowjacket`, so a keystore created with any other alias
|
||||
# would otherwise fail deep inside Gradle.
|
||||
if ! keytool -list -keystore "$keystore" -storepass "$pass" -alias "$alias_want" >/dev/null 2>&1; then
|
||||
echo "alias '$alias_want' is not in this keystore. It holds:" >&2
|
||||
sed -n 's/^\([^,]*\),.*Entry.*$/ \1/p' /tmp/ks.txt >&2
|
||||
echo "Set ANDROID_KEY_ALIAS to one of those." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "alias '$alias_want': present"
|
||||
|
||||
ANDROID_KEYSTORE_FILE="$keystore"
|
||||
ANDROID_KEYSTORE_PASSWORD="$pass"
|
||||
ANDROID_KEY_ALIAS="$alias_want"
|
||||
ANDROID_KEY_PASSWORD="$keypass"
|
||||
export ANDROID_KEYSTORE_FILE ANDROID_KEYSTORE_PASSWORD
|
||||
export ANDROID_KEY_ALIAS ANDROID_KEY_PASSWORD
|
||||
|
||||
# ANDROID_SDK is passed explicitly: the Makefile defaults it to
|
||||
# ~/Android/Sdk, which is the developer-machine layout and not
|
||||
# this container's.
|
||||
make android ANDROID_SDK="$ANDROID_HOME" ANDROID_NDK="$ANDROID_NDK_HOME"
|
||||
|
||||
- name: Verify the APK
|
||||
id: apk
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
working-directory: /src
|
||||
run: |
|
||||
set -eu
|
||||
apk=bin/yellowjacket.apk
|
||||
[ -s "$apk" ] || { echo "no APK was produced" >&2; ls -la bin || true; exit 1; }
|
||||
bt="$ANDROID_HOME/build-tools/34.0.0"
|
||||
|
||||
ls -la "$apk"
|
||||
"$bt/aapt2" dump badging "$apk" | sed -n '1p;/application-label:/p;/native-code/p'
|
||||
|
||||
# arm64 and *only* arm64. x86_64 Android cannot run this app
|
||||
# (modernc's raw lstat against Android's seccomp filter, which
|
||||
# is every x86_64 device and not merely the emulator), so an
|
||||
# x86_64 slice would be ~31 MB that runs nowhere -- and its
|
||||
# reappearance would mean someone had put the ABI back in
|
||||
# app/build.gradle without knowing that.
|
||||
"$bt/aapt2" dump badging "$apk" | grep -q "native-code: 'arm64-v8a'$" || {
|
||||
echo "the APK's ABI set is not exactly arm64-v8a" >&2; exit 1; }
|
||||
|
||||
# The identity the pipeline exists to keep stable.
|
||||
"$bt/aapt2" dump badging "$apk" | grep -q "versionCode='${{ steps.version.outputs.code }}'" || {
|
||||
echo "versionCode is not ${{ steps.version.outputs.code }}" >&2; exit 1; }
|
||||
|
||||
echo
|
||||
"$bt/apksigner" verify --print-certs "$apk" |
|
||||
grep -E 'Signer #1 certificate (DN|SHA-256 digest)'
|
||||
|
||||
# A build signed with the debug key installs once and can never
|
||||
# be updated. It must never reach the registry.
|
||||
if "$bt/apksigner" verify --print-certs "$apk" | grep -q 'CN=Android Debug'; then
|
||||
echo "REFUSING TO PUBLISH: signed with the debug keystore" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo
|
||||
echo "Record that SHA-256. If it ever changes, updates will fail."
|
||||
|
||||
# Two copies: a versioned one for history and a fixed `latest` URL
|
||||
# for Obtainium to watch. Gitea refuses to overwrite an existing
|
||||
# file, so `latest` is deleted first. Credentials are the same
|
||||
# OWNER/PACKAGE_TOKEN pair arch-package.yml publishes with.
|
||||
- name: Publish to the Gitea package registry
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
working-directory: /src
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
set -eu
|
||||
base="${SERVER_URL}/api/packages/${OWNER}/generic/${PACKAGE_NAME}"
|
||||
apk=bin/yellowjacket.apk
|
||||
|
||||
put() {
|
||||
code=$(curl -s -o /tmp/put.out -w '%{http_code}' \
|
||||
--user "${OWNER}:${PACKAGE_TOKEN}" \
|
||||
--upload-file "$apk" "$1")
|
||||
echo " -> $1 : $code"
|
||||
# 409 is "already there", which is the correct outcome for a
|
||||
# re-run of the same tag and not a failure.
|
||||
if [ "$code" != "201" ] && [ "$code" != "409" ]; then
|
||||
cat /tmp/put.out >&2
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
echo "publishing the versioned copy"
|
||||
put "$base/$VERSION/yellowjacket-$VERSION.apk"
|
||||
|
||||
echo "clearing the previous latest"
|
||||
curl -s -o /dev/null -w ' -> delete latest: %{http_code}\n' \
|
||||
--user "${OWNER}:${PACKAGE_TOKEN}" \
|
||||
-X DELETE "$base/latest/yellowjacket.apk" || true
|
||||
|
||||
echo "publishing latest"
|
||||
put "$base/latest/yellowjacket.apk"
|
||||
|
||||
echo
|
||||
echo "Obtainium URL:"
|
||||
echo " $base/latest/yellowjacket.apk"
|
||||
|
||||
# The generic registry is what Obtainium polls; the release page is
|
||||
# what a person looks at. Same file, already built and already
|
||||
# verified by the step above — so this cannot publish something the
|
||||
# signature check would have refused.
|
||||
- name: Attach the APK to the release
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
working-directory: /src
|
||||
env:
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
set -eu
|
||||
./scripts/release-asset.sh "$TAG" bin/yellowjacket.apk \
|
||||
"yellowjacket-${VERSION}-android-arm64.apk"
|
||||
@@ -1,8 +1,23 @@
|
||||
name: Build & publish Arch package
|
||||
|
||||
# Keyed on the tag, not on main. It used to publish on every push,
|
||||
# deriving a version from `git describe` — so the registry accumulated a
|
||||
# package per merge and none of them corresponded to anything a user
|
||||
# could be told to install. release.yml decides what a release is now,
|
||||
# and this builds the tag it cuts.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to build (default: the latest v* tag)"
|
||||
required: false
|
||||
|
||||
concurrency:
|
||||
group: arch-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
arch-package:
|
||||
@@ -17,14 +32,20 @@ jobs:
|
||||
REPO: ${{ github.repository }}
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
SHA: ${{ github.sha }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
# Arch registry name (the "$repo" in clients' pacman.conf). Arbitrary label.
|
||||
ARCH_REPO: stable
|
||||
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.
|
||||
# jq is scripts/release-asset.sh's, not the build's.
|
||||
pacman -Syu --noconfirm --needed \
|
||||
base-devel git go nodejs pnpm curl sudo \
|
||||
webkit2gtk-4.1 gtk3 alsa-lib
|
||||
base-devel git go nodejs pnpm curl sudo jq \
|
||||
webkitgtk-6.0 gtk4 alsa-lib
|
||||
|
||||
- name: Create unprivileged build user
|
||||
run: |
|
||||
@@ -32,15 +53,43 @@ jobs:
|
||||
install -d -o builder -g builder /build
|
||||
echo 'builder ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/builder
|
||||
|
||||
# v0.0.0 is semantic-release's version floor, not a shipment — see
|
||||
# the bootstrap step in release.yml. A clean skip rather than a
|
||||
# failure: a red run against a tag that was never meant to ship is
|
||||
# noise, and this is one of the four workflows that would otherwise
|
||||
# fire on it.
|
||||
- name: Resolve the version
|
||||
id: version
|
||||
run: |
|
||||
set -eu
|
||||
v="${{ inputs.version }}"
|
||||
[ -n "$v" ] || v="$REF_NAME"
|
||||
case "$v" in v*) ;; *) v="v$v" ;; esac
|
||||
|
||||
if [ "$v" = "v0.0.0" ]; then
|
||||
echo "v0.0.0 is the version floor, not a release; nothing to build"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$v" >> "$GITHUB_OUTPUT"
|
||||
echo "building $v"
|
||||
|
||||
- name: Clone repo at the pushed commit
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
run: |
|
||||
# Token auth works for private repos and needs no SSH key in CI.
|
||||
sudo -u builder git clone \
|
||||
"https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" \
|
||||
/build/yellowjacket
|
||||
# A tag push carries the tag's own commit in $SHA, so this checks
|
||||
# out exactly what was tagged. pkgver() then reads the tag from
|
||||
# the clone's own git history.
|
||||
sudo -u builder git -C /build/yellowjacket checkout --detach "$SHA"
|
||||
|
||||
- name: Build package with makepkg
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
run: |
|
||||
cd /build/yellowjacket/packaging/arch
|
||||
# Point the PKGBUILD at this local clone / exact commit; pkgver() then
|
||||
@@ -50,6 +99,7 @@ jobs:
|
||||
makepkg -f --noconfirm --cleanbuild
|
||||
|
||||
- name: Publish to the Gitea Arch registry
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
run: |
|
||||
cd /build/yellowjacket/packaging/arch
|
||||
# makepkg also produces a -debug package (detached symbols); end users
|
||||
@@ -63,3 +113,20 @@ jobs:
|
||||
--upload-file "$pkg" \
|
||||
"${SERVER_URL}/api/packages/${OWNER}/arch/${ARCH_REPO}"
|
||||
done
|
||||
|
||||
# The pacman registry is for people who have added it to pacman.conf;
|
||||
# the release page is for everyone else. Same file, and it is
|
||||
# already built.
|
||||
- name: Attach the package to the release
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
env:
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
set -eu
|
||||
cd /build/yellowjacket/packaging/arch
|
||||
for pkg in yellowjacket-*.pkg.tar.zst; do
|
||||
case "$pkg" in
|
||||
yellowjacket-debug-*) continue ;;
|
||||
esac
|
||||
/build/yellowjacket/scripts/release-asset.sh "$TAG" "$(pwd)/$pkg"
|
||||
done
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
name: CI
|
||||
|
||||
# The other five workflows package, publish or release; none of them test
|
||||
# anything, so a green tick on this repo used to mean "the Arch package
|
||||
# built", which is not the question anyone was asking. This is the
|
||||
# workflow that gates.
|
||||
#
|
||||
# Both jobs were prototyped end to end in a bare ubuntu:24.04 container
|
||||
# before being written here, so every step below is a transcription of
|
||||
# something observed working rather than something expected to.
|
||||
|
||||
# **A branch push and its PR are the same commit, and testing it twice
|
||||
# costs the only runner there is.** `branches: ['**']` here meant every
|
||||
# PR booked four runs — `check` and `e2e` for the branch push, then both
|
||||
# again for `refs/pull/N/head` — on a host with capacity 1, where the
|
||||
# queue is shared with an index build that can hold it for three hours.
|
||||
#
|
||||
# `pull_request` covers feature branches, and `main` is kept because a
|
||||
# post-merge run is the record of the trunk's health. Since main now
|
||||
# refuses direct pushes, that run happens exactly once per merge.
|
||||
#
|
||||
# The trade is explicit: a branch pushed with **no** PR open gets no CI.
|
||||
# That is consistent with the workflow this repo committed to — every
|
||||
# change goes through a PR — and the signal returns the moment one is
|
||||
# opened, on the same commit.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
# A newer push supersedes an older one on the same ref. Job 2 binds
|
||||
# :34115, so overlapping runs on one runner would fight over the port.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GO_VERSION: '1.25.0'
|
||||
# Shared by all three Playwright consumers (@playwright/cli, e2e/'s
|
||||
# @playwright/test, frontend/'s Vitest provider). See the browsers
|
||||
# step in job 2 for why that is not the whole story.
|
||||
PLAYWRIGHT_BROWSERS_PATH: /cache/ms-playwright
|
||||
# Best-effort pnpm store reuse; pnpm reads npm_config_* for its own
|
||||
# config keys. If it ever stops honouring this we lose cache warmth
|
||||
# and nothing else.
|
||||
npm_config_store_dir: /cache/pnpm-store
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------- #
|
||||
# Job 1: everything that does not need a display. #
|
||||
# ---------------------------------------------------------------- #
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
# Not golang:1.25 — this job runs `make ui-test`, which is Vitest
|
||||
# *browser* mode and needs a Chromium and its system libraries
|
||||
# anyway, so the "fast job needs no browser" split does not hold.
|
||||
# Not the Playwright image either: e2e/ pins @playwright/test
|
||||
# ^1.56 and frontend/ pins playwright ^1.62, so a prebuilt browser
|
||||
# set matches at most one of them. Ubuntu 24.04 is also what
|
||||
# Playwright's WebKit build links against, which job 2 needs.
|
||||
image: ubuntu:24.04
|
||||
# GOMODCACHE / GOCACHE / GOLANGCI_LINT_CACHE are already mounted
|
||||
# and exported for every job by the runner's container.options, so
|
||||
# only the Node-side caches are listed here. The runner's
|
||||
# valid_volumes allows anything under the cache root.
|
||||
volumes:
|
||||
- /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool
|
||||
- /home/logan/docker/gitea/data/runner/cache/ms-playwright:/cache/ms-playwright
|
||||
- /home/logan/docker/gitea/data/runner/cache/pnpm-store:/cache/pnpm-store
|
||||
env:
|
||||
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
SHA: ${{ github.sha }}
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
steps:
|
||||
- name: System packages
|
||||
run: |
|
||||
set -eu
|
||||
apt-get update -qq
|
||||
# libwebkitgtk-6.0-dev and libasound2-dev are not optional:
|
||||
# the app is cgo, and without alsa.pc oto/v3 fails at
|
||||
# `pkg-config --cflags -- alsa` before anything is compiled.
|
||||
# ubuntu:24.04 ships webkitgtk-6.0, which is what wails v3
|
||||
# builds against by default.
|
||||
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 ffmpeg
|
||||
|
||||
# Cloned by hand rather than with actions/checkout: that is a JS
|
||||
# action and needs node inside the job container before any step
|
||||
# has had a chance to install it. Same approach as the other
|
||||
# other workflows in this directory.
|
||||
- name: Clone repo at this commit
|
||||
run: |
|
||||
set -eu
|
||||
git clone --quiet \
|
||||
"https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src
|
||||
git -C /src checkout --quiet --detach "$SHA"
|
||||
git -C /src log --oneline -1
|
||||
# make bindings-check compares against the work tree, so git
|
||||
# has to be willing to operate on a directory it does not own.
|
||||
git config --global --add safe.directory /src
|
||||
|
||||
# Conventional Commits. `.releaserc.yml` has always derived the
|
||||
# version from the commit type; until now nothing checked that the
|
||||
# type was one it recognises, so a malformed subject silently meant
|
||||
# "no release". BEFORE is the push's previous tip and is absent or
|
||||
# all-zeros for a new branch, in which case only the tip is linted.
|
||||
- name: Commit messages
|
||||
working-directory: /src
|
||||
env:
|
||||
BEFORE: ${{ github.event.before }}
|
||||
run: |
|
||||
set -eu
|
||||
if [ -n "${BEFORE:-}" ] && [ "${BEFORE#0000000}" = "$BEFORE" ] \
|
||||
&& git cat-file -e "$BEFORE^{commit}" 2>/dev/null; then
|
||||
make commit-check RANGE="$BEFORE..$SHA"
|
||||
else
|
||||
make commit-check
|
||||
fi
|
||||
|
||||
- name: Go toolchain
|
||||
run: |
|
||||
set -eu
|
||||
if [ ! -x /cache/tool/go/bin/go ] || ! /cache/tool/go/bin/go version | grep -q "$GO_VERSION"; then
|
||||
mkdir -p /cache/tool && rm -rf /cache/tool/go
|
||||
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | tar -C /cache/tool -xz
|
||||
fi
|
||||
echo "/cache/tool/go/bin" >> "$GITHUB_PATH"
|
||||
/cache/tool/go/bin/go version
|
||||
|
||||
- name: Node toolchain
|
||||
run: |
|
||||
set -eu
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
apt-get install -y -qq --no-install-recommends nodejs
|
||||
corepack enable
|
||||
node --version
|
||||
|
||||
- name: Vitest provider browser
|
||||
working-directory: /src/frontend
|
||||
run: |
|
||||
set -eu
|
||||
pnpm install --frozen-lockfile
|
||||
npx playwright install --with-deps chromium
|
||||
|
||||
# main.go embeds the built frontend (`//go:embed all:frontend/dist`),
|
||||
# so *every* Go typecheck needs it to exist first — lint, test and
|
||||
# bindings-check all fail with "pattern all:frontend/dist: no
|
||||
# matching files found" on a fresh clone. This never bites locally
|
||||
# because anyone who has run the app once has a dist/ lying around,
|
||||
# which is exactly why CI has to do it explicitly.
|
||||
- name: Build the frontend
|
||||
working-directory: /src/frontend
|
||||
run: pnpm build
|
||||
|
||||
# `make lint` and `make test` each run all three build
|
||||
# configurations (app / indexbuild / dev) with matching tag sets.
|
||||
- name: Lint
|
||||
working-directory: /src
|
||||
run: make lint
|
||||
|
||||
- name: Test
|
||||
working-directory: /src
|
||||
run: make test
|
||||
|
||||
- name: Typecheck the frontend
|
||||
working-directory: /src/frontend
|
||||
run: npx tsc --noEmit
|
||||
|
||||
# A backtick inside a comment in a css`` literal ends the literal.
|
||||
# tsc above does fail on it, with a message about CSSResult
|
||||
# pointing at a line of prose; this one names the cause. It runs
|
||||
# after tsc for exactly that reason — whichever fails, the log has
|
||||
# the sentence in it.
|
||||
- name: CSS template literals are intact
|
||||
if: ${{ !cancelled() }}
|
||||
working-directory: /src
|
||||
run: make css-check
|
||||
|
||||
- name: Component and store suite
|
||||
working-directory: /src
|
||||
run: make ui-test
|
||||
|
||||
# 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
|
||||
run: make bindings-check
|
||||
|
||||
# Every `make <target>` named under .pi/**/*.md must exist, so an
|
||||
# agent is never sent at a command that was renamed away.
|
||||
- name: Documented make targets exist
|
||||
working-directory: /src
|
||||
run: make skill-check
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# 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
|
||||
needs: check
|
||||
container:
|
||||
image: ubuntu:24.04
|
||||
volumes:
|
||||
- /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool
|
||||
- /home/logan/docker/gitea/data/runner/cache/ms-playwright:/cache/ms-playwright
|
||||
- /home/logan/docker/gitea/data/runner/cache/pnpm-store:/cache/pnpm-store
|
||||
env:
|
||||
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
SHA: ${{ github.sha }}
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
# The explore artifact is stubbed with a dead address, exactly as
|
||||
# scripts/seed-sandbox.sh does it. Serving a real cut-down
|
||||
# artifact would mean building one under the indexbuild tag from
|
||||
# dump state this runner does not have, and no spec asserts on
|
||||
# explore content, so it would buy nothing. Note that
|
||||
# dev-headless.sh does *not* set this itself — only seed-sandbox
|
||||
# does — so the run would otherwise fetch the real artifact over
|
||||
# the network. It is also worth ~8x on suite wall clock: the
|
||||
# testctl DB restore spec copies every table, and the real
|
||||
# artifact makes that table set enormous.
|
||||
YJ_CORE_INDEX_URL: 'http://127.0.0.1:1/none.tar.zst'
|
||||
steps:
|
||||
- name: System packages
|
||||
run: |
|
||||
set -eu
|
||||
apt-get update -qq
|
||||
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 \
|
||||
dbus dbus-x11 ffmpeg libasound2t64 \
|
||||
alsa-utils libasound2-plugins pulseaudio pulseaudio-utils
|
||||
|
||||
- name: Clone repo at this commit
|
||||
run: |
|
||||
set -eu
|
||||
git clone --quiet \
|
||||
"https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src
|
||||
git -C /src checkout --quiet --detach "$SHA"
|
||||
git config --global --add safe.directory /src
|
||||
|
||||
- name: Go toolchain
|
||||
run: |
|
||||
set -eu
|
||||
if [ ! -x /cache/tool/go/bin/go ] || ! /cache/tool/go/bin/go version | grep -q "$GO_VERSION"; then
|
||||
mkdir -p /cache/tool && rm -rf /cache/tool/go
|
||||
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | tar -C /cache/tool -xz
|
||||
fi
|
||||
echo "/cache/tool/go/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Node toolchain
|
||||
run: |
|
||||
set -eu
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
apt-get install -y -qq --no-install-recommends nodejs
|
||||
corepack enable
|
||||
|
||||
# @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
|
||||
pnpm install --frozen-lockfile
|
||||
npx playwright install --with-deps chromium webkit
|
||||
|
||||
# oto/v3 talks to libasound directly, and a container has no
|
||||
# PulseAudio socket to fall back on — so it needs a default device
|
||||
# that not only accepts audio but **paces** it, because the player's
|
||||
# position is derived from what has been consumed.
|
||||
#
|
||||
# ALSA's `null` plugin does not pace. It was used here on the
|
||||
# belief that it advances its pointer on a timer. Measured in
|
||||
# this exact image, through beep and oto with the same
|
||||
# `speaker.Init` arguments `player.InitSpeaker` uses:
|
||||
#
|
||||
# type null 3000 ms of audio consumed in 2.96 ms
|
||||
# pulse sink 3000 ms of audio consumed in 3762 ms
|
||||
#
|
||||
# A thousand times too fast. Every track finished instantly, the
|
||||
# position reset to zero, and three specs failed on a clock that
|
||||
# never moved — which is the whole of the e2e job's red history,
|
||||
# and it looked like a flake because `InitSpeaker` succeeds either
|
||||
# way (in ~3 ms, also either way).
|
||||
#
|
||||
# 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
|
||||
# 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
|
||||
# --system because the job runs as root and PulseAudio refuses
|
||||
# to start as root any other way.
|
||||
adduser root pulse-access
|
||||
pulseaudio --system --daemonize --disallow-exit \
|
||||
--exit-idle-time=-1 \
|
||||
--load="module-null-sink sink_name=yellowjacket"
|
||||
printf 'pcm.!default { type pulse }\nctl.!default { type pulse }\n' \
|
||||
> /etc/asound.conf
|
||||
pactl list short sinks
|
||||
|
||||
# The sink is a dependency with a *rate*, so it is checked like
|
||||
# one. Without this the failure surfaces three steps later as
|
||||
# "the elapsed clock is 19 s adrift", which reads as an app bug
|
||||
# and cost two sessions of exactly that suspicion.
|
||||
- name: The sink plays at real time
|
||||
run: |
|
||||
set -eu
|
||||
ffmpeg -loglevel quiet -f lavfi -i "sine=frequency=440:duration=3" \
|
||||
-ar 44100 /tmp/probe.wav
|
||||
# aplay rather than the app: this is a check on the *device*,
|
||||
# and it has to be able to fail before the app is built.
|
||||
start=$(date +%s%N)
|
||||
aplay -q /tmp/probe.wav
|
||||
ms=$(( ($(date +%s%N) - start) / 1000000 ))
|
||||
echo "3000 ms of audio took ${ms} ms"
|
||||
if [ "$ms" -lt 2000 ]; then
|
||||
echo "The default ALSA device is discarding audio rather than" \
|
||||
"playing it. Every track will finish instantly and the" \
|
||||
"player's position will never advance." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Fixtures and seed
|
||||
working-directory: /src
|
||||
run: |
|
||||
set -eu
|
||||
make testdata
|
||||
# A seed is built by *running the app* and driving the real
|
||||
# AddLibrary binding — never by writing config.toml and DB
|
||||
# rows, which would be a second description of a valid YJ_HOME.
|
||||
make sandbox-seed NAME=default
|
||||
|
||||
# dev-headless daemonises (writes .dev/app.pid and returns), which
|
||||
# is why Playwright's webServer cannot supervise it and why this is
|
||||
# a step of its own. e2e/'s globalSetup checks /__test/health.
|
||||
- name: Start the app headless
|
||||
working-directory: /src
|
||||
run: make dev-headless SEED=default
|
||||
|
||||
- name: E2E — chromium
|
||||
working-directory: /src
|
||||
run: make e2e
|
||||
|
||||
# Playwright's Linux WebKit links Ubuntu 24.04 libraries that Arch
|
||||
# does not provide, so this cannot run on a dev machine at all: CI
|
||||
# is the only place we get any signal about the WebKit2GTK renderer
|
||||
# we actually ship. Required rather than advisory because it was
|
||||
# measured green (19/19) in this exact container before being
|
||||
# enabled, and because nothing in e2e/ compares pixels — every
|
||||
# assertion is an event payload, a testid, an attribute or backend
|
||||
# state, so a WebKit failure here is an engine bug, not baseline
|
||||
# noise. It costs ~11 s.
|
||||
#
|
||||
# `if: !cancelled()` because without it a chromium failure skips
|
||||
# this step, and chromium has been failing on the container's
|
||||
# audio clock — so the run that was the *only* source of WebKit
|
||||
# signal quietly stopped producing any, and the plan spent a pass
|
||||
# treating "CI also runs WebKit" as true when the job log said
|
||||
# `conclusion: skipped`.
|
||||
- name: E2E — webkit
|
||||
if: ${{ !cancelled() }}
|
||||
working-directory: /src
|
||||
env:
|
||||
YJ_E2E_WEBKIT: '1'
|
||||
run: make e2e E2E_ARGS="--project=webkit"
|
||||
|
||||
# The app log is the only place a hung binding call explains
|
||||
# itself, so put it in the job log where `gitea_ci job_logs` can
|
||||
# reach it without downloading an artifact.
|
||||
- name: App log on failure
|
||||
if: failure()
|
||||
working-directory: /src
|
||||
run: tail -n 200 .dev/app.log || true
|
||||
|
||||
- name: Upload traces and screenshots
|
||||
if: failure()
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-report-${{ github.run_id }}
|
||||
path: |
|
||||
/src/e2e/playwright-report/
|
||||
/src/.dev/app.log
|
||||
retention-days: 7
|
||||
|
||||
- name: Stop the app
|
||||
if: always()
|
||||
working-directory: /src
|
||||
run: make dev-stop || true
|
||||
@@ -0,0 +1,176 @@
|
||||
name: Attach the desktop build to the release
|
||||
|
||||
# The Arch package goes to the pacman registry and the APK to the generic
|
||||
# one, but a release page with nothing on it to download is a release page
|
||||
# nobody can use. This builds the plain Linux x86_64 binary and attaches
|
||||
# it, so "get the latest version" has an answer that needs no package
|
||||
# manager at all.
|
||||
#
|
||||
# **Linux only, and macOS is not an oversight.** `GOOS=darwin
|
||||
# CGO_ENABLED=0` fails at `wails/v3/pkg/mac: build constraints exclude all
|
||||
# Go files` — the darwin backend is Objective-C behind cgo, so a .app
|
||||
# needs a macOS host, and the runner is a Linux container. That is
|
||||
# exactly why the Homebrew formula builds from source on the user's own
|
||||
# Mac, and it stays the macOS channel.
|
||||
#
|
||||
# Windows *does* cross-compile (GOOS=windows CGO_ENABLED=0 succeeds in a
|
||||
# couple of seconds — nothing in the audio, database or webview path needs
|
||||
# cgo there), and is deliberately not published: no Windows build of this
|
||||
# app has ever been run, and no tier here can exercise one. Shipping it
|
||||
# would be a promise nothing in this repo can keep. Revisit when someone
|
||||
# has actually booted it.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to build and attach (default: the latest v* tag)"
|
||||
required: false
|
||||
|
||||
concurrency:
|
||||
group: desktop-assets-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ubuntu:24.04
|
||||
volumes:
|
||||
- /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool
|
||||
- /home/logan/docker/gitea/data/runner/cache/pnpm-store:/cache/pnpm-store
|
||||
env:
|
||||
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
REPO: ${{ github.repository }}
|
||||
SHA: ${{ github.sha }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
GO_VERSION: '1.25.0'
|
||||
npm_config_store_dir: /cache/pnpm-store
|
||||
steps:
|
||||
# The same set ci.yml's check job installs: the app is cgo, and
|
||||
# without alsa.pc oto/v3 fails at `pkg-config --cflags -- alsa`
|
||||
# before anything is compiled.
|
||||
- name: System packages
|
||||
run: |
|
||||
set -eu
|
||||
apt-get update -qq
|
||||
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
|
||||
|
||||
- name: Clone repo at this commit
|
||||
run: |
|
||||
set -eu
|
||||
git clone --quiet \
|
||||
"https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src
|
||||
git -C /src checkout --quiet --detach "$SHA"
|
||||
git config --global --add safe.directory /src
|
||||
git -C /src log --oneline -1
|
||||
|
||||
- name: Resolve the version
|
||||
id: version
|
||||
working-directory: /src
|
||||
run: |
|
||||
set -eu
|
||||
v="${{ inputs.version }}"
|
||||
if [ -z "$v" ]; then
|
||||
case "$REF_NAME" in
|
||||
v*) v="$REF_NAME" ;;
|
||||
*) v=$(git describe --tags --abbrev=0 --match 'v[0-9]*') ;;
|
||||
esac
|
||||
fi
|
||||
case "$v" in v*) ;; *) v="v$v" ;; esac
|
||||
|
||||
# v0.0.0 is semantic-release's version floor, not a shipment —
|
||||
# see the bootstrap step in release.yml. Nothing is built for
|
||||
# it, and this is a clean skip rather than a failure because a
|
||||
# red run against a tag that was never meant to ship is noise.
|
||||
if [ "$v" = "v0.0.0" ]; then
|
||||
echo "v0.0.0 is the version floor, not a release; nothing to build"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$v" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${v#v}" >> "$GITHUB_OUTPUT"
|
||||
echo "building $v"
|
||||
|
||||
- name: Go toolchain
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
run: |
|
||||
set -eu
|
||||
if [ ! -x /cache/tool/go/bin/go ] || ! /cache/tool/go/bin/go version | grep -q "$GO_VERSION"; then
|
||||
mkdir -p /cache/tool && rm -rf /cache/tool/go
|
||||
curl -fsSL "https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz" | tar -C /cache/tool -xz
|
||||
fi
|
||||
echo "/cache/tool/go/bin" >> "$GITHUB_PATH"
|
||||
/cache/tool/go/bin/go version
|
||||
|
||||
- name: Node toolchain
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
run: |
|
||||
set -eu
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
apt-get install -y -qq --no-install-recommends nodejs
|
||||
corepack enable
|
||||
node --version
|
||||
|
||||
# `make build-prod` is the production task: -trimpath and -w -s are
|
||||
# already in it, so only the version stamp is passed, through the
|
||||
# LDFLAGS_EXTRA variable this repo added to build/linux/Taskfile.yml.
|
||||
# (`wails3 build` has no -ldflags of its own; that was v2.)
|
||||
- name: Build
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
working-directory: /src
|
||||
env:
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
set -eu
|
||||
export PATH="/src/scripts/toolbin:$PATH"
|
||||
commit=$(git rev-parse --short HEAD)
|
||||
|
||||
go generate ./...
|
||||
go tool wails3 task build \
|
||||
LDFLAGS_EXTRA="-X 'main.version=${TAG}' -X 'main.commit=${commit}'"
|
||||
|
||||
# Described, never run: main.go has no flag parsing, so any
|
||||
# invocation here would try to open a window in a container with
|
||||
# no display and hang the job rather than printing a version.
|
||||
test -x bin/yellowjacket
|
||||
ls -la bin/yellowjacket
|
||||
file bin/yellowjacket || true
|
||||
|
||||
# The .desktop file and the icon go in the tarball because without
|
||||
# them the binary is a window with no menu entry — the Arch package
|
||||
# installs both, and this is the same app for people not using it.
|
||||
- name: Package the tarball
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
working-directory: /src
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
set -eu
|
||||
dir="yellowjacket-${VERSION}-linux-amd64"
|
||||
mkdir -p "/tmp/$dir"
|
||||
cp bin/yellowjacket "/tmp/$dir/"
|
||||
cp packaging/arch/yellowjacket.desktop "/tmp/$dir/"
|
||||
cp frontend/src/assets/images/icons/music/compact-disc.svg \
|
||||
"/tmp/$dir/yellowjacket.svg"
|
||||
tar -C /tmp -czf "/tmp/${dir}.tar.gz" "$dir"
|
||||
ls -la "/tmp/${dir}.tar.gz"
|
||||
|
||||
- name: Attach it to the release
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
working-directory: /src
|
||||
env:
|
||||
TAG: ${{ steps.version.outputs.tag }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
set -eu
|
||||
./scripts/release-asset.sh "$TAG" \
|
||||
"/tmp/yellowjacket-${VERSION}-linux-amd64.tar.gz"
|
||||
@@ -0,0 +1,112 @@
|
||||
name: Sync Homebrew formula
|
||||
|
||||
# On every version tag, recompute the release tarball checksum and push an
|
||||
# updated Formula/yellowjacket.rb into the Homebrew tap repo. Keeping the tap
|
||||
# in a separate repo (github.com/Shadow-Puppet/homebrew-yellowjacket) is what
|
||||
# lets users install with a single command:
|
||||
#
|
||||
# brew install shadow-puppet/yellowjacket/yellowjacket
|
||||
#
|
||||
# (`shadow-puppet/yellowjacket` is shorthand for the homebrew-yellowjacket repo;
|
||||
# brew auto-taps it, so no separate `brew tap` step is needed.)
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to sync (default: the pushed tag)"
|
||||
required: false
|
||||
|
||||
concurrency:
|
||||
group: homebrew-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
sync-formula:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# GitHub PAT (or fine-grained token) with write access to the tap repo.
|
||||
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
# Gitea source that serves the release tarball referenced by the formula.
|
||||
SOURCE_TARBALL_BASE: https://git.ljones.me/yonlu/yellowjacket/archive
|
||||
# separate GitHub tap repo the formula is published to.
|
||||
TAP_REPO: Shadow-Puppet/homebrew-yellowjacket
|
||||
steps:
|
||||
- name: Check out source (for the canonical formula)
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Compute version and tarball checksum
|
||||
id: version
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ inputs.version }}"
|
||||
[ -n "$TAG" ] || TAG="${GITHUB_REF_NAME}" # e.g. v0.0.1
|
||||
case "$TAG" in v*) ;; *) TAG="v$TAG" ;; esac
|
||||
VERSION="${TAG#v}" # e.g. 0.0.1
|
||||
|
||||
# v0.0.0 is semantic-release's version floor, not a shipment —
|
||||
# see the bootstrap step in release.yml. Skipped cleanly rather
|
||||
# than failing: this one would otherwise push a formula for a
|
||||
# version that does not exist into a *public* tap.
|
||||
if [ "$VERSION" = "0.0.0" ]; then
|
||||
echo "v0.0.0 is the version floor, not a release; nothing to sync"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
TARBALL="${SOURCE_TARBALL_BASE}/${TAG}.tar.gz"
|
||||
|
||||
echo "Fetching ${TARBALL}"
|
||||
# Retry briefly: the tag archive can lag a few seconds behind the push.
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if curl -fSsL "$TARBALL" -o release.tar.gz; then
|
||||
break
|
||||
fi
|
||||
echo "attempt ${attempt} failed, retrying..."
|
||||
sleep 5
|
||||
done
|
||||
|
||||
SHA256="$(sha256sum release.tar.gz | cut -d' ' -f1)"
|
||||
echo "version=${VERSION} sha256=${SHA256}"
|
||||
|
||||
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
echo "SHA256=${SHA256}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Render the formula with the new version and checksum
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
src="packaging/homebrew/Formula/yellowjacket.rb"
|
||||
# Rewrite only the two managed lines; the interpolated url picks up the
|
||||
# new version automatically.
|
||||
sed -E \
|
||||
-e "s|^ version \".*\"| version \"${VERSION}\"|" \
|
||||
-e "s|^ sha256 \".*\"| sha256 \"${SHA256}\"|" \
|
||||
"$src" > yellowjacket.rb
|
||||
echo "----- rendered formula -----"
|
||||
cat yellowjacket.rb
|
||||
|
||||
- name: Push to the Homebrew tap repo
|
||||
if: steps.version.outputs.skip == 'false'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone "https://x-access-token:${TAP_TOKEN}@github.com/${TAP_REPO}.git" tap
|
||||
mkdir -p tap/Formula
|
||||
cp yellowjacket.rb tap/Formula/yellowjacket.rb
|
||||
|
||||
cd tap
|
||||
git config user.name "yellowjacket-ci"
|
||||
git config user.email "yj@yellowjacket.app"
|
||||
|
||||
if git diff --quiet; then
|
||||
echo "Formula already up to date; nothing to push."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git add Formula/yellowjacket.rb
|
||||
git commit -m "yellowjacket ${VERSION}"
|
||||
git push origin HEAD:main
|
||||
@@ -0,0 +1,187 @@
|
||||
name: Search index maintenance
|
||||
|
||||
# indexbuild decides what to do from the index's own state, so every
|
||||
# trigger below runs the same command:
|
||||
#
|
||||
# no completed import -> build (first run, or resume a partial one)
|
||||
# import older than 6mo -> rebuild (re-import from the newest dump)
|
||||
# otherwise -> refresh (fold in new incremental listens)
|
||||
#
|
||||
# **There is deliberately no `push` trigger, and restoring one is a
|
||||
# decision rather than a cleanup.** A refresh is individually cheap, so
|
||||
# running it on every push to main looked free; what it actually does is
|
||||
# put an unattended job that mutates the only copy of a ~205 GB catalog
|
||||
# on the same trigger as an ordinary code change, on a runner with
|
||||
# capacity 1.
|
||||
#
|
||||
# That is not hypothetical. On 2026-08-17 `fix(database): retire a table
|
||||
# whose shape the schema moved past` landed on main, green — the CI
|
||||
# database is deliberately in the older encoding, so the stale-shape
|
||||
# repair judged its `explore_index` stale and dropped it, and this job
|
||||
# fell back to a full import from the dumps. `fix(database): never
|
||||
# retire the catalog the index build derives` stops that specific repair
|
||||
# and cannot undo it. Every push to main then booked another `budget`
|
||||
# (3h) of the one runner while ordinary CI queued behind it.
|
||||
#
|
||||
# So the rule this file is an instance of: **a job that mutates state
|
||||
# which cannot be rebuilt in ten minutes is triggered deliberately, not
|
||||
# by a push.** The weekly cron keeps the catalog current, and
|
||||
# workflow_dispatch resumes or forces a build — indexbuild picks up from
|
||||
# its checkpoint either way, so nothing is lost by not running on every
|
||||
# merge. See docs/index-cache.md for the snapshot and the restore.
|
||||
on:
|
||||
schedule:
|
||||
# Weekly update pass. The 6-month rebuild is triggered by the same
|
||||
# command when it notices the import has aged out.
|
||||
- cron: '0 4 * * 1'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
mode:
|
||||
description: 'auto | build | refresh | rebuild'
|
||||
required: false
|
||||
default: 'auto'
|
||||
budget:
|
||||
description: 'Max build time this run'
|
||||
required: false
|
||||
default: '3h'
|
||||
artists:
|
||||
description: 'Top artists in the core artifact'
|
||||
required: false
|
||||
default: '50000'
|
||||
|
||||
# Runs share one persistent working directory, so they must not overlap.
|
||||
# A push landing mid-build waits rather than corrupting the checkpoint.
|
||||
#
|
||||
# That directory holds the only copy of a catalog nothing can cheaply
|
||||
# re-derive: see docs/index-cache.md for the snapshot it takes and the
|
||||
# restore, which is minutes against the hours a rebuild costs.
|
||||
concurrency:
|
||||
group: search-index
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
maintain-index:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
# CGO is not needed: the project uses the pure-Go modernc sqlite
|
||||
# driver, and neither command imports the Wails app — which is a
|
||||
# claim with a test behind it now (cmd/indexbuild/deps_test.go),
|
||||
# because the v3 migration quietly broke it and this job was where
|
||||
# that surfaced.
|
||||
image: golang:1.25
|
||||
# This host path must exist on the runner and be listed verbatim in
|
||||
# act_runner's container.valid_volumes. It holds explore-staging/
|
||||
# (counts.bin + state.json) and yj.db — the checkpoint that makes
|
||||
# resuming possible. Losing it means re-downloading ~205GB.
|
||||
volumes:
|
||||
- /srv/yellowjacket/index-cache:/cache
|
||||
env:
|
||||
YJ_HOME: /cache
|
||||
CGO_ENABLED: '0'
|
||||
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
REPO: ${{ github.repository }}
|
||||
SHA: ${{ github.sha }}
|
||||
MODE: ${{ inputs.mode || 'auto' }}
|
||||
BUDGET: ${{ inputs.budget || '3h' }}
|
||||
ARTISTS: ${{ inputs.artists || '50000' }}
|
||||
steps:
|
||||
# Cloned by hand rather than with actions/checkout: that is a JS
|
||||
# action and needs node inside the job container, which the golang
|
||||
# image does not carry. Same approach as arch-package.yml.
|
||||
- name: Clone repo at this commit
|
||||
run: |
|
||||
set -eu
|
||||
git clone --quiet \
|
||||
"https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" \
|
||||
/src
|
||||
git -C /src checkout --quiet --detach "$SHA"
|
||||
git -C /src log --oneline -1
|
||||
|
||||
- name: Verify the cache volume
|
||||
run: |
|
||||
set -eu
|
||||
mkdir -p /cache
|
||||
# A RAM-backed cache would defeat the point: the checkpoint has
|
||||
# to outlive the job, and the import wants real disk headroom.
|
||||
fstype=$(stat -f -c %T /cache || echo unknown)
|
||||
echo "cache fstype: $fstype"
|
||||
case "$fstype" in
|
||||
tmpfs|ramfs)
|
||||
echo "::error::/cache is RAM-backed; use a disk-backed host path."
|
||||
exit 1 ;;
|
||||
esac
|
||||
df -h /cache
|
||||
|
||||
- name: Build tools
|
||||
working-directory: /src
|
||||
# The dump importer is behind the `indexbuild` tag so it is not
|
||||
# linked into the app binary; cmd/indexbuild carries the same tag
|
||||
# and will not build without it.
|
||||
run: |
|
||||
go build -tags indexbuild -o /usr/local/bin/ ./cmd/indexbuild
|
||||
go build -o /usr/local/bin/ ./cmd/indexexport
|
||||
|
||||
- name: Maintain index
|
||||
id: maintain
|
||||
run: |
|
||||
set +e
|
||||
indexbuild -mode "$MODE" -budget "$BUDGET"
|
||||
code=$?
|
||||
set -e
|
||||
case "$code" in
|
||||
0) ;;
|
||||
3) echo "::notice::Build checkpointed with work remaining — rerun to continue." ;;
|
||||
*) exit "$code" ;;
|
||||
esac
|
||||
|
||||
# Publishing only on `changed` keeps identical artifacts from
|
||||
# accumulating when a refresh finds nothing new.
|
||||
- name: Export core artifact
|
||||
if: steps.maintain.outputs.complete == 'true' && steps.maintain.outputs.changed == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
command -v zstd >/dev/null 2>&1 || { apt-get update -qq && apt-get install -y -qq zstd; }
|
||||
indexexport -o /tmp/core-index.db -artists "$ARTISTS"
|
||||
zstd -19 -T0 -q -f /tmp/core-index.db -o /tmp/core-index.db.zst
|
||||
sha256sum /tmp/core-index.db.zst | tee /tmp/core-index.db.zst.sha256
|
||||
ls -lh /tmp/core-index.db.zst
|
||||
|
||||
- name: Publish to the Gitea package registry
|
||||
if: steps.maintain.outputs.complete == 'true' && steps.maintain.outputs.changed == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
pkg="${SERVER_URL}/api/packages/${OWNER}/generic/yellowjacket-core-index"
|
||||
|
||||
# Published twice: under a dated version for history, and under
|
||||
# the fixed "latest" version the client fetches. Clients cannot
|
||||
# discover the newest dated version on their own — the package
|
||||
# listing API requires a token, while a plain file GET does not
|
||||
# — so "latest" is what makes an anonymous first run possible.
|
||||
#
|
||||
# A generic package rejects re-uploading a filename that already
|
||||
# exists, so "latest" is deleted before being rewritten. It is
|
||||
# absent on the very first publish, hence the tolerated 404.
|
||||
curl --silent --show-error --user "${OWNER}:${PACKAGE_TOKEN}" \
|
||||
--request DELETE "${pkg}/latest" || true
|
||||
|
||||
for version in "$(date -u +%Y%m%d)" latest; do
|
||||
for f in core-index.db.zst core-index.db.zst.sha256; do
|
||||
echo "Uploading $f -> $version"
|
||||
curl --fail-with-body --user "${OWNER}:${PACKAGE_TOKEN}" \
|
||||
--upload-file "/tmp/$f" "${pkg}/${version}/${f}"
|
||||
done
|
||||
done
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "complete=${{ steps.maintain.outputs.complete }}"
|
||||
echo "changed=${{ steps.maintain.outputs.changed }}"
|
||||
if [ "${{ steps.maintain.outputs.complete }}" != "true" ]; then
|
||||
echo "Build incomplete — rerun to continue from the checkpoint."
|
||||
echo "Progress lives in /cache/data/explore-staging."
|
||||
elif [ "${{ steps.maintain.outputs.changed }}" != "true" ]; then
|
||||
echo "Nothing new to publish."
|
||||
fi
|
||||
@@ -0,0 +1,182 @@
|
||||
name: Release
|
||||
|
||||
# The sixth workflow, and the one that decides whether the other three
|
||||
# run at all. On every push to main it reads the Conventional Commits
|
||||
# since the last tag, and if any of them is releasable it writes the
|
||||
# changelog, pushes the tag, and creates the Gitea release whose body is
|
||||
# that changelog section. The publishing workflows are keyed on `v*`, so
|
||||
# the tag push is what starts them.
|
||||
#
|
||||
# **Why the tag is pushed with PACKAGE_TOKEN and not the Actions token.**
|
||||
# Gitea, like GitHub, does not start a workflow from a ref pushed by a
|
||||
# workflow's own token (go-gitea#33123). The token is what decides this,
|
||||
# not the workflow — so semantic-release is handed a repositoryUrl
|
||||
# carrying a *user* PAT, and the resulting push is attributed to a person
|
||||
# and triggers the `v*` workflows normally.
|
||||
#
|
||||
# That limitation is used deliberately in the bootstrap step below, where
|
||||
# a tag that must *not* trigger anything is pushed with the Actions token
|
||||
# instead.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
# Cutting a tag is not a thing to cancel halfway: a superseded run must
|
||||
# finish, not be killed between `git push --tags` and the release POST.
|
||||
concurrency:
|
||||
group: release-main
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ubuntu:24.04
|
||||
env:
|
||||
SERVER_URL: ${{ github.server_url }}
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
REPO: ${{ github.repository }}
|
||||
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
steps:
|
||||
- name: System packages
|
||||
run: |
|
||||
set -eu
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends ca-certificates curl git jq
|
||||
|
||||
- name: Node toolchain
|
||||
run: |
|
||||
set -eu
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
apt-get install -y -qq --no-install-recommends nodejs
|
||||
node --version
|
||||
|
||||
# By hand rather than actions/checkout, like the other five: that is
|
||||
# a JS action and needs node inside the container before any step has
|
||||
# installed it. The full history is required — semantic-release
|
||||
# reads tags and walks commits, and a shallow clone silently makes
|
||||
# every release look like the first one.
|
||||
- name: Clone repo at this commit
|
||||
run: |
|
||||
set -eu
|
||||
git clone --quiet \
|
||||
"https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git" /src
|
||||
# -B main rather than --detach, which the other five workflows
|
||||
# use: semantic-release resolves the release branch and then
|
||||
# pushes a commit and a tag to it, and a detached HEAD is a
|
||||
# worse starting point for both than a local branch named after
|
||||
# the one being released. Pinned to this commit, not to
|
||||
# whatever main points at by the time the container started.
|
||||
git -C /src checkout --quiet -B main "${{ github.sha }}"
|
||||
git config --global --add safe.directory /src
|
||||
git -C /src log --oneline -1
|
||||
|
||||
# Nothing currently pushes a `chore(release):` commit — main is a
|
||||
# protected branch, so .releaserc.yml carries no @semantic-release/git
|
||||
# and the release page is the changelog. This guard is kept for the
|
||||
# day someone adds that plugin back: without it the commit-back is a
|
||||
# push to the branch this workflow runs on, and the loop is a release
|
||||
# per release. Six lines against that is cheap.
|
||||
- name: Skip a changelog commit, if one ever exists
|
||||
id: guard
|
||||
working-directory: /src
|
||||
run: |
|
||||
set -eu
|
||||
subject=$(git log -1 --format='%s')
|
||||
case "$subject" in
|
||||
"chore(release):"*)
|
||||
echo "this is the release commit itself; nothing to do"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
esac
|
||||
|
||||
# semantic-release calls the first release of a repo with no tags
|
||||
# 1.0.0, and offers no option to say otherwise. A floor tag is the
|
||||
# only way to start at 0.0.1, so this creates one — once, ever.
|
||||
#
|
||||
# **It is pushed with the Actions token on purpose.** v0.0.0 is a
|
||||
# floor, not a shipment: pushing it with a user PAT would start the
|
||||
# Arch, Homebrew and Android workflows for a version that does not
|
||||
# exist. The very limitation the header describes is what makes
|
||||
# this inert.
|
||||
- name: Seed the version floor
|
||||
if: steps.guard.outputs.skip == 'false'
|
||||
working-directory: /src
|
||||
env:
|
||||
ACTIONS_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -eu
|
||||
git fetch --quiet --tags origin
|
||||
|
||||
if [ -n "$(git tag --list 'v[0-9]*')" ]; then
|
||||
echo "floor already set; newest tag is $(git describe --tags --abbrev=0 --match 'v[0-9]*')"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Prefer the Actions token because a ref it pushes starts no
|
||||
# workflow, which is the whole point for a tag that is a floor
|
||||
# rather than a shipment. Falling back to the PAT is safe
|
||||
# rather than merely convenient: all four publishing workflows
|
||||
# skip v0.0.0 explicitly, so the worst case is four jobs that
|
||||
# start and immediately say there is nothing to build.
|
||||
token="${ACTIONS_TOKEN:-$PACKAGE_TOKEN}"
|
||||
[ -n "$ACTIONS_TOKEN" ] || echo "note: GITEA_TOKEN is unset; using the PAT"
|
||||
|
||||
# **On the parent, not on HEAD.** The floor marks what has
|
||||
# already been released, so tagging the commit being pushed
|
||||
# leaves nothing between the floor and HEAD — semantic-release
|
||||
# then correctly reports there is nothing to release, which is
|
||||
# exactly what the first run of this workflow did. HEAD^ is the
|
||||
# first parent, so on the merge commit this fires for it is main
|
||||
# as it was before the merge, and everything the merge brought
|
||||
# in is releasable.
|
||||
floor=$(git rev-parse "${{ github.sha }}^" 2>/dev/null || true)
|
||||
if [ -z "$floor" ]; then
|
||||
echo "HEAD has no parent, so no commit can precede the floor" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "no v* tag exists — seeding v0.0.0 so the first release is 0.0.1"
|
||||
git tag v0.0.0 "$floor"
|
||||
git push --quiet \
|
||||
"https://x-access-token:${token}@${SERVER_URL#https://}/${REPO}.git" \
|
||||
refs/tags/v0.0.0
|
||||
echo "seeded v0.0.0 at $floor (parent of ${{ github.sha }})"
|
||||
|
||||
# Pinned rather than installed into the repo: this is a Go project
|
||||
# and a package.json at its root invites the npm plugin and every
|
||||
# tool that looks for one. conventional-changelog-conventionalcommits
|
||||
# is in the list because both the analyzer and the notes generator
|
||||
# name that preset and neither depends on it.
|
||||
#
|
||||
# **That preset is held at 9 and the reason is worth keeping.** At
|
||||
# 10 it is silently incompatible with the writer that
|
||||
# release-notes-generator@14 pulls in (^8): every release note comes
|
||||
# out as a bare `## 0.0.1 (date)` heading with **no sections and no
|
||||
# commits under it**, and nothing errors. The version would have
|
||||
# been right, the tag would have been right, every job would have
|
||||
# been green, and the release body would have been empty. Check the
|
||||
# notes, not the exit code, before moving any of these.
|
||||
- name: Run semantic-release
|
||||
if: steps.guard.outputs.skip == 'false'
|
||||
working-directory: /src
|
||||
run: |
|
||||
set -eu
|
||||
git config user.name "yellowjacket-ci"
|
||||
git config user.email "yj@yellowjacket.app"
|
||||
|
||||
npx --yes \
|
||||
-p semantic-release@25 \
|
||||
-p @semantic-release/commit-analyzer@13 \
|
||||
-p @semantic-release/release-notes-generator@14 \
|
||||
-p @semantic-release/changelog@7 \
|
||||
-p @semantic-release/exec@7 \
|
||||
-p conventional-changelog-conventionalcommits@9 \
|
||||
semantic-release \
|
||||
--repository-url "https://x-access-token:${PACKAGE_TOKEN}@${SERVER_URL#https://}/${REPO}.git"
|
||||
@@ -0,0 +1,107 @@
|
||||
name: Unclaim
|
||||
|
||||
# A `Closes #N` footer in a commit body closes the issue on merge — and
|
||||
# leaves `Status/In Progress` on it, because Gitea's auto-close touches
|
||||
# state and nothing else. So #100 was closed and simultaneously marked
|
||||
# as being actively worked on, and `scripts/issue.sh close` (which does
|
||||
# drop the label) is exactly the thing the footer exists to avoid
|
||||
# calling.
|
||||
#
|
||||
# **This hooks the close, not the merge.** Stripping the label in the
|
||||
# PR would work and would be a per-PR habit; habits are what the footer
|
||||
# removed. `issues: [closed]` covers every path an issue can close by —
|
||||
# the footer on merge, `issue.sh close`, someone clicking Close in the
|
||||
# web UI — and asks nothing of anyone at any of them.
|
||||
#
|
||||
# **Reopening deliberately does not restore it.** Reopening says the
|
||||
# work was not finished, not that somebody is at a keyboard doing it
|
||||
# now; the claim gets re-made by whoever picks it up.
|
||||
#
|
||||
# **This is not instant, and should not be described as it.** The
|
||||
# runner has capacity 1 and is shared with an index build that can hold
|
||||
# it for three hours, so a label tweak can queue behind one. Stale for
|
||||
# an afternoon beats stale forever, which is what it was.
|
||||
#
|
||||
# The audit that answers "is this still firing" stays in CLAUDE.md and
|
||||
# is one command:
|
||||
#
|
||||
# ./scripts/issue.sh list --state closed --label "Status/In Progress"
|
||||
#
|
||||
# A workflow that silently stops working is the failure mode this whole
|
||||
# area has already produced once.
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [closed]
|
||||
|
||||
jobs:
|
||||
unclaim:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ubuntu:24.04
|
||||
|
||||
steps:
|
||||
- name: Drop the claim label
|
||||
# **Inside a container the act runner selects `sh`, not bash**, so
|
||||
# `set -o pipefail` fails the job on its second line with "Illegal
|
||||
# option" and the step never reaches the API. `homebrew-formula.yml`
|
||||
# carries the same `set -euo pipefail` without trouble because it
|
||||
# runs with **no container**, on the host image where bash is the
|
||||
# default — so "another workflow does it" is not evidence here.
|
||||
shell: bash
|
||||
env:
|
||||
# The automatic Actions token, as release.yml uses for the
|
||||
# floor tag. It needs no more than write access to this repo.
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||
ISSUE: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# `ca-certificates` is named because `--no-install-recommends`
|
||||
# skips it, and `ubuntu:24.04` ships no CA bundle of its own —
|
||||
# so curl comes up unable to verify TLS against our own Gitea
|
||||
# and fails with "error setting certificate file" (exit 77).
|
||||
# Every other containerised workflow here spells it out for the
|
||||
# same reason; this one did not, and cost a release cycle.
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq --no-install-recommends \
|
||||
ca-certificates curl jq >/dev/null
|
||||
|
||||
label_id=$(
|
||||
curl -sSf -H "Authorization: token $TOKEN" "$API/labels?limit=100" |
|
||||
jq -r '.[] | select(.name == "Status/In Progress") | .id'
|
||||
)
|
||||
|
||||
# The label not existing is a repo somebody reorganised, not a
|
||||
# failure of this run — say so and stop, rather than failing a
|
||||
# job on every close from then on.
|
||||
if [ -z "$label_id" ]; then
|
||||
echo "unclaim: no 'Status/In Progress' label in this repo; nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# DELETE is idempotent here: an issue that never carried the
|
||||
# label answers the same as one that did, which is what makes
|
||||
# this safe to run on *every* close rather than only the ones
|
||||
# that were claimed.
|
||||
# The body is captured, not discarded, so a refusal is
|
||||
# diagnosable from this log alone. Whether the automatic
|
||||
# token carries issue-write scope is still unproven, and
|
||||
# "DELETE returned 403" without Gitea's own sentence costs
|
||||
# another merge to find out which of the two it is.
|
||||
body=$(mktemp)
|
||||
code=$(
|
||||
curl -sS -o "$body" -w '%{http_code}' -X DELETE \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
"$API/issues/$ISSUE/labels/$label_id"
|
||||
)
|
||||
|
||||
case "$code" in
|
||||
204) echo "unclaim: #$ISSUE is closed and unclaimed" ;;
|
||||
*)
|
||||
echo "unclaim: DELETE returned $code for #$ISSUE" >&2
|
||||
cat "$body" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
+51
-2
@@ -1,8 +1,22 @@
|
||||
frontend/dist
|
||||
node_modules
|
||||
build
|
||||
build/bin/
|
||||
test_data
|
||||
test.db
|
||||
|
||||
# ── Agent development harness (plan 005) ──
|
||||
# playwright-cli scratch output (snapshots, console logs, screenshots)
|
||||
.playwright-cli/
|
||||
# headless app process state: pid file + captured stdout
|
||||
.dev/
|
||||
# Playwright spec output: traces, screenshots and videos of failures
|
||||
e2e/test-results/
|
||||
e2e/playwright-report/
|
||||
|
||||
# Vitest browser-mode scratch (screenshot diffs, failure captures).
|
||||
# The committed baselines under frontend/test/**/__screenshots__ stay.
|
||||
frontend/.vitest-attachments/
|
||||
|
||||
.aider*
|
||||
lefthook-local.yml
|
||||
|
||||
@@ -28,7 +42,7 @@ Thumbs.db
|
||||
node_modules/
|
||||
.next/
|
||||
dist/
|
||||
build/
|
||||
# build/ holds v3 build assets and is tracked; only its output is not.
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
@@ -39,3 +53,38 @@ vendor/
|
||||
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
|
||||
|
||||
# iOS is not carried. `wails3 update build-assets` regenerates the tree
|
||||
# whether or not anything asks for it, so it is ignored rather than
|
||||
# deleted-and-rediscovered on every asset refresh, and its includes:
|
||||
# entry is dropped from Taskfile.yml.
|
||||
#
|
||||
# build/android/ *is* carried — see plan 015. Note that `update
|
||||
# build-assets` does NOT regenerate it (only `generate build-assets`
|
||||
# does, and that rewrites the whole of build/), so the tree is committed
|
||||
# and edited by hand like any other source. Only its output is ignored,
|
||||
# below.
|
||||
build/ios/
|
||||
|
||||
# Android build output. jniLibs holds the ~30 MB per-ABI c-shared
|
||||
# libraries the Go build produces; gen/ and overlay.json are written by
|
||||
# `wails3 android overlay:gen`; the rest is Gradle's.
|
||||
build/android/app/src/main/jniLibs/
|
||||
build/android/app/build/
|
||||
build/android/build/
|
||||
build/android/.gradle/
|
||||
build/android/gen/
|
||||
build/android/overlay.json
|
||||
|
||||
# Written by @semantic-release/changelog purely to carry the release notes
|
||||
# into scripts/gitea-release.sh; the release page is the changelog.
|
||||
.release-notes.md
|
||||
|
||||
@@ -29,6 +29,17 @@ linters:
|
||||
- usetesting
|
||||
- whitespace
|
||||
- wsl_v5
|
||||
exclusions:
|
||||
paths:
|
||||
# Wails scaffold, not ours. `build/android/` is generated by
|
||||
# `wails3 generate build-assets` and carried verbatim (plan 015),
|
||||
# and it contains one Go file -- scripts/deps/install_deps.go, the
|
||||
# interactive SDK installer behind `task android:install:deps`.
|
||||
# It trips 24 of the strict linters above, and reformatting
|
||||
# upstream's file to our house style would be undone by the next
|
||||
# refresh and would make the diff against upstream unreadable.
|
||||
# `make android-setup` is what this repo uses instead.
|
||||
- build/android/
|
||||
formatters:
|
||||
enable:
|
||||
- gci
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
description: Promote a hand-driven playwright-cli session into a committed spec in e2e/
|
||||
argument-hint: "[name of the flow]"
|
||||
---
|
||||
Promote the flow I just drove by hand into a committed Playwright spec.
|
||||
Flow: ${@:-infer it from the playwright-cli commands in this session}
|
||||
|
||||
This is a transcription with fixed substitutions, not a fresh test.
|
||||
Work from what actually happened in this session, not from what the UI
|
||||
looks like it should do.
|
||||
|
||||
**1. Recover the flow.** List the `playwright-cli` calls made this
|
||||
session, in order, and the assertion each one was really checking. Then,
|
||||
before writing anything, ask the running app what fired:
|
||||
|
||||
```
|
||||
playwright-cli -s=yj eval "() => window.__yjEvents.names()"
|
||||
```
|
||||
|
||||
Await the events that are actually in that list. Do not guess event
|
||||
names from `backend/events/`.
|
||||
|
||||
**2. Substitute, one for one.**
|
||||
|
||||
- `click e15` → a role or `data-testid` selector. Snapshot refs are
|
||||
per-snapshot and meaningless in a spec. If the only stable selector
|
||||
would be structural, add a `data-testid` to the Lit component and
|
||||
re-run `make ui-test`.
|
||||
- any sleep, or "it looked settled" → `waitForEvent(app, 'X')`.
|
||||
- `window.go.…` → `callBinding(app, path, args)`, which times out.
|
||||
- a short fixture track → `LONG_TRACK`, if the flow needs playback to
|
||||
still be running on the next line. Every other fixture is 2–6 s.
|
||||
- `getByRole('button', { name })` → add `exact: true`.
|
||||
|
||||
**3. Place it.** `e2e/specs/<area>.spec.ts`, importing `test`, `expect`
|
||||
and the helpers from `../support/fixtures.js` — never `@playwright/test`
|
||||
directly. Match the surrounding specs' comment style: say what the test
|
||||
is protecting against, not what the lines do.
|
||||
|
||||
**4. Prove it is a spec and not a recording.** Three runs, in order:
|
||||
|
||||
```
|
||||
make e2e E2E_ARGS='--grep "<name>"' # it passes
|
||||
make e2e E2E_ARGS='--grep "<name>"' # again — catches dependence on
|
||||
# state the first run left
|
||||
```
|
||||
|
||||
then once more after restoring the database through `/__test/`, which
|
||||
catches dependence on state *my hand-driving* left behind — the single
|
||||
most likely way a promoted spec passes here and fails in CI. Leave the
|
||||
database as you found it: snapshot/restore, or reset in `beforeEach`.
|
||||
|
||||
**5. Then the whole suite:** `make e2e`. If the promotion turned up a
|
||||
new trap, append it to `.planning/NOTES.md`; if it turned up a bug,
|
||||
tell me rather than asserting the broken behaviour.
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"skills": ["../.claude/skills"]
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
---
|
||||
name: yellowjacket-dev
|
||||
description: Operating YellowJacket's development harness — which of the four test tiers to use for a given change, how to run the app headless and drive it with playwright-cli, seed and sandbox lifecycle, the three build-tag passes, and the failure modes that waste a cycle if you meet them cold. Use whenever building, running, testing or debugging this repo.
|
||||
---
|
||||
|
||||
# Working on YellowJacket
|
||||
|
||||
`CLAUDE.md` says what this system **is**. This skill says what to
|
||||
**run**. `.planning/NOTES.md` records what we **measured** and when.
|
||||
Keep them in those three tenses: if something here is wrong, fix it
|
||||
here and add the discovery to `NOTES.md` — do not add a corrective
|
||||
paragraph to `CLAUDE.md`.
|
||||
|
||||
Every command below is a `make` target on purpose. The Makefile is the
|
||||
source of truth for *how* to invoke something; this file only decides
|
||||
*which* and *in what order*. `make skill-check` fails if a target named
|
||||
here has disappeared.
|
||||
|
||||
## Read this part before you fail
|
||||
|
||||
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.
|
||||
|
||||
- **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
|
||||
bug. Use a seed unless you are *testing* the wizard, in which case
|
||||
`make dev-headless-fresh`.
|
||||
- **Never `pkill -f`.** The pattern matches the invoking shell's own
|
||||
command line, killing it and silently dropping the rest of your
|
||||
compound command. `make dev-stop` kills by saved PID.
|
||||
- **Seeds are produced by running the app**, never by hand-writing a
|
||||
`config.toml` and DB rows — a hand-built `YJ_HOME` is a second
|
||||
description of a valid one and will drift. `make sandbox-seed` drives
|
||||
the real `AddLibrary` binding and waits for the real scan.
|
||||
- **…and a seed freezes every default it has already persisted.**
|
||||
Changing a default in `backend/config` (or `backend/tracklist`) is
|
||||
invisible against an existing seed, whose `config.toml` holds the old
|
||||
value — while CI builds its seed by running the app and therefore
|
||||
tests the *new* one. Re-seed before believing either.
|
||||
- **A `wa-dialog` is awkward to locate, in three ways.** The host is
|
||||
`display: contents`, so the element carrying your testid always
|
||||
reports hidden; the visible thing is the native `<dialog>` in its
|
||||
shadow root. The slotted content is in the *host's* shadow root, not
|
||||
in that dialog's subtree, so `toContainText` on the dialog sees only
|
||||
its chrome. And it has an accessible name **only because
|
||||
`utils/name-dialog.ts` gives it one** — Web Awesome does not wire
|
||||
`label` to `aria-labelledby` — so a new dialog that forgets to call
|
||||
the helper from `updated()` is invisible to
|
||||
`getByRole('dialog', {name})`.
|
||||
- **A name is computed on the element carrying the *role*, and Web
|
||||
Awesome puts the role in its own shadow root.** `aria-label` on a
|
||||
`<wa-slider>` or a `<wa-dialog>` host never reaches the tree. Use the
|
||||
component's own `label` (plus `styles/wa-slider-label.css.ts`, since
|
||||
a slider's is visible) or `utils/name-dialog.ts`. And in the light
|
||||
DOM, a `<label>` that is a *sibling* of its control with no `for`
|
||||
names nothing — that was 24 of the 93 controls on Settings.
|
||||
**`getFullAXTree` is how you check, and "0 unnamed" is not the whole
|
||||
answer**: a `placeholder` is an accname fallback, so a box labelled
|
||||
only by one reports clean.
|
||||
- **The a11y snapshot cannot check an accessible name on a dialog.**
|
||||
`playwright-cli snapshot` prints `- dialog [ref=…]` with no name
|
||||
whether the dialog is named by `aria-labelledby`, by `aria-label`,
|
||||
or not at all — checked all three ways against the running app. Use
|
||||
`getByRole('dialog', {name})` in a spec, or CDP
|
||||
(`Accessibility.getFullAXTree`) for the browser's own computation,
|
||||
which also reports *where* the name came from. A snapshot read as a
|
||||
probe here reports failure on a working build.
|
||||
- **Playwright's WebKit does not run on Arch** (Ubuntu-only libs).
|
||||
`--browser=webkit` is CI-only; local work is Chromium. CI runs it
|
||||
with `if: !cancelled()` so a chromium failure does not silently
|
||||
skip it, which it did for two sessions.
|
||||
- **CI's `e2e` job is green on both engines** (88 specs each) since the
|
||||
container got an audio device that keeps time. If playback specs
|
||||
start failing there again, check the **`The sink plays at real time`**
|
||||
step first: ALSA's `null` plugin consumes 3000 ms of audio in 2.96 ms,
|
||||
so every track finishes instantly and the clock never moves — which
|
||||
reads as an app bug and cost two sessions of that suspicion.
|
||||
- **`make e2e` needs `SEED=default`.** Its specs assert on fixture
|
||||
content — unicode tracks, the fixture artists, a known playable file.
|
||||
Run against the `bulk` seed a measurement session left behind and a
|
||||
third of them fail (13 of 36, when it was measured), in a list that
|
||||
reads exactly like a regression in whatever you are holding. `make dev-headless SEED=default` first.
|
||||
- **The catalog is stubbed out locally now, like CI.**
|
||||
`dev-headless.sh` defaults `YJ_CORE_INDEX_URL` to a dead address
|
||||
because it was the only launcher that did not — `seed-sandbox.sh` and
|
||||
`ci.yml` always have. Without it the app downloads the real ~1M-row
|
||||
Explore catalog into the run's `YJ_HOME`, and specs that stage their
|
||||
own catalog rows then search a million real ones and fail *locally
|
||||
only*, which reads as a regression and is an environment. Pass
|
||||
`YJ_CORE_INDEX_URL=<real url>` when you want the real catalog to
|
||||
explore by hand.
|
||||
- **…and the suite spends state it cannot always give back.**
|
||||
`view-lifecycle.spec.ts` **skips an autotag album** on every run, out
|
||||
of the eleven the seed has, and does not put it back — so around the
|
||||
eleventh consecutive run against one app it starts failing on an
|
||||
empty queue. Restart between runs (`make dev-stop && make
|
||||
dev-headless SEED=default`) when a spec starts failing that you have
|
||||
not touched, and *before* believing a failure at all. Backend state
|
||||
outlives the page: shuffle used to be left on the same way, which
|
||||
failed `playback.spec` on the next run — that one is fixed, the
|
||||
autotag one is inherent.
|
||||
- **A frontend edit is not live until you restart the app.** Vite
|
||||
updates the module, but an already-registered custom element class
|
||||
cannot be re-registered, so a running page keeps the old one and your
|
||||
change reads as having done nothing — including across a browser
|
||||
reload. `make dev-stop && make dev-headless SEED=…`, then re-check.
|
||||
The nastier version: a **build error leaves the dev server serving
|
||||
the last good bundle**, so the page still works and still shows the
|
||||
old behaviour. `make dev-headless` prints the esbuild error; a
|
||||
reload does not. One way to cause one is a stray backtick inside a
|
||||
comment in a `css` tagged template literal, which ends the literal.
|
||||
**That one is a check now** — `make css-check` (instant, a pre-commit
|
||||
hook and a CI step) names the file, the line and the cause, because
|
||||
what you otherwise get is `Property 'scroll' does not exist on type
|
||||
'CSSResult'` pointing at a line of prose, or every test in the suite
|
||||
failing to import. It went in after the trap cost a fourth session in
|
||||
which its own warning had been read twice.
|
||||
- **A failing CI job's log is reachable even when `gitea_ci job_logs`
|
||||
says it is not.** That endpoint 404s on this Gitea build. The REST
|
||||
API answers, with the `GITEA_TOKEN` already in the environment:
|
||||
`/api/v1/repos/yonlu/yellowjacket/actions/runs/<run>/jobs` for
|
||||
per-step status (this is how "the WebKit step was *skipped*" was
|
||||
found) and `/api/v1/repos/yonlu/yellowjacket/actions/jobs/<id>/logs`
|
||||
for the whole log. Two sessions reasoned about the e2e failure from
|
||||
the commit list because the first tool's 404 read as "out of reach".
|
||||
- **`npx tsc --noEmit` is part of the gate, and nothing else runs it.**
|
||||
CI does (`.gitea/workflows/ci.yml`), and it typechecks
|
||||
`frontend/test/` — which `make lint`, `make test`, `make ui-test` and
|
||||
`make e2e` do not. A tree can be green on all four and red in CI.
|
||||
|
||||
## Which tier
|
||||
|
||||
Four tiers. Start at the cheapest one that can see your change, and
|
||||
only climb when it cannot.
|
||||
|
||||
| You changed | Run | Cost |
|
||||
|---|---|---|
|
||||
| A Lit component, a store, the shortcut service | `make ui-test` | ~2 s, no app |
|
||||
| …and it renders differently | `make ui-visual` | + 6 baselines, opt-in |
|
||||
| Any Go code | `make test` | 3 passes, ~2 min |
|
||||
| A service that emits events | `make test` — assert on the payload, see `backend/queue/emit_test.go` | in-process, no app |
|
||||
| A bound method or a bound struct field | `make bindings` then `make ui-test` | ~1.5 s + 2 s |
|
||||
| A user-visible flow across frontend *and* backend | `make e2e` (needs the app up) | ~1 min |
|
||||
| Something you cannot predict — exploring | `make dev-headless SEED=default` + `playwright-cli` | interactive |
|
||||
| Something whose answer is a *number*, not a pass | `make perf` against a bulk-seeded app | ~1 min + setup |
|
||||
| A `.sql` or `.templ` file | `make generate`, then the checklist in [references/schema-change.md](references/schema-change.md) | |
|
||||
| Anything that has to survive on a phone | `make android-smoke` against a booted emulator | ~1 min + setup |
|
||||
|
||||
Two targets are once-per-clone prerequisites that are **not**
|
||||
dependencies of the targets needing them, so on a fresh checkout each
|
||||
fails with a missing-browser error that reads like a broken test:
|
||||
`make ui-setup` before `make ui-test`, and `make e2e-setup` before
|
||||
`make e2e`. (`make testdata` *is* a dependency of `make test` and
|
||||
`make sandbox-seed`; run it by hand only when invoking `go test`
|
||||
directly, since anything using `internal/testfixtures` **skips**
|
||||
rather than fails without it — a green run without the library means
|
||||
less than it looks.)
|
||||
|
||||
Two rules about climbing:
|
||||
|
||||
- **A component test passing is not the app rendering.** If you touched
|
||||
anything in `frontend/src`, verify it in the real app too — start it
|
||||
headless, `screenshot --filename=/tmp/shot.png`, and *read the PNG*.
|
||||
Two of this repo's worst regressions were only ever visible there: a
|
||||
header badge contradicting the settings page, and a virtualized row
|
||||
whose columns no longer lined up with its own header. Neither failed
|
||||
anything.
|
||||
- **A list that renders is not a list that repaints.** `lit-virtualizer`
|
||||
re-renders its rows when one of its *own* properties changes, not
|
||||
when the parent does — so selection highlighting, the playing-track
|
||||
row and anything else driven by host state need an explicit
|
||||
`virtualizer.requestUpdate()`. Click a row and look, every time you
|
||||
touch one of these lists; the controller will hold the right state
|
||||
either way. **Check `el.viewActive` first**: dispatching a raw
|
||||
`navigate` event does not always activate a view, and an inactive one
|
||||
does not render at all — which looks exactly like this bug (the
|
||||
controller holds the selection, no row highlights) and is Phase 1
|
||||
working as designed. Navigate by clicking the sidebar.
|
||||
- **Do not write an e2e spec first.** Drive the flow by hand, then
|
||||
promote it with `/e2e`. Specs written blind assert on selectors that
|
||||
do not exist.
|
||||
|
||||
Before a commit, the gate is `make lint`, `make test`, `make ui-test`,
|
||||
`make bindings-check`, `make css-check` and — from `frontend/` —
|
||||
`npx tsc --noEmit`. The
|
||||
first four are lefthook hooks, so skipping them locally only defers the
|
||||
failure; the typecheck is a hook too but only CI runs it over the test
|
||||
tree, which is where it has actually broken.
|
||||
|
||||
The **message** is gated too: `make commit-check` (a `commit-msg` hook,
|
||||
and a CI step over every commit in a push) rejects a subject that is not
|
||||
`type(scope): subject`, is over 72 chars, or ends with a period. A
|
||||
`--no-verify` commit skips it locally and meets it in CI.
|
||||
|
||||
Two things about the e2e tier that are not obvious until they bite.
|
||||
**The 88 specs share one backend process in file order**, so a spec
|
||||
that leaves the app somewhere passes alone and fails the suite — leave
|
||||
the UI as you found it, and *wait* for it rather than trusting the
|
||||
click to have finished. The queue panel's width is animated and the
|
||||
transport slides with it, so a click issued while it closes lands on
|
||||
whichever button moved under the pointer. And **anything asserting on
|
||||
the queue panel's rows must open it first**: a closed panel renders no
|
||||
list at all.
|
||||
|
||||
## Measuring, when a pass is not the answer
|
||||
|
||||
Performance claims need a before and an after on the same machine
|
||||
against the same library, or they are anecdotes. The fixture library
|
||||
is a few dozen tracks and cannot show any of it.
|
||||
|
||||
```bash
|
||||
make bulkdata # ~11 s, 466 MB into a gitignored .dev/
|
||||
make sandbox-seed-bulk # minutes: it is a real scan of 50 000 files
|
||||
make dev-headless SEED=bulk
|
||||
make perf LABEL=before # ... make the change ...
|
||||
make perf LABEL=after
|
||||
make perf-compare BEFORE=before AFTER=after
|
||||
```
|
||||
|
||||
Fourteen numbers: startup (and the count of cross-origin requests, which
|
||||
is whether the app works offline), the bundle's shape and each view's
|
||||
first open, keystroke-to-paint in the search box, what a naturally
|
||||
finished track provokes, what one favourite toggle costs, what sitting
|
||||
idle on Settings costs, what **scrolling** a long list costs (image
|
||||
bytes and the tier they were requested at, plus frame cost through the
|
||||
artist grid), what a long **Explore session** retains (heap sampled
|
||||
after each of twenty-four searches, plus every registered cache's
|
||||
size), what opening a **2 000-track playlist** costs (elements
|
||||
retained, eager cover requests, heap, and what one update pass costs
|
||||
and rebinds), what the **selection** costs (ordering the selected keys
|
||||
with one row selected at either end of 50 000 and with all of them, and
|
||||
what "Select all → Edit tags" blocks for), what an **update pass of the
|
||||
player bar** costs (querySelectors, layout reads, style writes and the
|
||||
read-after-write interleaves inside `updated()`, measured with a clean
|
||||
DOM and a dirty one, plus six seconds of real playback), how many
|
||||
**document pointer listeners** are installed at rest (via CDP, so
|
||||
nothing else in the run is perturbed), what **"play these"** costs for
|
||||
an artist, twenty albums and five genres, and heap after a scripted
|
||||
browse. It wraps every bound Go method, so "did that refetch the
|
||||
library" is a fact rather than an inference.
|
||||
|
||||
`window.__yjCacheStats()` reports every registered cache's entries,
|
||||
retained chars and cap in one eval — which is how you check a bound is
|
||||
still holding without rebuilding the reproduction that justified it.
|
||||
|
||||
Adding a number is usually the first half of an item's work: most
|
||||
findings are not among the seven, and the fix cannot be believed
|
||||
without one. Two rules for adding one.
|
||||
|
||||
**Stage what the seed does not have, idempotently and by name.** The
|
||||
bulk seed has one empty playlist, against which "toggling a heart
|
||||
refetches every playlist" costs nothing and cannot be reproduced; the
|
||||
favourite measurement builds ten 500-track playlists first. Staging by
|
||||
name means a before and an after see the same shape — and
|
||||
`dev-headless` restores the seed tarball on every launch, so it is
|
||||
rebuilt each run anyway.
|
||||
|
||||
**Measure both halves of a trade.** Route splitting reports bytes
|
||||
before first paint *and* the slowest first open of a view, because a
|
||||
split that halves startup by making every page visibly slower has not
|
||||
helped anyone.
|
||||
|
||||
**Measure the state the cost depends on, not just the operation.** A
|
||||
forced layout costs 3 µs against a clean layout and 0.1 ms against a
|
||||
dirty one, so a component measured only in its steady state reports
|
||||
that the finding about it is imaginary. If the work is conditional,
|
||||
stage both conditions and put both rows in the table — they explain
|
||||
each other, and one of them is the number the fix has to move.
|
||||
|
||||
Fourteen traps, each of which produced a wrong number first:
|
||||
|
||||
- **A label is a filename, and audit IDs are case-insensitive as
|
||||
filenames.** `.dev/perf/before-m6.json` is the *capital* `M6` (the
|
||||
3 s ticker) from an earlier pass; measuring lowercase `m6` under that
|
||||
name silently overwrites a baseline three passes of numbers depend
|
||||
on. Name a label after the *change*, not the finding.
|
||||
- **The first run after a rebuild is not a measurement — and the
|
||||
second is not reliably a good one either.** A run taken immediately
|
||||
after `make dev-headless` often reports first contentful paint at
|
||||
96–112 ms against 28–32 ms on the next run of the same build (a cold
|
||||
Vite module graph). But the ordering does not hold: one pass saw 100
|
||||
then 96, and another 28 then 76. FCP moves ±50 ms for reasons this
|
||||
harness does not control, so take two, and if they disagree report it
|
||||
as noise rather than taking a third until they agree.
|
||||
- **A measurement is against whatever seed the app is running.**
|
||||
`make e2e` needs `SEED=default`, so a confirming perf run taken
|
||||
straight after one measures a few dozen tracks: "Play 20 albums"
|
||||
becomes a dash and an artist's bytes fall 40×. Plausible in shape,
|
||||
meaningless. Restart on `bulk` before re-measuring anything.
|
||||
- **A `longtask` entry is delivered *after* the task that produced
|
||||
it.** Reading `window.__yjPerf.longtasks` synchronously after the
|
||||
operation you just timed reports **0 ms of blocking beside a
|
||||
six-second stall**. Wait a couple of hundred milliseconds first. The
|
||||
tell is that the two numbers in the row disagree — which is a good
|
||||
reason to always measure blocking *and* wall time.
|
||||
|
||||
|
||||
- **`make dev-headless` immediately after `make sandbox-seed`** loses
|
||||
the race for port 34115 and comes up with no dev server, while still
|
||||
printing `up`. The measurement then attaches to a dying app. Sleep,
|
||||
or check `curl -s -o /dev/null -w '%{http_code}' localhost:34115`.
|
||||
- **`search-bar` debounces 150 ms.** Anything measuring to the next
|
||||
frame measures the input echoing its own character.
|
||||
- **`__yjEvents.wait()` returns an already-buffered event.** Without a
|
||||
`reset()` first you get the previous run's answer, which looks like a
|
||||
real result and is off by one iteration.
|
||||
- **A `0 ms` result is usually a broken measurement, not a win.**
|
||||
Waiting for `#main-content > :not(.view-hidden)` after a navigation
|
||||
matches the view being left — it stays on screen until the incoming
|
||||
one is ready — so every view reported 0 ms on every build. Wait for
|
||||
the specific element, never a generic selector. Same tell as the
|
||||
debounce: **a number that cannot move is not evidence.**
|
||||
- **`git stash` will not give you a baseline** on a tree carrying
|
||||
uncommitted phases: stashing one file reverts *every* uncommitted
|
||||
change in it, not the one being measured. Build the before by undoing
|
||||
the single change by hand in the current file. For a cap or a
|
||||
threshold, setting the constant to `Infinity` is the cleanest
|
||||
possible one-variable undo.
|
||||
- **A bound cannot be verified by a run that never reaches it.** The
|
||||
first bounded build measured *identical* to the unbounded one,
|
||||
because the session cached 180 entries against a cap of 192 and never
|
||||
evicted anything. Same tell as the two traps above — before and after
|
||||
suspiciously equal. Make the session overrun the limit.
|
||||
- **A negative result inherits the coverage of whatever produced it.**
|
||||
Two sessions recorded the unbounded Explore caches as "does not
|
||||
reproduce" from a browse script that visits Explore and never
|
||||
*searches* in it — so both caches were empty the whole time. Before
|
||||
believing a finding did not reproduce, check the code path it names
|
||||
actually ran.
|
||||
- **A measurement that warms something has to run after everything
|
||||
that reads it.** The playlist-open number pulls ~90 cover images;
|
||||
placed before the scroll measurement it filled the HTTP cache and
|
||||
took that row's request count from 26 to 0 — a clean, plausible,
|
||||
entirely fabricated improvement in a number nothing had touched. It
|
||||
runs last now, which costs it its own request count (zero on any
|
||||
build, so that row is in the JSON and off the table).
|
||||
- **The bulk library's covers are 300×300 and ~3.7 kB**, deliberately
|
||||
(a realistic cover generator made a 2 GB library). Any finding about
|
||||
full-size artwork cannot show its magnitude here; measure the
|
||||
mechanism instead — e.g. *which tier the request asked for* rather
|
||||
than bytes saved.
|
||||
|
||||
## Running the app
|
||||
|
||||
The app cannot be started without a display: `devserver.Run` ends in a
|
||||
blocking GTK window with no flag to suppress it. The harness gives it a
|
||||
virtual one and returns.
|
||||
|
||||
```bash
|
||||
make sandbox-seed NAME=default # once (~10 s; runs make testdata itself,
|
||||
# then builds a seed by running the app)
|
||||
make dev-headless SEED=default # starts in the background, returns when :34115 answers
|
||||
make dev-logs # tail .dev/app.log
|
||||
make dev-stop # SIGTERM, so shutdown hooks persist state
|
||||
```
|
||||
|
||||
Then drive it. Run `playwright-cli` **from the repo root** — it picks
|
||||
up `.playwright/cli.config.json` from the cwd, and writes its snapshots
|
||||
and console logs to `.playwright-cli/` relative to the cwd too. `playwright-cli`'s own skill covers the commands; what
|
||||
is specific here is that a session must be *named* so it survives
|
||||
across separate shell calls:
|
||||
|
||||
```bash
|
||||
playwright-cli -s=yj open http://localhost:34115
|
||||
playwright-cli -s=yj snapshot # a11y tree, pierces shadow DOM
|
||||
playwright-cli -s=yj screenshot --filename=/tmp/shot.png
|
||||
playwright-cli -s=yj eval "() => window.__yjEvents.names()"
|
||||
playwright-cli -s=yj eval "() => window.__yjEvents.call('queue.Queue.GetState', [], 5000)"
|
||||
playwright-cli -s=yj click e391 # ref from the snapshot
|
||||
playwright-cli -s=yj close # `make dev-stop` does not do this
|
||||
```
|
||||
|
||||
`snapshot` prints a *path*, not the tree — read the file it names, and
|
||||
check the timestamp, because a stale one from a previous session sits
|
||||
in the same directory.
|
||||
|
||||
`.playwright/cli.config.json` is picked up automatically: it sets the
|
||||
viewport, `data-testid`, and the init script that installs the event
|
||||
bridge. **Assert on an event, not a timeout** — half this app is
|
||||
push-driven. The bridge and the dev-only `/__test/` control surface are
|
||||
documented in [references/harness.md](references/harness.md).
|
||||
|
||||
Other `YJ_HOME`s exist for humans and block the terminal: `make dev`,
|
||||
`make sandbox <name>`, `make fresh-install`. Do not use them; you will
|
||||
never get the shell back.
|
||||
|
||||
## Go, and the three build configurations
|
||||
|
||||
`make test` and `make lint` already run all three. Spell them out only
|
||||
when iterating on a single package:
|
||||
|
||||
```bash
|
||||
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
|
||||
package. Audio integration tests additionally need
|
||||
`YELLOWJACKET_INTEGRATION=1`.
|
||||
|
||||
Three things golangci-lint v2 will reject that are easy to write:
|
||||
a dynamic `fmt.Errorf` without a sentinel (`err113`), a `return` with
|
||||
no blank line before it (`nlreturn`), and a long `//nolint` comment on
|
||||
the same line as its statement (`golines` reflows it and breaks the
|
||||
directive) — put the directive on its own line above.
|
||||
|
||||
**Emit events through `events.Emit(ctx, …)`, never
|
||||
`runtime.EventsEmit`.** `TestNoDirectRuntimeEmits` walks the tree and
|
||||
fails the build otherwise, including in files no lint pass compiles.
|
||||
|
||||
## References
|
||||
|
||||
- [harness.md](references/harness.md) — the event bridge API, the
|
||||
`/__test/` endpoints, and the config traps.
|
||||
- [fixtures.md](references/fixtures.md) — the generated library, the
|
||||
manifest, and selecting fixtures by case.
|
||||
- [ui-tier.md](references/ui-tier.md) — how the Vitest tier fakes Wails,
|
||||
and what breaks in it.
|
||||
- [schema-change.md](references/schema-change.md) — the two-file
|
||||
schema/migration checklist.
|
||||
- [android-tier.md](references/android-tier.md) — the emulator tier,
|
||||
and the three reasons a failure there looks like a success. **Read
|
||||
its first section before running anything on Android**: Go's stdout
|
||||
does not reach logcat, `os.Exit` leaves no panic and no tombstone,
|
||||
and ActivityManager restarts a dying app fast enough that `pidof`
|
||||
always answers.
|
||||
@@ -0,0 +1,350 @@
|
||||
# The Android tier
|
||||
|
||||
A sixth tier, and the only one where **the app failing looks exactly
|
||||
like the app working**. Read the first section before you run anything;
|
||||
it is the difference between a diagnosis and an afternoon.
|
||||
|
||||
This tier answers "does the phone build run", nothing else. It is not a
|
||||
spec tier, it does not run in CI, and the app is not a usable Android
|
||||
player yet (plan 015 says why, at length).
|
||||
|
||||
## Three facts that make failure invisible
|
||||
|
||||
**Go's stdout does not reach logcat.** An Android app's fd 1 and 2 go to
|
||||
`/dev/null`. Every `slog` line the app writes is discarded — including
|
||||
the one naming the error it is about to exit on. `setprop
|
||||
log.redirect-stdio true` does not help: it redirects the *Java*
|
||||
runtime's `System.out`, and the Go code is a c-shared native library.
|
||||
|
||||
**`os.Exit` is a silent death.** `main()` ends several failure paths in
|
||||
`os.Exit(1)`. From Android's side that is a process that vanished:
|
||||
`ActivityManager: Process com.wails.app has died`, `Zygote: exited due
|
||||
to signal 9`, and **no** panic, **no** `AndroidRuntime` stack, **no**
|
||||
tombstone under `/data/tombstones` and nothing in `logcat -b crash` or
|
||||
dropbox. All three of the places you would look are empty, and the one
|
||||
signal that is present — SIGKILL — reads as "the system killed it",
|
||||
which is the wrong hypothesis.
|
||||
|
||||
**ActivityManager restarts it, so a dead app looks alive.** A
|
||||
crash-looping app is respawned several times a second, so `pidof` always
|
||||
answers and `am start` always reports `Status: ok`. "Did it start" is
|
||||
the wrong question. `make android-smoke` asks the right one — is it the
|
||||
*same pid* a few seconds later.
|
||||
|
||||
The tell, once you know it: `I/WailsBridge: Wails bridge initialized`
|
||||
followed immediately by a new pid doing the same thing. That means the
|
||||
native library loaded, the JNI bridge came up, Go's `main()` ran, and
|
||||
`main()` left. Work backwards through its `os.Exit(1)` paths.
|
||||
|
||||
## What to run
|
||||
|
||||
One-time, ~3.5 GB:
|
||||
|
||||
```bash
|
||||
make android-setup # SDK pieces + the yj-test AVD, idempotent
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
make android # arm64-v8a APK -> bin/yellowjacket.apk (~16 MB)
|
||||
make android-emulator # boot headless in the background, wait for boot
|
||||
make android-install # adb install -r
|
||||
make android-smoke # launch, then assert the same pid survives 10s
|
||||
make android-logs # filtered logcat, follow
|
||||
make android-emulator-stop # console kill, then the saved PID
|
||||
```
|
||||
|
||||
`make android-smoke SECONDS=30` for a longer window. On failure it
|
||||
prints the last 40 app-relevant logcat lines and how to read them.
|
||||
|
||||
Never `pkill -f emulator` — the pattern matches the invoking shell's own
|
||||
command line and kills it, silently dropping the rest of your compound
|
||||
command. The emulator is addressed by its saved pid in
|
||||
`.dev/emulator.pid`, same discipline as `make dev-stop`.
|
||||
|
||||
**adb is addressed by AVD name, not by whatever is plugged in.** The
|
||||
script resolves `ANDROID_SERIAL` from `ro.boot.qemu.avd_name` before
|
||||
any device command, because a second emulator (another project's, or
|
||||
this one's own corpse left `offline` by a previous run) makes a bare
|
||||
`adb` fail with "more than one device" — which `cmd_install` reported
|
||||
as *"no device — run 'make android-emulator' first"* immediately after
|
||||
that had succeeded. Serials are assigned in boot order and change
|
||||
between runs, so the AVD name is the identity. Set `ANDROID_SERIAL`
|
||||
yourself and it is honoured; one device that is not ours (a phone) is
|
||||
taken as the target.
|
||||
|
||||
## Things that cost a cycle
|
||||
|
||||
- **`ANDROID_HOME` must carry a platform, and Arch's does not.**
|
||||
`/opt/android-sdk` (the `android-sdk` package) has an NDK and
|
||||
build-tools but `platforms/` is *empty*, so Gradle fails with a
|
||||
compileSdk error that reads like a version mismatch. The Makefile
|
||||
defaults `ANDROID_SDK` to `~/Android/Sdk` (user-owned, writable,
|
||||
where sdkmanager puts things) and `ANDROID_NDK` to `/opt/android-ndk`
|
||||
separately, because the Go half wants the NDK and the Gradle half
|
||||
wants the platform and they are in different places.
|
||||
- **The NDK is pinned to r26d** (`26.3.11579264`, Arch's
|
||||
`android-ndk-26`). Newer NDKs have broken the Wails Android build
|
||||
before. CI pins the same one.
|
||||
- **Without KVM the emulator still works and is unusably slow** — a 30 s
|
||||
boot becomes tens of minutes, which reads as a hung target rather than
|
||||
a slow one. `make android-setup` checks and warns.
|
||||
- **`-no-snapshot` is deliberate.** A snapshot-resumed emulator carries
|
||||
the previous run's app state, and a smoke result that depends on what
|
||||
the last run left behind is not a result.
|
||||
- **The logcat filter is not optional.** The emulator emits thousands of
|
||||
lines a second, nearly all WindowManager transitions; an unfiltered
|
||||
`adb logcat` buries the six lines that matter. `make android-logs`
|
||||
filters to `WailsBridge`, the app's own tag, `GoLog`, `AndroidRuntime`,
|
||||
`DEBUG` and `libc:F`.
|
||||
- **`run-as` does not work on a release-signed APK** (`package not
|
||||
debuggable`), so you cannot read the app's data directory or its
|
||||
environment that way. Ask the device instead, or build a debug variant.
|
||||
- **The `google_apis` system image, not `default`.** This app is a
|
||||
WebView app; `google_apis` ships the Chrome-based WebView that
|
||||
actually renders it.
|
||||
|
||||
## The current state of the build
|
||||
|
||||
**The app starts. The x86_64 emulator cannot run it, and that is not a
|
||||
bug in the app.**
|
||||
|
||||
`modernc.org/libc` — which `modernc.org/sqlite`, and therefore the whole
|
||||
database layer, sits on — issues a **raw `lstat` syscall on
|
||||
linux/amd64** (`libc_linux_amd64.go`'s `Xlstat64` calls
|
||||
`unix.Syscall(unix.SYS_LSTAT, …)`). Android's seccomp policy forbids
|
||||
syscall 6 on x86_64, because bionic never issues it, so the process
|
||||
takes `SIGSYS` the first time anything touches the database:
|
||||
|
||||
```
|
||||
F/libc: Fatal signal 31 (SIGSYS), code 1 (SYS_SECCOMP), syscall 6
|
||||
F/DEBUG: Cause: seccomp prevented call to disallowed x86_64 system call 6
|
||||
```
|
||||
|
||||
**arm64 is unaffected, and structurally so.** There is no `lstat`
|
||||
syscall on arm64 at all, so `ccgo_linux_arm64.go`'s `Xlstat` is
|
||||
`Xfstatat(…, AT_SYMLINK_NOFOLLOW)` → `SYS_newfstatat` (79), which
|
||||
Android permits. `grep -c SYS_LSTAT ccgo_linux_arm64.go` is 0. Go's own
|
||||
`syscall` package already uses `fstatat` on both architectures, which
|
||||
is why this is *only* the modernc path.
|
||||
|
||||
So: **verify on arm64, and on this machine that means a real device.**
|
||||
`make android-smoke` on an x86_64 AVD reports a `SIGSYS` tombstone that
|
||||
says nothing about your change.
|
||||
|
||||
**Do not reach for an arm64 system image — it will not run here, and
|
||||
finding that out costs a 3.8 GB download.** Emulator 37 refuses
|
||||
outright:
|
||||
|
||||
```
|
||||
FATAL | Avd's CPU Architecture 'arm64' is not supported by the QEMU2
|
||||
emulator on x86_64 host. System image must match the host
|
||||
architecture.
|
||||
```
|
||||
|
||||
Google dropped cross-architecture emulation; there is no flag. The
|
||||
options are an arm64 host, a physical device, or `adb connect` to one.
|
||||
|
||||
**The x86_64 ABI is therefore gone from the build** (`abiFilters` in
|
||||
`build/android/app/build.gradle`, `android:package` rather than
|
||||
`package:fat` in the Makefile, and a `native-code: 'arm64-v8a'$`
|
||||
assertion in `android-apk.yml` that fails if it comes back). It could
|
||||
not run on any Android until modernc fixes this — x86 Chromebooks
|
||||
included — and dropping it took the artifact from 27 MB to 15.9 MB.
|
||||
The tombstone was at least honest while it lasted: unlike the
|
||||
`os.Exit` that came before it, it left a real crash record with a
|
||||
backtrace.
|
||||
|
||||
### The emulator still installs it, and it still does not run
|
||||
|
||||
The obvious guess about dropping x86_64 — that `make android-install`
|
||||
would now refuse with `INSTALL_FAILED_NO_MATCHING_ABIS` — is **wrong,
|
||||
and was measured wrong before it was written down.** Google's
|
||||
`google_apis` x86_64 images carry arm64 translation:
|
||||
|
||||
```
|
||||
ro.product.cpu.abilist = x86_64,arm64-v8a
|
||||
```
|
||||
|
||||
So the arm64-only APK installs, the loader maps `lib/arm64/libwails.so`
|
||||
and runs it (the tombstone says `Guest architecture: 'arm64'`). It then
|
||||
dies **before any of our code**, with SIGILL rather than SIGSYS:
|
||||
|
||||
```
|
||||
signal 4 (SIGILL), code -6 (SI_TKILL)
|
||||
#00 pc 00000000015911d0 .../lib/arm64/libwails.so
|
||||
```
|
||||
|
||||
Disassembling that offset names the reason exactly:
|
||||
|
||||
```
|
||||
15911d0: d5380600 mrs x0, ID_AA64ISAR0_EL1
|
||||
```
|
||||
|
||||
That is Go's `internal/cpu` reading the arm64 CPU-feature ID register
|
||||
at runtime init, which the translator does not implement. So it is not
|
||||
"our Go program is unlucky": **no Go binary starts under this
|
||||
translation layer**, and no amount of work on this app changes it.
|
||||
|
||||
The three failures are worth holding side by side, because each looks
|
||||
like the app's fault and none is:
|
||||
|
||||
| build | on x86_64 Android | signal |
|
||||
|---|---|---|
|
||||
| x86_64 | modernc's raw `lstat` vs seccomp | SIGSYS, syscall 6 |
|
||||
| arm64, translated | Go reads `ID_AA64ISAR0_EL1` | SIGILL |
|
||||
| arm64, real device | — | unverified, still |
|
||||
|
||||
**A physical arm64 device remains the only verification path.**
|
||||
|
||||
### What was fixed to get here
|
||||
|
||||
`backend/system`'s `buildUserDirPath` switched on `runtime.GOOS` with a
|
||||
`default:` returning `errUnsupportedOS`, so Android failed at startup
|
||||
and `main()` called `os.Exit(1)` six milliseconds after the bridge came
|
||||
up. `main()` now calls `system.UseHomeOverride(application.Mobile.
|
||||
StoragePath())` before anything asks for a path — a documented,
|
||||
build-tag-free API that returns `""` on desktop, where the setter is a
|
||||
no-op. `backend/system` gained no import of the Wails application
|
||||
package, which matters for the same reason `backend/events` is split by
|
||||
the `indexbuild` tag.
|
||||
|
||||
### What is still not done
|
||||
|
||||
The shell is still a desktop shell, and the x86_64 half of the APK is
|
||||
still dead weight. Everything in plan 016's section A is now built:
|
||||
storage access, an in-app folder picker (Android's directory dialog
|
||||
returns an error, since the Storage Access Framework yields tree URIs
|
||||
rather than paths), MPRIS excluded, and a MediaSession with a transport
|
||||
notification and audio focus.
|
||||
|
||||
### Compiling the `android`-tagged Go by hand
|
||||
|
||||
`make lint` and `make test` never see it: their three tag sets are all
|
||||
linux/amd64, so the only thing that compiles `backend/mediacontrols/
|
||||
android.go` is `make android` — a full APK build for a Go type error.
|
||||
The short way round:
|
||||
|
||||
```bash
|
||||
B=$(echo /opt/android-ndk/toolchains/llvm/prebuilt/*/bin)
|
||||
CC=$B/aarch64-linux-android21-clang CXX=$B/aarch64-linux-android21-clang++ \
|
||||
GOOS=android GOARCH=arm64 CGO_ENABLED=1 go build ./backend/...
|
||||
```
|
||||
|
||||
**`CXX` is not optional.** Without it the oboe C++ sources in `oto`
|
||||
compile against the host sysroot and fail on `android/log.h` and
|
||||
`sys/system_properties.h`, which reads like a broken or missing NDK.
|
||||
Restrict it to `./backend/...`: `./...` additionally builds
|
||||
`build/android/gen`, a scaffold shim that only resolves inside the
|
||||
wails task and fails with `undefined: main` on its own.
|
||||
|
||||
A Go method added to a bound service also reaches the frontend unless
|
||||
it says not to — `//wails:ignore` above the func, which `make bindings`
|
||||
then honours. `Player.SetDuck` is driven by OS audio focus and carries
|
||||
one.
|
||||
|
||||
## The scaffold's own tasks
|
||||
|
||||
`build/android/Taskfile.yml` ships more than the Makefile wraps, and
|
||||
they are the right thing to reach for when you want something one-off:
|
||||
|
||||
```
|
||||
wails3 task android:run # debug build + emulator install + launch
|
||||
wails3 task android:run:device # same, first connected physical device
|
||||
wails3 task android:deploy-device # production APK to a device
|
||||
wails3 task android:bundle:fat # AAB, for a Play Store upload
|
||||
wails3 task android:studio # open build/android/ in Android Studio
|
||||
wails3 task android:device:list
|
||||
wails3 task android:logs:all
|
||||
wails3 task android:clean
|
||||
```
|
||||
|
||||
Two are deliberately **not** wrapped. `android:logs` greps logcat for
|
||||
`(Wails|yellowjacket)`, which catches the `WailsBridge` tag but misses
|
||||
the app's own process tag (`app.yellowjacket` — lowercase, so `Wails`
|
||||
does not match it) and misses `ActivityManager`'s "has died" line, which
|
||||
is the one that tells you it crashed; `make android-logs` filters by tag
|
||||
instead. And `ensure-emulator` boots whatever `-list-avds | tail -1`
|
||||
returns, with no pidfile and no boot wait, so it cannot be stopped or
|
||||
sequenced.
|
||||
|
||||
## The identity is declared twice
|
||||
|
||||
`applicationId` in `build/android/app/build.gradle` is what Gradle
|
||||
installs. `APP_ID` in `build/android/Taskfile.yml` is what every
|
||||
adb-driven task uninstalls, launches and filters. **Nothing enforces
|
||||
that they agree**, and `ANDROID.md`'s advice to set `APP_ID` in
|
||||
`build/config.yml` does not work in beta.8 — `wails3 task` never reads
|
||||
that file (verified with `--dry`), and even when set it feeds only the
|
||||
adb commands, never Gradle. Change both or the official `run`/`deploy`
|
||||
tasks address a package that is not installed.
|
||||
|
||||
Related, and it will bite once: the launcher activity is
|
||||
`com.wails.app.MainActivity` and the applicationId is
|
||||
`app.yellowjacket`. `am start -n app.yellowjacket/.MainActivity`
|
||||
resolves the leading dot against the *applicationId* and fails with a
|
||||
class-not-found that reads like a broken build. Always the
|
||||
fully-qualified form.
|
||||
|
||||
## What only a device can answer
|
||||
|
||||
The emulator cannot run this app (three separate reasons, none of them
|
||||
ours — see plan 016), so the phone in someone's pocket is a tier, and
|
||||
asking for it is cheap. The first run of it, on 2026-08-17, confirmed
|
||||
the whole of A4 and found two faults **no other tier can see**:
|
||||
|
||||
- **The back gesture.** `MainActivity.onBackPressed` asks
|
||||
`webView.canGoBack()`. Nothing in a desktop shell has a back gesture,
|
||||
so no spec had ever called `page.goBack()` and the app had never
|
||||
pushed a history entry — back quit from any depth. It is a history
|
||||
entry per navigation now, which is also what made it assertable in the
|
||||
browser tier (`e2e/specs/back-navigation.spec.ts`).
|
||||
- **The safe area.** `targetSdk 35` forces edge-to-edge, so the
|
||||
transport and the tab bar sat under the gesture bar. **A browser
|
||||
viewport has no system bars**: `phone-shell.spec.ts` at 390x844 will
|
||||
keep passing on a build the device is clipping 48dp off. Insets are
|
||||
handled in `applyWindowInsets()`.
|
||||
|
||||
So when asking for a device run, ask about what the platform *adds* —
|
||||
system bars, the back gesture, focus and audio interruptions,
|
||||
permission dialogs, the keyboard — not about what the app draws. The
|
||||
drawing is what the other five tiers already cover.
|
||||
|
||||
## Asking the device, not just looking at it
|
||||
|
||||
A real phone can be inspected, and that turns this tier from "reported
|
||||
symptoms" into evidence. Three commands:
|
||||
|
||||
```bash
|
||||
make android-screenshot # what the screen shows (.dev/ by default)
|
||||
make android-inspect # forward the WebView's devtools socket
|
||||
make android-eval EXPR='JSON.stringify({vp:[innerWidth,innerHeight]})'
|
||||
```
|
||||
|
||||
Four things about it, each of which costs an hour if met cold:
|
||||
|
||||
- **Only a `debuggable` build has a devtools socket**, and a debug build
|
||||
carries `applicationIdSuffix ".dev"` so it installs **beside** the
|
||||
release app. That matters more than convenience: the two are signed by
|
||||
different certificates, and Android's only remedy for a changed
|
||||
certificate is an uninstall, which takes the user's library with it.
|
||||
Never uninstall to make room for a build.
|
||||
- **Playwright cannot drive it.** `connectOverCDP` calls
|
||||
`Browser.setDownloadBehavior`, a WebView answers "Browser context
|
||||
management is not supported", and the connection dies before the first
|
||||
evaluate. `scripts/android-eval.mjs` is raw CDP over Node's built-in
|
||||
WebSocket for that reason.
|
||||
- **Wireless adb drops when the screen sleeps.** The symptoms are
|
||||
`device offline` mid-session and a `fetch failed` from the eval
|
||||
script. Plug in over USB for anything longer than a couple of probes.
|
||||
- **The socket name carries the pid**, which changes on every launch, so
|
||||
it is resolved rather than remembered.
|
||||
|
||||
**And the reason to bother: the phone is an engine, not a screen.** The
|
||||
first device here renders in **Chrome 113** at 424x439 CSS px. Every
|
||||
other tier runs a current Chromium or WebKit, so a spec that passes at
|
||||
that viewport says nothing about the phone — 113 has no Popover API and
|
||||
no relaxed CSS nesting, and a dropped CSS declaration renders as
|
||||
"present but wrong", which is the hardest failure to read from a
|
||||
picture. Get the version first; it reframes every other symptom.
|
||||
@@ -0,0 +1,86 @@
|
||||
# The fixture library
|
||||
|
||||
`test_data/music_library_test/` is **generated, not committed**:
|
||||
`make testdata` (~1 s) builds 31 deterministic tracks across MP3, FLAC,
|
||||
Ogg Vorbis and WAV. `make testdata-force` rebuilds unconditionally,
|
||||
`make testdata-clean` deletes it. `make test` and `make sandbox-seed`
|
||||
depend on it, so it is rarely run by hand.
|
||||
|
||||
Tests that need it fetch it through `internal/testfixtures` and skip
|
||||
themselves when it has not been generated.
|
||||
|
||||
## Select by case, never by path
|
||||
|
||||
```go
|
||||
m := testfixtures.Load(t)
|
||||
paths := m.Case(t, testfixtures.CaseCoverDedup)
|
||||
track := m.Track(t, rel)
|
||||
```
|
||||
|
||||
Cases: `cover-dedup`, `multi-disc`, `various-artists`, `flac-album`,
|
||||
`ogg-album`, `wav-tracks`, `partial-tags`, `unicode`, `duplicates`,
|
||||
`edge-lengths`, `broken`.
|
||||
|
||||
Two invariants worth not breaking:
|
||||
|
||||
- **The clean library is exactly 31 tracks**, because `sandbox-seed`
|
||||
verifies the scan against that count. Deliberately malformed files
|
||||
live in a *sibling* root, `test_data/music_library_broken/`
|
||||
(`m.BrokenPath()`), so the scanner never sees them.
|
||||
- **Tags are written by `backend/tagwriter`, not by ffmpeg** (which
|
||||
encodes with `-map_metadata -1`). Fixture and reader therefore cannot
|
||||
drift into agreeing with each other and disagreeing with reality.
|
||||
|
||||
The manifest (`test_data/music_library_test.manifest.json`, outside the
|
||||
scanned root) hashes the *spec* — paths, formats, durations, tags,
|
||||
cover identity — not the bytes, because ffmpeg stamps encoder version
|
||||
strings and identical specs produce different bytes on different builds.
|
||||
|
||||
## In e2e specs
|
||||
|
||||
- **Every fixture except one is 2–6 seconds.** A spec that starts
|
||||
playback and then clicks pause races the track ending and fails
|
||||
against a correct UI. Use `LONG_TRACK` (90 s, `edge-lengths`) exported
|
||||
from `e2e/support/fixtures.ts`.
|
||||
- **WAV tracks scan in untitled.** `backend/tagwriter` writes WAV tags
|
||||
into a RIFF `id3 ` chunk and `dhowden/tag` has no RIFF parser, so
|
||||
there is no "Field Recordings" artist in the Artists view. This is a
|
||||
known open bug pinned by `TestWAVTagsAreNotReadableYet`; do not
|
||||
"fix" a spec by asserting the broken behaviour elsewhere.
|
||||
|
||||
## Seeds
|
||||
|
||||
```bash
|
||||
make sandbox-seed NAME=default # build (boots a fresh YJ_HOME and drives the app)
|
||||
make sandbox-seeds # list
|
||||
make dev-headless SEED=default # restore into a run
|
||||
```
|
||||
|
||||
A seed is a tarred `YJ_HOME` produced by *running the app*: fresh home →
|
||||
real `AddLibrary` binding → poll until the real scan reports the
|
||||
manifest's track count → SIGTERM so shutdown hooks persist state → tar.
|
||||
Never hand-write one. Seeding points `YJ_CORE_INDEX_URL` at a dead
|
||||
address on purpose, so no seed depends on what the explore artifact
|
||||
server happened to be serving.
|
||||
|
||||
Rebuild a seed after any schema change. Nothing migrates a restored
|
||||
database: `applySchema` is `CREATE TABLE IF NOT EXISTS`, so an old seed
|
||||
keeps its old columns, the app starts, and the first query dies on
|
||||
`no such column`.
|
||||
|
||||
**Restoring the seed does not disable the artifact fetch — only
|
||||
*building* it does.** `dev-headless` leaves `YJ_CORE_INDEX_URL` alone,
|
||||
so on a developer machine the restored app immediately downloads and
|
||||
imports the real ~1.1M-row catalog, through the one writer connection,
|
||||
while whatever you started it for is running. A full `make e2e` against
|
||||
that reported **14 failures** that were all contention; the same suite
|
||||
against the same seed with
|
||||
|
||||
```bash
|
||||
YJ_CORE_INDEX_URL='http://127.0.0.1:1/none.tar.zst' make dev-headless SEED=default
|
||||
```
|
||||
|
||||
is the configuration CI runs (`ci.yml` sets exactly that address) and is
|
||||
what to use before believing a failure. The tell is in `.dev/app.log` —
|
||||
an import logging progress — and in how the failures look: timeouts
|
||||
spread across unrelated specs rather than one surface being wrong.
|
||||
@@ -0,0 +1,90 @@
|
||||
# The harness: event bridge and control surface
|
||||
|
||||
Two things ride on top of the headless app. Both exist only in dev
|
||||
builds; neither is reachable from a shipped binary.
|
||||
|
||||
## The event bridge (`.playwright/init-events.js`)
|
||||
|
||||
Loaded as an `initScript` by `.playwright/cli.config.json` and by
|
||||
`e2e/support/fixtures.ts`, so an exploratory session and a committed
|
||||
spec see an identical page. It records every backend event by wrapping
|
||||
`window.wails.EventsNotify` — the single choke point all 46 events pass
|
||||
through, whether or not the app subscribes to them.
|
||||
|
||||
```js
|
||||
window.__yjEvents.wait('LibraryScanComplete', { timeoutMs: 60000 })
|
||||
window.__yjEvents.names() // name -> count; use this to find out
|
||||
// what actually fired before asserting
|
||||
window.__yjEvents.last('QueueChanged')
|
||||
window.__yjEvents.since(seq)
|
||||
window.__yjEvents.reset() // drop the buffer
|
||||
window.__yjEvents.ready(20000) // resolves when a binding round-trips,
|
||||
// which is later than DOM-ready and true
|
||||
window.__yjEvents.call('queue.Queue.GetState', [], 5000)
|
||||
```
|
||||
|
||||
- **`wait` resolves against already-buffered events as well as future
|
||||
ones**, so there is no race between doing the thing and listening.
|
||||
- **Install exactly one recorder.** Listeners survive across `eval`
|
||||
calls; a second recorder double-counts. Call `reset()`, never
|
||||
re-register.
|
||||
- **`call` times out on purpose.** A binding with wrong argument types
|
||||
never fires its callback. A 5 s rejection naming `.dev/app.log` beats
|
||||
an infinite hang.
|
||||
|
||||
In specs, use the wrappers rather than `page.evaluate`:
|
||||
`waitForEvent`, `resetEvents`, `eventNames`, `callBinding`, and the
|
||||
`app` fixture (a page with the bridge installed and the backend
|
||||
actually answering) from `e2e/support/fixtures.ts`.
|
||||
|
||||
## The control surface (`backend/testctl`, mounted at `/__test/`)
|
||||
|
||||
Gated twice: behind the `dev` build tag (with a no-op `!dev` twin) and
|
||||
behind `YJ_TESTCTL=1`, which `scripts/dev-headless.sh` sets and
|
||||
`make dev` does not.
|
||||
|
||||
| Endpoint | Use |
|
||||
|---|---|
|
||||
| `GET /__test/health` | is this a seeded dev build, and which library |
|
||||
| `POST /__test/db/snapshot?name=X` | save the SQLite state |
|
||||
| `POST /__test/db/restore?name=X` | put it back (see below) |
|
||||
| `POST /__test/emit` `{name, data}` | force any backend event |
|
||||
| `POST /__test/sql` `{sql, args}` | read rows, or a write count |
|
||||
|
||||
`TestCtl` in `e2e/support/fixtures.ts` is the typed client.
|
||||
|
||||
- **`emit` is the fast way to render a push-driven view** without
|
||||
staging the work that would produce it — job progress, download
|
||||
progress, scan progress. It calls `events.Deliver`, which *errors*
|
||||
when the event reaches nobody, so a `200` means it really arrived.
|
||||
- **`restore` is slow** (~40 s in the suite) because it copies every
|
||||
table. Prefer snapshotting once and restoring only when a spec
|
||||
genuinely mutates state.
|
||||
|
||||
## Traps in the config
|
||||
|
||||
- **The two path keys in `.playwright/cli.config.json` resolve
|
||||
differently.** `initScript` is relative to the *config file's*
|
||||
directory (`"init-events.js"`, not `".playwright/init-events.js"`);
|
||||
`outputDir` is relative to the *shell's cwd*. Set `outputDir` to
|
||||
`".playwright-cli"` and run `playwright-cli` from the repo root, or
|
||||
snapshots land somewhere neither `.gitignore` nor your next `ls`
|
||||
will find, and you will read a stale one from a previous session
|
||||
and think a component regressed.
|
||||
- **`snapshot` writes a file, it does not print the tree.** The
|
||||
command prints a path under `outputDir`; read that. Only the tail
|
||||
is echoed.
|
||||
- **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
|
||||
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`.
|
||||
- **`e2e/` is its own npm package** with `"type": "module"`. Without
|
||||
that, Playwright transpiles the specs to CJS and every `import.meta`
|
||||
throws — reported, unhelpfully, as "No tests found".
|
||||
@@ -0,0 +1,76 @@
|
||||
# Changing the database schema
|
||||
|
||||
The reasoning — why the local library is shaped like files rather than
|
||||
like MusicBrainz, and what the metadata tables cost before they went —
|
||||
is in `CLAUDE.md` under *Backend packages → database*. Read it once.
|
||||
This is the checklist.
|
||||
|
||||
**There is one description of the schema and no migration chain.**
|
||||
`sql/schemas/*.sql` declares the current shape; `applySchema` runs every
|
||||
file on every open, and `CREATE ... IF NOT EXISTS` makes that idempotent.
|
||||
`sql/migrations/`, `applyMigrations` and `schema_migrations` were
|
||||
squashed away with plan 013. So:
|
||||
|
||||
**Adding a table or a column is one edit to one file.**
|
||||
|
||||
```bash
|
||||
make generate # sqlc + templ
|
||||
go test ./backend/database/ ./backend/datamap/
|
||||
make test
|
||||
```
|
||||
|
||||
A new table has a second gate: **`backend/datamap`**. Add an entry
|
||||
stating its Kind and Lifetime, or `TestCatalogCoversSchema` fails — and
|
||||
if it is `Authored` and cascades, `TestAuthoredCascadesAreDeliberate`
|
||||
wants an explicit exemption with a note, because authored data is what a
|
||||
user cannot get back. If a *column* holds a different Kind from its
|
||||
table (an authored flag on an owned projection, a fetched value beside a
|
||||
tag-derived one), say so in the entry's note; `audio_files` and `lyrics`
|
||||
are the worked examples.
|
||||
|
||||
**Existing databases are not migrated.** Nothing upgrades a database
|
||||
from an older shape — delete your dev `YJ_HOME` and rescan, and rebuild
|
||||
any seed you rely on (`make sandbox-seed NAME=default`). Revisit this
|
||||
once real user databases exist in the wild.
|
||||
|
||||
**A stale one fails at the first query, not at open**, which is worth
|
||||
knowing before you read the error. `applySchema` is
|
||||
`CREATE TABLE IF NOT EXISTS`, so an old database keeps its old columns
|
||||
and gains nothing; the app then starts fine and dies on
|
||||
`no such column: title`. Every tier that does not *run the app* — unit
|
||||
tests, `make ui-test`, `tsc` — is green while this is true, because
|
||||
they build their database from the current schema. `make e2e` and
|
||||
`make dev` are the two that will tell you, and only after the seed has
|
||||
been rebuilt.
|
||||
|
||||
## The four ways this goes wrong
|
||||
|
||||
- **A query file must be ASCII.** sqlc's parameter rewriter works on
|
||||
byte offsets, so a single non-ASCII character in a *query* comment
|
||||
(an em dash, a curly quote) shifts every placeholder and generates
|
||||
garbage like `SELECid` — a parse error a long way from its cause.
|
||||
Schema files are not rewritten and may contain anything.
|
||||
- **A slice and a named parameter do not compose.** `sqlc.slice`
|
||||
expands to N placeholders, but `sqlc.arg` is numbered independently,
|
||||
so the two in one query bind the wrong values —
|
||||
`GetFilePathsByAlbums([1,2], 0)` read album id 2 as the library id.
|
||||
Where a query needs both, return the column and filter in Go.
|
||||
- **A write wearing a query's shape still needs the writer.**
|
||||
`QueryContext`/`QueryRow` route to the query-only read pool, so an
|
||||
`INSERT ... RETURNING` through one fails at runtime with "attempt to
|
||||
write a readonly database (8)". Use `ExecContext`, or
|
||||
`QueryRowWriter`. `TestNoWritesOnTheReadPool` walks the tree for it.
|
||||
- **A view is dropped and recreated.** `CREATE VIEW IF NOT EXISTS`
|
||||
no-ops against a database holding the old definition, so
|
||||
`track_metadata.sql` opens with `DROP VIEW IF EXISTS`.
|
||||
|
||||
## Where things go
|
||||
|
||||
New queries go in `backend/database/sql/queries/`; generated Go lands in
|
||||
`backend/database/sql/sqlcgen/`, which is never edited by hand. Anything
|
||||
returning a track selects from the `track_metadata` view rather than
|
||||
re-joining — that is why there is one row type and one mapper.
|
||||
|
||||
Tests use `database.NewTestDB(t)`, built by the same `applySchema`
|
||||
production uses, and seed rows with `database.InsertTestTrack(t, db,
|
||||
database.TestTrack{...})` rather than assembling inserts by hand.
|
||||
@@ -0,0 +1,98 @@
|
||||
# The component and store tier (`make ui-test`)
|
||||
|
||||
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
|
||||
make ui-test # behaviour only
|
||||
make ui-watch
|
||||
make ui-visual # + toMatchScreenshot baselines (YJ_VISUAL=1)
|
||||
make ui-visual-update # re-record them
|
||||
make ui-test UI_ARGS='store/queue' # filter
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
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
|
||||
stub('queue.Queue.GetState', state); // a value, or a function of the args
|
||||
stubFailure('queue.Queue.SetQueue'); // reject, as a Go error does
|
||||
calls('queue.Queue.SetQueue'); // what the frontend called back with
|
||||
lastArgs('queue.Queue.SetQueue');
|
||||
const el = await fixture('now-playing'); // mount; shadow()/text() query it
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
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
|
||||
the stores that read config in their constructor. Without one, a store
|
||||
caches `undefined` where Go would have sent `[]`, and components crash
|
||||
on `.length` — which reads exactly like a component bug and is not.
|
||||
Adding a store that reads config on construction means adding its
|
||||
default there.
|
||||
- **`vitest.config.mts`, not `.ts`** — it `mergeConfig`s the repo's
|
||||
`vite.config.mts` to reuse the `@go`/`@store`/`@components` aliases,
|
||||
and a `.ts` sibling cannot import it.
|
||||
- **Screenshots need the theme.** The setup file imports
|
||||
`@store/theme-store` for its side effect (it applies the `--yj-*`
|
||||
ramp to `:root`); without it a component renders white-on-white and
|
||||
the baseline is blank.
|
||||
- **`@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
|
||||
recorded them.
|
||||
|
||||
## Bindings
|
||||
|
||||
`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 # ~3.5 s warm, also a pre-commit hook
|
||||
make bindings # regenerate for real
|
||||
```
|
||||
|
||||
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.
|
||||
+3485
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,355 @@
|
||||
# Frontend accessibility & interaction-model audit — YellowJacket
|
||||
|
||||
Scope: `frontend/src/components/**`, `frontend/src/services/keyboard-shortcut-service.ts`,
|
||||
`frontend/index.html`, `frontend/index.ts`, `frontend/index.css`, `frontend/src/styles/tokens.css.ts`.
|
||||
Read-only; nothing was changed.
|
||||
|
||||
Already confirmed by hand and **not** re-reported: track rows / sidebar `<li>` not focusable,
|
||||
14 tab stops app-wide, closed queue panel still focusable, global Space/arrow/S/N/P hijack,
|
||||
`data-shortcut-scope` never set. Adjacent consequences of those are marked *(adjacent)*.
|
||||
|
||||
---
|
||||
|
||||
## Critical
|
||||
|
||||
**1. `frontend/src/components/config-page/config-section.ts:98-104` — the entire Settings page is unreachable by keyboard**
|
||||
The disclosure header is a bare `<div class="header" @click=${this.toggle}>` with no `<button>`,
|
||||
no `tabindex`, no `role`, no `aria-expanded`, no `aria-controls`. Sections default to
|
||||
`expanded = false` (line 84/88), so every setting in the app is behind a control that cannot be
|
||||
tabbed to or activated.
|
||||
*Symptom:* a keyboard or screen-reader user can open Settings and see nothing but collapsed
|
||||
headings they can never expand.
|
||||
*Fix:* make the header a `<button type="button" aria-expanded=${this.expanded} aria-controls="body">`
|
||||
and give the body an `id`.
|
||||
|
||||
**2. `frontend/src/components/downloads-view/downloads-view.ts:258-271` — tab switching is mouse-only and has no tab semantics**
|
||||
`<div class="tabs">` containing two `<div class="tab" @click>`; no `role="tablist"`/`role="tab"`,
|
||||
no `aria-selected`, no `tabindex`, no arrow-key handling, no `aria-controls` on the panel.
|
||||
*Symptom:* the Downloads tab of the Downloads view can never be reached without a mouse; AT
|
||||
announces two unlabelled generic containers.
|
||||
*Fix:* `role="tablist"` on the wrapper, `<button role="tab" aria-selected=... aria-controls=...>`
|
||||
per tab with roving tabindex.
|
||||
|
||||
**3. `frontend/src/components/track-list/track-list.ts:1967`, `frontend/src/components/queue-panel/queue-panel.ts:1543`, `frontend/src/components/cover-grid/cover-grid.ts` — context menus have no menu semantics, no focus, no keyboard**
|
||||
`<div class="context-menu-panel">` holds `wa-dropdown-item`s inside a raw `<wa-popup>`. The items do
|
||||
carry `role="menuitem"` (Web Awesome sets it — verified in
|
||||
`node_modules/@awesome.me/webawesome/dist/chunks/chunk.MCDD6PFW.js`), but the container has no
|
||||
`role="menu"`, so the menuitems are orphaned. Because they are in a bare `wa-popup` rather than a
|
||||
`wa-dropdown`, nothing moves focus into the menu, nothing handles Up/Down/Escape, and nothing
|
||||
restores focus on close. The menu only opens on `contextmenu` (mouse right-click); there is no
|
||||
Shift+F10 / Menu-key path.
|
||||
*Symptom:* Play, Add to Queue, Play Next, Add to Playlist, Favourite and Track Details are
|
||||
completely unavailable without a mouse — this is the only path to most of those actions.
|
||||
*Fix:* wrap in `role="menu"`, open on `keydown` Shift+F10/ContextMenu, focus the first item, handle
|
||||
Arrow/Escape/Tab, restore focus to the originating row on close.
|
||||
|
||||
**4. `frontend/src/components/autotag-view/autotag-view.ts:2824-2950` — four hand-rolled modal dialogs with no dialog semantics, no focus trap, no focus restore**
|
||||
`renderPasteDialog` (2824), `renderWarningDialog` (2856), `renderLeaveDialog` (2891),
|
||||
`renderSearchDialog` (2922) each render `<div class="dialog-overlay"><div class="dialog">` with no
|
||||
`role="dialog"`, no `aria-modal="true"`, no `aria-labelledby` pointing at the `<h3>`, and no focus
|
||||
management. Only the paste and search dialogs set `autofocus`; the Warning and Leave dialogs — the
|
||||
two that gate an **irreversible on-disk metadata rewrite** — leave focus wherever it was.
|
||||
*Symptom:* a screen-reader user is never told a dialog opened, can Tab straight out of it into the
|
||||
page behind, and can confirm "this rewrites audio files" without ever hearing the warning.
|
||||
*Fix:* use `<wa-dialog>` (which already does `showModal()` + activeElement restore — see
|
||||
`chunk.ZUIYLL2X.js`), or add role/aria-modal/labelledby + a Tab trap + focus save/restore.
|
||||
|
||||
**5. `frontend/src/components/autotag-view/autotag-view.ts:1706-1746` — bare single-letter shortcuts on `document`, including a destructive one, with an incomplete guard**
|
||||
`A` = Apply (rewrites tags on every track on disk, explicitly "not automatically reversible" per the
|
||||
warning copy at 2866-2872), `S` = Skip, `L` = Leave as-is, `U`/`F` = dialogs. The suppression check
|
||||
at 1707-1712 only tests `tagName === 'INPUT' | 'TEXTAREA' | isContentEditable`. Events originating
|
||||
inside a Web Awesome control's shadow DOM are retargeted to the host (`WA-SELECT`, `WA-INPUT`,
|
||||
`YJ-COMBOBOX`), so the guard passes and `A` fires while the user is typing. Buttons, checkboxes and
|
||||
`<select>` are likewise unguarded — pressing `S` on a focused `<select>` triggers Skip *and* jumps
|
||||
the option list.
|
||||
*Symptom:* typing an artist name into a Web Awesome field, or type-ahead on a select, silently
|
||||
rewrites metadata on an entire album.
|
||||
*Fix:* reuse `isTextInputFocused` from `keyboard-shortcut-service.ts` (which resolves through shadow
|
||||
roots via `getDeepActiveElement`) and require a confirm/modifier for `A`.
|
||||
|
||||
**6. `frontend/src/components/search-bar/search-bar.ts:166-174` and `frontend/src/components/explore-view/explore-view.ts:1317-1323` — clear buttons have no accessible name at all**
|
||||
Both are `<button class="clear-button">` containing only `<wa-icon name="xmark">`. No `aria-label`,
|
||||
no `title`, no text. (A systematic scan of every `<button>` in `components/**` found these two as the
|
||||
only truly unnamed controls; the rest have text or at least a `title` fallback.)
|
||||
*Symptom:* announced as "button" with no name; unusable via voice control.
|
||||
*Fix:* `aria-label="Clear search"`.
|
||||
|
||||
**7. `frontend/src/components/top-results-row/top-results-row.ts:267` — result cards are click-only divs**
|
||||
`<div class="card" @click=${() => this.handleClick(r)}>` — the only `role`/`tabindex`/`keydown`-free
|
||||
card renderer in the codebase (every other card view added at least `role="button" tabindex="0"`).
|
||||
*Symptom:* the top-results row on the Explore page cannot be activated by keyboard.
|
||||
*Fix:* `role="button" tabindex="0"` + Enter/Space handler, matching `home-view.ts:305-309`.
|
||||
|
||||
**8. `frontend/index.html:34` + `frontend/index.ts:263-275` — queue toggle has no state, and the closed panel is not inert** *(adjacent)*
|
||||
The button carries `aria-label="Toggle queue"` but never `aria-expanded` or `aria-controls`. The
|
||||
toggle just adds/removes the `open` attribute; the closed state is purely
|
||||
`:host { width: 0; overflow: hidden }` (`queue-panel.ts:214-217`), which hides nothing from the
|
||||
accessibility tree.
|
||||
*Symptom:* the button never reports open/closed, and a screen-reader's virtual cursor walks the
|
||||
entire queue (title, artist, remove button for every track) while the panel is visually closed.
|
||||
This is the same root cause as the already-confirmed "closed queue panel is still focusable".
|
||||
*Fix:* set `aria-expanded`/`aria-controls` on the button and `inert` (or `aria-hidden="true"` plus
|
||||
`visibility: hidden`) on the panel when closed.
|
||||
|
||||
---
|
||||
|
||||
## Major
|
||||
|
||||
**9. `frontend/src/components/track-list/track-list.ts:1906-1926` — column headers are not headers and never expose sort state**
|
||||
`<div class="header-row">` with `<div class="header-cell" @click>` per column. No `role="grid"`/
|
||||
`row`/`columnheader`, no `aria-sort`, no `tabindex`, no keydown. The sort direction is conveyed only
|
||||
by a `▲`/`▼` glyph in a `<span class="sort-arrow">` at 10px (`track-list.ts:900-901`).
|
||||
*Symptom:* AT cannot tell which column the list is sorted by or in which direction, and clicking a
|
||||
header to sort is mouse-only. (There is a redundant keyboard-reachable sort dropdown at 1806-1841,
|
||||
so this is not a total loss of function.)
|
||||
*Fix:* `role="columnheader" aria-sort=${'ascending'|'descending'|'none'}` on each header cell and
|
||||
make it a `<button>`.
|
||||
|
||||
**10. `frontend/src/components/track-list/track-list.ts:1746-1755` — the per-row favourite toggle is an unlabelled, unfocusable div**
|
||||
`<div class=${classMap({'fav-icon': true, favorited: isFav})}>` with an inline `<svg>` and
|
||||
`cursor: pointer` (`track-list.ts:1034-1043`); the click is delegated off the virtualizer. No
|
||||
`role`, no `tabindex`, no accessible name, no `aria-pressed`.
|
||||
*Symptom:* favouriting a track from the list is mouse-only, and the current favourite state of every
|
||||
row is invisible to AT (heart/star fill is a shape-and-colour change with no text equivalent).
|
||||
*Fix:* `<button role="switch" aria-checked=${isFav} aria-label="Favourite ${track.TrackName}">`.
|
||||
|
||||
**11. `frontend/src/components/queue-panel/queue-panel.ts:1417` + `cover-grid.ts:1798`, `album-dropdown.ts:385`, `app-sidebar.ts:222-232` — drag-and-drop has no keyboard equivalent anywhere**
|
||||
Queue reordering (`draggable="true"` on `.track-item`, drop index computed from cursor Y at
|
||||
`queue-panel.ts:1093-1140`), album→queue/playlist drag, expanded-album track drag, and drop-on-nav-item
|
||||
are all pointer-only. There is no Alt+Up/Down reorder, no "move to…" command, and no `aria-grabbed`/
|
||||
`aria-dropeffect` substitute.
|
||||
*Symptom:* queue order can never be changed without a mouse. Combined with finding 3 (the context
|
||||
menu is mouse-only too), there is **no** keyboard path to add a track to the queue or a playlist.
|
||||
*Fix:* add Alt+ArrowUp/Down reorder on the focused queue item, and expose the drag targets as
|
||||
context-menu commands once the menu is keyboard-reachable.
|
||||
|
||||
**12. No `aria-live` region anywhere for async status — scan/job progress, toasts, search results, now-playing**
|
||||
A repo-wide grep finds exactly one live region: `catalog-scope-notice.ts:110` (`role="status"`), and
|
||||
even that is conditionally rendered *with* its content already present, which most ATs do not
|
||||
announce. Specific gaps:
|
||||
- `frontend/src/components/config-page/config-page.ts:2137-2139` — `<div class="toast">` with no
|
||||
`role="status"`/`aria-live`; it is the only feedback that a setting saved or failed, and it
|
||||
auto-dismisses after a timer (1174-1176).
|
||||
- `frontend/src/components/jobs/job-indicator.ts:359-370` — the trigger label swings between
|
||||
"Scanning Music", "3 background jobs" and "Finished" with no live region.
|
||||
- `frontend/src/components/now-playing/now-playing.ts:340-357` — track title/artist change on every
|
||||
auto-advance with no announcement.
|
||||
- `frontend/src/components/explore-view/explore-view.ts:1270-1278` — "Searching…" and the error
|
||||
block are silent.
|
||||
- `frontend/src/components/track-list/track-list.ts:1901`, `1930-1933` — "Loading tracks…" /
|
||||
"No tracks match your search." with no `aria-live` and no `aria-busy` on the list.
|
||||
*Symptom:* a screen-reader user gets no feedback that a scan started or finished, that a setting
|
||||
saved, that a search returned nothing, or that the track changed.
|
||||
*Fix:* one `<div role="status" aria-live="polite" class="sr-only">` per surface, populated after the
|
||||
region already exists in the DOM.
|
||||
|
||||
**13. `frontend/src/components/artists-view/artists-view.ts:1059-1063` and `frontend/src/components/genres-view/genres-view.ts:947-951` — `aria-selected` on `role="button"` is invalid and dropped**
|
||||
Both cards render `role="button" aria-selected="${isSelected}"`. `aria-selected` is only valid on
|
||||
`gridcell`, `option`, `row`, `tab` and `treeitem`; on `button` it is ignored outright. These grids
|
||||
are genuinely multi-select (ctrl/shift-click via `SelectionController`).
|
||||
*Symptom:* selection state — the thing the whole ctrl/shift interaction exists to produce — is
|
||||
invisible to AT; visually it is a background-colour change only.
|
||||
*Fix:* `role="listbox" aria-multiselectable="true"` on the grid, `role="option" aria-selected` on
|
||||
the cards.
|
||||
|
||||
**14. `frontend/src/components/combobox/combobox.ts:288-303` — combobox has no `aria-controls` / `aria-activedescendant`**
|
||||
`role="combobox" aria-expanded aria-autocomplete="list"` on the input, `role="listbox"` on the `<ul>`,
|
||||
`role="option"` on the `<li>`s — but no `id` on the listbox, no `aria-controls`, no
|
||||
`aria-activedescendant`, and no `id` on the options. `aria-selected` is used to mean "highlighted"
|
||||
(302), not "chosen".
|
||||
*Symptom:* arrowing through suggestions moves the visual highlight but announces nothing; the user
|
||||
hears only their own typing.
|
||||
*Fix:* give the listbox and each option an `id`, add `aria-controls` and
|
||||
`aria-activedescendant=${optionId(highlightedIndex)}`.
|
||||
|
||||
**15. `frontend/src/components/now-playing/now-playing.ts:203-212, 391-408` — marquee text auto-scrolls with no reduced-motion guard and no pause**
|
||||
`transition: transform var(--scroll-duration, 5s) linear` re-armed in a loop by
|
||||
`onScrollCycleEnd`; when `scrollMode === 'always'` (persisted in localStorage, line 388-395) the
|
||||
title and artist scroll continuously for as long as the track plays. Only four files in the repo
|
||||
have a `prefers-reduced-motion` guard (`job-indicator.ts:126`, `job-row.ts:154`,
|
||||
`autotag-view.ts:471,599`) and this is not one of them.
|
||||
*Symptom:* WCAG 2.2.2 — moving content longer than 5s with no mechanism to pause it, and a
|
||||
vestibular-trigger risk with no reduced-motion opt-out.
|
||||
*Fix:* `@media (prefers-reduced-motion: reduce) { .scroll-content { transition: none } }` and treat
|
||||
`always` as `never` under that query.
|
||||
|
||||
**16. `frontend/src/components/config-page/config-page.ts:2091-2131` — the "Remove Library" confirmation is not a dialog**
|
||||
`<div class="cancel-dialog-overlay">` / `<div class="cancel-dialog">` with a
|
||||
`<div class="cancel-dialog-title">` — no `role="dialog"`, no `aria-modal`, no `aria-labelledby`, no
|
||||
focus move, no focus trap, no Escape handler, no focus restore. This gates deleting tracks,
|
||||
playlists and queue entries.
|
||||
*Symptom:* the destructive confirmation is never announced and can be Tab-escaped.
|
||||
*Fix:* same as finding 4 — `wa-dialog`, or role + trap + restore.
|
||||
|
||||
**17. `frontend/src/components/jobs/job-indicator.ts:378` — `role="dialog"` on an unmanaged popover**
|
||||
The panel declares `role="dialog"` (and the trigger `aria-haspopup="dialog"`, line 362) but nothing
|
||||
moves focus into it, traps Tab, handles Escape, or restores focus. It is a non-modal popover, not a
|
||||
dialog.
|
||||
*Symptom:* AT announces a dialog that never receives focus and cannot be dismissed by keyboard;
|
||||
tabbing past the trigger lands in the page behind while the panel is open.
|
||||
*Fix:* drop `role="dialog"` (use `role="group" aria-label="Background jobs"` and
|
||||
`aria-haspopup="true"`), or implement real dialog behaviour.
|
||||
|
||||
**18. `frontend/src/components/explore-view/explore-view.ts:1289-1305` — search-mode "tabs" convey the active mode by colour class only**
|
||||
`<button class="search-mode-tab ${this.searchMode === 'catalog' ? 'active' : ''}">` — no
|
||||
`role="tab"`/`aria-selected`, no `aria-pressed`, no text or icon difference between active and
|
||||
inactive.
|
||||
*Symptom:* the user cannot tell whether they are searching the catalog or lyrics.
|
||||
*Fix:* `aria-pressed=${this.searchMode === 'catalog'}` (or a proper tablist).
|
||||
|
||||
---
|
||||
|
||||
## Minor
|
||||
|
||||
**19. `frontend/src/styles/tokens.css.ts:18-22` — the entire type scale is hardcoded px**
|
||||
`--yj-text-xs: 11px` … `--yj-text-xl: 18px`, consumed by essentially every component. Combined with
|
||||
~50 further literal `font-size: Npx` declarations (e.g. `job-indicator.ts:138` at **9px**,
|
||||
`explore-view.ts:518` at 10px, `track-list.ts:901` at 10px, and inline
|
||||
`style="font-size: 12px"` at `queue-panel.ts:1518` and `playlist-view.ts:1865`).
|
||||
*Symptom:* text-only resize (WCAG 1.4.4) does nothing — a user who raises their OS/browser font size
|
||||
sees no change. 9-11px body text is below any reasonable floor to begin with.
|
||||
*Fix:* express the scale in `rem` so it tracks the root font size.
|
||||
|
||||
**20. `frontend/src/components/track-list/track-list.ts:972-985` and `frontend/src/components/queue-panel/queue-panel.ts:164-166` — fixed row heights with `contain: strict`**
|
||||
`.track-row { height: 33px; contain: strict }` and the matching virtualizer `_itemSize`
|
||||
(`track-list.ts:222`, `queue-panel.ts:165`, 49px). `contain: strict` clips overflow rather than
|
||||
growing the row.
|
||||
*Symptom:* any increase in text size (finding 19, or a user stylesheet) clips row text mid-glyph
|
||||
instead of reflowing; the virtualizer's scroll math also desynchronises.
|
||||
*Fix:* out of scope for a quick change, but at minimum document that the type scale and `_itemSize`
|
||||
are coupled.
|
||||
|
||||
**21. `frontend/index.css:12-20` — the app shell is `height: 100vh; overflow: hidden`**
|
||||
`body { height: 100vh; grid-template: "top-bar top-bar" 4em ... "bottom-bar bottom-bar" 4em; overflow: hidden }`.
|
||||
*Symptom:* at high zoom the 4em bars grow while the viewport does not, and anything that no longer
|
||||
fits is clipped with no scrollbar — WCAG 1.4.10 Reflow. The bottom bar's
|
||||
`grid-template-columns: var(--now-playing-width, 200px) 1fr auto` keeps a fixed 200px column while
|
||||
its text scales.
|
||||
*Fix:* allow the shell to scroll (`min-height: 100vh` + `overflow: auto`) below a breakpoint.
|
||||
|
||||
**22. `frontend/src/components/track-list/track-list.ts:1000-1017` — "now playing" and "selected" rows are colour-only**
|
||||
`.track-row.active { background-color: var(--yj-accent-bg); color: var(--yj-accent) }` and
|
||||
`.track-row.selected { background-color: var(--yj-selection-bg) }`; the row markup
|
||||
(`track-list.ts:1736-1745`) carries no `aria-current`, `aria-selected` or non-colour marker.
|
||||
*Symptom:* WCAG 1.4.1 — a colour-blind user cannot distinguish the playing row, and AT has no signal
|
||||
at all. Same pattern in `queue-panel.ts:1406-1409`.
|
||||
*Fix:* add a ▶ marker (or the existing play icon) to the active row and `aria-current="true"` once
|
||||
rows carry `role="row"`.
|
||||
|
||||
**23. `frontend/src/components/jobs/job-indicator.ts:150-156, 369` — the failure indicator is a bare 6px red dot**
|
||||
`<span class="alert-dot">` with `background: #ff6b6b` and no text, `aria-label` or `title`; the
|
||||
trigger's own name (`title="Background jobs"`, 363) does not change when it appears.
|
||||
*Symptom:* "a background job failed" is communicated by colour alone and not at all to AT.
|
||||
*Fix:* `<span class="alert-dot" role="img" aria-label="A background job failed"></span>`.
|
||||
|
||||
**24. Ellipsis truncation without `title` in the highest-density lists**
|
||||
`text-overflow: ellipsis` appears in 40+ places. `cover-grid.ts:1821,1832` and `home-view.ts:308`
|
||||
do add `title`; these do not:
|
||||
- `frontend/src/components/queue-panel/queue-panel.ts:389,401` (`.track-title`, `.track-artist`)
|
||||
vs. the markup at 1422-1428 — no `title`.
|
||||
- `frontend/src/components/track-info/track-info.ts:92,100` vs. markup at 118-126.
|
||||
- `frontend/src/components/track-list/track-list.ts:1018-1022` (`.cell`) vs. `1782-1788`.
|
||||
- `frontend/src/components/playlist-view/playlist-view.ts:355,360`.
|
||||
*Symptom:* long titles are clipped with no way to read the full value — acute in the queue panel,
|
||||
whose width is user-resizable down to `MIN_WIDTH`.
|
||||
*Fix:* `title=${value}` on the truncating element.
|
||||
|
||||
**25. `frontend/src/components/jobs/job-row.ts:270-272` — progress bar has no accessible name**
|
||||
`<wa-progress-bar value=...>`; Web Awesome renders `role="progressbar"` + `aria-valuenow`
|
||||
(`chunk.WDFK5BNW.js:42,47`) but no label is supplied.
|
||||
*Symptom:* announced as an unnamed "progress bar, 45%" with no indication of what is progressing.
|
||||
*Fix:* `aria-label=${job.title}` (or WA's `label` attribute).
|
||||
|
||||
**26. `frontend/src/components/search-bar/search-bar.ts:157-163` and `explore-view.ts:1308-1314` — search inputs are labelled by placeholder only**
|
||||
No `aria-label`, no `<label>`, no `role="searchbox"`, no `aria-describedby` pointing at the result
|
||||
count.
|
||||
*Fix:* `aria-label="Search library"` / `"Search catalog"`.
|
||||
|
||||
**27. `frontend/src/components/sidebar/app-sidebar.ts:202-241` — nav list has no landmark or item role** *(adjacent)*
|
||||
`<ul>` of `<li>` with `aria-current` (219) but no `role`, so `aria-current` sits on a
|
||||
non-interactive item and the whole thing is not inside a `<nav>` (`frontend/index.html:22` is a
|
||||
plain `<div class="sidebar">`).
|
||||
*Fix:* `<nav aria-label="Main">` in `index.html` and make each item a `<button>`/`<a>` — which also
|
||||
resolves the already-confirmed focusability gap.
|
||||
|
||||
**28. Mouse-only resize handles with no keyboard equivalent**
|
||||
`app-sidebar.ts:200`, `queue-panel.ts:1447`, `now-playing.ts:377`, and the track-list column
|
||||
resizers at `track-list.ts:1945-1953` are all `@mousedown`-only `<div>`s with no `role="separator"`,
|
||||
`tabindex` or arrow-key handling.
|
||||
*Symptom:* panel and column widths cannot be adjusted without a mouse. Low impact (cosmetic
|
||||
preference), but the pattern repeats four times.
|
||||
|
||||
---
|
||||
|
||||
## Polish
|
||||
|
||||
**29. `frontend/index.html:14-16` — heading hierarchy skips h1 → h3**
|
||||
`<h1 class="title">` immediately followed by `<h3 class="subtitle">`, styled at `0.8em`
|
||||
(`index.css:52-55`) — using a heading level for type size.
|
||||
*Fix:* make the subtitle a `<p>`.
|
||||
|
||||
**30. `frontend/index.html` — no skip link**
|
||||
`<main id="main-content">` exists (line 26) but nothing links to it, so keyboard users traverse the
|
||||
top bar and sidebar on every navigation.
|
||||
*Fix:* add a visually-hidden `<a href="#main-content">Skip to content</a>` as the first body child.
|
||||
|
||||
**31. `frontend/src/components/cover-grid/cover-grid.ts:509` — `<img>` with no `alt`**
|
||||
The only `alt`-less `<img>` in the codebase (every other one is either descriptive or correctly
|
||||
`alt=""`).
|
||||
*Fix:* `alt=""` if decorative.
|
||||
|
||||
**32. `frontend/src/components/queue-panel/queue-panel.ts:1431-1437` — per-row remove button is named by `title` only, and the name is not unique**
|
||||
`title="Remove from queue"` on every row provides an accname fallback, but it never identifies
|
||||
*which* track and is invisible to touch users.
|
||||
*Fix:* `aria-label="Remove ${track.title} from queue"`.
|
||||
|
||||
**33. `frontend/src/components/cover-grid/cover-grid.ts:1793-1797` — every album card is `tabindex="0"`** *(adjacent)*
|
||||
`role="button" tabindex="0"` on each virtualised card means the tab sequence length equals the number
|
||||
of rendered cards, with no roving tabindex. This is the opposite failure mode to the confirmed
|
||||
"only 14 tab stops" finding and will surface as soon as the other views are made focusable.
|
||||
*Fix:* roving tabindex (one `tabindex="0"`, the rest `-1`) once the grid gets `role="listbox"` per
|
||||
finding 13.
|
||||
|
||||
**34. `frontend/src/components/track-list/track-list.ts:900-901` — 10px sort arrow**
|
||||
`font-size: 10px; /* intentionally sub-token: tiny sort indicator */` — the comment acknowledges it.
|
||||
Combined with finding 9 (no `aria-sort`), the sort direction is a 10px glyph or nothing.
|
||||
|
||||
---
|
||||
|
||||
## What is already correct
|
||||
|
||||
- **`frontend/src/components/audio-player/controls/player-controls.ts:121-148`** — every transport
|
||||
button has an `aria-label`, shuffle and repeat carry `aria-pressed`, and repeat's three-state mode
|
||||
is spelled into the label (`Repeat: one`) rather than left to the CSS class. This is the model the
|
||||
rest of the app should follow.
|
||||
- **`frontend/src/components/audio-player/seekbar/seek-bar.ts:160-168`** and
|
||||
**`volume-control.ts:198`** — `wa-slider` with `aria-label` and a `valueFormatter`, so the seek
|
||||
position is announced as `3:42` rather than `222`.
|
||||
- **All five `wa-dialog` usages are genuinely modal and restore focus** — `track-details.ts:735`,
|
||||
`duplicate-tracks-dialog.ts:278`, `download-picker.ts:180`, `phantom-resolver.ts:927`,
|
||||
`first-run-wizard.ts:170`. Web Awesome's dialog uses native `showModal()`, `lockBodyScrolling` and
|
||||
`activeElement` restore (`chunk.ZUIYLL2X.js`), and every one of them passes a `label`. The
|
||||
hand-rolled dialogs in findings 4 and 16 are the outliers, and both have a working component to
|
||||
migrate to.
|
||||
- **`frontend/src/components/explore-artist-details/explore-artist-details.ts:2152, 2178, 2201, 2327, 2457`**
|
||||
— every disclosure toggle is a real `<button>` with `aria-expanded`, and the CSS keys off the
|
||||
attribute (`:465, :520, :680`) rather than a duplicate class. This is exactly the pattern
|
||||
`config-section.ts` (finding 1) is missing.
|
||||
- **`keyboard-shortcut-service.ts:73-83, 106-121`** — `getDeepActiveElement` correctly walks the
|
||||
shadow-root chain and `isTextInputFocused` covers `contentEditable` and the empty-`type` input
|
||||
case. The suppression logic is sound; the problems the parent already found are in *what* it does
|
||||
with the result, not in the resolution itself. Finding 5 is the autotag view failing to reuse it.
|
||||
- **`library-status-indicator.ts:186-196`** — status is conveyed by three distinct icons *and* a
|
||||
full sentence in both `title` and `aria-label`, and `handleKeydown` (175-180) stops Enter/Space
|
||||
from double-firing on the wrapping card. Correct on every axis.
|
||||
|
||||
---
|
||||
|
||||
## Residual risks / not covered
|
||||
|
||||
- Colour-contrast ratios were not measured (no rendering); the token palette
|
||||
(`--yj-text-tertiary: #888` on `--yj-bg-surface: #212529` ≈ 4.1:1) is borderline for the 11-12px
|
||||
text it is most often paired with, but that needs a real measurement.
|
||||
- `templ`-rendered HTMX fragments in `backend/config/` were out of scope and are not audited.
|
||||
- WebKit2GTK-specific behaviour (whether Ctrl+= page zoom is even reachable in the Wails shell, and
|
||||
how Orca traverses lit-virtualizer's windowed DOM) can only be confirmed on a running app.
|
||||
@@ -0,0 +1,432 @@
|
||||
# Failure UX audit — YellowJacket
|
||||
|
||||
Scope: error handling, empty/loading states, destructive actions, and failure UX
|
||||
across the frontend/backend boundary. Read-only; nothing was changed.
|
||||
|
||||
Method: `backend/app.go`, every bound service in `FEBindings` (`backend/app.go:194-215`),
|
||||
the generated bindings under `frontend/wailsjs/go/**`, all 13 stores/controllers in
|
||||
`frontend/src/store/`, and every component in `frontend/src/components/` that calls a
|
||||
binding. Counts: 165 `catch` blocks in `frontend/src`, 84 of which end in
|
||||
`console.error`/`console.warn` and nothing else.
|
||||
|
||||
**Headline:** there is no application-level notification surface. Two components grew
|
||||
private, mutually-unaware toasts (`config-page.ts:1168`, `autotag-view.ts:1318`), and
|
||||
everything else logs to a console the user cannot open. The single most common failure
|
||||
in a music player — *this file will not play* — is one of the paths that reaches the
|
||||
user as complete silence.
|
||||
|
||||
---
|
||||
|
||||
## Critical
|
||||
|
||||
### C1. A track that fails to load or play is a silent no-op, forever
|
||||
**`backend/queue/queue.go:1181-1239`** (`loadCurrentTrack`, `playCurrentTrack`),
|
||||
reached from `Queue.Play/PlayIndex/Next/Previous/SetQueue`.
|
||||
|
||||
`LoadFile` or `Play` returning an error is logged and turns into `return false`; the
|
||||
caller reverts `currentIndex` (`queue.go:1069-1074`, `queue.go:920-925`) and returns.
|
||||
No event is emitted. Every Wails binding on the path returns `Promise<void>`
|
||||
(`frontend/wailsjs/go/queue/Queue.d.ts`) because the Go methods return nothing, so the
|
||||
frontend cannot even observe the failure — and `queue-store.ts:192-263` does not
|
||||
`await` or `.catch()` any of them regardless.
|
||||
|
||||
Symptom: double-click a track whose file was moved, is corrupt, or has an unsupported
|
||||
codec — nothing happens. No row highlight, no error, no skip. Double-click it again —
|
||||
still nothing. Mid-queue auto-advance onto a bad file stops playback dead with no
|
||||
explanation (`queue.go:920-925`), and pressing Next does nothing because Next hits the
|
||||
same bad track and reverts.
|
||||
|
||||
Fix: add a `PlaybackFailed` event carrying `{filePath, reason}`, emit it from
|
||||
`loadCurrentTrack`/`playCurrentTrack`, and have `Next`/auto-advance skip the failed
|
||||
track rather than reverting.
|
||||
|
||||
### C2. `SeekFailed` is emitted by the backend and nobody listens
|
||||
**`backend/player/player.go:776`** emits `events.SeekFailed`; **`frontend/src/events.ts:8`**
|
||||
declares it; there is no `EventsOn(Events.SeekFailed, ...)` anywhere in `frontend/src`
|
||||
(verified by grep — the only other hits are `events.go` and `emit_test.go`).
|
||||
|
||||
Symptom: dragging the seek bar on a track that has no loaded seeker snaps the thumb
|
||||
back to where it was, with no indication why.
|
||||
|
||||
Fix: subscribe in `player-store.ts` and surface it (revert the optimistic seek position
|
||||
plus a message), or delete the event so it stops implying coverage that does not exist.
|
||||
|
||||
### C3. Autotag apply writes to the user's files with no cancel, no undo, and no presence outside its own page
|
||||
**`backend/autotagservice/service.go:1078-1181`**, **`frontend/src/components/autotag-view/autotag-view.ts:1624-1662`**.
|
||||
|
||||
`ApplyAsync` spawns `go s.runApply(...)` which calls `s.applier.Apply(s.ctx, ...)` —
|
||||
it rewrites tags in place across a whole folder. There is:
|
||||
- no cancel (`grep 'jobs\.' backend/autotagservice/*.go` → nothing; it is not registered
|
||||
with the `jobs.Registry`, unlike scans, index builds and downloads),
|
||||
- no undo,
|
||||
- no visibility once the user leaves the autotag page — the progress lives entirely in
|
||||
`autotag-view`'s local `applyJobs` map (`autotag-view.ts:1180`), which is discarded on
|
||||
`disconnectedCallback` (`autotag-view.ts:1239`),
|
||||
- no drain on shutdown — `OnShutdown` (`backend/app.go:498-510`) saves player and queue
|
||||
state and returns; `OnBeforeClose` (`backend/app.go:461`) unconditionally returns
|
||||
`false`. Quitting mid-apply cancels `s.ctx` and leaves the folder half-retagged with
|
||||
nothing recording where it stopped.
|
||||
|
||||
Symptom: the user starts an apply, navigates away or quits, and comes back to a folder
|
||||
where some tracks carry the new tags and some the old, with no way to tell which.
|
||||
|
||||
Fix: register the apply with `jobs.Registry` (giving it the existing cancel/progress
|
||||
surface for free) and make `OnBeforeClose` return `true` while a file-writing job is in
|
||||
flight.
|
||||
|
||||
`backend/tagwriter/pipeline.go:286-360` (batch tag writes) has the same absence from
|
||||
the job registry, but is mitigated — see the note under **M8**.
|
||||
|
||||
### C4. `libraryStore` serves the previous library's data after a filter switch
|
||||
**`frontend/src/store/library-store.ts:339-343, 445-467, 128-152`**.
|
||||
|
||||
`setSelectedLibrary()` → `invalidate()` sets `this.tracks = null` and calls
|
||||
`eagerFetch()`. If the previous library's `GetAllTracksByLibrary` is still in flight,
|
||||
`getTracks()` sees `tracks === null && tracksLoading === true` and returns
|
||||
`waitForTracks()` (`library-store.ts:494`) — which waits for the *old* request. That
|
||||
request's `try` block then assigns `this.tracks = <library A's tracks>`
|
||||
(`library-store.ts:145`) and bumps `changeGen`, so the store is now caching A's tracks
|
||||
while `selectedLibraryIdValue` is B.
|
||||
|
||||
Symptom: switch the library filter twice quickly and the track/album/artist/genre lists
|
||||
show the wrong library's contents until the next scan or filter change.
|
||||
|
||||
Fix: stamp each fetch with a `fetchGen` captured at request time and drop the
|
||||
assignment when `fetchGen !== this.changeGen` (the same version-guard pattern
|
||||
`explore-view.ts:703/793/821` already uses correctly).
|
||||
|
||||
---
|
||||
|
||||
## Major
|
||||
|
||||
### M1. A failed library fetch hangs every waiter forever
|
||||
**`frontend/src/store/library-store.ts:128-152, 494-506`** (and the identical
|
||||
`waitForAlbums`/`waitForArtists`/`waitForGenres` at 508-548).
|
||||
|
||||
`getTracks()` rejects → `finally` sets `tracksLoading = false` and notifies → the
|
||||
`waitForTracks` subscriber tests `!this.tracksLoading && this.tracks !== null`, which is
|
||||
false because `tracks` is still `null` → the promise never settles and the subscription
|
||||
is never removed.
|
||||
|
||||
Symptom: any component that called `getTracks()` while another fetch was in flight
|
||||
hangs on an unresolved promise (permanent spinner) and leaks a store subscription.
|
||||
|
||||
Fix: give the four `waitFor*` helpers a reject path, or store the in-flight promise and
|
||||
return it instead of re-deriving it from subscriber notifications.
|
||||
|
||||
### M2. The track list conflates "empty", "loading" and "failed" into one permanent "Loading tracks…"
|
||||
**`frontend/src/components/track-list/track-list.ts:1901-1902`**:
|
||||
`this.tracks.length === 0 ? html\`<p>Loading tracks...</p>\``.
|
||||
`loadTracks()` (`track-list.ts:1242-1257`) `console.error`s on failure and leaves
|
||||
`this.tracks` at `[]`.
|
||||
|
||||
Symptom: three different situations render as an infinite "Loading tracks…" —
|
||||
a genuinely empty library, a backend query that failed, and a library filter with
|
||||
nothing in it. `genre-details.ts:194-198` makes it worse: on error it sets
|
||||
`this.tracks = []` and hands that to `<track-list>`, so a failed genre query is
|
||||
indistinguishable from a slow one.
|
||||
|
||||
Fix: track `loading`/`error` as separate state and render three distinct bodies —
|
||||
the `home-view.ts:263-280` `renderBody()` is the correct model already in this repo.
|
||||
|
||||
### M3. The Settings search-index panel says "Loading status…" forever
|
||||
**`frontend/src/components/config-page/config-page.ts:186, 195, 1016-1022, 1034, 1530`**.
|
||||
|
||||
`indexStatus` is only ever assigned from the `IndexStatusChanged` event listener, and
|
||||
that event is emitted from exactly one place — `backend/explore/searchindex.go:692`,
|
||||
inside `emitStatus()`, which only fires on build status *mutations*. `indexPollTimer`
|
||||
is declared (195) and cleared (1034) but **never assigned**. The pull binding
|
||||
`GetIndexStatus()` exists (`frontend/wailsjs/go/explore/Service.d.ts:42`) and is never
|
||||
called from `frontend/src`.
|
||||
|
||||
Symptom: open Settings when no index build is running — which is the steady state —
|
||||
and the Search Index section shows "Loading status…" indefinitely, even though the
|
||||
index is fully built.
|
||||
|
||||
Fix: call `GetIndexStatus()` in `connectedCallback` to seed `indexStatus` before the
|
||||
first event arrives.
|
||||
|
||||
### M4. Job pause/resume/cancel failures are unhandled promise rejections
|
||||
**`frontend/src/components/jobs/job-controls.ts:17-35`**, wired as
|
||||
`@job-control=${applyJobControl}` at `jobs-view.ts:330`,
|
||||
`job-details-drawer.ts:335`, `job-indicator.ts:397`.
|
||||
|
||||
`applyJobControl` is `async` and is used directly as a DOM event listener, so its
|
||||
returned promise is discarded. `jobStore.pause/resume/cancel/dismiss`
|
||||
(`job-store.ts:189-204`) `await` the binding with no `catch`.
|
||||
|
||||
Symptom: press Pause on a scan and, if the backend rejects, the button does nothing —
|
||||
no state change, no message. There is also no in-flight guard, so double-clicking
|
||||
Cancel issues two `CancelJob` calls.
|
||||
|
||||
Fix: wrap the switch in try/catch inside `applyJobControl` and surface the failure;
|
||||
disable the row's controls until the next `JobsChanged` snapshot arrives.
|
||||
|
||||
### M5. Scan / full-rescan buttons fail silently
|
||||
**`frontend/src/components/jobs/jobs-view.ts:276-282, 284-290, 296-314`**.
|
||||
|
||||
All three handlers `console.error` and return. `FullRescan` returns
|
||||
`errNoLibrariesConfigured` when no library is configured
|
||||
(`backend/library/rescan.go:33-35`), and — unlike "Scan all", which is disabled on
|
||||
`this.libraries.length === 0` (`jobs-view.ts:434`) — the Full rescan button is only
|
||||
disabled on `anyScanning` (`jobs-view.ts:470`).
|
||||
|
||||
Symptom: with no libraries configured, the user reads a scary confirmation, clicks
|
||||
"Full rescan", confirms, and absolutely nothing happens.
|
||||
|
||||
Also a double-click hazard: `anyScanning` is derived from `jobStore`, which is fed by
|
||||
`JobsChanged` events coalesced at 250 ms (`backend/events` / `jobs` registry). Two
|
||||
clicks inside that window both issue `ScanLibrary`.
|
||||
|
||||
Fix: surface the error, add `|| this.libraries.length === 0` to the Full rescan
|
||||
`?disabled`, and add a local `starting` flag that disables the button until the job
|
||||
snapshot lands.
|
||||
|
||||
### M6. Deleting a playlist has no confirmation and no undo
|
||||
**`frontend/src/components/playlist-view/playlist-view.ts:1352-1372`** (multi-select
|
||||
path) and **`1381-1392`** (`handleDeletePlaylist`).
|
||||
|
||||
The multi-select branch loops `await DeletePlaylist(id)` over every selected playlist
|
||||
with no prompt. `handleDeletePlaylist` `console.error`s on failure, so a partial
|
||||
failure looks like a success until the refresh reveals the playlist is still there.
|
||||
|
||||
Compare `jobs-view.ts:296` (full rescan) and `job-controls.ts:41-53` (index cancel),
|
||||
both of which do confirm — the codebase has the convention, this path just skips it.
|
||||
|
||||
Fix: `window.confirm` naming the playlist(s) and their track counts, matching the
|
||||
pattern already used for full rescan.
|
||||
|
||||
### M7. Durable download requests are removed with one click, no confirmation, unhandled rejection
|
||||
**`frontend/src/components/downloads-view/downloads-view.ts:466-476`**
|
||||
(`void downloadStore.removeRequest(request.id)`), and the same shape at
|
||||
**`451-460`** (`pauseRequest`) and **`296-300`** (`clearSatisfiedRequests`).
|
||||
|
||||
`downloadStore.removeRequest` (`download-store.ts:434-437`) awaits `RemoveRequest` with
|
||||
no catch, and the call site discards the promise with `void`.
|
||||
|
||||
Symptom: click the ✕ next to an artist subscription you have been building for months
|
||||
— it disappears with no prompt and no undo; or, if the delete fails, it stays put with
|
||||
no explanation.
|
||||
|
||||
Fix: confirm before removing a subscription, and `.catch()` the promise into a visible
|
||||
message.
|
||||
|
||||
### M8. Stale preview overwrites newer rules in the smart-playlist editor
|
||||
**`frontend/src/components/smart-playlist-editor/smart-playlist-editor.ts:666-707`**.
|
||||
|
||||
`schedulePreview()` debounces 300 ms, then `runPreview()` awaits
|
||||
`PreviewSmartPlaylist(json)` with no request id. Debouncing only coalesces keystrokes
|
||||
*within* the window; a query that takes longer than 300 ms overlaps the next one, and
|
||||
whichever resolves last wins.
|
||||
|
||||
Symptom: edit a rule, and the preview list settles on the results of the *previous*
|
||||
rule set. The `finally` block also clears `previewLoading` from the stale response,
|
||||
so the spinner stops while the current query is still running.
|
||||
|
||||
Fix: capture `const v = ++this.previewVersion` and bail on
|
||||
`if (v !== this.previewVersion) return` in both the success and `finally` paths —
|
||||
`explore-view.ts:703/793/821/826` does exactly this correctly.
|
||||
|
||||
### M9. Raw Go error strings are rendered to the user in six places
|
||||
No error is ever mapped to human copy. Verbatim `err.Error()` / `String(err)` reaches
|
||||
the UI at:
|
||||
|
||||
| Location | What the user sees |
|
||||
|---|---|
|
||||
| `explore-album-details.ts:1755, 1811` (set at `912, 969`) | `Get "https://musicbrainz.org/ws/2/…": context deadline exceeded` |
|
||||
| `explore-artist-details.ts:2023, 2296` (set at `1294, 1466`) | same class of string |
|
||||
| `explore-view.ts:1276` (set at `823, 848`) | same |
|
||||
| `config-page.ts:1142` | `Failed to remove 'Music': sql: database is locked` |
|
||||
| `config-page.ts:1514` | index tier `${t.error}` verbatim |
|
||||
| `autotag-view.ts:1289, 1651` | `Apply failed: build plan: …` |
|
||||
| `download-picker.ts:141, 159`; `download-clients.ts:645, 668, 690` | `String(err)` verbatim |
|
||||
| `first-run-wizard.ts:239, 259` | `Could not add the folder: ${err}` |
|
||||
|
||||
These come straight out of `musicbrainzws2` / `net/http` / `database/sql`
|
||||
(`backend/explore/musicbrainz.go:267-285` returns the client error unwrapped), so the
|
||||
string is a Go stack-flavoured HTTP error, not a sentence.
|
||||
|
||||
Fix: introduce a small `describeError(err)` helper in `frontend/src/utils/` that maps
|
||||
the handful of recognisable cases (offline, timeout, not found, permission) to copy and
|
||||
falls back to a generic line, and route all eight sites through it. Keep the raw text
|
||||
in `console.error` for debugging.
|
||||
|
||||
Genuine counter-example worth preserving: `download-store.ts:337-341` deliberately lets
|
||||
`TestProvider`'s message through, and documents why — that one is the user's debugging
|
||||
tool for a misconfigured client. That is the exception, not the rule.
|
||||
|
||||
---
|
||||
|
||||
## Minor
|
||||
|
||||
### m1. Every queue and player action is fire-and-forget
|
||||
**`frontend/src/store/queue-store.ts:192-266`**, **`frontend/src/store/player-store.ts:96-114`**.
|
||||
|
||||
Twenty binding calls (`Queue.Play`, `Queue.SetQueue`, `Queue.Clear`, `Queue.RemoveTracks`,
|
||||
`Player.Pause`, `Player.LoadFile`, `Player.Seek`, `Player.SetVolume`, …) are invoked
|
||||
with no `await`, no `.catch()`, and no `void`. Wails still returns a promise, so a
|
||||
rejection (which happens if the bridge is torn down, or the arg fails to marshal)
|
||||
becomes an unhandled rejection.
|
||||
|
||||
Mostly benign today because the Go methods return nothing (see **C1**), but it means
|
||||
these methods cannot report failure even after C1 is fixed.
|
||||
|
||||
Fix: as part of the C1 fix, change the queue methods to return `error` and have the
|
||||
store `.catch()` them.
|
||||
|
||||
### m2. Favorite toggles revert silently
|
||||
**`frontend/src/store/favorites-store.ts:137-158`** (and `160-190` for the batch forms).
|
||||
|
||||
The optimistic update and its revert are both correct, but the revert is invisible.
|
||||
|
||||
Symptom: click the heart, it fills, and half a second later it empties again with no
|
||||
explanation.
|
||||
|
||||
Fix: on the revert path, surface a one-line message.
|
||||
|
||||
### m3. Clearing the queue has no confirmation and no undo
|
||||
**`frontend/src/components/queue-panel/queue-panel.ts:683-685`** →
|
||||
`queue-store.ts:262` → `backend/queue/queue.go:1138`, which stops playback and
|
||||
discards the list.
|
||||
|
||||
Not catastrophic (the queue is reconstructable), but it is the only mutation in the
|
||||
panel with no way back, and it sits next to routine controls.
|
||||
|
||||
Fix: either confirm when the queue is non-trivially long, or keep the last cleared
|
||||
queue in memory behind an "Undo" affordance.
|
||||
|
||||
### m4. Removing a download client provider has no confirmation
|
||||
**`frontend/src/components/config-page/download-clients.ts:684-692`**.
|
||||
Deleting a provider discards its stored credentials
|
||||
(`backend/download`'s `FileSecretStore`), which cannot be recovered.
|
||||
|
||||
Fix: confirm, naming the client.
|
||||
|
||||
### m5. `AddLibrary` / `RenameLibrary` failures are console-only
|
||||
**`frontend/src/components/config-page/config-page.ts:1058-1072`** (add),
|
||||
**`1082-1097`** (rename). Both `console.error`. Note that *removal* — the more
|
||||
dangerous operation — is handled correctly in the same file (impact preview at
|
||||
`1105-1114`, confirmation, `isRemoving` guard, toast at `1129-1143`).
|
||||
|
||||
Fix: route these two through the existing `showToast` (`config-page.ts:1168`).
|
||||
|
||||
### m6. Autotag warning/skip/leave dialogs stall on a rejected binding
|
||||
**`frontend/src/components/autotag-view/autotag-view.ts:1328-1334`**
|
||||
(`onWarningContinue` → `await AckLibraryWarning(...)`),
|
||||
**`1336-1342`** (`onLeaveConfirm` → `await LeaveAsIs(...)`),
|
||||
**`1660-1664`** (`onSkip` → `await Skip(...)`).
|
||||
|
||||
None is wrapped. A rejection means the lines after the await — including
|
||||
`this.dialog = 'none'` — never run.
|
||||
|
||||
Symptom: press "Continue" on the destructive-write warning and the dialog just sits
|
||||
there.
|
||||
|
||||
Fix: try/catch each, close the dialog in a `finally`, and surface the error.
|
||||
|
||||
### m7. Add-to-playlist fails silently after a correct in-flight guard
|
||||
**`frontend/src/components/playlist-picker/playlist-picker.ts:164-193, 216-231`**.
|
||||
|
||||
The `this.loading` guard is right (no double-add), the create button is disabled while
|
||||
in flight (`playlist-picker.ts:321`) — but the failure path is `console.error` and the
|
||||
picker just closes.
|
||||
|
||||
Symptom: the tracks appear not to have been added, and the user cannot tell whether to
|
||||
retry.
|
||||
|
||||
Same shape at `playlist-details.ts:396-412` (remove tracks), `414-438` (remove
|
||||
phantoms), `584-601` (remove one phantom).
|
||||
|
||||
### m8. The download search cannot be cancelled
|
||||
**`frontend/src/components/download-picker/download-picker.ts:127-148`**.
|
||||
|
||||
`downloadStore.start()` queries every enabled provider. The dialog shows a spinner and
|
||||
"Searching your download clients…" but the only exit is Close, which does not cancel
|
||||
the backend work. `search()` also has no stale guard, so a close-and-reopen for a
|
||||
different album can be overwritten by the first search's result.
|
||||
|
||||
Otherwise this file is the strongest failure UX in the codebase — see **What is
|
||||
already right** below.
|
||||
|
||||
---
|
||||
|
||||
## Polish
|
||||
|
||||
### p1. `console.log` debug output left in shipped views
|
||||
`explore-album-details.ts:667, 673, 680, 695, 715, 877`;
|
||||
`explore-artist-details.ts:1017, 1030, 1037, 1071`;
|
||||
`config-page.ts:1019` (`'IndexStatusChanged event received'`).
|
||||
|
||||
### p2. Long-running operation coverage is inconsistent by subsystem
|
||||
|
||||
| Operation | Progress | Cancel | Pause/resume | Survives quit |
|
||||
|---|---|---|---|---|
|
||||
| Library scan | ✅ jobs registry | ✅ | ✅ | ✅ paused scans restored (`backend/library/scan_jobs.go:300`) |
|
||||
| Index build | ✅ | ✅ (confirmed, `job-controls.ts:41`) | ✅ | ✅ checkpointed |
|
||||
| Downloads | ✅ (`download/manager.go:192`) | ✅ | — | ✅ swept on restart |
|
||||
| Batch tag write | ✅ event | ✅ (`track-details.ts:1767`) | — | ❌ not in registry |
|
||||
| **Autotag apply** | ⚠️ page-local only | ❌ | ❌ | ❌ (see **C3**) |
|
||||
| **Download search** | spinner | ❌ | — | ❌ (see **m8**) |
|
||||
| **Requests reconcile** | `checking` flag (`downloads-view.ts:503`) | ❌ | — | — |
|
||||
|
||||
The pattern is clear: everything routed through `jobs.Registry` gets progress, cancel
|
||||
and a global indicator for free. The three gaps are the three things not registered.
|
||||
|
||||
### p3. `EventsOff` is global
|
||||
**`frontend/src/components/track-details/track-details.ts:1765`** calls
|
||||
`EventsOff(Events.BatchWriteProgress)`, which removes *all* listeners for that event,
|
||||
not just this component's. Correct today (single listener) but fragile; prefer the
|
||||
unsubscribe function `EventsOn` returns, as `jobs-view.ts:246-249` does.
|
||||
|
||||
### p4. `OnBeforeClose` never asks
|
||||
**`backend/app.go:445-484`** always returns `false`. Quitting during a full rescan
|
||||
leaves the library partially rebuilt — recoverable, because the soft scan re-runs on
|
||||
next launch (`backend/app.go:568`), but playlists are not restored until that scan
|
||||
completes (`RestoreAllPlaylists` only runs from the `PostScan` hook,
|
||||
`backend/app.go:341`). Worth a confirm while a destructive job is running.
|
||||
|
||||
---
|
||||
|
||||
## What is already right (keep these as the templates)
|
||||
|
||||
- **`frontend/src/components/download-picker/download-picker.ts`** — distinct
|
||||
searching / auto-picked / empty ("Nothing found. Try a different spelling…") /
|
||||
error bodies, an in-flight `picking` guard on `onPick` (`154`), and a footnote that
|
||||
explains *why* it is asking rather than deciding (`243-262`). This is the standard
|
||||
the rest of the app should be measured against.
|
||||
- **`frontend/src/components/home-view/home-view.ts:263-280`** — the only place that
|
||||
correctly distinguishes loading, failed, and genuinely-empty in three separate
|
||||
bodies.
|
||||
- **`frontend/src/components/explore-view/explore-view.ts:703, 793, 820-828`** — a
|
||||
correct monotonic request-version guard on search-as-you-type, checked on the success
|
||||
path, the catch path *and* the `finally` that clears the spinner. This is the fix
|
||||
pattern for **C4** and **M8**.
|
||||
- **`frontend/src/components/track-details/track-details.ts:1706-1766`** — the best
|
||||
destructive flow in the app: an explicit change summary, a confirmation step, live
|
||||
per-file progress, a working cancel, and a per-file failure list afterwards.
|
||||
- **`frontend/src/components/config-page/config-page.ts:1105-1143`** — removal shows a
|
||||
computed impact (`GetRemovalImpact`) *before* asking, guards with `isRemoving`, and
|
||||
reports the outcome. The right shape; only the raw error string (**M9**) lets it down.
|
||||
- **`frontend/src/components/catalog-scope-notice/catalog-scope-notice.ts`** — a
|
||||
purpose-built component whose entire job is to admit what the user is looking at, with
|
||||
Retry offered only in the one scope where retrying means anything.
|
||||
- **`backend/library/scan_jobs.go:265-345`** — paused scans survive a restart, and a
|
||||
pause that outlived the process resumes as an incremental rescan with a log line
|
||||
saying so.
|
||||
- **`frontend/src/store/favorites-store.ts:137-158`** — optimistic update with a
|
||||
correct revert. Only the silence (**m2**) is wrong.
|
||||
|
||||
---
|
||||
|
||||
## Suggested order
|
||||
|
||||
1. **C1** + **C2** — playback failure is the app's core job; it currently fails mute.
|
||||
2. A minimal app-level notification surface, then route **M9**'s eight sites,
|
||||
**M5**, **M6**, **M7**, **m2**, **m5**, **m7** through it. Most of these findings
|
||||
are one problem wearing thirty hats.
|
||||
3. **M3**, **M2** — two permanent fake "loading" states.
|
||||
4. **C4** + **M1** + **M8** — the three async-correctness bugs; all three are the same
|
||||
version-guard fix, and `explore-view.ts` already contains the reference
|
||||
implementation.
|
||||
5. **C3** — register the autotag apply with `jobs.Registry` and it inherits progress,
|
||||
cancel and the global indicator at once.
|
||||
@@ -0,0 +1,259 @@
|
||||
# UI/UX audit — YellowJacket
|
||||
|
||||
Date: 2026-08-11. Method: the app driven by hand headlessly
|
||||
(`make dev-headless SEED=default` + `playwright-cli`, then
|
||||
`make dev-headless-fresh` for first run), plus three read-only static
|
||||
reviews. Nothing was changed.
|
||||
|
||||
- `hands-on.md` (this file) — the empirically confirmed findings, i.e.
|
||||
things observed happening in the running app, with the reproduction.
|
||||
- `a11y.md` — accessibility and interaction model.
|
||||
- `perf.md` — rendering performance, memory, state correctness.
|
||||
- `errors.md` — error handling, empty/loading states, destructive actions.
|
||||
|
||||
Findings below are numbered `H-n` (hands-on) and cross-reference the
|
||||
static reports where they overlap. The reconciliation plan built from
|
||||
all four files is `.planning/plans/completed/007-ui-reconciliation.md`.
|
||||
|
||||
---
|
||||
|
||||
## Critical — confirmed by reproduction
|
||||
|
||||
### H-1. A keypress on any page silently mutates the Autotag queue
|
||||
|
||||
Every view the user visits stays mounted forever (`index.ts`, class
|
||||
`view-hidden`), so `disconnectedCallback` never runs and
|
||||
`autotag-view`'s `document` keydown listener (`autotag-view.ts:1188`,
|
||||
handler at `:1706`) stays live for the rest of the session.
|
||||
|
||||
Reproduced: visited Autotag (Pending 11), navigated to Settings,
|
||||
dispatched `keydown` `s` twice → **Pending 9**. Two albums skipped from
|
||||
a page that was not on screen and gave no feedback. `a` on the same
|
||||
listener is Apply, which rewrites tags on disk.
|
||||
|
||||
### H-2. `s` and the arrow keys fire two handlers at once
|
||||
|
||||
`autotag-view`'s listener and `keyboard-shortcut-service` are both on
|
||||
`document` and neither defers. Reproduced on the Autotag page: pressing
|
||||
`s` emitted `QueueModeChanged` (shuffle toggled) *and* skipped the
|
||||
album. `ArrowUp`/`ArrowDown` navigate the folder list *and* change the
|
||||
volume by 5, so walking the autotag list with the keyboard ramps volume
|
||||
to 0 or 100.
|
||||
|
||||
### H-3. The progress bar is a local timer that lies, and a keyboard seek desyncs it by ~30 s
|
||||
|
||||
`seek-bar.ts:110-116` increments `seekValue` by 1 every 1000 ms and only
|
||||
resyncs when `trackChangeId` changes. Nothing reconciles it against
|
||||
`Player.CurrentPositionSeconds`.
|
||||
|
||||
Reproduced twice:
|
||||
|
||||
| | UI | backend |
|
||||
|---|---|---|
|
||||
| steady playback, +10 s | 00:47 → 00:57 | 50 → 60 (constant 3 s lie) |
|
||||
| after 4× `ArrowRight` (seek +5 s) | 00:08 → **00:10** | 11 → **40** |
|
||||
|
||||
The keyboard seek path (`keyboard-shortcut-service.ts:207-214`) calls
|
||||
`Player.Seek` and never tells the seek bar, so the bar does not move at
|
||||
all — the shortcut looks broken, and the displayed time is wrong for
|
||||
the rest of the track.
|
||||
|
||||
### H-4. Every icon in the app is fetched from fontawesome.com at runtime
|
||||
|
||||
Confirmed from `performance.getEntriesByType('resource')`:
|
||||
`https://ka-f.fontawesome.com/releases/v7.1.0/svgs/solid/house.svg`
|
||||
and 35 more. `setBasePath('/dist/webawesome')` in `index.ts` does not
|
||||
affect the icon resolver, and no `registerIconLibrary` call exists.
|
||||
A desktop music player offline, on a captive portal, or behind a
|
||||
firewall renders **no icons at all**. See `perf.md` M9.
|
||||
|
||||
### H-5. The whole app is unusable without a mouse
|
||||
|
||||
Tabbing through the entire app yields **14 stops**, all of them chrome
|
||||
(library filter, search, one unlabelled track-list button, two queue
|
||||
buttons, five transport buttons, volume, queue toggle, seek). The
|
||||
sidebar nav (`app-sidebar.ts:202`, bare `<li @click>`), every track
|
||||
row, every album/artist/genre card and every context menu are
|
||||
unreachable. `Enter` on a selected track does nothing — reproduced.
|
||||
|
||||
The cause of the last part is that `data-shortcut-scope` is **never set
|
||||
anywhere in the codebase**, so `resolveScope` can only return
|
||||
`text-input` or `global`, and the two panel-scoped bindings
|
||||
(`tracklist.play` = Enter, `tracklist.delete` = Delete) are dead
|
||||
shortcuts that the Settings page still advertises as configurable.
|
||||
|
||||
Related: the closed queue panel is `width: 0` but not `inert` and not
|
||||
`visibility: hidden` (`queue-panel.ts:214`), so its Clear/Add buttons
|
||||
still take tab stops and are read by screen readers — reproduced, they
|
||||
appear in the tab order at x=1440.
|
||||
|
||||
---
|
||||
|
||||
## Major — confirmed by reproduction
|
||||
|
||||
### H-6. Global single-key shortcuts hijack keys from focused controls
|
||||
|
||||
Defaults (`backend/shortcuts/shortcuts.go:16`) bind unmodified
|
||||
`Space N P S R M / Q ↑ ↓ ← →` at global scope, and the service calls
|
||||
`preventDefault()` on a match. Only text inputs are exempt. So a
|
||||
focused `<button>` cannot be activated with Space, the native
|
||||
`<select>` library filter cannot be arrowed through, the volume and
|
||||
seek sliders fight the global handler for arrow keys, and Space/arrow
|
||||
page scrolling is dead everywhere.
|
||||
|
||||
`ArrowUp` also emits `MuteChanged` alongside `VolumeChanged` even when
|
||||
nothing is muted — reproduced.
|
||||
|
||||
### H-7. The last column of the track list is always clipped by exactly 40 px
|
||||
|
||||
`computeDefaultWidths` (`track-list.ts:409`) distributes
|
||||
`this.clientWidth` across the columns but never subtracts the 24 px
|
||||
favourite column or the 2×8 px row padding that
|
||||
`colBoundaryPositions` (`:378`) knows about. Measured: every
|
||||
`.track-row` and the `.header-row` report `scrollWidth 1280` against
|
||||
`clientWidth 1240`. Duration renders as "Durat…" on a fresh profile at
|
||||
1440×900, and disappears entirely below ~1000 px.
|
||||
|
||||
### H-8. The app never lands on Home
|
||||
|
||||
`app-sidebar.ts:124` defaults `activeView = 'tracks'`. The curated Home
|
||||
page — the one with the "somewhere to start listening" shelves — is
|
||||
listed first in the nav and is never what the user sees on launch.
|
||||
|
||||
### H-9. On the Home page, an album with no cover art renders as nothing
|
||||
|
||||
The Home shelf card's missing-art placeholder has no background, so the
|
||||
tile is invisible against the page and the shelf reads as having holes
|
||||
in it. The Albums grid and the Artists grid both do this correctly
|
||||
(letter-on-a-tile), so this is one card renderer disagreeing with the
|
||||
other two.
|
||||
|
||||
Also on Home: with a small library all three shelves ("Fresh in your
|
||||
library", "Never played", "Take a chance") show the **same seven
|
||||
albums** in different orders, so the page reads as repeating itself.
|
||||
A shelf whose contents largely duplicate the shelf above it would be
|
||||
better suppressed, the way an empty one already is.
|
||||
|
||||
### H-10. The header search is view-scoped but looks global
|
||||
|
||||
Typing `tide` on the Playlists page produced **"No playlists match your
|
||||
search"** while three tracks named *Tideline* sat in the library. The
|
||||
box is in the global header, is placeheld "Search…", and persists its
|
||||
term across navigation, so it reads as a library-wide search and is
|
||||
not one. It also vanishes entirely on Home and Explore (Explore has its
|
||||
own second search box), and its appearing/disappearing shifts the whole
|
||||
header layout.
|
||||
|
||||
### H-11. The layout has no responsive behaviour and the enforced minimum window is too small
|
||||
|
||||
`MinWidth/MinHeight` are 512×384 (`backend/config/window.go:15`). At
|
||||
900×600 the Duration column is off-screen; at 700×480 the sidebar
|
||||
overflows behind the player bar with no scroll, so **Settings and Jobs
|
||||
become unreachable**, and the app title wraps into the nav. The sidebar
|
||||
has a `.collapsed` icon mode but nothing triggers it automatically.
|
||||
|
||||
### H-12. First run shows "Loading tracks…" behind an inert copy of the whole app
|
||||
|
||||
On an empty `YJ_HOME` the wizard is a modal over a fully rendered app —
|
||||
sidebar, transport, search, library filter all visible and all inert —
|
||||
with a permanent "Loading tracks…" in the content area (the track list
|
||||
cannot tell empty from loading, `track-list.ts:1901`). Meanwhile the
|
||||
"Building search index" job is already downloading a 1.1 M-row catalog
|
||||
before the user has chosen a folder or consented to it.
|
||||
|
||||
`Get Started` is correctly disabled until a folder is chosen, but it is
|
||||
the filled accent button and its disabled state is barely visible.
|
||||
|
||||
### H-13. The album detail page has no way to play the album
|
||||
|
||||
The primary action is missing: no Play, no Shuffle, no Add to queue on
|
||||
the album header. Nor is there any legend for the green ✓ badges shown
|
||||
against the album title and every track.
|
||||
|
||||
### H-14. `IndexStatusChanged` is emitted every 3 seconds forever
|
||||
|
||||
`searchindex.go:276` starts an unconditional 3 s ticker in
|
||||
`SetContext` and never stops it. The payload is byte-identical once the
|
||||
index is ready (`building:false, ready:true`) and it keeps firing for
|
||||
the life of the process. Each tick re-renders the 2 149-line
|
||||
`config-page` (which never unmounts) and writes a `console.log`
|
||||
(`config-page.ts:1019`) — the browser console filled with ~200
|
||||
identical lines during a 20-minute session. See `perf.md` M6.
|
||||
|
||||
---
|
||||
|
||||
## Minor — confirmed by observation
|
||||
|
||||
- **H-15.** Three identical `Tideline / Aurora Fields / 00:06` rows are
|
||||
indistinguishable in the track list; the default columns carry no
|
||||
album, format or path, so the app's own duplicate fixtures cannot be
|
||||
told apart by eye in a library manager that has a duplicate-detection
|
||||
feature.
|
||||
- **H-16.** The remaining-time label is a countdown with no minus sign,
|
||||
no label and no toggle to total duration — `01:21` next to a track
|
||||
the list says is `01:30`.
|
||||
- **H-17.** The now-playing artist is truncated to a fixed ~120 px
|
||||
("The Orchestra Of") while ~400 px of empty space sits between it and
|
||||
the transport controls.
|
||||
- **H-18.** When a queue finishes, the now-playing bar empties
|
||||
completely, losing the context of what just played, while the queue
|
||||
panel still lists the finished track.
|
||||
- **H-19.** Page headings are inconsistent: Playlists, Downloads, Jobs,
|
||||
Settings and Home have a title (and Playlists/Downloads/Jobs have
|
||||
header actions); Artists, Genres, Albums and Tracks have none, and
|
||||
none of them shows a count. Sort controls exist on Albums and Tracks
|
||||
but not on Artists or Genres.
|
||||
- **H-20.** The sidebar's hover colour (`#343a40`) and its active
|
||||
colour (`#495057`) are close enough that a hovered item reads as a
|
||||
second selected item.
|
||||
- **H-21.** The track context menu has no Escape handler, no keyboard
|
||||
navigation and no focus movement (`context-menu-controller.ts` binds
|
||||
only click/contextmenu/mousedown), and is missing the conventional
|
||||
entries: Go to album, Go to artist, Show in file manager, Edit tags,
|
||||
Remove from library.
|
||||
- **H-22.** In Settings, "Libraries" — the section that matters most —
|
||||
is last and below the fold, while "Search Index" is first and
|
||||
expanded by default. There is no Playback/Audio section at all (no
|
||||
output device, gapless, crossfade or replay gain).
|
||||
- **H-23.** Explore is an empty page with a search box over a 1.1 M-row
|
||||
catalog: no browse, no popular-artists entry point, nothing to do
|
||||
without typing.
|
||||
- **H-24.** Long body copy (Downloads' intro, Jobs' descriptions) runs
|
||||
the full ~1200 px content width with no measure cap.
|
||||
|
||||
---
|
||||
|
||||
## Where the bar is already high
|
||||
|
||||
Worth naming, because the findings above are the exceptions:
|
||||
|
||||
- **`downloads-view`** — the best empty state in the app: it says what
|
||||
the feature is, why nothing is happening, and exactly what to do next.
|
||||
- **`autotag-view`** — genuinely dense and legible: per-field match
|
||||
breakdown, your-folder-vs-candidate side by side, confidence stated
|
||||
rather than hidden.
|
||||
- **`jobs-view`** — running / libraries / maintenance / recently
|
||||
finished, with the destructive action visually separated and honestly
|
||||
described.
|
||||
- **`track-list`** — a properly built virtualized list (memoized
|
||||
filter/sort, delegated handlers, `_itemSize` hint, inline SVG for the
|
||||
per-row icon). Its problems are at the edges, not in the core.
|
||||
- **`player-controls`** — every button labelled, `aria-pressed` on the
|
||||
toggles, repeat's three-state mode spelled into the label.
|
||||
|
||||
---
|
||||
|
||||
## Suggested order
|
||||
|
||||
1. **H-1 / H-2** — a hidden page mutating files on a keystroke is the
|
||||
only finding here that loses user data. Fix the view lifecycle
|
||||
(deactivate hidden views) and make the two keydown listeners agree.
|
||||
2. **H-3** — drive the seek bar from the backend position; the core
|
||||
surface of a music player currently lies.
|
||||
3. **H-4** — bundle the icons; the app is not usable offline.
|
||||
4. `errors.md` **C1** — a track that fails to play is a silent no-op,
|
||||
which is the same class of problem as H-3 on the same surface.
|
||||
5. **H-5 / H-6** — keyboard access, and stop the global shortcuts
|
||||
stealing keys from focused controls.
|
||||
6. **H-7 / H-11** — the layout arithmetic and a real minimum size.
|
||||
7. Then the consistency pass: **H-8, H-9, H-10, H-13, H-19**.
|
||||
@@ -0,0 +1,505 @@
|
||||
# Frontend performance / memory / state-correctness audit
|
||||
|
||||
**Scope:** `frontend/src/store/**`, `frontend/src/components/**`, `frontend/src/events.ts`,
|
||||
`frontend/vite.config.mts`, `frontend/package.json`, `frontend/index.ts`, `frontend/index.html`.
|
||||
Read-only. Nothing in the repo was modified. (Two throwaway production builds were emitted to
|
||||
`/tmp/yjbuild*` to measure bundle composition; `frontend/dist/` was not touched.)
|
||||
|
||||
**Excluded as already-known** (traced for consequences, not re-reported): views never unmount,
|
||||
`autotag-view`'s document keydown, `IndexStatusChanged` every 3 s, seek-bar drift.
|
||||
|
||||
---
|
||||
|
||||
## Critical
|
||||
|
||||
### C1 — Finishing a track re-downloads the entire library
|
||||
|
||||
`backend/queue/playhistory.go:63` → `frontend/src/store/library-store.ts:85` → `:445`
|
||||
|
||||
`recordPlay()` emits `TrackMetadataChanged` on **every naturally finished track**
|
||||
(`backend/queue/handlers.go:24,34,45,52`). `LibraryStore` treats that event exactly like a retag:
|
||||
`invalidate()` nulls tracks/albums/artists/genres and immediately `eagerFetch()`es all four
|
||||
(`library-store.ts:445-476`). On a 50 k-track library that is `GetAllTracks` +
|
||||
`GetAllAlbums` + `GetAllArtists` + `GetAllGenresWithCounts` — roughly 25 MB of JSON across the
|
||||
Wails IPC, parsed on the main thread — **once per song**, forever, whether or not the user is
|
||||
looking at a list.
|
||||
|
||||
The invalidation itself is correct and deliberate (`frontend/test/stores/library-store.test.ts:94-110`
|
||||
asserts it); the defect is that the backend reuses one event for "tags were rewritten" and
|
||||
"play_count went up by one".
|
||||
|
||||
*Symptom:* a multi-second main-thread stall between every two tracks on a large library, plus
|
||||
constant SQLite churn.
|
||||
*Fix:* emit a distinct `TrackPlayCountChanged` from `recordPlay` and have `LibraryStore` patch the
|
||||
one track in place instead of invalidating.
|
||||
|
||||
### C2 — …and silently wipes the user's selection while it does
|
||||
|
||||
`frontend/src/components/track-list/track-list.ts:1198-1211` → `:1242-1246`
|
||||
|
||||
`updated()` notices `libraryCtrl.cachedTracks` has a new identity and calls `loadTracks()`, which
|
||||
does `this.selection.clear()` (`:1246`). Combined with C1, **every track change clears whatever the
|
||||
user had selected in the track list.** Selecting 40 tracks to drag into a playlist while music plays
|
||||
is not possible.
|
||||
|
||||
*Fix:* re-key the selection against the new array (`selection` is keyed by `FilePath`, which
|
||||
survives a refetch) instead of clearing it.
|
||||
|
||||
### C3 — Library-filter / rescan race caches the wrong library's data
|
||||
|
||||
`frontend/src/store/library-store.ts:133-155` (and the identical `getAlbums`/`getArtists`/`getGenres`)
|
||||
|
||||
`getTracks()` guards on `tracksLoading`, but `invalidate()` (`:445`) clears `tracks` **without**
|
||||
clearing `tracksLoading`. Sequence:
|
||||
|
||||
1. `getTracks()` starts for library A → `tracksLoading = true`.
|
||||
2. User picks library B → `setSelectedLibrary` (`:339`) → `invalidate()` → `tracks = null`,
|
||||
`eagerFetch()` → `getTracks()` sees `tracks === null && tracksLoading === true` → returns
|
||||
`waitForTracks()`.
|
||||
3. Library A's response lands, is stored as `this.tracks`, `changeGen++`.
|
||||
4. `waitForTracks()` resolves with library A's tracks — under library B's filter.
|
||||
|
||||
The same window exists for `LibraryScanComplete` arriving while a fetch is in flight, in which case
|
||||
the pre-scan snapshot is cached as if it were post-scan and the newly scanned tracks never appear.
|
||||
|
||||
*Fix:* stamp each fetch with a request id (or the `selectedLibraryIdValue` + `changeGen` it started
|
||||
under) and discard the result if it no longer matches.
|
||||
|
||||
### C4 — `waitFor*` never resolves on a failed fetch, and leaks a subscriber forever
|
||||
|
||||
`frontend/src/store/library-store.ts:494-547` (4 copies), `frontend/src/store/playlist-store.ts:143-157`
|
||||
|
||||
`waitForTracks()` resolves only when `!tracksLoading && tracks !== null`. If the underlying binding
|
||||
rejects, `finally` sets `tracksLoading = false` but `tracks` stays `null`, so the promise **never
|
||||
settles** and its `subscribe()` callback is never removed from `LibraryStore.subscribers`. Every
|
||||
component or `explore-link` lookup awaiting that promise hangs, and each hung wait permanently adds
|
||||
a closure to the notify set that runs on every subsequent store change. `eagerFetch()`'s
|
||||
`void this.getTracks()` (`:474-477`) also swallows the rejection into an unhandled promise rejection.
|
||||
|
||||
*Fix:* have the fetch record an error state and reject/resolve all waiters in `finally`.
|
||||
|
||||
### C5 — Adding one track to one playlist re-downloads every track of every playlist
|
||||
|
||||
`frontend/src/store/playlist-store.ts:31-33` → `:124-129` → `:60`
|
||||
|
||||
`PlaylistTracksChanged` (emitted from 8 backend sites including `backend/playlist/favorites.go:200,231`)
|
||||
calls `invalidate()` → `GetAllPlaylistsWithTracks()`, which the backend implements as
|
||||
`GetAllPlaylists` + `GetAllPlaylistTracksWithMetadata` — **all rows of all playlists with full track
|
||||
metadata** (`backend/playlist/playlist.go:206-234`).
|
||||
|
||||
Toggling a single heart in the track list therefore refetches every playlist in the app. The store
|
||||
does this unconditionally (`void this.getPlaylists()` inside `invalidate()`), so it fires even when
|
||||
`playlist-view` — the only subscriber — has never been opened.
|
||||
|
||||
*Fix:* the event already carries the playlist id; refetch that one playlist, and only when there is
|
||||
a subscriber.
|
||||
|
||||
---
|
||||
|
||||
## Major
|
||||
|
||||
### M1 — One keystroke in the search box re-ranks every list in the app
|
||||
|
||||
`frontend/src/store/search-store.ts:55-57`, `frontend/src/store/controllers/search-controller.ts:29-32`
|
||||
|
||||
`SearchStore.notify()` is an unbatched broadcast to every subscriber, and `SearchController` maps it
|
||||
straight to `host.requestUpdate()`. Eight components hold a `SearchController`
|
||||
(`track-list`, `cover-grid`, `artists-view`, `genres-view`, `playlist-view`, `playlist-details`,
|
||||
`smart-playlist-details`, `search-bar`) and — because views stay mounted — **all of the mounted ones
|
||||
recompute on every keystroke**, not just the visible one:
|
||||
|
||||
- `track-list` → `rankTracks()` over 50 k tracks (`track-list.ts:271-289`)
|
||||
- `cover-grid` → filter + `[...albums].sort()` over 5 k albums (`cover-grid.ts:215-248`)
|
||||
- `artists-view`, `genres-view` → their own filter passes
|
||||
|
||||
Measured on Node/V8 (WebKit2GTK will be slower): `rankTracks`-equivalent work over 50 k tracks is
|
||||
**~18 ms**, so a single keystroke costs 50–100 ms of main-thread work across the mounted set even
|
||||
though four of the five results are invisible.
|
||||
|
||||
*Fix:* gate the notify on `searchStore.isSearchableView()` matching the subscriber's own view (the
|
||||
predicate already exists at `search-store.ts:41-43`), or have `SearchController` skip
|
||||
`requestUpdate()` when its host carries `view-hidden`.
|
||||
|
||||
### M2 — `rankTracks` allocates a `Set` and a closure per track, per keystroke
|
||||
|
||||
`frontend/src/components/track-list/search-ranking.ts:98-135`
|
||||
|
||||
`scoreTrack()` builds `new Set<string>()` plus a `check` closure for **every** track, then calls
|
||||
`col.accessor(track).toLowerCase()` (a fresh string allocation) per field. At 50 k tracks × 3 core
|
||||
fields that is 50 k Sets, 50 k closures and 150 k throwaway strings per keystroke. Benchmarked
|
||||
against a flat three-field comparison: **18.1 ms vs 5.8 ms** — a 3× tax purely from the dedup
|
||||
machinery, for a `seen` set that only ever contains 3–6 fixed ids.
|
||||
|
||||
*Fix:* hoist the deduped column list out of the per-track loop (compute it once in `rankTracks`) and
|
||||
drop the closure.
|
||||
|
||||
### M3 — Full-size original cover art rendered as a 24 px thumbnail in the track list
|
||||
|
||||
`frontend/src/components/track-list/columns.ts:53-63`
|
||||
|
||||
The `albumArt` column renders `track.CoverArtPath` — the **original embedded artwork**, commonly
|
||||
1500×1500 and several hundred KB — scaled to `width:24px;height:24px` by CSS. `CoverArtSmall`
|
||||
(100 px, quality 75) and `CoverArtMedium` (200 px) already exist on the same model
|
||||
(`wailsjs/go/models.ts:1583-1586`, generated by `backend/library/coverart.go:41-45`) and are used
|
||||
correctly everywhere else. There is also no `loading="lazy"` and no `decoding="async"`, so every row
|
||||
the virtualizer scrolls into view decodes a full-resolution JPEG synchronously on the main thread.
|
||||
|
||||
*Symptom:* enabling the Art column makes track-list scrolling stutter and inflates memory by the
|
||||
decoded bitmap of every album scrolled past.
|
||||
*Fix:* `track.CoverArtSmall || track.CoverArtPath`, plus `loading="lazy" decoding="async"`.
|
||||
|
||||
### M4 — Artist grid does a full linear scan of the album cache per card, per frame
|
||||
|
||||
`frontend/src/components/artists-view/artists-view.ts:988-1029`, called from `:1044` /
|
||||
`.renderItem` at `:1298`
|
||||
|
||||
When an artist has no `ImageSmall/Medium/Large` — the common case for a locally-tagged library —
|
||||
`renderArtistAvatar()` falls back to scanning **all of `libraryStore.cachedAlbums`** with
|
||||
`a.ArtistName.toLowerCase() === name` until it finds a match, allocating two lowercased strings per
|
||||
comparison. This runs inside the virtualizer's `renderItem`, i.e. for every visible card on every
|
||||
render pass. At 5 000 albums × ~50 visible cards that is 250 000 comparisons and 500 000 string
|
||||
allocations per scroll frame.
|
||||
|
||||
*Fix:* build a `Map<lowercasedArtistName, coverUrls>` once when `cachedAlbums` identity changes, and
|
||||
look up in O(1).
|
||||
|
||||
### M5 — Playlist and smart-playlist track lists are not virtualized
|
||||
|
||||
`frontend/src/components/playlist-details/playlist-details.ts:1265-1396`,
|
||||
`frontend/src/components/smart-playlist-details/smart-playlist-details.ts:1176-1250`
|
||||
|
||||
Both render **every** track with a plain `.map()` — no `lit-virtualizer`, no `repeat()` key. For a
|
||||
2 000-track playlist that is 2 000 rows × 8 elements in the DOM, and:
|
||||
|
||||
- `getVisibleTracks()` (`playlist-details.ts:750-780`) allocates a fresh `{track, trackIndex}`
|
||||
wrapper object for every track on **every** render, so the array identity always changes;
|
||||
- five event bindings per row (`@click`, `@dblclick`, `@contextmenu`, `@dragstart`, `@dragend`,
|
||||
`:1305-1330`) are new arrow functions each render, so lit removes and re-adds 10 000 listeners
|
||||
per pass;
|
||||
- both components hold a `PlayerController` (`playlist-details.ts` imports it), whose subscription
|
||||
is unfiltered — so **every** `PlaybackStateChanged` / `TrackChanged` / `VolumeChanged` /
|
||||
`MuteChanged` triggers that whole pass;
|
||||
- the row `<img>` (`:1386`, `smart-playlist-details.ts:1245`) has no `loading="lazy"`, so opening a
|
||||
2 000-track playlist fires 2 000 simultaneous cover-art requests at the Go asset handler.
|
||||
|
||||
Both files are ~30 kB of the bundle each and duplicate the same list; `track-list` already solves
|
||||
all of this (delegated handlers via `data-index`, stable `renderItem`, memoized caches) and is
|
||||
already reused by `genre-details.ts:276-278` via `.externalTracks`.
|
||||
|
||||
*Fix:* render these with `<track-list .externalTracks=…>` the way `genre-details` does, or at minimum
|
||||
add `lit-virtualizer` + delegated handlers.
|
||||
|
||||
### M6 — Visiting Settings costs a full re-render (and a console entry) every 3 seconds, forever
|
||||
|
||||
`frontend/src/components/config-page/config-page.ts:1016-1022`, `@state` at `:186`
|
||||
|
||||
The `IndexStatusChanged` handler assigns a freshly deserialized object to a `@state` field, so the
|
||||
identity always differs and Lit re-renders the entire 2 149-line `config-page` template every 3 s —
|
||||
for the rest of the session, since `config-page` is a cached primary view that never unmounts
|
||||
(`index.ts:71`) and its `disconnectedCallback` cleanup (`:1024-1036`, including
|
||||
`this.cancelIndexStatus?.()`) never runs.
|
||||
|
||||
The handler also does `console.log('IndexStatusChanged event received', status)` on every tick. With
|
||||
devtools open that retains ~1 200 status objects per hour as a genuine, unbounded leak.
|
||||
|
||||
*Fix:* drop the `console.log`; compare the incoming status field-wise and only assign on change.
|
||||
|
||||
### M7 — `explore-view` retains base64 image data forever
|
||||
|
||||
`frontend/src/components/explore-view/explore-view.ts:99-100`, `:987`, `:1003-1019`, `:936-944`
|
||||
|
||||
`thumbnailCache` stores the **data URL** returned by `GetThumbnails` —
|
||||
`"data:image/jpeg;base64," + base64(front-250 JPEG)` (`backend/explore/coverartproxy.go:114`,
|
||||
`backend/explore/coverart.go:27-29`). A 250 px CAA JPEG is ~15–25 kB, ~20–33 kB base64, and JS
|
||||
strings are UTF-16, so **~40–66 kB of retained heap per cached album**, plus the browser's decoded
|
||||
bitmap keyed off that same multi-kilobyte string.
|
||||
|
||||
Neither `thumbnailCache` nor `artistImageCache` is ever evicted, and `explore-view` is a cached
|
||||
primary view (`index.ts:67`) that never unmounts. A session of browsing — a desktop player runs for
|
||||
days — grows monotonically: a few hundred searches × ~50 results is on the order of hundreds of MB.
|
||||
|
||||
*Fix:* cap both maps with an LRU (a few hundred entries), or return a `/coverart/<mbid>` URL from the
|
||||
backend instead of a data URL so the browser's own image cache handles eviction.
|
||||
|
||||
### M8 — `exploreCache` is a second unbounded, never-evicted cache
|
||||
|
||||
`frontend/src/store/explore-cache.ts:35-38`
|
||||
|
||||
Four module-level `Map`s (`artists`, `albums`, `artistAlbums`, `artistTopTracks`) with `set` but no
|
||||
`delete`, no size cap and no TTL. `artistAlbums` holds full `MBReleaseGroup[]` discographies and
|
||||
`artistTopTracks` full `LBTopRecording[]` lists. Grows for the lifetime of the process.
|
||||
|
||||
*Fix:* bound each map (LRU, ~100 entries is plenty for "avoid a refetch when the user hits back").
|
||||
|
||||
### M9 — Every `<wa-icon>` is fetched from a remote CDN at runtime
|
||||
|
||||
`frontend/index.ts:29-30,47`; resolver in
|
||||
`@awesome.me/webawesome/dist/chunks/chunk.F5JLNOSF.js` (`library.default`)
|
||||
|
||||
WebAwesome's default icon library resolves to
|
||||
`https://ka-f.fontawesome.com/releases/v7.1.0/svgs/<folder>/<name>.svg`. The literal is present in
|
||||
the built bundle. `setBasePath('/dist/webawesome')` does **not** change this — `getBasePath` is only
|
||||
consumed by the component autoloader (`chunk.2PWIIYRH.js:51`), and no
|
||||
`registerIconLibrary(...)` call exists anywhere in the app.
|
||||
|
||||
There are 165 `<wa-icon>` instances across 36 distinct names, so first paint of each view fires up to
|
||||
36 cross-origin requests. The icon module caches by URL, so it is bounded per session — but a
|
||||
desktop music player that is offline, on a captive network, or behind a firewall renders **no icons
|
||||
at all**, and cold start waits on fontawesome.com.
|
||||
|
||||
*Fix:* register a local icon library resolving to bundled SVGs (`src/assets/images/icons/` already
|
||||
holds a set), and add a `vite-plugin-static-copy` rule — the plugin is already a declared devDep
|
||||
(`package.json`) but is not referenced by `vite.config.mts`, and `dist/webawesome/` does not exist.
|
||||
|
||||
### M10 — 1.18 MB single chunk, no route-level code splitting
|
||||
|
||||
`frontend/vite.config.mts:16-22`, `frontend/index.ts:1-27`
|
||||
|
||||
Verified build (`vite build --outDir /tmp/yjbuild`):
|
||||
|
||||
```
|
||||
assets/main-BAFmIgXb.css 53.46 kB │ gzip: 7.48 kB
|
||||
assets/main-yB2fsiPY.js 1,183.64 kB │ gzip: 242.14 kB
|
||||
(!) Some chunks are larger than 500 kB after minification.
|
||||
```
|
||||
|
||||
`rollupOptions` sets only `input`; there is no `manualChunks` and no `import()` anywhere, and
|
||||
`index.ts` statically imports all 27 views, so every module is downloaded, parsed and
|
||||
**side-effect-evaluated** (every store singleton constructed, every `@customElement` registered)
|
||||
before first paint.
|
||||
|
||||
Sourcemap-attributed composition of the 1.16 MB of mapped output:
|
||||
|
||||
| bytes | source |
|
||||
|---|---|
|
||||
| 199 497 | `@awesome.me/webawesome` |
|
||||
| 76 008 | `components/autotag-view/autotag-view.ts` |
|
||||
| 52 828 | `components/explore-artist-details/…` |
|
||||
| 48 519 | `components/config-page/config-page.ts` |
|
||||
| 42 172 | `components/track-details/track-details.ts` |
|
||||
| 37 394 | `@lit-labs/virtualizer` |
|
||||
| 36 666 | `components/playlist-view/playlist-view.ts` |
|
||||
| 36 333 | `components/explore-album-details/…` |
|
||||
| 34 457 | `components/explore-view/explore-view.ts` |
|
||||
| 31 317 | `components/track-list/track-list.ts` |
|
||||
| 30 989 | `components/playlist-details/…` |
|
||||
| 30 661 | `components/cover-grid/cover-grid.ts` |
|
||||
| 30 180 | `wailsjs/go/models.ts` |
|
||||
|
||||
The startup-critical path is roughly `track-list` + `cover-grid` + `now-playing` + `audio-player` +
|
||||
`app-sidebar` + lit + virtualizer ≈ 200 kB. `autotag-view` (76 kB, the single largest app module),
|
||||
`config-page`, `explore-*`, `track-details`, `jobs-*` and `downloads-view` are all reachable only
|
||||
from a sidebar click.
|
||||
|
||||
*Fix:* replace the static imports in `index.ts` with `await import()` inside the `navigate` handler's
|
||||
`VIEW_TAGS` branch — the view is already created lazily there (`index.ts:120-127`), only the module
|
||||
is eager.
|
||||
|
||||
---
|
||||
|
||||
## Minor
|
||||
|
||||
### m1 — `.renderItem` / `.keyFunction` are new closures every render in two virtualized views
|
||||
|
||||
`frontend/src/components/artists-view/artists-view.ts:1298-1299`,
|
||||
`frontend/src/components/genres-view/genres-view.ts:1196-1197`
|
||||
|
||||
`LitVirtualizer` declares both as `@property()` with the default `!==` `hasChanged`
|
||||
(`@lit-labs/virtualizer/LitVirtualizer.js:48-54`), so a fresh arrow function marks the property
|
||||
dirty and forces the virtualizer's own render pass on every host update. `cover-grid.ts:1893-1894`
|
||||
and `track-list.ts:1936-1937` correctly bind the stable `this.renderGridEntry` /
|
||||
`this.renderTrackRow` — these two do not. (`keyFunction` is a fresh closure in all four; `repeat()`
|
||||
keying limits the DOM damage to re-evaluated templates for the visible window.)
|
||||
|
||||
*Fix:* hoist to bound class fields, as `cover-grid` already does.
|
||||
|
||||
### m2 — Serial N+1 binding calls behind "play these"
|
||||
|
||||
- `frontend/src/components/artists-view/artists-view.ts:945-971` — `GetAlbumsByArtist`, then
|
||||
`await GetAlbumTracks(album.ID)` **inside a `for` loop**. A 30-album artist is 31 sequential IPC
|
||||
round-trips.
|
||||
- `frontend/src/components/cover-grid/album-selection.ts:100-112` — same shape; Ctrl+A over 5 000
|
||||
albums is 5 000 sequential round-trips (partly mitigated by `albumFilePathCache`).
|
||||
- `frontend/src/components/genres-view/genres-view.ts:740-751` — one `GetTracksByGenre` per selected
|
||||
genre, all fired concurrently, each returning full track rows that are then deduped client-side.
|
||||
|
||||
*Fix:* add a single `GetTracksByAlbumIDs([]int64)` / `GetTracksByGenres([]string)` binding.
|
||||
|
||||
### m3 — Timers that survive because their view never unmounts
|
||||
|
||||
The cleanup is written correctly; it simply never executes for cached primary views.
|
||||
|
||||
- `frontend/src/components/downloads-view/downloads-view.ts:216-218` — a 30 s `setInterval` clock,
|
||||
cleared at `:226` in `disconnectedCallback`. Once Downloads is visited it ticks and re-renders the
|
||||
view for the rest of the session.
|
||||
- `frontend/src/components/now-playing/now-playing.ts:481,503` — `onScrollCycleEnd` schedules
|
||||
`startScrollCycle` (2 s) which schedules the scroll (1.5 s), indefinitely, so a long track title
|
||||
drives a state change + re-render every ~3.5 s forever while it plays.
|
||||
|
||||
*Fix:* drive these off the `view-hidden` class (a `MutationObserver` on the host, or an explicit
|
||||
`viewActivated`/`viewDeactivated` hook in `index.ts`) rather than connect/disconnect.
|
||||
|
||||
### m4 — Permanent global `mousemove`/`mouseup` listeners for drag interactions
|
||||
|
||||
`frontend/src/components/track-list/track-list.ts:1076-1077`,
|
||||
`frontend/src/components/now-playing/now-playing.ts:240-241`
|
||||
|
||||
Column resize and panel resize register document-level `mousemove` in `connectedCallback` and only
|
||||
remove it in `disconnectedCallback`. Both guard-and-return immediately
|
||||
(`track-list.ts:622-623`, `now-playing.ts:582-583`), so the cost is small, but they run on every
|
||||
pointer move anywhere in the app for the process lifetime and defeat the browser's ability to skip
|
||||
the listener entirely.
|
||||
|
||||
*Fix:* attach on `mousedown`, detach on `mouseup` — the standard drag pattern.
|
||||
|
||||
### m5 — `updated()` does unconditional DOM work every cycle
|
||||
|
||||
- `frontend/src/components/artists-view/artists-view.ts:417-420` and
|
||||
`genres-view.ts:409-412` — `updateSizeProperties()` writes 2 `style.setProperty` calls on the host
|
||||
unconditionally (`artists-view.ts:671-701`), and `ensureWheelListener()` does a
|
||||
`shadowRoot.querySelector` every pass just to check a boolean it already stores
|
||||
(`:611-629`). Both should be guarded on the value/flag they already track.
|
||||
- `frontend/src/components/now-playing/now-playing.ts:259-263` — `checkOverflows()` +
|
||||
`applyScrollDistances()` do 6 `querySelector`s and interleave `scrollWidth`/`clientWidth` reads
|
||||
with `style.setProperty` writes on every update, i.e. forced synchronous layout followed by
|
||||
invalidation, on a component that re-renders on every player-store change.
|
||||
|
||||
### m6 — O(total items) helpers on the selection hot path
|
||||
|
||||
`frontend/src/utils/selection-controller.ts:160-173`
|
||||
|
||||
`getSelectedKeysOrdered()` walks the entire item list (50 k `getItemKey` calls) rather than the
|
||||
selection. It is called from every context-menu action, every favourite toggle and every
|
||||
`dragstart` (`track-list.ts:1379-1400`), so starting a drag of one row costs a 50 k-iteration loop.
|
||||
|
||||
Related: `frontend/src/components/track-list/track-list.ts:1507-1520` —
|
||||
`openBatchTrackDetails` does `filePaths.map(fp => this.tracks.find(...))`, i.e. O(selection × total).
|
||||
"Select all → Edit tags" on 50 k tracks is 2.5 × 10⁹ comparisons and will hang the renderer.
|
||||
|
||||
*Fix:* keep an index-ordered selection, and build a `Map<FilePath, Track>` for the batch lookup.
|
||||
|
||||
### m7 — The queue list stays live at zero width
|
||||
|
||||
`frontend/src/components/queue-panel/queue-panel.ts:214-231` (`:host { width: 0 }` when closed),
|
||||
`:653-681`
|
||||
|
||||
`contain: layout style paint` limits the blast radius, but the `lit-virtualizer` inside still has a
|
||||
real height and `min-width: 300px`, so it renders and measures its visible window on every queue
|
||||
change even with the panel closed — and `updated()` calls `scrollToIndex()` (`:675`) on every
|
||||
current-index change, which is `element(i).scrollIntoView()` on a laid-out but invisible element.
|
||||
|
||||
*Fix:* render `nothing` for the list body when the `open` attribute is absent.
|
||||
|
||||
### m8 — Backend emits scan progress nothing listens to
|
||||
|
||||
`frontend/src/events.ts:34-35`
|
||||
|
||||
`LibraryScanStarted` and `LibraryScanProgress` are declared but have **zero** consumers in
|
||||
`frontend/src/`. During a 50 k-file scan the backend serializes and pushes a progress payload across
|
||||
the IPC for an empty listener set.
|
||||
|
||||
*Fix:* either wire them into a scan indicator or stop emitting them.
|
||||
|
||||
### m9 — Remote artist avatars in Explore load eagerly
|
||||
|
||||
`frontend/src/components/explore-view/explore-view.ts:1461-1465`
|
||||
|
||||
The artist avatar `<img>` has neither `loading="lazy"` nor `decoding="async"`, unlike the album card
|
||||
20 lines below (`:1515-1519`) which has both. Every artist in a search result starts loading
|
||||
immediately.
|
||||
|
||||
---
|
||||
|
||||
## Polish
|
||||
|
||||
### p1 — Dead dependency
|
||||
|
||||
`@lit-labs/signals` is declared in `frontend/package.json` but imported nowhere in `src/` or
|
||||
`index.ts`. Rollup tree-shakes it out of the bundle, so this is install-size only — but it also
|
||||
signals a state-management direction that was never taken, next to five hand-rolled
|
||||
`Set<Subscriber>` stores.
|
||||
|
||||
### p2 — Dead code carried in the bundle
|
||||
|
||||
`frontend/src/components/cover-grid/cover-grid.ts:1908-1962` — `renderSplitGrid()` is documented in
|
||||
its own comment as "Currently unreferenced (the single-grid path is the active rendering mode)",
|
||||
along with `getBeforeEntries`/`getAfterEntries`/`ensureSplitCache` and the `splitMode` branches that
|
||||
feed it. `cover-grid.ts` is 30.6 kB of the bundle.
|
||||
|
||||
### p3 — Store notify batching is inconsistent
|
||||
|
||||
`library-store`, `player-store`, `queue-store`, `job-store` and `download-store` all coalesce with
|
||||
`queueMicrotask` + a `notifyScheduled` flag. `search-store.ts:55-57` and `playlist-store.ts:133-135`
|
||||
do not. Lit batches the resulting `requestUpdate()`s anyway, so the impact is small, but the
|
||||
inconsistency is the kind that hides a real double-notify later.
|
||||
|
||||
### p4 — Empty library reads as "Loading tracks..." forever
|
||||
|
||||
`frontend/src/components/track-list/track-list.ts:1900-1902` branches on `this.tracks.length === 0`
|
||||
rather than a loading flag, so a genuinely empty (or fully filtered-out) library shows a permanent
|
||||
loading message. `libraryCtrl.tracksLoading` already exists for this.
|
||||
|
||||
### p5 — `selectAll()` compares sizes, not membership
|
||||
|
||||
`frontend/src/utils/selection-controller.ts:148` — `if (next.size === this._selectedItems.size) return;`
|
||||
short-circuits on cardinality alone. Same-size-different-membership is hard to reach today, but the
|
||||
guard is wrong as written; comparing against `this.host.getItemCount()` would express the intent.
|
||||
|
||||
### p6 — `ResizeObserver` on hidden views writes localStorage on every navigation
|
||||
|
||||
`frontend/src/components/track-list/track-list.ts:1079-1085` → `onHostResize` (`:1218-1243`) →
|
||||
`normalizeWidths` + `saveColumnWidths` (`:515-534`). `.view-hidden` is
|
||||
`visibility: hidden; height: 0` (`frontend/index.css:162-170`), not `display: none`, so hidden views
|
||||
stay in the layout tree and their `ResizeObserver`s fire on every navigation. Cheap (localStorage
|
||||
only), but it is work done for an invisible element.
|
||||
|
||||
---
|
||||
|
||||
## What is already right
|
||||
|
||||
Worth stating plainly, because it is most of the codebase and the findings above are the exceptions:
|
||||
|
||||
- **`track-list` is a well-built virtualized list.** Memoized filter/sort caches keyed on input
|
||||
identity (`:238-270`), delegated event handlers via `data-index` with zero per-row closures
|
||||
(`:1140-1157`, `:1290-1312`), a stable `renderItem`, an `_itemSize` hint that avoids
|
||||
lit-virtualizer's scroll-error correction (`:222-228`), RAF-throttled scroll persistence
|
||||
(`:1280-1291`), and an inline `<svg>` for the per-row favourite icon instead of a `<wa-icon>` that
|
||||
would fetch. All 50 k rows go through this path.
|
||||
- **`cover-grid` memoizes correctly** — `buildGridEntries()` is keyed on the filtered-albums array
|
||||
identity (`:906-926`), so the virtualizer's `items` reference is stable across re-renders, and its
|
||||
covers pick the right thumbnail tier with `loading="lazy" decoding="async"` and explicit
|
||||
`width`/`height` (`:1803-1814`).
|
||||
- **`queue-store` is delta-driven**, not snapshot-driven (`queue-store.ts:82-110`) — index, mode and
|
||||
track-list mutations each ride their own event.
|
||||
- **`job-store` is the model for a push store**: microtask-coalesced notify with a documented
|
||||
rationale, and it evicts cached logs for jobs the backend has forgotten
|
||||
(`job-store.ts:229-236, 263-276`).
|
||||
- **`favorites-store` is Set-keyed**, so `isFavorited` in a row render is O(1) (`:99-101`).
|
||||
- **`LibraryController`'s `changeGeneration` guard** correctly suppresses `requestUpdate()` when only
|
||||
a loading flag toggled (`library-controller.ts:33-47`) — exactly the granularity most of the other
|
||||
controllers lack.
|
||||
- **`genre-details` and `artist-details` reuse `track-list` / `cover-grid`** via `.externalTracks` /
|
||||
`.externalAlbums` instead of reimplementing a list — which is precisely the fix M5 asks for.
|
||||
- **Detail views are ephemeral** (`index.ts:143-147`), so their `disconnectedCallback` cleanup does
|
||||
run and their per-instance caches (e.g. `explore-artist-details`' three `Map`s) are collectable.
|
||||
The leaks in M7/M8/m3 are all on the *cached* primary views.
|
||||
|
||||
## Things I checked and found no problem with
|
||||
|
||||
Recorded so they are not re-audited:
|
||||
|
||||
- **`localeCompare` in sort comparators** (`track-list/columns.ts:15`,
|
||||
`cover-grid/cover-grid-types.ts:50-76`). Benchmarked 50 k-element sorts: bare `localeCompare`
|
||||
**16.5 ms** vs a hoisted `Intl.Collator.compare` **28.7 ms**. V8 already caches the default
|
||||
collator; hoisting one would be a pessimization. No finding.
|
||||
- **Repeated `addEventListener('visibilityChanged', this.onVisibilityChanged)` in
|
||||
`track-list.loadTracks()`** (`:1249-1254`). The handler is a stable class-field arrow, so repeat
|
||||
registration with the same type+function is a spec-level no-op. Not a leak.
|
||||
- **WebAwesome's autoloader `MutationObserver`.** `startLoader()` is exported from
|
||||
`webawesome.js` but never called by the app, so no global mutation observer is installed. (The
|
||||
icon CDN issue in M9 is a separate mechanism.)
|
||||
- **`layout shift` from row cover art.** Every list container has a fixed pixel box
|
||||
(`playlist-details.ts:984-998`, `columns.ts:61`, `cover-grid.ts:1809-1810`), so images do not
|
||||
reflow their rows.
|
||||
- **`job-store` / `download-store` growth.** Both bound their state to the backend snapshot and
|
||||
evict.
|
||||
@@ -0,0 +1,379 @@
|
||||
# 001 — Ship a prebuilt "core" explore index
|
||||
|
||||
**Status:** complete
|
||||
**Branch:** cleanup/fresh-start-schema
|
||||
**Created:** 2026-07-25
|
||||
**Completed:** 2026-07-30
|
||||
|
||||
## Outcome
|
||||
|
||||
A fresh install downloads a 70.6 MB artifact and merges 1,076,133 rows
|
||||
in ~43 s, instead of streaming 205 GB over ~27 h. The dump importer that
|
||||
produces the artifact left the app binary entirely — it is behind the
|
||||
`indexbuild` build tag and runs only in CI.
|
||||
|
||||
Phase 5 landed differently than planned: rather than a user-facing
|
||||
setting gating the deep import, the deep import is simply not in the
|
||||
app. `deep_catalog_enabled` existed briefly and was removed with it.
|
||||
|
||||
Two things remain unverified or undone, both recorded in
|
||||
`.planning/NOTES.md`: anonymous package download on git.ljones.me has
|
||||
not been confirmed against a real published artifact, and installs whose
|
||||
index was built by older code (no `listens_applied_series`) have no
|
||||
rescue path — though with no migration chain, those databases are now
|
||||
unsupported anyway.
|
||||
|
||||
## Problem
|
||||
|
||||
A fresh install has no explore index. `StartIndexBuild()` is called
|
||||
unconditionally from two places in `app.go`, and `runDumpBuild` then
|
||||
downloads gigabytes from `data.metabrainz.org` before Explore can return
|
||||
anything beyond the user's own library:
|
||||
|
||||
| Stage | Source | Cost |
|
||||
|---|---|---|
|
||||
| Listen Counts | ListenBrainz spark full listens dump | **~205 GB streamed** — see below |
|
||||
| Catalog Import | MusicBrainz canonical dump (~2 GB `.tar.zst`) | scan ~30M CSV rows, assemble to budget |
|
||||
| Metadata Patch | MB/LB API | rate-limited at 3 req/s |
|
||||
| Listener Counts | LB API | rate-limited |
|
||||
|
||||
Measured 2026-07-25 against the live dump
|
||||
(`listenbrainz-spark-dump-2593-20260712-000004-full.tar`):
|
||||
|
||||
```
|
||||
content-length: 205073162240 # 205 GB
|
||||
accept-ranges: bytes
|
||||
```
|
||||
|
||||
The stage-1 reader skips non-`.parquet` tar members
|
||||
(`dumpcounts.go:317`), but a tar stream has no seek — skipped bytes
|
||||
still transit the wire. **So a first run on a fresh install pulls
|
||||
~205 GB.** Little of it touches disk (the counts map and checkpoint do,
|
||||
not the dump), but the bandwidth is real and it is per-user.
|
||||
|
||||
Consequences today:
|
||||
|
||||
- Every install pulls ~205 GB to derive a catalog that is **identical
|
||||
for everyone**. On a metered or slow connection this is untenable, and
|
||||
it is unconditional on first run.
|
||||
- **It refuses to start without 6 GB free** (`dumpMinStartFreeBytes`),
|
||||
and aborts below 2 GB (`dumpAbortFreeBytes`). This is what breaks
|
||||
`make fresh-install` on a tmpfs `/tmp`.
|
||||
- First-run Explore is empty for the length of the import.
|
||||
|
||||
The catalog half is **the same for everyone**. Only the local half
|
||||
(`PopulateLocalCrossReferences`, `BackfillLibraryDiscographies`) is
|
||||
per-user. Deriving the shared half on each machine is the waste this
|
||||
plan removes.
|
||||
|
||||
## Goal
|
||||
|
||||
Ship a prebuilt core index so a fresh install has a usable Explore
|
||||
immediately, and the runtime build collapses to the local half plus
|
||||
incremental refresh. The full dump import becomes an opt-in "deep
|
||||
catalog" upgrade rather than a prerequisite.
|
||||
|
||||
## Sizing evidence
|
||||
|
||||
Measured 2026-07-25 with a synthetic harness against the real schema and
|
||||
migrations (2.15M-row full run exceeded a 15-minute budget, so this is a
|
||||
200K-row calibration, `VACUUM`ed):
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| 200,000 rows, with FTS | 85.2 MB |
|
||||
| Cost per row | ~426 B |
|
||||
| zstd -19 | 29.6 MB (2.9x) |
|
||||
|
||||
Extrapolating to the current budgets (`keepRecordings` 1.5M +
|
||||
`keepReleaseGroup` 400K + `keepArtists` 250K = 2.15M rows):
|
||||
|
||||
| Tier | Rows | On disk | zstd -19 |
|
||||
|---|---|---|---|
|
||||
| Full budget | 2.15M | **~900 MB** | ~310 MB |
|
||||
| Core (proposed) | 500K | ~210 MB | **~72 MB** |
|
||||
| Minimal | 250K | ~105 MB | ~36 MB |
|
||||
|
||||
**This corrects an earlier figure.** A ~93 MB index was recorded in the
|
||||
2026-07-16 audit note; that measured the *legacy tier-crawl* index, not
|
||||
the dump-built one. The dump build targets an order of magnitude more
|
||||
rows. Shipping the full index is not viable as a casual download —
|
||||
which is exactly why this plan is scoped to a *core* subset.
|
||||
|
||||
⚠️ Two caveats on these numbers:
|
||||
|
||||
- The harness used a 14-word vocabulary, so its FTS measured only 7% of
|
||||
total size. Real titles have a far larger vocabulary and the real FTS
|
||||
share will be materially higher. **Treat the totals as a floor.**
|
||||
- Row width was estimated from the schema (3 UUIDs at 36 chars dominate);
|
||||
`aliases` was left empty and is populated for real artists.
|
||||
|
||||
Re-measure against a genuine dump-built index before committing to a
|
||||
tier size.
|
||||
|
||||
## What "core" should mean
|
||||
|
||||
`dumpcatalog.go` already has graded per-artist coverage (S2) —
|
||||
`perArtistArtistBudget = 10_000` split into tiers A/B/C with per-tier
|
||||
track and release-group caps. The core index should reuse that machinery
|
||||
rather than invent a second notion of importance:
|
||||
|
||||
- **Artists:** top ~50K by listen count.
|
||||
- **Release groups + recordings:** the S2 per-artist slice for those
|
||||
artists (tier A/B/C caps as they stand).
|
||||
- **Excluded:** the global long tail below the per-artist selection.
|
||||
|
||||
Anything not covered still works — it just resolves through the existing
|
||||
lazy paths (`EnsureArtistDiscography`, `AddFromCache`), which is the
|
||||
behaviour non-covered artists already get today.
|
||||
|
||||
## Distribution: download on first run, not `go:embed`
|
||||
|
||||
**Both packaging paths build from source** — the Homebrew formula builds
|
||||
from a release tarball, the Arch `PKGBUILD` clones the tag. So:
|
||||
|
||||
- Committing the artifact to git bloats the repo and every source tarball.
|
||||
- `go:embed` makes a from-source build require the artifact at build
|
||||
time, so source builds would have to download it anyway — and
|
||||
`build-prod` runs UPX over the binary, which would be pathological
|
||||
with a 70 MB+ embedded blob.
|
||||
|
||||
So "ship with the app" should mean **fetch a prebuilt artifact on first
|
||||
run** from a versioned URL. CI already publishes binary packages to the
|
||||
Gitea package registry (`.gitea/workflows/arch-package.yml`), so there is
|
||||
an existing place to host it.
|
||||
|
||||
Import path: download `.zst` → decompress → `ATTACH` → `INSERT INTO
|
||||
explore_index SELECT ...` through the **existing** `upsertBatch` conflict
|
||||
rules, which already do the right thing (non-empty wins, highest
|
||||
popularity wins, never clobber a good value with an empty one).
|
||||
|
||||
## Artifact contents
|
||||
|
||||
Ship the global catalog columns only. These are **per-user** and must be
|
||||
zeroed in the artifact, then recomputed locally by
|
||||
`PopulateLocalCrossReferences`:
|
||||
|
||||
- `in_library`, `is_similar`
|
||||
- `local_artist_id`, `local_release_group_id`, `local_recording_id`
|
||||
|
||||
`discog_fetched` should ship as `1` for artists whose S2 slice is
|
||||
included, so the backfill doesn't redundantly re-fetch them.
|
||||
|
||||
Also decide per-table whether to include: `similar_artist_map`,
|
||||
`artist_metadata`, `release_to_rg`. `release_to_rg` in particular may
|
||||
rival the index in size — measure before including.
|
||||
|
||||
**Resolved: the artifact ships no FTS.** Rows are inserted into the
|
||||
client's own `explore_index`, whose `AFTER INSERT` trigger populates
|
||||
`explore_index_fts` as a side effect — so shipping a search index would
|
||||
be pure redundant weight. `cmd/indexexport` builds the artifact without
|
||||
FTS or triggers accordingly.
|
||||
|
||||
## Update strategy
|
||||
|
||||
- **Popularity drift** — `dumpincremental.go` already implements
|
||||
incremental listens-dump refresh (`RefreshListenCounts`, weekly
|
||||
cadence). It applies unchanged on top of a shipped baseline, provided
|
||||
`listens_applied_series` is stamped in the artifact so deltas resume
|
||||
from the right point.
|
||||
- **Catalog additions** — new releases arrive via the existing lazy
|
||||
per-artist fetches. A refreshed artifact per app release is enough;
|
||||
no separate cadence needed.
|
||||
- **Schema changes** — `schema_version` exists on `explore_index` but is
|
||||
noted as dead in the audit. Either wire it up or version the artifact
|
||||
filename against the migration number, so an old artifact can't be
|
||||
imported into a newer schema.
|
||||
|
||||
## Build pipeline: build and cache in Gitea CI
|
||||
|
||||
The import is unusually well suited to running as a **series of
|
||||
time-boxed CI jobs against a persistent cache**, because the resumability
|
||||
already exists:
|
||||
|
||||
- Stage 1 streams over a `resumableReader` that reconnects with HTTP
|
||||
`Range` requests, and the live dump advertises `accept-ranges: bytes`.
|
||||
- `counts.bin` checkpoints `Offset` (absolute byte position) and
|
||||
`MemberIdx`, and the applier merges results **in member order** so
|
||||
"every checkpoint is a contiguous prefix of the stream"
|
||||
(`dumpcounts.go`).
|
||||
- Stage 2's canonical scan is deliberately restartable wholesale — "cheap
|
||||
enough to simply restart after an interruption" (`dumpcatalog.go`).
|
||||
|
||||
So a job that hits a runner time limit resumes at its exact byte offset
|
||||
on the next run. **No single multi-hour job is required** — schedule
|
||||
N bounded runs and let them converge.
|
||||
|
||||
What it needs:
|
||||
|
||||
1. **A persistent volume for `explore-staging/` + the DB.** `act_runner`
|
||||
uses the Docker backend and job containers are ephemeral, so bind-mount
|
||||
a host path (or a named Docker volume) and point `YJ_HOME` at it.
|
||||
Prefer this over the Actions cache — cache entries are size-capped and
|
||||
awkward at GB scale, and this is a self-hosted runner anyway.
|
||||
2. **A headless entrypoint** — currently the import only runs from the
|
||||
app lifecycle (`StartIndexBuild` via `OnDomReady`). This is a real gap,
|
||||
but a small one: `NewSearchIndex(db, lb, artistImg, logger)` takes no
|
||||
Wails dependency, and the single `runtime.EventsEmit` in
|
||||
`searchindex.go` sits inside `emitStatus`, which already early-returns
|
||||
when `runtimeCtx == nil`. A `cmd/indexbuild` that opens the DB and
|
||||
calls `StartBuild(context.Background())` — never `SetContext` — should
|
||||
work. Verify `scheduleChampionRebuild` in the `StartBuild` defer is
|
||||
also Wails-free.
|
||||
3. **Triggers.** `indexbuild` decides its own mode from index state, so
|
||||
every trigger runs the same command: push to `main` and a weekly cron
|
||||
both land on a cheap refresh (which no-ops when nothing new is
|
||||
published), and the 3-month rebuild fires when the command notices the
|
||||
import has aged out.
|
||||
|
||||
Then export: subset to core, zero the personal columns, stamp
|
||||
`dump_import_done` / `listens_applied_series` / schema version, `VACUUM`,
|
||||
`zstd -19`, checksum, publish to the Gitea package registry (the Arch
|
||||
workflow already authenticates against it with `PACKAGE_TOKEN`).
|
||||
|
||||
**Be a good citizen about the 205 GB.** Rebuild on the dump cadence
|
||||
(the audit notes a 90-day re-import cadence), never per-commit. Once a
|
||||
baseline exists, the ~180 MB daily incremental dumps already wired in
|
||||
`dumpincremental.go` keep popularity fresh — so the 205 GB is genuinely
|
||||
one-time per rebuild, not per refresh. Also check the runner's own
|
||||
egress if it is self-hosted on a home connection.
|
||||
|
||||
## Licensing
|
||||
|
||||
- MusicBrainz canonical dump is **CC0** — redistribution fine.
|
||||
- ListenBrainz-derived listen counts need their dump licence checked
|
||||
before redistribution, plus attribution in-app either way.
|
||||
- Note the derived counts already differ from LB API values (no MLHD+
|
||||
history) — a known, accepted divergence, but worth stating wherever
|
||||
the numbers are surfaced.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Artifact staleness vs app version** — a user on an old release gets
|
||||
an old catalog. Mitigated by incremental refresh + lazy fetches.
|
||||
- **Download failure / offline install** — must degrade to today's
|
||||
behaviour (local library search), not a broken Explore. The failure is
|
||||
now visible in the Jobs panel, which helps.
|
||||
- **Users who want the full catalog** — keep the existing dump import as
|
||||
an explicit opt-in, gated behind a setting. Note that no such setting
|
||||
exists today: `StartIndexBuild()` is unconditional, and Library Only
|
||||
mode is frontend-`localStorage` only with no backend wiring.
|
||||
|
||||
## Phasing
|
||||
|
||||
1. ✅ **Headless entrypoint.** `cmd/indexbuild` — resumable, budgeted
|
||||
(`-budget 3h`), signal-aware, exit 3 = "more work remains". Verified
|
||||
to run without Wails; builds with `CGO_ENABLED=0` and no build tags.
|
||||
2. ✅ **Export tooling.** `cmd/indexexport` — top-N artists plus a
|
||||
per-artist window of their release groups and recordings, personal
|
||||
columns dropped, metadata stamped, vacuumed. Verified against a
|
||||
synthetic index: no personal columns leak, no orphaned rows, caps
|
||||
respected.
|
||||
3. ✅ **One real build.** Superseded by a real dump-built index that
|
||||
already existed on the dev machine (`dump_import_done` 2026-07-17).
|
||||
Measured 2026-07-29 — these replace every extrapolation above:
|
||||
|
||||
| | rows | on disk |
|
||||
|---|---|---|
|
||||
| `explore_index` | 2,052,168 (227,359 artists / 400,675 RGs / 1,424,134 recordings) | 383 MB |
|
||||
| its indexes | | 395 MB |
|
||||
| FTS | | 80 MB |
|
||||
|
||||
187 B/row for the shippable table, 418 B/row all-in — so the ~900 MB
|
||||
full-budget estimate was right. Two real exports:
|
||||
|
||||
| tier | rows | artifact | zstd -19 |
|
||||
|---|---|---|---|
|
||||
| 50K artists (default) | 1,076,133 | 191.5 MB | **70.6 MB** |
|
||||
| 25K artists / 10 RG / 20 rec | 620,973 | 110.6 MB | **37.8 MB** |
|
||||
|
||||
`release_to_rg` was empty in that index — it predates the code that
|
||||
persists it — so its size is still unmeasured.
|
||||
4. ✅ **Import path.** `backend/explore/artifactfetch.go` (download,
|
||||
Range-resume, sha256, zstd) and `artifactimport.go` (validate, ATTACH,
|
||||
batched merge, FTS rebuild, meta stamping). Reported in the Jobs panel
|
||||
under its own two stages. Measured end to end on the real 50K-artist
|
||||
artifact against a disk-backed DB: **1,076,133 rows merged in 43.2s**
|
||||
(24,900 rows/s), yielding a 455 MB `yj.db`, FTS populated and
|
||||
searchable. In-memory the same merge runs in 28.3s.
|
||||
5. ✅ **Gate the dump build.** `deep_catalog_enabled` in
|
||||
`explore_index_meta` (beside `index_build_paused` — it is build state,
|
||||
read at one decision point). Off by default; exposed as
|
||||
`DeepCatalogEnabled` / `SetDeepCatalogEnabled` on the explore Service.
|
||||
An interrupted dump import resumes regardless of the setting, so the
|
||||
gate never discards a checkpoint that already cost hours.
|
||||
|
||||
## Measured 2026-07-29: why the client cannot fix this itself
|
||||
|
||||
`data.metabrainz.org` caps a client at ~2.1 MB/s. One Range stream and
|
||||
four concurrent Range lanes both delivered 32 MB at the same aggregate
|
||||
rate (2,111,195 B/s vs 2,209,000 B/s) while the same machine pulled
|
||||
66.9 MB/s from a CDN. **Parallelism buys nothing** — the four lanes just
|
||||
divide the same cap, and one of them starved to 0.5 MB/s.
|
||||
|
||||
So stage 1 costs, unavoidably:
|
||||
|
||||
| | bytes | wall clock |
|
||||
|---|---|---|
|
||||
| Whole tar (what shipped before column projection) | 205 GB | ~27 h |
|
||||
| Column projection, 3 columns (43.4%) | 89 GB | ~11.8 h |
|
||||
| `recording_mbid` only (24.1%), rolled up via canonical | 49 GB | ~6.5 h |
|
||||
| + 1-in-4 member stride sample | 12 GB | ~1.6 h |
|
||||
|
||||
The last two are CI-side options, not client defaults: recording-only
|
||||
drops listens carrying no recording MBID and re-derives artist totals as
|
||||
a sum over recordings, and sampling trades exact counts for a ranking.
|
||||
Both are only safe because the selection they feed is a top-N cut.
|
||||
|
||||
## Distribution: the "latest" version trick
|
||||
|
||||
The client cannot enumerate package versions — Gitea's package listing
|
||||
API requires a token, while an anonymous file GET does not (a probe of a
|
||||
non-existent artifact returns 404, not 401). So `index-artifact.yml`
|
||||
publishes each artifact twice: under a dated version for history, and
|
||||
under a fixed `latest` version that the client fetches from a
|
||||
predictable URL. Generic packages reject overwriting an existing
|
||||
filename, so `latest` is DELETEd before each rewrite.
|
||||
|
||||
⚠️ **Unverified:** that anonymous package *download* is actually enabled
|
||||
on git.ljones.me. The 404-vs-401 probe is suggestive, not proof — no
|
||||
artifact has been published yet to test against. Confirm before relying
|
||||
on it, and note that every install pulling from a personal Gitea makes
|
||||
its bandwidth and uptime a user-facing dependency.
|
||||
|
||||
## Incremental retention bounds artifact staleness
|
||||
|
||||
The incremental dump directory holds 30 dumps (series 2579–2610 as of
|
||||
2026-07-29) and full dumps land roughly monthly. An artifact older than
|
||||
~30 days therefore cannot be topped up: the dailies bridging the gap are
|
||||
gone. That is a permanent undercount of that window's listens, not
|
||||
corruption — but it pins the republish cadence at monthly.
|
||||
|
||||
## Upgrade path for indexes built by older code
|
||||
|
||||
The dev machine's index has `dump_import_done` set but **no**
|
||||
`listens_applied_series` and an empty `release_to_rg`, because it was
|
||||
built before the code that writes them. That combination is a dead end:
|
||||
`RefreshListenCounts` bails with "no baseline series recorded", and
|
||||
`runDumpBuild` short-circuits on the done marker, so popularity can
|
||||
never update again. Current code writes both, so this affects only
|
||||
pre-existing installs — but the artifact import is the natural place to
|
||||
rescue them, since merging one stamps a fresh baseline series.
|
||||
6. ✅ **CI wiring.** `.gitea/workflows/index-artifact.yml` — push +
|
||||
weekly cron + manual, concurrency-guarded, publishes only when
|
||||
`complete && changed` so identical artifacts don't accumulate.
|
||||
Runner-side prerequisites are in place (cache dir + `valid_volumes`
|
||||
on the VPS runner).
|
||||
|
||||
Step 3 is the gate on everything downstream — and it is worth doing
|
||||
regardless of whether the artifact ever ships, since it is the only way
|
||||
to get real numbers for the index.
|
||||
|
||||
## Related
|
||||
|
||||
- `backend/explore/dumpimport.go` — stage orchestration, disk floors
|
||||
- `backend/explore/dumpcatalog.go` — budgets, S2 per-artist tiers
|
||||
- `backend/explore/dumpincremental.go` — incremental refresh (update path)
|
||||
- `backend/explore/searchindex.go` — `upsertBatch` conflict rules,
|
||||
`PopulateLocalCrossReferences`
|
||||
- Migration 26 in `backend/database/database.go` — `explore_index` schema
|
||||
@@ -0,0 +1,155 @@
|
||||
# 002 — Data lifecycle architecture
|
||||
|
||||
**Status:** completed (first tranche); follow-ups tracked below
|
||||
**Branch:** main
|
||||
**Created:** 2026-07-26
|
||||
|
||||
## Problem
|
||||
|
||||
An audit of asset and row cleanup found five leaks, four of which shared
|
||||
one root cause: **deletion logic was hand-written per call site and lived
|
||||
far from the thing being deleted.** `RemoveLibrary` knew about ten tables
|
||||
because someone enumerated them once; migration 32 added an eleventh and
|
||||
nothing noticed. Files written by `explore` had no cleanup counterpart
|
||||
anywhere. A function that evicted expired cache rows was written and
|
||||
never called.
|
||||
|
||||
Findings, in severity order:
|
||||
|
||||
1. **`RemoveLibrary` was broken for any scanned library.** `tagging_items`
|
||||
holds `FOREIGN KEY(library_id) REFERENCES libraries(id)` with no
|
||||
`ON DELETE` clause and was never cleared, so `DELETE FROM libraries`
|
||||
failed with `FOREIGN KEY constraint failed (787)` and rolled back the
|
||||
whole removal. Every scanned library has `tagging_items` rows (the
|
||||
scan upserts one per album folder), so this fired on essentially every
|
||||
real removal. `RemoveLibrary` had zero test coverage.
|
||||
2. **Artist images were never deleted by anything.** No `os.Remove` in
|
||||
`explore`, no `DELETE FROM artist_images` in the codebase. Unbounded
|
||||
in the number of artists ever browsed in Explore, most of whom are not
|
||||
in the library.
|
||||
3. **Cover art size variants leaked on removal.** Only the base
|
||||
`cover_art.file_path` was unlinked; the `_sm/_md/_lg` files beside it
|
||||
are derived filenames, not rows, so three files per cover survived.
|
||||
4. **`http_cache` was never pruned.** `Cache.Evict()` existed with no
|
||||
callers. Reads filter on `expires_at`, so expired rows were inert but
|
||||
accumulated for the life of the install.
|
||||
5. **Cover-art proxy cache was never pruned.** No eviction, no size cap.
|
||||
|
||||
## Approach
|
||||
|
||||
Rather than patch five holes, classify the data so the *class* of bug
|
||||
becomes hard to write. Everything persisted falls on two axes —
|
||||
regenerability and cost of regeneration — which collapse to four kinds:
|
||||
|
||||
| Kind | Regenerable? | Deletion policy |
|
||||
|---|---|---|
|
||||
| **Owned** — projection of the user's files | Yes, by rescan | Follows the files |
|
||||
| **Authored** — user-created, no other copy | **No** | Explicit user action only |
|
||||
| **Derived** — computed from owned | Yes, cheaply | Free; must never block owned deletion |
|
||||
| **Cache** — network or dump sourced | Yes, expensively | TTL/age eviction, never cascade |
|
||||
|
||||
The classification is not just vocabulary — it produces the right fix for
|
||||
each finding. Finding 1 is derived data acting as a referential parent of
|
||||
owned data, which the taxonomy makes categorically illegal. Finding 2 is
|
||||
cache data that never needed owner-linked cleanup at all; it wants age
|
||||
eviction. Finding 3 is derived data that must be swept against a live set
|
||||
rather than tracked individually.
|
||||
|
||||
A Go interface was considered and rejected: the only polymorphic consumer
|
||||
is the janitor, the substrates have nothing in common (SQL rows, an FTS
|
||||
virtual table, a view, three directories of JPEGs, a 900 MB index), and
|
||||
provenance is a static fact better enforced by package boundaries than by
|
||||
methods an implementation may lie about. A declarative catalog gets the
|
||||
same benefit for a tenth of the cost.
|
||||
|
||||
## What shipped
|
||||
|
||||
**`backend/datamap`** — the catalog. Every table, view, and asset
|
||||
directory declared with its `Kind`, its `Lifetime` (`cascade`, `set-null`,
|
||||
`swept`, `retained`), and a note explaining the classification. Plain data
|
||||
with no service dependencies, so tests can assert it against a live
|
||||
schema. FTS5 shadow tables resolve to their parent.
|
||||
|
||||
Tests that give it teeth (`backend/datamap/datamap_test.go`):
|
||||
|
||||
- `TestCatalogCoversSchema` — every table in `sqlite_master` is claimed by
|
||||
exactly one entry. **A new table fails the build until somebody states
|
||||
what it is and how it dies.**
|
||||
- `TestCatalogHasNoStaleEntries` — the reverse, catching drift.
|
||||
- `TestNoActionForeignKeysAreDeclaredSwept` — a `NO ACTION` foreign key
|
||||
blocks its parent's deletion, so its table must declare `swept`. This is
|
||||
the exact shape of finding 1, now caught at CI time.
|
||||
- `TestLifetimesMatchSchema` — declared cascade/set-null must match what
|
||||
SQLite actually enforces.
|
||||
- `TestAuthoredCascadesAreDeliberate` — authored data is unrecoverable, so
|
||||
a cascade onto it needs an explicit exemption.
|
||||
|
||||
**`backend/maintenance`** — the janitor. A registry of named jobs with
|
||||
per-job minimum intervals, run at startup-idle and on a 6h tick. Policies
|
||||
follow the taxonomy: derived data sweeps against a live set, cache data
|
||||
ages out. Registered in one place (`app.go: startJanitor`) so the full set
|
||||
of janitorial work is a single visible list.
|
||||
|
||||
Jobs: `http-cache-evict` (6h), `covers-sweep` (24h, live set from
|
||||
`cover_art` expanded via `CoverArtFileSet`), `artist-images-sweep` (24h,
|
||||
keeps art for library artists indefinitely, evicts browsed-artist art
|
||||
after 90d), `cover-art-proxy-sweep` (24h, 30d age eviction).
|
||||
|
||||
The covers sweep refuses to act on an empty live set — that means the
|
||||
query failed to see the table, not that every cover is garbage.
|
||||
|
||||
**Leak tests** (`backend/library/leak_test.go`) — driven by the catalog
|
||||
rather than a hardcoded list, so new tables are covered the moment they
|
||||
are catalogued:
|
||||
|
||||
- `TestRemoveLibraryLeavesNoOwnedOrDerivedRows` — removing the only
|
||||
library leaves no owned or derived rows, except those in
|
||||
`staleTolerated` with a written reason.
|
||||
- `TestRemoveLibraryPreservesAuthoredData` — authored data survives.
|
||||
- `TestSweptTablesAreActuallySwept` — a table declaring `swept` that
|
||||
nothing sweeps is caught.
|
||||
|
||||
All three were verified to fail when the finding-1 fix is reverted.
|
||||
|
||||
**Fixes** — `tagging_items` cleared inside the removal transaction
|
||||
(`crud.go` step 17); `CoverArtFileSet` expands originals to variants and
|
||||
the legacy `_thumb` name; `Cache.Evict` logic moved into a registered job.
|
||||
|
||||
**Incidental:** `Library.emit` — `runtime.EventsEmit` calls `log.Fatalf`
|
||||
on a context without a Wails runtime, which killed the test binary and
|
||||
made the whole package untestable. All ten emits in the package now route
|
||||
through a nil-safe helper. This also removes a real crash risk for
|
||||
background workers that outlive their context.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
**`audio_files` is a mixed-kind table.** `play_count`, `last_played`, and
|
||||
`tag_status` are *authored* data living in an *owned* table. Orphan
|
||||
cleanup treats the whole row as regenerable, which is why renaming a file
|
||||
destroys its play count — the row is deleted and re-imported fresh. This
|
||||
is the strongest argument for splitting authored per-track state into its
|
||||
own table keyed by something more stable than a path. Related: an
|
||||
audio-stream content hash (excluding tag blocks, so it survives
|
||||
retagging) would let a rename be recognised as the same file. Deliberately
|
||||
out of scope here; it is a schema change plus a rename-detection pass, not
|
||||
a cleanup fix.
|
||||
|
||||
**Cascade adoption.** Fourteen of nineteen foreign keys are `NO ACTION`.
|
||||
Converting them to `CASCADE` would delete a lot of hand-written orphan
|
||||
sweeps, but SQLite cannot add `ON DELETE` via `ALTER TABLE` — each needs
|
||||
the 12-step table rebuild. Note the ordering constraint: cascades delete
|
||||
rows silently, so any code that collects file paths *before* deleting rows
|
||||
(as `RemoveLibrary` does for cover art) breaks under cascade. Mark-and-
|
||||
sweep must land first; the two compose, cascade plus path-collection does
|
||||
not.
|
||||
|
||||
**Consolidate the ten orphan sweeps.** `DELETE ... WHERE id NOT IN (...)`
|
||||
appears ten times across `crud.go`, `dbsync.go`, `smartplaylist.go`, and
|
||||
`database.go`. One shared `sweepOrphans(tx)` would shrink the surface where
|
||||
a new table can be forgotten. Worth doing opportunistically rather than as
|
||||
a big-bang refactor.
|
||||
|
||||
**Storage settings pane.** The catalog knows every table and directory and
|
||||
its kind; the janitor already computes bytes freed. A settings pane showing
|
||||
per-kind disk usage with "clear cache" and "rebuild derived data" buttons
|
||||
is now mostly a UI job.
|
||||
@@ -0,0 +1,279 @@
|
||||
# 003 — Download clients
|
||||
|
||||
**Status:** implemented (v1); follow-ups tracked below
|
||||
**Branch:** main
|
||||
**Created:** 2026-07-27
|
||||
|
||||
## Problem
|
||||
|
||||
YellowJacket can find music (`explore`), identify it (`autotag`), and
|
||||
manage it (`library`) — but it can't acquire it. The one gap between
|
||||
"you're missing this album" and "you own this album" is filled today by
|
||||
the user alt-tabbing to some other tool.
|
||||
|
||||
The naive fix is an HTTP client for Soulseek and a shell-out to yt-dlp.
|
||||
That produces two bespoke code paths with duplicated queueing, retry,
|
||||
staging and import logic, and a third service means a third copy. The
|
||||
services users want to connect are also not the same *kind* of thing —
|
||||
some search, some transfer bytes, some are entire automation systems we
|
||||
delegate to — so a single `DownloadClient` interface would be a lie that
|
||||
every adapter partially implements.
|
||||
|
||||
## The role decomposition
|
||||
|
||||
Every candidate integration fills one or two of three roles:
|
||||
|
||||
| Service | Searches | Transports | Delegates |
|
||||
|---|---|---|---|
|
||||
| slskd (Soulseek) | ✅ | ✅ | |
|
||||
| yt-dlp | ✅ | ✅ | |
|
||||
| Lidarr | | | ✅ |
|
||||
| Prowlarr | ✅ | | |
|
||||
| qBittorrent / Transmission | | ✅ | |
|
||||
| SABnzbd / NZBGet | | ✅ | |
|
||||
|
||||
So: three small interfaces, not one big one. A provider implements
|
||||
whichever it supports and declares that in a capability struct, the same
|
||||
way `jobs.Caps` lets the frontend render controls without switching on
|
||||
`Kind`.
|
||||
|
||||
```go
|
||||
// Searcher turns a request into ranked candidates.
|
||||
type Searcher interface {
|
||||
Search(ctx context.Context, req Request) ([]Candidate, error)
|
||||
}
|
||||
|
||||
// Transporter moves a candidate's bytes to a local staging directory.
|
||||
type Transporter interface {
|
||||
Grab(ctx context.Context, c Candidate, dst string, p ProgressFunc) (Result, error)
|
||||
Cancel(ctx context.Context, grabID string) error
|
||||
}
|
||||
|
||||
// Delegator hands the whole request to an external manager and
|
||||
// reports back when files land.
|
||||
type Delegator interface {
|
||||
Request(ctx context.Context, req Request) (string, error)
|
||||
Poll(ctx context.Context, externalID string) (DelegateStatus, error)
|
||||
}
|
||||
```
|
||||
|
||||
A `Provider` is the registry entry: identity, config, health check, caps,
|
||||
plus whichever of the three it satisfies. Search-only providers
|
||||
(Prowlarr) are paired with a transport at grab time by protocol match
|
||||
(`torrent` → qBittorrent, `usenet` → SABnzbd); providers that do both
|
||||
are self-pairing.
|
||||
|
||||
## v1 decisions (settled)
|
||||
|
||||
- **On-demand only.** User-initiated "find this album" from an Explore
|
||||
artist/album page or a missing-album row. No wanted list, no artist
|
||||
monitoring, no quality-cutoff upgrades. The queue and pipeline built
|
||||
here are exactly what monitoring would later sit on top of — see
|
||||
Deferred.
|
||||
- **Soulseek via slskd's REST API**, not a native protocol client. Same
|
||||
adapter shape as everything else, no wire protocol, no credentials in
|
||||
our process, fully testable against an `httptest` server. A native
|
||||
provider can slot in behind `Searcher`/`Transporter` later with no
|
||||
pipeline changes.
|
||||
- **Stage → autotag → import.** Downloads land in a staging directory,
|
||||
are matched against the intended release with the existing `autotag`
|
||||
scorer, tagged, then moved into the library and scanned. Never write
|
||||
into the library root directly.
|
||||
- **All four provider families in v1**, sequenced so each phase proves a
|
||||
different role shape (see Phases).
|
||||
|
||||
## Pipeline
|
||||
|
||||
```
|
||||
Request (MBID-anchored where possible)
|
||||
└─> fan-out Search across enabled providers (per-provider timeout)
|
||||
└─> merge + rank Candidates
|
||||
└─> user picks (or auto-pick above confidence threshold)
|
||||
└─> Grab into staging/<request-id>/
|
||||
└─> verify (audio decodes, expected track count)
|
||||
└─> autotag against the intended release
|
||||
└─> tagwriter writes tags
|
||||
└─> move into library layout
|
||||
└─> targeted incremental scan
|
||||
```
|
||||
|
||||
The `Request` should carry a release-group or release MBID whenever the
|
||||
user started from an Explore page, because that anchor is what makes the
|
||||
autotag step reliable instead of a second guess. Free-text requests are
|
||||
supported but flagged lower-confidence, and never auto-pick.
|
||||
|
||||
Staging lives under the user data dir, not the library. Partial grabs are
|
||||
resumable where the provider supports it and swept on startup where it
|
||||
doesn't.
|
||||
|
||||
## Candidate ranking
|
||||
|
||||
Two independent scores, kept separate:
|
||||
|
||||
1. **Match confidence** — does this candidate contain the release the
|
||||
user asked for? Reuse `autotag`'s distance/alignment machinery on the
|
||||
candidate's *filenames* (Soulseek gives paths, not tags), against the
|
||||
expected tracklist from the explore index.
|
||||
2. **Source quality** — format (FLAC > V0 > 320 > lower), bitrate,
|
||||
completeness (file count vs. expected track count), source health
|
||||
(slskd queue length and upload slots; seeders for torrents), and a
|
||||
user-set per-provider priority.
|
||||
|
||||
Ranking presents both, because they trade off — a perfectly-matched
|
||||
128kbps rip should lose to a well-matched FLAC, and the user should be
|
||||
able to see why. Reusing `autotag.ScoreBreakdown`'s "explain the ranking"
|
||||
pattern here is deliberate.
|
||||
|
||||
## Persistence
|
||||
|
||||
New tables (migration TBD, next free number):
|
||||
|
||||
- `download_providers` — id, kind, name, enabled, priority, config blob
|
||||
(JSON), `created_at`. Non-secret config only.
|
||||
- `download_requests` — id, source (`explore-album`, `explore-artist`,
|
||||
`manual`), release_mbid / release_group_mbid, free-text query,
|
||||
requested_at, state, resolved_download_id.
|
||||
- `download_items` — one row per grab attempt: request_id, provider_id,
|
||||
candidate JSON, state, bytes/total, staging path, error, timestamps.
|
||||
|
||||
**Secrets** (slskd API key, Lidarr/Prowlarr API keys, qBittorrent
|
||||
password) do not go in the TOML config or the DB in plaintext. Use the OS
|
||||
keyring where available with a clearly-labelled encrypted-file fallback,
|
||||
and never log a config value from a provider's secret field. Open
|
||||
question below on the exact library.
|
||||
|
||||
## Jobs integration
|
||||
|
||||
Add `jobs.KindDownload`. One job per request (not per file), with
|
||||
`Stages` for search → grab → import so the existing detail panel renders
|
||||
the pipeline for free. `Caps{Cancellable: true}`; pausable only for
|
||||
providers that can resume. Per-provider concurrency caps and a global
|
||||
cap, both configurable — hammering a Soulseek peer with eight parallel
|
||||
transfers gets you queued or banned.
|
||||
|
||||
## Frontend
|
||||
|
||||
- New `download-providers` section in `config-page` (HTMX + templ, same
|
||||
as existing settings) for provider CRUD, test-connection, priority.
|
||||
- New `download-picker` Lit component: the ranked-candidate dialog,
|
||||
invoked from Explore album/artist pages and from a missing-album row.
|
||||
- `download-store.ts` subscribing to the existing `JobsChanged` event —
|
||||
no new event channel needed for progress.
|
||||
|
||||
## Phases
|
||||
|
||||
Each phase is independently shippable and proves a distinct role shape.
|
||||
|
||||
1. **Core.** Interfaces, registry, `Request`/`Candidate`/`Result` types,
|
||||
staging dir, ranking, the stage→autotag→import tail, jobs wiring,
|
||||
schema, secret storage. Ships with a fake provider and full test
|
||||
coverage of the pipeline. No real network.
|
||||
2. **yt-dlp.** Subprocess provider: search + transport, no server for the
|
||||
user to run, so it's the fastest path to an end-to-end working
|
||||
feature. Proves the local-subprocess shape (binary discovery,
|
||||
version checks, stdout progress parsing, sandboxing the arg list).
|
||||
3. **slskd.** Remote search + transport over REST. Proves the remote
|
||||
HTTP shape and is the highest-value source. This is where filename-
|
||||
based match confidence earns its keep.
|
||||
4. **Lidarr.** Delegate. Proves the fire-and-poll shape, where we don't
|
||||
own the transfer and the "import" step is really "detect what Lidarr
|
||||
already imported and reconcile".
|
||||
5. **Prowlarr + qBittorrent/SABnzbd.** Proves split search/transport
|
||||
pairing — the one case where two providers cooperate on a single
|
||||
request.
|
||||
|
||||
## Risks and constraints
|
||||
|
||||
- **No bundled credentials, no default-on providers, no preconfigured
|
||||
indexers.** Every provider is off until the user configures it. The
|
||||
app ships the ability to connect to services the user already runs.
|
||||
- **yt-dlp is a moving target.** Pin a minimum version, check it at
|
||||
provider-enable time, and fail with a clear message rather than
|
||||
parsing garbage output.
|
||||
- **Filename-only matching is genuinely hard.** Soulseek results are
|
||||
`\Music\Album (1997) [FLAC]\01 - Track.flac` at best. Budget real
|
||||
effort for the path-parsing heuristics; `autotag/normalize.go` is the
|
||||
starting point.
|
||||
- **Partial and failed grabs must never reach the library.** The import
|
||||
step is the only writer into library paths, and it runs after
|
||||
verification. Staging sweep on startup.
|
||||
- **Tests must not hit the network.** `httptest` servers for slskd/
|
||||
Lidarr/Prowlarr, a stub binary for yt-dlp.
|
||||
|
||||
## Deferred
|
||||
|
||||
- Wanted list with background retry (the natural next plan).
|
||||
- Artist monitoring + auto-grab of new releases — cheap once the wanted
|
||||
list exists, because `explore`'s dump index already knows the full
|
||||
discography and `library` already knows what's owned.
|
||||
- Quality profiles and upgrade-if-better.
|
||||
- Native Soulseek protocol client.
|
||||
- Transmission/Deluge/NZBGet (same shape as their shipped siblings —
|
||||
add on demand).
|
||||
- Internet Archive / Bandcamp-collection providers: cheap REST adapters,
|
||||
worth adding once the core is proven.
|
||||
|
||||
## Resolved questions
|
||||
|
||||
1. **Secret storage.** No keyring dependency was added. Credentials go
|
||||
in a 0600 JSON file in the user data directory (`download-secrets.json`),
|
||||
keyed by provider row ID. This is deliberately *not* encryption — a
|
||||
key stored beside the data it unlocks protects nothing, and claiming
|
||||
otherwise would be worse than being clear about it. What the file
|
||||
mode buys is protection from other local users and from the config
|
||||
file being pasted into a bug report. `SecretStore` is an interface so
|
||||
an OS keyring backend can be added later without touching any
|
||||
provider.
|
||||
2. **Auto-pick.** Implemented behind `Downloads.AutoPick`, default off.
|
||||
It requires an MBID-anchored request, match ≥ 0.85, quality ≥ 0.5,
|
||||
and ≥ 0.08 of daylight over second place. Free-text requests can
|
||||
never auto-pick, because there is no tracklist to be right about.
|
||||
3. **Library layout.** Configurable path template, default
|
||||
`{albumartist}/{album}/{track} {title}`. Segments are sanitized for
|
||||
Windows-reserved characters and trailing dots/spaces so a library
|
||||
synced between platforms does not produce unopenable files. Existing
|
||||
files are never overwritten — a collision gets a numbered variant,
|
||||
because the file already there may be a better copy the user owns.
|
||||
4. **Entry point.** "Find this album" on the Explore album page, shown
|
||||
only when a client is connected and the album is not already owned.
|
||||
The artist-discography right-click is not wired up yet.
|
||||
|
||||
## What shipped
|
||||
|
||||
All five phases, ~4,500 lines with tests, `make lint` clean and the full
|
||||
backend suite green (including under `-race`).
|
||||
|
||||
**Core** (`backend/download/`): `Searcher`/`Transporter`/`Delegator`
|
||||
interfaces with capability-driven composition; `Request`/`Candidate`/
|
||||
`Result` types; provider registry with self-registering adapters;
|
||||
two-axis ranking; staging area with escape-guards and startup sweep;
|
||||
verify → tag → import tail; jobs integration under `KindDownload`;
|
||||
three tables catalogued in `datamap`.
|
||||
|
||||
**Providers**: yt-dlp (subprocess; assembles albums from per-track
|
||||
searches, since a "full album" video cannot be imported as tracks),
|
||||
slskd (remote search + transport, peer-health scoring, collects from the
|
||||
daemon's own downloads folder), Lidarr (delegate; reconciles in place
|
||||
rather than moving files out from under a system still managing them),
|
||||
Prowlarr (search-only) paired at grab time with qBittorrent or SABnzbd.
|
||||
|
||||
**Frontend**: `download-store.ts`, `download-picker` + `candidate-row`
|
||||
(two meters, not one blended score), `download-clients` settings section
|
||||
rendering its forms from backend descriptors so a new adapter needs no
|
||||
frontend change.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- **Resume across restart.** Live transfers are currently marked failed
|
||||
on startup and their staging swept, because the transports do not
|
||||
survive the process. slskd and qBittorrent can both resume in
|
||||
principle; the item rows already carry what would be needed.
|
||||
- ~~**Per-provider concurrency caps.**~~ Done in 004: per-kind defaults
|
||||
(slskd 1, yt-dlp 2, torrent/usenet 4) with a per-provider override,
|
||||
and the provider's slot is taken before the global one.
|
||||
- **Prowlarr candidates score blind.** Indexer results carry no file
|
||||
list, so match scoring has only the release title. Fetching the
|
||||
torrent metadata before ranking would fix this and is the single
|
||||
biggest ranking improvement available.
|
||||
- ~~Wanted list, artist monitoring~~ — done in 004. Quality profiles
|
||||
and upgrade-if-better remain deferred.
|
||||
@@ -0,0 +1,163 @@
|
||||
# 004 — Wanted list
|
||||
|
||||
**Status:** implemented
|
||||
**Branch:** main
|
||||
**Created:** 2026-07-29
|
||||
**Follows:** 003-download-clients
|
||||
|
||||
## Problem
|
||||
|
||||
Plan 003 shipped a request as a heavyweight row: library, anchors,
|
||||
cached tracklist, state machine, error text, cascading items. That is
|
||||
the right shape for *one attempt to acquire something* and the wrong
|
||||
shape for *the user wanting something*, and 003 used it for both.
|
||||
|
||||
The consequences showed up immediately. A request that found nothing was
|
||||
marked `failed`, which is a lie — the album exists, no source had it
|
||||
today. Retrying meant the user remembering to press a button. Wanting an
|
||||
artist's future releases was not expressible at all. And a user who
|
||||
acquired an album by other means kept a failed row about it forever.
|
||||
|
||||
## The model
|
||||
|
||||
A **want** is an MBID, what that MBID names, and retry bookkeeping.
|
||||
That is all.
|
||||
|
||||
```
|
||||
download_wants(mbid, entity, library_id, scope, secondary, state,
|
||||
parent_id, attempts, last_error, next_try_at,
|
||||
external_ids)
|
||||
```
|
||||
|
||||
`entity` is the only type distinction, and it carries all the policy:
|
||||
|
||||
| entity | meaning |
|
||||
|---|---|
|
||||
| `artist` | a subscription. Never satisfied; each pass expands the discography into child wants |
|
||||
| `release-group` | an album in the abstract — any release satisfies it |
|
||||
| `release` | one specific edition |
|
||||
| `recording` | one track |
|
||||
|
||||
`UNIQUE(mbid, library_id)` is load-bearing: it is what makes artist
|
||||
expansion idempotent, so a subscription can re-run every pass and add
|
||||
only what is genuinely new.
|
||||
|
||||
Requests did not go away — they became what they always were, the
|
||||
ephemeral record of one attempt, with a nullable `want_id` back-link.
|
||||
The lifetimes are now opposite and explicit: **a request is history, a
|
||||
want is intent.**
|
||||
|
||||
### Nothing here fails
|
||||
|
||||
There is no `failed` want state. An attempt can fail; a want cannot. A
|
||||
want that found nothing gets `attempts + 1`, a reason the user can read,
|
||||
and a longer backoff — 6h doubling to a 7-day ceiling, jittered so a
|
||||
list added in one sitting does not come due in one burst.
|
||||
|
||||
### Satisfaction is ownership, not download
|
||||
|
||||
A want retires when the *library* owns what it names, however it got
|
||||
there — bought, ripped, copied in. Inferring satisfaction from our own
|
||||
completed downloads would keep hunting for music already on disk.
|
||||
|
||||
### Artist scope defaults to `future`
|
||||
|
||||
Following an artist takes new releases only, and skips compilations,
|
||||
live albums and remixes. `all` backfills the discography, and the user
|
||||
can widen it from the wanted list. Subscribing should not silently queue
|
||||
forty albums.
|
||||
|
||||
## The reconciler
|
||||
|
||||
A 6-hourly loop (plus on-demand, plus a 3-minute startup delay so the
|
||||
explore index has loaded). Four steps, in this order:
|
||||
|
||||
1. **Expand** artist subscriptions into album wants — first, so step 2
|
||||
sees them this pass rather than next.
|
||||
2. **Retire** wants the library already owns.
|
||||
3. **Sync** to clients that keep their own list.
|
||||
4. **Attempt** a bounded batch (25) of due wants.
|
||||
|
||||
Everything the loop needs about music comes through a four-method
|
||||
`CatalogPort`, adapted to the explore index in `backend/downloadcatalog.go`
|
||||
— the composition root, so neither package learns about the other.
|
||||
|
||||
### Unattended grabs, and what stops them
|
||||
|
||||
`Manager.Attempt` is `Start` without the parking: it searches, and grabs
|
||||
only if `AutoPickable` clears. When it does not, **nothing is
|
||||
persisted** — no request row. A want retried weekly for a year would
|
||||
otherwise leave fifty identical failed rows, none of them anything the
|
||||
user can act on.
|
||||
|
||||
`AutoPickable` gained one condition: an anchored request with an empty
|
||||
`Expected` is refused. An anchor with no tracklist behind it is an
|
||||
anchor in name only, and match then rests on album/artist text — exactly
|
||||
the evidence a wrong-album candidate also has. Nobody is watching a
|
||||
reconcile pass.
|
||||
|
||||
## Per-provider concurrency
|
||||
|
||||
`Downloads.MaxConcurrent` was the only limit, and was never actually
|
||||
applied (`SetMaxConcurrent` did not exist). Now:
|
||||
|
||||
- **slskd defaults to 1.** A Soulseek peer serves one file at a time
|
||||
from one person's upload slot; asking for more gets you queued behind
|
||||
everyone else at best. One is both the polite number and usually the
|
||||
fastest.
|
||||
- yt-dlp 2, torrent/usenet clients 4, overridable per provider via a
|
||||
`maxConcurrent` field that `Register` appends automatically to any
|
||||
descriptor declaring `CanTransport`.
|
||||
- A grab takes its **provider's** slot before the global one, so a queue
|
||||
on a busy slskd cannot sit on a global slot a usenet transfer could
|
||||
have used. The transport is resolved before either slot is taken;
|
||||
delegates take neither, since the transfer is happening inside another
|
||||
system that is doing its own limiting.
|
||||
|
||||
## The Lister role
|
||||
|
||||
The fourth role, alongside Searcher/Transporter/Delegator. Lidarr
|
||||
already models a want — a monitored artist or album — and it is always
|
||||
on, where a desktop player is not. A subscription mirrored there keeps
|
||||
working while the app is closed.
|
||||
|
||||
- `artist` → Lidarr artist, `monitor: future|missing` per scope
|
||||
- `release-group`/`release` → monitored album
|
||||
- `recording` → not pushed. Lidarr cannot say "one track", and
|
||||
monitoring the album to get it downloads far more than was asked.
|
||||
|
||||
Sync is push-only in the loop; pulling happens only when the user
|
||||
explicitly imports ("adopt the artists Lidarr already monitors", which
|
||||
arrive at `future` scope). Removal **unmonitors**, never deletes — the
|
||||
user's Lidarr may predate this app.
|
||||
|
||||
## Frontend
|
||||
|
||||
- `Wanted` view in the sidebar: Following / Looking for / Paused /
|
||||
Found, with pause, remove, scope toggle and "Check now".
|
||||
- "Want this" on the album page, "Follow for new releases" on the artist
|
||||
page. The want button shows whether or not a client is connected —
|
||||
wanting is durable and stays queued until one exists.
|
||||
- `WantedListChanged` event, since a background pass changes the list
|
||||
without the UI doing anything.
|
||||
|
||||
## Files
|
||||
|
||||
`backend/download/want.go`, `wantstore.go`, `reconcile.go`,
|
||||
`provider_lidarr_list.go`; `backend/downloadcatalog.go`;
|
||||
schema `download_wants.sql` + migration 48 for the two new
|
||||
`download_requests` columns; `frontend/src/components/wanted-view/`.
|
||||
|
||||
## Deferred
|
||||
|
||||
- **Release-group wants are not retired by ownership of a specific
|
||||
release.** The library indexes release groups and recordings, not
|
||||
editions, so a `release` want is only satisfied by its own download
|
||||
completing.
|
||||
- **No recording lookup on the explore index**, so a track want relies
|
||||
on the title the UI passed in. A want added as a bare recording MBID
|
||||
has no tracklist and waits.
|
||||
- Quality profiles and upgrade-if-better (from 003).
|
||||
- Resume across restart (from 003) — still the largest gap, and it now
|
||||
matters more: an unattended grab that dies on restart is retried by
|
||||
the reconciler, but from zero bytes.
|
||||
@@ -0,0 +1,175 @@
|
||||
# 005 — Agent development harness
|
||||
|
||||
**Status:** implemented
|
||||
**Branch:** main
|
||||
**Created:** 2026-08-10
|
||||
**Shipped:** 2026-08-11 (`5ca6cad`, `ccacd67`)
|
||||
**Follows:** 004-wanted-list
|
||||
|
||||
## Problem
|
||||
|
||||
A coding agent could develop this repo's Go packages competently and
|
||||
could not develop the *application* at all. It could read 66k lines of
|
||||
backend, run 31k lines of tests and lint two build configurations. It
|
||||
could not start the app, see a window, click anything, or find out
|
||||
whether a change to a Lit component rendered.
|
||||
|
||||
The gap was not missing tests. Every path to running YellowJacket ended
|
||||
in a blocking GTK window — `make dev`, `make sandbox <n>` and
|
||||
`make fresh-install` all launch a WebKit window and never return the
|
||||
shell. So 265 bound methods across 11 services, 46 backend events, 33
|
||||
component directories, 13 reactive stores and a 357-line keyboard
|
||||
shortcut service had exactly one form of verification available:
|
||||
`tsc --noEmit`.
|
||||
|
||||
Three secondary facts made it worse. `test_data/music_library_test/` was
|
||||
referenced by three test files, gitignored, absent, and had no
|
||||
generator, so the audio path was unreachable from a clean clone. No
|
||||
workflow ran `make test` or `make lint` — gating existed only in
|
||||
`lefthook.yml`, which is local and `--no-verify`-skippable. And there
|
||||
was no `.pi/`, so none of the awkward invocations were wrapped in
|
||||
anything an agent could call.
|
||||
|
||||
## The unlock
|
||||
|
||||
`wails dev` already runs an HTTP + WebSocket dev server on
|
||||
`localhost:34115` (`internal/frontend/devserver/`). It serves the real
|
||||
frontend assets, injects the real generated bindings, and bridges every
|
||||
method call and every event over a websocket to the **same running Go
|
||||
backend** a desktop window attaches to. A plain Chromium pointed at
|
||||
that port gets a fully functional YellowJacket — not a mock, not a stub
|
||||
`wailsjs` layer. This is the sanctioned approach; Wails v3 ships a guide
|
||||
for it and the v2 community reached the same answer independently
|
||||
(discussion #4205).
|
||||
|
||||
The one caveat: `devserver.Run` still calls `d.Frontend.Run(ctx)`, which
|
||||
opens the GTK window and blocks, with no flag to suppress it. So the app
|
||||
needs a display — a virtual one.
|
||||
|
||||
## What shipped
|
||||
|
||||
117 files, ~14.6k lines. Four test tiers, cheapest first:
|
||||
|
||||
| Tier | Command | Cost | Needs the app? |
|
||||
|---|---|---|---|
|
||||
| Components and stores | `make ui-test` | ~2 s, 313 tests | no |
|
||||
| Services, in-process | `make test` | 3 passes | no |
|
||||
| Exploration | `make dev-headless` + `playwright-cli` | interactive | yes |
|
||||
| Frozen regressions | `make e2e` | ~20 s, 19 specs × 2 browsers | yes |
|
||||
|
||||
**Fixtures** (`cmd/gentestdata`, `make testdata`, `internal/testfixtures`).
|
||||
31 tracks across MP3/FLAC/OGG/WAV in ~1 s, deterministic, gitignored,
|
||||
covering the cases the app has code for: shared album art (dedup),
|
||||
missing and partial tags, unicode and RTL, multi-disc, various artists,
|
||||
a deliberate duplicate pair. Tests select by *case*
|
||||
(`CaseCoverDedup`, `CaseUnicode`, …) rather than by path, and skip
|
||||
themselves when the library has not been generated.
|
||||
|
||||
**Headless launch** (`scripts/dev-headless.sh`, `dev-stop.sh`,
|
||||
`seed-sandbox.sh`). `dbus-run-session -- xvfb-run -a` around the
|
||||
`dev`-tagged binary, backgrounded, writing `.dev/app.pid` and
|
||||
`.dev/app.log` and returning once `:34115` answers. The dev binary is
|
||||
run directly rather than through `wails dev`: `app_dev.go` parses
|
||||
`-devserver`/`-assetdir` from `os.Args`, so one process with a
|
||||
deterministic startup replaces a file watcher and rebuild supervisor an
|
||||
agent does not want. `dbus-run-session` is not incidental — a private
|
||||
session bus makes MPRIS actually register.
|
||||
|
||||
**Driving and seeing.** `.playwright/init-events.js` records every
|
||||
backend event on `window.__yjEvents` by wrapping
|
||||
`window.wails.EventsNotify`, the single choke point all 46 events pass
|
||||
through, so assertions await an event rather than a timeout. It also
|
||||
provides `ready()` and a `call()` that times out. `backend/testctl`
|
||||
mounts `/__test/` on the existing asset handler — `health`,
|
||||
`db/snapshot`, `db/restore`, `emit`, `sql` — gated twice, behind the
|
||||
`dev` build tag and behind `YJ_TESTCTL=1`. A `data-testid`/aria pass
|
||||
turned out to be mostly an accessibility fix: the five transport
|
||||
buttons had no accessible name at all.
|
||||
|
||||
**Component coverage** (`frontend/test/`, Vitest 4 browser mode).
|
||||
`frontend/wailsjs/` is a pure passthrough to `window.go` /
|
||||
`window.runtime`, so faking just those two globals runs the *real*
|
||||
generated bindings and the *real* store code — no module mocking, and
|
||||
no second description of the Wails layer free to drift.
|
||||
`make bindings-check` regenerates `frontend/wailsjs` in ~1.5 s and
|
||||
fails on a dirty tree, closing the gap where a renamed Go field first
|
||||
appeared at runtime in a window.
|
||||
|
||||
**`events.Emit`** (`backend/events/`). `runtime.getEvents` `log.Fatalf`s
|
||||
on any context lacking wails' internal `"events"` value — any
|
||||
`context.Background()` — so 35 emit sites could not run under test and a
|
||||
background worker could take the app down. All 35 now route through one
|
||||
wrapper that drops at debug level instead. Four packages had each
|
||||
hand-rolled the same guard; nine more guarded on `ctx != nil`, which
|
||||
does not help. The test sink rides in the context
|
||||
(`events.WithSink`), and `TestNoDirectRuntimeEmits` walks the tree —
|
||||
not a lint rule, because lint runs once per build configuration and
|
||||
would miss a stray emit in a tagged-out file.
|
||||
|
||||
**pi affordances** (`.pi/`). `skills/yellowjacket-dev/` is the
|
||||
operational manual; `prompts/e2e.md` promotes a hand-driven session
|
||||
into a spec; `journal.md` is the work log. `make skill-check` fails a
|
||||
commit if the skill cites a make target that does not exist.
|
||||
|
||||
**CI that gates** (`.gitea/workflows/ci.yml`). Two jobs in
|
||||
`ubuntu:24.04`: `check` (lint ×3, test ×3, `tsc --noEmit`, `ui-test`,
|
||||
`bindings-check`, `skill-check`) and `e2e` (Xvfb + private bus +
|
||||
fixtures + seed + `dev-headless` + Playwright on **Chromium and
|
||||
WebKit**). The other three workflows only package, so `gitea_ci`
|
||||
previously reported nothing about whether a push was healthy.
|
||||
|
||||
## Decisions worth keeping
|
||||
|
||||
- **The split between the three docs is grammatical, not topical.**
|
||||
`NOTES.md` past, `CLAUDE.md` present, the skill imperative. A topical
|
||||
split rots because every new fact gets two plausible homes.
|
||||
- **Seeds are produced by running the app**, never by hand-writing
|
||||
`config.toml` and DB rows — the same discipline `sql/schemas/` gets,
|
||||
for the same reason. A hand-built `YJ_HOME` is a second description
|
||||
of a valid one and will drift.
|
||||
- **The Makefile is the source of truth for *how* to invoke something**;
|
||||
the skill only decides *which* and *in what order*, and
|
||||
`make skill-check` enforces it.
|
||||
- **Verify in a fresh clone, not a copy of the working tree.** The CI
|
||||
prototype ran both jobs in one mounted directory and so consumed a
|
||||
`frontend/dist` an earlier job had built — hiding that `main.go`
|
||||
embeds it and every Go typecheck needs it. The question is not
|
||||
clean-vs-dirty but *whose* dirt.
|
||||
- **`make lint`'s tag sets must equal `make test`'s.** Without
|
||||
`webkit2_41` wails resolves `webkit2gtk-4.0`, which Arch ships and
|
||||
Ubuntu 24.04 does not, so lint was checking a configuration that only
|
||||
built on one distro. CI caught this on its first run.
|
||||
- **Playwright's WebKit gates** because it was measured (19/19) rather
|
||||
than assumed, and because nothing in `e2e/` compares pixels — so a
|
||||
failure is an engine difference, not baseline noise. It is the only
|
||||
WebKit2GTK signal obtainable, since it cannot start on Arch at all.
|
||||
|
||||
## Known blind spots
|
||||
|
||||
- **Xvfb is X11**, and `main.go` carries a Wayland-specific NVIDIA
|
||||
DMABuf workaround. CI never exercises that path. Acceptable — it is a
|
||||
crash workaround, not a feature — but it is a blind spot, not a
|
||||
surprise.
|
||||
- **Playwright's WebKit is not WebKit2GTK.** Closer than Chromium,
|
||||
still not the shipped renderer. A GTK-specific rendering bug can
|
||||
escape, and will for any view not in the smoke suite.
|
||||
- **The fixture hash is deterministic per ffmpeg, not across versions**
|
||||
(`5425fbb454a2` on Arch, `599a8dd4f152` on Ubuntu 24.04). Nothing
|
||||
asserts a literal hash; a test that did would be portable by accident.
|
||||
|
||||
## Left open, deliberately
|
||||
|
||||
- **WAV tags are write-only.** `backend/tagwriter` writes them into a
|
||||
RIFF `id3 ` chunk; `backend/metadata` reads through `dhowden/tag`,
|
||||
which has no RIFF parser, so every WAV scans in untitled. Found by
|
||||
the fixtures and pinned by `TestWAVTagsAreNotReadableYet`. The fix is
|
||||
small: unwrap the chunk, hand the payload to `tag.ReadFrom`.
|
||||
- **`themeStore.loadFromBackend`'s failure handler cannot recover** — it
|
||||
re-derives the colour ramp from the state that just failed it. One
|
||||
line; reachable only if the backend returns an empty accent.
|
||||
- **`backend/playlist` has no CRUD suite.** 2,900 lines; phase 5 added
|
||||
four emit-focused tests. Its own piece of work.
|
||||
- **Driving the real WebKit2GTK window.**
|
||||
`WEBKIT_INSPECTOR_SERVER` exposes WebKit's remote inspector, but the
|
||||
protocol is not CDP and Playwright cannot attach. A bespoke client is
|
||||
the only route and is not worth it.
|
||||
@@ -0,0 +1,89 @@
|
||||
# 006 — Orientation fixes: knowing where you are and what you're looking at
|
||||
|
||||
**Status:** implemented
|
||||
**Branch:** main
|
||||
**Created:** 2026-08-11
|
||||
**Follows:** 005-agent-development-harness
|
||||
|
||||
## Problem
|
||||
|
||||
Six reports from using the app, which turned out to be one theme with
|
||||
six faces: **the UI knew things it did not say.**
|
||||
|
||||
1. Some track names in the track list were links, most were not. The
|
||||
rule (has both a release-group *and* a recording MBID) was invisible,
|
||||
so the list looked randomly broken.
|
||||
2. Opening an album sometimes showed the full catalog tracklist and
|
||||
sometimes only the tracks the user owned, with nothing on screen
|
||||
distinguishing the two — or distinguishing either from "still
|
||||
fetching".
|
||||
3. The same on artist pages: a discography that was the artist's, or a
|
||||
discography that was the user's shelf, rendered identically.
|
||||
4. "Check now" on the requests list appeared to do nothing, because it
|
||||
honoured each request's retry backoff — a request searched an hour
|
||||
ago was not due, so a deliberate button press produced silence.
|
||||
5. Pressing **M** muted playback and left the volume indicator
|
||||
unchanged, because mute does not change the volume *number* and
|
||||
`VolumeChanged` carried nothing else.
|
||||
6. The home page did not exist. The sidebar had a Home item; it fell
|
||||
through to "Coming soon: home".
|
||||
|
||||
## What shipped
|
||||
|
||||
**Backend**
|
||||
|
||||
- `events.MuteChanged` (bool), emitted alongside `VolumeChanged` so the
|
||||
UI has something to react to when silence is the only thing that
|
||||
changed. `Player.Muted()` for symmetry; `MuteToggle` now takes the
|
||||
speaker lock and refuses politely when no streamer exists.
|
||||
- `download.Reconciler.RunNow` — a forced pass that ignores backoff,
|
||||
backed by a new `ListWantedDownloadRequests` query. `RunOnce` (the
|
||||
loop) still honours it: the backoff is a promise to the providers,
|
||||
not to the user, and a person pressing a button *is* the schedule.
|
||||
`Summary` gained `Waiting` and `NoProviders` so "nothing happened"
|
||||
can be reported with a reason.
|
||||
- `backend/home` — the shelf builder, with queries in
|
||||
`sql/queries/home.sql` that return album ids only, joined back to
|
||||
`GetAllAlbumsWithDetails` in Go rather than restating the album
|
||||
projection six times. A shelf with nothing behind it is omitted.
|
||||
|
||||
**Frontend**
|
||||
|
||||
- `explore-link.ts` rewritten: a name always goes somewhere. No MBID
|
||||
means the *library* page for the same album/artist (both detail views
|
||||
already accept a local id), resolved through the library store, with
|
||||
an untagged track highlighted by title instead of by recording MBID.
|
||||
Links now fire on a genuine single click only — see below.
|
||||
- `<catalog-scope-notice>` — one banner, four states (`catalog`,
|
||||
`loading`, `library`, `unavailable`), used by both detail pages. The
|
||||
album and artist pages grew an explicit `catalogPending` /
|
||||
`catalogLoaded` pair, because `loadingReleases` already meant
|
||||
"something is renderable" and a library stand-in satisfies that.
|
||||
- Artist page: an empty `BrowseReleaseGroups` no longer wipes the
|
||||
library-hydrated discography — an empty catalog answer means "not
|
||||
indexed yet", not "released nothing".
|
||||
- Downloads: a no-client banner, per-request "next check in …", honest
|
||||
idle summaries, and copy that says the retry schedule exists.
|
||||
- `<home-view>`: shelves as horizontal rows; a cover opens the album, a
|
||||
play button plays it.
|
||||
|
||||
## The one thing worth remembering
|
||||
|
||||
**Making every track name a link broke double-click-to-play**, and the
|
||||
e2e playback suite caught it: the title is the widest thing in a row,
|
||||
so the first click of the double-click landed on the link and navigated
|
||||
away. Fixed in one place — `singleClick()` in `explore-link.ts` holds
|
||||
the navigation for one double-click interval (250 ms) and drops it if a
|
||||
`dblclick` arrives, while leaving the dblclick itself to bubble to the
|
||||
row. Rows do not need to know links exist.
|
||||
|
||||
This is exactly the failure mode plan 005's e2e tier was built for; it
|
||||
was invisible before the change because the seeded fixture library has
|
||||
no MBIDs, so no track name was a link.
|
||||
|
||||
## Verification
|
||||
|
||||
`make lint` (3 configs), `make test` (3 passes), `make ui-test`
|
||||
(329 passing, up from 313), `make e2e` (23 passing, up from 19 — four
|
||||
new home-page specs), `tsc --noEmit`, and manual verification of all
|
||||
six items in the running app via `make dev-headless` + `playwright-cli`.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,663 @@
|
||||
# 008 — The last audit, and the one binding that outlived six phases
|
||||
|
||||
**Status:** complete — all four phases shipped. `a11y.md` is closed,
|
||||
and with it all four audits from 2026-08-11.
|
||||
**Branch:** main
|
||||
**Created:** 2026-08-12
|
||||
**Follows:** 007-ui-reconciliation
|
||||
**Source:** `.planning/audits/2026-08-11-ui/a11y.md` (34 findings), plus
|
||||
one item inherited through all six phases of 007.
|
||||
|
||||
## Problem
|
||||
|
||||
Three of the four audits from 2026-08-11 are closed. `a11y.md` is not,
|
||||
and it is **the least verified material in the repo** — 007's own notes
|
||||
say so twice, and every pass that touched it found the audit wrong about
|
||||
something.
|
||||
|
||||
Two things follow from that, and they are the shape of this plan.
|
||||
|
||||
**The coverage map lies, and it lies in the direction of more work than
|
||||
exists.** 007's map assigns `a11y.7` (top-results cards are click-only
|
||||
divs) to Phase 6; the code has carried `role="button" tabindex="0"` and
|
||||
an Enter/Space handler since Phase 1. It is not alone. A grep pass over
|
||||
all 34 findings against the current tree closes at least five the map
|
||||
still shows open, and three of those (`17`, `19`, `27`) were closed by
|
||||
phases that were not about them.
|
||||
|
||||
**And what survives is not evenly distributed.** Of the ~13 that look
|
||||
open, three are genuine loss of function and the rest are Minor or
|
||||
Polish. One of the three — `15` — is a hard WCAG conformance failure
|
||||
that has been sitting under a "Major" heading being read as a nice-to-
|
||||
have.
|
||||
|
||||
Cutting across both: **two items in this audit were never measured at
|
||||
all.** Colour contrast is flagged borderline (`--yj-text-tertiary` on
|
||||
`--yj-bg-surface` ≈ 4.1:1 against 11 px text) with "that needs a real
|
||||
measurement" written next to it, and 007 parked it under "deliberately
|
||||
not planned — worth measuring before planning". The mouse-only resize
|
||||
handles (`28`) were dropped as "cosmetic preference, no function lost",
|
||||
which is a judgement made by reading. Both are claims with no number
|
||||
behind them, which by this repo's own standard is not a finding yet.
|
||||
|
||||
Separately, and not from any audit: **`tracklist.delete`**. Advertised
|
||||
in Settings as configurable, bound to nothing, and carried through six
|
||||
phases because it needs an operation that does not exist.
|
||||
|
||||
## The triage, as of 2026-08-12
|
||||
|
||||
Grep-level against `1e4a4e6`. **Every row is a hypothesis** — this is
|
||||
where the audit's claims are, not where the code is. Nothing here is
|
||||
fixed until it has been reproduced in the running app.
|
||||
|
||||
**Closed** (verified present in code): `1`, `2`, `3`, `4`, `5`, `6`,
|
||||
`7`, `8`, `12`, `13`, `16`, `17`, `19`, `27`, `33`.
|
||||
|
||||
**Closed by argument rather than by code**, to confirm by reading:
|
||||
`20` (the type-scale/`_itemSize` coupling is now documented in
|
||||
`tokens.css.ts`, which is what the finding asked for), `31` (one of
|
||||
`cover-grid`'s two `<img>`s has an `alt`; the finding named one).
|
||||
|
||||
**Open:**
|
||||
|
||||
| # | Level | What the grep says |
|
||||
|---|---|---|
|
||||
| `15` | Major | `now-playing` is not among the four files carrying `prefers-reduced-motion`. WCAG 2.2.2: moving content over 5 s with no pause mechanism. |
|
||||
| `14` | Major | `combobox.ts` has no `aria-controls`, no `aria-activedescendant`, no option ids. |
|
||||
| `11` | Major | No `altKey` handler in `queue-panel`. The *other* half of this finding — "no keyboard path to add a track to the queue or a playlist" — was closed by Phase 5's `MenuKeyboard`. |
|
||||
| `21` | Minor | `body { height: 100vh; overflow: hidden }` unchanged. WCAG 1.4.10. **Shipped — and the stated mechanism was wrong; the failure is horizontal.** |
|
||||
| `22` | Minor | `queue-panel` gained `aria-current`; `track-list` did not, and neither has a non-colour marker. **Shipped.** |
|
||||
| `24` | Minor | No `title` on the truncating element in `track-info`, `playlist-view`, `queue-panel` or `track-list`. **Shipped.** |
|
||||
| `25` | Minor | `<wa-progress-bar value=…>` with no label, verbatim as filed. **Shipped — it was named "Progress", not unnamed.** |
|
||||
| — | ~~new~~ | ~~**Two unnamed native `<select>`s**, one of them `page-header`'s sort control on nine views.~~ **False.** The sort control is named "Sort: " by its wrapping `<label>` on all nine. The two unnamed roles were **one** `config-field` select and the **seek bar**. See Phase 3's list. |
|
||||
| — | new | **24 of 93 controls on Settings unnamed** — every `config-field` select and toggle, all eighteen column checkboxes. **Shipped, 0 of 93.** |
|
||||
| — | new | **Both `wa-slider`s have no accessible name**, which `a11y.md` files under *what is already correct*. **Shipped.** |
|
||||
| `26` | Minor | Explore's search box is named by its placeholder only — which *is* an accname fallback, so an AX sweep reports it clean. `search-bar` was already fixed. **Shipped.** |
|
||||
| `28` | ~~dropped~~ | **Measured, stays dropped.** One header *label* clips at 800×600; zero data cells do. |
|
||||
| `29` | Polish | `<h3 class="subtitle">` for type size. **Shipped.** |
|
||||
| `30` | Polish | No skip link anywhere. **Shipped.** |
|
||||
| `32` | Polish | `title="Remove from queue"`, not identifying the track. **Shipped.** |
|
||||
| `34` | Polish | The 10 px sort arrow, unchanged. **Shipped — half of it was closed by Phase 1's `aria-sort`.** |
|
||||
| — | — | Colour contrast, never measured. **Measured and fixed in Phase 2.** |
|
||||
|
||||
## Ordering principle
|
||||
|
||||
By **what is lost**, then by containment.
|
||||
|
||||
Phase 1 first because it is the only phase where something a user needs
|
||||
is unavailable: a marquee they cannot stop, a combobox that announces
|
||||
nothing while they arrow through it, and a queue whose order cannot be
|
||||
changed without a mouse.
|
||||
|
||||
Phase 2 second because both of its items are *questions*, and the
|
||||
answers change what Phase 3 contains. If the contrast measurement comes
|
||||
back below 4.5:1 it is a Phase 1 item wearing a Polish hat; if the
|
||||
resize handles turn out to lose function rather than preference, `28`
|
||||
stops being dropped.
|
||||
|
||||
Phase 3 is the tail, batched, because each item is a line and the cost
|
||||
is in the verification rather than the change.
|
||||
|
||||
Phase 4 is `tracklist.delete`, last, because it is the only work in this
|
||||
plan that can destroy a user's data and it should not share a pass with
|
||||
anything.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — The three that lose function
|
||||
|
||||
### `15` — the marquee cannot be stopped
|
||||
|
||||
`now-playing`'s title and artist scroll continuously while a track plays
|
||||
when `scrollMode === 'always'` (persisted in localStorage), re-armed in
|
||||
a loop by `onScrollCycleEnd`.
|
||||
|
||||
**Ships:** a `prefers-reduced-motion: reduce` guard that treats `always`
|
||||
as `never` and disables the transition. The setting stays; the query
|
||||
overrides it, which is the right precedence — a stated OS-level
|
||||
accessibility preference outranks an app default the user may never have
|
||||
touched.
|
||||
|
||||
**Reproduce first:** the guard is two lines and will look like it
|
||||
worked whether or not it did. Check under an emulated
|
||||
`prefers-reduced-motion` in the running app, and check that the *hover*
|
||||
scroll (`scrollMode === 'hover'`, the default) is also covered — the
|
||||
finding names `always` and the mechanism is shared.
|
||||
|
||||
**Watch for:** `now-playing`'s geometry work in `updated()` keys on the
|
||||
two scroll flags, because `.will-scroll .scroll-content` carries
|
||||
`padding-right: 2em` and changing the class changes the distance the
|
||||
marquee travels. A guard that suppresses the animation without telling
|
||||
the geometry key will leave a stale measurement behind.
|
||||
|
||||
### `14` — the combobox announces nothing
|
||||
|
||||
`role="combobox" aria-expanded aria-autocomplete="list"` on the input
|
||||
and `role="listbox"`/`role="option"` below it, with no `id` on the
|
||||
listbox, no `aria-controls`, no `aria-activedescendant`, and no `id` on
|
||||
the options. `aria-selected` is used to mean "highlighted".
|
||||
|
||||
**Ships:** ids on the listbox and each option, `aria-controls`,
|
||||
`aria-activedescendant` tracking the highlight, and `aria-selected`
|
||||
meaning *chosen*.
|
||||
|
||||
**Reproduce first:** the a11y snapshot could not see a dialog's name and
|
||||
may not see this either — 007 lost twenty minutes to exactly that.
|
||||
CDP's `Accessibility.getFullAXTree` reports the computed value and where
|
||||
it came from; use it, not the snapshot.
|
||||
|
||||
### `11` — queue order cannot be changed without a mouse
|
||||
|
||||
Reordering is `draggable="true"` with the drop index computed from
|
||||
cursor Y. There is no keyboard equivalent and no `aria-` substitute.
|
||||
|
||||
**Ships:** Alt+ArrowUp / Alt+ArrowDown moves the focused queue item, on
|
||||
the roving tab stop `utils/roving-rows.ts` already gives that list, with
|
||||
a live region announcing the new position.
|
||||
|
||||
**Watch for:** Alt+Arrow is unmodified-adjacent but not unmodified, so
|
||||
`focusedControlOwnsKey` does not apply — this is a panel binding in
|
||||
`backend/shortcuts/config.go`, registered the way `tracklist.*` is, not
|
||||
a document listener. And the queue panel renders no list at all when
|
||||
closed, so anything asserting on it has to open it first.
|
||||
|
||||
**This is the risky one.** It is a new interaction model, it touches the
|
||||
backend shortcut table, and the drag path it parallels computes its drop
|
||||
index geometrically. It lands last in the phase, alone.
|
||||
|
||||
### Verification
|
||||
|
||||
`make ui-test` per rule, and then **run the existing tests** — that is
|
||||
what has caught every bad version of a new rule in 007, including twice
|
||||
in the last pass. `make ui-visual` for `15` (it changes what renders).
|
||||
An e2e case for `11`, because the queue panel's animated width means a
|
||||
click issued while it moves lands on whatever slid under the pointer.
|
||||
A manual pass per landing, with a screenshot read.
|
||||
|
||||
### Phase 1 — what actually shipped
|
||||
|
||||
Three landings, one per finding, each reproduced in the running app
|
||||
before anything was written and each watched failing on the pre-fix
|
||||
build before being believed.
|
||||
|
||||
- **`15`.** `shouldScroll()` returns false under
|
||||
`prefers-reduced-motion: reduce`, live (a `matchMedia` listener, so
|
||||
changing the OS setting is honoured without a reload — verified).
|
||||
It covers `hover` as well as `always`.
|
||||
- **`14`.** Ids on the listbox and every option, `aria-controls`,
|
||||
`aria-activedescendant`, and `aria-selected` meaning *chosen* rather
|
||||
than *highlighted*.
|
||||
- **`11`.** Alt+ArrowUp/Down moves the focused queue row, with a live
|
||||
region saying where it went.
|
||||
|
||||
Pinned by `now-playing.test.ts` (+2), `combobox-aria.test.ts` (5),
|
||||
`queue-reorder.test.ts` (7), `e2e/specs/reduced-motion.spec.ts` (2) and
|
||||
`e2e/specs/queue-reorder.spec.ts` (4). `make ui-test` 558 → **572**;
|
||||
`make e2e` 68 → **74**.
|
||||
|
||||
#### Where the plan was wrong — Phase 1
|
||||
|
||||
Nine things, and the first group is the triage being right for the
|
||||
wrong reason.
|
||||
|
||||
- **The grep triage was accurate about *what* is open and wrong about
|
||||
*why* two of them are.** It is a good first pass and it cannot see
|
||||
mechanism. `15` is filed as "no reduced-motion guard", which is true;
|
||||
what makes a CSS-only guard wrong is that the cycle is a transition
|
||||
out, a `transitionend` and a transition back, so suppressing the
|
||||
animation strands the text off its own box with nothing to bring it
|
||||
back. That is only visible by reading the cycle.
|
||||
- **A fix routes people into a state nobody has looked at.** With the
|
||||
marquee off, the fallback hard-clipped — "Overlong Trac|", no
|
||||
ellipsis — because `text-overflow` was on the outer span while the
|
||||
overflowing box is the inline-block child. It had never produced an
|
||||
ellipsis **in any mode**, including the default, and no test saw it.
|
||||
Found by reading the screenshot of the fix.
|
||||
- **…and fixing that broke the measurement it depends on.** Giving the
|
||||
child its own `overflow: hidden` stops the *parent* overflowing, so
|
||||
`titleOverflows` went false and nothing would ever have scrolled
|
||||
again, for anyone. Caught by the new test's positive case, which is
|
||||
the whole reason it has one.
|
||||
- **`a11y.6` scanned `<button>`, and says so.** "The only truly
|
||||
unnamed controls" is a claim about buttons. The AX tree has two
|
||||
unnamed `combobox` roles that are native `<select>`s — one of them
|
||||
the page header's sort control, on nine views. Not fixed here; it is
|
||||
a sweep of every form control, not a one-liner, and it belongs with
|
||||
`a11y.26` in Phase 3.
|
||||
- **The reproduction of the *fix* was wrong twice, on the probe side
|
||||
both times.** Reading `activedescendant` out of the AX tree as
|
||||
`relatedNodes[0].text` returned `(none)` on a working build — the
|
||||
property is there, with `value.type: "idref"`. And `last('QueueChanged')`
|
||||
returned a stale payload, so a reorder that had happened looked like
|
||||
one that had not. Ask `GetState`, dump the whole property.
|
||||
- **`11`'s stated scope is half done and the other half was already
|
||||
closed.** The finding is "drag-and-drop has no keyboard equivalent
|
||||
anywhere" and lists four sites; its stated *symptom* — "there is no
|
||||
keyboard path to add a track to the queue or a playlist" — was closed
|
||||
by Phase 5's `MenuKeyboard`. What was left is the queue's order, which
|
||||
is the one the menu cannot express. Album→queue drag and
|
||||
drop-on-nav-item remain, and are menu commands, not reorder.
|
||||
- **The plan said a backend panel binding; it should not be one.** The
|
||||
queue panel already handles Enter and the roving arrows in its own
|
||||
*delegated* (not document) keydown, which is the sanctioned pattern.
|
||||
Alt+Arrow joins them: it cannot collide with the global Up/Down
|
||||
volume bindings (measured — 0 `VolumeChanged` events from a focused
|
||||
row), and it keeps a reordering key out of a user-editable table
|
||||
where it could be rebound onto something unmodified.
|
||||
- **The index arithmetic is not symmetric, and the symmetric version
|
||||
fails silently.** `MoveQueueTracks` takes an index into the array
|
||||
*before* the move, so down-by-one must ask for `i + 2`; `i + 1` is
|
||||
where the row already is once its own removal is accounted for, and
|
||||
the backend's contiguous-block guard correctly returns without doing
|
||||
anything. Pinned in both tiers.
|
||||
- **`focusedIndex` was only ever moved by an arrow key.** A row reached
|
||||
by a click or by Tab left it at 0, so `Enter` played the first track
|
||||
in the queue from any focused row. Pre-existing, invisible until a
|
||||
key moved something, fixed by reading the index off the row the event
|
||||
came from.
|
||||
|
||||
And one that is not about the audit: **the backtick-in-a-`css`-comment
|
||||
trap cost a cycle again**, in the same session as reading the warning
|
||||
about it twice. It is worth treating as a lint rule rather than a piece
|
||||
of knowledge.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — The two that were never measured
|
||||
|
||||
Neither is a fix. Both are a number, and the number decides whether
|
||||
there is work.
|
||||
|
||||
**Colour contrast.** Measured against the rendered app, not against the
|
||||
token file: the tokens are what a component *may* use, and what matters
|
||||
is the pairs that actually appear. Sample the real computed colours at
|
||||
the real sizes, report the ratios, and only then decide. The audit's own
|
||||
number (≈ 4.1:1) is a hand calculation from two hex values and has the
|
||||
status of a hypothesis.
|
||||
|
||||
**`a11y.28`, the resize handles.** Four of them: the sidebar, the queue
|
||||
panel, the now-playing column, and the track-list column resizers.
|
||||
"Cosmetic preference, no function lost" is the claim to test. The
|
||||
track-list one is the suspicious member — a column narrowed to its floor
|
||||
clips its label (007 phase 5 found "Durat…" at 800 px), so widening a
|
||||
column may be the only way to read a value, which is function.
|
||||
|
||||
**Record both outcomes either way.** A measurement that closes a finding
|
||||
is worth as much as one that opens it, and this plan's predecessor got
|
||||
about a third of its value from findings that evaporated.
|
||||
|
||||
### Phase 2 — what the measurements said
|
||||
|
||||
One opened much wider than filed; one closed.
|
||||
|
||||
#### Contrast: worse than "borderline", and it was never one token
|
||||
|
||||
The audit's ≈ 4.1:1 was a hand calculation from two hex values, and
|
||||
plan 007 filed it under "deliberately not planned — worth measuring
|
||||
before planning". Measured against the rendered app across twelve views
|
||||
and then across all three ramps: **110 failing nodes**, and
|
||||
`textTertiary` failing AA in **nine of twelve** text/surface
|
||||
combinations — 4.35:1 on dark's surface, 3.25:1 on its elevated,
|
||||
2.31:1 on its overlay, and 2.55–3.32:1 on *every* surface of the light
|
||||
ramp, which the audit never considered.
|
||||
|
||||
Fixed, and now **0 of 659 nodes** on dark and darker. Three mechanisms,
|
||||
only the first of which is the finding:
|
||||
|
||||
- **The ramps.** `textTertiary` per ramp — `#a6a6a6` / `#949494` /
|
||||
`#5c636a` — sized to the lightest surface it actually sits on and
|
||||
keeping its hue.
|
||||
- **The avatar generator**, which is not a colour but a *family* of
|
||||
them: `hsl(hue, 45%, 35%)` behind white initials failed for **35 of
|
||||
360 hues**, so which artists were unreadable depended on how their
|
||||
names hashed. 32% clears every hue.
|
||||
- **Jobs' local `#ff6b6b`**, 4.15:1 on elevated.
|
||||
|
||||
Pinned by `theme-contrast.test.ts` and `avatar-color.test.ts` — unit
|
||||
tests over the data, not sweeps of the DOM. `make ui-test` 572 →
|
||||
**608**.
|
||||
|
||||
#### `a11y.28`: the drop was right, and now for a measured reason
|
||||
|
||||
"Cosmetic preference, no function lost" holds. At the window minimum
|
||||
(800×600, which is where the shell was measured in 007) the track list
|
||||
clips exactly one thing: the **Duration header label**. Zero data cells
|
||||
clip, and the sort that label names has a redundant keyboard-reachable
|
||||
dropdown. The queue panel at its default 321px clips nothing either.
|
||||
A keyboard-only user cannot change a panel width; they do not lose
|
||||
access to any value by not being able to. **Stays dropped.**
|
||||
|
||||
#### Two things the measurements found that are not in the audit
|
||||
|
||||
Both were bigger than what they were found under. Both are now fixed —
|
||||
see the third landing below.
|
||||
|
||||
- **The semantic colours were fixed across ramps, and a fixed colour
|
||||
cannot serve a near-black and a near-white background.** `--yj-error`
|
||||
measured 3.42:1 on dark's surface and 2.55:1 on its elevated;
|
||||
`--yj-info` 3.10:1 and 2.31:1; success and warning failed on dark and
|
||||
light both. As *backgrounds* under white text, success (3.45) and
|
||||
warning (3.58) failed too.
|
||||
- **The light ramp was not a usable theme.** With the greyscale fixed
|
||||
it still had **50 failing nodes**: the accent yellow under white text
|
||||
(1.43:1) and the autotag diff's pale greens and reds on white
|
||||
(1.36–2.59:1).
|
||||
|
||||
### Phase 2, third landing — the ramp reaches the semantic colours
|
||||
|
||||
**2237 nodes across three ramps and twelve views, 0 failing.**
|
||||
|
||||
The split is by the question a colour answers. A **fill** is "what
|
||||
colour is a danger button" — red in every theme, unchanged. A **text**
|
||||
colour is "what colour is the word *failed* on this background" — per
|
||||
ramp, because one value cannot clear 4.5:1 against both a near-black and
|
||||
a near-white surface. `bgOverlay` keeps the exception it already had on
|
||||
the dark ramp.
|
||||
|
||||
And every fill now carries a **computed foreground**, because the accent
|
||||
is a colour picker and no fixed answer survives one: white if white
|
||||
clears 4.5:1, else black. That keeps a red danger button white and
|
||||
flips a green or amber one to black. Accent-as-text goes through
|
||||
`accentTextOn()`, which mixes along the hue until it clears the ramp's
|
||||
surface and stops — returning the accent *unchanged* on both dark
|
||||
ramps, so the dark themes are visually untouched by that half.
|
||||
|
||||
`make ui-test` 608 → **649**.
|
||||
|
||||
#### Where this pass was wrong
|
||||
|
||||
- **"The chrome stays dark while the body goes light" was mine, and it
|
||||
was false.** I read it off a screenshot; the DOM says `.top-bar` is
|
||||
`#e9ecef` and `.sidebar` `#f8f9fa` under the light ramp, and a
|
||||
re-taken screenshot agrees. The first one was captured before the
|
||||
theme had propagated. Third time in two passes that a screenshot read
|
||||
at the wrong moment produced a confident wrong claim — and the second
|
||||
time this pass that **the picture and the number disagreed and the
|
||||
number was mine**.
|
||||
- **A `color:` regex matches `border-color:`.** Twice: once rewriting
|
||||
semantic text colours (3 borders) and once rewriting accent text (30
|
||||
more). A border is a fill, not text. Caught by grepping the result
|
||||
rather than by any test, because nothing renders differently enough
|
||||
to fail.
|
||||
- **Two accent buttons took their foreground from `--yj-bg-base`**,
|
||||
which inverts with the ramp — white on yellow at 1.43:1. That is not
|
||||
a colour that was chosen badly; it is a token used for the wrong
|
||||
meaning, and it only shows up in the theme nobody looks at.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — The tail
|
||||
|
||||
`21`, `22`, `24`, `25`, `29`, `30`, `32`, `34`, plus whatever Phase 2
|
||||
promotes or closes. One landing, batched, each item confirmed against
|
||||
the code before it is touched.
|
||||
|
||||
Two of them are not one-liners and should be treated as such:
|
||||
|
||||
- **`21`** (the shell is `100vh; overflow: hidden`) is a layout change
|
||||
to the app frame, and 007 phase 5 already measured the frame's real
|
||||
minimum at 800×600. Reflow at high zoom is the same question one
|
||||
variable over. It may want its own landing.
|
||||
- **The unnamed `<select>`s** from Phase 1, with `26`.
|
||||
- ~~**The semantic palette** and **the light ramp**~~ — both landed in
|
||||
Phase 2's third pass rather than waiting for this tail.
|
||||
- **`22`** asks for a non-colour marker on the playing row, which is a
|
||||
visual change to the densest list in the app and moves a baseline.
|
||||
|
||||
### Phase 3 — what actually shipped
|
||||
|
||||
Six landings rather than one, ordered by risk, each reproduced in the
|
||||
running app before anything was written and each watched failing on the
|
||||
pre-fix build.
|
||||
|
||||
- **Web Awesome's two hidden roles.** `label` on both `wa-slider`s and
|
||||
on `wa-progress-bar` (`25`), plus `styles/wa-slider-label.css.ts`,
|
||||
which hides the slider's visible label by part and puts back the 8px
|
||||
margin `#slider` takes as soon as one exists.
|
||||
- **Settings' form controls.** `for`/`id` in `config-field`,
|
||||
`aria-label` on the eighteen column toggles and thirty-six column
|
||||
arrows, and the action's name on every `shortcut-capture`.
|
||||
**24 unnamed of 93 → 0.**
|
||||
- **`24` and `32`.** `title` on the four clipping surfaces, on the
|
||||
track-list *cell* rather than on what is inside it; and a queue row's
|
||||
remove button named after its own track.
|
||||
- **`29`, `30`, `34`.** A skip link, `<h3>` → `<p>`, and the sort arrow
|
||||
at the type scale's floor. Plus the state that landed in: the hgroup
|
||||
measured 67px in a 64px bar and the subtitle's descenders were
|
||||
clipped once the h3's bottom margin went with it.
|
||||
- **`22`.** A triangle in each row's own left padding, in both lists,
|
||||
and `aria-current` on the track-list row.
|
||||
- **`21`.** `overflow-x: auto` — measured, and the finding's stated
|
||||
mechanism is not the one that exists.
|
||||
|
||||
And `26`'s remaining half, found last: Explore's search box.
|
||||
|
||||
Pinned by `wa-control-names.test.ts` (4), `settings-names.test.ts`
|
||||
(11), `aria-tail.test.ts` (+5), `queue-reorder.test.ts` (+3),
|
||||
`e2e/specs/control-names.spec.ts` (3), `e2e/specs/skip-link.spec.ts`
|
||||
(4), `e2e/specs/layout-overflow.spec.ts` (+6) and
|
||||
`e2e/specs/playback.spec.ts` (+1). `make ui-test` 649 → **672**;
|
||||
`make e2e` 74 → **88**.
|
||||
|
||||
#### Where the plan was wrong — Phase 3
|
||||
|
||||
Ten things. The first four are the audit or the plan being wrong about
|
||||
where a control's name lives.
|
||||
|
||||
- **The two unnamed `<select>`s from Phase 1 were one `<select>` and a
|
||||
slider, and neither was the page header's.** `page-header`'s sort
|
||||
control computes "Sort: " from its wrapping `<label>`, on every one
|
||||
of the nine views — checked with `getFullAXTree`, `from:
|
||||
relatedElement`. The other unnamed role was the **seek bar**, which
|
||||
`a11y.md` lists under *what is already correct*. Fourth probe error
|
||||
in two passes, and the same shape as the rest: read at the wrong
|
||||
level.
|
||||
- **`aria-label` on a Web Awesome host does not name the control.**
|
||||
`wa-slider` puts `role="slider"` on a div in its own shadow root
|
||||
pointing `aria-labelledby` at an empty internal `<label>`, and that
|
||||
IDREF outranks the host's `aria-label`. Both sliders computed `""`.
|
||||
Exactly `wa-dialog`'s trap one component over, and the audit made
|
||||
exactly the same mistake in the opposite direction — it read the
|
||||
source and credited a name that was never computed.
|
||||
`volume-control` did not even have the `aria-label` it is credited
|
||||
with.
|
||||
- **`a11y.25` is not "unnamed".** `wa-progress-bar` falls back to the
|
||||
localised word *progress*, so it announced "Progress, 45%" — named
|
||||
after the widget rather than after the work. Same fix, smaller claim.
|
||||
- **Settings was full of unnamed controls and no finding says so.** 24
|
||||
of 93. `a11y.6` is not wrong: it says in its own line that it scanned
|
||||
every `<button>`. Third time this pass that a count in the audit was
|
||||
answering a narrower question than it reads as.
|
||||
- **A placeholder is an accessible name.** Explore's search box
|
||||
therefore reported *clean* in an AX sweep of all eleven views, which
|
||||
is why `a11y.26` outlived four phases of people looking for exactly
|
||||
this. A sweep for empty names cannot see a weak one.
|
||||
- **`a11y.21`'s mechanism does not exist.** "The 4em bars grow while
|
||||
the viewport does not, and anything that no longer fits is clipped
|
||||
with no scrollbar" — the middle row is `1fr` and absorbs them
|
||||
exactly. At 200% text on 800×600 the bars go 64 → 128 and the panel
|
||||
472 → 344, footer still on 600. The real failure is horizontal, which
|
||||
the finding does not mention: 784px of app in a 320px viewport, 464px
|
||||
of it unreachable.
|
||||
- **…and the obvious probe for it passes on the broken build.**
|
||||
`overflow: hidden` still permits *programmatic* scrolling, so
|
||||
`scrollLeft = 9999` returns a healthy number on the build with the
|
||||
bug. It did. The spec is a wheel gesture now.
|
||||
- **A fix's own test was pinning the bug.** `transport.test.ts`
|
||||
asserted `aria-label` on the `wa-slider` host and called it "carries
|
||||
an accessible name". Running the existing suite is what found it,
|
||||
for the third plan running.
|
||||
- **`a11y.34` was half closed by Phase 1 and nobody had noticed.** "The
|
||||
sort direction is a 10px glyph *or nothing*" — it is announced now,
|
||||
via the `aria-sort` Phase 1 added. What was left is one declaration.
|
||||
- **The queue's `aria-current` is dead in the common path.** A track
|
||||
started from the *track list* leaves the queue's `currentIndex` at
|
||||
−1, so the panel has no current row at all — which is why `22`'s
|
||||
marker looked broken the first time it was checked in the running
|
||||
app. Pre-existing, not fixed here, and the reason the e2e case plays
|
||||
from the queue.
|
||||
|
||||
And one that is about the harness rather than the audit: **a synthetic
|
||||
`MouseEvent` does not reach a delegated handler the way a real gesture
|
||||
does.** Three probes in a row reported the queue row as never becoming
|
||||
active; `page.getByTestId('queue-row').dblclick()` made it active
|
||||
immediately. Same family as everything above — the probe was wrong, not
|
||||
the code.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — `tracklist.delete`, and the operation behind it
|
||||
|
||||
### The decision
|
||||
|
||||
*(Decided 2026-08-12, before any code.)*
|
||||
|
||||
**"Remove from library" removes the database row and excludes the path
|
||||
from future scans. It does not touch the file.**
|
||||
|
||||
The comment at `backend/shortcuts/config.go:36` states the fork exactly:
|
||||
the row (which the next scan puts back unless the path is also excluded)
|
||||
or the file (a delete-your-music button one keystroke from a focused
|
||||
row). Three shapes were considered:
|
||||
|
||||
- **A — row + path exclusion.** Reversible, needs an exclusions table,
|
||||
so a schema file *and* a migration.
|
||||
- **B — delete the file**, to the platform trash. Real user intent for
|
||||
an app with duplicate detection, genuinely destructive, and a new
|
||||
cross-platform dependency.
|
||||
- **C — ship the operation as a menu command only**, leave `Delete`
|
||||
unbound.
|
||||
|
||||
**A, delivered as C**, and then the keystroke. Without the exclusion,
|
||||
A is a button that undoes itself on the next scan, which is worse than
|
||||
no button — so the exclusion is not an enhancement, it is what makes the
|
||||
operation mean anything. `Delete` is bound only to *open the
|
||||
confirmation*, never to perform the removal: that makes the keystroke a
|
||||
request rather than an action, which is the only version defensible one
|
||||
key from a focused row.
|
||||
|
||||
**B is not foreclosed and is not in this plan.** It deserves its own
|
||||
argument.
|
||||
|
||||
### What ships
|
||||
|
||||
- An exclusions table, following the two-file schema discipline
|
||||
(`sql/schemas/` for the target shape, `sql/migrations/` for the
|
||||
existing install, column order matching, no index on a migrated
|
||||
column in the schema file).
|
||||
- `RemoveFromLibrary(filePaths)` — rows deleted, paths excluded, one
|
||||
event carrying enough for the stores to patch rather than invalidate.
|
||||
It is a *write*, so it goes through `ExecContext`, not the read pool.
|
||||
- A context-menu command behind `confirmAction()`, with impact copy
|
||||
naming the count and saying explicitly that files on disk are not
|
||||
touched.
|
||||
- `tracklist.delete` re-advertised, bound to opening that dialog.
|
||||
- The scanner honouring the exclusion list, which is the half that makes
|
||||
the rest true.
|
||||
|
||||
### Verification
|
||||
|
||||
A Go test that a removed path survives a rescan; an e2e case that the
|
||||
row is gone, the dialog said so, and the file still exists. Both halves
|
||||
matter — the second is the promise the copy makes.
|
||||
|
||||
### Phase 4 — what actually shipped
|
||||
|
||||
Three landings, in the order the plan proposed, each watched failing on
|
||||
the pre-fix build by neutering one line rather than stashing.
|
||||
|
||||
- **The schema, `RemoveFromLibrary`, and the scanner honouring the
|
||||
list.** `excluded_paths` (one file — see below), rows deleted the way
|
||||
the scan's own orphan cleanup deletes them, `TracksRemovedFromLibrary`
|
||||
carrying `{filePaths, count}`, and both of the scanner's walks taking
|
||||
the exclusion set.
|
||||
- **The context-menu command**, behind `confirmAction()` with an impact
|
||||
line that says the files are not deleted, plus `library-store`
|
||||
splicing rather than invalidating.
|
||||
- **`tracklist.delete`**, bound to opening that dialog, and the e2e
|
||||
case.
|
||||
|
||||
Pinned by `remove_tracks_test.go` (6), `library-store.test.ts` (+4),
|
||||
`keyboard-shortcuts.test.ts` (+1) and
|
||||
`e2e/specs/remove-from-library.spec.ts` (2). `make ui-test` 672 →
|
||||
**677**; `make e2e` 88 → **90**.
|
||||
|
||||
#### Where the plan was wrong — Phase 4
|
||||
|
||||
Six things, and the first two are the plan asking for work that does
|
||||
not exist and skipping work that does.
|
||||
|
||||
- **"Following the two-file schema discipline" is wrong for a new
|
||||
table.** `applySchema` runs every file in `sql/schemas/` on every
|
||||
open, so a `CREATE TABLE IF NOT EXISTS` reaches an existing install
|
||||
verbatim; the migration file the plan asked for would have been a
|
||||
*second description of the same table*, which is the one thing the
|
||||
checklist's third rule forbids. Column order and "no index on a
|
||||
migrated column" do not apply either — nothing is being added to an
|
||||
existing table, so the index lives beside its own `CREATE TABLE`.
|
||||
- **The half that would have undone the feature is not in the plan.**
|
||||
The startup soft scan decides "library unchanged" by comparing files
|
||||
on disk against rows in the database. An excluded path is on disk and
|
||||
deliberately not a row, so the two counts disagree *forever* and
|
||||
every launch queues a full scan of the whole library. Both walks take
|
||||
the exclusion set now. Nothing in any tier would have caught it: it
|
||||
is not a wrong answer, it is a permanent, invisible re-scan.
|
||||
- **…and neither is the queue.** Deleting an `audio_files` row cascades
|
||||
to `queue_tracks`, so the queue's in-memory copy — and possibly the
|
||||
playing track — goes stale. `RemoveLibrary` has had the
|
||||
`CompactQueue` hook for exactly this since it was written; the
|
||||
removal reuses it.
|
||||
- **The plan says nothing about undo, and the operation needs one.** An
|
||||
exclusion with no UI to clear it is a one-way door: the file is on
|
||||
disk and the user cannot get it back. A full rescan clears the table,
|
||||
which is the escape hatch until there is a list to manage. Recorded
|
||||
rather than implied, because it is the difference between
|
||||
"reversible" (shape A's stated advantage) and a claim.
|
||||
- **A new table has a second gate nobody remembers.**
|
||||
`backend/datamap` catalogues every table's Kind and Lifetime, and two
|
||||
of its tests fail on a new one: `TestCatalogCoversSchema` for the
|
||||
missing entry, then `TestAuthoredCascadesAreDeliberate` because an
|
||||
*authored* table that cascades needs an argued exemption. Both are
|
||||
right to ask; neither is mentioned in `references/schema-change.md`.
|
||||
- **The copy was wrong in the first screenshot, and only there.** The
|
||||
title was singular and the body said "**They** are removed" — the
|
||||
message and impact strings were written for the multi-select case and
|
||||
used for both. Nothing failed. Found by reading the PNG, which is now
|
||||
the sixth regression in four plans that only a PNG has caught.
|
||||
|
||||
And one about the harness rather than the work: **a hook gets 30
|
||||
seconds, not the test's timeout.** The e2e case's `afterAll` restore
|
||||
passed in isolation and timed out in the full suite, where earlier
|
||||
specs have staged an explore catalog and the restore takes longer than
|
||||
the hook's default budget. `test.setTimeout()` inside the hook is what
|
||||
raises it.
|
||||
|
||||
---
|
||||
|
||||
## Deliberately not in this plan
|
||||
|
||||
- **Splitting `explore-view.ts`** (1 900 lines). The shelves are in it
|
||||
because a separate component would need its own art fetching and
|
||||
therefore its own cache, cap and probe, and `perf.M7` exists because
|
||||
that view never unmounts. The reasoning holds; the size is the price.
|
||||
- **A `make perf` before/after for Phase 6's shelves.** Both seeds get
|
||||
their catalog from the artifact rather than from the seed tarball, so
|
||||
a before and an after are not the same corpus unless the e2e staging
|
||||
fixture is extended to bulk scale. Recorded as unmeasured in 007
|
||||
rather than implied to be free.
|
||||
- **The unowned badge's `+` glyph.** It becomes correct the day the
|
||||
badge becomes a button. Changing it now touches four components'
|
||||
visual baselines for a call better made then.
|
||||
- **The albums shelf leading with one act.** Needs dump-side data to
|
||||
express "these eight artists are one group and its solo members".
|
||||
A plan, not a fix.
|
||||
- **WebKit2GTK-specific behaviour** (page zoom in the Wails shell, how
|
||||
Orca traverses the virtualizer's windowed DOM). Only answerable on the
|
||||
real shell, and CI is the only place WebKit runs.
|
||||
|
||||
## First step
|
||||
|
||||
Phase 1, and within it `15` — reproduced under an emulated
|
||||
`prefers-reduced-motion` **before** the guard is written, because a
|
||||
two-line CSS change looks identical whether or not it worked, and this
|
||||
plan's predecessor met that failure in seven different costumes.
|
||||
@@ -0,0 +1,300 @@
|
||||
# 009 — The badge that cannot act, and the state it already had
|
||||
|
||||
**Status:** complete — all three phases shipped.
|
||||
**Branch:** main
|
||||
**Created:** 2026-08-13
|
||||
**Follows:** 008-the-last-audit
|
||||
|
||||
## Problem
|
||||
|
||||
007 phase 6 turned `library-status-indicator` from a `<button>` that did
|
||||
nothing into a `role="img"` badge, on the rule that **a control which
|
||||
cannot act is worse than none**, and wrote down what would change the
|
||||
answer: *"when the download-client integration lands, the right change
|
||||
is to make it a `<button>` again with a handler."*
|
||||
|
||||
Two things about that are wrong, and both were found by reading the code
|
||||
and then the running app rather than the note.
|
||||
|
||||
**The download client has largely already landed.** `backend/download`
|
||||
is 16 541 lines: a durable request model with four entity types
|
||||
(`artist` / `release-group` / `release` / `recording`, `request.go`), a
|
||||
reconciler, a staging importer, six provider adapters, 20 bound methods,
|
||||
`downloads-view`, the `download-picker` dialog, and a working **"Want
|
||||
this"** toggle on `explore-album-details`. What has not landed is the
|
||||
badge.
|
||||
|
||||
**And the badge is not merely inert — it is wrong.** `LibraryStatus`
|
||||
declares, styles and labels a third state, `queued` ("… is queued for
|
||||
download"). **Zero of the eight call sites ever produce it**
|
||||
(`explore-view:1839,1877`, `explore-artist-details:2116,2228,2323`,
|
||||
`explore-album-details:1641,2283`, `top-results-row:258` — every one is
|
||||
a two-way ternary). So an album the user has *already requested*
|
||||
displays a plus and says it is not in their library.
|
||||
|
||||
### Reproduced, 2026-08-13, before anything was written
|
||||
|
||||
Against `SEED=default` with the real 900 000-row catalog:
|
||||
`AddRequest({mbid: e51c54ea…, entity: 'release-group'})` for *GOLDEN* by
|
||||
Jung Kook, then Explore → search "GOLDEN":
|
||||
|
||||
```
|
||||
status not-in-library
|
||||
icon plus
|
||||
aria Album "GOLDEN" is not in your library
|
||||
```
|
||||
|
||||
and on the album's **own detail page**, forty pixels apart in the same
|
||||
screenshot: the button reads **"Wanted"** (filled) and the badge beside
|
||||
the title reads **plus / "is not in your library"**. One component,
|
||||
two surfaces, opposite answers. This is the header-badge-contradicting-
|
||||
Settings failure again, and again only a PNG showed it.
|
||||
|
||||
The same PNG showed a second one, which is why it is in this plan:
|
||||
**`bookmark-check` is not a bundled icon.** `window.__yjIconMisses`
|
||||
reports exactly `["bookmark-check"]`, so the "Wanted" button renders the
|
||||
fallback question-mark glyph. `e2e/specs/offline-icons.spec.ts` asserts
|
||||
that array is empty and passes, because no spec has ever put the app in
|
||||
a state where an album is requested — precisely the "twenty call sites
|
||||
compute their icon name from state" case `names.txt` exists for.
|
||||
|
||||
## Ordering principle
|
||||
|
||||
By **what is a fact and what is a decision**.
|
||||
|
||||
Phase 1 is a bug: the badge contradicts the app's own state, and fixing
|
||||
it needs no interaction design at all. It also produces the evidence
|
||||
Phase 2 needs — once the badge can say "requested", whether it must also
|
||||
*become* requestable is a question that can be looked at rather than
|
||||
assumed.
|
||||
|
||||
Phase 2 is a decision made before any code, in the shape 008 phase 4
|
||||
used, because one 20 px circle would otherwise mean three different
|
||||
commitments: on an artist card a **discography subscription**
|
||||
(`scope: 'future'`, `Expands()`, never satisfied), on an album a
|
||||
release-group request, on a track row a recording request.
|
||||
|
||||
Phase 3 is whatever Phase 2 leaves. **"Album only" is a legitimate
|
||||
outcome** and shrinks this plan rather than inventing work for it.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — the badge tells the truth
|
||||
|
||||
**Ships:**
|
||||
|
||||
- `utils/library-status.ts` — one definition of the rule, since the
|
||||
reason all eight sites are two-state is that the rule is written at
|
||||
all eight. Owning something outranks wanting it, so `in-library` wins
|
||||
over `queued`.
|
||||
- The eight call sites using it.
|
||||
- `explore-view` gaining the `downloadStore` subscription both detail
|
||||
views already have (`init()` + `subscribe()`), through
|
||||
`view-lifecycle` — it is a **cached primary view**, so a raw
|
||||
`connectedCallback` subscription would live for the session.
|
||||
- `bookmark-check` in `src/icons/names.txt`, and an e2e case that
|
||||
reaches the state that exposes it.
|
||||
|
||||
**The badge stays `role="img"`.** Telling the truth is not acting.
|
||||
|
||||
**Watch for:** `downloadStore.init()` fetches providers, descriptors,
|
||||
downloads *and* requests, so this warms a singleton on a page that
|
||||
previously did not construct it — "a store with no subscriber fetches
|
||||
nothing" cuts the other way here, and the cost belongs in the note.
|
||||
|
||||
### Phase 1 — what actually shipped
|
||||
|
||||
Three landings. `make ui-test` 677 → **685**; `make e2e` 90 → **92**.
|
||||
|
||||
- **The rule, written once.** `utils/library-status.ts`, the eight call
|
||||
sites, and `explore-view`'s subscription.
|
||||
- **The Pro icon.** `regular/bookmark` / `solid/bookmark`, vendored.
|
||||
- **`e2e/specs/requested-badge.spec.ts`**, which is also the first spec
|
||||
that reaches the state the icon sweep needed.
|
||||
|
||||
Pinned by `library-status.test.ts` (8) and `requested-badge.spec.ts`
|
||||
(2). Both e2e cases were watched failing on the pre-fix build by
|
||||
neutering one line each — the badge reported `not-in-library` where
|
||||
`queued` was expected, and the sweep returned `["bookmark-check"]`.
|
||||
|
||||
#### Where the plan was wrong — Phase 1
|
||||
|
||||
Six things, and the first is the plan's own framing.
|
||||
|
||||
- **"When the download client lands" had already half happened, and
|
||||
the note that said otherwise was written before it.** 007 phase 6
|
||||
left a condition ("make it a button *with* a handler") that reads as
|
||||
future work; `backend/download` was 16 541 lines and 20 bound methods
|
||||
at the time it was written. The badge was not waiting on the download
|
||||
client. It was waiting on somebody looking.
|
||||
- **The bug was one layer below the one in the plan.** The plan says
|
||||
the badge cannot act. What the reproduction says is that it could not
|
||||
even *report* — three states declared, two produced, at eight sites
|
||||
none of which knew about the third. "A control that cannot act" and
|
||||
"a control that is wrong" are different faults and only the second
|
||||
one is a lie.
|
||||
- **The second bug was in the screenshot of the first.** The "Wanted"
|
||||
button rendered a question mark, which is the missing-icon fallback:
|
||||
`bookmark-check` is a **Pro** name. It has been that way for as long
|
||||
as anything could be requested, and `offline-icons.spec.ts` — which
|
||||
exists to assert exactly this — passed throughout, because it never
|
||||
reached a state where an album was requested. Seventh regression in
|
||||
five plans that only a PNG has caught, and the first one caught in a
|
||||
PNG taken of a *different* bug.
|
||||
- **A sibling component does not hear its host re-render.**
|
||||
`top-results-row` takes `results` as a property; `explore-view`
|
||||
re-rendering hands back the same array, so Lit stops at the property
|
||||
and the row keeps its old badges. Same shape as the virtualizer rule
|
||||
one level milder, and the fix is the same: subscribe where the state
|
||||
is read.
|
||||
- **The cleanup ran on a page that could not run it.** `afterAll` used
|
||||
`callBinding`, which goes through `window.__yjEvents` — installed by
|
||||
the `app` fixture and not by `browser.newPage()`. It threw where
|
||||
nothing was watching, left the request behind, and failed the *next*
|
||||
run of the same spec with a stale `queued`. A spec that gives state
|
||||
back has to be checked by running it twice, which is what found this.
|
||||
- **A freshly launched app cannot search its own catalog for ~40 s.**
|
||||
The core artifact merge (`core artifact: merge complete` in
|
||||
`.dev/app.log`) has to land first, and until it does Explore's search
|
||||
returns nothing — *including for rows staged directly into
|
||||
`explore_index` a moment earlier*, which is what makes it look like a
|
||||
staging bug. It cost a cycle here reading as a failure of the neuter
|
||||
it was run under.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — what a badge click means, per entity
|
||||
|
||||
*(Decided 2026-08-13, before any code.)*
|
||||
|
||||
**A badge is a button where it is the only way to act, and what it
|
||||
toggles is a request — never a download.**
|
||||
|
||||
Two of the three questions were answered by the code rather than by a
|
||||
judgement, which is the point of asking them before writing anything.
|
||||
|
||||
**There is no artist badge, and there never was.** The worry that one
|
||||
20 px circle would commit a user to a whole discography does not apply:
|
||||
`top-results-row` renders `nothing` for an artist, and no other site
|
||||
passes `entity-type="artist"` to this component at all. Artist
|
||||
subscription already has a home — `explore-artist-details`'s
|
||||
`renderFollowAction()`, a labelled button with the scope beside it,
|
||||
which is where a commitment that never completes belongs.
|
||||
|
||||
**A track badge is honoured end to end.** `EntityRecording` is not a
|
||||
placeholder in the request model: `Reconciler.tracklistFor` has a
|
||||
deliberate branch for it ("A track request is its own tracklist") whose
|
||||
comment explains that the single expected title is what lets filename
|
||||
matching score a one-song download at all. So a track badge promises
|
||||
something the backend can keep, and it is a button too.
|
||||
|
||||
That also disposes of the second observation. An hourglass on an album
|
||||
over a row of plusses read as noise while a plus meant nothing; once a
|
||||
plus on a track means *want just this one*, the mixed row is the
|
||||
interface working. No special case, and none of the four surfaces needs
|
||||
to know what contains what.
|
||||
|
||||
**The album detail header keeps its badge read-only.** "Want this" sits
|
||||
directly below it saying the same thing in words. The rule is not "a
|
||||
badge is decorative on detail pages" — it is that a call site **opts in
|
||||
by supplying the MBID to act on**, so a redundancy is visible in the
|
||||
template rather than hidden in the component.
|
||||
|
||||
**And it is a request, not an acquisition.** The old copy said "Add …
|
||||
to library", which 007 called the button's promise written into the
|
||||
copy — and it would still be a lie, because clicking adds a row to the
|
||||
request list and nothing to the library. The name is the action, in the
|
||||
words the rest of the app already uses: **"Want …"**, and **"Cancel the
|
||||
request for …"** when it is already wanted. No confirmation: the action
|
||||
is one click to undo, which is the whole test for whether a dialog is
|
||||
owed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — the button
|
||||
|
||||
Ships what Phase 2 decided: `request-mbid` as the opt-in, a `<button>`
|
||||
where a call site passes one and the entity is not already owned, and
|
||||
`toggleRequest()` beside `libraryStatusFor()` because
|
||||
`explore-album-details`'s "Want this" asks the same question and two
|
||||
implementations of *what wanting something means* is what Phase 1 was
|
||||
about.
|
||||
|
||||
### Phase 3 — what actually shipped
|
||||
|
||||
Seven of the eight call sites opt in; the album header does not.
|
||||
`make ui-test` 685 → **695**; `make e2e` 92 → **93**.
|
||||
|
||||
Verified in the running app with a **real mouse gesture and a real
|
||||
keyboard path**, not a synthetic event: click the badge → the request
|
||||
is filed, the badge becomes an hourglass, the album page does not
|
||||
open. Tab → the badge takes focus with its own ring inside the card's;
|
||||
Enter → same, and the card's own Enter handler does not fire.
|
||||
|
||||
Pinned by `library-status.test.ts` (+10, watched failing on the
|
||||
pre-fix build — 8 of 18) and `requested-badge.spec.ts` (+1).
|
||||
|
||||
#### Where the plan was wrong — Phase 3
|
||||
|
||||
Five things, and the first two are the plan asking questions the code
|
||||
had already answered.
|
||||
|
||||
- **Two thirds of the Phase 2 decision was not a decision.** "An artist
|
||||
badge would mean a discography subscription" describes a badge that
|
||||
does not exist — `top-results-row` renders `nothing` for an artist
|
||||
and no other site passes `entity-type="artist"` at all. And "should a
|
||||
track inside a requested album show something different" evaporated
|
||||
the moment a plus on a track meant *want just this one*. A decision
|
||||
phase is worth having; two of its three items were answered by
|
||||
reading rather than by choosing, which is the cheaper half of it
|
||||
working.
|
||||
- **`EntityRecording` is load-bearing and reads like a placeholder.**
|
||||
It would have been easy to rule tracks out as unsupported; the
|
||||
reconciler has an explicit branch for them whose comment explains
|
||||
that a one-entry expected tracklist is what lets filename matching
|
||||
score a single-track download at all. Ruling it out would have been a
|
||||
feature removed by assumption.
|
||||
- **A test that passes on the neutered build is not a test.** "Keeps
|
||||
its click off the card it sits on" asserted that nothing bubbled —
|
||||
which is free when there is no button to click, since `?.click()` on
|
||||
null is a silent no-op. It passed on the neutered build. It asserts
|
||||
the click *did the thing it was swallowed for* as well now, and fails
|
||||
there like the other seven.
|
||||
- **A measured coordinate is stale before it is used.** The e2e gesture
|
||||
read a bounding box the moment the search settled; cover art is still
|
||||
arriving then, and a card that grows moves the badge, so the click
|
||||
landed on the card and opened the album — reported as a failure to
|
||||
file a request, which is a different bug entirely. A locator
|
||||
re-resolves and waits for the element to stop moving.
|
||||
- **A fix moves its own assertions.** Phase 1's spec asserted the
|
||||
badge's name was "… is queued for download"; a control is named after
|
||||
what activating it does, so it is "Cancel the request for …" now. The
|
||||
spec was right when it was written and wrong two commits later, which
|
||||
is the ordinary cost of naming a thing after its state.
|
||||
|
||||
---
|
||||
|
||||
## Deliberately not in this plan
|
||||
|
||||
- **Deleting the file from disk** (008 phase 4's explicit sequel). Not
|
||||
refused — mis-ordered. 008's own notes record that the *reversible*
|
||||
option shipped with **nothing implementing its reversibility**:
|
||||
`excluded_paths` has no management surface, and "a full rescan clears
|
||||
it" is the escape hatch. Shipping an irreversible delete beside a
|
||||
reversible one that cannot yet be undone is backwards, and the
|
||||
platform trash is a new cross-platform dependency besides.
|
||||
- **`a11y.20`, deriving `_itemSize` from a measured row.** Real and
|
||||
confirmed in code — `.track-row` is `height: 33px; contain: strict`
|
||||
with a `rem` font size, so text scales and the box does not, across
|
||||
four lists (33 / 49 / 45 / 45 px). It waits because its only honest
|
||||
verification does not exist yet: both surviving comments
|
||||
(`track-list.ts:349`, `queue-panel.ts:179`) say a wrong `_itemSize`
|
||||
desynchronises the **native scrollbar at 20k+ rows**, and `make perf`
|
||||
has no scroll-fidelity row. That measurement is its own first phase
|
||||
and belongs to a plan that is about it.
|
||||
|
||||
## First step
|
||||
|
||||
Phase 1, and within it the helper rather than the call sites — the
|
||||
reproduction above is already the failing case, and the point of the
|
||||
helper is that there is one place for the next state to be added.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
||||
# 011 — An owned artist's discography, whole and offline
|
||||
|
||||
**Status:** built, **not yet verified against a real library**. Lint,
|
||||
the three Go test configurations, `tsc` and the Vitest suite all pass;
|
||||
what has *not* happened is a run against a seeded library with real
|
||||
MusicBrainz traffic, which is the only thing that can show the pass
|
||||
completing an artist end to end. Do that before moving this to
|
||||
`completed/`.
|
||||
**Branch:** main
|
||||
**Created:** 2026-08-13
|
||||
**Depends on:** nothing
|
||||
**Related:** 010 (owned albums, offline) — the same rate limiter, the
|
||||
next layer down. 010 warms *tracklists*; this warms the *list of
|
||||
albums*. Read 010's "the rate limiter is the whole design constraint"
|
||||
section before building either.
|
||||
|
||||
---
|
||||
|
||||
## The problem
|
||||
|
||||
`BackfillLibraryDiscographies` sounds like it does this and does not.
|
||||
Per owned artist, `indexOneArtist` (`searchindex.go:1910`) fetches from
|
||||
ListenBrainz:
|
||||
|
||||
- `fetchTopReleaseGroups` — capped at `indexMaxRGs` (50)
|
||||
- `fetchTopRecordings` — capped at `indexMaxRecs` (200)
|
||||
|
||||
and both drop anything under `indexMinPopularity` (50 listens). So what
|
||||
an owned artist's page shows offline is **their fifty most-listened
|
||||
release groups**, not their discography. For an artist with a long tail
|
||||
— early EPs, live albums, splits, anything regional — the missing rows
|
||||
are precisely the ones a user who owns that artist is most likely to be
|
||||
looking for.
|
||||
|
||||
**It is also untyped.** LB's `top-release-groups-for-artist` returns no
|
||||
secondary types, so the first view of every backfilled artist has no
|
||||
EP / Live / Compilation / Soundtrack distinction — the discography
|
||||
renders as one undifferentiated list.
|
||||
|
||||
MusicBrainz's browse-by-artist has both the full list and the types,
|
||||
and `BrowseReleaseGroups` (`explore.go:589`) already knows it: on
|
||||
finding no secondary types on any indexed row it fires the browse **in
|
||||
a goroutine, for next time**, and `AddFromCache` writes the result into
|
||||
the index. So the fix is not new machinery. It is running that call
|
||||
deliberately, once per owned artist, at scan time instead of
|
||||
accidentally, on view, one artist at a time.
|
||||
|
||||
## What to build
|
||||
|
||||
Extend the existing post-scan pass — it is already bounded, resumable,
|
||||
idempotent and ordered by owned-track count, which is the shape this
|
||||
needs and the proven one in this codebase.
|
||||
|
||||
Per unenriched owned artist, in addition to today's LB fetches:
|
||||
|
||||
1. **`BrowseReleaseGroups`, paged to exhaustion.** `musicbrainz.go:318`
|
||||
issues a single `Paginator{Limit: MaxLimit}` with no offset loop, so
|
||||
a prolific artist is silently truncated at 100 release groups. Page
|
||||
until a short response. This is the one change that makes the word
|
||||
*full* honest, and it is a change to a function the interactive path
|
||||
also calls — which is a win, not a risk.
|
||||
2. **`SimilarArtists`.** `similar_artist_map` is not in the shipped
|
||||
artifact and is filled lazily on view (`explore.go:905`), so it is
|
||||
empty for every artist nobody has opened. It is one LB labs call and
|
||||
already persists; folding it in here costs a request and removes the
|
||||
page's last routine network dependency.
|
||||
|
||||
Deliberately **not** in scope: cover art for non-owned release groups.
|
||||
It is roughly *RGs per artist* fetches rather than one — an order of
|
||||
magnitude more requests than everything else here combined — and a
|
||||
missing thumbnail degrades to a placeholder, where a missing release
|
||||
group degrades to a page that is quietly wrong. Covers stay lazy.
|
||||
|
||||
## Four things that bite
|
||||
|
||||
**`discog_fetched` is one boolean and would now cover three fetches
|
||||
with different failure modes.** Today it is set only if an LB fetch
|
||||
returned rows (`indexOneArtist:1962`), which is the right rule for one
|
||||
call and useless for three — an MB failure would either permanently
|
||||
claim the artist as done or force the LB fetches to repeat. Track the
|
||||
facets separately. Prefer **a new table keyed by artist MBID** over new
|
||||
`explore_index` columns: `artifactimport.go:95` enumerates the columns
|
||||
the artifact merge preserves, so a flag column added there is a second
|
||||
place to remember, and forgetting it silently wipes every mark on the
|
||||
next artifact update. A new table is also the single-file schema case
|
||||
(`CREATE TABLE IF NOT EXISTS`, no migration) and needs a `datamap`
|
||||
entry — `Cache` / `Swept`, since it is re-derivable.
|
||||
|
||||
**The `hasSecondaryTypes` heuristic re-fires forever for an artist who
|
||||
has none.** An artist whose discography is entirely plain albums writes
|
||||
`secondary_types = ''` on every row, so the "we must be missing them"
|
||||
test is true on every visit and browses again (cheaply — 7-day
|
||||
`cacheTTLEntity` — but forever). An explicit per-artist "browsed at"
|
||||
mark retires the heuristic, which is a second reason for the table
|
||||
above.
|
||||
|
||||
**Popularity is safe, and only because of the upsert rule.**
|
||||
`AddFromCache` writes `Popularity: 0` for every browsed release group;
|
||||
`upsertIndexConflictSQL:2180` is "highest wins", so it cannot clobber
|
||||
the LB figures. The consequence is one to state rather than fix:
|
||||
`TopReleaseGroupsByArtist` orders by popularity descending, so the deep
|
||||
cuts this plan adds sort below the top fifty. That is the correct
|
||||
order.
|
||||
|
||||
**The MB limiter is shared — and the priority work this needed is
|
||||
done.** ~~One `NewRateLimiter()` at 1 req/s serves this,
|
||||
`PrefetchReleases`, and every interactive browse~~ — 010 says that and
|
||||
it is wrong on the detail: `e.mb` runs on `mbSearchLimiter`,
|
||||
`NewRateLimiterBurst(3, 1)`, while the 1/s `NewRateLimiter()` at
|
||||
`explore.go:84` is the *artist image* limiter. Both were shared with
|
||||
background work and both are FIFO, which was the real problem.
|
||||
|
||||
Shipped ahead of this plan (same session it was written):
|
||||
|
||||
- `RateLimiter.WithBackgroundLane(perSecond)` plus
|
||||
`WithBackgroundPriority(ctx)` — a marked caller yields entirely while
|
||||
any interactive wait is outstanding, and is paced at MB's own 1/s
|
||||
rather than the interactive burst rate. The marker is a context value
|
||||
so a backfill and a detail page can call the same
|
||||
`MusicBrainzClient` method and be treated differently.
|
||||
- Both existing backfills mark their context, including the artist
|
||||
image resolution (`GetArtistImage` takes a `ctx` now for no reason
|
||||
other than carrying that marking).
|
||||
- `jobs.KindCatalogEnrich` and `startBackfillJob` — both backfills are
|
||||
registered, cancellable, and show progress. No job is registered
|
||||
when there is nothing to do, which is every launch once the library
|
||||
is covered.
|
||||
|
||||
So this plan inherits the lane: mark the new fetches background and add
|
||||
them to the existing job's progress. What it must **not** do is treat
|
||||
"a backfill is now polite" as licence to widen it without measuring —
|
||||
the yield gate protects latency, not the origin's patience.
|
||||
|
||||
## Done when
|
||||
|
||||
- An owned artist's page, opened for the first time after a scan,
|
||||
renders their complete typed discography with no network call —
|
||||
including release groups under the popularity floor and beyond the
|
||||
first 100.
|
||||
- Similar artists render offline for an owned artist nobody has opened.
|
||||
- An interactive browse issued while the backfill runs is not delayed
|
||||
by it.
|
||||
- The backfill appears in the jobs indicator and can be paused and
|
||||
cancelled.
|
||||
- A second run after a completed one does approximately nothing, and an
|
||||
artifact update does not undo a completed one.
|
||||
@@ -0,0 +1,160 @@
|
||||
# 012 — What we ask the network for, and what we already had
|
||||
|
||||
> **Completed.** Findings 1, 2 and 4 shipped. Finding 3 — the bound-but-uncalled methods — is now **#86**.
|
||||
|
||||
**Status:** all four findings fixed. Lint (3 configs), Go tests (3
|
||||
configs), `tsc` and 752 Vitest tests pass; **not driven against the
|
||||
real app**, so the numbers below are read off the code, not measured.
|
||||
|
||||
One claim in the audit was wrong and is corrected in finding 3:
|
||||
`CheckLibraryMBIDs` is *not* dead — `downloadcatalog.go:152` calls it.
|
||||
It has no *frontend* caller, which is what was checked and not what was
|
||||
written.
|
||||
**Branch:** none yet
|
||||
**Created:** 2026-08-13
|
||||
**Related:** 010 (owned albums offline), 011 (owned artists' discography)
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
Every frontend call site that can reach the network, and the backend
|
||||
method behind it. The question asked of each: *is there a local answer
|
||||
first, and if we do go out, do we go out once for many things or many
|
||||
times for one?*
|
||||
|
||||
## What is already right, and is the standard the rest is measured against
|
||||
|
||||
- **Every catalog read is index-first.** `LookupArtist`,
|
||||
`LookupReleaseGroup`, `BrowseReleaseGroups`,
|
||||
`TopRecordingsForArtist`, `TopReleaseGroupsForArtist`,
|
||||
`SimilarArtists` and `ResolveReleaseGroupMBIDs` all answer from
|
||||
`explore_index` / `similar_artist_map` and only fall through on a
|
||||
miss — several kick a background fetch and return empty rather than
|
||||
blocking, with a `*Ready` event to re-read.
|
||||
- **Album art has the right shape:** seed from the library, one
|
||||
`GetThumbnails` batch that is *cached-only by contract*, then
|
||||
per-item `GetThumbnail` calls that stream in
|
||||
(`explore-view.ts:1445`). Nothing waits on a batch of network
|
||||
fetches.
|
||||
- **Artist art has the right shape in exactly one place:**
|
||||
`seedSimilarArtistImagesFromLibrary`
|
||||
(`explore-artist-details.ts:1627`) — library store, then disk-only
|
||||
`GetArtistImageCachedPath`, fired in parallel, zero network calls.
|
||||
It is the model for finding 1.
|
||||
|
||||
## Finding 1 — Explore's artist images: no disk check, and serial
|
||||
|
||||
`explore-view.ts:1526-1546`. `loadArtistImages` seeds from
|
||||
`libraryStore.cachedArtists` — i.e. **owned artists only**, which on a
|
||||
catalog search is a small minority of results — and then, for every
|
||||
remaining artist:
|
||||
|
||||
```ts
|
||||
const url = await GetArtistImageURL(a.mbid); // in a for loop
|
||||
```
|
||||
|
||||
Two faults, both fixed by patterns already in the codebase:
|
||||
|
||||
- **No cached-path pass.** `GetArtistImageCachedPath` and
|
||||
`GetArtistImageCached` are disk-only and free, and neither is used
|
||||
here. An artist whose portrait is already on disk from a previous
|
||||
search still takes the resolution path.
|
||||
- **`await` in a loop.** `GetArtistImageURL` is the *resolving* entry
|
||||
point: on a miss it does MB artist-rels (on the 1/s artist-image
|
||||
limiter) → Wikidata → Wikipedia → a Wikimedia image download. Serial
|
||||
awaits mean 8 unresolved artists are 8 of those end to end, each
|
||||
blocking the next, while the equivalent album-art path fires all of
|
||||
them at once.
|
||||
|
||||
The same "resolver used where a cache check belongs" appears at
|
||||
`top-results-row.ts:218` and `artist-details.ts:207` (both fire in
|
||||
parallel, so only the first fault applies, and both are small-N).
|
||||
|
||||
**Fix:** disk-cached pass first, then network in parallel. A
|
||||
`GetArtistImagesCached(mbids []string) map[string]string` mirroring
|
||||
`GetThumbnails` would make it one IPC call instead of N — see finding 4
|
||||
for why that is not `GetArtistImages`.
|
||||
|
||||
## Finding 2 — The artist page prefetches tracklists twice, or four times
|
||||
|
||||
`prefetchReleases` (`explore-artist-details.ts:1531`) is called from
|
||||
**both** `fetchTopReleaseGroups` (:1467) and `fetchReleaseGroups`
|
||||
(:1506), and `PrefetchReleases` fires up to **8** `BrowseReleases` per
|
||||
call — the most expensive request the app makes (every version of a
|
||||
release group, with `recordings` and `media`).
|
||||
|
||||
The top release groups are a subset of the discography, so the two
|
||||
calls are asking about overlapping sets; the backend's
|
||||
`BrowseReleasesCached` guard stops a *literal* repeat, which means the
|
||||
second call spends its 8 slots on the next 8 uncached albums rather
|
||||
than doing nothing. One page view is therefore up to 16 browses — and
|
||||
on a cold artist, `ArtistDiscographyReady` re-runs both fetchers
|
||||
(:945, :948), taking it to 32.
|
||||
|
||||
Worse, some of that is now provably wasted: since tag-derived
|
||||
completeness landed (`dcc40b1`), **a complete, MBID-matched album opens
|
||||
with no catalog call at all**, so warming its tracklist buys nothing.
|
||||
|
||||
**Fix, in order of value:**
|
||||
|
||||
1. Prefetch once, from the union of both lists, after both resolve.
|
||||
2. Skip release groups that are owned and complete —
|
||||
`GetAlbumCompleteness` already answers this locally.
|
||||
3. Revisit the cap of 8 with the other two in place. Plan 010 flags
|
||||
the same number from the other direction.
|
||||
|
||||
## Finding 3 — Batch helpers with no caller (one of which was live)
|
||||
|
||||
`CheckLibraryMBIDs`, `GetPopularityBatch` and `GetArtistImages` are
|
||||
bound to the frontend and have **no call site in `frontend/src`**.
|
||||
They are the batch shapes a future N+1 would want, and their existence
|
||||
is presumably why the N+1s above were not noticed.
|
||||
|
||||
**`CheckLibraryMBIDs` is not dead** — `downloadcatalog.go:152` calls
|
||||
it from Go, one MBID at a time. Deleting it broke the build, which is
|
||||
how that was found; it is kept, with a comment saying who its consumer
|
||||
is. Read "no frontend caller" as exactly that, and grep both languages
|
||||
before removing a bound method.
|
||||
|
||||
Note `GetArtistImages` is not the helper finding 1 needs: it resolves
|
||||
names through `libMBID.AllArtistMBIDs()`, so it only answers for
|
||||
artists **in the library** — the exact set Explore's search results are
|
||||
not. Either give it an MBID-keyed sibling or replace it.
|
||||
|
||||
Also bound with no caller, and worth a separate decision about whether
|
||||
the feature is live at all: `GetTrackLyrics`, `GenerateMix`,
|
||||
`GetArtistPlayCount`, `GetLibrarySimilarArtists`,
|
||||
`GetCandidateThumbnail`.
|
||||
|
||||
## Finding 4 — One more background pass with no job and no priority
|
||||
|
||||
`BackfillLibraryLyrics` (`lyrics.go:129`) is a bare `go` call: bounded
|
||||
by passes and per-track (LRCLIB has no batch endpoint, so per-track is
|
||||
correct), but with no `jobs` registration and no
|
||||
`WithBackgroundPriority` marking. It runs on its own limiter, so it
|
||||
starves nothing today — but it is invisible and uncancellable, which is
|
||||
the gap 011 just closed for the other two backfills.
|
||||
|
||||
## Not a finding, recorded so it is not re-audited
|
||||
|
||||
- `GetThumbnails` returning only cached entries is deliberate and
|
||||
documented; the per-item follow-up is the streaming half, not an
|
||||
N+1.
|
||||
- `explore-artist-details` calling both `TopReleaseGroupsForArtist`
|
||||
(50) and `BrowseReleaseGroups` (200) reads overlapping rows from the
|
||||
index twice, but both are local queries feeding two different
|
||||
sections. Not worth merging.
|
||||
- The newest components (`home-view`, `catalog-scope-notice`,
|
||||
`page-header`, the notification stack, `shortcuts-overlay`) make no
|
||||
network calls at all. `home-view` is `GetShelves` + `GetAlbumTracks`,
|
||||
both local.
|
||||
|
||||
## Done when
|
||||
|
||||
- An Explore search with no owned artists in it makes zero artist-image
|
||||
network calls for portraits already on disk, and resolves the rest
|
||||
concurrently.
|
||||
- Opening an artist page issues one prefetch pass, over albums that are
|
||||
not already fully owned.
|
||||
- The bound-but-uncalled batch helpers are either wired or removed.
|
||||
@@ -0,0 +1,639 @@
|
||||
# 013 — The database audit
|
||||
|
||||
**Status:** **complete** (2026-08-16). R1–R10 landed, the album page
|
||||
that prompted the audit with them, and the one part of R5 that ships
|
||||
*in the artifact* — a per-release-group track denominator — landed as
|
||||
plan 014.
|
||||
The audit below is unchanged from when it was written — the measurements
|
||||
describe the *old* shape and are the reason for the new one.
|
||||
**Branch:** none
|
||||
**Created:** 2026-08-15
|
||||
**Supersedes:** the four-part album-page fix sketched in conversation
|
||||
(it survives, reduced, as R1 and R3 below)
|
||||
**Related:** 010 (owned albums offline), 011 (owned artists'
|
||||
discography), 012 (API call audit), 002 (data lifecycle)
|
||||
|
||||
---
|
||||
|
||||
## Method
|
||||
|
||||
Every number here is measured against the **real 25,966-track library**
|
||||
at `~/.local/share/yellowjacket/yj.db` (copied read-only), not against
|
||||
a fixture and not inferred from the code. Where a claim rests on a
|
||||
capability rather than a count — "sqlc can do X" — it was executed, not
|
||||
assumed.
|
||||
|
||||
The brief: *efficiency and simplicity — the minimum required to achieve
|
||||
our featureset*, with fewer lines and a smaller database as evidence
|
||||
rather than as the goal. Two named sources of confusion to resolve:
|
||||
**local versus remote** versions of a thing, **files versus tracks**,
|
||||
and **indexed versus live** lookups. One added constraint: **avoid
|
||||
hitting APIs by storing intelligently, without a ridiculous base
|
||||
install.**
|
||||
|
||||
---
|
||||
|
||||
## The measurements
|
||||
|
||||
### The database is 1.00 GB, and 78% of it is one table
|
||||
|
||||
| object | size | rows |
|
||||
|---|---|---|
|
||||
| `explore_index` | 383 MB | 2,052,200 |
|
||||
| its five indexes + `UNIQUE(mbid)` | 395 MB | — |
|
||||
| its two FTS tables | 85 MB | 2,052,200 + 96,451 |
|
||||
| `recordings` | 38 MB (27 MB of it lyrics) | 26,778 |
|
||||
| `lyrics_index` | 18 MB | 24,294 |
|
||||
| `artist_metadata` | 12 MB | 7,673 |
|
||||
| `http_cache` | 9 MB | 2,930 |
|
||||
| `audio_files` | 5 MB | 25,966 |
|
||||
| everything else | < 10 MB | — |
|
||||
|
||||
The local library — the part that is *the user's* — is about 50 MB.
|
||||
The catalog and its indexes are 780 MB.
|
||||
|
||||
### Inside `explore_index`, half the bytes are three text columns
|
||||
|
||||
| column | bytes | note |
|
||||
|---|---|---|
|
||||
| `mbid` | 70 MB | 36-char text; 16 bytes as a blob |
|
||||
| `artist_mbid` | 70 MB | same, and it is a foreign key in disguise |
|
||||
| `caa_release_mbid` | 62 MB | same |
|
||||
| `entity_type` | 18 MB | three distinct values, stored as words |
|
||||
| `title` / `artist_name` / `release_name` | 74 MB | real data |
|
||||
|
||||
Five columns are declared, shipped in the artifact, selected in every
|
||||
query, and **empty**: `aliases` (0 rows), `sort_name` (0),
|
||||
`disambiguation` (0), `country` (69 rows of 2.05 M), `artist_type`
|
||||
(72). `aliases` is additionally a column in *both* FTS tables, so the
|
||||
tokenizer indexes nothing, twice.
|
||||
|
||||
### Two 50 MB indexes have a `WHERE` clause that excludes 0.3% of rows
|
||||
|
||||
`idx_explore_title_lower` (53 MB) and `idx_explore_artist_lower`
|
||||
(48 MB) are `WHERE popularity > 0`. 2,046,645 of 2,052,200 rows satisfy
|
||||
that. They are full indexes wearing a partial index's clothes, and they
|
||||
exist to serve one exact-match tier (`ExactMatches`,
|
||||
`searchindex.go:1298`) that the champion FTS — 96,451 rows, 2 MB —
|
||||
already covers the popular half of.
|
||||
|
||||
### The local library models many-to-many relationships that are all 1:1
|
||||
|
||||
| claim | measured |
|
||||
|---|---|
|
||||
| recordings with more than one file | **0** |
|
||||
| recordings in more than one release group | **0** |
|
||||
| artist credits with more than one artist | **3** of 2,823 |
|
||||
| files sharing a recording | **0** |
|
||||
|
||||
`recordings` (26,778) is one row per file. `release_group_recordings`
|
||||
(26,778) is one row per file. `artist_credit` (2,823) and
|
||||
`artist_credit_artist` (2,826) differ by three.
|
||||
|
||||
### …and it leaks rows that outlive the files
|
||||
|
||||
| orphan | count |
|
||||
|---|---|
|
||||
| `recordings` with no `audio_files` row | **812** (218 carry MBIDs) |
|
||||
| `release_groups` with no file underneath | **216** |
|
||||
| `artists` credited on no file | **260** |
|
||||
| `explore_index` rows flagged **`in_library` with no file behind them** | **129** recordings, 2 release groups, 1 artist |
|
||||
|
||||
That last row is the bug reported today, in the user's own data.
|
||||
|
||||
### The query surface
|
||||
|
||||
| surface | count |
|
||||
|---|---|
|
||||
| sqlc queries | 235 (7,850 generated Go lines) |
|
||||
| raw SQL call sites outside sqlc | 188 |
|
||||
| bound IPC methods | 272 |
|
||||
| `X` / `XByLibrary` query twins | 14 (8 of them exposed as separate bindings) |
|
||||
| copies of the "one row per file with its metadata" projection | **9**, plus the view that already defines it |
|
||||
|
||||
`mapTrackRow` takes **22 positional arguments** and is called from 9
|
||||
places, because each duplicated query generates its own row struct.
|
||||
|
||||
### The data directory is 8.5 GB — the database is the small part
|
||||
|
||||
| path | size | of which |
|
||||
|---|---|---|
|
||||
| `artist-images/` | 5.4 GB | **4,125 MB is candidate images no code path reads**; 1,222 MB is primaries + tiers for **5,770 artists** in a library with **1,301** |
|
||||
| `covers/` | 1.4 GB | **1,134 MB is originals**; all three rendered tiers together are 110 MB |
|
||||
| `ffmpeg/` | 283 MB | bundled binary |
|
||||
| `yj.db` | 1.0 GB | above |
|
||||
| `yj.db.bak` + `.bak.20260309` | 452 MB | nothing deletes these |
|
||||
| art caches (`cover-art-cache`, `artist-image-cache`) | 81 MB | catalog art, fine |
|
||||
|
||||
The 4.1 GB of unreachable artist candidates is the bug `CLAUDE.md`
|
||||
records as fixed; this install still carries it, so **the janitor jobs
|
||||
have never run here**. Worth confirming they run at all before
|
||||
declaring that one closed.
|
||||
|
||||
---
|
||||
|
||||
## The diagnosis
|
||||
|
||||
Everything below is downstream of one thing.
|
||||
|
||||
**There are three different notions of "a track" in this app, and the
|
||||
code keeps asking the wrong one.**
|
||||
|
||||
1. **A file** — a row in `audio_files`. The only thing that is
|
||||
unambiguously *yours*: it has a path, it plays.
|
||||
2. **A local entity** — a row in `recordings` / `release_groups` /
|
||||
`artists`. Created by a scan *from* a file, but with an independent
|
||||
lifetime: nothing deletes it when the file goes, and retagging a
|
||||
file **creates a new one and abandons the old**
|
||||
(`library.go:1722` repoints `audio_files.recording_id` at a fresh
|
||||
recording; `pruneOrphanedMetadata` only runs on the scan's
|
||||
*deleted-file* branch, `library.go:982`). This is where the 812
|
||||
orphans come from — and autotagging is the machine that makes them.
|
||||
3. **A catalog entity** — a row in `explore_index`, downloaded, global,
|
||||
identical for every user.
|
||||
|
||||
"Is this mine" is asked of **(2)** almost everywhere, and answered by
|
||||
**(1)** whenever the user actually does something:
|
||||
|
||||
- `LibraryMBIDIndex.CheckMBIDs` (`librarymbid.go:64`) is literally
|
||||
`SELECT mbid FROM recordings WHERE mbid IN (…)`. It sets `inLibrary`
|
||||
on every catalog tracklist.
|
||||
- `pruneStaleLocalCrossReferences` (`searchindex.go:2480`) clears
|
||||
`explore_index.in_library` when the **`recordings` row** disappears —
|
||||
not when the file does. Hence 129 phantom "you own this" rows.
|
||||
- `albumLibraryStatus()` in `explore-album-details.ts` ORs four claims
|
||||
of decreasing confidence, none of which is "a file exists".
|
||||
- But `GetFilePathsByRecordingMBIDs`, which every *action* goes
|
||||
through, joins `audio_files`. It is the only one that tells the
|
||||
truth.
|
||||
|
||||
So a retagged file leaves behind a recording carrying the **old** MBID;
|
||||
the catalog matches that MBID; the row renders owned, undimmed, with a
|
||||
Play button; and every action on it fails with "could not be found in
|
||||
your library" — on a fully-tagged library. The user's instinct that the
|
||||
check is fragile is correct, and the fragility is not the live lookup.
|
||||
**The live lookup is the only part that is right.**
|
||||
|
||||
The same confusion explains "files vs tracks" and "local vs remote":
|
||||
tables (2) exist to be a local mirror of the catalog's shape, so a
|
||||
"track" is sometimes a file, sometimes a mirror row, sometimes a
|
||||
catalog row, and the three are joined by MBID — a key that **two of the
|
||||
three can lack or lie about**.
|
||||
|
||||
---
|
||||
|
||||
## Findings and recommendations
|
||||
|
||||
### R1 — Ownership is "a file exists". Say it once, in SQL.
|
||||
|
||||
*Cheap, immediate, and it fixes the reported bug.*
|
||||
|
||||
- `CheckMBIDs`' `recordings` and `release_groups` branches gain a join
|
||||
to `audio_files`. (`artists` too, via credit.)
|
||||
- `pruneStaleLocalCrossReferences` tests for a file, not for a local
|
||||
row.
|
||||
- `pruneOrphanedMetadata` runs after the retag path as well as the
|
||||
delete path — or, better, is deleted along with the tables that need
|
||||
it (R2).
|
||||
- One-shot cleanup of the 812/216/260 existing orphans at open.
|
||||
|
||||
**Effect:** 129 lying rows in this library become honest; the class
|
||||
cannot recur while (2) exists.
|
||||
|
||||
### R2 — Collapse the MusicBrainz-shaped local schema into a file-shaped one
|
||||
|
||||
*The big one. It is what makes R1 structural rather than a patch.*
|
||||
|
||||
The local model imitates MusicBrainz's normalization — `artist_credit`
|
||||
is an MB concept — for a dataset in which **every relationship it
|
||||
models is 1:1** (measured above). The cost of that imitation:
|
||||
|
||||
- 5 tables (`recordings`, `release_group_recordings`, `artist_credit`,
|
||||
`artist_credit_artist`, `release_to_rg` — the last has **0 rows** and
|
||||
no schema-file writer) and ~12 indexes.
|
||||
- A 6-way join in every read, including a `MIN(release_group_id)`
|
||||
subquery repeated in **11 places** to undo a many-to-many that never
|
||||
happens, and a "first credited artist" subquery in **9** to undo
|
||||
another (the row-multiplication bug class documented at length in
|
||||
`CLAUDE.md`, which serves 3 rows).
|
||||
- An orphan-cleanup subsystem (`GetOrphaned*IDs` ×3, `Count*References`
|
||||
×2, `pruneOrphanedMetadata`) that exists only because these rows can
|
||||
outlive their file — and which does not actually work (812 orphans).
|
||||
- The entire phantom-ownership class above.
|
||||
|
||||
Proposed shape:
|
||||
|
||||
```
|
||||
audio_files id, path, library_id, …, title, track_no, disc_no, year,
|
||||
composer, comment, artist_credit TEXT, artist_id→artists,
|
||||
album_id→albums, recording_mbid, modified_at, …
|
||||
albums id, name, artist_id, mbid, year, original_year,
|
||||
cover_art_id, total_tracks… (genuinely many files→1)
|
||||
artists id, name, mbid (genuinely many→1)
|
||||
genres + file_genres (genuinely many↔many:
|
||||
107k rows / 26k files)
|
||||
```
|
||||
|
||||
`artist_credit` survives as **text on the file** (display: "A feat.
|
||||
B") plus `artist_id` (the primary artist, for grouping) — which is
|
||||
everything the UI does with it today, minus the join that multiplies
|
||||
rows.
|
||||
|
||||
**Effect:** a row exists iff a file exists, so R1 becomes a foreign key
|
||||
rather than a rule anyone can forget. Removes 5 tables, ~12 indexes,
|
||||
~30 sqlc queries, the orphan subsystem, both repeated subqueries, and
|
||||
the `AUTOMATIC COVERING INDEX` SQLite builds on every library load.
|
||||
Estimated −1,500 to −2,500 lines across `backend/library`,
|
||||
`backend/database/sql/*` and `sqlcgen`.
|
||||
|
||||
**Cost:** one real migration of user data (not an `ADD COLUMN`), and it
|
||||
touches autotag, tagwriter, playlist matching and the explore xref.
|
||||
This is the item to sequence carefully; everything else is independent
|
||||
of it.
|
||||
|
||||
### R3 — One projection, one row type, one mapper
|
||||
|
||||
`track_metadata` (the view) already *is* the canonical "one row per
|
||||
file" definition, and **only the raw-SQL search paths use it**
|
||||
(`search.go`, `lyrics_search.go`). Every sqlc query re-implements it —
|
||||
9 copies, which have already drifted: the view prefers
|
||||
`rg.original_year` for `year`, `GetAllTracksWithFullMetadata` uses
|
||||
`r.year`. The same library shows a different year depending on which
|
||||
screen you are on.
|
||||
|
||||
**Verified, not assumed:** sqlc generates cleanly against the view —
|
||||
`SELECT * FROM track_metadata WHERE …` yields one `TrackMetadatum`
|
||||
struct with correct types (run during this audit).
|
||||
|
||||
And the 14 `X`/`XByLibrary` twins collapse into one query each:
|
||||
|
||||
```sql
|
||||
WHERE (CAST(sqlc.arg(library_id) AS INTEGER) = 0
|
||||
OR library_id = CAST(sqlc.arg(library_id) AS INTEGER))
|
||||
```
|
||||
|
||||
**Measured cost of the collapse: none.** Scoped-with-OR 23 ms, scoped
|
||||
direct 21 ms, unscoped 145 ms over the full 26k rows.
|
||||
|
||||
**Effect:** −14 queries, −8 bindings, −8 frontend branches, 9 row
|
||||
structs → 1, 9 call sites of a 22-argument mapper → 1. Roughly −2,000
|
||||
generated lines and −300 hand-written ones, and the year inconsistency
|
||||
cannot exist.
|
||||
|
||||
### R4 — Put `explore_index` on a diet (~200 MB, no feature loss)
|
||||
|
||||
| change | saved |
|
||||
|---|---|
|
||||
| `mbid`, `artist_mbid`, `caa_release_mbid` as 16-byte blobs | ~110 MB in the table |
|
||||
| …and the same keys in `UNIQUE(mbid)` (99 MB) and `idx_explore_index_artist_mbid` (131 MB) | ~70–100 MB |
|
||||
| `entity_type` → INTEGER | 18 MB + index |
|
||||
| drop `aliases`, `sort_name`, `disambiguation` (0 rows); reconsider `country`/`artist_type` (69/72 rows) | small bytes, real clarity — and one fewer empty FTS column |
|
||||
| make the two `LOWER()` indexes' partial predicate *mean* something (`popularity >= championPopThreshold OR in_library`), or retire the tier onto the champion FTS | up to 101 MB |
|
||||
|
||||
Better still for `artist_mbid`: it is a foreign key spelled as text.
|
||||
An integer reference to the artist row is 8 bytes instead of 36 and
|
||||
makes the 131 MB index a fraction of its size.
|
||||
|
||||
**Also worth separating:** `in_library`, `local_*_id`, `is_similar` and
|
||||
`discog_fetched` are *personalization* stored inside the *shipped
|
||||
catalog* table, which is why the artifact import has to merge by
|
||||
explicit column list and why `artist_enrichment` had to become its own
|
||||
table for exactly this reason. Measured: `in_library` and
|
||||
`local_*_id IS NOT NULL` agree on **every one of 2,052,200 rows** —
|
||||
they are the same fact stored twice. A `library_xref(mbid, kind,
|
||||
local_id)` side table would make the catalog table purely the artifact
|
||||
and delete the merge-by-column-list rule.
|
||||
|
||||
### R5 — Ask the network less, without a bigger install
|
||||
|
||||
Present state (from `musicbrainz.go:17-27`): search 24 h, **entity 7
|
||||
days**, releases 90 days. MusicBrainz entity data changes on the order
|
||||
of *never* for the fields we read, and 251 of 2,930 cache rows are
|
||||
already expired on this install — so a fully-populated artist page
|
||||
re-fetches itself weekly, forever.
|
||||
|
||||
- **Raise `cacheTTLEntity` to a year** (or drop expiry and revalidate
|
||||
in the background). Cost: bytes already stored. Benefit: the
|
||||
steady-state network cost of browsing your own library goes to
|
||||
roughly zero.
|
||||
- **Ship a per-release-group `total_tracks` in the artifact.** 010
|
||||
correctly rejects shipping *tracklists* (the per-artist track budget
|
||||
would truncate them, and "Play 7 of 9" for a twelve-track album is a
|
||||
confident lie). But the **denominator** is one small integer per
|
||||
release group — 400,677 rows, ~2 bytes — and it is exactly what
|
||||
`albumLibraryStatus`/`ownership()` needs to say complete /
|
||||
incomplete / unknown for a catalog album with no local tags. Tiny,
|
||||
honest, and it does not depend on coverage.
|
||||
- **Keep 010's per-user backfill** for the tracklists themselves; this
|
||||
does not replace it, it shrinks what it has to cover.
|
||||
- `http_cache` has no size bound and no vacuum beyond expiry. Give it a
|
||||
ceiling.
|
||||
|
||||
### R6 — The 5.3 GB on disk that no feature needs
|
||||
|
||||
- **4,125 MB of artist candidate images** that nothing reads (the
|
||||
documented bug — but the janitors have not run on this install;
|
||||
verify they run at all).
|
||||
- Artist images exist for **5,770 artists** in a **1,301-artist**
|
||||
library. Fetching art for artists you do not own is the same
|
||||
"prefetch everything" instinct as the discography backfill 011
|
||||
corrected.
|
||||
- **1,134 MB of cover originals** versus 110 MB for all three rendered
|
||||
tiers. Nothing renders the original; and it is re-derivable from the
|
||||
audio file itself, which is on disk by definition. Keep `_lg` as the
|
||||
largest and drop originals — that is 1.1 GB with no visible change.
|
||||
- `yj.db.bak` (394 MB) and `yj.db.bak.20260309` (58 MB) accumulate with
|
||||
nothing to clean them.
|
||||
|
||||
This is the largest single win available and it does not touch the
|
||||
schema.
|
||||
|
||||
### R7 — Redundant indexes and dead columns
|
||||
|
||||
Five indexes are prefixes of an existing UNIQUE/PK and can be dropped
|
||||
outright (they cost write time on every insert):
|
||||
|
||||
`idx_recording_genres_recording_id` ⊂ `UNIQUE(recording_id, genre_id)` ·
|
||||
`idx_similar_artist_map_source` ⊂ `PK(source, similar)` ·
|
||||
`idx_artist_credit_artist_artist_id` ⊂ `UNIQUE(artist_id, credit_id)` ·
|
||||
`idx_artist_metadata_mbid` ⊂ `PK(mbid, source)` ·
|
||||
`idx_artist_images_mbid` ⊂ `UNIQUE(artist_mbid, source, source_url)`.
|
||||
|
||||
Dead data:
|
||||
|
||||
- **`recordings.genre`** — populated on 25,619 rows at every scan and
|
||||
**read by nothing**. Every genre read goes through
|
||||
`recording_genres` + `genres`. Write-only column.
|
||||
- **`release_groups.total_tracks` / `total_discs`** — 0 rows populated;
|
||||
the feature that needed them put the number on
|
||||
`release_group_recordings` instead.
|
||||
- **`release_to_rg`** — 0 rows, no writer in any schema file.
|
||||
- `libraries.sql` carries a doc comment about `download_requests`,
|
||||
pasted from another file. Small, but it is the kind of drift the
|
||||
two-file schema rule exists to catch.
|
||||
|
||||
### R8 — One genuine N+1
|
||||
|
||||
`mixCandidates` (`explore/mix.go:181`) issues
|
||||
`GetGenreNamesByFilePath` **per candidate path**, inside a loop over
|
||||
similar artists, inside a loop over seed artists. Twenty seeds × twenty
|
||||
similar × thirty paths is 12,000 single-row queries for one mix. It is
|
||||
one query with an `IN` clause, or one query for the whole weighted set.
|
||||
(`mixSeedProfile` above it is the same shape, bounded by seed size.)
|
||||
|
||||
Nothing else in the tree matches this pattern — a scan of every query
|
||||
issued inside a loop turned up 72 candidates and this is the only real
|
||||
one.
|
||||
|
||||
### R9 — The IPC surface has internals in it
|
||||
|
||||
Bound and reachable from the frontend today: `AcquirePipelineLock`,
|
||||
`ReleasePipelineLock`, `SetJobRegistry`, `SetScanHooks`,
|
||||
`SetRescanHooks`, `SetRemovalHooks`, `MusicBrainz`, `CAALimiter`,
|
||||
`PopulateLocalCrossReferences`. v3's generator binds every exported
|
||||
method; these want to be unexported or moved off the service type.
|
||||
Free lines, and one less way to wedge the app from a console.
|
||||
|
||||
### R10 — The test DB is not the shape production runs
|
||||
|
||||
`NewTestDB` shares one in-memory connection and leaves `readDB` nil, so
|
||||
`reader()` returns the writer. That is why the read-pool write bug
|
||||
(documented in `CLAUDE.md`) reached a user, and why
|
||||
`TestNoWritesOnTheReadPool` had to be a tree-walk instead of a test.
|
||||
Giving the test DB two handles over one shared in-memory file would let
|
||||
that be an ordinary test.
|
||||
|
||||
---
|
||||
|
||||
## What I recommend leaving alone
|
||||
|
||||
- **The download subsystem** (requests / downloads / items). Three
|
||||
tables, clean lifetimes, well argued in the schema comments. The
|
||||
`download_wants` table in this install is the pre-rename name; the
|
||||
rename migration will clear it on next launch.
|
||||
- **The champion FTS.** 96k rows, 2 MB, a real latency tier.
|
||||
- **The dual write/read handle**, WAL, and the persist-writer queues.
|
||||
These are recent, measured, and correct.
|
||||
- **File paths as the frontend's identity for a track.** Integer ids
|
||||
would be cheaper over IPC, but `CLAUDE.md`'s argument (an index goes
|
||||
stale on re-sort/refilter, a path does not) is right, and the cost is
|
||||
bounded.
|
||||
- **Storing lyrics locally** (27 MB + 18 MB index for 24k tracks). That
|
||||
is the API-avoidance trade working exactly as intended.
|
||||
|
||||
---
|
||||
|
||||
## What landed (2026-08-15 / 16)
|
||||
|
||||
### The third pass: the album page, which is where the report came from
|
||||
|
||||
The audit started from a user report — a fully-tagged library saying
|
||||
"not in your library", on hover rather than on click — and R1 fixed the
|
||||
half of that which lives in SQL. The other half was the page: ownership
|
||||
was four claims OR'd into a tick, and the context menu asked the backend
|
||||
per row, as the menu opened.
|
||||
|
||||
`explore-album-details` now resolves the displayed tracklist's file
|
||||
paths **once**, from `updated()`, into one `filePaths` map that the
|
||||
badge, the Play count, the dimmed rows and every menu item read. The
|
||||
synthesised local tracks carry their own `FilePath`, so a library album
|
||||
costs no lookup at all; a catalog tracklist costs one batched
|
||||
`GetFilePathsByRecordingMBIDs`. `catalogScope()` no longer returns
|
||||
`'library'` here — that was the second complaint in the same report, and
|
||||
the artist page keeps it because a library-only *artist* really is
|
||||
missing sections.
|
||||
|
||||
Two bugs fell out of doing it this way, and neither is the one that was
|
||||
reported:
|
||||
|
||||
- The render loop. Guarding the lookup on `filePaths` (answered) rather
|
||||
than on `askedFor` (asked) re-requests every *unowned* MBID forever,
|
||||
because an unowned MBID never lands in the map.
|
||||
- "No release data available" over a tracklist held in memory.
|
||||
`loadLocalTracks` rebuilt the version list only when catalog releases
|
||||
existed, but the "Your Library" entry is synthesised *from* the local
|
||||
tracks — so the no-releases case was the one case it skipped. Nothing
|
||||
caught it because the old ownership check answered from the local
|
||||
album id and never needed the tracklist to exist.
|
||||
|
||||
### The second pass: R5–R10
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| the two exact-match indexes | 101 MB | **3 MB** (predicate narrowed to the champion set; plan unchanged, measured) |
|
||||
| cover art on disk | original + 3 tiers | **3 tiers** — 1,134 MB of a 1.4 GB directory was the original, and nothing rendered it |
|
||||
| browsed artist art | 90-day expiry, no ceiling | expiry **plus a 256 MB budget**, oldest evicted first; owned artists never in it |
|
||||
| MusicBrainz entity TTL | 7 days | **1 year**, with a 128 MB ceiling on the response cache |
|
||||
| redundant indexes | 5 | **0** (3 dropped here, 2 went with their tables) |
|
||||
| internal methods on the IPC surface | 24 | **0** (`//wails:ignore`; 272 → 248 bound methods) |
|
||||
| test DB | one handle, `readDB` nil | **two handles**, the shape production runs |
|
||||
|
||||
The catalog line is R4, finished the day after: MBIDs stored as 16 raw
|
||||
bytes and entity types as codes, measured by converting the real
|
||||
2,052,200-row catalog through the shipped schema. It needed no artifact
|
||||
rebuild — the importer asks the artifact which encoding it carries and
|
||||
converts the older text form on the way in. Plan 014 has the detail.
|
||||
|
||||
Two of those repaid immediately. Giving the test database its own
|
||||
read pool **caught three tests writing through it** on the first run —
|
||||
the exact bug class that reached a user as "attempt to write a readonly
|
||||
database" and that `TestNoWritesOnTheReadPool` had to walk the source
|
||||
tree to find. And the artist-image sweep's own test turned out to seed
|
||||
an `artists` row with no file and call it owned: the phantom this whole
|
||||
audit is about, sitting in the fixture of the test that guards it.
|
||||
|
||||
**One finding in this audit was wrong.** `aliases`, `sort_name`,
|
||||
`disambiguation`, `country` and `artist_type` are not dead columns. They
|
||||
are empty on that install because the artist-enrichment pass had barely
|
||||
run (which is finding 011's subject), but `indexOneArtist` writes all
|
||||
five, and `aliases` is an FTS column that makes an artist findable by
|
||||
alias. They stay.
|
||||
|
||||
### The first pass: R2, carrying R1 and R3
|
||||
|
||||
R2 shipped with R1 and R3 inside it, because the collapse made them
|
||||
free rather than separate work. No migration: fresh installs only, by
|
||||
the user's decision, so `sql/migrations/` went with it.
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| local tables | 9 | 5 (`audio_files`, `albums`, `artists`, `genres`, `file_genres`) |
|
||||
| sqlc queries | 235 | 185 |
|
||||
| generated Go | 7,850 | 6,023 |
|
||||
| bound IPC methods | 272 | 264 |
|
||||
| copies of the track projection | 9 + the view | the view |
|
||||
| `X`/`XByLibrary` query twins | 14 | 0 |
|
||||
| migration files + runner | 7 + ~120 lines | 0 |
|
||||
| **net** | | **−5,070 lines** across 122 files |
|
||||
|
||||
Gone: `recordings`, `release_group_recordings`, `artist_credit`,
|
||||
`artist_credit_artist`, `pruneOrphanedMetadata`'s four sweeps,
|
||||
`RemoveLibrary`'s eight, `mapTrackRow`'s 22 positional arguments, and
|
||||
340 lines of `tagwriter/dbsync.go` that existed to relink and then
|
||||
un-orphan those tables.
|
||||
|
||||
Ownership is now a file in every one of the places that used to ask a
|
||||
metadata table: `CheckMBIDs`, `collectLibraryEntities`,
|
||||
`pruneStaleLocalCrossReferences` and `GetFilePathsByRecordingMBIDs`.
|
||||
|
||||
Three things found on the way, each written down where it can be hit
|
||||
again (`CLAUDE.md`, `references/schema-change.md`):
|
||||
|
||||
- **sqlc's parameter rewriter is byte-offset based**, so one em dash in
|
||||
a *query* comment corrupts generation into `SELECid`.
|
||||
- **`sqlc.slice` and `sqlc.arg` do not compose** — slice expansion
|
||||
renumbers, so `GetFilePathsByAlbums([1,2], 0)` read album id 2 as the
|
||||
library id. Caught by a test, not by a type.
|
||||
- **`release_to_rg` looked dead and was not**: 0 rows on any ordinary
|
||||
install, because only a local `indexbuild` fills it, and the daily
|
||||
incremental refresh reads it. Restored.
|
||||
|
||||
Verified: `make lint` (3 configurations), `go test ./...` plus the
|
||||
`indexbuild` and `dev` tag passes, `tsc --noEmit`, `make ui-test`
|
||||
(768), and a new end-to-end test that scans the real fixture library
|
||||
and asserts no row outlives its file
|
||||
(`TestScan_FixtureLibraryLeavesNothingBehind`).
|
||||
|
||||
---
|
||||
|
||||
## Sequence
|
||||
|
||||
**Revised 2026-08-15, after the compatibility constraint was lifted:**
|
||||
breaking changes are acceptable and the schema may be squashed. That
|
||||
inverts the order — R2 was last only because of the migration, and it
|
||||
*subsumes* R1 (ownership becomes a foreign key) and reshapes R3 (the
|
||||
projection is defined over the new tables). Doing R1 and R3 against the
|
||||
old shape first would be work thrown away.
|
||||
|
||||
1. **R2** — the schema collapse, with the rebuild below. It carries R1
|
||||
and R3 with it.
|
||||
2. **R6** — reclaim the 5.3 GB on disk; confirm the janitors run.
|
||||
3. **R7 / R9 / R8 / R10** — the small correctness and hygiene items.
|
||||
4. **R4** — the `explore_index` diet. Artifact rebuild + format bump.
|
||||
5. **R5** — cache TTLs (trivial) and the shipped denominator (rides
|
||||
along with R4's artifact change).
|
||||
|
||||
### "Break everything" has a floor, and it is not the schema
|
||||
|
||||
Reshaping tables freely is fine. **Dropping the database is not**, and
|
||||
the numbers say so — a wipe-and-rescan would destroy:
|
||||
|
||||
| | count | why a rescan does not restore it |
|
||||
|---|---|---|
|
||||
| files marked `user_confirmed` | **25,014** | the user's autotag review decisions |
|
||||
| reviewed tagging folders (`confirmed`/`skipped`) | **2,109** | ditto, plus every `skipped` becomes pending again |
|
||||
| rows in `recordings.lyrics` | **24,294** | an unknown share came from **LRCLIB**, not from tags — re-fetching them is precisely the API traffic we are trying to avoid |
|
||||
| playlists / playlist tracks | 22 / 1,917 | `Authored`; nothing else has them |
|
||||
|
||||
So the change ships as a **one-shot in-place rebuild**: create the new
|
||||
tables, `INSERT … SELECT` across, drop the old ones, in a single
|
||||
transaction at open. Seconds on 26k rows, ~40 lines of SQL, no
|
||||
migration *chain* and no rollback path — which is the freedom that was
|
||||
actually being asked for. `sql/migrations/` gets squashed into
|
||||
`sql/schemas/` at the same time (`NOTES.md` already blesses this
|
||||
pre-1.0).
|
||||
|
||||
### Two tables are classified as one Kind and hold another
|
||||
|
||||
`backend/datamap` already encodes what is safe to lose (`Owned` and
|
||||
`Derived` rebuild from the files; `Cache` is expensive; `Authored` is
|
||||
irreplaceable). The audit found two places where the *column* disagrees
|
||||
with the *table's* entry, which is exactly why a wipe looked cheaper
|
||||
than it is:
|
||||
|
||||
- **`audio_files.tag_status`** — the table is `Owned` (a projection of
|
||||
the files), but `user_confirmed` / `user_skipped_permanent` are
|
||||
**`Authored`**: a decision the user made that exists nowhere else.
|
||||
- **`recordings.lyrics`** — the table is `Owned`, but lyrics fetched by
|
||||
the LRCLIB backfill are **`Cache`**, and nothing records which of the
|
||||
24,294 rows came from a tag and which from the network.
|
||||
|
||||
The new schema fixes both by construction: lyrics move to their own
|
||||
MBID-keyed table with a `source` column (so they survive any rebuild of
|
||||
the owned tables, and the provenance question becomes answerable), and
|
||||
`tag_status`' authored values are carried across explicitly rather than
|
||||
recomputed.
|
||||
|
||||
**Expected outcome if all of it lands:** database ~1.0 GB → ~0.75 GB,
|
||||
data directory 8.5 GB → ~2.5 GB, sqlc queries 235 → ~180, generated Go
|
||||
7,850 → ~5,000, bound methods 272 → ~255, and — the part that matters —
|
||||
one definition of "this is mine" that a file either satisfies or does
|
||||
not.
|
||||
|
||||
## The open questions, answered
|
||||
|
||||
1. **R2's migration** — the user's call, and it was "just assume this
|
||||
new version will only be installed by a new user". So there is no
|
||||
in-place rebuild and no chain: `sql/schemas/` is the whole
|
||||
description. An existing `YJ_HOME` does not open (its `audio_files`
|
||||
has `recording_id` and none of the tag columns, and
|
||||
`CREATE TABLE IF NOT EXISTS` cannot add them) — delete and rescan,
|
||||
and rebuild any seed with `make sandbox-seed`.
|
||||
2. **R4's artifact format** — no break was needed. The importer asks
|
||||
the artifact what it carries rather than trusting a version, so the
|
||||
published text-form artifact still imports. Plan 014 has it.
|
||||
3. **Yes, the janitors run.** `Runner.Start` calls `RunDue` immediately
|
||||
and `lastRun` is in-memory, so every launch runs everything due.
|
||||
The 4.1 GB survived because `OrphanedArtistImagesJob` joined a bare
|
||||
MBID onto a *sharded* directory — deleting the rows and leaving the
|
||||
files, which is worse than not running — and because
|
||||
`StrayArtistImageFilesJob` did not exist. Both are fixed; it was a
|
||||
bug report, not a cleanup.
|
||||
|
||||
## Measured on the finished refactor
|
||||
|
||||
| | expected | actual |
|
||||
|---|---|---|
|
||||
| sqlc queries | ~180 | **185** |
|
||||
| generated Go | ~5,000 | **6,024** |
|
||||
| bound methods | ~255 | **248** |
|
||||
| `explore_index` + indexes | — | **780 MB → 405 MB** |
|
||||
|
||||
## The one recommendation not taken
|
||||
|
||||
R4's "better still" for `artist_mbid`: an integer reference to the
|
||||
artist row (8 bytes) rather than the 16 raw bytes it now stores. It is
|
||||
a further ~30 MB on `idx_explore_index_artist_mbid`, and the reason to
|
||||
stop short is that the *artifact* carries MBIDs and not local ids, so
|
||||
the import would have to resolve every row against a table it is in the
|
||||
middle of filling. Worth its own argument, not a footnote to this one.
|
||||
@@ -0,0 +1,99 @@
|
||||
# 014 — The catalog's compact encoding, and the denominator it owed
|
||||
|
||||
**Status:** **complete** (2026-08-16). The encoding landed first; the
|
||||
per-release-group `total_tracks` denominator landed with the album page
|
||||
that spends it.
|
||||
**Branch:** none
|
||||
**Created:** 2026-08-16
|
||||
**Depends on:** nothing
|
||||
**Related:** 013 (the database audit, which measured all of this), 010
|
||||
(owned albums offline), 001 (ship core index)
|
||||
|
||||
---
|
||||
|
||||
## The encoding
|
||||
|
||||
Measured on the real 2,052,200-row catalog, converting it through the
|
||||
shipped schema (not a projection):
|
||||
|
||||
| object | before | after |
|
||||
|---|---|---|
|
||||
| `explore_index` | 383 MB | **242 MB** |
|
||||
| `idx_explore_index_artist_mbid` | 131 MB | **65 MB** |
|
||||
| `UNIQUE(mbid)` | 99 MB | **54 MB** |
|
||||
| `idx_explore_index_entity_pop` | 47 MB | **28 MB** |
|
||||
| `idx_explore_caa_release` | 17 MB | **11 MB** |
|
||||
| the two `LOWER()` indexes | 101 MB | **3 MB** (013) |
|
||||
| **total** | **780 MB** | **405 MB** |
|
||||
|
||||
Every row converted with the `CHECK` constraints live, which is also a
|
||||
result: no MBID in a real 2 M-row catalog is malformed.
|
||||
|
||||
**No format bump, and no rebuilt artifact needed.** The importer asks
|
||||
the artifact what encoding it carries (`typeof(mbid)`) and converts on
|
||||
the way in if it is the old text form, so the artifact already
|
||||
published keeps working and the exporter switches whenever CI next
|
||||
runs. That is strictly better than the version negotiation this plan
|
||||
originally proposed.
|
||||
|
||||
The silent-failure risk the plan was written around was handled by
|
||||
making the failure loud instead of by avoiding the change: a `CHECK` on
|
||||
the column turns a stringly write into an error at the insert, the
|
||||
22-column projection became one constant and one scanner instead of
|
||||
four copies, and `TestStoredEncodingRoundTrips` sweeps every read path
|
||||
in the package. It found one real bug on its first run — the artifact
|
||||
probe was asking the read pool, where the attached artifact does not
|
||||
exist.
|
||||
|
||||
## The denominator
|
||||
|
||||
`total_tracks` on `explore_index`, ~2 bytes across 400,677 release
|
||||
groups. It makes "do I have all of this" answerable offline for an
|
||||
album whose **files declared no total**, which is a great deal of any
|
||||
untagged library and the one thing `GetAlbumCompleteness` cannot answer
|
||||
from tags. 010 rightly rejected shipping whole tracklists — the
|
||||
per-artist track budget truncates them, and a truncated tracklist is a
|
||||
confident lie about which tracks exist. A denominator has no such
|
||||
problem, and the album page spends it as one: the numerator stays
|
||||
local (distinct track numbers on disk), only the denominator is
|
||||
borrowed, and only where the tags have none.
|
||||
|
||||
Four things about it are load-bearing.
|
||||
|
||||
**It is counted before the popularity filter.** `cmd/indexbuild` counts
|
||||
the canonical dump's rows per kept release, which is that release's
|
||||
track count because the dump carries one row per recording per
|
||||
canonical release. Counting the *kept* recordings instead would say
|
||||
"9" about a twelve-track album whose other three nobody has played —
|
||||
worse than saying nothing, and the same class of lie as the truncated
|
||||
tracklist. `TestDumpImportEndToEnd` has an unplayed track on a fixture
|
||||
album for exactly this: three tracks in the total, two indexed as
|
||||
recordings.
|
||||
|
||||
**Zero means "the catalog does not say"**, which is the same third
|
||||
state the local answer already has. An album neither side can total
|
||||
wears no ring rather than a wrong one.
|
||||
|
||||
**Adding a column to the importer's SELECT is how you break every
|
||||
artifact already published.** `artifactHasTotals()` asks the attached
|
||||
artifact whether the column exists, the same way and on the same handle
|
||||
as `artifactStoresText()`, and selects a literal `0` when it does not.
|
||||
Verified by forcing the probe true: the older shape then fails with
|
||||
`no such column: total_tracks`, which is what a shipped build would
|
||||
have done to a file nobody can re-cut retroactively.
|
||||
|
||||
**A test seeder that binds the upsert's parameters by hand is not
|
||||
"breaking where the app breaks".** Three of them did, on the argument
|
||||
that a schema change should fail the tests in the same place — and what
|
||||
it actually produced was `missing argument with index 25`, three files
|
||||
at a time, for a column none of them cares about. They go through
|
||||
`upsertBatch` now, which is the one writer, and keep the property they
|
||||
wanted: a field written to the wrong column still fails there.
|
||||
|
||||
## Done when
|
||||
|
||||
- [x] `GetAlbumCompleteness`'s gap is answerable for a catalog album the
|
||||
library has no tags for, with no network call.
|
||||
- [x] The artifact grows by less than a megabyte (~800 kB at 400,677
|
||||
release groups).
|
||||
- [x] An artifact published before the column still imports.
|
||||
@@ -0,0 +1,385 @@
|
||||
# 015 — Android release pipeline
|
||||
|
||||
> **Completed.** The pipeline ships a signed APK from CI on every `v*` tag; `docs/android-release.md` is its operating document.
|
||||
|
||||
Ship an Android APK from CI on every version tag, published to the Gitea
|
||||
generic package registry so Obtainium can poll a plain URL.
|
||||
|
||||
The baseline is `~/Development/ljos`, whose `.gitea/workflows/ci.yml`
|
||||
`android:` job has been through the failure modes already. Most of what
|
||||
follows is a transcription of that job onto this repo's conventions;
|
||||
where it differs, the difference is argued.
|
||||
|
||||
## What this is not
|
||||
|
||||
**This ships a pipeline, not a usable Android music player.** The
|
||||
success criterion is a signed, installable APK that launches — not an
|
||||
app anyone would want. Explicitly out of scope, and each is real:
|
||||
|
||||
- `backend/mediacontrols/mpris_linux.go` **will be compiled on Android**.
|
||||
Go's `android` GOOS implies the `linux` build tag, so the `//go:build
|
||||
linux` file is in the build and MPRIS will look for a session bus that
|
||||
does not exist. It compiles; it will error at runtime.
|
||||
- `backend/system` resolves XDG paths. Android has no XDG.
|
||||
- The explore catalog artifact is ~0.6 GB. Nothing on a phone wants that.
|
||||
- The shell is a desktop shell: an eleven-item sidebar, a 800×600
|
||||
measured minimum, a transport bar. None of that is a phone layout.
|
||||
- The library scanner walks a filesystem Android does not grant.
|
||||
|
||||
Those are the *next* plan, if there is one. Conflating them with this one
|
||||
is how a build pipeline takes six weeks.
|
||||
|
||||
## Phase 0 — the gate [DONE 2026-08-16]
|
||||
|
||||
**Passed, further than asked.** No source changes were needed; a full
|
||||
27 MB fat APK built first try, both ABIs, production-stripped. Numbers,
|
||||
the environment and four non-obvious findings are in
|
||||
`.planning/NOTES.md` — including a scaffold bug that put a *debug*
|
||||
library in the release APK's phone ABI, fixed here.
|
||||
|
||||
**It also installs and launches on an emulator, and then exits.** One
|
||||
line stops it: `backend/system/buildUserDirPath` switches on
|
||||
`runtime.GOOS` and Android takes the `default:` branch returning
|
||||
`errUnsupportedOS`, so `main()` hits `os.Exit(1)` six milliseconds
|
||||
after the JNI bridge comes up. That is the *first* thing that stops it,
|
||||
not the only one — see the "not this" section above, all of which is
|
||||
still true and still out of scope.
|
||||
|
||||
The emulator tier that found it is now part of the harness:
|
||||
`scripts/android-emulator.sh`, the `make android-*` targets, and
|
||||
`.pi/skills/yellowjacket-dev/references/android-tier.md`. It exists
|
||||
because the failure is invisible in all three places anyone would look
|
||||
(no panic, no tombstone, no crash buffer) and ActivityManager restarts
|
||||
the app fast enough that `pidof` always answers — so the tier's
|
||||
assertion is "same pid after N seconds", not "it started".
|
||||
|
||||
Original phase 0 text follows, kept because its reasoning is what the
|
||||
later phases rest on.
|
||||
|
||||
|
||||
Everything downstream is wasted if the c-shared link fails. Establish it
|
||||
by hand, locally, before writing a line of YAML.
|
||||
|
||||
Already established, by probe rather than by assumption:
|
||||
|
||||
```
|
||||
GOOS=android GOARCH=arm64 CGO_ENABLED=0 go build ./backend/... ./internal/...
|
||||
```
|
||||
|
||||
compiles the entire tree. Exactly two packages fail, and both fail only
|
||||
because their Android implementation is cgo:
|
||||
|
||||
- `ebitengine/oto/v3` — `driver_android.go` needs the bundled **oboe**
|
||||
C++ backend. Oto supports Android natively; there is no Java audio
|
||||
glue to write.
|
||||
- `wails/v3/pkg/application` — `mobile_features_android.go` needs the
|
||||
JNI bridge.
|
||||
|
||||
`modernc.org/sqlite` (the whole database layer), `beep`, `godbus` and
|
||||
every `backend/` package are clean. **No source changes are known to be
|
||||
required**, which is the single most surprising finding here and the
|
||||
reason this plan is worth doing at all.
|
||||
|
||||
What Phase 0 must actually verify:
|
||||
|
||||
1. Install NDK **r26d** (`26.3.11579264`) locally. Pinned, not "whatever
|
||||
sdkmanager gives you" — ljos's AGENTS.md records newer NDKs breaking
|
||||
this build.
|
||||
2. Generate the scaffolding (Phase 1) and run
|
||||
`wails3 task android:compile:go:shared ARCH=arm64` by hand.
|
||||
3. Confirm `build/android/app/src/main/jniLibs/arm64-v8a/libwails.so`
|
||||
exists and is an ARM64 shared object.
|
||||
4. Repeat for `amd64` (the emulator ABI).
|
||||
|
||||
**If the link fails, stop and re-plan.** The likely culprits, in order:
|
||||
alsa (oto must select oboe, not ALSA — if it reaches for `alsa.pc` the
|
||||
build tags are wrong), and `main.go`'s `//go:embed all:frontend/dist`
|
||||
combined with the generated `main_android.gen.go` overlay.
|
||||
|
||||
Deliverable: a note in `.planning/NOTES.md` recording the exact command
|
||||
and the NDK version that produced a `.so`, or the reason it cannot.
|
||||
|
||||
## Phase 1 — un-ignore and commit the Android scaffolding [DONE]
|
||||
|
||||
Done as a side-effect of phase 0, which could not run without it. One
|
||||
correction to the text below: **step 1 is wrong.** `update
|
||||
build-assets` does not generate the android tree (NOTES.md explains);
|
||||
it was generated with `generate build-assets` into a scratch dir and
|
||||
`android/` copied across. CLAUDE.md is corrected to match. Steps 2-5
|
||||
were done as written.
|
||||
|
||||
|
||||
`build/android/` is gitignored (`.gitignore:72`) and its `includes:`
|
||||
entry was dropped from `Taskfile.yml` during plan 009. That was correct
|
||||
when nothing could target Android and is what has to be undone.
|
||||
|
||||
1. `wails3 task common:update:build-assets` — beta.8 embeds
|
||||
`internal/commands/build_assets/android/`, so this generates the tree.
|
||||
2. Remove `build/android/` from `.gitignore`; add `build/ios/`'s reason
|
||||
to a comment so the asymmetry is explained rather than looking like an
|
||||
oversight.
|
||||
3. Add `android: ./build/android/Taskfile.yml` to `Taskfile.yml`'s
|
||||
`includes:`.
|
||||
4. **Gitignore the tree's own output**, or the repo grows a few hundred
|
||||
Gradle intermediates. ljos has exactly this problem — its
|
||||
`app/build/android/app/build/**` is committed. Ignore:
|
||||
- `build/android/app/build/`
|
||||
- `build/android/app/src/main/jniLibs/`
|
||||
- `build/android/overlay.json` and `build/android/gen/`
|
||||
5. `make build-prod` and `make test` still pass — the new include must
|
||||
not perturb the desktop path.
|
||||
|
||||
**The refresh hazard has to be written down.** CLAUDE.md's Packaging
|
||||
section already says `build/`'s platform metadata is regenerated from
|
||||
`build/config.yml` and hand edits are lost. Phase 2 edits `build.gradle`
|
||||
by hand. Extend that paragraph to name `build/android/app/build.gradle`
|
||||
specifically, because the loss is silent and the symptom (a debug-signed
|
||||
APK) appears months later as a failed update.
|
||||
|
||||
## Phase 2 — make the APK identifiable and updatable [DONE 2026-08-16]
|
||||
|
||||
**Narrower than planned, because beta.8's scaffold is ahead of ljos's
|
||||
beta.3: the release signing config already exists** and reads the four
|
||||
`ANDROID_KEYSTORE_*` variables with a debug-keystore fallback. So this
|
||||
phase was identity and versioning only. Verified end to end:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| package | `app.yellowjacket` (was `com.wails.app`) |
|
||||
| versionCode / versionName | `10301` / `1.3.1`, from `YJ_VERSION_CODE` / `YJ_VERSION` |
|
||||
| label | `YellowJacket` |
|
||||
| signing | throwaway keystore -> `Signer #1 DN: CN=YellowJacket Test`, not the debug key |
|
||||
| ABIs | arm64-v8a + x86_64, both production-stripped |
|
||||
|
||||
Installs and launches under the new identity. Still exits on the known
|
||||
`buildUserDirPath` bug, which is phase 0's finding and not this phase's.
|
||||
|
||||
Two things this phase learned that the text below did not know:
|
||||
|
||||
- **The identity has to be declared twice.** `applicationId` in
|
||||
`app/build.gradle` is what Gradle installs; `APP_ID` in
|
||||
`build/android/Taskfile.yml` is what every adb-driven task targets.
|
||||
`ANDROID.md` says to set `APP_ID` in `build/config.yml` — that does
|
||||
nothing in beta.8, verified with `--dry`. Both are set, each
|
||||
commented pointing at the other.
|
||||
- **The launcher activity is not under the applicationId.** It stays
|
||||
`com.wails.app.MainActivity` (the scaffold's Java package), so
|
||||
`am start -n app.yellowjacket/.MainActivity` resolves the dot against
|
||||
the wrong package and fails. `scripts/android-emulator.sh` carries the
|
||||
fully-qualified name and a comment saying why.
|
||||
|
||||
The `keytool` PKCS12 note below was confirmed verbatim: given a
|
||||
`-keypass` differing from `-storepass` it prints "Different store and
|
||||
key passwords not supported for PKCS12 KeyStores. Ignoring
|
||||
user-specified -keypass value."
|
||||
|
||||
Original phase 2 text follows.
|
||||
|
||||
|
||||
Edit `build/android/app/build.gradle`, following ljos's, whose comments
|
||||
are worth reading before writing this:
|
||||
|
||||
- `applicationId "app.yellowjacket"` — matches `config.yml`'s
|
||||
`productIdentifier`. The `namespace` stays `com.wails.app` (it is the
|
||||
Java package, not the app identity).
|
||||
- `versionCode Integer.parseInt(System.getenv("YJ_VERSION_CODE") ?: "1")`
|
||||
— **`Integer.parseInt`, not `(...) as Integer`**. Groovy binds the
|
||||
parentheses to `versionCode` first, so the cast reads as
|
||||
`versionCode("1") as Integer`, which sets a String and then casts the
|
||||
setter's null return; Gradle fails the whole project with "Value is
|
||||
null" at that line.
|
||||
- `versionName System.getenv("YJ_VERSION") ?: "0.0.0"`.
|
||||
- `abiFilters 'arm64-v8a', 'x86_64'`.
|
||||
- A `release` signing config reading `ANDROID_KEYSTORE_FILE` /
|
||||
`_PASSWORD` / `ANDROID_KEY_ALIAS` / `ANDROID_KEY_PASSWORD`, falling
|
||||
back to the debug keystore only when no keystore is supplied.
|
||||
|
||||
**Android orders releases by an integer and refuses anything not greater
|
||||
than what is installed.** A hardcoded `versionCode 1` means the first
|
||||
install is the last: every later build is rejected as a downgrade and the
|
||||
only fix is an uninstall. `1.3.1 -> 10301`, monotonic as long as minor
|
||||
and patch stay under 100.
|
||||
|
||||
**Signing is not optional past the first install.** Android refuses to
|
||||
update an app whose signing key changed, and the debug keystore differs
|
||||
between every machine and every runner — so an unsigned CI build is a
|
||||
decision to reinstall by hand forever. The job must **refuse to build**
|
||||
without the keystore rather than quietly produce an APK that can never be
|
||||
updated.
|
||||
|
||||
There is **one password and two required secrets**. keytool has defaulted
|
||||
to PKCS12 since JDK 9 regardless of the `.jks` extension, and PKCS12
|
||||
cannot hold a separate key password — given `-keypass` it warns and
|
||||
ignores it. So `ANDROID_KEY_PASSWORD` defaults to the store password and
|
||||
`ANDROID_KEY_ALIAS` to `yellowjacket`. Asking for a second password that
|
||||
cannot exist is how someone sets a wrong value and debugs Gradle at
|
||||
midnight.
|
||||
|
||||
Add `make android` → `PATH="$(TOOLBIN):$$PATH" go tool wails3 task
|
||||
android:package:fat`, beside `build-prod`. `make skill-check` fails on a
|
||||
documented target that does not exist, so document it only once it does.
|
||||
|
||||
## Phase 3 — the workflow [DONE 2026-08-16]
|
||||
|
||||
`.gitea/workflows/android-apk.yml`, plus `docs/android-release.md` as
|
||||
the operating document its error messages point at (phase 4's
|
||||
documentation half; the secrets themselves still have to be created by
|
||||
hand — see the table there).
|
||||
|
||||
Three departures from the text below, all argued in the file:
|
||||
|
||||
- **No `continue-on-error`.** The plan inherited it from ljos, where
|
||||
the Android job shares a pipeline with a server deploy that must
|
||||
never go red over a phone build. Here it is standalone and can
|
||||
neither delay nor redden anything, so a release step that fails
|
||||
silently is strictly worse than one that fails visibly.
|
||||
- **No cached `wails3` binary.** The plan budgeted for ljos's
|
||||
`tools-bin` copy. Unnecessary: the CLI is a vendored `go tool`, and
|
||||
the runner already bind-mounts `GOCACHE`/`GOMODCACHE` for every job,
|
||||
so it is warm from `ci.yml`'s own `make bindings-check`. The GTK and
|
||||
WebKit *dev* headers are still installed, because `go tool wails3`
|
||||
links them.
|
||||
- **A fourth cache volume, `/cache/gradle`.** Not in the plan and worth
|
||||
~700 MB a run.
|
||||
|
||||
Four publish-gates were added and each was checked against a real APK:
|
||||
both ABIs present, `versionCode` equal to the one derived from the tag,
|
||||
a non-empty artifact, and **not signed with the debug key** — verified
|
||||
by pointing the check at a deliberately debug-signed build, which it
|
||||
refused.
|
||||
|
||||
Rehearsed locally with the exact CI invocation
|
||||
(`make android ANDROID_SDK=... ANDROID_NDK=...`, `YJ_VERSION`,
|
||||
`YJ_VERSION_CODE`, a throwaway keystore): `app.yellowjacket`,
|
||||
versionCode 10301, versionName 1.3.1, label YellowJacket, both ABIs,
|
||||
`Signer #1 DN: CN=YellowJacket`. Not yet run on the runner.
|
||||
|
||||
Original phase 3 text follows.
|
||||
|
||||
|
||||
New file: `.gitea/workflows/android-apk.yml`. **Not a job in `ci.yml`.**
|
||||
`ci.yml` runs on every branch push and is the workflow that gates; the
|
||||
runner is capacity 1, and a 45-minute Android build in it would put every
|
||||
push behind an SDK download.
|
||||
|
||||
```yaml
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
```
|
||||
|
||||
This is where the baseline genuinely diverges. ljos computes its version
|
||||
in CI (`scripts/next-version.sh`) and gates the Android job on
|
||||
`needs.release.outputs.version != ''`, with an `always()` whose absence
|
||||
would silently kill the manual path. **This repo has no release
|
||||
automation** — tags are pushed by hand and `homebrew-formula.yml` already
|
||||
keys on `v*`. So there is no `needs:`, no `always()`, and no status
|
||||
function to get wrong: the tag *is* the version, and a dispatch falls
|
||||
back to `git describe --tags --abbrev=0`.
|
||||
|
||||
Container, matching `ci.yml`'s conventions (`ubuntu:24.04`, clone by hand
|
||||
with `PACKAGE_TOKEN` rather than `actions/checkout`, which is a JS action
|
||||
needing node before any step has installed it):
|
||||
|
||||
```yaml
|
||||
container:
|
||||
image: ubuntu:24.04
|
||||
volumes:
|
||||
- /home/logan/docker/gitea/data/runner/cache/tool:/cache/tool
|
||||
- /home/logan/docker/gitea/data/runner/cache/android-sdk:/cache/android-sdk
|
||||
```
|
||||
|
||||
The SDK path must be inside the runner's `valid_volumes` allowlist —
|
||||
a directory outside it makes the job **fail to start**, not silently skip
|
||||
the mount. `/cache/tool` is already allowed and already holds the Go
|
||||
toolchain `ci.yml` downloads.
|
||||
|
||||
`continue-on-error: true` and `timeout-minutes: 45`. Advisory, because a
|
||||
tag's other three workflows must not go red over a phone build, and a
|
||||
backstop because a wedged SDK download must not hold the only runner slot
|
||||
for hours.
|
||||
|
||||
Steps:
|
||||
|
||||
1. **System packages.** `ci.yml`'s set plus `unzip` and `openjdk-17-jdk`.
|
||||
`libasound2-dev` stays — it is for the *host* `wails3` build, not the
|
||||
Android cross-build, which uses oboe.
|
||||
2. **Go toolchain** — reuse `ci.yml`'s `/cache/tool/go` block verbatim.
|
||||
3. **Android SDK and NDK (cached).** ljos's `install_if_missing`
|
||||
idempotent guard, unchanged: cmdline-tools 11076708, `platform-tools`,
|
||||
`platforms;android-34`, `build-tools;34.0.0`, `ndk;26.3.11579264`.
|
||||
sdkmanager is itself idempotent but still spends minutes verifying,
|
||||
which is why the explicit directory guards are there. ~3 GB and most of
|
||||
the job's wall clock on the first run; a directory listing after.
|
||||
4. **wails3.** Cheaper here than in ljos, which pins
|
||||
`go install …/wails3@$version` against `app/go.mod`. This repo vendors
|
||||
the CLI (`go tool wails3`, `scripts/toolbin/wails3`), so the version is
|
||||
already pinned by `go.mod` and there is nothing to drift. It still
|
||||
*links* GTK and WebKit, so cache the built binary in
|
||||
`/cache/android-sdk/tools-bin` keyed on the wails version — and note
|
||||
ljos's finding that **caching the binary alone turned a slow job into
|
||||
a broken one**: `wails3` is dynamically linked, so the runtime
|
||||
packages are needed even on a cache hit. Here they are already in
|
||||
step 1.
|
||||
5. **Frontend + codegen.** `pnpm install --frozen-lockfile && pnpm build`
|
||||
(pnpm, not ljos's npm), then `make generate`. `main.go` embeds
|
||||
`frontend/dist`, so nothing Go-side typechecks without it.
|
||||
6. **Decode the keystore.** Refuse to build if `ANDROID_KEYSTORE_B64` is
|
||||
unset, with the sentence explaining why (Phase 2). Decide the absolute
|
||||
path *here* and export it via `$GITHUB_ENV` — **`${{ env.HOME }}`
|
||||
evaluates to an empty string in Gitea's expression context**, which
|
||||
turned `$HOME/x.jks` into `/x.jks` and surfaced as a missing file
|
||||
fifty-five seconds into a Gradle run.
|
||||
7. **Build.** Compute `YJ_VERSION_CODE` from the tag, verify the keystore
|
||||
opens with `keytool -list` *before* Gradle does (Gradle only notices at
|
||||
`:app:validateSigningRelease`, a minute in, and reports it as a missing
|
||||
file), then `make android`.
|
||||
8. **Verify the signature.** `apksigner verify --print-certs`, and print
|
||||
the SHA-256 with the note that a change to it breaks every future
|
||||
update. **Nothing here pipes into `head`**: under `set -o pipefail`,
|
||||
`head -1` exits early, the producer takes SIGPIPE, and the step fails
|
||||
with 141 *after* printing a perfectly good APK. Use `find … -print
|
||||
-quit` and a captured variable.
|
||||
9. **Publish** to `api/packages/${OWNER}/generic/yellowjacket-android`,
|
||||
authenticating `--user "${OWNER}:${PACKAGE_TOKEN}"` — the same
|
||||
credential pair `arch-package.yml` already uses, not ljos's
|
||||
`REGISTRY_USER`/`REGISTRY_TOKEN`. Two copies: a versioned one for
|
||||
history and a fixed `latest/yellowjacket.apk` that Obtainium watches.
|
||||
Gitea refuses to overwrite, so delete `latest` first. The generic
|
||||
registry is readable **without credentials**, which is what lets
|
||||
Obtainium poll a plain URL with no token and no public source mirror.
|
||||
|
||||
## Phase 4 — secrets and documentation
|
||||
|
||||
Secrets to create on the repo (all under Settings → Actions → Secrets):
|
||||
|
||||
| Secret | Required | Note |
|
||||
|---|---|---|
|
||||
| `ANDROID_KEYSTORE_B64` | yes | `base64 -w0 yellowjacket-release.jks` |
|
||||
| `ANDROID_KEYSTORE_PASSWORD` | yes | |
|
||||
| `ANDROID_KEY_ALIAS` | no | defaults to `yellowjacket` |
|
||||
| `ANDROID_KEY_PASSWORD` | no | defaults to the store password |
|
||||
| `PACKAGE_TOKEN` | already exists | used by `arch-package.yml` |
|
||||
|
||||
Write the keytool command, the Obtainium URL and the signing-key warning
|
||||
into a docs page — this is the part of ljos's setup that lives in
|
||||
`docs/clients.md` and is referenced from the workflow's error messages,
|
||||
so the messages have somewhere to point.
|
||||
|
||||
Then extend CLAUDE.md's CI section: it currently says "four workflows,
|
||||
three of them package and publish; only `ci.yml` gates". That becomes
|
||||
five, with the same sentence still true.
|
||||
|
||||
## Order and stopping points
|
||||
|
||||
Phase 0 gates everything. Phases 1–2 are one commit's worth of work and
|
||||
are verifiable locally without CI. Phase 3 is the only part that needs a
|
||||
runner, and its first run will be slow and will probably fail once on
|
||||
something in the SDK step — budget for that rather than treating it as a
|
||||
setback.
|
||||
|
||||
**Stop after Phase 0 if the c-shared link does not work.** Every later
|
||||
phase is scaffolding for a build that does not exist, and the honest
|
||||
outcome is a NOTES.md entry saying which package cannot cross-compile and
|
||||
what it would take.
|
||||
@@ -0,0 +1,339 @@
|
||||
# 015 — Multi-artist credits, navigable
|
||||
|
||||
> **Completed.** Phases 1, 2 and 4 shipped. Running the ingest against the real dump and publishing an artifact that carries credits is **#88**; Phase 3 (`file_artists`) is **#89**, blocked on it.
|
||||
|
||||
## The problem
|
||||
|
||||
A track credited to more than one artist has exactly one navigable
|
||||
artist in this app, and the others are punctuation.
|
||||
|
||||
`audio_files` carries `artist_credit` (the credit as tagged, for
|
||||
display) and `artist_id` (one artist, for grouping and browsing).
|
||||
`primaryArtist()` (`backend/library/artistcredit.go:53`) resolves that
|
||||
one artist by *string-parsing* the credit: it strips a " feat. "
|
||||
clause, and deliberately does not split on `&`, `x`, `with` or `,`
|
||||
because those appear inside real artist names. So "Lana Del Rey ft.
|
||||
Sean Lennon" stores Lana Del Rey and discards Sean Lennon entirely,
|
||||
and "Alina Baraz & Galimatias" stores one artist whose name is the
|
||||
whole credit.
|
||||
|
||||
### What the measurement says
|
||||
|
||||
Measured 2026-08-16 against a real 26,069-file library (19,840 mp3,
|
||||
6,229 flac; 57 unreadable, m4a/ogg not examined), plus an 80+80
|
||||
MusicBrainz `inc=artist-credits` sample.
|
||||
|
||||
- **13%** of a random sample of the library's recordings have more
|
||||
than one credited artist in MusicBrainz (10 of 79 resolved).
|
||||
Extrapolates to ~3,250 of the 24,989 files carrying a recording
|
||||
MBID.
|
||||
- **0.86%** of files (224) carry any structured multi-artist signal in
|
||||
their own tags. mp3 carries **zero** files with multiple
|
||||
`MUSICBRAINZ_ARTISTID` values across 19,840 files; flac has 87.
|
||||
- **1,286** files say "feat." in `ARTIST`; **1,159 of them (90%)**
|
||||
have nothing structured behind it. A sample of 80 such files was
|
||||
multi-artist in MB **80 of 80 times**.
|
||||
|
||||
CLAUDE.md currently justifies plan 013's removal of `artist_credit` /
|
||||
`artist_credit_artist` with "3 credits of 2,823 listed more than one
|
||||
artist". That figure measured **our own writer**, not the library:
|
||||
`cachedLinkArtist` was called exactly once per credit
|
||||
(`e7748f1^:backend/library/library.go:1842`), so a collaboration could
|
||||
never have been recorded, and the three were resolution collisions on
|
||||
shared credit text. Dropping the join table was still correct — it only
|
||||
ever held one row, so it was pure join cost — but the stated evidence
|
||||
does not support "multi-artist is rare". Correcting that claim is part
|
||||
of this plan.
|
||||
|
||||
### Why the tags cannot answer it
|
||||
|
||||
Deriving the decomposition locally, with no network, works **79% of the
|
||||
time** (169 of 215 files with a multi-value `ARTISTS` tag: mp3 69/105,
|
||||
flac 100/110), and the failures are systematic rather than random:
|
||||
|
||||
```
|
||||
ARTIST = '2Pac feat. Snoop Dogg, Nate Dogg, Hussein Fatal & Yaki Kadafi'
|
||||
ARTISTS = ['2Pac', 'Snoop Doggy Dogg', 'Nate Dogg', 'Fatal', 'Yaki Kadafi']
|
||||
```
|
||||
|
||||
`ARTISTS` holds **canonical** artist names; `ARTIST` holds
|
||||
**as-credited** names. Locating one inside the other fails on
|
||||
"Snoop Doggy Dogg" vs "Snoop Dogg", on "Fatal" vs "Hussein Fatal", and
|
||||
on Unicode (`Michel'le` vs `Michel’le`, `K-Ci` vs `K‐Ci` — U+2010, not
|
||||
a hyphen). That distinction is precisely what a join phrase encodes,
|
||||
and it is why this cannot be a tag-parsing feature.
|
||||
|
||||
Two format details that will mislead anyone re-running the probe:
|
||||
Picard writes `ARTISTS` **slash-joined into one TXXX frame** on mp3 and
|
||||
as **true repeated Vorbis keys** on flac, so a probe splitting only on
|
||||
NUL undercounts mp3 to zero.
|
||||
|
||||
## The shape
|
||||
|
||||
MusicBrainz models a credit as ordered parts, and the credit *string*
|
||||
is derived from them — `artist_credit.name` is a cached render, nothing
|
||||
more. Each participant is `(position, artist, name, join_phrase)`,
|
||||
where `artist` is the MBID (canonical, what you navigate to) and `name`
|
||||
is the credited spelling (what you display).
|
||||
|
||||
**Join phrases are assembly instructions, not disassembly
|
||||
instructions.** Rendering is a concatenation, never a search:
|
||||
|
||||
```
|
||||
for each (position, artist_mbid, credited_name, join_phrase):
|
||||
emit link(credited_name -> artist_mbid)
|
||||
emit text(join_phrase)
|
||||
```
|
||||
|
||||
The link positions are known **by construction**. This is load-bearing:
|
||||
if we instead located each `credited_name` inside the stored
|
||||
`artist_credit` text, we would reintroduce the mismatch above — the
|
||||
stored string may have come from the tags while the parts come from the
|
||||
catalog, and those **disagree for ~1 in 3 multi-artist files** (61 of
|
||||
90 sampled credits rendered exactly equal to the tag string).
|
||||
Divergences seen: `'Skrillex feat. Swae Lee'` tagged vs
|
||||
`'Skrillex & Swae Lee'` in MB; `'STRFKR'` vs `'Starfucker'`;
|
||||
`'Zedd feat. Hayley Williams'` vs `'... of Paramore'`. Either MB was
|
||||
edited after tagging or Picard versions differ; either way the search
|
||||
would miss or match the wrong span.
|
||||
|
||||
So `audio_files.artist_credit` stops being the source of truth and
|
||||
becomes the **fallback**, used only where there are no parts.
|
||||
|
||||
## Where the data comes from
|
||||
|
||||
The catalog carries the decomposition; no user ever makes a
|
||||
per-recording call. Two sources were ruled out first, both cheaply:
|
||||
|
||||
- **The canonical dump — which is what CI already pulls
|
||||
(`dumpimport.go:84-85`) — does not have it.**
|
||||
`canonical_musicbrainz_data.csv` gives `artist_mbids` (ordered list)
|
||||
and `artist_credit_name`, but that last column is the *rendered*
|
||||
string. Splitting it on CI needs the as-credited names, so CI would
|
||||
fail exactly the way a local parse does.
|
||||
- **The JSON dumps do not cover the catalog.**
|
||||
`json-dumps/recording.tar.xz` is 31 MB / 368 MB uncompressed and
|
||||
holds **153,691 recordings**, not ~35M. Measured against the test
|
||||
library's 24,885 recording MBIDs: **0.00% overlap, zero rows**. It is
|
||||
some other subset and is not usable.
|
||||
|
||||
That leaves the core dump, **`mbdump.tar.bz2`** (7.1 GB compressed at
|
||||
the 20260815 export), from
|
||||
`https://data.metabrainz.org/pub/musicbrainz/data/fullexport/`. Four
|
||||
members are needed:
|
||||
|
||||
| member | why | approx rows |
|
||||
| --- | --- | --- |
|
||||
| `mbdump/artist_credit_name` | `(artist_credit, position, artist, name, join_phrase)` — the payload | ~4M |
|
||||
| `mbdump/artist` | `id -> gid`, since the above references artist *row ids* | ~2.6M |
|
||||
| `mbdump/recording` | `gid -> artist_credit`, to key credits by recording MBID | ~35M |
|
||||
| `mbdump/release_group` | same, for album credits | ~2M |
|
||||
|
||||
### Coverage is not a concern
|
||||
|
||||
Of 24,885 distinct recording MBIDs in the test library, **24,808
|
||||
(99.7%)** already have an `explore_index` recording row, measured
|
||||
against a database at 2,052,200 rows — i.e. shipped-artifact coverage,
|
||||
not a local build's. The popularity filter does not strand the long
|
||||
tail here.
|
||||
|
||||
## Status
|
||||
|
||||
- **Phase 1 — done.** `backend/explore/dumpcredits.go` +
|
||||
`dumpcreditswrite.go`, wired into `dumpimport.go`'s `run` behind its
|
||||
own `credits_import_done` marker.
|
||||
- **Phase 2 — done.** `cmd/indexexport` writes the two tables;
|
||||
`artifactimport.go` reads them behind `artifactHasCredits()`.
|
||||
- **Phase 4 — done, and it does not need Phase 3.** `explore.GetCredits`
|
||||
reads the catalog tables keyed on the *recording* MBID, which both
|
||||
sides of the app already carry — a catalog row has one and so does a
|
||||
local file (`library.Track.RecordingMBID`). So one binding serves the
|
||||
Explore pages and the library's own lists, and all ten artist-link
|
||||
call sites render credits today without a local table.
|
||||
- **Phase 3 (`file_artists`) — not started, and now an
|
||||
offline-resilience task rather than a prerequisite.** The table is
|
||||
deliberately *not* declared yet: nothing writes or reads it, and a
|
||||
schema file plus a datamap note describing behaviour that does not
|
||||
exist is a claim the code cannot back. Its remaining
|
||||
value is that credits currently vanish when the catalog is absent or
|
||||
still downloading, which is precisely the `no-index` state
|
||||
`ShelfPage.State` exists to describe. Materialising into
|
||||
`file_artists` is what makes a library stand on its own.
|
||||
|
||||
**Nothing renders yet in practice**, because no published artifact
|
||||
carries credit tables — every credit falls back to its single link
|
||||
until an index build with Phase 1 runs and is exported.
|
||||
|
||||
**Column layouts are verified against the real 20260815 export**, not
|
||||
taken from the schema docs — `artist(id, gid, …)`,
|
||||
`artist_credit(id, name, artist_count, …)`,
|
||||
`artist_credit_name(credit, position, artist, name, join_phrase)` and
|
||||
`recording(id, gid, name, artist_credit, …)` were each read out of the
|
||||
dump. `release_group` shares `recording`'s first four columns and is
|
||||
the one layout still taken on trust; `ErrDumpShape` turns a wrong guess
|
||||
into a loud failure rather than a quietly wrong catalog.
|
||||
|
||||
**Still unrun: the ingest against the real 7.1 GB dump.** Everything is
|
||||
covered by tests over a synthetic tar, which cannot catch a surprise in
|
||||
the other ~35M rows.
|
||||
|
||||
### Phase 1 — Ingest credits on CI
|
||||
|
||||
New dump stage in `cmd/indexbuild`, behind the `indexbuild` tag with
|
||||
the rest of `dumpimport.go`'s stages.
|
||||
|
||||
**Constraint from `b98840e`:** `cmd/indexbuild` is built
|
||||
`CGO_ENABLED=0` in a plain `golang` container and must not reach the
|
||||
Wails `application` package — `TestIndexToolsDoNotImportWails` walks
|
||||
`go list -deps -tags indexbuild`. Nothing here should need it, but a
|
||||
new `ServiceStartup` hook on a package this imports is how it comes
|
||||
back. Go's `compress/bzip2` is pure Go and decompress-only, which is
|
||||
all this needs.
|
||||
|
||||
**Measured, 20260815 export.** Tar members are **alphabetical**, and
|
||||
that is favourable: `artist` (435 MB), `artist_credit` (414 MB) and
|
||||
`artist_credit_name` (237 MB) all fall inside the first ~900 MB
|
||||
compressed, while `recording` and `release_group` come later. So the
|
||||
maps are complete before the rows that consume them arrive, and no
|
||||
recording data is ever buffered.
|
||||
|
||||
Pure-Go `compress/bzip2` decompresses at **26 MB/s uncompressed /
|
||||
8.7 MB/s compressed** (measured on a 250 MB prefix, 3.01x ratio) —
|
||||
**~13.7 min** for the whole file single-threaded, and less because the
|
||||
stream can stop after `release_group` rather than reading the
|
||||
`series`/`tag`/`track`/`url`/`work` tail. The 2 MB/s origin throttle
|
||||
dominates, as it already does for every other dump here.
|
||||
|
||||
Do not, however, *depend* on the ordering: assert it and fall back to
|
||||
buffering if a future export reorders, rather than silently emitting
|
||||
nothing.
|
||||
|
||||
- `artist` -> `map[int32]uuid16` (~2.6M x ~20 B = ~60 MB)
|
||||
- `artist_credit_name` -> `map[int32][]creditPart` (~4M x ~40 B =
|
||||
~200 MB)
|
||||
- `recording` / `release_group` -> emit `gid -> credit_id` **only for
|
||||
MBIDs already in `explore_index`** (the kept set is ~1.4M x 16 B =
|
||||
~22 MB), which is what keeps 35M rows from being held
|
||||
|
||||
Peak ~300 MB, one sequential pass.
|
||||
|
||||
**Only multi-artist credits are stored.** A single-artist credit is
|
||||
`(name, "")` and is already fully described by `explore_index`'s
|
||||
`artist_name` / `artist_mbid`; storing it would triple the table for
|
||||
nothing. Post-filter after loading, once the row count per credit is
|
||||
known.
|
||||
|
||||
New tables (and `datamap` entries, or `TestCatalogCoversSchema` fails
|
||||
the build — both are `Cache`, matching `explore_index`):
|
||||
|
||||
```
|
||||
artist_credit_part(credit_id, position, artist_mbid, credited_name, join_phrase)
|
||||
```
|
||||
|
||||
with `explore_index.artist_credit_id` as the link. Credits are
|
||||
**shared** — an album's twelve tracks by one artist share one credit
|
||||
row — which is the opposite of 013's local verdict, and correctly so:
|
||||
1:1 in a local library, genuinely many-to-one at 2M-row catalog scale.
|
||||
|
||||
### Phase 2 — Ship them in the artifact
|
||||
|
||||
`cmd/indexexport` currently creates exactly two tables in the artifact
|
||||
(`explore_index`, `artifact_meta`, at `cmd/indexexport/*.go:147,170`),
|
||||
so this is a structural addition, not a column.
|
||||
|
||||
Estimated size: ~13% of 1.4M recordings, deduplicated by shared credit,
|
||||
at ~2.3 parts each — order 400k rows, ~18 MB uncompressed. Against a
|
||||
~0.6 GB install that is acceptable; it must be measured rather than
|
||||
assumed before merge.
|
||||
|
||||
`artifactimport.go` must read it **only if present**, on the writer
|
||||
handle where `core` is attached — the `artifactHasTotals()` /
|
||||
`artifactStoresText()` pattern (`artifactimport.go:145-175`), one step
|
||||
up from a column to a table. An artifact published before this exists
|
||||
is still a perfectly good catalog and must import as one that declines
|
||||
to answer. Adding this to the importer's SELECT list without the probe
|
||||
is how every already-published artifact starts failing.
|
||||
|
||||
`artifactCatalogColumns` gains `artist_credit_id`; it is kept in sync
|
||||
with the exporter by `TestArtifactColumnsMatchExporter`.
|
||||
|
||||
### Phase 3 — Materialize locally
|
||||
|
||||
```
|
||||
file_artists(audio_file_id, position, artist_id, credited_name, join_phrase)
|
||||
```
|
||||
|
||||
`credited_name` is stored **per row**, not looked up from
|
||||
`artists.name` — that is the Snoop-Doggy-Dogg distinction, and it is
|
||||
the whole point.
|
||||
|
||||
Filled at scan/import time by joining `audio_files.recording_mbid`
|
||||
against the catalog. **Materialized rather than resolved live**,
|
||||
because the catalog is a downloaded artifact that can be absent or
|
||||
still arriving — that is why `ShelfPage.State` has a `no-index` value —
|
||||
and a library whose track rows lose their artists when the catalog is
|
||||
missing is worse than today.
|
||||
|
||||
That implies a backfill for the case where the catalog arrives *after*
|
||||
the library was scanned. It registers with `jobs` (progress, cancel)
|
||||
like every other long pass, and takes a **distinct kind** from
|
||||
`index-build`, since `job-controls.ts` keys its "you will discard hours
|
||||
of downloading" confirmation on that kind.
|
||||
|
||||
`artists` gains rows for guests who own no files. **This changes what
|
||||
the artists grid shows** and is an open question below.
|
||||
|
||||
### Phase 4 — Render
|
||||
|
||||
`utils/explore-link.ts` gains a credit-rendering entry point taking
|
||||
ordered parts and returning a `TemplateResult`. Every row and detail
|
||||
view already renders artist names through it, so they inherit
|
||||
multi-artist links without individually knowing credits exist — the
|
||||
property that made centralising it worthwhile.
|
||||
|
||||
Its existing fallback philosophy already covers the no-parts case: "a
|
||||
list where some rows are clickable and others silently are not reads as
|
||||
a bug, not as a statement about metadata." Where there are no parts
|
||||
(no recording MBID, or no catalog row — ~4% of the test library) render
|
||||
today's behaviour: the flat `artist_credit` string with one link to the
|
||||
primary artist. **Do not split the string there.** There is genuinely
|
||||
no information to split on, and that is the one place the temptation
|
||||
returns.
|
||||
|
||||
`primaryArtist()` stays exactly as it is. It remains the fallback and
|
||||
is still what `artist_id` means.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Catalog credit vs tagged credit, when they disagree** (~1 in 3
|
||||
multi-artist files). Rendering the catalog's decomposition is what
|
||||
makes names navigable; preserving the file's is what makes the app
|
||||
reflect the user's files. Leaning toward: render the catalog
|
||||
decomposition, keep `artist_credit` as the fallback string. Wants a
|
||||
deliberate decision, not an accident.
|
||||
2. **Do guest artists appear in the artists grid?** Phase 3 creates
|
||||
`artists` rows for people who own no files. The grid currently means
|
||||
"artists in your library" and joins `audio_files`. A guest on one
|
||||
track is arguably in the library and arguably not. Whichever way,
|
||||
the ownership question stays "is there a file" — that rule does not
|
||||
bend.
|
||||
3. **`release_group` credits** are ingested in the same pass for
|
||||
nearly nothing, but album-artist rendering is a separate surface.
|
||||
Ship the data in phase 1, render in a follow-up rather than widening
|
||||
phase 4.
|
||||
4. **Our own `tagwriter`** does not write `ARTISTS` or multiple
|
||||
`MUSICBRAINZ_ARTISTID` frames, so autotagging a folder degrades the
|
||||
very field this rests on — the same shape as the existing
|
||||
track-totals note. Out of scope here; worth recording.
|
||||
|
||||
## Verification
|
||||
|
||||
- Coverage: re-run the library probe and assert `file_artists` is
|
||||
populated for ~13% of files, not ~0.9%.
|
||||
- `TestCatalogCoversSchema` / `TestLifetimesMatchSchema` for the new
|
||||
tables.
|
||||
- `TestIndexToolsDoNotImportWails` still passes with the new stage.
|
||||
- An artifact **without** the credits table imports cleanly (the
|
||||
`artifactHasTotals` regression shape).
|
||||
- Round-trip: a known multi-artist recording renders each name as a
|
||||
separate link with the correct join phrases between them.
|
||||
@@ -0,0 +1,412 @@
|
||||
# 016 — What Android parity would actually take
|
||||
|
||||
> **Completed.** Sections A, B1, B2 and B4 shipped. B3, writing tags on the device, is now **#87**; the device-found UI faults are #51–#72, sequenced by #73.
|
||||
|
||||
> **Status: all of section A is done.** A1–A3 landed with "let the app
|
||||
> reach the user's music"; A4 (MediaSession, transport notification,
|
||||
> audio focus) landed with "survive the screen locking". The direction
|
||||
> taken is **option 1, the full librarian**: `MANAGE_EXTERNAL_STORAGE`
|
||||
> plus an in-app folder browser, which keeps the path-keyed model
|
||||
> intact. B1/B2 remain, both awaiting a decision rather than work. The
|
||||
> sections below are kept as written, because they are the argument the
|
||||
> decision rests on — see "What is left" at the end for the current
|
||||
> state.
|
||||
|
||||
Plan 015 shipped a *pipeline*: the app cross-compiles, is signed and
|
||||
versioned, and publishes from CI. This is the assessment of what stands
|
||||
between that and an Android app worth installing.
|
||||
|
||||
**The headline: parity is the wrong target, and choosing it would be
|
||||
the expensive mistake.** Four of the blockers below are not porting work
|
||||
— they are the Android platform declining to support the model this app
|
||||
is built on. The decision to make first is in "The fork in the road" at
|
||||
the end; everything before it is evidence for that decision.
|
||||
|
||||
Severity is what the app *does* today, verified against the source and
|
||||
the generated manifest, not guessed.
|
||||
|
||||
## A. It cannot work at all until these are fixed
|
||||
|
||||
### A1. The app can read no music. (deepest)
|
||||
|
||||
`build/android/app/src/main/AndroidManifest.xml` requests INTERNET,
|
||||
VIBRATE, ACCESS_NETWORK_STATE, USE_BIOMETRIC, POST_NOTIFICATIONS, the
|
||||
two location permissions, CAMERA and the two FOREGROUND_SERVICE ones.
|
||||
**There is no storage or media permission of any kind.** At
|
||||
`targetSdk 35` that means the app can see its own private directory and
|
||||
nothing else.
|
||||
|
||||
Adding `READ_MEDIA_AUDIO` is necessary and *not sufficient*, because it
|
||||
grants access through **MediaStore**, not through the filesystem. This
|
||||
app's entire model is absolute paths: `audio_files.file_path` is the
|
||||
primary key of ownership, `AddLibrary(path)` takes a directory, the
|
||||
scanner walks it with `os.ReadDir`, and every one of
|
||||
`GetFilePathsByAlbums` / `ByGenres` / `ByRecordingMBIDs` exists to hand
|
||||
paths to the player. Scoped storage does not offer a stable directory
|
||||
to walk.
|
||||
|
||||
The honest options are three, and they are not close in cost:
|
||||
|
||||
- **MediaStore as the library source.** Query the content resolver,
|
||||
keep MediaStore IDs (or content URIs) beside or instead of paths, and
|
||||
open audio through a `ContentResolver` file descriptor. This is the
|
||||
Android-native answer and it touches the schema, the scanner, the
|
||||
player's file opening and every path-keyed query.
|
||||
- **`MANAGE_EXTERNAL_STORAGE`.** Keeps the path model intact and is
|
||||
effectively barred from Google Play except for genuine file managers.
|
||||
Viable *only* because we distribute through Obtainium — which is a
|
||||
real point in its favour here, and worth stating plainly rather than
|
||||
dismissing.
|
||||
- **App-private storage only**, i.e. the user copies music into the
|
||||
app's sandbox. Trivial to build, and nobody wants it.
|
||||
|
||||
### A2. The first-run flow cannot complete.
|
||||
|
||||
`first-run-wizard.ts` calls `DirectoryPicker()`, which is
|
||||
`frontendutil.DirectoryPicker` → `app.Dialog.OpenFile().
|
||||
CanChooseDirectories(true)`. Wails' own `ANDROID.md` lists open-directory
|
||||
dialogs as **"❌ Returns an error — SAF yields tree URIs, not filesystem
|
||||
paths"**. So the one action the wizard exists to perform fails, and
|
||||
`<first-run-wizard>` intercepts all pointer events until a library
|
||||
exists — so the app is not merely empty, it is inert.
|
||||
|
||||
Whatever A1 resolves to decides this: a MediaStore library needs no
|
||||
picker at all, and a SAF tree needs the picker to return a URI the
|
||||
backend can use.
|
||||
|
||||
### A3. MPRIS is compiled into the Android build.
|
||||
|
||||
`mpris_linux.go` is `//go:build linux`, and **`android` implies
|
||||
`linux`** (documented, and the reason it is in the APK). It will look
|
||||
for a session bus that does not exist. It needs `//go:build linux &&
|
||||
!android`, and its Android counterpart is A4.
|
||||
|
||||
This one is cheap and should be done regardless — it is a two-character
|
||||
build-tag change plus whatever `mediacontrols.New` returns instead.
|
||||
|
||||
### A4. Playback will be killed the moment the screen locks.
|
||||
|
||||
The scaffold's `WailsForegroundService` is typed **`dataSync`**
|
||||
(`foregroundServiceType="dataSync"`, `FOREGROUND_SERVICE_TYPE_DATA_SYNC`),
|
||||
and the manifest requests `FOREGROUND_SERVICE_DATA_SYNC`. A music player
|
||||
needs `mediaPlayback` and `FOREGROUND_SERVICE_MEDIA_PLAYBACK`, plus a
|
||||
`MediaSession` for lock-screen and notification transport controls,
|
||||
plus **audio focus** — pause on a phone call, duck for a notification,
|
||||
pause on headphone unplug. None of that exists today. `oto` will happily
|
||||
keep writing to a stream nobody can hear.
|
||||
|
||||
This is the difference between "an app that plays audio" and "a music
|
||||
player", and it is Java-side work in the scaffold plus a Go-side bridge.
|
||||
|
||||
## B. It works, but wrongly
|
||||
|
||||
### B1. The x86_64 half of the APK cannot run on any Android.
|
||||
|
||||
Established in plan 015: `modernc.org/libc`'s `Xlstat64` issues a raw
|
||||
`lstat` on linux/amd64, which Android's seccomp forbids, so the process
|
||||
takes `SIGSYS` the first time it touches the database. arm64 is
|
||||
structurally unaffected (no `lstat` syscall exists; it routes through
|
||||
`fstatat`).
|
||||
|
||||
So ~31 MB of the artifact is dead weight on *every* Android device,
|
||||
including x86 Chromebooks. Options: drop `x86_64` from `abiFilters`
|
||||
(smaller APK, no emulator target — which does not work anyway), or
|
||||
carry it against a future modernc fix. **Dropping it is the honest
|
||||
default**; it is also the only item in this plan that is a five-minute
|
||||
change.
|
||||
|
||||
### B2. The UI is a desktop shell.
|
||||
|
||||
`MinWidth`/`MinHeight` are 800×600 and were *measured* — below ~780 the
|
||||
header subtitle wraps the title out of its bar. A phone is ~360–430 CSS
|
||||
px wide. The sidebar collapses to icons below 900px, which is a
|
||||
laptop-sized breakpoint, not a phone one. Beyond width: the app is built
|
||||
on hover (the marquee's `hover` mode, tooltips), right-click context
|
||||
menus, a keyboard shortcut layer with its own overlay and settings page,
|
||||
multi-select with ctrl/shift, and a resizable-column track list. None of
|
||||
those are gestures.
|
||||
|
||||
This is not a stylesheet pass. It is a second front end for the views
|
||||
worth having on a phone, sharing the stores and bindings — which the
|
||||
architecture supports, since a view is already a lazily-loaded chunk
|
||||
behind `VIEW_LOADERS`.
|
||||
|
||||
### B3. Tag writing cannot reach the user's files.
|
||||
|
||||
`tagwriter` rewrites tags in place, and autotag's whole purpose is
|
||||
applying them to a folder. Under scoped storage that is impossible
|
||||
outside the sandbox without a SAF write grant per tree. If A1 lands on
|
||||
MediaStore, in-place tag writing needs `MediaStore` write requests and
|
||||
user confirmation per file on Android 11+.
|
||||
|
||||
Autotagging is arguably a desktop-only feature and saying so is a
|
||||
legitimate answer.
|
||||
|
||||
### B4. The Explore catalog is a ~0.6 GB download into app-private storage.
|
||||
|
||||
It works — but with no awareness of a metered connection and no
|
||||
accounting for a device where that is a meaningful fraction of free
|
||||
space. At minimum it needs to be opt-in on mobile and to refuse a
|
||||
metered network by default. `Android.NetworkJSON()` reports
|
||||
`{connected,type}`, so the signal is available.
|
||||
|
||||
## C. Inert, and fine
|
||||
|
||||
Window geometry, menus and the system tray are documented no-ops on
|
||||
mobile. The keyboard shortcut layer is harmless but its Settings page
|
||||
is dead weight. `profiling` is already compiled out of production
|
||||
builds. These cost nothing and need no work.
|
||||
|
||||
## D. Unknown until it runs on a device
|
||||
|
||||
**Nothing in section A or B has been observed on Android**, because the
|
||||
x86_64 emulator cannot run the app (B1) and emulator 37 refuses arm64
|
||||
images on an x86_64 host. Everything above is read from the source, the
|
||||
generated manifest and Wails' own documentation. The first real device
|
||||
run will find things this list does not have, and the most likely
|
||||
places are audio latency and buffering under `oto`/oboe, and SQLite
|
||||
behaviour on app-private storage.
|
||||
|
||||
## The fork in the road
|
||||
|
||||
The four blockers in section A are all the same question wearing
|
||||
different clothes: **is the Android app a librarian, or a player?**
|
||||
|
||||
YellowJacket on the desktop is a *librarian*. It scans folders,
|
||||
deduplicates covers, detects duplicate tracks, reconciles against
|
||||
MusicBrainz, rewrites tags on disk, and manages downloads. That model
|
||||
rests on owning a filesystem, which is precisely what Android declines
|
||||
to give.
|
||||
|
||||
Three coherent products, and only the first is "parity":
|
||||
|
||||
1. **Full librarian on Android.** Requires `MANAGE_EXTERNAL_STORAGE`
|
||||
(Obtainium-only distribution, which we already have), a phone UI for
|
||||
every view, and media-session playback. Largest scope by far; the
|
||||
result is an app almost nobody has asked for on a phone.
|
||||
2. **A player for music already on the phone.** MediaStore as the
|
||||
source, no scanner, no autotag, no downloads; the library, queue,
|
||||
playlists, favourites and Explore-as-browsing all still make sense.
|
||||
This is a genuinely good Android app and it is *not* parity — it is
|
||||
a subset with a different data source.
|
||||
3. **A companion to the desktop app.** The phone browses and controls
|
||||
the desktop's library over the network, or syncs a subset. Smallest
|
||||
Android surface, and it leans on the thing that already works.
|
||||
|
||||
**Option 2 is the recommendation** if the goal is an app people use;
|
||||
option 3 if the goal is the least work for the most value. Option 1 is
|
||||
the only one that answers "feature parity" literally, and it is the one
|
||||
worth arguing hardest against.
|
||||
|
||||
> **Decided:** option 1's *data model* (the librarian keeps its
|
||||
> filesystem and its scanner — A1 shipped that) with option 2's
|
||||
> *surface*. The phone is a player over the library this app already
|
||||
> builds; it does not get every view. The list is below.
|
||||
|
||||
## The phone gets a subset (decided)
|
||||
|
||||
B2 is not a stylesheet pass and not a second front end either. A view
|
||||
is already a lazily-loaded chunk behind `VIEW_LOADERS` /
|
||||
`DETAIL_LOADERS` in `index.ts`, and the stores and bindings are shared,
|
||||
so the phone build is **a different loader table and a different
|
||||
chrome**, over the same stores.
|
||||
|
||||
**In**, because each is something a person does with a phone in their
|
||||
hand:
|
||||
|
||||
- **Home** — the shelves are already a phone-shaped surface.
|
||||
- **Library browse** — albums, artists, genres. The grids are already
|
||||
virtualized and card-shaped.
|
||||
- **Now playing** — which on a phone is a *view*, not a 4em bar.
|
||||
- **The queue.**
|
||||
- **Search** — the header box, scoped as it already is.
|
||||
- **Playlists**, including smart ones, as lists to play rather than to
|
||||
edit.
|
||||
|
||||
**Out**, and each for a reason rather than by omission:
|
||||
|
||||
- **Autotag** — the review UI is a wide table and the action rewrites
|
||||
files on disk; B3 has not been verified even as *possible* yet.
|
||||
- **Downloads** — two tab panels of client configuration.
|
||||
- **Explore** — the catalog is a ~0.6 GB download (B4); browsing it is
|
||||
the last thing to earn a phone's storage.
|
||||
- **Settings** — not the page. The phone needs a handful of settings
|
||||
(theme, the library folder, playback) and not the 93 controls the
|
||||
desktop page carries.
|
||||
- **Jobs**, **shortcuts overlay**, **column configuration** — a phone
|
||||
has no keyboard and no resizable columns, and the jobs indicator is
|
||||
enough.
|
||||
|
||||
What the shell has to lose, from the audit at the top of this section:
|
||||
the 800×600 minimum, the 11-item sidebar (a phone wants a bottom tab
|
||||
bar over the five things above), hover as a route to anything,
|
||||
right-click as the only route to a context menu (long-press is the
|
||||
gesture), and ctrl/shift multi-select.
|
||||
|
||||
One rule for the work: **no view forks.** A phone layout that copies a
|
||||
view's template is two templates to fix every bug in. Where a view
|
||||
cannot serve both, the split belongs at the chunk boundary that already
|
||||
exists.
|
||||
|
||||
Phase 1 followed that rule and found its cost: reusing `<app-sidebar>`
|
||||
inside the drawer means reusing its `data-testid`s too, and a second
|
||||
copy standing by in the DOM broke 30 specs that had nothing to do with
|
||||
the phone. The rule holds — a second list of destinations would be
|
||||
worse — but a shared component must be rendered only when it is wanted,
|
||||
and the guard belongs in a test that names the reason.
|
||||
|
||||
## What is worth doing regardless of that decision
|
||||
|
||||
Cheap, independently useful, and each unblocks measurement:
|
||||
|
||||
1. **Drop `x86_64` from `abiFilters`** (B1) — or keep it and document
|
||||
why. Five minutes.
|
||||
2. **`//go:build linux && !android` on `mpris_linux.go`** (A3), so the
|
||||
Android build stops carrying a D-Bus client. Small.
|
||||
3. **A device smoke run**, which needs someone's phone and the published
|
||||
APK. Everything in D depends on it, and it is the single highest
|
||||
information-per-minute action available.
|
||||
4. **Make the first-run wizard fail legibly** rather than inertly (A2)
|
||||
— the picker's error already routes through `describeError`, but the
|
||||
wizard still blocks pointer events, so an Android user sees a dead
|
||||
screen rather than a sentence. Even under option 3 this is the right
|
||||
behaviour.
|
||||
|
||||
|
||||
## What is left (updated after A4)
|
||||
|
||||
**A4 is done.** `backend/mediacontrols/android.go` is a `Handler`
|
||||
beside the MPRIS one, and the Java half is
|
||||
`WailsForegroundService.java`: a `MediaSession`, a `MediaStyle`
|
||||
transport notification and audio focus. It needed no new JNI and no new
|
||||
Gradle dependency — `application.Android.StartForegroundService(json)`
|
||||
going out, `WailsBridge.emitEvent` → the application event bus coming
|
||||
back, and the platform `android.media.session` API rather than
|
||||
androidx.media, which minSdk 21 makes available anyway.
|
||||
|
||||
Four decisions in it are worth keeping:
|
||||
|
||||
- **Ducking is a player concept, not a volume change.**
|
||||
`Player.SetDuck` re-applies the *user's* level with an attenuation
|
||||
offset, so `getUserVolume` still reports what the user chose and
|
||||
nothing is persisted or emitted. A duck that wrote through to the
|
||||
volume would let one notification tone permanently turn the music
|
||||
down.
|
||||
- **The duck path is pre-Oreo only.** From API 26 the framework ducks
|
||||
the app itself and sends no `CAN_DUCK` focus change, so asking to be
|
||||
told instead (`setWillPauseWhenDucked`) would mean pausing for every
|
||||
notification tone, and doing both would attenuate twice.
|
||||
- **An unchanged payload is not an event here either.** Every push
|
||||
crosses JNI and re-delivers an Intent, and the player pushes state on
|
||||
several paths that can agree.
|
||||
- **After the first start, updates use `startService`.** From Android
|
||||
12 an app in the background may not *start* a foreground service, but
|
||||
it may keep delivering intents to one it already has — which is every
|
||||
track change with the screen off.
|
||||
|
||||
The contract with Java — the payload keys, the state words, the command
|
||||
names — is in `androidpayload.go`, deliberately *without* the `android`
|
||||
build tag, so `go test` exercises it on every platform. Everything left
|
||||
in `android.go` is untested by construction: it compiles only under a
|
||||
cross-compiler and runs only on a phone.
|
||||
|
||||
**B1 is done: x86_64 is dropped.** 27.1 MB → 15.9 MB, measured. Three
|
||||
places had to agree — `abiFilters`, the Makefile's `android:package`
|
||||
(or Go still compiles a library Gradle then discards) and the
|
||||
`native-code: 'arm64-v8a'$` assertion in `android-apk.yml`, whose
|
||||
anchor is what stops it also matching the fat APK's line. Adding the
|
||||
ABI back, if modernc ever fixes `Xlstat64`, is those same three edits.
|
||||
|
||||
**B2, the desktop shell.** Scope decided (below); **all four phases are
|
||||
done.**
|
||||
|
||||
- *Phase 1, the shell.* Below 600px the sidebar column is gone,
|
||||
`<bottom-nav>` is the primary navigation, and the shell fits 320px
|
||||
exactly — measured, from 652px in a 360px viewport before.
|
||||
- *Phase 2, the full-screen now-playing view.* Where phase 1's seek bar
|
||||
and volume went. A detail view, so Back pops the nav stack; it
|
||||
composes the real transport components rather than copying them; and
|
||||
it hides the bottom bar while it is up, so it carries its own queue
|
||||
button.
|
||||
- *Phase 3, long-press.* `utils/long-press.ts`: one document-capture
|
||||
listener, installed once from `index.ts`, which turns a 500 ms
|
||||
stationary touch into a synthetic `contextmenu` at the touch point.
|
||||
Every menu in the app opens from that event, so all six components
|
||||
gained the gesture without one of them changing — which is the same
|
||||
argument `ContextMenuController` rests on, one layer lower. The
|
||||
details that are not obvious are in `NOTES.md` (2026-08-17); the one
|
||||
worth repeating is that ours is told from the browser's own
|
||||
long-press event by **identity**, not `isTrusted`, because a test
|
||||
cannot dispatch a trusted event and that path would otherwise be the
|
||||
only uncovered one.
|
||||
|
||||
- *Phase 4, the track list.* A phone draws `titleArtist` (title over
|
||||
artist) plus the duration, and drops the column headers and the resize
|
||||
handles — a column set rather than a second row template, so the row
|
||||
and everything delegated on it is unchanged. Verified at the device's
|
||||
own 424x439: `24px 304px 80px`, 52 px rows, no truncation, no
|
||||
overflow. The device also found the bug in it, which no browser
|
||||
viewport would have: saved *desktop* column widths reached the phone
|
||||
through an id-keyed store and gave the duration column 55% of the row.
|
||||
|
||||
**B2 and B4 are complete.** B4 is `backend/explore/netpolicy.go`: the
|
||||
catalog download is skipped on a cellular connection unless
|
||||
`AllowMeteredCatalogDownload` is on, with the toggle in Settings' Search
|
||||
Index section. The policy and the JSON parsing are in `explore` (tested
|
||||
on every platform) and only the platform call is injected from `app.go`,
|
||||
because `cmd/indexbuild` imports `explore` and must not link Wails. Two
|
||||
things the plan got slightly wrong: the portable API is
|
||||
`application.Mobile.NetworkJSON()` rather than `Android`'s, and it
|
||||
reports no metered flag — so cellular is the signal and a metered Wi-Fi
|
||||
cannot be seen.
|
||||
|
||||
What is left in this plan is B3 (tag writing, which needs a device) and
|
||||
the standing question of the Light Phone's Chrome 113 — which so far has
|
||||
cost nothing: menus, dialogs and long-press all work on it.
|
||||
|
||||
**B3/B4** are unchanged, and B3 is now *possible* where it was not:
|
||||
with all-files access, `tagwriter` can write in place.
|
||||
|
||||
### What the first device run answered (2026-08-17)
|
||||
|
||||
A4 **works**: playback survives the screen locking, and the transport
|
||||
notification appears with cover art — which also settles the service's
|
||||
access to a `MANAGE_EXTERNAL_STORAGE` path, the permission grant and
|
||||
the lock-screen session in one observation. Everything below in "what
|
||||
none of section A answered" was written before this and is now answered
|
||||
except the OEM permission-flow variance.
|
||||
|
||||
It also found two faults no browser tier can see, both fixed and both
|
||||
awaiting the next APK for confirmation (`NOTES.md`, same date):
|
||||
|
||||
- **Back quit the app from any depth.** The scaffold asks
|
||||
`webView.canGoBack()`; the frontend had never used `history`. A
|
||||
navigation is a history entry now, and `navStack` is gone rather than
|
||||
kept beside it.
|
||||
- **The transport was under the gesture bar** — or so the version
|
||||
number said. `applyWindowInsets()` in `MainActivity` is right and
|
||||
stays, but the phone is **Android 14**, where the system still insets
|
||||
the window: the fix is pre-emptive and the symptom has another cause.
|
||||
Still open, along with icons that do not appear at all. The phone's
|
||||
WebView is **Chrome 113**, which is the lead (no Popover API, no
|
||||
relaxed CSS nesting), and `make android-inspect` / `android-eval` are
|
||||
how it gets asked.
|
||||
|
||||
The standing item is unchanged in kind: **B3 (tag writing) and the
|
||||
permission flow still need a device**, and so does confirming these two.
|
||||
|
||||
### What none of section A answered
|
||||
|
||||
Nothing here has been observed on a device. The permission flow in
|
||||
particular is the kind of thing that behaves differently across OEM
|
||||
builds — `ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION` is
|
||||
implemented inconsistently, which is why there is a fallback to the
|
||||
global list, and neither path has been exercised.
|
||||
|
||||
A4 adds its own list of things only a device can answer, and they are
|
||||
the likely first failures: whether the notification appears at all
|
||||
(POST_NOTIFICATIONS is requested from `startForegroundService`, so a
|
||||
user who declines gets a service with an invisible notification),
|
||||
whether audio focus arrives while `oto`/oboe holds the output, whether
|
||||
the lock screen picks up the session, and whether cover art decoded
|
||||
from a `MANAGE_EXTERNAL_STORAGE` path is readable by the service.
|
||||
@@ -0,0 +1,87 @@
|
||||
# 017 — Releases that happen by themselves
|
||||
|
||||
**Shipped as `v0.0.1`.** A merge to `main` now reads the Conventional
|
||||
Commits since the last tag, cuts the tag and the Gitea release whose body
|
||||
is the generated changelog, and the four publishing workflows build that
|
||||
tag and attach their artifacts. Nothing is released by hand.
|
||||
|
||||
## What it looks like now
|
||||
|
||||
`release.yml` on push to `main` → semantic-release → tag → four `v*`
|
||||
workflows in parallel (serialised in practice by the capacity-1 runner):
|
||||
|
||||
| workflow | publishes | attaches |
|
||||
| --- | --- | --- |
|
||||
| `arch-package` | pacman registry | `…-x86_64.pkg.tar.zst` |
|
||||
| `android-apk` | generic registry (Obtainium) | `…-android-arm64.apk` |
|
||||
| `desktop-assets` | — | `…-linux-amd64.tar.gz` |
|
||||
| `homebrew-formula` | the public tap | — (builds from source) |
|
||||
|
||||
Verified on the real thing: all five green, three assets on the release,
|
||||
the tap at `0.0.1`, and the Obtainium `latest` URL serving 200.
|
||||
|
||||
## The five decisions, and what they cost
|
||||
|
||||
1. **semantic-release, not a shell script.** The first draft of this plan
|
||||
proposed hand-rolling it and the argument did not survive checking:
|
||||
`@semantic-release/exec` is first-party and current, and the
|
||||
Gitea-shaped part is one `curl`. What I would have hand-rolled —
|
||||
commit parsing, semver ordering, note rendering — is the part with the
|
||||
edge cases and none of it is Gitea-shaped.
|
||||
2. **`@saithodev/semantic-release-gitea` is a dead end** and was offered
|
||||
before it was checked: last published 2022, `got@10`, and no peer
|
||||
dependency on semantic-release at all.
|
||||
3. **No `@semantic-release/git`.** `main` is protected, so a changelog
|
||||
commit-back is rejected by the pre-receive hook — and would be
|
||||
rejected *after* the tag was pushed, leaving a tagged release the run
|
||||
reports as failed. The release page is the changelog;
|
||||
`.release-notes.md` is a gitignored carrier and `CHANGELOG.md` is a
|
||||
signpost.
|
||||
4. **Versions restart at `0.0.1`**, a downgrade on every channel. No
|
||||
`epoch`, no `versionCode` offset: both are permanent, a reinstall is
|
||||
once. Documented in `packaging/homebrew/README.md` and
|
||||
`docs/android-release.md`.
|
||||
5. **No macOS and no Windows.** `GOOS=darwin CGO_ENABLED=0` fails at
|
||||
`wails/v3/pkg/mac` and there is no macOS runner, so Homebrew-from-source
|
||||
stays that channel. Windows cross-compiles in ~2.5 s and is withheld
|
||||
because no build of it has ever been *run*.
|
||||
|
||||
## Four things that only showed up by running it
|
||||
|
||||
- **`conventional-changelog-conventionalcommits@10` renders empty
|
||||
notes.** Silently: right version, right tag, every step green, and a
|
||||
release body that is a bare `## 0.0.1 (date)` heading with nothing
|
||||
beneath it. Held at `9`, in `release.yml` and `make release-dry`, with
|
||||
the reason beside both. **Check the rendered notes, never the exit
|
||||
code.**
|
||||
- **semantic-release core dry-run-pushes to the release branch** as a
|
||||
permission check, independently of any plugin. `PACKAGE_TOKEN` had
|
||||
package-write and repo-*read* — enough to clone, not enough for this —
|
||||
and it failed with a flat `403 Forbidden` that reads exactly like
|
||||
branch protection. It is not: a `--dry-run` push never reaches the
|
||||
pre-receive hook, which a one-line experiment settled. The token needed
|
||||
`write:repository`.
|
||||
- **The floor tag must go on `HEAD^`, not `HEAD`.** Seeded on the merge
|
||||
commit itself it leaves nothing between the floor and HEAD, and
|
||||
semantic-release correctly reports there is nothing to release. The
|
||||
first run did exactly that and cut nothing.
|
||||
- **A tag-triggered workflow runs from the tagged commit's tree.**
|
||||
Moving `v0.0.0` back to `6fb7b5e` ran the *pre-merge* homebrew
|
||||
workflow, which predates the `v0.0.0` skip guard, and pushed a `0.0.0`
|
||||
formula to the public tap. Self-corrected at `0.0.1`. The corollary is
|
||||
general: a guard added today does not protect a tag pointing at
|
||||
yesterday.
|
||||
|
||||
## Two mechanisms confirmed, having been assumptions
|
||||
|
||||
- **A tag pushed with a user PAT does start the `v*` workflows**; one
|
||||
pushed with the Actions token does not (go-gitea#33123). Both halves
|
||||
are load-bearing and both were observed: the floor seed triggered
|
||||
nothing, and the release tag triggered all four.
|
||||
- **Tags are not protected** on this repo, only `main` — which is what
|
||||
lets semantic-release tag at all.
|
||||
|
||||
## Left behind deliberately
|
||||
|
||||
`v0.0.0` stays on `origin` as the floor. It carries no release, and all
|
||||
four publishers skip it by name.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Autotag (v1.3) — MusicBrainz Autotagger
|
||||
|
||||
> **Historical record.** Phases 008–010 shipped, and the scoring engine was subsequently overhauled (`recommend.go`, `rank.go`, `mixedbag.go`), which makes the 011/012 sections below stale in their details. What is actually left is **#90** (auto-accept and entry points) and **#91** (settings, and a way back from the dismissed file-write warning).
|
||||
|
||||
The MusicBrainz autotagger, collectively **v1.3**. Builds on the explore-browser API client + cache foundation. Five sequential phases (008–012), each depending on the prior one.
|
||||
|
||||
| Phase | Title | Status |
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"browser": {
|
||||
"browserName": "chromium",
|
||||
"launchOptions": {
|
||||
"channel": "chromium"
|
||||
},
|
||||
"contextOptions": {
|
||||
"viewport": { "width": 1440, "height": 900 }
|
||||
},
|
||||
"initScript": ["init-events.js"]
|
||||
},
|
||||
"testIdAttribute": "data-testid",
|
||||
"outputDir": ".playwright-cli",
|
||||
"console": {
|
||||
"level": "warning"
|
||||
},
|
||||
"timeouts": {
|
||||
"action": 10000,
|
||||
"navigation": 30000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
/*
|
||||
* YellowJacket harness bridge — installed as a Playwright initScript, so
|
||||
* it runs in every page *before* any application script.
|
||||
*
|
||||
* Why this file exists: half of what this app does is push-driven. Scan
|
||||
* progress, job updates, download progress, WantedListChanged and 40-odd
|
||||
* other events arrive from Go whenever they arrive. An assertion that
|
||||
* sleeps and hopes is flaky; an assertion that awaits the event is not.
|
||||
*
|
||||
* 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, 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. Two places, and neither is `EventsOn`.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
* `__yjEvents.reset()`; they never re-install.
|
||||
*/
|
||||
(() => {
|
||||
if (window.__yjEvents) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
const counts = {};
|
||||
for (const e of log) {
|
||||
counts[e.name] = (counts[e.name] || 0) + 1;
|
||||
}
|
||||
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);
|
||||
if (log.length > LIMIT) {
|
||||
log.splice(0, log.length - LIMIT);
|
||||
}
|
||||
for (const w of Array.from(waiters)) {
|
||||
let hit = false;
|
||||
try {
|
||||
hit = w.test(entry);
|
||||
} catch {
|
||||
hit = false;
|
||||
}
|
||||
if (hit) {
|
||||
waiters.delete(w);
|
||||
clearTimeout(w.timer);
|
||||
w.resolve(entry);
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
};
|
||||
|
||||
// `name` is a string, or "*" for any event. `match` is an optional
|
||||
// predicate over (data, entry) — only usable from an eval'd function,
|
||||
// which is how every harness call is written anyway.
|
||||
const makeTest = (name, match) => (entry) => {
|
||||
if (name && name !== "*" && entry.name !== name) {
|
||||
return false;
|
||||
}
|
||||
return match ? !!match(entry.data, entry) : true;
|
||||
};
|
||||
|
||||
const api = {
|
||||
version: 2,
|
||||
|
||||
/** Every recorded event, oldest first. */
|
||||
get log() {
|
||||
return log.slice();
|
||||
},
|
||||
|
||||
/** The sequence number of the most recent event. */
|
||||
get seq() {
|
||||
return seq;
|
||||
},
|
||||
|
||||
/**
|
||||
* 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;
|
||||
},
|
||||
|
||||
/** Every recorded event, optionally filtered by name. */
|
||||
all(name) {
|
||||
return name ? log.filter((e) => e.name === name) : log.slice();
|
||||
},
|
||||
|
||||
/** How many of `name` (or of everything) have arrived. */
|
||||
count(name) {
|
||||
return this.all(name).length;
|
||||
},
|
||||
|
||||
/** The most recent matching event, or null. */
|
||||
last(name) {
|
||||
const hits = this.all(name);
|
||||
return hits.length ? hits[hits.length - 1] : null;
|
||||
},
|
||||
|
||||
/** Everything after a sequence number — pairs with `.seq`. */
|
||||
since(n) {
|
||||
return log.filter((e) => e.seq > n);
|
||||
},
|
||||
|
||||
/** name -> count, for "what actually happened?" */
|
||||
names() {
|
||||
return summarize();
|
||||
},
|
||||
|
||||
/**
|
||||
* Settle on the next (or already-buffered) matching event.
|
||||
*
|
||||
* await __yjEvents.wait('LibraryScanComplete', { timeoutMs: 60000 })
|
||||
* await __yjEvents.wait('JobsChanged', { match: (d) => d.length > 0 })
|
||||
*
|
||||
* Rejects on timeout with the names that did arrive, because
|
||||
* "timed out waiting for X" without that list is a dead end.
|
||||
*/
|
||||
wait(name, opts) {
|
||||
const o = opts || {};
|
||||
const test = makeTest(name, o.match);
|
||||
const since = o.since || 0;
|
||||
|
||||
for (const entry of log) {
|
||||
if (entry.seq > since && test(entry)) {
|
||||
return Promise.resolve(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const w = { test, resolve };
|
||||
w.timer = setTimeout(() => {
|
||||
waiters.delete(w);
|
||||
reject(
|
||||
new Error(
|
||||
`__yjEvents.wait(${JSON.stringify(name)}) timed out after ` +
|
||||
`${o.timeoutMs || 5000}ms; events seen: ` +
|
||||
JSON.stringify(summarize()),
|
||||
),
|
||||
);
|
||||
}, o.timeoutMs || 5000);
|
||||
waiters.add(w);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolve when the backend is actually answering calls — not
|
||||
* when the DOM is ready, which is earlier and lies.
|
||||
*/
|
||||
async ready(timeoutMs) {
|
||||
const deadline = Date.now() + (timeoutMs || 15000);
|
||||
for (;;) {
|
||||
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");
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Call a bound Go method by dotted path.
|
||||
*
|
||||
* await __yjEvents.call('player.Player.SetVolume', [42])
|
||||
*
|
||||
* 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 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([
|
||||
request,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error(
|
||||
`__yjEvents.call(${path}) did not settle in ` +
|
||||
`${timeoutMs || 10000}ms — the runtime endpoint ` +
|
||||
`hung, which is not how a bad argument fails; ` +
|
||||
`check .dev/app.log`,
|
||||
),
|
||||
),
|
||||
timeoutMs || 10000,
|
||||
),
|
||||
),
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
Object.defineProperty(window, "__yjEvents", {
|
||||
value: api,
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
});
|
||||
|
||||
// ── 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 || {};
|
||||
|
||||
let dispatch;
|
||||
|
||||
Object.defineProperty(window._wails, "dispatchWailsEvent", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: () => dispatch,
|
||||
set: (fn) => {
|
||||
dispatch = function (event) {
|
||||
try {
|
||||
record(event?.name, argsOf(event?.data), "in");
|
||||
} catch {
|
||||
/* a broken recorder must never break the app */
|
||||
}
|
||||
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;
|
||||
});
|
||||
};
|
||||
})();
|
||||
+52
-16
@@ -1,6 +1,20 @@
|
||||
# semantic-release configuration
|
||||
# Runs on main branch pushes to auto-determine version from conventional commits.
|
||||
# Creates a git tag + GitHub Release draft; a separate workflow builds binaries.
|
||||
# semantic-release configuration.
|
||||
#
|
||||
# Runs on pushes to main from .gitea/workflows/release.yml: determine the
|
||||
# version from the Conventional Commits since the last tag, write the
|
||||
# changelog, commit it, push the tag, and create the Gitea release.
|
||||
#
|
||||
# **There is no `@semantic-release/github` plugin here and there must not
|
||||
# be.** Gitea's API is `/api/v1` and is not GitHub's surface. The Gitea
|
||||
# community plugin (@saithodev/semantic-release-gitea) was considered and
|
||||
# rejected: last published 2022, depends on got@10, and declares no peer
|
||||
# dependency on semantic-release at all — i.e. untested against anything
|
||||
# since v19, against a core now at v25. `exec` is first-party, current,
|
||||
# and the Gitea-shaped part is one curl.
|
||||
#
|
||||
# The type list below is the one scripts/commit-check.sh enforces the
|
||||
# grammar for — keep the two in step, or semantic-release will silently
|
||||
# decline to release something the commit hook accepted.
|
||||
branches:
|
||||
- main
|
||||
|
||||
@@ -63,19 +77,41 @@ plugins:
|
||||
section: Build
|
||||
hidden: true
|
||||
|
||||
# Write CHANGELOG.md.
|
||||
# Render the notes to a file.
|
||||
#
|
||||
# **This plugin is here to carry the notes, not to maintain a document.**
|
||||
# It is how they reach the Gitea API *without being interpolated into a
|
||||
# shell command*: release notes are rendered commit messages — arbitrary
|
||||
# text carrying backticks, quotes and `$` — so templating
|
||||
# ${nextRelease.notes} into `publishCmd` would be a shell injection with
|
||||
# the commit log as its input. scripts/gitea-release.sh reads the top
|
||||
# section of this file instead, and the only thing interpolated below is
|
||||
# a semver string.
|
||||
#
|
||||
# The target is a gitignored build artifact rather than CHANGELOG.md,
|
||||
# because nothing commits it back — see below.
|
||||
- - "@semantic-release/changelog"
|
||||
- changelogFile: CHANGELOG.md
|
||||
- changelogFile: .release-notes.md
|
||||
changelogTitle: "# Release notes"
|
||||
|
||||
# Commit the changelog back to the repo.
|
||||
- - "@semantic-release/git"
|
||||
- assets:
|
||||
- CHANGELOG.md
|
||||
message: "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
|
||||
# Create the Gitea release, whose body is that section.
|
||||
# `publish` runs after `prepare`, so the tag already exists by here.
|
||||
- - "@semantic-release/exec"
|
||||
- publishCmd: "./scripts/gitea-release.sh ${nextRelease.version}"
|
||||
|
||||
# **There is deliberately no @semantic-release/git here.**
|
||||
#
|
||||
# `main` is a protected branch with `enable_push: false` and an empty
|
||||
# push whitelist, so a changelog commit-back would be rejected by the
|
||||
# pre-receive hook — *after* the tag had already been pushed, leaving a
|
||||
# tagged release the run then reported as failed. The alternative was to
|
||||
# whitelist the CI user, which weakens a protection someone set on
|
||||
# purpose and lets a bot push to main without passing the checks every
|
||||
# human PR has to.
|
||||
#
|
||||
# So the release page is the changelog. Tags are not protected, so the
|
||||
# tag push semantic-release does itself is unaffected. CHANGELOG.md in
|
||||
# the repo is a signpost to the releases page and is not written by any
|
||||
# of this; a file that claimed to be a changelog and silently stopped
|
||||
# updating would be worse than no file at all.
|
||||
|
||||
# Create the GitHub Release (draft, so the build workflow can attach binaries).
|
||||
- - "@semantic-release/github"
|
||||
- draft: true
|
||||
successComment: false
|
||||
failComment: false
|
||||
releasedLabels: false
|
||||
|
||||
+16
-372
@@ -1,377 +1,21 @@
|
||||
## [1.3.0](https://github.com/onion-4-dinner/yellowjacket/compare/v1.2.3...v1.3.0) (2026-03-20)
|
||||
# Changelog
|
||||
|
||||
### Features
|
||||
The changelog is the releases page:
|
||||
|
||||
* **09-01:** add scan control events and cancelled metrics field ([c695024](https://github.com/onion-4-dinner/yellowjacket/commit/c695024241a7513b8fedb3fbf7ff364d0515b392))
|
||||
* **09-01:** add scan control fields and per-scan cancellable context ([cf22e52](https://github.com/onion-4-dinner/yellowjacket/commit/cf22e52a64850a80b9fcc63c21d81313e6bd56ab))
|
||||
* **09-02:** add frontend keyboard shortcut service, store, and controller ([40d4815](https://github.com/onion-4-dinner/yellowjacket/commit/40d48151dd798b57eed9f54a572ae4735356d09e))
|
||||
* **09-02:** add shortcuts config package with default bindings and Wails persistence ([6285ca9](https://github.com/onion-4-dinner/yellowjacket/commit/6285ca9dc4e6f211197e377d01c485b1ef65c300))
|
||||
* **09-03:** add scan control UI with pause/resume/cancel and confirmation dialog ([3914369](https://github.com/onion-4-dinner/yellowjacket/commit/391436927c826f2f17a4523be7829aefc04a6b12))
|
||||
* **09-04:** add keyboard shortcuts section to config page with conflict detection ([0451fb3](https://github.com/onion-4-dinner/yellowjacket/commit/0451fb38805ff2c27e43deb152daa892e733d2db))
|
||||
* **10-01:** implement migration 6 and pre-migration backup ([1179f56](https://github.com/onion-4-dinner/yellowjacket/commit/1179f56c3680112692e71e8dc7ce946446fa8a8a))
|
||||
* **10-01:** update SQL schema files for multi-library fresh installs ([535855b](https://github.com/onion-4-dinner/yellowjacket/commit/535855b383a457dd2be3298b4361313bef22b39d))
|
||||
* **10-02:** add migration 6 integration tests and NewTestDBWithLibrary helper ([bc15189](https://github.com/onion-4-dinner/yellowjacket/commit/bc151891b50e59e41da2e00dbfafbecaad11b4ac))
|
||||
* **10-02:** add sqlc queries for libraries and update playlist queries for phantom support ([02548dd](https://github.com/onion-4-dinner/yellowjacket/commit/02548dd55e59b28f3d6c8d9614f209140c979250))
|
||||
* **11-01:** per-library scan pipeline with queue coordinator ([943db1c](https://github.com/onion-4-dinner/yellowjacket/commit/943db1cf274bdf59daf28ab6c20f78ef5ef53105))
|
||||
* **11-02:** update config-page with per-library progress display and queue-aware cancel dialog ([d01591d](https://github.com/onion-4-dinner/yellowjacket/commit/d01591d6cc054a63b832c05a3164a72fdcaba342))
|
||||
* **11-02:** update library-manager with per-library progress and Scan All button ([d61f122](https://github.com/onion-4-dinner/yellowjacket/commit/d61f122b567e8ac2b30fa96c637cbebc14493c89))
|
||||
* **12-01:** add queue compaction method and wire removal hooks ([5995dfd](https://github.com/onion-4-dinner/yellowjacket/commit/5995dfd01d61cd4d2c0749eeeee2a1f93b739d68))
|
||||
* **12-01:** implement library CRUD methods and orphan cleanup pipeline ([bd44f83](https://github.com/onion-4-dinner/yellowjacket/commit/bd44f8306c9129b9420ad81938bcf8105a1cb55a))
|
||||
* **12-02:** make config sections collapsible with chevron dropdown ([12c6782](https://github.com/onion-4-dinner/yellowjacket/commit/12c678284c7582bd85cd52722f4d405b0bd0e20f))
|
||||
* **12-02:** remove Libraries sidebar nav item and view routing ([e199712](https://github.com/onion-4-dinner/yellowjacket/commit/e199712a56e1cb3c0fc43d3340abb892a6f5fa7b))
|
||||
* **12-02:** replace config-page library section with full library management UI ([ffc5d96](https://github.com/onion-4-dinner/yellowjacket/commit/ffc5d9639cf7c916a4f846590ae0d67cf13afe27))
|
||||
* **12-02:** selectable library list with checkbox scan targeting ([13a42ae](https://github.com/onion-4-dinner/yellowjacket/commit/13a42aea2287d7ed0ec9ff9856f52c1fa7767338))
|
||||
* **12-02:** show scan progress bar inline in library list entry ([df824c6](https://github.com/onion-4-dinner/yellowjacket/commit/df824c6989e92b2aefaa1ddf05b131ee319612d8))
|
||||
* **13-01:** add library-filtered Go query methods and FTS search ([5f7de50](https://github.com/onion-4-dinner/yellowjacket/commit/5f7de5060a5bc557b96203267de694ef366ed507))
|
||||
* **13-01:** add library-filtered sqlc queries for all browse views ([5cc58ce](https://github.com/onion-4-dinner/yellowjacket/commit/5cc58ce66ab70d8d5a570df5067f79ae2201037e))
|
||||
* **13-02:** add library filter dropdown and wire all views to respect active filter ([42b8cf9](https://github.com/onion-4-dinner/yellowjacket/commit/42b8cf9f52133499ffcd7363bd39dd0c1069e091))
|
||||
* **15-01:** migrate FTS5 search_index to contentless_delete=1 ([cb5155b](https://github.com/onion-4-dinner/yellowjacket/commit/cb5155b8906357ff77c5c579d57d02cf2eec6abe))
|
||||
* **15-02:** create backend/fileutil package with AtomicWrite ([4d64b5d](https://github.com/onion-4-dinner/yellowjacket/commit/4d64b5dcfe43951e8ec63383bbf72c99107c63c4))
|
||||
* **16-01:** add selectAll() to SelectionController and dispatch shortcut:select-all event ([f567762](https://github.com/onion-4-dinner/yellowjacket/commit/f5677628ef283b67370630b564f23178e43da3d2))
|
||||
* **16-01:** wire shortcut:select-all listener in track-list, queue-panel, and playlist-view ([906ea28](https://github.com/onion-4-dinner/yellowjacket/commit/906ea28751ce9f96fdeeb9410ab5f6518f09fcb9))
|
||||
* **16-02:** add go-flac dependencies and implement FLAC tag writer ([3642cbe](https://github.com/onion-4-dinner/yellowjacket/commit/3642cbe0d58f8912a786a4fc5380c40403add94a))
|
||||
* **16-03:** implement DB sync module for tag write pipeline ([2966079](https://github.com/onion-4-dinner/yellowjacket/commit/2966079625cd42412411429af02184d015526e9b))
|
||||
* **16-03:** WriteTrackTags pipeline with player safety, scan mutex, events, and app wiring ([64322f9](https://github.com/onion-4-dinner/yellowjacket/commit/64322f93538515d5a3e486dc14691b9c9dcf6f66))
|
||||
* **17-01:** add TrackMetadataChanged handler and remove selection gate on Track Details ([fc5cf70](https://github.com/onion-4-dinner/yellowjacket/commit/fc5cf70e4c1be3d3f1545c140db5202601a08109))
|
||||
* **17-01:** add WriteTrackTagsByPath and ImageFilePicker backend methods ([4235b4a](https://github.com/onion-4-dinner/yellowjacket/commit/4235b4a4d555882ce86628a88dd4e4eeee2c9097))
|
||||
* **17-02:** implement save flow, cover art editing, and error handling ([265a9ea](https://github.com/onion-4-dinner/yellowjacket/commit/265a9ea8ceba893f956a03546e9ac4189adc7716))
|
||||
* **18-01:** add BatchWriteProgress event constant ([3dba0e1](https://github.com/onion-4-dinner/yellowjacket/commit/3dba0e143c091327d305d39d2fa7a687ec47e172))
|
||||
* **18-01:** add BatchWriteTrackTags with progress, cancellation, and partial failure ([f557ffd](https://github.com/onion-4-dinner/yellowjacket/commit/f557ffd652179b7cf8f8ff4a06824f30edf08007))
|
||||
* **18-02:** add batch edit mode to track-details component ([6dab32b](https://github.com/onion-4-dinner/yellowjacket/commit/6dab32b36b497d54e8645e969aa79737ad3523ab))
|
||||
* **18-02:** wire batch track-details to all 4 view context menus ([656985a](https://github.com/onion-4-dinner/yellowjacket/commit/656985add92663440baebb871f8cd6d5723117fd))
|
||||
* **19-01:** implement WAV RIFF parser/writer and writeWavTags ([e6610ff](https://github.com/onion-4-dinner/yellowjacket/commit/e6610ff15e041213b6898ad48ff63b7060b312e7))
|
||||
* **20-01:** implement OGG Vorbis tag writer with custom page parser and CRC32 ([5e98c03](https://github.com/onion-4-dinner/yellowjacket/commit/5e98c036342b9e174abdc6d00db21c2e2901f18b))
|
||||
* **quick-17:** create playlist-details subpage component ([dc5c7d6](https://github.com/onion-4-dinner/yellowjacket/commit/dc5c7d6ca6cfbfac15546c048f1b33aaf47209c6))
|
||||
* **quick-18:** replace track-info with multi-column grid layout in playlist-details ([ce23177](https://github.com/onion-4-dinner/yellowjacket/commit/ce2317722870f932792dc6456a63235ff4611466))
|
||||
<https://git.ljones.me/yonlu/yellowjacket/releases>
|
||||
|
||||
### Bug Fixes
|
||||
Every release there is generated from the Conventional Commits it
|
||||
contains, by `.gitea/workflows/release.yml` on merge to `main`. Each one
|
||||
carries its notes as its body, grouped by change type, with a link to the
|
||||
commit behind every line.
|
||||
|
||||
* **09-05:** emit VolumeChanged event and persist state in ChangeVolume and MuteToggle ([bb3fd20](https://github.com/onion-4-dinner/yellowjacket/commit/bb3fd204f0895f357a14479b40754f397aae74c4))
|
||||
* **10-01:** move library_id index to migration 6 to fix existing DB startup ([75b2a34](https://github.com/onion-4-dinner/yellowjacket/commit/75b2a349ebd6fada5cbc92bfae9854cc2cd53c63))
|
||||
* **12-02:** claim orphaned tracks when adding library with matching path ([f60b6b5](https://github.com/onion-4-dinner/yellowjacket/commit/f60b6b525546ef77a3329fe92f03f336b7435a0e))
|
||||
* **12-02:** count failed saves as skipped so scan progress bar advances ([b36e472](https://github.com/onion-4-dinner/yellowjacket/commit/b36e472212957ff089f4f5d35f3978a754e23502))
|
||||
* **12-02:** delete artist_credit_artist before artist_credit in removal pipeline ([890284d](https://github.com/onion-4-dinner/yellowjacket/commit/890284ddb1d0fb95e423bddf27b40fb0db2d11e5))
|
||||
* **12-02:** dismiss inline rename on click outside ([9272b06](https://github.com/onion-4-dinner/yellowjacket/commit/9272b060bf98118e37f19a8c0834034691bfe6a2))
|
||||
* **12-02:** downgrade per-file save error to Debug, add warning count to scan summary ([cf18c39](https://github.com/onion-4-dinner/yellowjacket/commit/cf18c39dbd849d60218228cf1d2285ab2071e788))
|
||||
* **12-02:** invalidate library store cache on LibraryRemoved event ([b093fbb](https://github.com/onion-4-dinner/yellowjacket/commit/b093fbb10a24054c4ef62b0bd13f28d9bfe6f121))
|
||||
* **12-02:** keep Add Library button visible during scan ([649e516](https://github.com/onion-4-dinner/yellowjacket/commit/649e516aa30090665e9f10e89c1ccce378e36b96))
|
||||
* **12-02:** move Add Library button inline with scan buttons ([771345d](https://github.com/onion-4-dinner/yellowjacket/commit/771345dd9d3870b3a907e1cce09c7456ab7ccd85))
|
||||
* **12-02:** move scan buttons above library list, default to none selected ([ba3f840](https://github.com/onion-4-dinner/yellowjacket/commit/ba3f840a28fe2c6ca40c558305814d29c233d6e0))
|
||||
* **12-02:** refresh library track counts after scan completes ([1f872aa](https://github.com/onion-4-dinner/yellowjacket/commit/1f872aa005a9405d9bc1f64a4b1dd2f1f1d4a16c))
|
||||
* **12-02:** reorder orphan cleanup to delete FK children before recordings ([1d735c3](https://github.com/onion-4-dinner/yellowjacket/commit/1d735c3a5f5a78996d6ddbe5c787adf040fe2f21))
|
||||
* **12-02:** replace removed Scan() import with ScanAllLibraries() ([0559822](https://github.com/onion-4-dinner/yellowjacket/commit/05598224e4d5532d2e2a3a7e5d3b5411240b1024))
|
||||
* **12-02:** resolve phantom tracks caused by empty library root after TOML cleanup ([717e249](https://github.com/onion-4-dinner/yellowjacket/commit/717e249c368fd1cc8d5c8f945c352175708691cf))
|
||||
* **12-02:** serialize ScanWarning.Err as string instead of error interface ([ac8cbb3](https://github.com/onion-4-dinner/yellowjacket/commit/ac8cbb3296bd561a305627668c211dce7209df25))
|
||||
* **12-02:** soft scan claims orphaned library_id=0 tracks on startup ([1ad099a](https://github.com/onion-4-dinner/yellowjacket/commit/1ad099a9d35fc722475e238d3443fd5473566acd))
|
||||
* **12-02:** soft scan on launch — only scan libraries with changed file counts ([92c4d23](https://github.com/onion-4-dinner/yellowjacket/commit/92c4d23a9a1e545fab497816ee3dce43a181cded))
|
||||
* **12-02:** wait for scan to stop before library removal, surface errors in UI ([cf00498](https://github.com/onion-4-dinner/yellowjacket/commit/cf004986c95732d00208e83467267904ea3f2ef6))
|
||||
* **13-02:** auto-resolve phantom playlist tracks after library scan ([93262b9](https://github.com/onion-4-dinner/yellowjacket/commit/93262b9ae0f737d2893839ac585776207b3b44b6))
|
||||
* **13-02:** defer virtualizer event delegation until element exists ([f05d2bb](https://github.com/onion-4-dinner/yellowjacket/commit/f05d2bb603f5ea827164466fd0795a6c6e662529))
|
||||
* **13-02:** resolve phantom playlist tracks using M3U8 paths after scan ([9f595b7](https://github.com/onion-4-dinner/yellowjacket/commit/9f595b7ac10c2191b5469004901cbbc1331c1abb))
|
||||
* **14-01:** downgrade main-panel from contain:strict to layout+style+paint ([4b7d35d](https://github.com/onion-4-dinner/yellowjacket/commit/4b7d35d7ec4c8b14453a8f8250cd154b8c4c2537))
|
||||
* **14-perf:** fix scroll jumping and input latency ([3b2e189](https://github.com/onion-4-dinner/yellowjacket/commit/3b2e189e7d0e6d00393d087565190fd307774257))
|
||||
* **17-02:** fix cover art replace and remove ([d7c2965](https://github.com/onion-4-dinner/yellowjacket/commit/d7c2965752ae0ac9009d00f2431d5919a24558b7))
|
||||
* **17-02:** handle float64 numeric values from Wails JSON deserialization ([900db2e](https://github.com/onion-4-dinner/yellowjacket/commit/900db2e56cca254873a3a5a7a384008feac4211b))
|
||||
* **17-02:** refresh cover art URLs after save ([8cd4914](https://github.com/onion-4-dinner/yellowjacket/commit/8cd4914842f61c0c6b49e0216c7816e201a3c94a))
|
||||
* **17-02:** refresh track-details dialog data after successful save ([ffcdc41](https://github.com/onion-4-dinner/yellowjacket/commit/ffcdc41b0d4fad8ed428dbaa55f6cdd38c096822))
|
||||
* **18-02:** add field labels above title/artist/album inputs in batch edit mode ([9df2d67](https://github.com/onion-4-dinner/yellowjacket/commit/9df2d6764a0b0566dda33cff675debea4a61dea8))
|
||||
* **18-02:** add field labels to all track-details states (single/batch, read/edit) ([d430ad8](https://github.com/onion-4-dinner/yellowjacket/commit/d430ad884bfd38bea93389d8be730ff00388a7be))
|
||||
* **19-01:** add album_artist TPE2 mapping to applyTextChanges ([8f4c4a0](https://github.com/onion-4-dinner/yellowjacket/commit/8f4c4a0c2b14eeeaeccb972a40addb11f3d65437))
|
||||
* preserve scroll position in cached grid views ([54df917](https://github.com/onion-4-dinner/yellowjacket/commit/54df917ffdd69c4f7ffaeccf2d161261ca80d84e))
|
||||
* **queue-panel:** set flow layout _itemSize to match actual track item height ([288d9de](https://github.com/onion-4-dinner/yellowjacket/commit/288d9deae22d437fcd7857b368827db7b62c24f6))
|
||||
* **queue-panel:** suppress virtualizer scroll corrections during scrollbar drag ([0bd8cef](https://github.com/onion-4-dinner/yellowjacket/commit/0bd8cefa00dcae2f8bd9579de2aefd58e0a9e6c9))
|
||||
* **quick-19:** multi-root path resolution for playlist M3U8 tracks ([9144ded](https://github.com/onion-4-dinner/yellowjacket/commit/9144dedc2742925dc252d491763b4f2929238d0e))
|
||||
* **S21/T01:** fix all lint warnings and upgrade wsl to wsl_v5 ([f16157a](https://github.com/onion-4-dinner/yellowjacket/commit/f16157a2134cbeb1787ff851d4875d77f2f3f86b))
|
||||
**This file is not generated and is not a copy of that.** `main` is a
|
||||
protected branch, so nothing pushes a changelog commit back to it — and a
|
||||
file that claimed to be a changelog while silently never updating would
|
||||
be worse than no file at all. `make release-dry` prints what the next
|
||||
merge would release.
|
||||
|
||||
### Performance
|
||||
|
||||
* **12-02:** increase scan batch size from 50 to 300 ([21ea71e](https://github.com/onion-4-dinner/yellowjacket/commit/21ea71e2575d76258bd81d89ab8ac883aa3bed36))
|
||||
* **12-02:** skip FTS5 rebuild during library removal ([30f4461](https://github.com/onion-4-dinner/yellowjacket/commit/30f4461e6957e20d3dc607fa0886a75b5c21b3cf))
|
||||
* **14-01:** add CSS containment to app shell layout boundaries ([efa06f7](https://github.com/onion-4-dinner/yellowjacket/commit/efa06f7edf1e4acdc3d8865cad264403257ae40d))
|
||||
* **14-01:** add GPU promotion and containment to all scroll containers ([ac8a52e](https://github.com/onion-4-dinner/yellowjacket/commit/ac8a52e110f9f8ebdc3433b60594370352126a18))
|
||||
* **14-02:** replace innerHTML navigation with view caching system ([ad91043](https://github.com/onion-4-dinner/yellowjacket/commit/ad9104374a628342e0ea30cf409ff43de2c2f86e))
|
||||
* **14-03:** add notification batching to queue store and granular change tracking to library store ([d0c05dc](https://github.com/onion-4-dinner/yellowjacket/commit/d0c05dc1d43a4fe12cc07f3cff25375b08a74ba0))
|
||||
* **14-03:** eliminate per-item closure allocation in scroll render paths ([2f7ed70](https://github.com/onion-4-dinner/yellowjacket/commit/2f7ed7030425ed0ebb7a1a186917a79a7b26b850))
|
||||
* **14-04:** RAF-throttle scroll position saves and add overflow-anchor to queue panel ([6ca0b3c](https://github.com/onion-4-dinner/yellowjacket/commit/6ca0b3c5a84769af064ebe45a6eaac014d1a270a))
|
||||
* auto-detect NVIDIA+Wayland for DMABuf workaround ([915591a](https://github.com/onion-4-dinner/yellowjacket/commit/915591aea962beb60da2e96ac0f57307f646f675))
|
||||
* inline SVGs, memoize grid slices, batch store notifications ([a4eac39](https://github.com/onion-4-dinner/yellowjacket/commit/a4eac394cebefd29d0ebcb4b1e331444dcb8fbaf))
|
||||
* reduce software rendering overhead for NVIDIA+Wayland ([199c910](https://github.com/onion-4-dinner/yellowjacket/commit/199c91013fd806f6aefce49357df8a32b46faaa0))
|
||||
|
||||
### Refactoring
|
||||
|
||||
* **quick-17:** simplify playlist-view to navigate instead of expand ([955cd68](https://github.com/onion-4-dinner/yellowjacket/commit/955cd68be2dbf7a9071ef1c93084d687b59b6bd7))
|
||||
|
||||
## [1.2.2](https://github.com/onion-4-dinner/yellowjacket/compare/v1.2.1...v1.2.2) (2026-03-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* recover from go-mp3 seek panic on startup ([#86](https://github.com/onion-4-dinner/yellowjacket/issues/86)) ([2f9d9f8](https://github.com/onion-4-dinner/yellowjacket/commit/2f9d9f8508b90b6188fe894c282c5b8e330e8046))
|
||||
|
||||
## [1.2.1](https://github.com/onion-4-dinner/yellowjacket/compare/v1.2.0...v1.2.1) (2026-03-06)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** pin go-webview2 to v1.0.21 for Wails v2 compat ([25f0fe8](https://github.com/onion-4-dinner/yellowjacket/commit/25f0fe81560eeff36a0b2beb52ce1bdf13d5e122))
|
||||
|
||||
## [1.2.0](https://github.com/onion-4-dinner/yellowjacket/compare/v1.1.3...v1.2.0) (2026-03-06)
|
||||
|
||||
### Features
|
||||
|
||||
* **02-02:** add ScanWarning type and reclassify scan errors as warnings ([e6866de](https://github.com/onion-4-dinner/yellowjacket/commit/e6866ded9dc0ea30ff942cd31b6c5ea3269e9584))
|
||||
* **03-01:** create NewTestDB helper for in-memory SQLite test databases ([bae9d70](https://github.com/onion-4-dinner/yellowjacket/commit/bae9d70d23157ef4e79e60dd713d9a02ab63790b))
|
||||
* **03-01:** extract shared applyPRAGMAs and add production PRAGMAs to NewDB ([d348815](https://github.com/onion-4-dinner/yellowjacket/commit/d34881530adda7fb75be84737798da46d17bfa8c))
|
||||
* **06-01:** create track_metadata VIEW schema and migration 4 ([9c7e5a9](https://github.com/onion-4-dinner/yellowjacket/commit/9c7e5a96344a81bf132de487b4763f1dc3ff6df9))
|
||||
* **06-02:** create Go→TypeScript event constant codegen tool ([3e9edd0](https://github.com/onion-4-dinner/yellowjacket/commit/3e9edd05e87395499ac24e456640d1f6d9b97f04))
|
||||
* **06-03:** migrate lookupChunk to sqlc-generated LookupTrackMetaByPaths query ([2221a68](https://github.com/onion-4-dinner/yellowjacket/commit/2221a68459850a837c996c6e6d2bc95d41b20fb3))
|
||||
* **08-01:** define design token CSS custom properties for icon sizes and type scale ([1444a66](https://github.com/onion-4-dinner/yellowjacket/commit/1444a66bb201ce5fdf16552a32bcd281089c64ed))
|
||||
* **08-04:** apply design tokens to cover-grid, track-list, queue-panel, and detail components ([1303422](https://github.com/onion-4-dinner/yellowjacket/commit/1303422e69c27d528363900b3ca5287a48cc9f8e))
|
||||
* **08-04:** convert sidebar em-based spacing to px and apply icon/type tokens ([aed90d7](https://github.com/onion-4-dinner/yellowjacket/commit/aed90d7b1710d0c5cece2e4956c0a6ce77b9a999))
|
||||
* add scan progress bar with phase indicator ([a28b4d1](https://github.com/onion-4-dinner/yellowjacket/commit/a28b4d1e0673658824750d4c702359321dc9a78e))
|
||||
* **quick-001:** add multi-file picker and batch import support ([c34e4ad](https://github.com/onion-4-dinner/yellowjacket/commit/c34e4ad029c119bff8f70a07ccc6bca58b11ea3c))
|
||||
* **quick-001:** regenerate bindings and update frontend for multi-import ([2a542bf](https://github.com/onion-4-dinner/yellowjacket/commit/2a542bf3bcdc7772edb1aceb41f488774494f656))
|
||||
* **quick-002:** add CountPlaylistsByName SQL query and regenerate sqlc ([04b2088](https://github.com/onion-4-dinner/yellowjacket/commit/04b2088b28b84a4d4df25b23d97112c5a955dff1))
|
||||
* **quick-002:** add uniquePlaylistName helper and wire into ImportPlaylist ([8ba8bbe](https://github.com/onion-4-dinner/yellowjacket/commit/8ba8bbe7bed2ecff97613ebaa42a49a662050353))
|
||||
* **quick-006:** remove list icon from playlists, add favorites icon to default ([3c19766](https://github.com/onion-4-dinner/yellowjacket/commit/3c19766fd0885d4171cf9929db6d69a3d5c1a3ff))
|
||||
* **quick-11:** add configurable log level via YJ_LOG_LEVEL env var ([55b4902](https://github.com/onion-4-dinner/yellowjacket/commit/55b4902fac7b7f2c04ad5efac398ecedc5fedc2f))
|
||||
* **quick-11:** add make dev-debug target for verbose logging ([c45bca4](https://github.com/onion-4-dinner/yellowjacket/commit/c45bca411ba1d4f32deea6027acf91237173dd15))
|
||||
* **quick-12:** add favorite icon to album dropdown track rows ([12a0bbc](https://github.com/onion-4-dinner/yellowjacket/commit/12a0bbc89c19128485d597a61bd16bd0786450ad))
|
||||
* **quick-15:** add BufferedStreamer with goroutine read-ahead ([85b23ac](https://github.com/onion-4-dinner/yellowjacket/commit/85b23acb24a048d2f7b85808e477bb991ae124e6))
|
||||
* **quick-15:** insert BufferedStreamer into player pipeline and increase speaker buffer ([8a0b16a](https://github.com/onion-4-dinner/yellowjacket/commit/8a0b16a4ec08a95bfd3834c8216e21dce854432d))
|
||||
* **quick-3:** add playlist-level multi-select state and selection handling ([e13151f](https://github.com/onion-4-dinner/yellowjacket/commit/e13151ffa5dc86e41ce242421679d65a740c3af0))
|
||||
* **quick-3:** wire playlist context menu for batch delete of selected playlists ([c92ced2](https://github.com/onion-4-dinner/yellowjacket/commit/c92ced2c74e72bfc123c880c047462dc969cde34))
|
||||
* **quick-4:** add 'Set as Default Playlist' context menu option ([9971b63](https://github.com/onion-4-dinner/yellowjacket/commit/9971b635b81fe3f8621c80a6664eccb3e1fc4bb8))
|
||||
* **quick-5:** add CreatedAt/UpdatedAt to playlist Summary struct ([bdaff47](https://github.com/onion-4-dinner/yellowjacket/commit/bdaff478e802ee5c0745327c52dd9b190fcfef7d))
|
||||
* **quick-5:** add sort dropdown UI and client-side sorting to playlist view ([5c07485](https://github.com/onion-4-dinner/yellowjacket/commit/5c074855351f1363cc7918837a78bbd3c0b7ebf5))
|
||||
* **quick-7:** add PinDefault config field with backend getter/setter ([6e123bd](https://github.com/onion-4-dinner/yellowjacket/commit/6e123bd47f55e6d565f20bf7f19950e65f80787f))
|
||||
* **quick-7:** wire frontend pin-default-playlist feature end-to-end ([e6378e1](https://github.com/onion-4-dinner/yellowjacket/commit/e6378e1f0d3b0f2a7604b8ef6097dba9050cdd16))
|
||||
* **quick-8:** add FindDuplicateTracksInPlaylist backend method ([83de934](https://github.com/onion-4-dinner/yellowjacket/commit/83de934c39ca7d850a8b5925c90e6d0b3fe0a487))
|
||||
* **quick-8:** create duplicate-tracks-dialog component ([9f3ba2b](https://github.com/onion-4-dinner/yellowjacket/commit/9f3ba2b9d474fa30dcb4934b01d4650e0d0d3cba))
|
||||
* **quick-8:** wire duplicate detection into playlist-picker and playlist-view ([917a79a](https://github.com/onion-4-dinner/yellowjacket/commit/917a79a8d6e30dddd2170323bb26692386794872))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **01-01:** add mutex protection to Queue, Library, and Playlist SetContext methods ([daaa6b7](https://github.com/onion-4-dinner/yellowjacket/commit/daaa6b7f9779385979fe9dddae4e7bb388b3e5fb))
|
||||
* **01-01:** collapse Player.SetContext double-lock into single acquisition ([3abaeba](https://github.com/onion-4-dinner/yellowjacket/commit/3abaeba3afb0f4d0edb81e26ca55b31bf59990ac))
|
||||
* **02-01:** eliminate package-level startupErr and fix config file permissions ([2a86408](https://github.com/onion-4-dinner/yellowjacket/commit/2a864082017e489ffa086c136f1002277a77a7c4))
|
||||
* **02-01:** log MPRIS callback errors instead of discarding them ([0860b2f](https://github.com/onion-4-dinner/yellowjacket/commit/0860b2fd4b2250da1eeb80c21f14fdf341697501))
|
||||
* **08-02:** revert repeat() inside lit-virtualizer, restore .renderItem + .keyFunction ([72ef719](https://github.com/onion-4-dinner/yellowjacket/commit/72ef719ba70eeca0fa4bae47df092706f6fbaeed))
|
||||
* drop+recreate contentless FTS5 index instead of DELETE ([8e9a616](https://github.com/onion-4-dinner/yellowjacket/commit/8e9a61603779eacbee7013b9bc760b315baf782a))
|
||||
* **frontend:** reposition search indicator into toolbar and fix album cover art lookup ([a29137b](https://github.com/onion-4-dinner/yellowjacket/commit/a29137b2ba4c6b33ce9a5f868cbd6013e0e3b116))
|
||||
* include full track metadata in GetAudioFilesByReleaseGroup query ([97f256d](https://github.com/onion-4-dinner/yellowjacket/commit/97f256d67f463d752f7adc5b400c4bf34eae1df1))
|
||||
* **quick-10:** add migration 5 and fix entity cache for composite album key ([d43ba7b](https://github.com/onion-4-dinner/yellowjacket/commit/d43ba7bd0c7ace2a9ed71990a19498f8e9f90751))
|
||||
* **quick-10:** update release_groups schema and queries for composite uniqueness ([999ab96](https://github.com/onion-4-dinner/yellowjacket/commit/999ab967beb9107a3f30ba287acbffad22f0b0de))
|
||||
* **quick-13:** resolve lint issues in main source files ([e1a95e6](https://github.com/onion-4-dinner/yellowjacket/commit/e1a95e65a9f0f436b2e2d92befa9c881b6e8e430))
|
||||
* **quick-14:** add roll-back-on-failure to queue index advancement ([2820de2](https://github.com/onion-4-dinner/yellowjacket/commit/2820de2510560fcd6d1015c18542d5ac30468247))
|
||||
* **quick-9:** set fixed height on queue track items for stable virtualizer scroll ([ebde5e5](https://github.com/onion-4-dinner/yellowjacket/commit/ebde5e5a8bc4da8f40bef8f171c7ed86c213a336))
|
||||
|
||||
### Performance
|
||||
|
||||
* **07-01:** add incremental persistence helpers for queue mutations ([cdd17db](https://github.com/onion-4-dinner/yellowjacket/commit/cdd17db27509908514c21517631306655a2b3bd7))
|
||||
* **07-01:** eliminate redundant lookups in SetQueue Phase 2 ([ced58fe](https://github.com/onion-4-dinner/yellowjacket/commit/ced58fe6a93d6f220137562b8ff09ffc33c69266))
|
||||
* **07-02:** defer eagerFetch to after DOM ready for instant app shell ([cd98ad6](https://github.com/onion-4-dinner/yellowjacket/commit/cd98ad6dc8c2e4e6e0f01a48099b0c0511bf5a98))
|
||||
* **08-01:** add queueMicrotask coalescing to library store and debounce search input ([3bf66ed](https://github.com/onion-4-dinner/yellowjacket/commit/3bf66ed125ed55bfbde95b0bc973710c2f2243b8))
|
||||
* **08-02:** migrate cover-grid, artists-view, and genres-view virtualizers to repeat() directive ([1c3514d](https://github.com/onion-4-dinner/yellowjacket/commit/1c3514da1d0491b9758d7a6f9f72d59ef78fc8ed))
|
||||
* **08-02:** migrate track-list and queue-panel virtualizers to repeat() directive ([d2d7d8c](https://github.com/onion-4-dinner/yellowjacket/commit/d2d7d8c6ce22923772cae4858b02804d15f74bb7))
|
||||
* **08-03:** optimize column rendering and apply classMap to queue-panel renderTrackItem ([62f41c2](https://github.com/onion-4-dinner/yellowjacket/commit/62f41c24910632b270f9f5765e20e48db4b95ec9))
|
||||
* **08-03:** replace class string construction with classMap directive in renderTrackRow ([ad21027](https://github.com/onion-4-dinner/yellowjacket/commit/ad210278fc20729dc76390e6bba9bff050549046))
|
||||
|
||||
### Refactoring
|
||||
|
||||
* **06-01:** consolidate search queries to use track_metadata VIEW ([9159b40](https://github.com/onion-4-dinner/yellowjacket/commit/9159b409dcd2afaa7dcc97bf5b0694edf85f06a4))
|
||||
* **quick-14:** make playOrLoadCurrentTrack and playCurrentTrack return bool ([6eeddda](https://github.com/onion-4-dinner/yellowjacket/commit/6eeddda97669258cc5b7ba175a3c98d598a2871f))
|
||||
|
||||
## [1.1.3](https://github.com/onion-4-dinner/yellowjacket/compare/v1.1.2...v1.1.3) (2026-02-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add typescript as explicit devDependency and auto-install frontend deps in setup ([#70](https://github.com/onion-4-dinner/yellowjacket/issues/70)) ([7316587](https://github.com/onion-4-dinner/yellowjacket/commit/73165877fa79656ab9bc6f60bd8e9e52d6be206c))
|
||||
* use local tsc binary in pre-commit hook to avoid PATH issues ([#71](https://github.com/onion-4-dinner/yellowjacket/issues/71)) ([6079e55](https://github.com/onion-4-dinner/yellowjacket/commit/6079e558ff913d38c7f1c4aeb52cc09474c4ed20))
|
||||
|
||||
## [1.1.2](https://github.com/onion-4-dinner/yellowjacket/compare/v1.1.1...v1.1.2) (2026-02-15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* r2 upload ([#69](https://github.com/onion-4-dinner/yellowjacket/issues/69)) ([0252466](https://github.com/onion-4-dinner/yellowjacket/commit/0252466f615b4e2fd9694790c6d311a9eac1ccf2))
|
||||
|
||||
## [1.1.1](https://github.com/onion-4-dinner/yellowjacket/compare/v1.1.0...v1.1.1) (2026-02-15)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** remove build-check job from CI workflow ([#66](https://github.com/onion-4-dinner/yellowjacket/issues/66)) ([42d3f45](https://github.com/onion-4-dinner/yellowjacket/commit/42d3f45d85afa694e9545997af3ff4ac814ad021))
|
||||
|
||||
## [1.1.0](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.3...v1.1.0) (2026-02-15)
|
||||
|
||||
### Features
|
||||
|
||||
* **ci:** upload release artifacts to Cloudflare R2 ([#65](https://github.com/onion-4-dinner/yellowjacket/issues/65)) ([8985084](https://github.com/onion-4-dinner/yellowjacket/commit/89850848cbf7783e5c85348ff18f7cd11d60231a))
|
||||
|
||||
## [1.0.3](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.2...v1.0.3) (2026-02-15)
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **deps:** update module github.com/evilmartians/lefthook to v2 (#61)
|
||||
* **deps:** update actions/checkout action to v6 (#45)
|
||||
* **deps:** update dependency vite to v7 (#53)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* resolve all lint errors and make linting a required CI check ([#62](https://github.com/onion-4-dinner/yellowjacket/issues/62)) ([30b2480](https://github.com/onion-4-dinner/yellowjacket/commit/30b2480df49f57878b0e8c923da6ad8d6fe99416))
|
||||
* virtual list and cover grid ([#63](https://github.com/onion-4-dinner/yellowjacket/issues/63)) ([7579a76](https://github.com/onion-4-dinner/yellowjacket/commit/7579a768be84225ed46db4e7a90781f3e30e2953))
|
||||
|
||||
### Miscellaneous
|
||||
|
||||
* **deps:** update actions/checkout action to v6 ([#45](https://github.com/onion-4-dinner/yellowjacket/issues/45)) ([2d6e221](https://github.com/onion-4-dinner/yellowjacket/commit/2d6e22105d2daed1dc5b586c0442e2941949a165))
|
||||
* **deps:** update dependency vite to v7 ([#53](https://github.com/onion-4-dinner/yellowjacket/issues/53)) ([f0006c4](https://github.com/onion-4-dinner/yellowjacket/commit/f0006c4c4335b60b58cccdd29de4792965e39694))
|
||||
* **deps:** update module github.com/evilmartians/lefthook to v2 ([#61](https://github.com/onion-4-dinner/yellowjacket/issues/61)) ([e32b217](https://github.com/onion-4-dinner/yellowjacket/commit/e32b2179129ae7f26037697a125710ff7587566d))
|
||||
|
||||
## [1.0.2](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.1...v1.0.2) (2026-02-14)
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **deps:** update actions/setup-node action to v6 (#48)
|
||||
* **deps:** update dependency stylelint-config-standard to v40 (#52)
|
||||
* **deps:** update dependency node to v24 (#51)
|
||||
* **deps:** update dependency vite-plugin-static-copy to v3 (#54)
|
||||
* **deps:** update golangci/golangci-lint-action action to v9 (#55)
|
||||
* **deps:** update amannn/action-semantic-pull-request action to v6 (#50)
|
||||
* **deps:** update actions/upload-artifact action to v6 (#49)
|
||||
* **deps:** update actions/setup-go action to v6 (#47)
|
||||
* **deps:** update actions/download-artifact action to v7 (#46)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** use allowedPostUpgradeCommands for Renovate post-upgrade tasks ([#60](https://github.com/onion-4-dinner/yellowjacket/issues/60)) ([0aef483](https://github.com/onion-4-dinner/yellowjacket/commit/0aef483b3cccd0616fd5be2d06d0856b46851d09))
|
||||
|
||||
### Miscellaneous
|
||||
|
||||
* **deps:** update actions/download-artifact action to v7 ([#46](https://github.com/onion-4-dinner/yellowjacket/issues/46)) ([1910f99](https://github.com/onion-4-dinner/yellowjacket/commit/1910f99cf64e9bdc5ce91e89cab254ecca15d030))
|
||||
* **deps:** update actions/setup-go action to v6 ([#47](https://github.com/onion-4-dinner/yellowjacket/issues/47)) ([8911fb2](https://github.com/onion-4-dinner/yellowjacket/commit/8911fb2400047cf2f3dfa719edc1d1bf474cdaa5))
|
||||
* **deps:** update actions/setup-node action to v6 ([#48](https://github.com/onion-4-dinner/yellowjacket/issues/48)) ([d7382fd](https://github.com/onion-4-dinner/yellowjacket/commit/d7382fd8444b6618dbfe991f5f97231528a07f13))
|
||||
* **deps:** update actions/upload-artifact action to v6 ([#49](https://github.com/onion-4-dinner/yellowjacket/issues/49)) ([a2c644b](https://github.com/onion-4-dinner/yellowjacket/commit/a2c644b00eed83acc0ed38a2eb8c73868b7b79af))
|
||||
* **deps:** update amannn/action-semantic-pull-request action to v6 ([#50](https://github.com/onion-4-dinner/yellowjacket/issues/50)) ([643ba27](https://github.com/onion-4-dinner/yellowjacket/commit/643ba27f066164aeb47e8d9aaf20fe98b9b69d30))
|
||||
* **deps:** update dependency node to v24 ([#51](https://github.com/onion-4-dinner/yellowjacket/issues/51)) ([e7d3971](https://github.com/onion-4-dinner/yellowjacket/commit/e7d39711078ce86b0c029f0d03ff81162c5dc28a))
|
||||
* **deps:** update dependency stylelint-config-standard to v40 ([#52](https://github.com/onion-4-dinner/yellowjacket/issues/52)) ([422aabc](https://github.com/onion-4-dinner/yellowjacket/commit/422aabcc07e9700ff189302b363e13d87c69163a))
|
||||
* **deps:** update dependency vite-plugin-static-copy to v3 ([#54](https://github.com/onion-4-dinner/yellowjacket/issues/54)) ([77fa643](https://github.com/onion-4-dinner/yellowjacket/commit/77fa6435a5298f58ef83607d99c59b876132c66c))
|
||||
* **deps:** update golangci/golangci-lint-action action to v9 ([#55](https://github.com/onion-4-dinner/yellowjacket/issues/55)) ([aedb7d1](https://github.com/onion-4-dinner/yellowjacket/commit/aedb7d1e6d204c56c468dd26b340752fd6bfeaeb))
|
||||
|
||||
## [1.0.1](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.0...v1.0.1) (2026-02-14)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* resolve Renovate repo detection and pre-push hook hang ([#36](https://github.com/onion-4-dinner/yellowjacket/issues/36)) ([b205889](https://github.com/onion-4-dinner/yellowjacket/commit/b205889128f01e9eb75b607cf7c4034887cda3f4))
|
||||
|
||||
## 1.0.0 (2026-02-14)
|
||||
|
||||
### Features
|
||||
|
||||
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow library to initialize without config and fix lefthook lint flag ([5a958db](https://github.com/onion-4-dinner/yellowjacket/commit/5a958db16284a74e19c43259757b163b347cda7d))
|
||||
* **ci:** configure git credentials explicitly for semantic-release PAT ([24f21af](https://github.com/onion-4-dinner/yellowjacket/commit/24f21af8350227e77fc1fef9243c238e6417aca0))
|
||||
* **ci:** fix golangci-lint version, skip player test in CI, remove standalone frontend build ([7317e09](https://github.com/onion-4-dinner/yellowjacket/commit/7317e093a7f92651ab65b2f83381d02105bdc0df))
|
||||
* **ci:** resolve CI failures for Go checks, codegen, and frontend type-checking ([d4f9361](https://github.com/onion-4-dinner/yellowjacket/commit/d4f936143ac75fbf3247cdbe2113bd89b0795d83))
|
||||
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
|
||||
* rename downloaded artifacts to platform-specific names for release ([e3bda0e](https://github.com/onion-4-dinner/yellowjacket/commit/e3bda0e2fc7700fad382cabe00aeb46f91fbb0a0))
|
||||
* resolve frontend build failures in CI ([330a53c](https://github.com/onion-4-dinner/yellowjacket/commit/330a53c9f4b1292840ad0f75479b76b3d429c954))
|
||||
* trigger build workflow from release event instead of tag push ([47772f7](https://github.com/onion-4-dinner/yellowjacket/commit/47772f73cc04093c55414bf20ebe2ef442418d19))
|
||||
* use path.Join for embed.FS paths to fix Windows build ([672fe24](https://github.com/onion-4-dinner/yellowjacket/commit/672fe24ee99debf4a394fff7eec55f17b0e44476))
|
||||
|
||||
## 1.0.0 (2026-02-14)
|
||||
|
||||
### Features
|
||||
|
||||
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow library to initialize without config and fix lefthook lint flag ([5a958db](https://github.com/onion-4-dinner/yellowjacket/commit/5a958db16284a74e19c43259757b163b347cda7d))
|
||||
* **ci:** configure git credentials explicitly for semantic-release PAT ([24f21af](https://github.com/onion-4-dinner/yellowjacket/commit/24f21af8350227e77fc1fef9243c238e6417aca0))
|
||||
* **ci:** fix golangci-lint version, skip player test in CI, remove standalone frontend build ([7317e09](https://github.com/onion-4-dinner/yellowjacket/commit/7317e093a7f92651ab65b2f83381d02105bdc0df))
|
||||
* **ci:** resolve CI failures for Go checks, codegen, and frontend type-checking ([d4f9361](https://github.com/onion-4-dinner/yellowjacket/commit/d4f936143ac75fbf3247cdbe2113bd89b0795d83))
|
||||
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
|
||||
* resolve frontend build failures in CI ([330a53c](https://github.com/onion-4-dinner/yellowjacket/commit/330a53c9f4b1292840ad0f75479b76b3d429c954))
|
||||
* trigger build workflow from release event instead of tag push ([47772f7](https://github.com/onion-4-dinner/yellowjacket/commit/47772f73cc04093c55414bf20ebe2ef442418d19))
|
||||
* use path.Join for embed.FS paths to fix Windows build ([672fe24](https://github.com/onion-4-dinner/yellowjacket/commit/672fe24ee99debf4a394fff7eec55f17b0e44476))
|
||||
|
||||
## [1.0.3](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.2...v1.0.3) (2026-02-14)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* use path.Join for embed.FS paths to fix Windows build ([672fe24](https://github.com/onion-4-dinner/yellowjacket/commit/672fe24ee99debf4a394fff7eec55f17b0e44476))
|
||||
|
||||
## [1.0.2](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.1...v1.0.2) (2026-02-14)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* resolve frontend build failures in CI ([330a53c](https://github.com/onion-4-dinner/yellowjacket/commit/330a53c9f4b1292840ad0f75479b76b3d429c954))
|
||||
|
||||
## [1.0.1](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.0...v1.0.1) (2026-02-14)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow library to initialize without config and fix lefthook lint flag ([5a958db](https://github.com/onion-4-dinner/yellowjacket/commit/5a958db16284a74e19c43259757b163b347cda7d))
|
||||
|
||||
## 1.0.0 (2026-02-14)
|
||||
|
||||
### Features
|
||||
|
||||
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** configure git credentials explicitly for semantic-release PAT ([24f21af](https://github.com/onion-4-dinner/yellowjacket/commit/24f21af8350227e77fc1fef9243c238e6417aca0))
|
||||
* **ci:** fix golangci-lint version, skip player test in CI, remove standalone frontend build ([7317e09](https://github.com/onion-4-dinner/yellowjacket/commit/7317e093a7f92651ab65b2f83381d02105bdc0df))
|
||||
* **ci:** resolve CI failures for Go checks, codegen, and frontend type-checking ([d4f9361](https://github.com/onion-4-dinner/yellowjacket/commit/d4f936143ac75fbf3247cdbe2113bd89b0795d83))
|
||||
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
|
||||
|
||||
## [1.0.2](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.1...v1.0.2) (2026-02-14)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** fix golangci-lint version, skip player test in CI, remove standalone frontend build ([7317e09](https://github.com/onion-4-dinner/yellowjacket/commit/7317e093a7f92651ab65b2f83381d02105bdc0df))
|
||||
|
||||
## [1.0.1](https://github.com/onion-4-dinner/yellowjacket/compare/v1.0.0...v1.0.1) (2026-02-14)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** resolve CI failures for Go checks, codegen, and frontend type-checking ([d4f9361](https://github.com/onion-4-dinner/yellowjacket/commit/d4f936143ac75fbf3247cdbe2113bd89b0795d83))
|
||||
|
||||
## 1.0.0 (2026-02-14)
|
||||
|
||||
### Features
|
||||
|
||||
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** configure git credentials explicitly for semantic-release PAT ([24f21af](https://github.com/onion-4-dinner/yellowjacket/commit/24f21af8350227e77fc1fef9243c238e6417aca0))
|
||||
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
|
||||
|
||||
## 1.0.0 (2026-02-14)
|
||||
|
||||
### Features
|
||||
|
||||
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** configure git credentials explicitly for semantic-release PAT ([24f21af](https://github.com/onion-4-dinner/yellowjacket/commit/24f21af8350227e77fc1fef9243c238e6417aca0))
|
||||
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
|
||||
|
||||
## 1.0.0 (2026-02-14)
|
||||
|
||||
### Features
|
||||
|
||||
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** use PAT for semantic-release to trigger build workflow ([68d41c0](https://github.com/onion-4-dinner/yellowjacket/commit/68d41c0ff22fede57acab7a2bfed42df7814bb90))
|
||||
|
||||
## 1.0.0 (2026-02-14)
|
||||
|
||||
### Features
|
||||
|
||||
* **ci:** add semantic-release pipeline, cross-platform builds, and lefthook git hooks ([caf3e84](https://github.com/onion-4-dinner/yellowjacket/commit/caf3e843af7da37e05da36da5c41b6dc3c53ded1))
|
||||
History before `v0.0.1` is in `git log`. The versions before it were cut
|
||||
by hand and are not on the releases page; the entries this file used to
|
||||
hold were generated against a GitHub remote this project no longer has,
|
||||
and every link in them was dead.
|
||||
|
||||
@@ -2,17 +2,326 @@ VERSION ?= dev
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
LDFLAGS := -X 'main.version=$(VERSION)' -X 'main.commit=$(COMMIT)'
|
||||
|
||||
# YJ_HOME isolates the dev build's config + database from a packaged
|
||||
# install. Defaults to a sandbox under XDG data; override in .env to
|
||||
# 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; go tool wails dev -tags webkit2_41 -loglevel Debug -v 2
|
||||
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_LOG_LEVEL=debug go tool wails dev -tags webkit2_41 -loglevel Debug -v 2
|
||||
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 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)
|
||||
|
||||
dev-headless-fresh: ## Same, but on an empty YJ_HOME (first-run wizard)
|
||||
@./scripts/dev-headless.sh --fresh $(HEADLESS_ARGS)
|
||||
|
||||
dev-stop: ## Stop the headless app (SIGTERM, so shutdown hooks run)
|
||||
@./scripts/dev-stop.sh
|
||||
|
||||
dev-logs: ## Tail the headless app log
|
||||
@tail -f .dev/app.log
|
||||
|
||||
# ---------------------------------------------------------------- #
|
||||
# The Android tier. See .pi/skills/yellowjacket-dev/references/ #
|
||||
# android-tier.md for which of these to reach for and why a failure #
|
||||
# here looks like nothing at all. #
|
||||
# ---------------------------------------------------------------- #
|
||||
|
||||
# The NDK is pinned: r26d is what the pipeline is built and checked
|
||||
# against, and newer NDKs have broken Wails' Android build before.
|
||||
# ANDROID_HOME must carry a *platform*, which Arch's /opt/android-sdk
|
||||
# does not — hence the separate default.
|
||||
ANDROID_SDK ?= $(HOME)/Android/Sdk
|
||||
ANDROID_NDK ?= /opt/android-ndk
|
||||
ANDROID_ENV := ANDROID_HOME=$(ANDROID_SDK) ANDROID_SDK_ROOT=$(ANDROID_SDK) ANDROID_NDK_HOME=$(ANDROID_NDK)
|
||||
|
||||
# `package`, not `package:fat`: x86_64 Android cannot run this app at
|
||||
# all (modernc's raw lstat vs Android's seccomp -- see
|
||||
# android-tier.md), so the second ABI was ~31 MB that could not run
|
||||
# anywhere. app/build.gradle's abiFilters says the same thing to
|
||||
# Gradle; both have to agree or the .so is built and then dropped.
|
||||
android: build-frontend ## Build the arm64 APK into bin/
|
||||
@$(ANDROID_ENV) PATH="$(TOOLBIN):$$PATH" go tool wails3 task android:package
|
||||
|
||||
android-setup: ## Install the SDK pieces and create the AVD (once, ~3.5GB)
|
||||
@$(ANDROID_ENV) ./scripts/android-emulator.sh setup
|
||||
|
||||
android-emulator: ## Boot the emulator headless in the background and wait for it
|
||||
@$(ANDROID_ENV) ./scripts/android-emulator.sh start
|
||||
|
||||
android-emulator-stop: ## Shut the emulator down (console kill, then saved PID)
|
||||
@$(ANDROID_ENV) ./scripts/android-emulator.sh stop
|
||||
|
||||
android-install: ## Install bin/yellowjacket.apk onto the running emulator
|
||||
@$(ANDROID_ENV) ./scripts/android-emulator.sh install
|
||||
|
||||
android-launch: ## Force-stop, clear logcat, and start the app
|
||||
@$(ANDROID_ENV) ./scripts/android-emulator.sh launch
|
||||
|
||||
android-logs: ## Tail logcat, filtered to the app's own tags
|
||||
@$(ANDROID_ENV) ./scripts/android-emulator.sh logs
|
||||
|
||||
# The only tier that can see the platform is the one you can look at.
|
||||
android-screenshot: ## Grab the device screen (OUT=<path>)
|
||||
@$(ANDROID_ENV) ./scripts/android-emulator.sh screenshot $(OUT)
|
||||
|
||||
# The page's own answer, from the engine that is really rendering it.
|
||||
# Needs the debug build installed (it is a sibling id, so it does not
|
||||
# disturb the release app): see scripts/android-eval.mjs.
|
||||
android-inspect: ## Forward the device WebView's devtools socket
|
||||
@$(ANDROID_ENV) ./scripts/android-emulator.sh inspect
|
||||
|
||||
android-eval: ## Evaluate JS in the device WebView (EXPR='...')
|
||||
@node ./scripts/android-eval.mjs $(if $(EXPR),'$(EXPR)',)
|
||||
|
||||
# "Did it start" is the wrong question — a crash-looping app starts
|
||||
# several times a second. This asserts the *same pid* is still there.
|
||||
android-smoke: ## Launch and assert the app is still alive (SECONDS=<n>)
|
||||
@$(ANDROID_ENV) ./scripts/android-emulator.sh smoke $(if $(SECONDS),$(SECONDS),10)
|
||||
|
||||
# Seeds are produced by *running the app* — driving the real AddLibrary
|
||||
# binding and waiting for the real scan — never by hand-writing a
|
||||
# config.toml and DB rows. A hand-built seed is a second description
|
||||
# of a valid YJ_HOME and would drift from the real one.
|
||||
sandbox-seed: testdata ## Build a seeded YJ_HOME snapshot: make sandbox-seed NAME=<n>
|
||||
@./scripts/seed-sandbox.sh $(if $(NAME),--name $(NAME),)
|
||||
|
||||
# The bulk seed is the *measurement* seed, not a fixture seed. Same
|
||||
# script and the same discipline (the app builds it by scanning for
|
||||
# real); the only difference is which manifest it is pointed at. It is
|
||||
# a separate target because a 50 000-track scan is minutes, and nothing
|
||||
# routine should depend on it.
|
||||
sandbox-seed-bulk: bulkdata ## Build a seeded YJ_HOME from the bulk library
|
||||
@./scripts/seed-sandbox.sh --name $(if $(NAME),$(NAME),bulk) \
|
||||
--manifest .dev/music_library_bulk.manifest.json
|
||||
|
||||
sandbox-seeds: ## List built seeds
|
||||
@ls -1 .dev/seeds/*.tar 2>/dev/null | sed 's|.*/||; s|\.tar$$||' \
|
||||
|| echo " (none; build one with: make sandbox-seed NAME=default)"
|
||||
|
||||
# The specs drive the app that is *already* running: `make dev-headless`
|
||||
# daemonises, which is the opposite of what Playwright's `webServer`
|
||||
# supervises, and starting one per run would rebuild the frontend every
|
||||
# time. globalSetup fails with the exact commands to run if it is down.
|
||||
# Phase 4 of plan 007 is verified by measurement rather than assertion,
|
||||
# so this is not a spec and does not run in CI: it produces a number to
|
||||
# read, against a running app seeded with the bulk library.
|
||||
#
|
||||
# make sandbox-seed-bulk && make dev-headless SEED=bulk
|
||||
# make perf LABEL=before ... change something ... make perf LABEL=after
|
||||
# make perf-compare BEFORE=before AFTER=after
|
||||
perf: ## Take a performance measurement (LABEL=<name>) of a running app
|
||||
@cd e2e && pnpm install --silent && \
|
||||
node perf/measure.mjs --label $(if $(LABEL),$(LABEL),current)
|
||||
|
||||
perf-compare: ## Print a before/after table: BEFORE=<a> AFTER=<b>
|
||||
@cd e2e && node perf/measure.mjs --compare \
|
||||
$(if $(BEFORE),$(BEFORE),before) $(if $(AFTER),$(AFTER),after)
|
||||
|
||||
e2e: ## Run the Playwright smoke suite against a running dev-headless app
|
||||
@cd e2e && pnpm install --silent && npx playwright test $(E2E_ARGS)
|
||||
|
||||
e2e-setup: ## Install the e2e runner and its browser (once)
|
||||
@cd e2e && pnpm install && npx playwright install chromium
|
||||
|
||||
e2e-report: ## Open the HTML report from the last e2e run
|
||||
@cd e2e && npx playwright show-report
|
||||
|
||||
# The cheapest tier: components and stores in a real browser, with no
|
||||
# Wails, no backend, no seeded library and no virtual display. Lives in
|
||||
# frontend/ rather than e2e/ so the Vitest browser provider and the
|
||||
# Playwright runner cannot fight over versions or globs.
|
||||
ui-test: ## Run the Vitest component and store suite (frontend/)
|
||||
@cd frontend && pnpm install --silent && npx vitest run $(UI_ARGS)
|
||||
|
||||
ui-watch: ## Same suite, in watch mode
|
||||
@cd frontend && npx vitest
|
||||
|
||||
# Visual regression is opt-in: toMatchScreenshot baselines depend on
|
||||
# font hinting and compositing, so they only mean anything on the
|
||||
# machine (or container) that took them.
|
||||
ui-visual: ## Run the suite including screenshot comparisons
|
||||
@cd frontend && YJ_VISUAL=1 npx vitest run $(UI_ARGS)
|
||||
|
||||
ui-visual-update: ## Re-record the screenshot baselines
|
||||
@cd frontend && YJ_VISUAL=1 npx vitest run --update $(UI_ARGS)
|
||||
|
||||
ui-setup: ## Install the Vitest browser provider's own Chromium (once)
|
||||
@cd frontend && pnpm install && npx playwright install chromium
|
||||
|
||||
# Bindings are generated by `wails3`, NOT by `go generate`, so the
|
||||
# pre-commit codegen check does not cover them: a renamed Go struct
|
||||
# field would otherwise surface at runtime, inside a window.
|
||||
bindings-check: ## Fail if the generated bindings are stale
|
||||
@./scripts/bindings-check.sh
|
||||
|
||||
# A backtick inside a comment in a css`` literal ends the literal, and
|
||||
# what you get back is a type error about CSSResult, or every test in
|
||||
# the suite failing to import. Four sessions, three plans. Instant.
|
||||
.PHONY: css-check
|
||||
css-check: ## Fail if a css`` literal was ended early by a backtick in a comment
|
||||
@cd frontend && node scripts/check-css-literals.mjs
|
||||
|
||||
# .pi/ and CLAUDE.md document commands, and a doc that documents a
|
||||
# command wrongly is worse than no doc: an agent runs it confidently.
|
||||
# Every command in them is a make target on purpose, so this is
|
||||
# checkable. It also asserts AGENTS.md is a symlink to CLAUDE.md, so the
|
||||
# two harnesses cannot drift onto two descriptions of one project.
|
||||
skill-check: ## Fail if the agent docs name a missing make target, or AGENTS.md is not a symlink
|
||||
@./scripts/skill-check.sh
|
||||
|
||||
# Conventional Commits, which CLAUDE.md claimed CI enforced for a long
|
||||
# time before anything did. RANGE=A..B lints a push; bare lints HEAD.
|
||||
commit-check: ## Fail if a commit subject is not a Conventional Commit
|
||||
@./scripts/commit-check.sh $(if $(RANGE),--range $(RANGE))
|
||||
|
||||
# What a merge to main would release, without releasing it. Reads the
|
||||
# same .releaserc.yml CI does, so "why did that not cut a version" is
|
||||
# answerable locally instead of by pushing and watching. Needs no
|
||||
# credentials: --dry-run neither tags nor publishes.
|
||||
#
|
||||
# The pins must stay identical to release.yml's, which is where the note
|
||||
# on holding the conventionalcommits preset at 9 lives -- at 10 the
|
||||
# release notes come out empty with everything green.
|
||||
release-dry: ## Print the version a merge to main would release
|
||||
@npx --yes \
|
||||
-p semantic-release@25 \
|
||||
-p @semantic-release/commit-analyzer@13 \
|
||||
-p @semantic-release/release-notes-generator@14 \
|
||||
-p @semantic-release/changelog@7 \
|
||||
-p @semantic-release/exec@7 \
|
||||
-p conventional-changelog-conventionalcommits@9 \
|
||||
semantic-release --dry-run --no-ci
|
||||
|
||||
# v3 generates TypeScript into frontend/bindings/, nested by Go import
|
||||
# path, rather than v2's frontend/wailsjs/. The `@go` alias absorbs the
|
||||
# constant prefix, so a call site imports '@go/library/library.js'.
|
||||
#
|
||||
# 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
|
||||
|
||||
.PHONY: dev-headless dev-headless-fresh dev-stop dev-logs \
|
||||
sandbox-seed sandbox-seed-bulk sandbox-seeds e2e e2e-setup e2e-report \
|
||||
perf perf-compare \
|
||||
ui-test ui-watch ui-visual ui-visual-update ui-setup \
|
||||
bindings bindings-check skill-check commit-check release-dry
|
||||
|
||||
# Base directory for fresh-install sandboxes. Deliberately NOT $TMPDIR:
|
||||
# on most Linux distros /tmp is tmpfs (RAM-backed) and only a few GB, so
|
||||
# the search index dump import — which wants 6GB free before it will even
|
||||
# start, then streams multi-GB dumps through explore-staging/ — either
|
||||
# fails its precheck or eats that much RAM. XDG cache is disk-backed
|
||||
# everywhere and still throwaway.
|
||||
FRESH_HOME_BASE ?= $(if $(XDG_CACHE_HOME),$(XDG_CACHE_HOME),$(HOME)/.cache)
|
||||
|
||||
# fresh-install runs dev against a brand-new YJ_HOME so every launch
|
||||
# starts from a clean first-run state (no config.toml, no yj.db). The dir
|
||||
# is not cleaned up automatically, so you can inspect it afterward; the
|
||||
# printed path tells you where it is. Override the location with
|
||||
# FRESH_HOME_BASE=/some/disk make fresh-install.
|
||||
fresh-install: setup generate clean
|
||||
if [ -f .env ]; then set -a; . ./.env; set +a; fi; \
|
||||
mkdir -p "$(FRESH_HOME_BASE)"; \
|
||||
export YJ_HOME="$$(mktemp -d "$(FRESH_HOME_BASE)/yellowjacket-fresh.XXXXXX")"; \
|
||||
echo "==> fresh YJ_HOME=$$YJ_HOME"; \
|
||||
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; \
|
||||
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
|
||||
# and reusing it (never deleting) afterward, so you can keep several
|
||||
# long-lived states around — one with an imported search index, one with
|
||||
# a small library, etc. `make sandbox-foo` is the same thing.
|
||||
#
|
||||
# `make sandboxes` lists the ones that exist.
|
||||
#
|
||||
# `make sandbox-rm foo [bar ...]` deletes them again, after confirming.
|
||||
#
|
||||
# The bare words after `sandbox` / `sandbox-rm` are extra make goals, so
|
||||
# they need do-nothing rules to keep make from complaining. Those rules
|
||||
# exist only when one of those is the first goal, so typos in other
|
||||
# targets still fail loudly.
|
||||
SANDBOX_DIR = $(FRESH_HOME_BASE)/yellowjacket-sandbox
|
||||
ifneq (,$(filter $(firstword $(MAKECMDGOALS)),sandbox sandbox-rm))
|
||||
SANDBOX_ARGS := $(wordlist 2,$(words $(MAKECMDGOALS)),$(MAKECMDGOALS))
|
||||
SANDBOX_NAME := $(firstword $(SANDBOX_ARGS))
|
||||
$(foreach a,$(SANDBOX_ARGS),$(eval $(a):;@:))
|
||||
endif
|
||||
|
||||
sandbox: ## Run dev against a named, persistent YJ_HOME: make sandbox <name>
|
||||
@if [ -z "$(SANDBOX_NAME)" ]; then \
|
||||
echo "usage: make sandbox <name> (e.g. make sandbox foo)" >&2; exit 2; \
|
||||
fi
|
||||
@$(MAKE) --no-print-directory sandbox-$(SANDBOX_NAME)
|
||||
|
||||
sandbox-rm: ## Delete named sandboxes: make sandbox-rm <name> [name ...]
|
||||
@if [ -z "$(SANDBOX_ARGS)" ]; then \
|
||||
echo "usage: make sandbox-rm <name> [name ...]" >&2; exit 2; \
|
||||
fi
|
||||
@set -e; \
|
||||
targets=""; \
|
||||
for n in $(SANDBOX_ARGS); do \
|
||||
d="$(SANDBOX_DIR)-$$n"; \
|
||||
if [ -d "$$d" ]; then \
|
||||
echo " $$(du -sh "$$d" 2>/dev/null | cut -f1) $$d"; \
|
||||
targets="$$targets $$d"; \
|
||||
else \
|
||||
echo " (no such sandbox: $$n)" >&2; \
|
||||
fi; \
|
||||
done; \
|
||||
if [ -z "$$targets" ]; then exit 1; fi; \
|
||||
if [ "$(FORCE)" != "1" ]; then \
|
||||
printf "delete the above? [y/N] "; read -r ans; \
|
||||
case "$$ans" in y|Y|yes|YES) ;; *) echo "aborted"; exit 1 ;; esac; \
|
||||
fi; \
|
||||
rm -rf $$targets; \
|
||||
echo "==> removed"
|
||||
|
||||
sandbox-%: setup generate clean
|
||||
if [ -f .env ]; then set -a; . ./.env; set +a; fi; \
|
||||
export YJ_HOME="$(SANDBOX_DIR)-$*"; \
|
||||
mkdir -p "$$YJ_HOME"; \
|
||||
echo "==> sandbox '$*' YJ_HOME=$$YJ_HOME"; \
|
||||
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; \
|
||||
PATH="$(TOOLBIN):$$PATH" go tool wails3 dev -config ./build/config.yml
|
||||
|
||||
sandboxes: ## List existing named sandboxes
|
||||
@ls -d "$(SANDBOX_DIR)"-* 2>/dev/null \
|
||||
| sed 's|.*/yellowjacket-sandbox-| |' \
|
||||
|| echo " (none)"
|
||||
|
||||
.PHONY: sandbox sandbox-rm sandboxes
|
||||
|
||||
build-dev: generate
|
||||
go tool wails build -tags webkit2_41 -debug -clean -ldflags "$(LDFLAGS)"
|
||||
PATH="$(TOOLBIN):$$PATH" go tool wails3 task build DEV=true
|
||||
|
||||
build-prod: generate
|
||||
go tool wails build -tags webkit2_41 -clean -upx -ldflags "-s -w $(LDFLAGS)"
|
||||
PATH="$(TOOLBIN):$$PATH" go tool wails3 task build
|
||||
|
||||
build-frontend:
|
||||
cd frontend && pnpm install && pnpm build
|
||||
@@ -24,11 +333,59 @@ clean:
|
||||
generate:
|
||||
go generate ./...
|
||||
|
||||
# The fixture library is generated, not committed: deterministic audio
|
||||
# across all four supported formats, tagged by backend/tagwriter so the
|
||||
# fixtures and the reader under test cannot drift. Regenerates only
|
||||
# when the spec's manifest hash has changed, so it is cheap to depend on.
|
||||
testdata: ## Generate the deterministic fixture music library
|
||||
go run ./cmd/gentestdata
|
||||
|
||||
testdata-force: ## Regenerate the fixture library unconditionally
|
||||
go run ./cmd/gentestdata -force
|
||||
|
||||
testdata-clean: ## Delete the generated fixture library
|
||||
rm -rf test_data/music_library_test test_data/music_library_broken \
|
||||
test_data/music_library_test.manifest.json
|
||||
|
||||
# The bulk library answers a different question from the fixture one:
|
||||
# not "does this behave correctly" but "how does this behave at the
|
||||
# size the audit measured". ~11 s, ~470 MB, into a gitignored .dev/,
|
||||
# and deliberately not a dependency of `make test`.
|
||||
BULK_TRACKS ?= 50000
|
||||
|
||||
bulkdata: ## Generate the bulk measurement library (BULK_TRACKS=50000)
|
||||
go run ./cmd/gentestdata -bulk $(BULK_TRACKS)
|
||||
|
||||
bulkdata-clean: ## Delete the bulk measurement library
|
||||
rm -rf .dev/music_library_bulk .dev/music_library_bulk.manifest.json
|
||||
|
||||
.PHONY: testdata testdata-force testdata-clean bulkdata bulkdata-clean
|
||||
|
||||
# The tag sets must match `make test` exactly, or lint is checking three
|
||||
# configurations that nothing builds. The webkit2_41 tag these all used
|
||||
# to carry is gone with v2: v3 builds against GTK4 + WebKitGTK 6.0 by
|
||||
# default, which both Arch and ubuntu:24.04 ship, so the default tag set
|
||||
# is the one that ships. (`-tags gtk3` still exists as an escape hatch
|
||||
# for a machine without webkitgtk-6.0; it is not what CI or releases
|
||||
# build.)
|
||||
lint:
|
||||
go tool golangci-lint run
|
||||
go tool golangci-lint run --build-tags indexbuild
|
||||
go tool golangci-lint run --build-tags dev
|
||||
|
||||
test:
|
||||
go test -tags webkit2_41 -race -count=1 -timeout 120s ./...
|
||||
# Three passes: the app build, the `indexbuild` build that adds the
|
||||
# CI-only dump importer, and the `dev` build that adds profiling and
|
||||
# backend/testctl. Without the extra passes nothing would compile or
|
||||
# exercise backend/explore/dump*.go, cmd/indexbuild or the harness
|
||||
# control surface at all.
|
||||
test: testdata
|
||||
go test -race -count=1 -timeout 120s ./...
|
||||
go test -tags indexbuild -race -count=1 -timeout 300s \
|
||||
./backend/explore/... ./cmd/...
|
||||
# backend/testctl only exists under the `dev` tag, so the pass above
|
||||
# does not compile it, let alone run it.
|
||||
go test -tags dev -race -count=1 -timeout 120s \
|
||||
./backend/testctl/...
|
||||
|
||||
vulncheck:
|
||||
go tool govulncheck ./...
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -97,5 +106,8 @@ make dev # run with hot-reload
|
||||
make build-prod # produce a release binary
|
||||
```
|
||||
|
||||
More detail for contributors lives in
|
||||
[`docs/dev/overview.md`](./docs/dev/overview.md) and [`CLAUDE.md`](./CLAUDE.md).
|
||||
More detail for contributors lives in [`CLAUDE.md`](./CLAUDE.md) — the
|
||||
architecture, the conventions and the reasons behind them. What is
|
||||
being worked on is [the issue
|
||||
tracker](https://git.ljones.me/yonlu/yellowjacket/issues); #73 is the
|
||||
roadmap.
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
version: '3'
|
||||
|
||||
vars:
|
||||
APP_NAME: "yellowjacket"
|
||||
BIN_DIR: "bin"
|
||||
PACKAGE_MANAGER: '{{.PACKAGE_MANAGER | default "pnpm"}}'
|
||||
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
|
||||
# Target OS for build/package/run. Defaults to the host OS, and is overridden
|
||||
# by `wails3 build GOOS=...` (or the GOOS env var) for cross-compilation. The
|
||||
# tasks below dispatch to the matching platform Taskfile via this variable.
|
||||
GOOS: '{{.GOOS | default OS}}'
|
||||
|
||||
includes:
|
||||
common: ./build/Taskfile.yml
|
||||
windows: ./build/windows/Taskfile.yml
|
||||
darwin: ./build/darwin/Taskfile.yml
|
||||
linux: ./build/linux/Taskfile.yml
|
||||
android: ./build/android/Taskfile.yml
|
||||
|
||||
tasks:
|
||||
build:
|
||||
summary: Builds the application
|
||||
cmds:
|
||||
- task: "{{.GOOS}}:build"
|
||||
|
||||
package:
|
||||
summary: Packages a production build of the application
|
||||
cmds:
|
||||
- task: "{{.GOOS}}:package"
|
||||
|
||||
run:
|
||||
summary: Runs the application
|
||||
cmds:
|
||||
- task: "{{.GOOS}}:run"
|
||||
|
||||
dev:
|
||||
summary: Runs the application in development mode
|
||||
cmds:
|
||||
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
|
||||
|
||||
setup:docker:
|
||||
summary: Builds Docker image for cross-compilation (~800MB download)
|
||||
cmds:
|
||||
- task: common:setup:docker
|
||||
|
||||
build:server:
|
||||
summary: Builds the application in server mode (no GUI, HTTP server only)
|
||||
cmds:
|
||||
- task: common:build:server
|
||||
|
||||
run:server:
|
||||
summary: Runs the application in server mode
|
||||
cmds:
|
||||
- task: common:run:server
|
||||
|
||||
build:docker:
|
||||
summary: Builds a Docker image for server mode deployment
|
||||
cmds:
|
||||
- task: common:build:docker
|
||||
|
||||
run:docker:
|
||||
summary: Builds and runs the Docker image
|
||||
cmds:
|
||||
- task: common:run:docker
|
||||
+377
-41
@@ -10,17 +10,24 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
|
||||
"yellowjacket/backend/assets"
|
||||
"yellowjacket/backend/autotagservice"
|
||||
"yellowjacket/backend/config"
|
||||
"yellowjacket/backend/coverart"
|
||||
"yellowjacket/backend/database"
|
||||
"yellowjacket/backend/download"
|
||||
"yellowjacket/backend/events"
|
||||
"yellowjacket/backend/explore"
|
||||
"yellowjacket/backend/frontendutil"
|
||||
"yellowjacket/backend/home"
|
||||
"yellowjacket/backend/jobs"
|
||||
"yellowjacket/backend/library"
|
||||
"yellowjacket/backend/maintenance"
|
||||
"yellowjacket/backend/mediacontrols"
|
||||
"yellowjacket/backend/player"
|
||||
"yellowjacket/backend/playlist"
|
||||
@@ -28,11 +35,14 @@ import (
|
||||
"yellowjacket/backend/queue"
|
||||
"yellowjacket/backend/system"
|
||||
"yellowjacket/backend/tagwriter"
|
||||
"yellowjacket/backend/testctl"
|
||||
)
|
||||
|
||||
// YellowJacketApp is the main application struct for Wails.
|
||||
type YellowJacketApp struct {
|
||||
FEBindings []any
|
||||
// Services is what v3 binds to the frontend. Each entry's
|
||||
// ServiceStartup runs before the app-level wiring in OnStartup.
|
||||
Services []application.Service
|
||||
FrontendUtil *frontendutil.FrontendUtil
|
||||
|
||||
logger *slog.Logger
|
||||
@@ -44,11 +54,22 @@ type YellowJacketApp struct {
|
||||
queue *queue.Queue
|
||||
explore *explore.Service
|
||||
autotag *autotagservice.Service
|
||||
downloads *download.Manager
|
||||
downloadSvc *download.Service
|
||||
wanted *download.Reconciler
|
||||
jobs *jobs.Registry
|
||||
mediaControls mediacontrols.Handler
|
||||
tagWriter *tagwriter.TagWriter
|
||||
janitor *maintenance.Runner
|
||||
appContext context.Context
|
||||
appConfig *config.Config
|
||||
startupErr error
|
||||
|
||||
// quitAsking guards the one quit-confirmation dialog; quitConfirmed
|
||||
// records that the user already answered "quit anyway", so the
|
||||
// Quit() issued from that callback is not questioned again.
|
||||
quitAsking atomic.Bool
|
||||
quitConfirmed atomic.Bool
|
||||
}
|
||||
|
||||
// NewYellowJacketApp creates and initializes the application.
|
||||
@@ -63,6 +84,7 @@ func NewYellowJacketApp(
|
||||
logger: logger,
|
||||
assetHandler: assetHandler,
|
||||
appContext: context.Background(),
|
||||
janitor: maintenance.NewRunner(logger),
|
||||
}
|
||||
|
||||
// create database
|
||||
@@ -120,6 +142,17 @@ func NewYellowJacketApp(
|
||||
yjApp.assetHandler.RegisterHandler("/artist-images/", artistImgHandler)
|
||||
}
|
||||
|
||||
// Dev-only /__test/ control surface: the residue of harness work the
|
||||
// browser cannot reach (snapshot/restore the DB mid-run, force a
|
||||
// backend event). Compiled out of non-dev builds entirely, and even
|
||||
// in a dev build it registers nothing unless YJ_TESTCTL=1. The
|
||||
// context is read lazily because it only exists after OnStartup.
|
||||
testctl.Register(yjApp.assetHandler, testctl.Deps{
|
||||
Logger: logger,
|
||||
DB: yjApp.database,
|
||||
Context: func() context.Context { return yjApp.appContext },
|
||||
})
|
||||
|
||||
// create playlist service
|
||||
yjApp.playlist = playlist.NewService(
|
||||
yjApp.logger, yjApp.database, yjApp.appConfig,
|
||||
@@ -148,6 +181,44 @@ func NewYellowJacketApp(
|
||||
yjApp.logger.WithGroup("explore"), yjApp.database,
|
||||
)
|
||||
|
||||
// create the background job registry and wire it into the
|
||||
// subsystems that run long jobs, so scans and index builds all
|
||||
// report through one surface.
|
||||
yjApp.jobs = jobs.NewRegistry(
|
||||
yjApp.logger.WithGroup("jobs"),
|
||||
jobs.NewStore(yjApp.database, yjApp.logger.WithGroup("jobs")),
|
||||
)
|
||||
yjApp.library.SetJobRegistry(yjApp.jobs)
|
||||
yjApp.explore.SetJobRegistry(yjApp.jobs)
|
||||
|
||||
// Whether this connection is one to spend ~0.6 GB of catalog on
|
||||
// (plan 016 B4). The probe is injected from here because `explore` is
|
||||
// imported by `cmd/indexbuild`, which must not link Wails: naming
|
||||
// `application` there is what `TestIndexToolsDoNotImportWails`
|
||||
// forbids.
|
||||
//
|
||||
// `application.Mobile`, not `application.Android`: the latter exists
|
||||
// only under the `android` build tag, while `Mobile` is the portable
|
||||
// name whose desktop implementation is a stub returning "" — which
|
||||
// parses to "unknown" and refuses nothing. Plan 016 named the tagged
|
||||
// one; this is the same call by the name every build has.
|
||||
yjApp.explore.SetNetworkPolicy(
|
||||
func() explore.Network {
|
||||
return explore.ParseNetworkJSON(application.Mobile.NetworkJSON())
|
||||
},
|
||||
yjApp.appConfig.GetAllowMeteredCatalogDownload,
|
||||
)
|
||||
|
||||
// Let the release prefetch skip albums the user already owns in
|
||||
// full — those open with no catalog call at all, so warming their
|
||||
// tracklists spends the most expensive request in the app on
|
||||
// nothing. Injected because neither package imports the other.
|
||||
yjApp.explore.SetAlbumComplete(func(albumID int64) bool {
|
||||
c, err := yjApp.library.GetAlbumCompleteness(albumID)
|
||||
|
||||
return err == nil && c.Known && c.Complete
|
||||
})
|
||||
|
||||
// create autotag service (depends on explore + tagWriter)
|
||||
yjApp.autotag = autotagservice.NewService(
|
||||
yjApp.logger.WithGroup("autotag"),
|
||||
@@ -155,22 +226,95 @@ func NewYellowJacketApp(
|
||||
yjApp.explore,
|
||||
yjApp.tagWriter,
|
||||
)
|
||||
yjApp.autotag.SetJobRegistry(yjApp.jobs)
|
||||
|
||||
yjApp.FEBindings = []any{
|
||||
yjApp.FrontendUtil,
|
||||
yjApp.appConfig,
|
||||
yjApp.library,
|
||||
yjApp.playlist,
|
||||
yjApp.queue,
|
||||
yjApp.player,
|
||||
yjApp.tagWriter,
|
||||
yjApp.explore,
|
||||
yjApp.autotag,
|
||||
// Create the download subsystem. Acquiring music is optional: a
|
||||
// failure here (unwritable data dir, say) must not stop the app
|
||||
// from playing the library the user already has, so it is logged
|
||||
// and the feature stays unavailable rather than fatal.
|
||||
if err := yjApp.initDownloads(); err != nil {
|
||||
yjApp.logger.Error(
|
||||
"download clients unavailable", "error", err,
|
||||
)
|
||||
}
|
||||
|
||||
// application.NewService is generic over a concrete pointer type —
|
||||
// the static analyser that generates bindings reads these calls, so
|
||||
// a []any of the same values would generate nothing.
|
||||
yjApp.Services = []application.Service{
|
||||
application.NewService(yjApp.FrontendUtil),
|
||||
application.NewService(yjApp.appConfig),
|
||||
application.NewService(yjApp.library),
|
||||
application.NewService(yjApp.playlist),
|
||||
application.NewService(yjApp.queue),
|
||||
application.NewService(yjApp.player),
|
||||
application.NewService(yjApp.tagWriter),
|
||||
application.NewService(yjApp.explore),
|
||||
application.NewService(yjApp.autotag),
|
||||
application.NewService(jobs.NewService(yjApp.jobs)),
|
||||
application.NewService(home.NewService(
|
||||
yjApp.logger.WithGroup("home"),
|
||||
yjApp.database,
|
||||
yjApp.library,
|
||||
)),
|
||||
}
|
||||
|
||||
if yjApp.downloadSvc != nil {
|
||||
yjApp.Services = append(
|
||||
yjApp.Services, application.NewService(yjApp.downloadSvc),
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// initDownloads builds the download subsystem: staging area, secret
|
||||
// store, importer and manager, plus the Wails-bound service.
|
||||
func (yj *YellowJacketApp) initDownloads() error {
|
||||
logger := yj.logger.WithGroup("download")
|
||||
|
||||
staging, err := download.NewStaging(logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not create download staging: %w", err)
|
||||
}
|
||||
|
||||
secrets, err := download.NewFileSecretStore()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not create download secret store: %w", err)
|
||||
}
|
||||
|
||||
store := download.NewStore(yj.database)
|
||||
|
||||
importer := download.NewImporter(logger, staging, yj.tagWriter, yj.library)
|
||||
|
||||
yj.downloads = download.NewManager(
|
||||
logger, store, secrets, staging, importer, yj.library,
|
||||
)
|
||||
yj.downloads.SetJobRegistry(yj.jobs)
|
||||
|
||||
yj.downloadSvc = download.NewService(logger, yj.downloads, store, secrets)
|
||||
|
||||
// The wanted list needs the explore index to know what an artist
|
||||
// released and what the library already owns, so it is wired here
|
||||
// where both exist. The reconcile loop itself is not started until
|
||||
// the Wails runtime is up.
|
||||
yj.wanted = download.NewReconciler(
|
||||
logger, store, yj.downloads, newExploreCatalog(yj.explore),
|
||||
)
|
||||
yj.downloadSvc.SetReconciler(yj.wanted)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// playerAdapter wraps *player.Player to satisfy the tagwriter.PlayerStopper
|
||||
// interface, breaking the import cycle between tagwriter and player.
|
||||
type playerAdapter struct{ p *player.Player }
|
||||
@@ -181,27 +325,69 @@ func (a *playerAdapter) CurrentFilePath() string {
|
||||
|
||||
func (a *playerAdapter) StopAndRelease() { a.p.UnloadTrack() }
|
||||
|
||||
// initDownloadRuntime brings the download subsystem up once the Wails
|
||||
// runtime exists: it applies the user's import layout, builds providers
|
||||
// from stored config, and clears staging left by a previous run.
|
||||
//
|
||||
// Provider construction and the sweep both touch the network and the
|
||||
// filesystem, so they run in the background — a slow or unreachable
|
||||
// download client must not delay the window appearing.
|
||||
func (yj *YellowJacketApp) initDownloadRuntime(ctx context.Context) {
|
||||
cfg := yj.appConfig.Downloads
|
||||
if cfg == nil {
|
||||
cfg = &download.UserConfig{}
|
||||
cfg.ApplyDefaults()
|
||||
}
|
||||
|
||||
yj.downloads.SetImportOptions(download.ImportOptions{
|
||||
PathTemplate: cfg.PathTemplate,
|
||||
})
|
||||
yj.downloads.SetMaxConcurrent(cfg.MaxConcurrent)
|
||||
yj.downloads.SetPreferences(cfg.AutoDownloadPrefs())
|
||||
|
||||
go func() {
|
||||
if err := yj.downloads.Reload(ctx); err != nil {
|
||||
yj.logger.Warn("could not load download providers", "error", err)
|
||||
}
|
||||
|
||||
yj.downloads.Sweep(ctx)
|
||||
}()
|
||||
|
||||
if yj.wanted == nil {
|
||||
return
|
||||
}
|
||||
|
||||
yj.wanted.SetInterval(cfg.WantedInterval())
|
||||
yj.wanted.SetBatch(cfg.WantedBatch)
|
||||
yj.wanted.SetOnChange(func() {
|
||||
events.Emit(ctx, events.RequestsChanged)
|
||||
})
|
||||
yj.wanted.Start(ctx)
|
||||
}
|
||||
|
||||
// WindowConfig returns the window configuration for use by the host.
|
||||
func (yj *YellowJacketApp) WindowConfig() *config.WindowConfig {
|
||||
return yj.appConfig.Window
|
||||
}
|
||||
|
||||
// OnStartup initializes components that require the Wails runtime context.
|
||||
// OnStartup wires the services to each other once the runtime exists.
|
||||
//
|
||||
// It is no longer where each service *gets* the context: every bound
|
||||
// service implements v3's ServiceStartup, which the runtime calls
|
||||
// before this runs. What is left here is the cross-service wiring —
|
||||
// hooks, adapters and the callbacks that make one package drive
|
||||
// another — which has no home inside any single service.
|
||||
func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
||||
defer profiling.TimeOp(yj.logger, "app.OnStartup")()
|
||||
|
||||
// initialize anything that needs to use the wails runtime AFTER its been initialized
|
||||
// you CANNOT use the wails runtime during this function
|
||||
yj.appContext = ctx
|
||||
|
||||
// Set context for components that need Wails runtime for events
|
||||
yj.appConfig.SetContext(ctx)
|
||||
yj.FrontendUtil.SetContext(ctx)
|
||||
yj.library.SetContext(ctx)
|
||||
yj.playlist.SetContext(ctx)
|
||||
yj.playlist.EnsureDefaultPlaylist()
|
||||
// Recover playlists that lost tracks from a pre-fix FullRescan.
|
||||
go yj.playlist.RepopulateFromM3U()
|
||||
// Backfill snapshots for smart playlists created before
|
||||
// creation-time materialization existed.
|
||||
go yj.playlist.MaterializeUnmaterializedSmartPlaylists()
|
||||
|
||||
// Initialize speaker hardware (player struct created in
|
||||
// NewYellowJacketApp for Wails binding registration).
|
||||
@@ -212,14 +398,26 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
||||
)
|
||||
}
|
||||
|
||||
yj.player.SetContext(ctx)
|
||||
yj.tagWriter.SetContext(ctx)
|
||||
yj.explore.SetContext(ctx)
|
||||
yj.autotag.SetContext(ctx)
|
||||
// The job registry is not a bound service — it is wrapped by
|
||||
// jobs.NewService for that — so it still takes the context by hand.
|
||||
yj.jobs.SetContext(ctx)
|
||||
|
||||
if yj.downloadSvc != nil {
|
||||
yj.initDownloadRuntime(ctx)
|
||||
}
|
||||
|
||||
// Bring back jobs the user paused before the last shutdown, still
|
||||
// paused. Must run before the soft scan in OnDomReady, which
|
||||
// checks these records so it does not restart a paused library.
|
||||
yj.library.RestorePausedScans()
|
||||
yj.explore.AdoptPausedIndexBuild()
|
||||
|
||||
// Wire queue (created in NewYellowJacketApp for Wails binding)
|
||||
yj.queue.SetContext(ctx)
|
||||
yj.queue.SetPlayer(yj.player)
|
||||
yj.queue.SetFallbackSource(&queueFallbackAdapter{
|
||||
config: yj.appConfig,
|
||||
playlist: yj.playlist,
|
||||
explore: yj.explore,
|
||||
})
|
||||
yj.queue.RestoreState()
|
||||
|
||||
// Wire cross-cutting rescan hooks so the library can
|
||||
@@ -259,6 +457,11 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
||||
// no-op once every owned artist is covered.
|
||||
yj.explore.BackfillLibraryDiscographies()
|
||||
|
||||
// Resolve any release-group MBIDs the scan could only find a
|
||||
// release-level tag for (see updateMBIDs). Same shape as the
|
||||
// discography backfill above: background, bounded, resumable.
|
||||
yj.explore.BackfillReleaseGroupMBIDs()
|
||||
|
||||
// Start (or resume) the dump-based index build. Skips
|
||||
// itself once the one-time import has completed, so this
|
||||
// is cheap on every startup.
|
||||
@@ -299,20 +502,24 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
||||
// Register playback finished handler to drive queue auto-advance.
|
||||
yj.player.SetPlaybackFinishedHandler(yj.queue.OnPlaybackFinished)
|
||||
|
||||
// Initialize OS media controls (MPRIS on Linux, no-op elsewhere).
|
||||
// Initialize OS media controls (MPRIS on desktop Linux, a
|
||||
// MediaSession on Android, no-op elsewhere). The callbacks are the
|
||||
// same on every platform; only what delivers them differs.
|
||||
yj.mediaControls = mediacontrols.NewHandler(yj.logger)
|
||||
|
||||
if err := yj.mediaControls.Init(mediacontrols.Callbacks{
|
||||
OnPlay: yj.queue.Play,
|
||||
OnPause: func() {
|
||||
if err := yj.player.Pause(); err != nil {
|
||||
yj.logger.Warn("MPRIS Pause failed", "err", err)
|
||||
yj.logger.Warn("Media controls Pause failed", "err", err)
|
||||
}
|
||||
},
|
||||
OnPlayPause: func() {
|
||||
if yj.player.IsPlaying() {
|
||||
if err := yj.player.Pause(); err != nil {
|
||||
yj.logger.Warn("MPRIS PlayPause(pause) failed", "err", err)
|
||||
yj.logger.Warn(
|
||||
"Media controls PlayPause(pause) failed", "err", err,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
yj.queue.Play()
|
||||
@@ -320,14 +527,14 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
||||
},
|
||||
OnStop: func() {
|
||||
if err := yj.player.Pause(); err != nil {
|
||||
yj.logger.Warn("MPRIS Stop failed", "err", err)
|
||||
yj.logger.Warn("Media controls Stop failed", "err", err)
|
||||
}
|
||||
},
|
||||
OnNext: yj.queue.Next,
|
||||
OnPrevious: yj.queue.Previous,
|
||||
OnSeek: func(positionSec int) {
|
||||
if err := yj.player.Seek(positionSec); err != nil {
|
||||
yj.logger.Warn("MPRIS Seek failed", "err", err)
|
||||
yj.logger.Warn("Media controls Seek failed", "err", err)
|
||||
}
|
||||
},
|
||||
OnVolume: func(vol float64) {
|
||||
@@ -337,6 +544,7 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
||||
),
|
||||
)
|
||||
},
|
||||
OnDuck: yj.player.SetDuck,
|
||||
}); err != nil {
|
||||
yj.logger.Error(
|
||||
"Failed to initialize media controls",
|
||||
@@ -347,26 +555,32 @@ func (yj *YellowJacketApp) OnStartup(ctx context.Context) {
|
||||
yj.player.SetMediaControls(yj.mediaControls)
|
||||
}
|
||||
|
||||
// OnBeforeClose captures window state while the window is still alive.
|
||||
func (yj *YellowJacketApp) OnBeforeClose(ctx context.Context) bool {
|
||||
w, h := wailsruntime.WindowGetSize(ctx)
|
||||
// SaveWindowState captures the window's size while the window is still
|
||||
// alive. It is registered on the WindowClosing event, because at
|
||||
// shutdown there is no window left to measure.
|
||||
func (yj *YellowJacketApp) SaveWindowState(window application.Window) {
|
||||
if window == nil {
|
||||
return
|
||||
}
|
||||
|
||||
w, h := window.Size()
|
||||
|
||||
// Guard against a bogus size clobbering a good saved one. During
|
||||
// teardown / hot-reload the runtime can report a zero or below-
|
||||
// minimum size; persisting that would shrink the window to the
|
||||
// minimum on next launch. Keep the previously-saved size instead.
|
||||
if w < config.MinWidth || h < config.MinHeight {
|
||||
yj.logger.Warn("OnBeforeClose: ignoring bogus window size",
|
||||
yj.logger.Warn("window close: ignoring bogus window size",
|
||||
"width", w,
|
||||
"height", h,
|
||||
"kept_width", yj.appConfig.Window.Width,
|
||||
"kept_height", yj.appConfig.Window.Height,
|
||||
)
|
||||
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
yj.logger.Info("OnBeforeClose: saving window state",
|
||||
yj.logger.Info("window close: saving window state",
|
||||
"width", w,
|
||||
"height", h,
|
||||
"accentColor", yj.appConfig.Theme.AccentColor,
|
||||
@@ -382,12 +596,69 @@ func (yj *YellowJacketApp) OnBeforeClose(ctx context.Context) bool {
|
||||
"err", err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ShouldQuit answers v3's quit veto: false keeps the app running.
|
||||
//
|
||||
// Quitting mid-apply cancels the service context and leaves a folder
|
||||
// half-retagged with nothing recording where it stopped (errors.p4),
|
||||
// which is the one case worth interrupting a quit for.
|
||||
//
|
||||
// The shape differs from v2's OnBeforeClose because v3's dialog is
|
||||
// asynchronous — Show() returns immediately and the answer arrives on
|
||||
// a button callback — so this cannot ask and answer in one call. It
|
||||
// vetoes the quit, asks, and quits again from the callback if the user
|
||||
// says so. quitConfirmed is what stops that second Quit() coming
|
||||
// straight back here and asking a second time.
|
||||
func (yj *YellowJacketApp) ShouldQuit() bool {
|
||||
if yj.quitConfirmed.Load() {
|
||||
return true
|
||||
}
|
||||
|
||||
if yj.autotag == nil || !yj.autotag.WritesInFlight() {
|
||||
return true
|
||||
}
|
||||
|
||||
// A dialog already up must not spawn another on every close attempt.
|
||||
if !yj.quitAsking.CompareAndSwap(false, true) {
|
||||
return false
|
||||
}
|
||||
|
||||
app := application.Get()
|
||||
if app == nil {
|
||||
// No runtime to ask through: never trap the user in the app.
|
||||
return true
|
||||
}
|
||||
|
||||
dialog := app.Dialog.Question()
|
||||
dialog.SetTitle("Tags are still being written")
|
||||
dialog.SetMessage(
|
||||
"YellowJacket is rewriting tags on your files. " +
|
||||
"Quitting now leaves that folder holding a mix of old and " +
|
||||
"new tags.\n\nQuit anyway?",
|
||||
)
|
||||
|
||||
quit := dialog.AddButton("Quit anyway")
|
||||
quit.OnClick(func() {
|
||||
yj.quitConfirmed.Store(true)
|
||||
yj.quitAsking.Store(false)
|
||||
app.Quit()
|
||||
})
|
||||
|
||||
stay := dialog.AddButton("Keep writing")
|
||||
stay.OnClick(func() { yj.quitAsking.Store(false) })
|
||||
stay.SetAsDefault()
|
||||
stay.SetAsCancel()
|
||||
|
||||
dialog.Show()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// OnShutdown saves player state and cleans up resources before the application exits.
|
||||
func (yj *YellowJacketApp) OnShutdown(_ context.Context) {
|
||||
// OnShutdown saves player state and cleans up resources before the
|
||||
// application exits. v3 passes no context — the app is going away, so
|
||||
// there is nothing left to scope work to.
|
||||
func (yj *YellowJacketApp) OnShutdown() {
|
||||
if yj.player != nil {
|
||||
yj.player.SaveState()
|
||||
}
|
||||
@@ -406,10 +677,17 @@ func (yj *YellowJacketApp) OnShutdown(_ context.Context) {
|
||||
// driven by the frontend: once its stores have registered their event
|
||||
// listeners, index.ts calls Player.EmitCurrentState() and
|
||||
// Queue.EmitCurrentState() via Wails bindings.
|
||||
func (yj *YellowJacketApp) OnDomReady(ctx context.Context) {
|
||||
func (yj *YellowJacketApp) OnDomReady(_ context.Context) {
|
||||
if yj.startupErr != nil {
|
||||
yj.logger.Error("startup error", "err", yj.startupErr.Error())
|
||||
wailsruntime.Quit(ctx)
|
||||
|
||||
// A startup failure is not a mid-write quit, so go straight out
|
||||
// rather than through the ShouldQuit question.
|
||||
yj.quitConfirmed.Store(true)
|
||||
|
||||
if app := application.Get(); app != nil {
|
||||
app.Quit()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
@@ -449,6 +727,9 @@ func (yj *YellowJacketApp) OnDomReady(ctx context.Context) {
|
||||
// discography (e.g. a prior run was capped or interrupted).
|
||||
// Cheap no-op once every owned artist is covered.
|
||||
yj.explore.BackfillLibraryDiscographies()
|
||||
|
||||
// Same continuation for release-group MBID resolution.
|
||||
yj.explore.BackfillReleaseGroupMBIDs()
|
||||
}
|
||||
|
||||
// Kick off the autotag prefetch worker so any unscored
|
||||
@@ -457,5 +738,60 @@ func (yj *YellowJacketApp) OnDomReady(ctx context.Context) {
|
||||
// every app launch is fine; previously-scored items are
|
||||
// skipped (the worker filters score IS NULL).
|
||||
yj.autotag.StartBackgroundPrefetch()
|
||||
|
||||
// Start the janitor last: its sweeps compare against live data,
|
||||
// so running them after the scan and index work has settled
|
||||
// avoids deleting something a running import is about to
|
||||
// reference. Each job enforces its own minimum interval, so the
|
||||
// daily tick is a cheap no-op most of the time.
|
||||
yj.startJanitor()
|
||||
}()
|
||||
}
|
||||
|
||||
// janitorTick is how often the maintenance runner wakes up. Individual
|
||||
// jobs enforce their own minimum intervals, so most ticks do nothing.
|
||||
const janitorTick = 6 * time.Hour
|
||||
|
||||
// startJanitor registers the maintenance jobs and starts the background
|
||||
// runner. Every job is registered here rather than at each package's
|
||||
// init, so the full set of janitorial work is one visible list — a cache
|
||||
// that forgets to register is missing from this function, which is
|
||||
// harder to overlook than a function nobody calls.
|
||||
func (yj *YellowJacketApp) startJanitor() {
|
||||
coversDir, err := coverart.CoversDir()
|
||||
if err != nil {
|
||||
yj.logger.Warn("janitor: could not resolve covers directory",
|
||||
"err", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
dataDir, err := system.GetUserDataDirPath()
|
||||
if err != nil {
|
||||
yj.logger.Warn("janitor: could not resolve user data directory",
|
||||
"err", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
yj.janitor.Register(maintenance.ExpiredHTTPCacheJob(yj.database))
|
||||
yj.janitor.Register(maintenance.OrphanedCoverFilesJob(
|
||||
yj.database, coversDir, library.CoverArtFileSet,
|
||||
))
|
||||
yj.janitor.Register(maintenance.OrphanedArtistImagesJob(
|
||||
yj.database,
|
||||
filepath.Join(dataDir, explore.ArtistImageDirName),
|
||||
explore.ArtistImageDir,
|
||||
))
|
||||
yj.janitor.Register(maintenance.StrayArtistImageFilesJob(
|
||||
filepath.Join(dataDir, explore.ArtistImageDirName),
|
||||
explore.ArtistImageKeepNames(),
|
||||
))
|
||||
yj.janitor.Register(maintenance.ExpiredProxyCacheJob(
|
||||
filepath.Join(dataDir, explore.CoverArtCacheDirName),
|
||||
))
|
||||
|
||||
yj.logger.Info("janitor started", "jobs", yj.janitor.JobNames())
|
||||
|
||||
yj.janitor.Start(yj.appContext, janitorTick)
|
||||
}
|
||||
|
||||
@@ -3,15 +3,22 @@ package assets
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
)
|
||||
|
||||
// distRoot is where the frontend build lands inside the embedded FS.
|
||||
// v2 knew this prefix itself; v3 takes an fs.FS rooted at the assets,
|
||||
// so the sub-FS is taken here.
|
||||
const distRoot = "frontend/dist"
|
||||
|
||||
// Handler serves frontend assets with custom route support.
|
||||
type Handler struct {
|
||||
Options *assetserver.Options
|
||||
Options application.AssetOptions
|
||||
logger *slog.Logger
|
||||
frontendDistAssets embed.FS
|
||||
serveMux *http.ServeMux
|
||||
@@ -25,8 +32,16 @@ func NewAssetHandler(logger *slog.Logger, frontendDistAssets embed.FS) (*Handler
|
||||
frontendDistAssets: frontendDistAssets,
|
||||
serveMux: http.NewServeMux(),
|
||||
}
|
||||
handler.Options = &assetserver.Options{
|
||||
Assets: handler.frontendDistAssets,
|
||||
|
||||
dist, err := fs.Sub(frontendDistAssets, distRoot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"could not open %s in the embedded assets: %w", distRoot, err,
|
||||
)
|
||||
}
|
||||
|
||||
handler.Options = application.AssetOptions{
|
||||
Handler: application.AssetFileServerFS(dist),
|
||||
Middleware: handler.Middleware,
|
||||
}
|
||||
|
||||
|
||||
+29
-30
@@ -328,43 +328,42 @@ func (a *Applier) Apply(
|
||||
func (a *Applier) syncDBMBIDs(
|
||||
ctx context.Context, tr TrackApply, cand Candidate,
|
||||
) error {
|
||||
// Look up recording row via audio_file.
|
||||
af, err := a.q.GetAudioFile(ctx, tr.Local.AudioFileID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get audio_file: %w", err)
|
||||
}
|
||||
|
||||
if tr.CandidateTrack.MBID != "" {
|
||||
if err := a.q.SetRecordingMBID(ctx, sqlcgen.SetRecordingMBIDParams{
|
||||
Mbid: sql.NullString{String: tr.CandidateTrack.MBID, Valid: true},
|
||||
ID: af.RecordingID,
|
||||
if err := a.q.SetFileRecordingMBID(ctx, sqlcgen.SetFileRecordingMBIDParams{
|
||||
RecordingMbid: sql.NullString{String: tr.CandidateTrack.MBID, Valid: true},
|
||||
ID: tr.Local.AudioFileID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("set recording mbid: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if cand.ReleaseGroupMBID != "" {
|
||||
rgID, err := a.q.GetRecordingReleaseGroupID(ctx, af.RecordingID)
|
||||
if err == nil && rgID > 0 {
|
||||
if err := a.q.SetReleaseGroupMBID(ctx, sqlcgen.SetReleaseGroupMBIDParams{
|
||||
Mbid: sql.NullString{String: cand.ReleaseGroupMBID, Valid: true},
|
||||
ID: rgID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("set release group mbid: %w", err)
|
||||
}
|
||||
if cand.ReleaseGroupMBID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stamp the release-group's original-release year too —
|
||||
// this is what the tracklist / smart-playlist year rule
|
||||
// surfaces by default once the user accepts a candidate.
|
||||
if year := parseYear(cand.OriginalDate); year > 0 {
|
||||
if err := a.q.SetReleaseGroupOriginalYear(
|
||||
ctx, sqlcgen.SetReleaseGroupOriginalYearParams{
|
||||
OriginalYear: sql.NullInt64{Int64: int64(year), Valid: true},
|
||||
ID: rgID,
|
||||
},
|
||||
); err != nil {
|
||||
return fmt.Errorf("set release group original year: %w", err)
|
||||
}
|
||||
// The album is reached through the file rather than through two
|
||||
// join tables; SetFileAlbumMBID takes the file id and does the
|
||||
// lookup in one statement.
|
||||
if err := a.q.SetFileAlbumMBID(ctx, sqlcgen.SetFileAlbumMBIDParams{
|
||||
Mbid: sql.NullString{String: cand.ReleaseGroupMBID, Valid: true},
|
||||
ID: tr.Local.AudioFileID,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("set album mbid: %w", err)
|
||||
}
|
||||
|
||||
// Stamp the album's original-release year too - this is what the
|
||||
// tracklist and the smart-playlist year rule surface by default
|
||||
// once the user accepts a candidate.
|
||||
if year := parseYear(cand.OriginalDate); year > 0 {
|
||||
af, err := a.q.GetAudioFile(ctx, tr.Local.AudioFileID)
|
||||
if err == nil && af.AlbumID.Valid {
|
||||
if err := a.q.SetAlbumOriginalYear(
|
||||
ctx, sqlcgen.SetAlbumOriginalYearParams{
|
||||
OriginalYear: sql.NullInt64{Int64: int64(year), Valid: true},
|
||||
ID: af.AlbumID.Int64,
|
||||
},
|
||||
); err != nil {
|
||||
return fmt.Errorf("set album original year: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package autotag_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -87,51 +86,22 @@ func seedAudioFiles(
|
||||
q := db.Queries
|
||||
ctx := db.Ctx
|
||||
|
||||
ac, err := q.UpsertArtistCredit(ctx, "Test Artist")
|
||||
if err != nil {
|
||||
t.Fatalf("upsert artist credit: %v", err)
|
||||
}
|
||||
|
||||
rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{
|
||||
Name: "Test Album",
|
||||
AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert rg: %v", err)
|
||||
}
|
||||
|
||||
out := make([]sqlcgen.AudioFile, 0, len(paths))
|
||||
|
||||
for i, p := range paths {
|
||||
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
|
||||
Name: p,
|
||||
ArtistCreditID: ac.ID,
|
||||
TrackNumber: sql.NullInt64{Int64: int64(i + 1), Valid: true},
|
||||
id := database.InsertTestTrack(t, db, database.TestTrack{
|
||||
FilePath: p,
|
||||
Title: p,
|
||||
Artist: "Test Artist",
|
||||
Album: "Test Album",
|
||||
TrackNumber: int64(i + 1),
|
||||
LengthMs: 100000,
|
||||
GroupKey: groupKey,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create recording: %v", err)
|
||||
}
|
||||
|
||||
if _, err := q.CreateReleaseGroupRecording(ctx, sqlcgen.CreateReleaseGroupRecordingParams{
|
||||
ReleaseGroupID: rg.ID,
|
||||
RecordingID: rec.ID,
|
||||
TrackNumber: sql.NullInt64{Int64: int64(i + 1), Valid: true},
|
||||
}); err != nil {
|
||||
t.Fatalf("link rg recording: %v", err)
|
||||
}
|
||||
|
||||
af, err := q.CreateAudioFileWithGroupKey(ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{
|
||||
FilePath: p,
|
||||
LengthMilliseconds: 100000,
|
||||
FileTypeID: 0,
|
||||
RecordingID: rec.ID,
|
||||
Basename: p,
|
||||
LibraryID: 0,
|
||||
GroupKey: groupKey,
|
||||
TagStatus: "untagged",
|
||||
})
|
||||
af, err := q.GetAudioFile(ctx, id)
|
||||
if err != nil {
|
||||
t.Fatalf("create audio file: %v", err)
|
||||
t.Fatalf("read seeded audio file: %v", err)
|
||||
}
|
||||
|
||||
out = append(out, af)
|
||||
|
||||
@@ -192,6 +192,16 @@ func rotateEndWord(s string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// TitleSimilarity exposes titleSimilarity for callers outside the
|
||||
// package that compare music metadata strings and should get the same
|
||||
// answer the tagger would. The download pipeline uses it to match
|
||||
// candidate filenames against an expected tracklist — Soulseek and
|
||||
// torrent results carry paths, not tags, so filename comparison is the
|
||||
// only signal available before the bytes arrive.
|
||||
func TitleSimilarity(a, b string) float64 {
|
||||
return titleSimilarity(a, b)
|
||||
}
|
||||
|
||||
// titleSimilarity returns a score in [0, 1] from stringDist. 1.0
|
||||
// means identical after normalization, 0.0 means fully dissimilar.
|
||||
func titleSimilarity(a, b string) float64 {
|
||||
|
||||
+121
-7
@@ -19,12 +19,14 @@ import (
|
||||
//
|
||||
// libraryID || 0 || normalized_parent_dir || 0 || disc_number
|
||||
//
|
||||
// where the parent directory is lower-cased. The folder is taken
|
||||
// as the album boundary — including the album tag string would
|
||||
// fragment albums whose tracks carry slightly different tags
|
||||
// (`Abbey Road` vs `Abbey Road (Remastered 2009)`, etc.). The
|
||||
// album name is still surfaced in `tagging_items.album_name` for
|
||||
// the review UI; it just doesn't decide grouping.
|
||||
// where the parent directory is lower-cased and disc_number is
|
||||
// normalized so an untagged disc (0) folds into disc 1 — see
|
||||
// normalizeDiscNumber. The folder is taken as the album boundary —
|
||||
// including the album tag string would fragment albums whose tracks
|
||||
// carry slightly different tags (`Abbey Road` vs `Abbey Road
|
||||
// (Remastered 2009)`, etc.). The album name is still surfaced in
|
||||
// `tagging_items.album_name` for the review UI; it just doesn't
|
||||
// decide grouping.
|
||||
//
|
||||
// Using SHA-1 matches the codebase's existing non-crypto
|
||||
// deterministic-key convention; collision risk at album-group
|
||||
@@ -41,7 +43,119 @@ func GroupKey(
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(parentDir))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(strconv.Itoa(discNumber)))
|
||||
h.Write([]byte(strconv.Itoa(normalizeDiscNumber(discNumber))))
|
||||
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// SyntheticGroupKey returns a deterministic identifier for a
|
||||
// tag-clustered sub-group carved out of parentGroupKey by
|
||||
// SplitMixedFolder — same SHA-1-over-null-separated-fields shape as
|
||||
// GroupKey, but keyed on the cluster's (album, album-artist) tags
|
||||
// instead of a directory, since a synthetic group's tracks don't
|
||||
// share a directory boundary distinct from their siblings left
|
||||
// behind in the parent folder.
|
||||
func SyntheticGroupKey(parentGroupKey, albumName, albumArtist string) string {
|
||||
h := sha1.New() //nolint:gosec // see package doc — grouping only.
|
||||
h.Write([]byte(parentGroupKey))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(Normalize(albumName)))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(Normalize(albumArtist)))
|
||||
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// SyntheticTrackGroupKey returns a deterministic identifier for a
|
||||
// single leftover track carved out of a mixed-bag folder by
|
||||
// SplitMixedFolder's singleton fallback (autotag.SplitPlan). Keyed on
|
||||
// the track's own audio_files id rather than its tags — two
|
||||
// untagged leftover tracks would otherwise both normalize to the
|
||||
// same empty (album, album-artist) pair and collide under
|
||||
// SyntheticGroupKey.
|
||||
func SyntheticTrackGroupKey(parentGroupKey string, audioFileID int64) string {
|
||||
h := sha1.New() //nolint:gosec // see package doc — grouping only.
|
||||
h.Write([]byte(parentGroupKey))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte("track"))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(strconv.FormatInt(audioFileID, 10)))
|
||||
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// normalizeDiscNumber folds a missing/invalid disc tag (<= 0) into
|
||||
// disc 1 for grouping purposes. Without this, a folder where only
|
||||
// some tracks carry an explicit "disc 1 of 1" tag — common when
|
||||
// files were ripped or re-tagged at different times — splits into
|
||||
// two tagging groups for what is really one single-disc album: the
|
||||
// untagged tracks hash to disc 0, the tagged ones to disc 1. A
|
||||
// genuine multi-disc release still separates correctly, since its
|
||||
// disc-2-and-up tracks carry an explicit non-zero, non-one disc
|
||||
// number.
|
||||
//
|
||||
// This is the single-file fallback used where a whole directory's
|
||||
// disc tags aren't available (e.g. maybeRebindTaggingGroup, which
|
||||
// rebinds one changed file at a time). Where a directory's full set
|
||||
// of raw disc numbers IS available, prefer ResolveDirectoryDiscNumbers
|
||||
// instead — a hardcoded "1" is the wrong guess for an untagged track
|
||||
// sitting alongside siblings that all agree on disc 2.
|
||||
func normalizeDiscNumber(discNumber int) int {
|
||||
if discNumber <= 0 {
|
||||
return 1
|
||||
}
|
||||
|
||||
return discNumber
|
||||
}
|
||||
|
||||
// ResolveDirectoryDiscNumbers returns, for one directory's files, the
|
||||
// disc number each should use when computing its GroupKey.
|
||||
//
|
||||
// normalizeDiscNumber's fixed "fold untagged to disc 1" is only a
|
||||
// safe guess when the caller has no other evidence. Given the whole
|
||||
// directory's raw disc tags at once, a better guess is available: if
|
||||
// every file that DOES carry an explicit disc number agrees on the
|
||||
// same value, an untagged sibling is almost certainly the same disc
|
||||
// — a partially re-tagged rip, not a stray track from a different
|
||||
// one — so it folds to that value instead of a hardcoded 1. If the
|
||||
// directory's explicit disc numbers disagree, it's a genuine
|
||||
// multi-disc release with no per-disc subfolders, and there's no
|
||||
// single disc to guess for the untagged ones, so they fall back to
|
||||
// normalizeDiscNumber's default.
|
||||
//
|
||||
// rawDiscNumbers must be in the same order as the files they belong
|
||||
// to; the returned slice mirrors that order 1:1.
|
||||
func ResolveDirectoryDiscNumbers(rawDiscNumbers []int) []int {
|
||||
consensus := 0
|
||||
ambiguous := false
|
||||
|
||||
for _, d := range rawDiscNumbers {
|
||||
if d <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case consensus == 0:
|
||||
consensus = d
|
||||
case consensus != d:
|
||||
ambiguous = true
|
||||
}
|
||||
}
|
||||
|
||||
fallback := 1
|
||||
if consensus > 0 && !ambiguous {
|
||||
fallback = consensus
|
||||
}
|
||||
|
||||
out := make([]int, len(rawDiscNumbers))
|
||||
|
||||
for i, d := range rawDiscNumbers {
|
||||
if d <= 0 {
|
||||
out[i] = fallback
|
||||
} else {
|
||||
out[i] = d
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -88,6 +88,95 @@ func TestGroupKey_DistinctInputsDiffer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupKey_UntaggedDiscFoldsIntoDiscOne(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A folder where only some tracks carry an explicit disc tag must
|
||||
// not split: the untagged tracks (disc 0, dhowden/tag's zero value
|
||||
// for a missing frame) should group with the ones tagged disc 1.
|
||||
untagged := autotag.GroupKey(1, "/music/Artist/Album/01.mp3", 0)
|
||||
tagged := autotag.GroupKey(1, "/music/Artist/Album/02.mp3", 1)
|
||||
|
||||
if untagged != tagged {
|
||||
t.Fatalf(
|
||||
"disc 0 and disc 1 in the same folder should share a key, got %q vs %q",
|
||||
untagged, tagged,
|
||||
)
|
||||
}
|
||||
|
||||
// A genuine disc 2 must still separate from disc 1/untagged.
|
||||
discTwo := autotag.GroupKey(1, "/music/Artist/Album/01.mp3", 2)
|
||||
if discTwo == tagged {
|
||||
t.Fatalf("disc 2 should not share a key with disc 1, got %q", discTwo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDirectoryDiscNumbers_UntaggedFoldsToConsensus(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A folder that's really disc 2, partially re-tagged: untagged
|
||||
// tracks should join disc 2, not fall back to a hardcoded disc 1.
|
||||
got := autotag.ResolveDirectoryDiscNumbers([]int{2, 0, 2, 0})
|
||||
want := []int{2, 2, 2, 2}
|
||||
|
||||
if !equalInts(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDirectoryDiscNumbers_AllUntaggedFallsBackToOne(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := autotag.ResolveDirectoryDiscNumbers([]int{0, 0, 0})
|
||||
want := []int{1, 1, 1}
|
||||
|
||||
if !equalInts(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDirectoryDiscNumbers_GenuineMultiDiscKeepsExplicitValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Explicit disagreement (disc 1 and disc 2 both present, no
|
||||
// subfolders) means there's no single disc to guess for the
|
||||
// untagged track — it falls back to normalizeDiscNumber's default
|
||||
// rather than being assigned to either disc.
|
||||
got := autotag.ResolveDirectoryDiscNumbers([]int{1, 1, 2, 2, 0})
|
||||
want := []int{1, 1, 2, 2, 1}
|
||||
|
||||
if !equalInts(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDirectoryDiscNumbers_PreservesExplicitValuesEvenWhenUnanimous(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Every file already agrees on disc 3 — nothing to resolve, but
|
||||
// the explicit values must pass through unchanged.
|
||||
got := autotag.ResolveDirectoryDiscNumbers([]int{3, 3, 3})
|
||||
want := []int{3, 3, 3}
|
||||
|
||||
if !equalInts(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func equalInts(a, b []int) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func TestGroupKey_AmbiguityBoundary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+19
-17
@@ -39,14 +39,16 @@ func (r *LocalResolver) LocalTracksForGroup(
|
||||
out := make([]LocalTrack, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, LocalTrack{
|
||||
AudioFileID: row.ID,
|
||||
FilePath: row.FilePath,
|
||||
Title: row.Title,
|
||||
Artist: row.ArtistName,
|
||||
TrackNumber: int(row.TrackNumber),
|
||||
DiscNumber: int(row.DiscNumber),
|
||||
LengthMillis: row.LengthMilliseconds,
|
||||
RecordingMBID: row.RecordingMbid,
|
||||
AudioFileID: row.ID,
|
||||
FilePath: row.FilePath,
|
||||
Title: row.Title,
|
||||
Artist: row.ArtistName,
|
||||
TrackNumber: int(row.TrackNumber),
|
||||
DiscNumber: int(row.DiscNumber),
|
||||
LengthMillis: row.LengthMilliseconds,
|
||||
RecordingMBID: row.RecordingMbid,
|
||||
AlbumTag: row.AlbumName,
|
||||
AlbumArtistTag: row.AlbumArtist,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -54,7 +56,7 @@ func (r *LocalResolver) LocalTracksForGroup(
|
||||
}
|
||||
|
||||
// ResolveLocal returns candidate releases sourced from the local
|
||||
// DB's release_groups rows (filtered to those carrying an MBID)
|
||||
// DB's albums (filtered to those carrying an MBID)
|
||||
// whose normalized name matches the tagging item's album name.
|
||||
// No network calls. Candidates carry all tracks flat; caller runs
|
||||
// AlignTracks on each to produce per-track alignments.
|
||||
@@ -65,7 +67,7 @@ func (r *LocalResolver) ResolveLocal(
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
rows, err := r.q.ListLocalReleaseGroupCandidates(ctx, albumName)
|
||||
rows, err := r.q.ListLocalAlbumCandidates(ctx, albumName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list local candidates: %w", err)
|
||||
}
|
||||
@@ -82,12 +84,12 @@ func (r *LocalResolver) ResolveLocal(
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := byID[row.ReleaseGroupID]; !ok {
|
||||
byID[row.ReleaseGroupID] = localCandidate(row)
|
||||
if _, ok := byID[row.AlbumID]; !ok {
|
||||
byID[row.AlbumID] = localCandidate(row)
|
||||
}
|
||||
|
||||
tracksByID[row.ReleaseGroupID] = append(
|
||||
tracksByID[row.ReleaseGroupID],
|
||||
tracksByID[row.AlbumID] = append(
|
||||
tracksByID[row.AlbumID],
|
||||
CandidateTrack{
|
||||
Position: int(row.TrackNumber),
|
||||
DiscNumber: int(row.DiscNumber),
|
||||
@@ -111,15 +113,15 @@ func (r *LocalResolver) ResolveLocal(
|
||||
// localCandidate converts one sqlc row (minus track-level fields)
|
||||
// into a Candidate shell. Track fields and alignments are filled
|
||||
// in by the caller.
|
||||
func localCandidate(row sqlcgen.ListLocalReleaseGroupCandidatesRow) *Candidate {
|
||||
func localCandidate(row sqlcgen.ListLocalAlbumCandidatesRow) *Candidate {
|
||||
date := ""
|
||||
if row.Year > 0 {
|
||||
date = fmt.Sprintf("%04d", row.Year)
|
||||
}
|
||||
|
||||
mbid := ""
|
||||
if row.ReleaseGroupMbid.Valid {
|
||||
mbid = row.ReleaseGroupMbid.String
|
||||
if row.AlbumMbid.Valid {
|
||||
mbid = row.AlbumMbid.String
|
||||
}
|
||||
|
||||
return &Candidate{
|
||||
|
||||
+41
-1
@@ -61,6 +61,18 @@ type MBClient interface {
|
||||
query string,
|
||||
limit int,
|
||||
) ([]MBReleaseGroupHit, int, error)
|
||||
// SearchReleaseGroupsLocal searches the offline dump-derived
|
||||
// catalog for release groups matching albumName — no network
|
||||
// round-trip. ok is false when the local catalog isn't
|
||||
// populated yet (or the implementation has no offline index),
|
||||
// telling the caller to rely on the network cascade alone; ok
|
||||
// true with zero hits means the catalog was consulted and
|
||||
// genuinely has nothing.
|
||||
SearchReleaseGroupsLocal(
|
||||
ctx context.Context,
|
||||
albumName string,
|
||||
limit int,
|
||||
) (hits []MBReleaseGroupHit, ok bool)
|
||||
SearchRecordings(
|
||||
ctx context.Context,
|
||||
query string,
|
||||
@@ -128,12 +140,40 @@ func (r *MBResolver) ResolveMB(ctx context.Context, g Group) ([]Candidate, error
|
||||
}
|
||||
|
||||
nArtist := Normalize(groupArtist(g))
|
||||
steps := buildMBQueryCascade(nAlbum, nArtist, len(g.Tracks), vaLikely(g))
|
||||
|
||||
seen := make(map[string]bool)
|
||||
|
||||
var merged []Candidate
|
||||
|
||||
// Local-index pass: the offline dump-derived catalog covers
|
||||
// essentially every popular release group, so try it before
|
||||
// spending any rate-limited search calls. This never skips
|
||||
// BrowseReleases (the catalog doesn't carry per-release
|
||||
// tracklists) but it very often means the network Lucene
|
||||
// cascade below never has to run at all.
|
||||
if localHits, ok := r.client.SearchReleaseGroupsLocal(ctx, g.AlbumName, r.limit); ok {
|
||||
added := r.fanOutBrowse(ctx, g, localHits, "index", seen, &merged)
|
||||
|
||||
r.logger.Debug(
|
||||
"local index search done",
|
||||
"hits", len(localHits), "new_candidates", added,
|
||||
)
|
||||
|
||||
if added > 0 {
|
||||
ranked := RankCandidates(g, merged)
|
||||
if len(ranked) > 0 && ranked[0].Score >= cascadeSufficient {
|
||||
r.logger.Info(
|
||||
"MB cascade stopped — sufficient local-index candidate",
|
||||
"score", ranked[0].Score,
|
||||
)
|
||||
|
||||
return merged, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
steps := buildMBQueryCascade(nAlbum, nArtist, len(g.Tracks), vaLikely(g))
|
||||
|
||||
for _, step := range steps {
|
||||
hits, _, err := r.client.SearchReleaseGroups(ctx, step.query, r.limit)
|
||||
if err != nil {
|
||||
|
||||
@@ -23,6 +23,8 @@ type fakeMBClient struct {
|
||||
lookupRGs map[string]MBReleaseGroupHit
|
||||
searchRecs []MBRecordingHit
|
||||
recRelsByMBID map[string][]MBReleaseRef
|
||||
localHits []MBReleaseGroupHit
|
||||
localOK bool
|
||||
}
|
||||
|
||||
func (f *fakeMBClient) SearchReleaseGroups(
|
||||
@@ -36,6 +38,15 @@ func (f *fakeMBClient) SearchReleaseGroups(
|
||||
return hits, len(hits), nil
|
||||
}
|
||||
|
||||
// SearchReleaseGroupsLocal is a no-op by default (ok=false), so
|
||||
// existing cascade tests exercise the network path unchanged. Set
|
||||
// localHits / localOK on the fake to exercise the index-first path.
|
||||
func (f *fakeMBClient) SearchReleaseGroupsLocal(
|
||||
_ context.Context, _ string, _ int,
|
||||
) ([]MBReleaseGroupHit, bool) {
|
||||
return f.localHits, f.localOK
|
||||
}
|
||||
|
||||
func (f *fakeMBClient) BrowseReleases(
|
||||
_ context.Context, mbid string,
|
||||
) ([]MBRelease, error) {
|
||||
@@ -188,6 +199,89 @@ func TestMBResolver_CascadeStopsWhenSufficient(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMBResolver_LocalIndexSufficientSkipsNetworkSearch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fake := &fakeMBClient{
|
||||
localOK: true,
|
||||
localHits: []MBReleaseGroupHit{
|
||||
{MBID: "rg1", Title: "Abbey Road"},
|
||||
},
|
||||
browseByMBID: map[string][]MBRelease{
|
||||
"rg1": {{
|
||||
MBID: "rel1", Title: "Abbey Road", Status: "Official",
|
||||
Tracks: []CandidateTrack{
|
||||
{Position: 1, Title: "Come Together", LengthMillis: 259000},
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
|
||||
|
||||
cands, err := r.ResolveMB(context.Background(), abbeyRoadGroup())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMB: %v", err)
|
||||
}
|
||||
|
||||
if len(cands) != 1 {
|
||||
t.Fatalf("expected 1 candidate, got %d", len(cands))
|
||||
}
|
||||
|
||||
if cands[0].Provenance != "index" {
|
||||
t.Errorf("provenance = %q, want 'index'", cands[0].Provenance)
|
||||
}
|
||||
|
||||
if len(fake.queries) != 0 {
|
||||
t.Errorf(
|
||||
"expected zero network search queries when the local index sufficed, got %d: %v",
|
||||
len(fake.queries), fake.queries,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMBResolver_LocalIndexThinFallsThroughToNetwork(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Local index is "ready" but has nothing plausible for this
|
||||
// album — the cascade must still fall through to the network
|
||||
// steps exactly as if there were no local index at all.
|
||||
fake := &fakeMBClient{
|
||||
localOK: true,
|
||||
localHits: nil,
|
||||
searchByStep: map[int][]MBReleaseGroupHit{
|
||||
1: {{MBID: "rg1", Title: "Abbey Road"}},
|
||||
},
|
||||
browseByMBID: map[string][]MBRelease{
|
||||
"rg1": {{
|
||||
MBID: "rel1", Title: "Abbey Road", Status: "Official",
|
||||
Tracks: []CandidateTrack{
|
||||
{Position: 1, Title: "Come Together", LengthMillis: 259000},
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
|
||||
r := NewMBResolver(fake, slog.New(slog.DiscardHandler))
|
||||
|
||||
cands, err := r.ResolveMB(context.Background(), abbeyRoadGroup())
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveMB: %v", err)
|
||||
}
|
||||
|
||||
if len(cands) != 1 {
|
||||
t.Fatalf("expected 1 candidate, got %d", len(cands))
|
||||
}
|
||||
|
||||
if cands[0].Provenance != "no-track-count" {
|
||||
t.Errorf("provenance = %q, want 'no-track-count'", cands[0].Provenance)
|
||||
}
|
||||
|
||||
if len(fake.queries) != 2 { //nolint:mnd
|
||||
t.Errorf("expected the usual 2 network queries, got %d", len(fake.queries))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMBResolver_CascadeContinuesPastMediocreHits(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
package autotag
|
||||
|
||||
// mixedBagMinTracks is the smallest folder IsMixedBag will flag.
|
||||
// Below this, artist/album divergence is just as likely to be
|
||||
// sampling noise (a 2-track folder with two different artists could
|
||||
// easily be a legitimate 2-track EP with a featured artist) as it is
|
||||
// a genuine junk-drawer folder.
|
||||
const mixedBagMinTracks = 4
|
||||
|
||||
// clusterMinSize is the smallest tag-matched group ClusterByAlbumArtist
|
||||
// will surface as a splittable cluster. A single track sharing no
|
||||
// album/artist with anything else in the folder gains nothing from
|
||||
// becoming its own one-track group — it stays in the leftover folder,
|
||||
// which the existing evidence-scaling (rank.go) already treats
|
||||
// appropriately harshly for a 1-track match.
|
||||
const clusterMinSize = 2
|
||||
|
||||
// IsMixedBag reports whether a group's local tracks look like an
|
||||
// unrelated pile of songs rather than one release: no artist
|
||||
// consensus AND no album consensus, across enough tracks that the
|
||||
// divergence isn't just noise. An explicit, non-VA album-artist tag
|
||||
// on the folder overrides the heuristic — a user (or a prior tagger)
|
||||
// who set a real album-artist meant this to read as one release.
|
||||
func IsMixedBag(g Group) bool {
|
||||
if len(g.Tracks) < mixedBagMinTracks {
|
||||
return false
|
||||
}
|
||||
|
||||
if g.AlbumArtist != "" && !isVAName(g.AlbumArtist) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !hasTagConsensus(trackArtistTags(g.Tracks)) &&
|
||||
!hasTagConsensus(trackAlbumTags(g.Tracks))
|
||||
}
|
||||
|
||||
// hasTagConsensus reports whether every non-empty value in vals
|
||||
// normalizes to the same string. Empty values are ignored — missing
|
||||
// tags are unknown, not disagreement. A folder with zero non-empty
|
||||
// values has no consensus either way; callers only reach here after
|
||||
// already requiring enough tracks to matter.
|
||||
func hasTagConsensus(vals []string) bool {
|
||||
distinct := make(map[string]bool, 2) //nolint:mnd
|
||||
|
||||
for _, v := range vals {
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
distinct[Normalize(v)] = true
|
||||
|
||||
if len(distinct) > 1 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return len(distinct) == 1
|
||||
}
|
||||
|
||||
func trackArtistTags(tracks []LocalTrack) []string {
|
||||
out := make([]string, len(tracks))
|
||||
for i, t := range tracks {
|
||||
out[i] = t.Artist
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func trackAlbumTags(tracks []LocalTrack) []string {
|
||||
out := make([]string, len(tracks))
|
||||
for i, t := range tracks {
|
||||
out[i] = t.AlbumTag
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// TrackCluster is a set of local tracks whose album (and album-artist)
|
||||
// tags are close enough to describe the same release — a candidate
|
||||
// sub-album hiding inside a mixed-bag folder.
|
||||
type TrackCluster struct {
|
||||
AlbumName string
|
||||
AlbumArtist string
|
||||
Tracks []LocalTrack
|
||||
}
|
||||
|
||||
// clusterFuzzyThreshold is the maximum stringDist between a track's
|
||||
// album tag (and, separately, its album-artist tag) and the tags that
|
||||
// started a cluster for the two to be considered the same album.
|
||||
// Tight enough to keep genuinely different albums by the same artist
|
||||
// apart, loose enough to absorb the kind of typo, dropped diacritic,
|
||||
// or stray whitespace that exact Normalize()-equality clustering used
|
||||
// to split into separate clusters — the same distance function
|
||||
// candidate scoring already uses to decide two titles describe the
|
||||
// same release (rank.go's albumTitleFit/artistCreditFit), applied to
|
||||
// the same question here: do these two tags name the same thing.
|
||||
const clusterFuzzyThreshold = 0.15
|
||||
|
||||
// clusterTracks groups tracks into candidate sub-albums: a track
|
||||
// joins the first existing cluster whose founding track's album tag
|
||||
// is within clusterFuzzyThreshold (in stringDist terms), and whose
|
||||
// album-artist tag either also matches or is empty on either side —
|
||||
// same "empty means unknown, not a mismatch" contract as
|
||||
// artistCreditFit — or else it starts a new cluster. Tracks with no
|
||||
// album tag are left unassigned (memberOf entry -1).
|
||||
//
|
||||
// Comparing only against the cluster's founding track, not a running
|
||||
// centroid or every member, keeps this O(tracks × clusters) and
|
||||
// deterministic in first-seen order — the order ClusterByAlbumArtist
|
||||
// and SplitPlan's callers already depend on (they pass tracks ordered
|
||||
// by disc/track/path).
|
||||
func clusterTracks(tracks []LocalTrack) (clusters []TrackCluster, memberOf []int) {
|
||||
type rep struct{ album, artist string }
|
||||
|
||||
var reps []rep
|
||||
|
||||
memberOf = make([]int, len(tracks))
|
||||
|
||||
for i, t := range tracks {
|
||||
if Normalize(t.AlbumTag) == "" {
|
||||
memberOf[i] = -1
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
joined := -1
|
||||
|
||||
for ci, r := range reps {
|
||||
artistMatches := t.AlbumArtistTag == "" || r.artist == "" ||
|
||||
stringDist(t.AlbumArtistTag, r.artist) <= clusterFuzzyThreshold
|
||||
|
||||
if artistMatches && stringDist(t.AlbumTag, r.album) <= clusterFuzzyThreshold {
|
||||
joined = ci
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if joined < 0 {
|
||||
joined = len(clusters)
|
||||
|
||||
reps = append(reps, rep{album: t.AlbumTag, artist: t.AlbumArtistTag})
|
||||
clusters = append(clusters, TrackCluster{
|
||||
AlbumName: t.AlbumTag,
|
||||
AlbumArtist: t.AlbumArtistTag,
|
||||
})
|
||||
}
|
||||
|
||||
clusters[joined].Tracks = append(clusters[joined].Tracks, t)
|
||||
memberOf[i] = joined
|
||||
}
|
||||
|
||||
return clusters, memberOf
|
||||
}
|
||||
|
||||
// ClusterByAlbumArtist groups tracks by album/album-artist tag
|
||||
// similarity (see clusterTracks) and returns the clusters with at
|
||||
// least clusterMinSize members, in first-seen order. Tracks with no
|
||||
// album tag, or whose cluster never reaches clusterMinSize, are
|
||||
// omitted — they belong in the leftover folder, not a synthetic group
|
||||
// of their own.
|
||||
func ClusterByAlbumArtist(tracks []LocalTrack) []TrackCluster {
|
||||
clusters, _ := clusterTracks(tracks)
|
||||
|
||||
out := clusters[:0]
|
||||
|
||||
for _, c := range clusters {
|
||||
if len(c.Tracks) >= clusterMinSize {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// SplitPlan returns the full set of synthetic groups a mixed-bag
|
||||
// folder should be torn into: ClusterByAlbumArtist's tag-matched
|
||||
// sub-albums, plus a one-track cluster for every track that didn't
|
||||
// end up sharing a cluster with anything else in the folder. Unlike
|
||||
// ClusterByAlbumArtist alone — which leaves unclustered tracks behind
|
||||
// in the parent group, where they'd still get folded into whatever
|
||||
// partial-album match the scorer finds for the rest of the pile —
|
||||
// this guarantees every track leaves the parent, so a folder of
|
||||
// entirely unrelated singles (no two tracks share an album tag) still
|
||||
// gets torn apart instead of being scored as one bogus album with a
|
||||
// pile of "extra" tracks. Each singleton's evidence-scaled score
|
||||
// (rank.go) keeps it appropriately humble on its own — it just no
|
||||
// longer drags an unrelated release's score down, or gets dragged
|
||||
// down by one.
|
||||
func SplitPlan(tracks []LocalTrack) []TrackCluster {
|
||||
clusters, memberOf := clusterTracks(tracks)
|
||||
|
||||
// Clusters that never reached clusterMinSize don't survive as a
|
||||
// group; their sole member falls through to the singleton pass
|
||||
// below instead.
|
||||
kept := make([]TrackCluster, 0, len(clusters))
|
||||
keptIndex := make(map[int]int, len(clusters))
|
||||
|
||||
for oldIdx, c := range clusters {
|
||||
if len(c.Tracks) >= clusterMinSize {
|
||||
keptIndex[oldIdx] = len(kept)
|
||||
kept = append(kept, c)
|
||||
}
|
||||
}
|
||||
|
||||
for i, t := range tracks {
|
||||
if ci := memberOf[i]; ci >= 0 {
|
||||
if _, ok := keptIndex[ci]; ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
kept = append(kept, TrackCluster{
|
||||
AlbumName: t.AlbumTag,
|
||||
AlbumArtist: t.AlbumArtistTag,
|
||||
Tracks: []LocalTrack{t},
|
||||
})
|
||||
}
|
||||
|
||||
return kept
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
package autotag
|
||||
|
||||
import "testing"
|
||||
|
||||
func junkDrawerTracks() []LocalTrack {
|
||||
return []LocalTrack{
|
||||
{
|
||||
Title: "Song A", Artist: "Artist One",
|
||||
AlbumTag: "Album One", AlbumArtistTag: "Artist One",
|
||||
},
|
||||
{
|
||||
Title: "Song B", Artist: "Artist One",
|
||||
AlbumTag: "Album One", AlbumArtistTag: "Artist One",
|
||||
},
|
||||
{
|
||||
Title: "Song C", Artist: "Artist Two",
|
||||
AlbumTag: "Album Two", AlbumArtistTag: "Artist Two",
|
||||
},
|
||||
{
|
||||
Title: "Song D", Artist: "Artist Two",
|
||||
AlbumTag: "Album Two", AlbumArtistTag: "Artist Two",
|
||||
},
|
||||
{
|
||||
Title: "Song E", Artist: "Artist Three",
|
||||
AlbumTag: "Album Three", AlbumArtistTag: "Artist Three",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMixedBag_DetectsJunkDrawer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := Group{Tracks: junkDrawerTracks()}
|
||||
|
||||
if !IsMixedBag(g) {
|
||||
t.Fatal("expected a folder with no artist or album consensus to be flagged mixed-bag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMixedBag_RealAlbumNotFlagged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := Group{
|
||||
AlbumArtist: "The Beatles",
|
||||
Tracks: []LocalTrack{
|
||||
{Title: "Come Together", Artist: "The Beatles"},
|
||||
{Title: "Something", Artist: "The Beatles"},
|
||||
{Title: "Maxwell's Silver Hammer", Artist: "The Beatles"},
|
||||
{Title: "Oh! Darling", Artist: "The Beatles"},
|
||||
},
|
||||
}
|
||||
|
||||
if IsMixedBag(g) {
|
||||
t.Fatal("a coherent single-artist album must not be flagged mixed-bag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMixedBag_ExplicitAlbumArtistOverridesHeuristic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Per-track artists disagree (feat. credits, remixers, etc.) but
|
||||
// the folder carries a real album-artist tag — trust it.
|
||||
g := Group{
|
||||
AlbumArtist: "Some Artist",
|
||||
Tracks: []LocalTrack{
|
||||
{Title: "Track 1", Artist: "Some Artist"},
|
||||
{Title: "Track 2", Artist: "Some Artist feat. Guest"},
|
||||
{Title: "Track 3", Artist: "Someone Else"},
|
||||
{Title: "Track 4", Artist: "Some Artist"},
|
||||
},
|
||||
}
|
||||
|
||||
if IsMixedBag(g) {
|
||||
t.Fatal("explicit non-VA album-artist tag should override the divergence heuristic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMixedBag_VACompilationNotFlagged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Various-artists compilation: artists diverge but every track
|
||||
// agrees on the album — this is vaLikely's case, not a junk
|
||||
// drawer, so IsMixedBag must require album divergence too.
|
||||
g := Group{
|
||||
Tracks: []LocalTrack{
|
||||
{Title: "Track 1", Artist: "Artist One", AlbumTag: "Now That's What I Call Music"},
|
||||
{Title: "Track 2", Artist: "Artist Two", AlbumTag: "Now That's What I Call Music"},
|
||||
{Title: "Track 3", Artist: "Artist Three", AlbumTag: "Now That's What I Call Music"},
|
||||
{Title: "Track 4", Artist: "Artist Four", AlbumTag: "Now That's What I Call Music"},
|
||||
},
|
||||
}
|
||||
|
||||
if IsMixedBag(g) {
|
||||
t.Fatal("a VA compilation with consistent album tags must not be flagged mixed-bag")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMixedBag_TooFewTracksNotFlagged(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
g := Group{
|
||||
Tracks: []LocalTrack{
|
||||
{Title: "Track 1", Artist: "Artist One", AlbumTag: "Album One"},
|
||||
{Title: "Track 2", Artist: "Artist Two", AlbumTag: "Album Two"},
|
||||
},
|
||||
}
|
||||
|
||||
if IsMixedBag(g) {
|
||||
t.Fatal("a folder below mixedBagMinTracks must not be flagged, even if it diverges")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClusterByAlbumArtist_FindsSubAlbums(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tracks := junkDrawerTracks() // two 2-track clusters + one true singleton
|
||||
|
||||
clusters := ClusterByAlbumArtist(tracks)
|
||||
|
||||
if len(clusters) != 2 { //nolint:mnd
|
||||
t.Fatalf("expected 2 clusters (Album One, Album Two), got %d: %+v", len(clusters), clusters)
|
||||
}
|
||||
|
||||
for _, c := range clusters {
|
||||
if len(c.Tracks) != 2 { //nolint:mnd
|
||||
t.Errorf("cluster %q: expected 2 tracks, got %d", c.AlbumName, len(c.Tracks))
|
||||
}
|
||||
}
|
||||
|
||||
total := 0
|
||||
for _, c := range clusters {
|
||||
total += len(c.Tracks)
|
||||
}
|
||||
|
||||
if total != 4 { //nolint:mnd
|
||||
t.Errorf(
|
||||
"expected 4 clustered tracks total (Song E stays unclustered), got %d",
|
||||
total,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClusterByAlbumArtist_TypoVariantsMergeIntoOneCluster(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A dropped diacritic and a stray trailing space are the kind of
|
||||
// noise exact Normalize()-equality clustering used to treat as
|
||||
// two different albums, splitting one real album across clusters
|
||||
// even though a candidate search on either would land on the same
|
||||
// release. Fuzzy clustering absorbs both into one cluster.
|
||||
tracks := []LocalTrack{
|
||||
{
|
||||
Title: "Song A",
|
||||
Artist: "Sigur Ros",
|
||||
AlbumTag: "Agaetis Byrjun",
|
||||
AlbumArtistTag: "Sigur Ros",
|
||||
},
|
||||
{
|
||||
Title: "Song B",
|
||||
Artist: "Sigur Ros",
|
||||
AlbumTag: "Ágætis byrjun",
|
||||
AlbumArtistTag: "Sigur Ros",
|
||||
},
|
||||
{
|
||||
Title: "Song C",
|
||||
Artist: "Sigur Ros",
|
||||
AlbumTag: "Agaetis Byrjun ",
|
||||
AlbumArtistTag: "Sigur Ros",
|
||||
},
|
||||
}
|
||||
|
||||
clusters := ClusterByAlbumArtist(tracks)
|
||||
|
||||
if len(clusters) != 1 {
|
||||
t.Fatalf(
|
||||
"expected typo variants to merge into 1 cluster, got %d: %+v",
|
||||
len(clusters),
|
||||
clusters,
|
||||
)
|
||||
}
|
||||
|
||||
if len(clusters[0].Tracks) != 3 { //nolint:mnd
|
||||
t.Fatalf("expected all 3 tracks in the merged cluster, got %d", len(clusters[0].Tracks))
|
||||
}
|
||||
}
|
||||
|
||||
func TestClusterByAlbumArtist_DifferentAlbumsBySameArtistStaySeparate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Fuzzy clustering must not blur genuinely different albums by
|
||||
// the same artist into one cluster just because they share an
|
||||
// artist tag — the threshold has to stay tight enough for this.
|
||||
tracks := []LocalTrack{
|
||||
{
|
||||
Title: "Song A",
|
||||
Artist: "Radiohead",
|
||||
AlbumTag: "OK Computer",
|
||||
AlbumArtistTag: "Radiohead",
|
||||
},
|
||||
{
|
||||
Title: "Song B",
|
||||
Artist: "Radiohead",
|
||||
AlbumTag: "OK Computer",
|
||||
AlbumArtistTag: "Radiohead",
|
||||
},
|
||||
{Title: "Song C", Artist: "Radiohead", AlbumTag: "Kid A", AlbumArtistTag: "Radiohead"},
|
||||
{Title: "Song D", Artist: "Radiohead", AlbumTag: "Kid A", AlbumArtistTag: "Radiohead"},
|
||||
}
|
||||
|
||||
clusters := ClusterByAlbumArtist(tracks)
|
||||
|
||||
if len(clusters) != 2 { //nolint:mnd
|
||||
t.Fatalf(
|
||||
"expected OK Computer and Kid A to stay separate, got %d clusters: %+v",
|
||||
len(clusters),
|
||||
clusters,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClusterByAlbumArtist_NoAlbumTagStaysUnclustered(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tracks := []LocalTrack{
|
||||
{Title: "Track 1", Artist: "Artist One"},
|
||||
{Title: "Track 2", Artist: "Artist One"},
|
||||
}
|
||||
|
||||
if clusters := ClusterByAlbumArtist(tracks); len(clusters) != 0 {
|
||||
t.Fatalf("tracks with no album tag must never cluster, got %+v", clusters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClusterByAlbumArtist_DeterministicOrder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tracks := junkDrawerTracks()
|
||||
|
||||
first := ClusterByAlbumArtist(tracks)
|
||||
second := ClusterByAlbumArtist(tracks)
|
||||
|
||||
if len(first) != len(second) {
|
||||
t.Fatalf("non-deterministic cluster count: %d vs %d", len(first), len(second))
|
||||
}
|
||||
|
||||
for i := range first {
|
||||
if first[i].AlbumName != second[i].AlbumName {
|
||||
t.Errorf(
|
||||
"non-deterministic cluster order at %d: %q vs %q",
|
||||
i,
|
||||
first[i].AlbumName,
|
||||
second[i].AlbumName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if first[0].AlbumName != "Album One" {
|
||||
t.Errorf("expected first-seen cluster order, got %q first", first[0].AlbumName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitPlan_ClustersPlusSingletonForEveryLeftover(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tracks := junkDrawerTracks() // two 2-track clusters + one true singleton (Song E)
|
||||
|
||||
plan := SplitPlan(tracks)
|
||||
|
||||
total := 0
|
||||
for _, c := range plan {
|
||||
total += len(c.Tracks)
|
||||
}
|
||||
|
||||
if total != len(tracks) {
|
||||
t.Fatalf("expected every track accounted for, got %d of %d", total, len(tracks))
|
||||
}
|
||||
|
||||
var singletons, clustered int
|
||||
|
||||
for _, c := range plan {
|
||||
switch len(c.Tracks) {
|
||||
case 1:
|
||||
singletons++
|
||||
case 2: //nolint:mnd
|
||||
clustered++
|
||||
default:
|
||||
t.Errorf("unexpected cluster size %d: %+v", len(c.Tracks), c)
|
||||
}
|
||||
}
|
||||
|
||||
if singletons != 1 {
|
||||
t.Errorf("expected exactly 1 singleton (Song E), got %d", singletons)
|
||||
}
|
||||
|
||||
if clustered != 2 { //nolint:mnd
|
||||
t.Errorf("expected exactly 2 clustered groups, got %d", clustered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitPlan_AllUnrelatedTracksAllBecomeSingletons(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tracks := []LocalTrack{
|
||||
{Title: "Track 1", Artist: "Artist One", AlbumTag: "Album A"},
|
||||
{Title: "Track 2", Artist: "Artist Two", AlbumTag: "Album B"},
|
||||
{Title: "Track 3", Artist: "Artist Three"}, // no album tag at all
|
||||
}
|
||||
|
||||
plan := SplitPlan(tracks)
|
||||
|
||||
if len(plan) != len(tracks) {
|
||||
t.Fatalf(
|
||||
"expected every unrelated track to become its own singleton, got %d clusters for %d tracks",
|
||||
len(plan),
|
||||
len(tracks),
|
||||
)
|
||||
}
|
||||
|
||||
for _, c := range plan {
|
||||
if len(c.Tracks) != 1 {
|
||||
t.Errorf("expected singleton cluster, got %d tracks: %+v", len(c.Tracks), c)
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
-3
@@ -33,6 +33,19 @@ const (
|
||||
// auto-accept entirely.
|
||||
evidenceFloor = 0.85
|
||||
evidenceFullTracks = 3
|
||||
|
||||
// Synthetic groups (SplitMixedFolder's tag-clustered sub-albums)
|
||||
// are, by construction, a SUBSET of a bigger folder: the folder
|
||||
// might not have every track from the release the cluster
|
||||
// belongs to. A candidate with more tracks than the synthetic
|
||||
// group is therefore expected, not a sign of a wrong match, so
|
||||
// its trackCountMatch penalty is softened relative to a real
|
||||
// folder (where a track-count gap usually does mean the wrong
|
||||
// release). A candidate with FEWER tracks than the group is
|
||||
// still scored by the normal (harsher) formula — that's a real
|
||||
// mismatch regardless of source.
|
||||
syntheticMissingPenaltyScale = 0.35
|
||||
syntheticTrackCountFloor = 0.55
|
||||
)
|
||||
|
||||
// vaNames are artist strings that signal "various artists" — used
|
||||
@@ -139,7 +152,7 @@ func ScoreCandidate(g Group, c Candidate) Candidate {
|
||||
|
||||
trackAgg := ((titleAvg*weightTitle + lengthAvg*weightLength) / trackWeightSum) * coverage
|
||||
|
||||
trackCountScore := trackCountMatch(len(targets), len(local))
|
||||
trackCountScore := trackCountMatch(len(targets), len(local), g.Synthetic)
|
||||
|
||||
// Artist fit: compare the folder's artist against the
|
||||
// candidate's release artist-credit. This is a SOFT signal, not
|
||||
@@ -347,8 +360,15 @@ func evidenceFactor(localTrackCount int) float64 {
|
||||
}
|
||||
|
||||
// trackCountMatch returns 1.0 when equal, 0.0 when off by >= 50%,
|
||||
// linear between.
|
||||
func trackCountMatch(a, b int) float64 {
|
||||
// linear between. When synthetic is true and the candidate (a) has
|
||||
// MORE tracks than the local group (b) — the group having fewer
|
||||
// tracks than the full release, exactly what's expected from a
|
||||
// tag-clustered subset of a folder — the penalty is softened instead
|
||||
// of using the normal harsh formula. Fewer candidate tracks than
|
||||
// local (b > a) always uses the normal formula: that pattern means
|
||||
// the group has tracks the candidate release doesn't, which is a
|
||||
// real mismatch however the group was built.
|
||||
func trackCountMatch(a, b int, synthetic bool) float64 {
|
||||
if a == 0 && b == 0 {
|
||||
return 1.0
|
||||
}
|
||||
@@ -357,6 +377,13 @@ func trackCountMatch(a, b int) float64 {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
if synthetic && a > b {
|
||||
diff := a - b
|
||||
frac := float64(diff) / float64(a)
|
||||
|
||||
return max(1.0-frac*syntheticMissingPenaltyScale, syntheticTrackCountFloor)
|
||||
}
|
||||
|
||||
diff := a - b
|
||||
if diff < 0 {
|
||||
diff = -diff
|
||||
|
||||
@@ -49,6 +49,55 @@ func TestRankCandidates_PrefersExactTrackCountMatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreCandidate_SyntheticGroupSoftensMissingTrackPenalty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Two tracks pulled from a mixed-bag folder, tag-clustered as a
|
||||
// subset of a 5-track release — exactly what SplitMixedFolder
|
||||
// produces. A candidate release with the other 3 tracks the
|
||||
// folder simply never had must not be penalized nearly as hard
|
||||
// as a real folder missing 3 of 5 tracks would be.
|
||||
local := []autotag.LocalTrack{
|
||||
{Title: "A", TrackNumber: 1, LengthMillis: 200000},
|
||||
{Title: "B", TrackNumber: 2, LengthMillis: 200000},
|
||||
}
|
||||
|
||||
candidate := autotag.Candidate{
|
||||
ReleaseMBID: "full-release",
|
||||
Title: "Album",
|
||||
Status: "Official",
|
||||
Tracks: []autotag.CandidateTrack{
|
||||
{Position: 1, Title: "A", LengthMillis: 200000},
|
||||
{Position: 2, Title: "B", LengthMillis: 200000},
|
||||
{Position: 3, Title: "C", LengthMillis: 200000},
|
||||
{Position: 4, Title: "D", LengthMillis: 200000},
|
||||
{Position: 5, Title: "E", LengthMillis: 200000},
|
||||
},
|
||||
}
|
||||
|
||||
fromRealFolder := autotag.ScoreCandidate(
|
||||
autotag.Group{Tracks: local, Synthetic: false}, candidate,
|
||||
)
|
||||
fromSynthetic := autotag.ScoreCandidate(
|
||||
autotag.Group{Tracks: local, Synthetic: true}, candidate,
|
||||
)
|
||||
|
||||
if fromSynthetic.Breakdown.TrackCountFit <= fromRealFolder.Breakdown.TrackCountFit {
|
||||
t.Errorf(
|
||||
"synthetic track-count fit (%.3f) should exceed the real-folder fit (%.3f) for the same gap",
|
||||
fromSynthetic.Breakdown.TrackCountFit,
|
||||
fromRealFolder.Breakdown.TrackCountFit,
|
||||
)
|
||||
}
|
||||
|
||||
if fromSynthetic.Score <= fromRealFolder.Score {
|
||||
t.Errorf(
|
||||
"synthetic group score (%.3f) should exceed the real-folder score (%.3f)",
|
||||
fromSynthetic.Score, fromRealFolder.Score,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankCandidates_PrefersOfficial(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -59,9 +59,16 @@ func Recommend(g Group, candidates []Candidate) Recommendation {
|
||||
|
||||
// Cap: missing or unmatched tracks mean the alignment itself is
|
||||
// incomplete, however good the matched tracks look (beets caps
|
||||
// these penalties at "medium" the same way).
|
||||
// these penalties at "medium" the same way). A synthetic
|
||||
// (tag-clustered) group is, by construction, a subset of a
|
||||
// bigger folder, so AlignmentMissing (the candidate has tracks
|
||||
// the group doesn't) is the expected shape rather than a defect
|
||||
// and doesn't cap the recommendation. AlignmentUnmatched (the
|
||||
// group has a track the candidate doesn't) is still a real
|
||||
// discrepancy regardless of source.
|
||||
for _, a := range top.Alignments {
|
||||
if a.Status == AlignmentMissing || a.Status == AlignmentUnmatched {
|
||||
if a.Status == AlignmentUnmatched ||
|
||||
(a.Status == AlignmentMissing && !g.Synthetic) {
|
||||
rec = minRecommendation(rec, RecommendationMedium)
|
||||
|
||||
break
|
||||
|
||||
@@ -97,6 +97,46 @@ func TestRecommend_AlignmentDefectsCapAtMedium(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommend_SyntheticGroupMissingTracksDoNotCap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
top := mkScoredCandidate("rg1", 0.95)
|
||||
top.Alignments = []TrackAlignment{
|
||||
{Status: AlignmentMatched},
|
||||
{Status: AlignmentMissing, LocalIndex: -1},
|
||||
}
|
||||
|
||||
g := fullGroup()
|
||||
g.Synthetic = true
|
||||
|
||||
if got := Recommend(g, []Candidate{top}); got != RecommendationStrong {
|
||||
t.Errorf(
|
||||
"synthetic group with only missing (not unmatched) tracks: Recommend = %q, want strong",
|
||||
got,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommend_SyntheticGroupUnmatchedTracksStillCap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
top := mkScoredCandidate("rg1", 0.95)
|
||||
top.Alignments = []TrackAlignment{
|
||||
{Status: AlignmentMatched},
|
||||
{Status: AlignmentUnmatched, LocalIndex: 1},
|
||||
}
|
||||
|
||||
g := fullGroup()
|
||||
g.Synthetic = true
|
||||
|
||||
if got := Recommend(g, []Candidate{top}); got != RecommendationMedium {
|
||||
t.Errorf(
|
||||
"synthetic group with an unmatched local track: Recommend = %q, want medium",
|
||||
got,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommend_ThinEvidenceCapsAtMedium(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ func (s *Scorer) scoreGroup(
|
||||
AlbumName: item.AlbumName,
|
||||
AlbumArtist: item.AlbumArtist,
|
||||
Tracks: locals,
|
||||
Synthetic: item.Synthetic != 0,
|
||||
}
|
||||
|
||||
localHits, err := s.local.ResolveLocal(ctx, item.AlbumName)
|
||||
@@ -142,6 +143,7 @@ func (s *Scorer) scoreGroup(
|
||||
LocalTracks: locals,
|
||||
Candidates: candidates,
|
||||
Recommendation: Recommend(g, candidates),
|
||||
Synthetic: g.Synthetic,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,11 @@ package autotag_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/autotag"
|
||||
"yellowjacket/backend/database"
|
||||
"yellowjacket/backend/database/sql/sqlcgen"
|
||||
)
|
||||
|
||||
// seedAlbum drops a minimal release_group + recordings + audio_files
|
||||
@@ -33,70 +31,19 @@ type seededTrack struct {
|
||||
func seed(t *testing.T, db *database.DB, album seededAlbum) {
|
||||
t.Helper()
|
||||
|
||||
ctx := db.Ctx
|
||||
q := db.Queries
|
||||
|
||||
ac, err := q.UpsertArtistCredit(ctx, "Test Artist")
|
||||
if err != nil {
|
||||
t.Fatalf("upsert ac: %v", err)
|
||||
}
|
||||
|
||||
rg, err := q.UpsertReleaseGroup(ctx, sqlcgen.UpsertReleaseGroupParams{
|
||||
Name: album.albumName,
|
||||
AlbumArtistCreditID: sql.NullInt64{Int64: ac.ID, Valid: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upsert rg: %v", err)
|
||||
}
|
||||
|
||||
if album.releaseMBID != "" {
|
||||
if _, err := db.ExecContext(
|
||||
`UPDATE release_groups SET mbid = ? WHERE id = ?`,
|
||||
album.releaseMBID, rg.ID,
|
||||
); err != nil {
|
||||
t.Fatalf("set rg mbid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tr := range album.tracks {
|
||||
rec, err := q.CreateRecordingFull(ctx, sqlcgen.CreateRecordingFullParams{
|
||||
Name: tr.title,
|
||||
ArtistCreditID: ac.ID,
|
||||
TrackNumber: sql.NullInt64{Int64: int64(tr.trackNumber), Valid: true},
|
||||
database.InsertTestTrack(t, db, database.TestTrack{
|
||||
FilePath: tr.filePath,
|
||||
Title: tr.title,
|
||||
Artist: "Test Artist",
|
||||
Album: album.albumName,
|
||||
AlbumMBID: album.releaseMBID,
|
||||
RecordingMBID: tr.recordingMBID,
|
||||
TrackNumber: int64(tr.trackNumber),
|
||||
LengthMs: tr.lengthMillis,
|
||||
LibraryID: album.libraryID,
|
||||
GroupKey: album.groupKey,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create recording: %v", err)
|
||||
}
|
||||
|
||||
if tr.recordingMBID != "" {
|
||||
if _, err := db.ExecContext(
|
||||
`UPDATE recordings SET mbid = ? WHERE id = ?`,
|
||||
tr.recordingMBID, rec.ID,
|
||||
); err != nil {
|
||||
t.Fatalf("set recording mbid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := q.CreateReleaseGroupRecording(ctx, sqlcgen.CreateReleaseGroupRecordingParams{
|
||||
ReleaseGroupID: rg.ID,
|
||||
RecordingID: rec.ID,
|
||||
TrackNumber: sql.NullInt64{Int64: int64(tr.trackNumber), Valid: true},
|
||||
}); err != nil {
|
||||
t.Fatalf("link rg recording: %v", err)
|
||||
}
|
||||
|
||||
if _, err := q.CreateAudioFileWithGroupKey(ctx, sqlcgen.CreateAudioFileWithGroupKeyParams{
|
||||
FilePath: tr.filePath,
|
||||
LengthMilliseconds: tr.lengthMillis,
|
||||
FileTypeID: 0,
|
||||
RecordingID: rec.ID,
|
||||
Basename: tr.filePath,
|
||||
LibraryID: album.libraryID,
|
||||
GroupKey: album.groupKey,
|
||||
TagStatus: "untagged",
|
||||
}); err != nil {
|
||||
t.Fatalf("create audio file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(`
|
||||
@@ -338,6 +285,12 @@ func (c *idFakeClient) LookupReleaseGroup(
|
||||
return autotag.MBReleaseGroupHit{}, nil
|
||||
}
|
||||
|
||||
func (c *idFakeClient) SearchReleaseGroupsLocal(
|
||||
_ context.Context, _ string, _ int,
|
||||
) ([]autotag.MBReleaseGroupHit, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func TestScorer_PersistScoreWritesTopMatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -462,3 +415,11 @@ func (c *countingMBClient) LookupReleaseGroup(
|
||||
|
||||
return autotag.MBReleaseGroupHit{}, nil
|
||||
}
|
||||
|
||||
// SearchReleaseGroupsLocal is not a network call — it never counts
|
||||
// against the zero-network-call assertions this fake exists for.
|
||||
func (c *countingMBClient) SearchReleaseGroupsLocal(
|
||||
_ context.Context, _ string, _ int,
|
||||
) ([]autotag.MBReleaseGroupHit, bool) {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
@@ -12,6 +12,15 @@ type LocalTrack struct {
|
||||
DiscNumber int
|
||||
LengthMillis int64
|
||||
RecordingMBID string
|
||||
|
||||
// AlbumTag/AlbumArtistTag are this track's OWN album tags (via
|
||||
// its release_group link), independent of the folder-level
|
||||
// Group.AlbumName/AlbumArtist below. A coherent album's tracks
|
||||
// all carry the same values here; a junk-drawer folder's don't.
|
||||
// Used only by SplitMixedFolder's clustering — the scorer itself
|
||||
// still ranks against Group.AlbumName/AlbumArtist.
|
||||
AlbumTag string
|
||||
AlbumArtistTag string
|
||||
}
|
||||
|
||||
// Group is the folder-level context candidates are ranked against:
|
||||
@@ -21,6 +30,14 @@ type Group struct {
|
||||
AlbumName string
|
||||
AlbumArtist string
|
||||
Tracks []LocalTrack
|
||||
|
||||
// Synthetic marks a group carved out of a mixed-bag folder by
|
||||
// SplitMixedFolder rather than corresponding to a real directory.
|
||||
// Its tracks are a tag-matched subset of a bigger folder, so a
|
||||
// candidate with MORE tracks than the group is expected, not a
|
||||
// sign of a bad match — see the synthetic-aware evidence/track-
|
||||
// count handling in rank.go and recommend.go.
|
||||
Synthetic bool
|
||||
}
|
||||
|
||||
// CandidateSource distinguishes candidates served from the local
|
||||
@@ -125,4 +142,5 @@ type GroupScore struct {
|
||||
LocalTracks []LocalTrack
|
||||
Candidates []Candidate // sorted by Score, descending
|
||||
Recommendation Recommendation
|
||||
Synthetic bool // true for a SplitMixedFolder-derived group
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package autotagservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"yellowjacket/backend/jobs"
|
||||
)
|
||||
|
||||
// applyJobPrefix namespaces autotag apply jobs in the shared registry.
|
||||
const applyJobPrefix = "autotag:"
|
||||
|
||||
// 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.
|
||||
//
|
||||
//wails:ignore // internal wiring, not part of the app's IPC surface.
|
||||
func (s *Service) SetJobRegistry(reg *jobs.Registry) {
|
||||
s.mu.Lock()
|
||||
s.jobsReg = reg
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// applyJobID is the registry ID for one folder's apply.
|
||||
func applyJobID(groupKey string) string {
|
||||
return applyJobPrefix + groupKey
|
||||
}
|
||||
|
||||
// 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).
|
||||
func (s *Service) WritesInFlight() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
return len(s.runningApplies) > 0
|
||||
}
|
||||
|
||||
// startApplyJob registers the job and returns the handle plus a context
|
||||
// the user's Cancel button can stop. A nil registry (tests, and the
|
||||
// window before wiring) degrades to the plain context.
|
||||
func (s *Service) startApplyJob(
|
||||
groupKey string,
|
||||
total int,
|
||||
) (*jobs.Handle, context.Context, context.CancelFunc) {
|
||||
s.mu.Lock()
|
||||
parent := s.ctx
|
||||
reg := s.jobsReg
|
||||
s.mu.Unlock()
|
||||
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
|
||||
if reg == nil {
|
||||
return nil, ctx, cancel
|
||||
}
|
||||
|
||||
handle := reg.Start(jobs.Spec{
|
||||
ID: applyJobID(groupKey),
|
||||
Kind: jobs.KindAutotagApply,
|
||||
Title: "Writing tags",
|
||||
Subtitle: folderLabel(groupKey),
|
||||
Total: int64(total),
|
||||
Caps: jobs.Caps{Cancellable: true},
|
||||
Controls: jobs.Controls{Cancel: cancel},
|
||||
})
|
||||
|
||||
return handle, ctx, cancel
|
||||
}
|
||||
|
||||
// folderLabel is the part of a group key worth showing: the folder,
|
||||
// not the whole path, which is usually wider than the row.
|
||||
func folderLabel(groupKey string) string {
|
||||
trimmed := strings.TrimRight(groupKey, "/")
|
||||
|
||||
if idx := strings.LastIndex(trimmed, "/"); idx >= 0 {
|
||||
return trimmed[idx+1:]
|
||||
}
|
||||
|
||||
return trimmed
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package autotagservice
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/autotag"
|
||||
"yellowjacket/backend/jobs"
|
||||
)
|
||||
|
||||
var errApplyFailed = errors.New("write failed")
|
||||
|
||||
// newJobService builds the smallest Service that can register a job:
|
||||
// no database, no scorer, no MB client.
|
||||
func newJobService(t *testing.T) (*Service, *jobs.Registry) {
|
||||
t.Helper()
|
||||
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
reg := jobs.NewRegistry(logger, nil)
|
||||
svc := &Service{
|
||||
logger: logger,
|
||||
ctx: context.Background(),
|
||||
runningApplies: make(map[string]struct{}),
|
||||
}
|
||||
|
||||
svc.SetJobRegistry(reg)
|
||||
|
||||
return svc, reg
|
||||
}
|
||||
|
||||
func TestApplyJob_RegistersACancellableJob(t *testing.T) {
|
||||
svc, reg := newJobService(t)
|
||||
|
||||
handle, ctx, cancel := svc.startApplyJob("/music/Artist/Album", 9)
|
||||
defer cancel()
|
||||
|
||||
if handle == nil {
|
||||
t.Fatal("no job handle: an apply that is not registered has no cancel and no progress")
|
||||
}
|
||||
|
||||
snapshot := handle.Snapshot()
|
||||
|
||||
if snapshot.Kind != jobs.KindAutotagApply {
|
||||
t.Errorf("kind = %q, want %q", snapshot.Kind, jobs.KindAutotagApply)
|
||||
}
|
||||
|
||||
if snapshot.Total != 9 {
|
||||
t.Errorf("total = %d, want 9", snapshot.Total)
|
||||
}
|
||||
|
||||
if !snapshot.Caps.Cancellable {
|
||||
t.Error("apply job is not cancellable, which is the point of registering it")
|
||||
}
|
||||
|
||||
if snapshot.Subtitle != "Album" {
|
||||
t.Errorf("subtitle = %q, want the folder name", snapshot.Subtitle)
|
||||
}
|
||||
|
||||
// The registry's Cancel control has to reach the context the apply
|
||||
// is running under, or the button is decoration.
|
||||
reg.Cancel(applyJobID("/music/Artist/Album"))
|
||||
|
||||
<-ctx.Done()
|
||||
}
|
||||
|
||||
func TestApplyJob_FinishStateMatchesTheRun(t *testing.T) {
|
||||
cancelled, cancelStop := context.WithCancel(context.Background())
|
||||
cancelStop()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
result *autotag.ApplyResult
|
||||
err error
|
||||
want jobs.State
|
||||
}{
|
||||
{
|
||||
name: "every track written",
|
||||
ctx: context.Background(),
|
||||
result: &autotag.ApplyResult{Succeeded: 4},
|
||||
want: jobs.StateComplete,
|
||||
},
|
||||
{
|
||||
name: "some tracks failed",
|
||||
ctx: context.Background(),
|
||||
result: &autotag.ApplyResult{Succeeded: 3, Failed: 1},
|
||||
want: jobs.StateComplete,
|
||||
},
|
||||
{
|
||||
name: "the apply itself failed",
|
||||
ctx: context.Background(),
|
||||
err: errApplyFailed,
|
||||
want: jobs.StateError,
|
||||
},
|
||||
{
|
||||
// Cancelled beats failed: Apply returns a context error on
|
||||
// the way out, and reading that as a failure would make
|
||||
// every cancel look like a bug.
|
||||
name: "the user cancelled",
|
||||
ctx: cancelled,
|
||||
result: &autotag.ApplyResult{Succeeded: 1},
|
||||
err: context.Canceled,
|
||||
want: jobs.StateCancelled,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc, _ := newJobService(t)
|
||||
handle, _, cancel := svc.startApplyJob("/music/"+tt.name, 4)
|
||||
|
||||
defer cancel()
|
||||
|
||||
svc.finishApplyJob(tt.ctx, handle, tt.result, tt.err)
|
||||
|
||||
if got := handle.State(); got != tt.want {
|
||||
t.Errorf("state = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritesInFlight_TracksTheApplySet(t *testing.T) {
|
||||
svc, _ := newJobService(t)
|
||||
|
||||
if svc.WritesInFlight() {
|
||||
t.Fatal("nothing is running, so nothing should be reported in flight")
|
||||
}
|
||||
|
||||
svc.tryStartApply("/music/Album")
|
||||
|
||||
if !svc.WritesInFlight() {
|
||||
t.Error("an apply is running: quitting now would half-retag a folder")
|
||||
}
|
||||
|
||||
svc.endApply("/music/Album")
|
||||
|
||||
if svc.WritesInFlight() {
|
||||
t.Error("the apply finished and the app should stop asking about it")
|
||||
}
|
||||
}
|
||||
@@ -19,13 +19,14 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
|
||||
"yellowjacket/backend/autotag"
|
||||
"yellowjacket/backend/database"
|
||||
"yellowjacket/backend/database/sql/sqlcgen"
|
||||
"yellowjacket/backend/events"
|
||||
"yellowjacket/backend/explore"
|
||||
"yellowjacket/backend/jobs"
|
||||
"yellowjacket/backend/metadata"
|
||||
"yellowjacket/backend/tagwriter"
|
||||
)
|
||||
@@ -85,13 +86,10 @@ type Service struct {
|
||||
exp *explore.Service
|
||||
logger *slog.Logger
|
||||
ctx context.Context
|
||||
// ctxReady reports whether ctx is the Wails lifecycle context set
|
||||
// via SetContext (rather than the context.Background() default). It
|
||||
// gates event emission: calling wailsruntime.EventsEmit with a
|
||||
// non-runtime context triggers log.Fatalf (os.Exit) inside Wails, so
|
||||
// a background worker that fires before OnStartup wires the context
|
||||
// would otherwise take the whole app down on launch.
|
||||
ctxReady bool
|
||||
|
||||
// Registry for the apply job, wired after construction like every
|
||||
// other subsystem's. Guarded by mu.
|
||||
jobsReg *jobs.Registry
|
||||
|
||||
// Queue cursor — the group_key of the last item returned.
|
||||
// GetNextPending uses it to advance. Reset by StartAutotagQueue.
|
||||
@@ -177,7 +175,7 @@ func NewService(
|
||||
exp *explore.Service,
|
||||
tw *tagwriter.TagWriter,
|
||||
) *Service {
|
||||
mbAdapter := explore.NewAutotagClient(exp.MusicBrainz())
|
||||
mbAdapter := explore.NewAutotagClient(exp)
|
||||
scorer := autotag.NewScorer(db.Queries, mbAdapter, logger.WithGroup("autotag"))
|
||||
mbr := autotag.NewMBResolver(mbAdapter, logger.WithGroup("autotag-mb"))
|
||||
|
||||
@@ -203,39 +201,32 @@ func NewService(
|
||||
}
|
||||
}
|
||||
|
||||
// SetContext stores the Wails runtime context (called from
|
||||
// OnStartup).
|
||||
func (s *Service) SetContext(ctx context.Context) {
|
||||
// ServiceStartup is v3's service lifecycle hook: it runs once the
|
||||
// runtime exists, and ctx is cancelled when the app shuts down. It
|
||||
// replaces v2's SetContext, which had to be called by hand from
|
||||
// OnStartup and was exported, so it was also bound to the frontend.
|
||||
func (s *Service) ServiceStartup(
|
||||
ctx context.Context,
|
||||
_ application.ServiceOptions,
|
||||
) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.ctx = ctx
|
||||
s.ctxReady = ctx != nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// emitEvent emits a Wails runtime event, but only when the stored
|
||||
// context actually carries the Wails runtime. Wails' EventsEmit calls
|
||||
// log.Fatalf — which os.Exit()s the process and cannot be recovered —
|
||||
// whenever the context lacks its internal "events" value (e.g. the
|
||||
// context.Background() default, or any non-lifecycle context). A
|
||||
// background worker (the prefetch/apply sweeps) that emits before, or
|
||||
// independently of, OnStartup wiring the real context would otherwise
|
||||
// take the whole app down on launch. We replicate Wails' own
|
||||
// precondition here so a not-yet-ready context degrades to a no-op
|
||||
// instead of a crash.
|
||||
// emitEvent emits a Wails runtime event under the service lock, which
|
||||
// the background prefetch/apply sweeps need because they can emit
|
||||
// before OnStartup has wired the real context. events.Emit tolerates
|
||||
// that; see its doc comment.
|
||||
func (s *Service) emitEvent(eventName string, data any) {
|
||||
s.mu.Lock()
|
||||
ready := s.ctxReady
|
||||
ctx := s.ctx
|
||||
s.mu.Unlock()
|
||||
|
||||
// hasWailsRuntime mirrors the check in wails/pkg/runtime.getEvents:
|
||||
// the runtime is present only when ctx.Value("events") is non-nil.
|
||||
if !ready || ctx == nil || ctx.Value("events") == nil {
|
||||
return
|
||||
}
|
||||
|
||||
wailsruntime.EventsEmit(ctx, eventName, data)
|
||||
events.Emit(ctx, eventName, data)
|
||||
}
|
||||
|
||||
// StartBackgroundPrefetch kicks off (or restarts) the prefetch
|
||||
@@ -307,6 +298,12 @@ func (s *Service) startPrefetch(libraryID int64) {
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
// Self-heal before enumerating: don't burn a scoring pass on rows
|
||||
// whose bookkeeping never ran or drifted (see ListPendingFolders).
|
||||
if err := s.db.Queries.PruneOrphanedTaggingItems(ctx); err != nil {
|
||||
s.logger.Warn("prefetch: prune orphaned items failed", "err", err)
|
||||
}
|
||||
|
||||
// Find all pending items missing a score. Ordered alphabetically
|
||||
// for stable progress reporting; libraryID=0 fans out to all.
|
||||
const maxPrefetch = 5000
|
||||
@@ -376,25 +373,30 @@ func (s *Service) startPrefetch(libraryID int64) {
|
||||
continue
|
||||
}
|
||||
|
||||
// A folder that looks like a pile of unrelated tracks gets
|
||||
// torn apart before scoring — otherwise the scorer treats
|
||||
// the whole pile as one album candidate and every track that
|
||||
// doesn't fit the best partial match gets counted as an
|
||||
// "extra" of it, rather than being matched on its own. The
|
||||
// original group key is gone once every track has moved to a
|
||||
// synthetic child, so score those instead of key.
|
||||
if newKeys := s.autoSplitMixedBag(ctx, key); len(newKeys) > 0 {
|
||||
for _, nk := range newKeys {
|
||||
s.scoreAndPersist(ctx, nk, "prefetch: score synthetic group")
|
||||
}
|
||||
|
||||
s.emitEvent(events.AutotagPrefetchProgress, map[string]any{
|
||||
"processed": i + 1,
|
||||
"total": total,
|
||||
})
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// Local-first: the background sweep skips the MusicBrainz
|
||||
// cascade when a local candidate already scores well, so a
|
||||
// library with cross-library duplicates costs no network here.
|
||||
score, err := s.scorer.ScoreGroupLocalFirst(ctx, key)
|
||||
if err != nil {
|
||||
s.logger.Debug(
|
||||
"prefetch: score failed — skipping",
|
||||
"group_key", key, "err", err,
|
||||
)
|
||||
} else {
|
||||
s.cacheCandidates(key, score.Candidates)
|
||||
|
||||
if perr := s.scorer.PersistScore(ctx, score); perr != nil {
|
||||
s.logger.Debug(
|
||||
"prefetch: persist failed",
|
||||
"group_key", key, "err", perr,
|
||||
)
|
||||
}
|
||||
}
|
||||
s.scoreAndPersist(ctx, key, "prefetch: score failed — skipping")
|
||||
|
||||
s.emitEvent(events.AutotagPrefetchProgress, map[string]any{
|
||||
"processed": i + 1,
|
||||
@@ -409,6 +411,74 @@ func (s *Service) startPrefetch(libraryID int64) {
|
||||
s.logger.Info("autotag prefetch: done", "groups", total)
|
||||
}
|
||||
|
||||
// scoreAndPersist runs the cheap local-first score for one group and
|
||||
// caches + persists the result, logging (never failing the caller)
|
||||
// on error. failMsg labels the debug log line when scoring itself
|
||||
// errors.
|
||||
func (s *Service) scoreAndPersist(ctx context.Context, groupKey, failMsg string) {
|
||||
score, err := s.scorer.ScoreGroupLocalFirst(ctx, groupKey)
|
||||
if err != nil {
|
||||
s.logger.Debug(failMsg, "group_key", groupKey, "err", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
s.cacheCandidates(groupKey, score.Candidates)
|
||||
|
||||
if perr := s.scorer.PersistScore(ctx, score); perr != nil {
|
||||
s.logger.Debug(
|
||||
"prefetch: persist failed",
|
||||
"group_key", groupKey, "err", perr,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// autoSplitMixedBag detects a folder that looks like a pile of
|
||||
// unrelated tracks (autotag.IsMixedBag) and, if so, tears it apart
|
||||
// via the same clustering SplitMixedFolder uses (autotag.SplitPlan)
|
||||
// before the background sweep scores it — otherwise the scorer
|
||||
// treats the whole pile as one album candidate and every track that
|
||||
// doesn't fit the best partial match gets counted as an "extra" of
|
||||
// it. Returns the new synthetic group keys, or nil when the folder
|
||||
// isn't a mixed bag (or had nothing to split).
|
||||
func (s *Service) autoSplitMixedBag(ctx context.Context, groupKey string) []string {
|
||||
item, err := s.db.Queries.GetTaggingItem(ctx, groupKey)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
locals, err := s.scorer.LocalTracksForGroup(ctx, groupKey)
|
||||
if err != nil {
|
||||
s.logger.Debug("prefetch: auto-split load locals failed", "group_key", groupKey, "err", err)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
g := autotag.Group{AlbumName: item.AlbumName, AlbumArtist: item.AlbumArtist, Tracks: locals}
|
||||
if !autotag.IsMixedBag(g) {
|
||||
return nil
|
||||
}
|
||||
|
||||
clusters := autotag.SplitPlan(locals)
|
||||
if len(clusters) <= 1 {
|
||||
return nil
|
||||
}
|
||||
|
||||
newKeys, err := s.splitIntoSyntheticGroups(groupKey, item.LibraryID, clusters)
|
||||
if err != nil {
|
||||
s.logger.Warn("prefetch: auto-split failed", "group_key", groupKey, "err", err)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
s.logger.Info(
|
||||
"autotag prefetch: auto-split mixed-bag folder",
|
||||
"group_key", groupKey, "into", len(newKeys),
|
||||
)
|
||||
|
||||
return newKeys
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -430,6 +500,19 @@ type PendingItem struct {
|
||||
BestMatchReleaseMbid string `json:"bestMatchReleaseMbid"`
|
||||
Score float64 `json:"score"`
|
||||
Status string `json:"status"`
|
||||
// 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 bool `json:"synthetic"`
|
||||
// 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 bool `json:"likelyMixedBag"`
|
||||
}
|
||||
|
||||
// GetNextPending returns the next pending tagging item after the
|
||||
@@ -489,6 +572,15 @@ func (s *Service) GetNextPending() (*PendingItem, error) {
|
||||
func (s *Service) ListPendingFolders(libraryID int64) ([]PendingItem, error) {
|
||||
const maxFolders = 5000
|
||||
|
||||
// Self-heal before listing: a row whose bookkeeping (scan orphan
|
||||
// cleanup, maybeRebindTaggingGroup, SplitMixedFolder) never ran
|
||||
// or drifted otherwise lingers here indefinitely, showing as an
|
||||
// "old/nonexistent" entry with no folder path. Best-effort — a
|
||||
// failed prune shouldn't block the list itself.
|
||||
if err := s.db.Queries.PruneOrphanedTaggingItems(s.ctx); err != nil {
|
||||
s.logger.Warn("list pending folders: prune orphaned items failed", "err", err)
|
||||
}
|
||||
|
||||
rows, err := s.db.Queries.ListPendingTaggingItemsByScore(
|
||||
s.ctx,
|
||||
sqlcgen.ListPendingTaggingItemsByScoreParams{
|
||||
@@ -502,19 +594,32 @@ func (s *Service) ListPendingFolders(libraryID int64) ([]PendingItem, error) {
|
||||
return nil, fmt.Errorf("list pending folders: %w", err)
|
||||
}
|
||||
|
||||
mixedBagKeys, err := s.db.Queries.ListLikelyMixedBagGroupKeys(s.ctx)
|
||||
if err != nil {
|
||||
// A cheap badge hint isn't worth failing the whole list for.
|
||||
s.logger.Warn("list pending folders: mixed-bag triage failed", "err", err)
|
||||
}
|
||||
|
||||
mixedBag := make(map[string]bool, len(mixedBagKeys))
|
||||
for _, k := range mixedBagKeys {
|
||||
mixedBag[k] = true
|
||||
}
|
||||
|
||||
out := make([]PendingItem, 0, len(rows))
|
||||
|
||||
for _, row := range rows {
|
||||
item := PendingItem{
|
||||
GroupKey: row.GroupKey,
|
||||
LibraryID: row.LibraryID,
|
||||
LibraryName: row.LibraryName,
|
||||
FolderSubPath: folderSubPath(row.LibraryPath, row.SampleFilePath),
|
||||
TrackCount: row.TrackCount,
|
||||
AlbumName: row.AlbumName,
|
||||
AlbumArtist: row.AlbumArtist,
|
||||
DiscNumber: row.DiscNumber,
|
||||
Status: row.Status,
|
||||
GroupKey: row.GroupKey,
|
||||
LibraryID: row.LibraryID,
|
||||
LibraryName: row.LibraryName,
|
||||
FolderSubPath: folderSubPath(row.LibraryPath, row.SampleFilePath),
|
||||
TrackCount: row.TrackCount,
|
||||
AlbumName: row.AlbumName,
|
||||
AlbumArtist: row.AlbumArtist,
|
||||
DiscNumber: row.DiscNumber,
|
||||
Status: row.Status,
|
||||
Synthetic: row.Synthetic != 0,
|
||||
LikelyMixedBag: mixedBag[row.GroupKey],
|
||||
}
|
||||
|
||||
if row.BestMatchReleaseMbid.Valid {
|
||||
@@ -555,6 +660,7 @@ func (s *Service) GetPendingFolder(groupKey string) (*PendingItem, error) {
|
||||
AlbumArtist: row.AlbumArtist,
|
||||
DiscNumber: row.DiscNumber,
|
||||
Status: row.Status,
|
||||
Synthetic: row.Synthetic != 0,
|
||||
}
|
||||
|
||||
if row.BestMatchReleaseMbid.Valid {
|
||||
@@ -742,6 +848,15 @@ type ScoreView struct {
|
||||
// raw score it accounts for ambiguity (a rival release group
|
||||
// scoring nearly as high) and alignment defects.
|
||||
Recommendation string `json:"recommendation"`
|
||||
// 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 bool `json:"mixedBag"`
|
||||
// Synthetic mirrors PendingItem.Synthetic for the currently
|
||||
// open group.
|
||||
Synthetic bool `json:"synthetic"`
|
||||
}
|
||||
|
||||
// LocalTrackView mirrors autotag.LocalTrack.
|
||||
@@ -1002,7 +1117,9 @@ func (s *Service) ApplyAsync(groupKey, releaseMBID string) error {
|
||||
"total": total,
|
||||
})
|
||||
|
||||
go s.runApply(groupKey, plan, total)
|
||||
handle, ctx, cancel := s.startApplyJob(groupKey, total)
|
||||
|
||||
go s.runApply(ctx, cancel, handle, groupKey, plan, total)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1046,10 +1163,26 @@ func (s *Service) prepareApplyPlan(
|
||||
// runApply executes the plan in the background and emits progress
|
||||
// + completion events. Always releases the in-flight slot when
|
||||
// it returns, even on panic.
|
||||
func (s *Service) runApply(groupKey string, plan *autotag.ApplyPlan, total int) {
|
||||
//
|
||||
// The job handle is the same progress and cancel surface every other
|
||||
// long-running operation has; the events stay because the autotag page
|
||||
// drives its per-folder row from them.
|
||||
func (s *Service) runApply(
|
||||
ctx context.Context,
|
||||
cancel context.CancelFunc,
|
||||
handle *jobs.Handle,
|
||||
groupKey string,
|
||||
plan *autotag.ApplyPlan,
|
||||
total int,
|
||||
) {
|
||||
defer s.endApply(groupKey)
|
||||
defer cancel()
|
||||
|
||||
onProgress := func(current, total, succeeded, failed int) {
|
||||
if handle != nil {
|
||||
handle.SetProgress(int64(current), int64(total))
|
||||
}
|
||||
|
||||
s.emitEvent(events.AutotagApplyProgress, map[string]any{
|
||||
"groupKey": groupKey,
|
||||
"current": current,
|
||||
@@ -1059,7 +1192,7 @@ func (s *Service) runApply(groupKey string, plan *autotag.ApplyPlan, total int)
|
||||
})
|
||||
}
|
||||
|
||||
result, err := s.applier.Apply(s.ctx, plan, onProgress)
|
||||
result, err := s.applier.Apply(ctx, plan, onProgress)
|
||||
|
||||
finished := map[string]any{
|
||||
"groupKey": groupKey,
|
||||
@@ -1077,9 +1210,40 @@ func (s *Service) runApply(groupKey string, plan *autotag.ApplyPlan, total int)
|
||||
finished["error"] = err.Error()
|
||||
}
|
||||
|
||||
s.finishApplyJob(ctx, handle, result, err)
|
||||
|
||||
s.emitEvent(events.AutotagApplyFinished, finished)
|
||||
}
|
||||
|
||||
// finishApplyJob closes the job out in the state the run ended in, so
|
||||
// a cancelled apply reads as cancelled rather than as a failure and a
|
||||
// partial write says how far it got.
|
||||
func (s *Service) finishApplyJob(
|
||||
ctx context.Context,
|
||||
handle *jobs.Handle,
|
||||
result *autotag.ApplyResult,
|
||||
err error,
|
||||
) {
|
||||
if handle == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case ctx.Err() != nil:
|
||||
handle.Cancelled()
|
||||
case err != nil:
|
||||
handle.Fail(err)
|
||||
case result != nil && result.Failed > 0:
|
||||
handle.Logf(jobs.LevelWarn, fmt.Sprintf(
|
||||
"%d of %d tracks could not be written",
|
||||
result.Failed, result.Succeeded+result.Failed,
|
||||
))
|
||||
handle.Complete()
|
||||
default:
|
||||
handle.Complete()
|
||||
}
|
||||
}
|
||||
|
||||
// tryStartApply records that an Apply for the given group is
|
||||
// running. Returns false when a previous job for the same key
|
||||
// hasn't finished yet — caller should treat that as
|
||||
@@ -1151,6 +1315,156 @@ func (s *Service) RetagGroup(groupKey string) error {
|
||||
)
|
||||
}
|
||||
|
||||
// errNothingToSplit is returned by SplitMixedFolder when the
|
||||
// folder's tracks carry no repeated (album, album-artist) tag pair
|
||||
// to cluster on — nothing to split out.
|
||||
var errNothingToSplit = errors.New("autotag: no tag-matched sub-albums to split out")
|
||||
|
||||
// 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.
|
||||
func (s *Service) SplitMixedFolder(groupKey string) ([]PendingItem, error) {
|
||||
item, err := s.db.Queries.GetTaggingItem(s.ctx, groupKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get tagging item: %w", err)
|
||||
}
|
||||
|
||||
locals, err := s.scorer.LocalTracksForGroup(s.ctx, groupKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load locals: %w", err)
|
||||
}
|
||||
|
||||
clusters := autotag.SplitPlan(locals)
|
||||
if len(clusters) <= 1 {
|
||||
return nil, errNothingToSplit
|
||||
}
|
||||
|
||||
newKeys, err := s.splitIntoSyntheticGroups(groupKey, item.LibraryID, clusters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := make([]PendingItem, 0, len(newKeys)+1)
|
||||
|
||||
if leftover, err := s.GetPendingFolder(groupKey); err != nil {
|
||||
s.logger.Warn("split: reload leftover parent", "group_key", groupKey, "err", err)
|
||||
} else if leftover != nil {
|
||||
out = append(out, *leftover)
|
||||
}
|
||||
|
||||
for _, k := range newKeys {
|
||||
child, err := s.GetPendingFolder(k)
|
||||
if err != nil || child == nil {
|
||||
s.logger.Warn("split: reload synthetic group", "group_key", k, "err", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
out = append(out, *child)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// splitIntoSyntheticGroups performs the actual DB migration inside a
|
||||
// single transaction: each cluster's tracks are reassigned onto a
|
||||
// deterministic synthetic group key, the parent's track count is
|
||||
// decremented per track moved, and the parent row is dropped if it
|
||||
// ends up empty. Returns the new group keys in cluster order.
|
||||
func (s *Service) splitIntoSyntheticGroups(
|
||||
parentKey string, libraryID int64, clusters []autotag.TrackCluster,
|
||||
) ([]string, error) {
|
||||
tx, err := s.db.BeginTx()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin split tx: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
q := s.db.Queries.WithTx(tx)
|
||||
newKeys := make([]string, 0, len(clusters))
|
||||
|
||||
for _, c := range clusters {
|
||||
var newKey string
|
||||
if len(c.Tracks) == 1 {
|
||||
// A lone leftover track from SplitPlan's singleton
|
||||
// fallback may carry an empty (or shared-but-coincidental)
|
||||
// album/album-artist tag — key on the track itself so two
|
||||
// untagged leftovers can't collide.
|
||||
newKey = autotag.SyntheticTrackGroupKey(parentKey, c.Tracks[0].AudioFileID)
|
||||
} else {
|
||||
newKey = autotag.SyntheticGroupKey(parentKey, c.AlbumName, c.AlbumArtist)
|
||||
}
|
||||
|
||||
newKeys = append(newKeys, newKey)
|
||||
|
||||
for _, t := range c.Tracks {
|
||||
if err := q.DecrementTaggingItemTrackCount(s.ctx, parentKey); err != nil {
|
||||
return nil, fmt.Errorf("decrement parent group: %w", err)
|
||||
}
|
||||
|
||||
upsertParams := sqlcgen.UpsertTaggingItemOnTrackAddParams{
|
||||
GroupKey: newKey,
|
||||
LibraryID: libraryID,
|
||||
AlbumName: c.AlbumName,
|
||||
AlbumArtist: c.AlbumArtist,
|
||||
DiscNumber: 0,
|
||||
}
|
||||
if err := q.UpsertTaggingItemOnTrackAdd(s.ctx, upsertParams); err != nil {
|
||||
return nil, fmt.Errorf("upsert synthetic group: %w", err)
|
||||
}
|
||||
|
||||
if err := q.SetAudioFileGroupKey(s.ctx, sqlcgen.SetAudioFileGroupKeyParams{
|
||||
GroupKey: newKey,
|
||||
ID: t.AudioFileID,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("reassign track %d: %w", t.AudioFileID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := q.MarkTaggingItemSynthetic(s.ctx, sqlcgen.MarkTaggingItemSyntheticParams{
|
||||
ParentGroupKey: parentKey,
|
||||
GroupKey: newKey,
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("mark synthetic: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := q.DeleteTaggingItemIfEmpty(s.ctx, parentKey); err != nil {
|
||||
return nil, fmt.Errorf("cleanup leftover parent: %w", err)
|
||||
}
|
||||
|
||||
// The parent's cached candidates (if it still exists) no longer
|
||||
// reflect its track set now that some tracks moved out.
|
||||
if err := q.DeleteTaggingCandidates(s.ctx, parentKey); err != nil {
|
||||
s.logger.Warn("split: drop stale parent candidates", "group_key", parentKey, "err", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit split: %w", err)
|
||||
}
|
||||
|
||||
return newKeys, nil
|
||||
}
|
||||
|
||||
// AckLibraryWarning records that the user has seen the first-
|
||||
// time-apply irreversibility warning for this library.
|
||||
func (s *Service) AckLibraryWarning(libraryID int64) error {
|
||||
@@ -1188,6 +1502,7 @@ func (s *Service) GetCandidatesForPasteURL(
|
||||
AlbumName: score.AlbumName,
|
||||
AlbumArtist: score.AlbumArtist,
|
||||
Tracks: score.LocalTracks,
|
||||
Synthetic: score.Synthetic,
|
||||
}, pasted)
|
||||
merged := append([]autotag.Candidate{scored}, score.Candidates...)
|
||||
score.Candidates = merged
|
||||
@@ -1357,16 +1672,26 @@ func extractReleaseMBID(url string) string {
|
||||
// top-ranked candidate; pass nil to skip cover art entirely (used
|
||||
// only by paths that don't need art).
|
||||
func scoreToView(s *autotag.GroupScore, exp *explore.Service) *ScoreView {
|
||||
group := autotag.Group{
|
||||
AlbumName: s.AlbumName,
|
||||
AlbumArtist: s.AlbumArtist,
|
||||
Tracks: s.LocalTracks,
|
||||
Synthetic: s.Synthetic,
|
||||
}
|
||||
|
||||
rec := s.Recommendation
|
||||
if rec == "" {
|
||||
// Paths that rebuild a GroupScore from cached candidates
|
||||
// don't run the scorer; derive the tier here.
|
||||
rec = autotag.Recommend(
|
||||
autotag.Group{Tracks: s.LocalTracks}, s.Candidates,
|
||||
)
|
||||
rec = autotag.Recommend(group, s.Candidates)
|
||||
}
|
||||
|
||||
out := &ScoreView{GroupKey: s.GroupKey, Recommendation: string(rec)}
|
||||
out := &ScoreView{
|
||||
GroupKey: s.GroupKey,
|
||||
Recommendation: string(rec),
|
||||
Synthetic: s.Synthetic,
|
||||
MixedBag: !s.Synthetic && autotag.IsMixedBag(group),
|
||||
}
|
||||
|
||||
for _, l := range s.LocalTracks {
|
||||
out.LocalTracks = append(out.LocalTracks, LocalTrackView{
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
package autotagservice
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/autotag"
|
||||
"yellowjacket/backend/database"
|
||||
)
|
||||
|
||||
// newTestService builds a Service with just enough wired up for
|
||||
// SplitMixedFolder — no MB client, no tag writer. Constructed
|
||||
// directly (bypassing NewService) since this package's tests live
|
||||
// inside the package and don't need the explore/tagwriter
|
||||
// dependencies that method never touches.
|
||||
func newTestService(t *testing.T, db *database.DB) *Service {
|
||||
t.Helper()
|
||||
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
return &Service{
|
||||
db: db,
|
||||
scorer: autotag.NewScorer(db.Queries, nil, logger),
|
||||
logger: logger,
|
||||
ctx: db.Ctx,
|
||||
}
|
||||
}
|
||||
|
||||
// seedMixedBagFolder drops one physical folder (single group_key)
|
||||
// containing two 2-track clusters (different album/album-artist tags
|
||||
// each) plus one leftover track with no album tag at all — the shape
|
||||
// SplitMixedFolder is meant to untangle.
|
||||
func seedMixedBagFolder(t *testing.T, db *database.DB, groupKey string, libraryID int64) {
|
||||
t.Helper()
|
||||
|
||||
addTrack := func(filePath, title, artist, album, albumArtist string, trackNum int) {
|
||||
database.InsertTestTrack(t, db, database.TestTrack{
|
||||
FilePath: filePath,
|
||||
Title: title,
|
||||
Artist: artist,
|
||||
Album: album,
|
||||
AlbumArtist: albumArtist,
|
||||
TrackNumber: int64(trackNum),
|
||||
LengthMs: 200000,
|
||||
LibraryID: libraryID,
|
||||
GroupKey: groupKey,
|
||||
})
|
||||
}
|
||||
|
||||
addTrack("/junk/01.mp3", "Song A1", "Artist One", "Album One", "Artist One", 1)
|
||||
addTrack("/junk/02.mp3", "Song A2", "Artist One", "Album One", "Artist One", 2)
|
||||
addTrack("/junk/03.mp3", "Song B1", "Artist Two", "Album Two", "Artist Two", 1)
|
||||
addTrack("/junk/04.mp3", "Song B2", "Artist Two", "Album Two", "Artist Two", 2)
|
||||
addTrack("/junk/05.mp3", "Lone Song", "Artist Three", "", "", 1)
|
||||
|
||||
if _, err := db.ExecContext(`
|
||||
INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status)
|
||||
VALUES (?, ?, 5, '', '', 0, 'pending')
|
||||
`, groupKey, libraryID); err != nil {
|
||||
t.Fatalf("insert tagging item: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedCoherentAlbum drops a single-artist, single-album folder — the
|
||||
// negative case for the mixed-bag triage query.
|
||||
func seedCoherentAlbum(t *testing.T, db *database.DB, groupKey string, libraryID int64) {
|
||||
t.Helper()
|
||||
|
||||
for i, title := range []string{"Come Together", "Something", "Maxwell's Silver Hammer"} {
|
||||
database.InsertTestTrack(t, db, database.TestTrack{
|
||||
FilePath: fmt.Sprintf("/beatles/%02d.mp3", i+1),
|
||||
Title: title,
|
||||
Artist: "The Beatles",
|
||||
Album: "Abbey Road",
|
||||
TrackNumber: int64(i + 1),
|
||||
LengthMs: 200000,
|
||||
LibraryID: libraryID,
|
||||
GroupKey: groupKey,
|
||||
})
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(`
|
||||
INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status)
|
||||
VALUES (?, ?, 3, 'Abbey Road', 'The Beatles', 0, 'pending')
|
||||
`, groupKey, libraryID); err != nil {
|
||||
t.Fatalf("insert tagging item: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPendingFolders_FlagsLikelyMixedBag(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
seedMixedBagFolder(t, db, "g-junk", 0)
|
||||
seedCoherentAlbum(t, db, "g-abbey-road", 0)
|
||||
|
||||
s := newTestService(t, db)
|
||||
|
||||
items, err := s.ListPendingFolders(0)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPendingFolders: %v", err)
|
||||
}
|
||||
|
||||
got := make(map[string]bool, len(items))
|
||||
for _, it := range items {
|
||||
got[it.GroupKey] = it.LikelyMixedBag
|
||||
}
|
||||
|
||||
if !got["g-junk"] {
|
||||
t.Error("expected g-junk (no artist/album consensus) to be flagged LikelyMixedBag")
|
||||
}
|
||||
|
||||
if got["g-abbey-road"] {
|
||||
t.Error(
|
||||
"expected g-abbey-road (coherent single-artist album) to NOT be flagged LikelyMixedBag",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitMixedFolder_CarvesOutClustersAndSingletons(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
seedMixedBagFolder(t, db, "g-junk", 0)
|
||||
|
||||
s := newTestService(t, db)
|
||||
|
||||
items, err := s.SplitMixedFolder("g-junk")
|
||||
if err != nil {
|
||||
t.Fatalf("SplitMixedFolder: %v", err)
|
||||
}
|
||||
|
||||
// Every track leaves the parent: 2 clustered groups (2 tracks
|
||||
// each) + 1 singleton for the unclustered "Lone Song" track. The
|
||||
// parent is now empty and must not survive as a 4th item.
|
||||
if len(items) != 3 { //nolint:mnd
|
||||
t.Fatalf("expected 3 resulting groups, got %d: %+v", len(items), items)
|
||||
}
|
||||
|
||||
for _, it := range items {
|
||||
if it.GroupKey == "g-junk" {
|
||||
t.Fatal("expected the original group to be fully drained and removed")
|
||||
}
|
||||
|
||||
if !it.Synthetic {
|
||||
t.Errorf("child group %q: Synthetic = false, want true", it.GroupKey)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
clustered []PendingItem
|
||||
singleton *PendingItem
|
||||
)
|
||||
|
||||
for i, it := range items {
|
||||
if it.TrackCount == 1 {
|
||||
singleton = &items[i]
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
clustered = append(clustered, it)
|
||||
}
|
||||
|
||||
if singleton == nil {
|
||||
t.Fatal("expected a singleton child for the unclustered Lone Song track")
|
||||
}
|
||||
|
||||
if singleton.AlbumName != "" {
|
||||
t.Errorf(
|
||||
"singleton child album_name = %q, want empty (Lone Song had no album tag)",
|
||||
singleton.AlbumName,
|
||||
)
|
||||
}
|
||||
|
||||
if len(clustered) != 2 { //nolint:mnd
|
||||
t.Fatalf("expected 2 clustered children, got %d", len(clustered))
|
||||
}
|
||||
|
||||
seenAlbums := map[string]bool{}
|
||||
|
||||
for _, c := range clustered {
|
||||
if c.TrackCount != 2 { //nolint:mnd
|
||||
t.Errorf("child group %q: track_count = %d, want 2", c.GroupKey, c.TrackCount)
|
||||
}
|
||||
|
||||
seenAlbums[c.AlbumName] = true
|
||||
}
|
||||
|
||||
if !seenAlbums["Album One"] || !seenAlbums["Album Two"] {
|
||||
t.Errorf("expected children for Album One and Album Two, got %+v", clustered)
|
||||
}
|
||||
|
||||
// The physical file paths must be untouched — only group_key
|
||||
// reassignment happened, no files moved on disk.
|
||||
locals, err := s.scorer.LocalTracksForGroup(db.Ctx, clustered[0].GroupKey)
|
||||
if err != nil {
|
||||
t.Fatalf("load synthetic group tracks: %v", err)
|
||||
}
|
||||
|
||||
for _, l := range locals {
|
||||
if l.FilePath == "" {
|
||||
t.Error("expected non-empty file path preserved on the synthetic group's tracks")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitMixedFolder_NothingToClusterErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
|
||||
database.InsertTestTrack(t, db, database.TestTrack{
|
||||
FilePath: "/coherent/01.mp3",
|
||||
Title: "Track",
|
||||
GroupKey: "g-coherent",
|
||||
})
|
||||
|
||||
if _, err := db.ExecContext(`
|
||||
INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status)
|
||||
VALUES ('g-coherent', 0, 1, '', '', 0, 'pending')
|
||||
`); err != nil {
|
||||
t.Fatalf("insert tagging item: %v", err)
|
||||
}
|
||||
|
||||
s := newTestService(t, db)
|
||||
|
||||
if _, err := s.SplitMixedFolder("g-coherent"); !errors.Is(err, errNothingToSplit) {
|
||||
t.Fatalf("err = %v, want errNothingToSplit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPendingFolders_PrunesOrphanedEntries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := database.NewTestDB(t)
|
||||
|
||||
// A real, live folder — must survive.
|
||||
database.InsertTestTrack(t, db, database.TestTrack{
|
||||
FilePath: "/live/01.mp3",
|
||||
Title: "Track",
|
||||
GroupKey: "g-live",
|
||||
})
|
||||
|
||||
if _, err := db.ExecContext(`
|
||||
INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status)
|
||||
VALUES ('g-live', 0, 1, '', '', 0, 'pending')
|
||||
`); err != nil {
|
||||
t.Fatalf("insert live tagging item: %v", err)
|
||||
}
|
||||
|
||||
// An orphaned row: no audio_files row points at this group_key
|
||||
// any more (the file was deleted/moved and the bookkeeping that's
|
||||
// supposed to clean this up never ran) — this is exactly the
|
||||
// "old/nonexistent" entry the review UI shouldn't show.
|
||||
if _, err := db.ExecContext(`
|
||||
INSERT INTO tagging_items (group_key, library_id, track_count, album_name, album_artist, disc_number, status)
|
||||
VALUES ('g-orphan', 0, 3, 'Ghost Album', 'Ghost Artist', 0, 'pending')
|
||||
`); err != nil {
|
||||
t.Fatalf("insert orphaned tagging item: %v", err)
|
||||
}
|
||||
|
||||
s := newTestService(t, db)
|
||||
|
||||
items, err := s.ListPendingFolders(0)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPendingFolders: %v", err)
|
||||
}
|
||||
|
||||
got := make(map[string]bool, len(items))
|
||||
for _, it := range items {
|
||||
got[it.GroupKey] = true
|
||||
}
|
||||
|
||||
if !got["g-live"] {
|
||||
t.Error("expected g-live (has a real audio_files row) to remain listed")
|
||||
}
|
||||
|
||||
if got["g-orphan"] {
|
||||
t.Error("expected g-orphan (no matching audio_files rows) to be pruned, not listed")
|
||||
}
|
||||
|
||||
if _, err := db.Queries.GetTaggingItem(db.Ctx, "g-orphan"); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Errorf("expected g-orphan row to be deleted from tagging_items, got err=%v", err)
|
||||
}
|
||||
}
|
||||
+250
-46
@@ -10,8 +10,9 @@ import (
|
||||
"path"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
|
||||
"yellowjacket/backend/download"
|
||||
"yellowjacket/backend/events"
|
||||
"yellowjacket/backend/favorites"
|
||||
"yellowjacket/backend/library"
|
||||
@@ -31,14 +32,16 @@ var errSaveBeforeLoad = errors.New("refusing to save: config not loaded from dis
|
||||
type Config struct {
|
||||
ctx context.Context
|
||||
logger *slog.Logger
|
||||
filePath string // required
|
||||
loaded bool // true once Load() succeeds
|
||||
Library *library.Config `toml:"Library"`
|
||||
Theme *theme.Config `toml:"Theme"`
|
||||
Window *WindowConfig `toml:"Window"`
|
||||
TrackList *tracklist.Config `toml:"TrackList"`
|
||||
Favorites *favorites.Config `toml:"Favorites"`
|
||||
Shortcuts *shortcuts.Config `toml:"Shortcuts"`
|
||||
filePath string // required
|
||||
loaded bool // true once Load() succeeds
|
||||
Library *library.Config `toml:"Library"`
|
||||
Theme *theme.Config `toml:"Theme"`
|
||||
General *GeneralConfig `toml:"General"`
|
||||
Window *WindowConfig `toml:"Window"`
|
||||
TrackList *tracklist.Config `toml:"TrackList"`
|
||||
Favorites *favorites.Config `toml:"Favorites"`
|
||||
Shortcuts *shortcuts.Config `toml:"Shortcuts"`
|
||||
Downloads *download.UserConfig `toml:"Downloads"`
|
||||
}
|
||||
|
||||
// NewConfig creates a new config by loading it from disk.
|
||||
@@ -83,6 +86,12 @@ func (c *Config) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
if c.General != nil {
|
||||
if err := c.General.Validate(); err != nil {
|
||||
configErrs = errors.Join(configErrs, err)
|
||||
}
|
||||
}
|
||||
|
||||
if c.TrackList != nil {
|
||||
if err := c.TrackList.Validate(); err != nil {
|
||||
configErrs = errors.Join(configErrs, err)
|
||||
@@ -239,6 +248,12 @@ func (c *Config) applyDefaults() {
|
||||
|
||||
c.Theme.ApplyDefaults()
|
||||
|
||||
if c.General == nil {
|
||||
c.General = &GeneralConfig{}
|
||||
}
|
||||
|
||||
c.General.ApplyDefaults()
|
||||
|
||||
if c.TrackList == nil {
|
||||
c.TrackList = &tracklist.Config{}
|
||||
}
|
||||
@@ -258,11 +273,25 @@ func (c *Config) applyDefaults() {
|
||||
}
|
||||
|
||||
c.Shortcuts.ApplyDefaults()
|
||||
|
||||
if c.Downloads == nil {
|
||||
c.Downloads = &download.UserConfig{}
|
||||
}
|
||||
|
||||
c.Downloads.ApplyDefaults()
|
||||
}
|
||||
|
||||
// SetContext sets the Wails runtime context for event emission.
|
||||
func (c *Config) SetContext(ctx context.Context) {
|
||||
// ServiceStartup is v3's service lifecycle hook: it runs once the
|
||||
// runtime exists, and ctx is cancelled when the app shuts down. It
|
||||
// replaces v2's SetContext, which had to be called by hand from
|
||||
// OnStartup and was exported, so it was also bound to the frontend.
|
||||
func (c *Config) ServiceStartup(
|
||||
ctx context.Context,
|
||||
_ application.ServiceOptions,
|
||||
) error {
|
||||
c.ctx = ctx
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLibraryDirectory returns the currently configured library directory path.
|
||||
@@ -298,15 +327,13 @@ func (c *Config) SetLibraryDirectory(dir string) error {
|
||||
)
|
||||
}
|
||||
|
||||
if c.ctx != nil {
|
||||
runtime.EventsEmit(
|
||||
c.ctx,
|
||||
events.LibraryConfigChanged,
|
||||
map[string]any{
|
||||
"DirectoryPath": dir,
|
||||
},
|
||||
)
|
||||
}
|
||||
events.Emit(
|
||||
c.ctx,
|
||||
events.LibraryConfigChanged,
|
||||
map[string]any{
|
||||
"DirectoryPath": dir,
|
||||
},
|
||||
)
|
||||
|
||||
c.logger.Info(
|
||||
"library directory updated",
|
||||
@@ -356,6 +383,51 @@ func (c *Config) SetScanConcurrency(mode string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDownloadPreferences returns the configured auto-download
|
||||
// guardrails.
|
||||
func (c *Config) GetDownloadPreferences() download.AutoDownloadPrefs {
|
||||
if c.Downloads == nil {
|
||||
return download.AutoDownloadPrefs{}
|
||||
}
|
||||
|
||||
return c.Downloads.AutoDownloadPrefs()
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (c *Config) SetDownloadPreferences(prefs download.AutoDownloadPrefs) error {
|
||||
if c.Downloads == nil {
|
||||
c.Downloads = &download.UserConfig{}
|
||||
c.Downloads.ApplyDefaults()
|
||||
}
|
||||
|
||||
formats := make([]string, 0, len(prefs.AllowedFormats))
|
||||
for _, f := range prefs.AllowedFormats {
|
||||
formats = append(formats, string(f))
|
||||
}
|
||||
|
||||
c.Downloads.MinKbps = prefs.MinKbps
|
||||
c.Downloads.MaxKbps = prefs.MaxKbps
|
||||
c.Downloads.PreferredKbps = prefs.PreferredKbps
|
||||
c.Downloads.MaxFileSizeMB = prefs.MaxSizeMB
|
||||
c.Downloads.AllowedFormats = formats
|
||||
|
||||
if err := c.Save(); err != nil {
|
||||
return fmt.Errorf(
|
||||
"could not save config: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
c.logger.Info("download auto-pick preferences updated")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetThemeAccentColor returns the configured accent colour.
|
||||
func (c *Config) GetThemeAccentColor() string {
|
||||
if c.Theme == nil {
|
||||
@@ -442,11 +514,11 @@ func (c *Config) SetThemeBackgroundShade(
|
||||
|
||||
// emitThemeChanged sends the ThemeConfigChanged event to the frontend.
|
||||
func (c *Config) emitThemeChanged() {
|
||||
if c.ctx == nil || c.Theme == nil {
|
||||
if c.Theme == nil {
|
||||
return
|
||||
}
|
||||
|
||||
runtime.EventsEmit(
|
||||
events.Emit(
|
||||
c.ctx,
|
||||
events.ThemeConfigChanged,
|
||||
map[string]any{
|
||||
@@ -456,6 +528,144 @@ func (c *Config) emitThemeChanged() {
|
||||
)
|
||||
}
|
||||
|
||||
// GetDefaultPage returns the view the app opens to on launch.
|
||||
func (c *Config) GetDefaultPage() string {
|
||||
if c.General == nil {
|
||||
return string(DefaultDefaultPage)
|
||||
}
|
||||
|
||||
return string(c.General.DefaultPage)
|
||||
}
|
||||
|
||||
// SetDefaultPage validates and saves a new launch page.
|
||||
func (c *Config) SetDefaultPage(page string) error {
|
||||
if c.General == nil {
|
||||
c.General = &GeneralConfig{}
|
||||
c.General.ApplyDefaults()
|
||||
}
|
||||
|
||||
c.General.DefaultPage = DefaultPage(page)
|
||||
|
||||
if err := c.General.Validate(); err != nil {
|
||||
return fmt.Errorf(
|
||||
"invalid default page: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
if err := c.Save(); err != nil {
|
||||
return fmt.Errorf(
|
||||
"could not save config: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
events.Emit(
|
||||
c.ctx,
|
||||
events.GeneralConfigChanged,
|
||||
map[string]any{
|
||||
"DefaultPage": string(c.General.DefaultPage),
|
||||
},
|
||||
)
|
||||
|
||||
c.logger.Info(
|
||||
"default page updated",
|
||||
"page", page,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetQueueFallback returns what plays, if anything, once the queue
|
||||
// runs out.
|
||||
func (c *Config) GetQueueFallback() string {
|
||||
if c.General == nil {
|
||||
return string(DefaultQueueFallback)
|
||||
}
|
||||
|
||||
return string(c.General.QueueFallback)
|
||||
}
|
||||
|
||||
// SetQueueFallback validates and saves a new queue-fallback mode.
|
||||
func (c *Config) SetQueueFallback(mode string) error {
|
||||
if c.General == nil {
|
||||
c.General = &GeneralConfig{}
|
||||
c.General.ApplyDefaults()
|
||||
}
|
||||
|
||||
c.General.QueueFallback = QueueFallback(mode)
|
||||
|
||||
if err := c.General.Validate(); err != nil {
|
||||
return fmt.Errorf(
|
||||
"invalid queue fallback: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
if err := c.Save(); err != nil {
|
||||
return fmt.Errorf(
|
||||
"could not save config: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
events.Emit(
|
||||
c.ctx,
|
||||
events.GeneralConfigChanged,
|
||||
map[string]any{
|
||||
"QueueFallback": string(c.General.QueueFallback),
|
||||
},
|
||||
)
|
||||
|
||||
c.logger.Info(
|
||||
"queue fallback updated",
|
||||
"mode", mode,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllowMeteredCatalogDownload reports whether the ~0.6 GB Explore
|
||||
// catalog may be fetched on a metered connection.
|
||||
func (c *Config) GetAllowMeteredCatalogDownload() bool {
|
||||
if c.General == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return c.General.AllowMeteredCatalogDownload
|
||||
}
|
||||
|
||||
// SetAllowMeteredCatalogDownload saves the metered-download permission.
|
||||
//
|
||||
// There is nothing to validate and nothing to restart: the policy is
|
||||
// read at the moment a download would start, so turning it on takes
|
||||
// effect on the next attempt rather than needing this launch to be over.
|
||||
func (c *Config) SetAllowMeteredCatalogDownload(allow bool) error {
|
||||
if c.General == nil {
|
||||
c.General = &GeneralConfig{}
|
||||
c.General.ApplyDefaults()
|
||||
}
|
||||
|
||||
c.General.AllowMeteredCatalogDownload = allow
|
||||
|
||||
if err := c.Save(); err != nil {
|
||||
return fmt.Errorf(
|
||||
"could not save config: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
events.Emit(
|
||||
c.ctx,
|
||||
events.GeneralConfigChanged,
|
||||
map[string]any{
|
||||
"AllowMeteredCatalogDownload": allow,
|
||||
},
|
||||
)
|
||||
|
||||
c.logger.Info(
|
||||
"metered catalog download permission updated",
|
||||
"allow", allow,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTrackListColumns returns the configured track-list columns.
|
||||
func (c *Config) GetTrackListColumns() []tracklist.Column {
|
||||
if c.TrackList == nil {
|
||||
@@ -512,7 +722,7 @@ func (c *Config) emitTrackListChanged() {
|
||||
})
|
||||
}
|
||||
|
||||
runtime.EventsEmit(
|
||||
events.Emit(
|
||||
c.ctx,
|
||||
events.TrackListConfigChanged,
|
||||
map[string]any{
|
||||
@@ -636,11 +846,11 @@ func (c *Config) SetPinDefaultPlaylist(pin bool) error {
|
||||
// emitFavoritesChanged sends the FavoritesConfigChanged event
|
||||
// to the frontend.
|
||||
func (c *Config) emitFavoritesChanged() {
|
||||
if c.ctx == nil || c.Favorites == nil {
|
||||
if c.Favorites == nil {
|
||||
return
|
||||
}
|
||||
|
||||
runtime.EventsEmit(
|
||||
events.Emit(
|
||||
c.ctx,
|
||||
events.FavoritesConfigChanged,
|
||||
map[string]any{
|
||||
@@ -677,13 +887,11 @@ func (c *Config) SetShortcuts(
|
||||
)
|
||||
}
|
||||
|
||||
if c.ctx != nil {
|
||||
runtime.EventsEmit(
|
||||
c.ctx,
|
||||
events.ShortcutsConfigChanged,
|
||||
bindings,
|
||||
)
|
||||
}
|
||||
events.Emit(
|
||||
c.ctx,
|
||||
events.ShortcutsConfigChanged,
|
||||
bindings,
|
||||
)
|
||||
|
||||
c.logger.Info("shortcuts config updated")
|
||||
|
||||
@@ -707,13 +915,11 @@ func (c *Config) SetShortcut(
|
||||
)
|
||||
}
|
||||
|
||||
if c.ctx != nil {
|
||||
runtime.EventsEmit(
|
||||
c.ctx,
|
||||
events.ShortcutsConfigChanged,
|
||||
c.Shortcuts.Bindings,
|
||||
)
|
||||
}
|
||||
events.Emit(
|
||||
c.ctx,
|
||||
events.ShortcutsConfigChanged,
|
||||
c.Shortcuts.Bindings,
|
||||
)
|
||||
|
||||
c.logger.Info(
|
||||
"shortcut updated",
|
||||
@@ -736,13 +942,11 @@ func (c *Config) ResetShortcuts() error {
|
||||
)
|
||||
}
|
||||
|
||||
if c.ctx != nil {
|
||||
runtime.EventsEmit(
|
||||
c.ctx,
|
||||
events.ShortcutsConfigChanged,
|
||||
c.Shortcuts.Bindings,
|
||||
)
|
||||
}
|
||||
events.Emit(
|
||||
c.ctx,
|
||||
events.ShortcutsConfigChanged,
|
||||
c.Shortcuts.Bindings,
|
||||
)
|
||||
|
||||
c.logger.Info("shortcuts reset to defaults")
|
||||
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
|
||||
"yellowjacket/backend/events"
|
||||
)
|
||||
|
||||
// setupRecordedConfig builds a Config that saves to a temp directory
|
||||
// and records the events it would push to the frontend.
|
||||
func setupRecordedConfig(t *testing.T) (*Config, *events.Recorder) {
|
||||
t.Helper()
|
||||
|
||||
conf := &Config{
|
||||
logger: slog.Default(),
|
||||
filePath: filepath.Join(t.TempDir(), "config.toml"),
|
||||
}
|
||||
conf.applyDefaults()
|
||||
|
||||
// Load, not just applyDefaults: Save refuses to write a config that
|
||||
// was never hydrated from disk, so without this only the first
|
||||
// setter in a test succeeds.
|
||||
if err := conf.Load(); err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
rec := events.NewRecorder()
|
||||
_ = conf.ServiceStartup(
|
||||
events.WithSink(context.Background(), rec),
|
||||
application.ServiceOptions{},
|
||||
)
|
||||
|
||||
return conf, rec
|
||||
}
|
||||
|
||||
// payloadMap returns the map payload of the most recent named event.
|
||||
func payloadMap(
|
||||
t *testing.T,
|
||||
rec *events.Recorder,
|
||||
name string,
|
||||
) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
ev, ok := rec.Last(name)
|
||||
if !ok {
|
||||
t.Fatalf("no %s emitted; got %v", name, rec.Names())
|
||||
}
|
||||
|
||||
data, ok := ev.Payload().(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("%s payload is %T, want map[string]any", name, ev.Payload())
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// TestEmit_ThemeChangeCarriesBothFields pins that the theme event is a
|
||||
// snapshot of both fields, not a delta: the frontend applies the whole
|
||||
// colour ramp from it, so an accent change that omitted the shade would
|
||||
// re-derive the ramp against a default background.
|
||||
func TestEmit_ThemeChangeCarriesBothFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conf, rec := setupRecordedConfig(t)
|
||||
|
||||
if err := conf.SetThemeBackgroundShade("light"); err != nil {
|
||||
t.Fatalf("SetThemeBackgroundShade: %v", err)
|
||||
}
|
||||
|
||||
if err := conf.SetThemeAccentColor("#ff0000"); err != nil {
|
||||
t.Fatalf("SetThemeAccentColor: %v", err)
|
||||
}
|
||||
|
||||
if got := rec.Count(events.ThemeConfigChanged); got != 2 {
|
||||
t.Errorf("emitted %d ThemeConfigChanged, want 2", got)
|
||||
}
|
||||
|
||||
data := payloadMap(t, rec, events.ThemeConfigChanged)
|
||||
if data["AccentColor"] != "#ff0000" {
|
||||
t.Errorf("AccentColor = %v, want #ff0000", data["AccentColor"])
|
||||
}
|
||||
|
||||
if data["BackgroundShade"] != "light" {
|
||||
t.Errorf("BackgroundShade = %v, want light", data["BackgroundShade"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmit_ThemeChangeIsNotEmittedOnRejectedValue pins that a rejected
|
||||
// write does not tell the frontend the theme changed.
|
||||
func TestEmit_ThemeChangeIsNotEmittedOnRejectedValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conf, rec := setupRecordedConfig(t)
|
||||
|
||||
if err := conf.SetThemeAccentColor("not-a-colour"); err == nil {
|
||||
t.Fatal("SetThemeAccentColor accepted an invalid colour")
|
||||
}
|
||||
|
||||
if got := rec.Count(events.ThemeConfigChanged); got != 0 {
|
||||
t.Errorf("emitted %d ThemeConfigChanged for a rejected write, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmit_ShortcutChangeSendsWholeBindingMap covers the surface the
|
||||
// 357-line frontend shortcut service rebuilds itself from.
|
||||
func TestEmit_ShortcutChangeSendsWholeBindingMap(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conf, rec := setupRecordedConfig(t)
|
||||
|
||||
if err := conf.SetShortcut("playPause", "k"); err != nil {
|
||||
t.Fatalf("SetShortcut: %v", err)
|
||||
}
|
||||
|
||||
ev, ok := rec.Last(events.ShortcutsConfigChanged)
|
||||
if !ok {
|
||||
t.Fatalf("no ShortcutsConfigChanged; got %v", rec.Names())
|
||||
}
|
||||
|
||||
bindings, ok := ev.Payload().(map[string]string)
|
||||
if !ok {
|
||||
t.Fatalf("payload is %T, want map[string]string", ev.Payload())
|
||||
}
|
||||
|
||||
if bindings["playPause"] != "k" {
|
||||
t.Errorf("playPause = %q, want k", bindings["playPause"])
|
||||
}
|
||||
|
||||
// The whole map, not just the changed key — the frontend replaces
|
||||
// its binding table wholesale on this event.
|
||||
if len(bindings) < 2 {
|
||||
t.Errorf("emitted %d bindings, want the full default set", len(bindings))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmit_ResetShortcutsRepublishesDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conf, rec := setupRecordedConfig(t)
|
||||
|
||||
if err := conf.SetShortcut("playPause", "k"); err != nil {
|
||||
t.Fatalf("SetShortcut: %v", err)
|
||||
}
|
||||
|
||||
rec.Reset()
|
||||
|
||||
if err := conf.ResetShortcuts(); err != nil {
|
||||
t.Fatalf("ResetShortcuts: %v", err)
|
||||
}
|
||||
|
||||
ev, ok := rec.Last(events.ShortcutsConfigChanged)
|
||||
if !ok {
|
||||
t.Fatalf("no ShortcutsConfigChanged after reset; got %v", rec.Names())
|
||||
}
|
||||
|
||||
bindings, ok := ev.Payload().(map[string]string)
|
||||
if !ok {
|
||||
t.Fatalf("payload is %T, want map[string]string", ev.Payload())
|
||||
}
|
||||
|
||||
if bindings["playPause"] == "k" {
|
||||
t.Error("reset emitted the overridden binding, not the default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmit_FavoritesChangeCarriesFullConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
conf, rec := setupRecordedConfig(t)
|
||||
|
||||
if err := conf.SetFavoritesPlaylistID(7); err != nil {
|
||||
t.Fatalf("SetFavoritesPlaylistID: %v", err)
|
||||
}
|
||||
|
||||
data := payloadMap(t, rec, events.FavoritesConfigChanged)
|
||||
if data["PlaylistID"] != int64(7) {
|
||||
t.Errorf("PlaylistID = %#v, want int64(7)", data["PlaylistID"])
|
||||
}
|
||||
|
||||
for _, key := range []string{"IconStyle", "PinDefault"} {
|
||||
if _, ok := data[key]; !ok {
|
||||
t.Errorf("payload is missing %q; the settings page reads it", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// DefaultPage identifies which view the app opens to on launch.
|
||||
type DefaultPage string
|
||||
|
||||
// Valid DefaultPage values, matching the frontend's top-level route ids.
|
||||
const (
|
||||
DefaultPageHome DefaultPage = "home"
|
||||
DefaultPageTracks DefaultPage = "tracks"
|
||||
DefaultPageAlbums DefaultPage = "albums"
|
||||
DefaultPageArtists DefaultPage = "artists"
|
||||
DefaultPageGenres DefaultPage = "genres"
|
||||
DefaultPagePlaylists DefaultPage = "playlists"
|
||||
DefaultPageExplore DefaultPage = "explore"
|
||||
DefaultPageDownloads DefaultPage = "downloads"
|
||||
DefaultPageAutotag DefaultPage = "autotag"
|
||||
DefaultPageJobs DefaultPage = "jobs"
|
||||
)
|
||||
|
||||
// DefaultDefaultPage is the launch page for a fresh install.
|
||||
const DefaultDefaultPage = DefaultPageHome
|
||||
|
||||
var errUnknownDefaultPage = errors.New("unknown default page")
|
||||
|
||||
// QueueFallback identifies what plays, if anything, once the queue
|
||||
// runs out with nothing left to auto-advance to.
|
||||
type QueueFallback string
|
||||
|
||||
// Valid QueueFallback values.
|
||||
const (
|
||||
QueueFallbackStop QueueFallback = "stop"
|
||||
QueueFallbackFavorites QueueFallback = "favorites"
|
||||
QueueFallbackDynamicMix QueueFallback = "dynamicMix"
|
||||
)
|
||||
|
||||
// DefaultQueueFallback is the fallback behavior for a fresh install.
|
||||
const DefaultQueueFallback = QueueFallbackFavorites
|
||||
|
||||
var errUnknownQueueFallback = errors.New("unknown queue fallback")
|
||||
|
||||
// GeneralConfig holds general application preferences that don't
|
||||
// belong to a more specific subsystem.
|
||||
type GeneralConfig struct {
|
||||
DefaultPage DefaultPage `toml:"DefaultPage"`
|
||||
QueueFallback QueueFallback `toml:"QueueFallback"`
|
||||
// AllowMeteredCatalogDownload permits the ~0.6 GB Explore catalog to
|
||||
// be fetched on a connection the platform calls cellular. It defaults
|
||||
// to false, which is the whole point: the zero value is the safe one,
|
||||
// so an existing config with no such key refuses by default rather
|
||||
// than needing a migration to become careful.
|
||||
AllowMeteredCatalogDownload bool `toml:"AllowMeteredCatalogDownload"`
|
||||
}
|
||||
|
||||
// ApplyDefaults fills zero-value fields with sensible defaults.
|
||||
func (c *GeneralConfig) ApplyDefaults() {
|
||||
if c.DefaultPage == "" {
|
||||
c.DefaultPage = DefaultDefaultPage
|
||||
}
|
||||
|
||||
if c.QueueFallback == "" {
|
||||
c.QueueFallback = DefaultQueueFallback
|
||||
}
|
||||
}
|
||||
|
||||
// Validate checks that all values are well-formed.
|
||||
func (c *GeneralConfig) Validate() error {
|
||||
c.ApplyDefaults()
|
||||
|
||||
switch c.DefaultPage {
|
||||
case DefaultPageHome, DefaultPageTracks, DefaultPageAlbums, DefaultPageArtists,
|
||||
DefaultPageGenres, DefaultPagePlaylists, DefaultPageExplore, DefaultPageDownloads,
|
||||
DefaultPageAutotag, DefaultPageJobs:
|
||||
// Valid.
|
||||
default:
|
||||
return fmt.Errorf("%w: %q", errUnknownDefaultPage, c.DefaultPage)
|
||||
}
|
||||
|
||||
switch c.QueueFallback {
|
||||
case QueueFallbackStop, QueueFallbackFavorites, QueueFallbackDynamicMix:
|
||||
// Valid.
|
||||
default:
|
||||
return fmt.Errorf("%w: %q", errUnknownQueueFallback, c.QueueFallback)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -12,9 +12,17 @@ const (
|
||||
// MinWidth is the smallest allowed window width in pixels. Wails
|
||||
// enforces this at runtime; it is also the floor below which a
|
||||
// reported size is treated as bogus and not persisted.
|
||||
MinWidth = 512
|
||||
//
|
||||
// 800x600 is where the shell was measured to still work, rather
|
||||
// than a round number: below ~780 the header's subtitle wraps and
|
||||
// pushes the title out of the 4em top bar, and below ~600 tall the
|
||||
// eleven sidebar items no longer fit at once. The previous
|
||||
// 512x384 was aspirational — at 700x480 the sidebar overflowed
|
||||
// behind the player bar with no scroll and Settings and Jobs could
|
||||
// not be reached at all.
|
||||
MinWidth = 800
|
||||
// MinHeight is the smallest allowed window height in pixels.
|
||||
MinHeight = 384
|
||||
MinHeight = 600
|
||||
)
|
||||
|
||||
// WindowConfig holds window size preferences.
|
||||
|
||||
@@ -12,7 +12,15 @@ import (
|
||||
// PathPrefix is the URL path prefix for cover art served by the asset handler.
|
||||
const PathPrefix = "/covers/"
|
||||
|
||||
// URLs holds the resolved URL paths for all cover art size variants.
|
||||
// URLs holds the resolved URL paths for a cover's size variants.
|
||||
//
|
||||
// Original is the largest variant kept, which is the Large one: the
|
||||
// full-resolution image is no longer stored. It was 1,134 MB of a
|
||||
// 1.4 GB covers directory on a real 2,057-album library against 110 MB
|
||||
// for all three rendered tiers, and nothing rendered it - the grid caps
|
||||
// at 350 px and the largest tier is 400. The field keeps its name
|
||||
// because it is what a caller means by "the cover", and the bytes it
|
||||
// came from are still in the audio file if a bigger one is ever wanted.
|
||||
type URLs struct {
|
||||
Original string
|
||||
Small string
|
||||
@@ -36,25 +44,41 @@ func CoversDir() (string, error) {
|
||||
return filepath.Join(dataDir, dirName), nil
|
||||
}
|
||||
|
||||
// SizedFilename derives a sized-variant filename from an original cover art
|
||||
// filename and a size suffix.
|
||||
// For example, SizedFilename("a1b2c3d4.jpg", "_sm") returns "a1b2c3d4_sm.jpg".
|
||||
func SizedFilename(originalFilename, suffix string) string {
|
||||
ext := filepath.Ext(originalFilename)
|
||||
name := strings.TrimSuffix(originalFilename, ext)
|
||||
// Suffixes are the size variants a cover is stored as, largest last.
|
||||
var Suffixes = []string{"_sm", "_md", "_lg"}
|
||||
|
||||
return name + suffix + ".jpg"
|
||||
// SizedFilename derives a sized-variant filename from a cover art
|
||||
// filename and a size suffix. The input may itself be a variant, so
|
||||
// its suffix is stripped first: SizedFilename("a1b2_lg.jpg", "_sm")
|
||||
// and SizedFilename("a1b2.jpg", "_sm") both return "a1b2_sm.jpg".
|
||||
func SizedFilename(filename, suffix string) string {
|
||||
return BaseName(filename) + suffix + ".jpg"
|
||||
}
|
||||
|
||||
// BaseName strips the extension and any size suffix from a cover art
|
||||
// filename, leaving the content hash that identifies the cover.
|
||||
func BaseName(filename string) string {
|
||||
name := strings.TrimSuffix(filename, filepath.Ext(filename))
|
||||
|
||||
for _, suffix := range Suffixes {
|
||||
if strings.HasSuffix(name, suffix) {
|
||||
return strings.TrimSuffix(name, suffix)
|
||||
}
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
// ResolveURLs converts a cover art filesystem path into URL paths
|
||||
// for the original and all size variants (small, medium, large).
|
||||
func ResolveURLs(filesystemPath string) URLs {
|
||||
base := filepath.Base(filesystemPath)
|
||||
large := PathPrefix + SizedFilename(base, "_lg")
|
||||
|
||||
return URLs{
|
||||
Original: PathPrefix + base,
|
||||
Original: large,
|
||||
Small: PathPrefix + SizedFilename(base, "_sm"),
|
||||
Medium: PathPrefix + SizedFilename(base, "_md"),
|
||||
Large: PathPrefix + SizedFilename(base, "_lg"),
|
||||
Large: large,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,8 +108,10 @@ func TestResolveURLs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
name string
|
||||
path string
|
||||
// Original is the largest kept variant: the full-resolution
|
||||
// image is not stored (see URLs).
|
||||
wantOrig string
|
||||
wantSm string
|
||||
wantMd string
|
||||
@@ -118,7 +120,7 @@ func TestResolveURLs(t *testing.T) {
|
||||
{
|
||||
name: "absolute path",
|
||||
path: "/home/user/.local/share/yellowjacket/covers/a1b2c3d4.jpg",
|
||||
wantOrig: "/covers/a1b2c3d4.jpg",
|
||||
wantOrig: "/covers/a1b2c3d4_lg.jpg",
|
||||
wantSm: "/covers/a1b2c3d4_sm.jpg",
|
||||
wantMd: "/covers/a1b2c3d4_md.jpg",
|
||||
wantLg: "/covers/a1b2c3d4_lg.jpg",
|
||||
@@ -126,7 +128,7 @@ func TestResolveURLs(t *testing.T) {
|
||||
{
|
||||
name: "bare filename",
|
||||
path: "abcdef01.png",
|
||||
wantOrig: "/covers/abcdef01.png",
|
||||
wantOrig: "/covers/abcdef01_lg.jpg",
|
||||
wantSm: "/covers/abcdef01_sm.jpg",
|
||||
wantMd: "/covers/abcdef01_md.jpg",
|
||||
wantLg: "/covers/abcdef01_lg.jpg",
|
||||
|
||||
+190
-3707
File diff suppressed because it is too large
Load Diff
@@ -12,7 +12,7 @@ import (
|
||||
// Migration 6 integration tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMigration6FreshDB(t *testing.T) {
|
||||
func TestSchemaCreatesLibrariesTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := NewTestDB(t)
|
||||
@@ -172,32 +172,6 @@ func TestMigration6FreshDB(t *testing.T) {
|
||||
t.Error("track_metadata VIEW does not contain library_id")
|
||||
}
|
||||
|
||||
// Verify user_version >= 7.
|
||||
var version int
|
||||
|
||||
verRows, err := db.QueryContext("PRAGMA user_version")
|
||||
if err != nil {
|
||||
t.Fatalf("PRAGMA user_version: %v", err)
|
||||
}
|
||||
|
||||
if !verRows.Next() {
|
||||
_ = verRows.Close()
|
||||
|
||||
t.Fatal("PRAGMA user_version: no row returned")
|
||||
}
|
||||
|
||||
if err := verRows.Scan(&version); err != nil {
|
||||
_ = verRows.Close()
|
||||
|
||||
t.Fatalf("scan user_version: %v", err)
|
||||
}
|
||||
|
||||
_ = verRows.Close()
|
||||
|
||||
if version < 7 {
|
||||
t.Errorf("user_version = %d, want >= 7", version)
|
||||
}
|
||||
|
||||
// Verify libraries table has only the sentinel row on fresh DB.
|
||||
count, err := db.Queries.CountLibraries(db.Ctx)
|
||||
if err != nil {
|
||||
@@ -213,7 +187,7 @@ func TestMigration6FreshDB(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigration6LibraryQueries(t *testing.T) {
|
||||
func TestLibraryQueries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := NewTestDB(t)
|
||||
@@ -331,38 +305,20 @@ func TestMigration6LibraryQueries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigration6PhantomPlaylistTracks(t *testing.T) {
|
||||
func TestPhantomPlaylistTracksAreCleaned(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, libID := NewTestDBWithLibrary(t, "Test", "/test/music")
|
||||
|
||||
// Create prerequisite data: artist_credit, recording,
|
||||
// audio_file.
|
||||
_, err := db.ExecContext(
|
||||
"INSERT INTO artist_credit (id, text) VALUES (1, 'Test Artist')",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert artist_credit: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO recordings (id, name, artist_credit_id) " +
|
||||
"VALUES (1, 'Test Song', 1)",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert recording: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO audio_files "+
|
||||
"(id, file_path, length_milliseconds, file_type_id, "+
|
||||
"recording_id, library_id) "+
|
||||
"VALUES (1, '/test/music/song.mp3', 180000, 0, 1, ?)",
|
||||
libID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert audio_file: %v", err)
|
||||
}
|
||||
InsertTestTrack(t, db, TestTrack{
|
||||
FilePath: "/test/music/song.mp3",
|
||||
Title: "Test Song",
|
||||
Artist: "Test Artist",
|
||||
LengthMs: 180000,
|
||||
LibraryID: libID,
|
||||
})
|
||||
|
||||
// Create playlist.
|
||||
playlist, err := db.Queries.CreatePlaylist(
|
||||
@@ -462,45 +418,24 @@ func TestMigration6PhantomPlaylistTracks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigration6AudioFilesLibraryFK(t *testing.T) {
|
||||
func TestAudioFilesLibraryForeignKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, libID := NewTestDBWithLibrary(t, "Test", "/test/fk-lib")
|
||||
|
||||
// Insert prerequisite recording.
|
||||
InsertTestTrack(t, db, TestTrack{
|
||||
FilePath: "/test/track.mp3",
|
||||
Title: "Track",
|
||||
Artist: "Test",
|
||||
LibraryID: libID,
|
||||
})
|
||||
|
||||
// Insert audio file with invalid library_id - should fail FK.
|
||||
_, err := db.ExecContext(
|
||||
"INSERT INTO artist_credit (id, text) VALUES (1, 'Test')",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert artist_credit: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO recordings (id, name, artist_credit_id) " +
|
||||
"VALUES (1, 'Track', 1)",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert recording: %v", err)
|
||||
}
|
||||
|
||||
// Insert audio file with valid library_id — should succeed.
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO audio_files "+
|
||||
"(id, file_path, length_milliseconds, file_type_id, "+
|
||||
"recording_id, library_id) "+
|
||||
"VALUES (1, '/test/song.mp3', 180000, 0, 1, ?)",
|
||||
libID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert audio_file with valid library: %v", err)
|
||||
}
|
||||
|
||||
// Insert audio file with invalid library_id — should fail FK.
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO audio_files " +
|
||||
"(id, file_path, length_milliseconds, file_type_id, " +
|
||||
"recording_id, library_id) " +
|
||||
"VALUES (2, '/test/song2.mp3', 200000, 0, 1, 999)",
|
||||
"(id, file_path, length_milliseconds, file_type_id, library_id) " +
|
||||
"VALUES (2, '/test/song2.mp3', 200000, 0, 999)",
|
||||
)
|
||||
if err == nil {
|
||||
t.Error(
|
||||
@@ -509,51 +444,33 @@ func TestMigration6AudioFilesLibraryFK(t *testing.T) {
|
||||
}
|
||||
|
||||
// Count files by library.
|
||||
count, err := db.Queries.CountAudioFilesByLibrary(
|
||||
count, err := db.Queries.CountAudioFiles(
|
||||
db.Ctx, libID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("CountAudioFilesByLibrary: %v", err)
|
||||
t.Fatalf("CountAudioFiles: %v", err)
|
||||
}
|
||||
|
||||
if count != 1 {
|
||||
t.Errorf(
|
||||
"CountAudioFilesByLibrary = %d, want 1", count,
|
||||
"CountAudioFiles = %d, want 1", count,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigration6TrackMetadataViewHasLibraryID(t *testing.T) {
|
||||
func TestTrackMetadataViewHasLibraryID(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, libID := NewTestDBWithLibrary(t, "Test", "/test/view-lib")
|
||||
|
||||
// Insert prerequisites.
|
||||
_, err := db.ExecContext(
|
||||
"INSERT INTO artist_credit (id, text) VALUES (1, 'View Artist')",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert artist_credit: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO recordings (id, name, artist_credit_id) " +
|
||||
"VALUES (1, 'View Track', 1)",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert recording: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO audio_files "+
|
||||
"(id, file_path, length_milliseconds, file_type_id, "+
|
||||
"recording_id, library_id) "+
|
||||
"VALUES (1, '/test/view.mp3', 200000, 0, 1, ?)",
|
||||
libID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert audio_file: %v", err)
|
||||
}
|
||||
InsertTestTrack(t, db, TestTrack{
|
||||
FilePath: "/test/view.mp3",
|
||||
Title: "View Track",
|
||||
Artist: "View Artist",
|
||||
LengthMs: 200000,
|
||||
LibraryID: libID,
|
||||
})
|
||||
|
||||
// Query track_metadata VIEW and verify library_id is present
|
||||
// with the correct value.
|
||||
@@ -592,37 +509,11 @@ func TestMigration6TrackMetadataViewHasLibraryID(t *testing.T) {
|
||||
// Migration 9 integration tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMigration9SmartPlaylistColumns(t *testing.T) {
|
||||
func TestSmartPlaylistColumns(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := NewTestDB(t)
|
||||
|
||||
// Verify user_version >= 9.
|
||||
var version int
|
||||
|
||||
verRows, err := db.QueryContext("PRAGMA user_version")
|
||||
if err != nil {
|
||||
t.Fatalf("PRAGMA user_version: %v", err)
|
||||
}
|
||||
|
||||
if !verRows.Next() {
|
||||
_ = verRows.Close()
|
||||
|
||||
t.Fatal("PRAGMA user_version: no row returned")
|
||||
}
|
||||
|
||||
if err := verRows.Scan(&version); err != nil {
|
||||
_ = verRows.Close()
|
||||
|
||||
t.Fatalf("scan user_version: %v", err)
|
||||
}
|
||||
|
||||
_ = verRows.Close()
|
||||
|
||||
if version < 9 {
|
||||
t.Errorf("user_version = %d, want >= 9", version)
|
||||
}
|
||||
|
||||
// Verify playlists table has is_smart and smart_rules columns.
|
||||
hasSmart := false
|
||||
hasRules := false
|
||||
@@ -774,37 +665,11 @@ func TestMigration9SmartPlaylistColumns(t *testing.T) {
|
||||
// Migration 10 — play history tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMigration10PlayHistory(t *testing.T) {
|
||||
func TestPlayHistoryTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := NewTestDB(t)
|
||||
|
||||
// Verify user_version >= 10.
|
||||
var version int
|
||||
|
||||
verRows, err := db.QueryContext("PRAGMA user_version")
|
||||
if err != nil {
|
||||
t.Fatalf("PRAGMA user_version: %v", err)
|
||||
}
|
||||
|
||||
if !verRows.Next() {
|
||||
_ = verRows.Close()
|
||||
|
||||
t.Fatal("PRAGMA user_version: no row returned")
|
||||
}
|
||||
|
||||
if err := verRows.Scan(&version); err != nil {
|
||||
_ = verRows.Close()
|
||||
|
||||
t.Fatalf("scan user_version: %v", err)
|
||||
}
|
||||
|
||||
_ = verRows.Close()
|
||||
|
||||
if version < 10 {
|
||||
t.Errorf("user_version = %d, want >= 10", version)
|
||||
}
|
||||
|
||||
// Verify play_history table exists.
|
||||
var tableCount int64
|
||||
|
||||
@@ -920,29 +785,13 @@ func TestMigration10PlayHistory(t *testing.T) {
|
||||
|
||||
// Round-trip: insert a play_history row and verify play_count update.
|
||||
// First, set up test data. The test DB already has library id=0.
|
||||
_, err = db.ExecContext(
|
||||
"INSERT OR IGNORE INTO artist_credit (id, text) VALUES (1, 'Test Artist')",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert artist_credit: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
`INSERT OR IGNORE INTO recordings (id, name, artist_credit_id, track_number, disc_number)
|
||||
VALUES (1, 'Test Track', 1, 1, 1)`,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert recording: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
`INSERT INTO audio_files
|
||||
(id, file_path, length_milliseconds, file_type_id, recording_id, library_id)
|
||||
VALUES (1, '/test/track.mp3', 180000, 0, 1, 0)`,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert audio_file: %v", err)
|
||||
}
|
||||
InsertTestTrack(t, db, TestTrack{
|
||||
FilePath: "/test/play_history.mp3",
|
||||
Title: "Test Track",
|
||||
Artist: "Test Artist",
|
||||
TrackNumber: 1,
|
||||
DiscNumber: 1,
|
||||
})
|
||||
|
||||
// Verify default play_count is 0.
|
||||
var playCount int64
|
||||
@@ -1058,7 +907,7 @@ func TestMigration10PlayHistory(t *testing.T) {
|
||||
// Migration 11 — explore_cache table
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMigration11ExploreCache(t *testing.T) {
|
||||
func TestHTTPCacheTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// explore_cache was split into http_cache + artist_metadata by
|
||||
@@ -1069,32 +918,6 @@ func TestMigration11ExploreCache(t *testing.T) {
|
||||
|
||||
db := NewTestDB(t)
|
||||
|
||||
// Verify user_version >= 11.
|
||||
var version int
|
||||
|
||||
verRows, err := db.QueryContext("PRAGMA user_version")
|
||||
if err != nil {
|
||||
t.Fatalf("PRAGMA user_version: %v", err)
|
||||
}
|
||||
|
||||
if !verRows.Next() {
|
||||
_ = verRows.Close()
|
||||
|
||||
t.Fatal("PRAGMA user_version: no row returned")
|
||||
}
|
||||
|
||||
if err := verRows.Scan(&version); err != nil {
|
||||
_ = verRows.Close()
|
||||
|
||||
t.Fatalf("scan user_version: %v", err)
|
||||
}
|
||||
|
||||
_ = verRows.Close()
|
||||
|
||||
if version < 11 {
|
||||
t.Errorf("user_version = %d, want >= 11", version)
|
||||
}
|
||||
|
||||
// Verify explore_cache table exists.
|
||||
var tableCount int64
|
||||
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// seedExploreRow inserts one explore_index row.
|
||||
//
|
||||
// The catalog stores an MBID as 16 raw bytes and an entity type as a
|
||||
// code (see backend/explore/mbid.go), and the column says so, so the
|
||||
// label these tests use as an id is hashed into something the table
|
||||
// will accept. What they actually assert on is the FTS text.
|
||||
func seedExploreRow(t *testing.T, db *DB, mbid, title, artist string) {
|
||||
t.Helper()
|
||||
|
||||
sum := sha256.Sum256([]byte(mbid))
|
||||
|
||||
if _, err := db.ExecContext(`
|
||||
INSERT INTO explore_index (entity_type, mbid, title, artist_name, artist_mbid)
|
||||
VALUES (3 /* recording */, ?, ?, ?, x'')
|
||||
`, sum[:16], title, artist); err != nil {
|
||||
t.Fatalf("seed %s: %v", mbid, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ftsMatches returns how many FTS rows match a query.
|
||||
func ftsMatches(t *testing.T, db *DB, query string) int {
|
||||
t.Helper()
|
||||
|
||||
rows, err := db.QueryContext(
|
||||
"SELECT COUNT(*) FROM explore_index_fts WHERE explore_index_fts MATCH ?", query,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("fts query %q: %v", query, err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
n := 0
|
||||
|
||||
if rows.Next() {
|
||||
if err := rows.Scan(&n); err != nil {
|
||||
t.Fatalf("scan fts count: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("fts rows: %v", err)
|
||||
}
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
// Rows written while FTS sync is suspended are invisible to search
|
||||
// until the window closes — and fully searchable afterwards. This is
|
||||
// the contract the dump import's bulk-load path depends on.
|
||||
func TestExploreFTSSuspendResumeIndexesBulkRows(t *testing.T) {
|
||||
db := NewTestDB(t)
|
||||
|
||||
seedExploreRow(t, db, "mbid-before", "Before Suspend", "Artist One")
|
||||
|
||||
if got := ftsMatches(t, db, "Before"); got != 1 {
|
||||
t.Fatalf("matches for pre-suspend row = %d, want 1", got)
|
||||
}
|
||||
|
||||
if err := db.SuspendExploreIndexFTS(); err != nil {
|
||||
t.Fatalf("suspend: %v", err)
|
||||
}
|
||||
|
||||
seedExploreRow(t, db, "mbid-during", "During Suspend", "Artist Two")
|
||||
|
||||
if got := ftsMatches(t, db, "During"); got != 0 {
|
||||
t.Errorf("matches while suspended = %d, want 0 (triggers should be off)", got)
|
||||
}
|
||||
|
||||
if err := db.ResumeExploreIndexFTS(); err != nil {
|
||||
t.Fatalf("resume: %v", err)
|
||||
}
|
||||
|
||||
if got := ftsMatches(t, db, "During"); got != 1 {
|
||||
t.Errorf("matches for bulk-loaded row after resume = %d, want 1", got)
|
||||
}
|
||||
|
||||
if got := ftsMatches(t, db, "Before"); got != 1 {
|
||||
t.Errorf("matches for pre-suspend row after resume = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The import wipes explore_index before reassembling it. With the
|
||||
// triggers suspended that DELETE writes no FTS delete-markers, so the
|
||||
// rebuild must be what clears the old rows out of search.
|
||||
func TestExploreFTSResumeDropsDeletedRows(t *testing.T) {
|
||||
db := NewTestDB(t)
|
||||
|
||||
seedExploreRow(t, db, "mbid-stale", "Stale Recording", "Old Artist")
|
||||
|
||||
if err := db.SuspendExploreIndexFTS(); err != nil {
|
||||
t.Fatalf("suspend: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext("DELETE FROM explore_index"); err != nil {
|
||||
t.Fatalf("wipe: %v", err)
|
||||
}
|
||||
|
||||
seedExploreRow(t, db, "mbid-fresh", "Fresh Recording", "New Artist")
|
||||
|
||||
if err := db.ResumeExploreIndexFTS(); err != nil {
|
||||
t.Fatalf("resume: %v", err)
|
||||
}
|
||||
|
||||
if got := ftsMatches(t, db, "Stale"); got != 0 {
|
||||
t.Errorf("matches for wiped row = %d, want 0", got)
|
||||
}
|
||||
|
||||
if got := ftsMatches(t, db, "Fresh"); got != 1 {
|
||||
t.Errorf("matches for reassembled row = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// resumeFTS runs from a defer as well as at its natural point in the
|
||||
// pipeline, so a second call must be harmless.
|
||||
func TestExploreFTSResumeIsIdempotent(t *testing.T) {
|
||||
db := NewTestDB(t)
|
||||
|
||||
if err := db.SuspendExploreIndexFTS(); err != nil {
|
||||
t.Fatalf("suspend: %v", err)
|
||||
}
|
||||
|
||||
seedExploreRow(t, db, "mbid-a", "Repeatable Resume", "Artist")
|
||||
|
||||
if err := db.ResumeExploreIndexFTS(); err != nil {
|
||||
t.Fatalf("first resume: %v", err)
|
||||
}
|
||||
|
||||
if err := db.ResumeExploreIndexFTS(); err != nil {
|
||||
t.Fatalf("second resume: %v", err)
|
||||
}
|
||||
|
||||
if got := ftsMatches(t, db, "Repeatable"); got != 1 {
|
||||
t.Errorf("matches after repeated resume = %d, want 1", got)
|
||||
}
|
||||
|
||||
// Triggers must still be live for ordinary writes after the window.
|
||||
seedExploreRow(t, db, "mbid-b", "Postwindow Row", "Artist")
|
||||
|
||||
if got := ftsMatches(t, db, "Postwindow"); got != 1 {
|
||||
t.Errorf("matches for row written after resume = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Suspending twice must not fail — the triggers are simply already gone.
|
||||
func TestExploreFTSSuspendIsIdempotent(t *testing.T) {
|
||||
db := NewTestDB(t)
|
||||
|
||||
if err := db.SuspendExploreIndexFTS(); err != nil {
|
||||
t.Fatalf("first suspend: %v", err)
|
||||
}
|
||||
|
||||
if err := db.SuspendExploreIndexFTS(); err != nil {
|
||||
t.Fatalf("second suspend: %v", err)
|
||||
}
|
||||
|
||||
if err := db.ResumeExploreIndexFTS(); err != nil {
|
||||
t.Fatalf("resume: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ftsSegmentCount reports how much the FTS index itself has been
|
||||
// written to. Every delete + insert the update trigger performs
|
||||
// appends to the shadow content table, so this is the observable that
|
||||
// tells "the trigger re-indexed the row" from "the trigger declined
|
||||
// to". Search results cannot: a no-op re-index leaves the same
|
||||
// matches behind.
|
||||
func ftsSegmentCount(t *testing.T, db *DB) int {
|
||||
t.Helper()
|
||||
|
||||
rows, err := db.QueryContext("SELECT COUNT(*) FROM explore_index_fts_data")
|
||||
if err != nil {
|
||||
t.Fatalf("fts data count: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
n := 0
|
||||
|
||||
if rows.Next() {
|
||||
if err := rows.Scan(&n); err != nil {
|
||||
t.Fatalf("scan fts data count: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
// The common write in this schema is an upsert whose merge rules keep
|
||||
// every existing value — the discography backfill re-browsing a known
|
||||
// artist, the incremental dump refreshing popularity. Re-indexing
|
||||
// those cost an FTS5 delete against a multi-million row index while
|
||||
// holding the single writer connection, which is what starved the
|
||||
// playback path. An update that leaves title, artist_name and aliases
|
||||
// alone must not touch the FTS index at all.
|
||||
func TestExploreFTSUpdateSkipsUnchangedText(t *testing.T) {
|
||||
db := NewTestDB(t)
|
||||
|
||||
seedExploreRow(t, db, "mbid-1", "Unchanged Title", "Steady Artist")
|
||||
|
||||
before := ftsSegmentCount(t, db)
|
||||
|
||||
// A popularity refresh: an FTS column is not named at all.
|
||||
if _, err := db.ExecContext(
|
||||
"UPDATE explore_index SET popularity = 42 WHERE title = ?",
|
||||
"Unchanged Title",
|
||||
); err != nil {
|
||||
t.Fatalf("popularity update: %v", err)
|
||||
}
|
||||
|
||||
// An upsert-shaped write that re-states the text identically, which
|
||||
// is what the merge rules produce for a row that has not changed.
|
||||
if _, err := db.ExecContext(`
|
||||
UPDATE explore_index
|
||||
SET title = 'Unchanged Title', artist_name = 'Steady Artist', popularity = 43
|
||||
WHERE title = ?
|
||||
`, "Unchanged Title"); err != nil {
|
||||
t.Fatalf("no-op text update: %v", err)
|
||||
}
|
||||
|
||||
if got := ftsSegmentCount(t, db); got != before {
|
||||
t.Errorf(
|
||||
"FTS index written by an update that changed no text: %d rows, want %d",
|
||||
got, before,
|
||||
)
|
||||
}
|
||||
|
||||
if got := ftsMatches(t, db, "Unchanged"); got != 1 {
|
||||
t.Errorf("matches after unchanged updates = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The other half of the same guard: a real rename still re-indexes,
|
||||
// old term gone and new term found.
|
||||
func TestExploreFTSUpdateReindexesChangedText(t *testing.T) {
|
||||
db := NewTestDB(t)
|
||||
|
||||
seedExploreRow(t, db, "mbid-2", "Original Title", "Some Artist")
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
"UPDATE explore_index SET title = 'Corrected Title' WHERE title = ?",
|
||||
"Original Title",
|
||||
); err != nil {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
|
||||
if got := ftsMatches(t, db, "Original"); got != 0 {
|
||||
t.Errorf("matches for the old title = %d, want 0", got)
|
||||
}
|
||||
|
||||
if got := ftsMatches(t, db, "Corrected"); got != 1 {
|
||||
t.Errorf("matches for the new title = %d, want 1", got)
|
||||
}
|
||||
|
||||
// The same for the other two indexed columns.
|
||||
if _, err := db.ExecContext(
|
||||
"UPDATE explore_index SET artist_name = 'Renamed Artist', aliases = 'AKA Thing' WHERE title = ?",
|
||||
"Corrected Title",
|
||||
); err != nil {
|
||||
t.Fatalf("artist rename: %v", err)
|
||||
}
|
||||
|
||||
if got := ftsMatches(t, db, "Renamed"); got != 1 {
|
||||
t.Errorf("matches for the new artist = %d, want 1", got)
|
||||
}
|
||||
|
||||
if got := ftsMatches(t, db, "AKA"); got != 1 {
|
||||
t.Errorf("matches for the new alias = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An existing install already carries the previous, unguarded trigger,
|
||||
// and a create that tolerated "already exists" would leave it there
|
||||
// forever — so the definition has to be replaced on open, not merely
|
||||
// offered.
|
||||
func TestExploreFTSTriggersAreReplacedOnOpen(t *testing.T) {
|
||||
db := NewTestDB(t)
|
||||
|
||||
if err := db.SuspendExploreIndexFTS(); err != nil {
|
||||
t.Fatalf("suspend: %v", err)
|
||||
}
|
||||
|
||||
// The shape that shipped before: fires on every UPDATE.
|
||||
if _, err := db.ExecContext(`
|
||||
CREATE TRIGGER explore_index_au AFTER UPDATE ON explore_index BEGIN
|
||||
INSERT INTO explore_index_fts(explore_index_fts, rowid, title, artist_name, aliases)
|
||||
VALUES ('delete', old.id, old.title, old.artist_name, old.aliases);
|
||||
INSERT INTO explore_index_fts(rowid, title, artist_name, aliases)
|
||||
VALUES (new.id, new.title, new.artist_name, new.aliases);
|
||||
END
|
||||
`); err != nil {
|
||||
t.Fatalf("install old trigger: %v", err)
|
||||
}
|
||||
|
||||
if err := createExploreIndexFTSTriggers(db.Ctx, db.db); err != nil {
|
||||
t.Fatalf("recreate triggers: %v", err)
|
||||
}
|
||||
|
||||
rows, err := db.QueryContext(
|
||||
"SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = 'explore_index_au'",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("read trigger sql: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
definition := ""
|
||||
|
||||
if rows.Next() {
|
||||
if err := rows.Scan(&definition); err != nil {
|
||||
t.Fatalf("scan trigger sql: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !strings.Contains(definition, "UPDATE OF") ||
|
||||
!strings.Contains(definition, "WHEN") {
|
||||
t.Errorf("explore_index_au was not replaced; definition is:\n%s", definition)
|
||||
}
|
||||
}
|
||||
+108
-111
@@ -1,15 +1,26 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// toNullString treats an empty string as NULL.
|
||||
func toNullString(v string) sql.NullString {
|
||||
if v == "" {
|
||||
return sql.NullString{}
|
||||
}
|
||||
|
||||
return sql.NullString{String: v, Valid: true}
|
||||
}
|
||||
|
||||
// LyricsHit is a single result from a lyric-fragment search: the
|
||||
// matched recording plus enough metadata to render and play it.
|
||||
// matched file plus enough metadata to render and play it.
|
||||
type LyricsHit struct {
|
||||
RecordingID int64
|
||||
AudioFileID int64
|
||||
FilePath string
|
||||
LengthMilliseconds int64
|
||||
Title string
|
||||
@@ -37,27 +48,22 @@ func (d *DB) SearchLyrics(query string, limit int) ([]LyricsHit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Map the matched recording (lyrics_index.rowid == recordings.id)
|
||||
// to a representative playable file via the lowest audio_files id,
|
||||
// then to the track_metadata VIEW for display fields.
|
||||
// lyrics_index.rowid is the audio file's id, so the hit is already
|
||||
// a playable file - it used to be a recording id, which then had to
|
||||
// be mapped back to "some file of that recording" by a grouped
|
||||
// subquery.
|
||||
//
|
||||
// SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation.
|
||||
rows, err := d.db.QueryContext(d.Ctx, `
|
||||
rows, err := d.reader().QueryContext(d.Ctx, `
|
||||
SELECT
|
||||
r.id,
|
||||
tm.id,
|
||||
tm.file_path,
|
||||
tm.length_milliseconds,
|
||||
tm.title,
|
||||
tm.artist_name,
|
||||
tm.album
|
||||
FROM lyrics_index li
|
||||
JOIN recordings r ON r.id = li.rowid
|
||||
JOIN (
|
||||
SELECT recording_id, MIN(id) AS af_id
|
||||
FROM audio_files
|
||||
GROUP BY recording_id
|
||||
) af ON af.recording_id = r.id
|
||||
JOIN track_metadata tm ON tm.id = af.af_id
|
||||
JOIN track_metadata tm ON tm.id = li.rowid
|
||||
WHERE lyrics_index MATCH ?
|
||||
ORDER BY rank
|
||||
LIMIT ?
|
||||
@@ -73,7 +79,7 @@ func (d *DB) SearchLyrics(query string, limit int) ([]LyricsHit, error) {
|
||||
for rows.Next() {
|
||||
var h LyricsHit
|
||||
if err := rows.Scan(
|
||||
&h.RecordingID,
|
||||
&h.AudioFileID,
|
||||
&h.FilePath,
|
||||
&h.LengthMilliseconds,
|
||||
&h.Title,
|
||||
@@ -93,43 +99,64 @@ func (d *DB) SearchLyrics(query string, limit int) ([]LyricsHit, error) {
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetRecordingLyrics returns the stored lyrics for a recording, or
|
||||
// an empty string if none are stored.
|
||||
func (d *DB) GetRecordingLyrics(recordingID int64) (string, error) {
|
||||
// GetLyrics returns the stored lyrics for a file, or "" if none.
|
||||
func (d *DB) GetLyrics(audioFileID int64) (string, error) {
|
||||
var lyrics string
|
||||
|
||||
err := d.db.QueryRowContext(d.Ctx,
|
||||
"SELECT COALESCE(lyrics, '') FROM recordings WHERE id = ?",
|
||||
recordingID,
|
||||
err := d.reader().QueryRowContext(d.Ctx,
|
||||
"SELECT text FROM lyrics WHERE audio_file_id = ?", audioFileID,
|
||||
).Scan(&lyrics)
|
||||
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not read recording lyrics: %w", err)
|
||||
return "", fmt.Errorf("could not read lyrics: %w", err)
|
||||
}
|
||||
|
||||
return lyrics, nil
|
||||
}
|
||||
|
||||
// SetRecordingLyrics writes lyrics onto a recording and keeps the FTS
|
||||
// lyrics_index in sync (delete + reinsert the single row). Used by
|
||||
// the LRCLIB backfill to persist fetched lyrics. Passing an empty
|
||||
// string clears both the column and the index entry.
|
||||
func (d *DB) SetRecordingLyrics(recordingID int64, lyrics string) error {
|
||||
if _, err := d.db.ExecContext(d.Ctx,
|
||||
"UPDATE recordings SET lyrics = ? WHERE id = ?",
|
||||
lyrics, recordingID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("could not update recording lyrics: %w", err)
|
||||
// SetLyrics writes lyrics for a file and keeps the FTS index in sync.
|
||||
//
|
||||
// `source` says where they came from, which is the question the old
|
||||
// column could not answer: lyrics read from a USLT frame are rebuilt
|
||||
// free by any rescan, and lyrics fetched from LRCLIB are network
|
||||
// traffic nobody wants to repeat. Passing an empty string clears both
|
||||
// the row and the index entry.
|
||||
func (d *DB) SetLyrics(audioFileID int64, lyrics, source, recordingMBID string) error {
|
||||
if strings.TrimSpace(lyrics) == "" {
|
||||
if _, err := d.db.ExecContext(d.Ctx,
|
||||
"DELETE FROM lyrics WHERE audio_file_id = ?", audioFileID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("could not delete lyrics: %w", err)
|
||||
}
|
||||
|
||||
return d.upsertLyricsIndex(audioFileID, "")
|
||||
}
|
||||
|
||||
return d.upsertLyricsIndex(recordingID, lyrics)
|
||||
if _, err := d.db.ExecContext(d.Ctx, `
|
||||
INSERT INTO lyrics (audio_file_id, text, source, recording_mbid)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(audio_file_id) DO UPDATE SET
|
||||
text = excluded.text,
|
||||
source = excluded.source,
|
||||
recording_mbid = COALESCE(excluded.recording_mbid, lyrics.recording_mbid),
|
||||
fetched_at = CURRENT_TIMESTAMP
|
||||
`, audioFileID, lyrics, source, toNullString(recordingMBID)); err != nil {
|
||||
return fmt.Errorf("could not write lyrics: %w", err)
|
||||
}
|
||||
|
||||
return d.upsertLyricsIndex(audioFileID, lyrics)
|
||||
}
|
||||
|
||||
// upsertLyricsIndex refreshes a single recording's entry in the
|
||||
// contentless lyrics_index. contentless_delete=1 makes the DELETE
|
||||
// valid; an empty lyrics string leaves the row deleted.
|
||||
func (d *DB) upsertLyricsIndex(recordingID int64, lyrics string) error {
|
||||
// upsertLyricsIndex refreshes a single file's entry in the contentless
|
||||
// lyrics_index. contentless_delete=1 makes the DELETE valid; an empty
|
||||
// lyrics string leaves the row deleted.
|
||||
func (d *DB) upsertLyricsIndex(audioFileID int64, lyrics string) error {
|
||||
if _, err := d.db.ExecContext(d.Ctx,
|
||||
"DELETE FROM lyrics_index WHERE rowid = ?", recordingID,
|
||||
"DELETE FROM lyrics_index WHERE rowid = ?", audioFileID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("could not delete lyrics_index row: %w", err)
|
||||
}
|
||||
@@ -141,7 +168,7 @@ func (d *DB) upsertLyricsIndex(recordingID int64, lyrics string) error {
|
||||
// SAFETY: FTS5 virtual table INSERT unsupported by sqlc. All values parameterized.
|
||||
if _, err := d.db.ExecContext(d.Ctx,
|
||||
"INSERT INTO lyrics_index(rowid, lyrics) VALUES (?, ?)",
|
||||
recordingID, lyrics,
|
||||
audioFileID, lyrics,
|
||||
); err != nil {
|
||||
return fmt.Errorf("could not insert lyrics_index row: %w", err)
|
||||
}
|
||||
@@ -149,22 +176,16 @@ func (d *DB) upsertLyricsIndex(recordingID int64, lyrics string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RebuildLyricsIndex repopulates lyrics_index from scratch using the
|
||||
// current recordings table. Cheap for a personal library and safe to
|
||||
// run after every scan.
|
||||
// RebuildLyricsIndex repopulates lyrics_index from the lyrics table.
|
||||
func (d *DB) RebuildLyricsIndex() error {
|
||||
if _, err := d.db.ExecContext(d.Ctx,
|
||||
"DELETE FROM lyrics_index",
|
||||
); err != nil {
|
||||
if _, err := d.db.ExecContext(d.Ctx, "DELETE FROM lyrics_index"); err != nil {
|
||||
return fmt.Errorf("could not clear lyrics_index: %w", err)
|
||||
}
|
||||
|
||||
// SAFETY: FTS5 virtual table INSERT unsupported by sqlc. Values sourced from recordings; no user input.
|
||||
// SAFETY: FTS5 virtual table INSERT. Values sourced from lyrics; no user input.
|
||||
if _, err := d.db.ExecContext(d.Ctx, `
|
||||
INSERT INTO lyrics_index(rowid, lyrics)
|
||||
SELECT id, lyrics
|
||||
FROM recordings
|
||||
WHERE lyrics IS NOT NULL AND lyrics != ''
|
||||
SELECT audio_file_id, text FROM lyrics WHERE text != ''
|
||||
`); err != nil {
|
||||
return fmt.Errorf("could not rebuild lyrics_index: %w", err)
|
||||
}
|
||||
@@ -172,39 +193,35 @@ func (d *DB) RebuildLyricsIndex() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordingsMissingLyrics returns recordings that have no stored
|
||||
// lyrics but do carry the artist/title/duration needed to look them
|
||||
// up from an external provider. Used by the LRCLIB backfill. The
|
||||
// limit bounds each batch so the backfill can be run incrementally.
|
||||
func (d *DB) RecordingsMissingLyrics(limit int) ([]LyricsCandidate, error) {
|
||||
// LyricsCandidate identifies a file that needs its lyrics fetched and
|
||||
// carries the fields an external provider matches on.
|
||||
type LyricsCandidate struct {
|
||||
AudioFileID int64
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
RecordingMBID string
|
||||
LengthMilliseconds int64
|
||||
}
|
||||
|
||||
// FilesMissingLyrics returns files with no stored lyrics that carry
|
||||
// the artist/title/duration needed to look them up. Used by the
|
||||
// LRCLIB backfill; the limit bounds each batch.
|
||||
func (d *DB) FilesMissingLyrics(limit int) ([]LyricsCandidate, error) {
|
||||
if limit <= 0 {
|
||||
limit = 200
|
||||
}
|
||||
|
||||
rows, err := d.db.QueryContext(d.Ctx, `
|
||||
SELECT
|
||||
r.id,
|
||||
COALESCE(r.name, ''),
|
||||
COALESCE(ac.text, ''),
|
||||
COALESCE(rg.name, ''),
|
||||
MIN(af.length_milliseconds)
|
||||
FROM recordings r
|
||||
JOIN audio_files af ON af.recording_id = r.id
|
||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN (
|
||||
SELECT recording_id, MIN(release_group_id) AS release_group_id
|
||||
FROM release_group_recordings
|
||||
GROUP BY recording_id
|
||||
) rgr ON rgr.recording_id = r.id
|
||||
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
|
||||
WHERE (r.lyrics IS NULL OR r.lyrics = '')
|
||||
AND r.name IS NOT NULL AND r.name != ''
|
||||
AND ac.text IS NOT NULL AND ac.text != ''
|
||||
GROUP BY r.id
|
||||
rows, err := d.reader().QueryContext(d.Ctx, `
|
||||
SELECT tm.id, tm.title, tm.artist_name, tm.album,
|
||||
tm.recording_mbid, tm.length_milliseconds
|
||||
FROM track_metadata tm
|
||||
WHERE NOT EXISTS (SELECT 1 FROM lyrics l WHERE l.audio_file_id = tm.id)
|
||||
AND tm.title != '' AND tm.artist_name != ''
|
||||
LIMIT ?
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not query recordings missing lyrics: %w", err)
|
||||
return nil, fmt.Errorf("could not query files missing lyrics: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
@@ -214,7 +231,8 @@ func (d *DB) RecordingsMissingLyrics(limit int) ([]LyricsCandidate, error) {
|
||||
for rows.Next() {
|
||||
var c LyricsCandidate
|
||||
if err := rows.Scan(
|
||||
&c.RecordingID, &c.Title, &c.Artist, &c.Album, &c.LengthMilliseconds,
|
||||
&c.AudioFileID, &c.Title, &c.Artist, &c.Album,
|
||||
&c.RecordingMBID, &c.LengthMilliseconds,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("could not scan lyrics candidate: %w", err)
|
||||
}
|
||||
@@ -229,44 +247,23 @@ func (d *DB) RecordingsMissingLyrics(limit int) ([]LyricsCandidate, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LyricsCandidate identifies a recording that needs its lyrics fetched
|
||||
// and carries the fields an external provider matches on.
|
||||
type LyricsCandidate struct {
|
||||
RecordingID int64
|
||||
Title string
|
||||
Artist string
|
||||
Album string
|
||||
LengthMilliseconds int64
|
||||
}
|
||||
|
||||
// RecordingLyricLookup returns the provider-match fields (artist,
|
||||
// title, album, duration) for a single recording, so lyrics can be
|
||||
// fetched on demand. Returns nil if the recording has no audio file
|
||||
// or no artist/title to match on.
|
||||
func (d *DB) RecordingLyricLookup(recordingID int64) (*LyricsCandidate, error) {
|
||||
// FileLyricLookup returns the provider-match fields for one file, so
|
||||
// lyrics can be fetched on demand. Returns nil if the file has no
|
||||
// artist/title to match on.
|
||||
func (d *DB) FileLyricLookup(audioFileID int64) (*LyricsCandidate, error) {
|
||||
var c LyricsCandidate
|
||||
|
||||
err := d.db.QueryRowContext(d.Ctx, `
|
||||
SELECT
|
||||
r.id,
|
||||
COALESCE(r.name, ''),
|
||||
COALESCE(ac.text, ''),
|
||||
COALESCE(rg.name, ''),
|
||||
COALESCE(MIN(af.length_milliseconds), 0)
|
||||
FROM recordings r
|
||||
JOIN audio_files af ON af.recording_id = r.id
|
||||
LEFT JOIN artist_credit ac ON r.artist_credit_id = ac.id
|
||||
LEFT JOIN (
|
||||
SELECT recording_id, MIN(release_group_id) AS release_group_id
|
||||
FROM release_group_recordings
|
||||
GROUP BY recording_id
|
||||
) rgr ON rgr.recording_id = r.id
|
||||
LEFT JOIN release_groups rg ON rg.id = rgr.release_group_id
|
||||
WHERE r.id = ?
|
||||
GROUP BY r.id
|
||||
`, recordingID).Scan(&c.RecordingID, &c.Title, &c.Artist, &c.Album, &c.LengthMilliseconds)
|
||||
err := d.reader().QueryRowContext(d.Ctx, `
|
||||
SELECT tm.id, tm.title, tm.artist_name, tm.album,
|
||||
tm.recording_mbid, tm.length_milliseconds
|
||||
FROM track_metadata tm
|
||||
WHERE tm.id = ?
|
||||
`, audioFileID).Scan(
|
||||
&c.AudioFileID, &c.Title, &c.Artist, &c.Album,
|
||||
&c.RecordingMBID, &c.LengthMilliseconds,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not look up recording for lyrics: %w", err)
|
||||
return nil, fmt.Errorf("could not look up file for lyrics: %w", err)
|
||||
}
|
||||
|
||||
if c.Title == "" || c.Artist == "" {
|
||||
|
||||
@@ -4,59 +4,33 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// seedLyricsTrack inserts the minimal FK chain (artist_credit →
|
||||
// recording → audio_file → release_group link) for one track with the
|
||||
// given lyrics, so lyric-search tests have realistic joins.
|
||||
// seedLyricsTrack inserts one file with the given lyrics, so lyric
|
||||
// searches have something realistic to join against. It used to
|
||||
// insert a four-row FK chain by hand.
|
||||
func seedLyricsTrack(
|
||||
t *testing.T,
|
||||
db *DB,
|
||||
id int64,
|
||||
title, artist, album, lyrics string,
|
||||
lenMs int64,
|
||||
) {
|
||||
) int64 {
|
||||
t.Helper()
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
"INSERT OR IGNORE INTO artist_credit (id, text) VALUES (?, ?)", id, artist,
|
||||
); err != nil {
|
||||
t.Fatalf("insert artist_credit: %v", err)
|
||||
fileID := InsertTestTrack(t, db, TestTrack{
|
||||
FilePath: "/music/track" + itoa(id) + ".mp3",
|
||||
Title: title,
|
||||
Artist: artist,
|
||||
Album: album,
|
||||
LengthMs: lenMs,
|
||||
})
|
||||
|
||||
if lyrics != "" {
|
||||
if err := db.SetLyrics(fileID, lyrics, "tag", ""); err != nil {
|
||||
t.Fatalf("seed lyrics: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
"INSERT OR IGNORE INTO release_groups (id, name) VALUES (?, ?)", id, album,
|
||||
); err != nil {
|
||||
t.Fatalf("insert release_group: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
"INSERT INTO recordings (id, name, artist_credit_id, lyrics) VALUES (?, ?, ?, ?)",
|
||||
id, title, id, nullableLyrics(lyrics),
|
||||
); err != nil {
|
||||
t.Fatalf("insert recording: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
"INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) "+
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
id, "/music/track"+itoa(id)+".mp3", lenMs, 0, id,
|
||||
); err != nil {
|
||||
t.Fatalf("insert audio_file: %v", err)
|
||||
}
|
||||
|
||||
if _, err := db.ExecContext(
|
||||
"INSERT INTO release_group_recordings (release_group_id, recording_id) VALUES (?, ?)",
|
||||
id, id,
|
||||
); err != nil {
|
||||
t.Fatalf("insert release_group_recordings: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func nullableLyrics(l string) any {
|
||||
if l == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return l
|
||||
return fileID
|
||||
}
|
||||
|
||||
func itoa(v int64) string {
|
||||
@@ -111,8 +85,8 @@ func TestSearchLyrics(t *testing.T) {
|
||||
}
|
||||
|
||||
h := hits[0]
|
||||
if h.RecordingID != 1 {
|
||||
t.Errorf("RecordingID = %d, want 1", h.RecordingID)
|
||||
if h.AudioFileID != 1 {
|
||||
t.Errorf("RecordingID = %d, want 1", h.AudioFileID)
|
||||
}
|
||||
|
||||
if h.Title != "The Sound of Silence" {
|
||||
@@ -191,11 +165,11 @@ func TestSetRecordingLyricsUpdatesIndex(t *testing.T) {
|
||||
|
||||
// Backfill lyrics — should update both the column and the FTS index.
|
||||
const lyrics = "Yesterday all my troubles seemed so far away"
|
||||
if err := db.SetRecordingLyrics(1, lyrics); err != nil {
|
||||
if err := db.SetLyrics(1, lyrics, "lrclib", ""); err != nil {
|
||||
t.Fatalf("SetRecordingLyrics: %v", err)
|
||||
}
|
||||
|
||||
stored, err := db.GetRecordingLyrics(1)
|
||||
stored, err := db.GetLyrics(1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRecordingLyrics: %v", err)
|
||||
}
|
||||
@@ -209,7 +183,7 @@ func TestSetRecordingLyricsUpdatesIndex(t *testing.T) {
|
||||
t.Fatalf("SearchLyrics: %v", err)
|
||||
}
|
||||
|
||||
if len(hits) != 1 || hits[0].RecordingID != 1 {
|
||||
if len(hits) != 1 || hits[0].AudioFileID != 1 {
|
||||
t.Fatalf("expected recording 1 after backfill, got %+v", hits)
|
||||
}
|
||||
}
|
||||
@@ -222,7 +196,7 @@ func TestRecordingsMissingLyrics(t *testing.T) {
|
||||
seedLyricsTrack(t, db, 1, "Has Lyrics", "Artist A", "Album A", "some words here", 100000)
|
||||
seedLyricsTrack(t, db, 2, "No Lyrics", "Artist B", "Album B", "", 200000)
|
||||
|
||||
missing, err := db.RecordingsMissingLyrics(50)
|
||||
missing, err := db.FilesMissingLyrics(50)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordingsMissingLyrics: %v", err)
|
||||
}
|
||||
@@ -232,7 +206,7 @@ func TestRecordingsMissingLyrics(t *testing.T) {
|
||||
}
|
||||
|
||||
c := missing[0]
|
||||
if c.RecordingID != 2 || c.Title != "No Lyrics" || c.Artist != "Artist B" {
|
||||
if c.AudioFileID != 2 || c.Title != "No Lyrics" || c.Artist != "Artist B" {
|
||||
t.Errorf("unexpected candidate: %+v", c)
|
||||
}
|
||||
|
||||
@@ -241,7 +215,7 @@ func TestRecordingsMissingLyrics(t *testing.T) {
|
||||
}
|
||||
|
||||
// Single-recording lookup mirrors the batch fields.
|
||||
one, err := db.RecordingLyricLookup(2)
|
||||
one, err := db.FileLyricLookup(2)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordingLyricLookup: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestOneRowPerTrackForAMultiArtistCredit pins what is left of the
|
||||
// multi-artist problem, which is now much smaller than it was.
|
||||
//
|
||||
// It used to be possible for one file to produce several rows: an
|
||||
// artist credit was a row in its own table linking *many* artists, so
|
||||
// any query that joined artist_credit_artist to read the artist MBID
|
||||
// returned the same track once per credited artist. The playlist, the
|
||||
// queue, the library list and the phantom resolver all did, and all
|
||||
// showed collaborations twice. Nine queries carried a
|
||||
// first-credited-artist subquery to work around it.
|
||||
//
|
||||
// The join is gone: a file carries its credit as text and points at one
|
||||
// primary artist, so the fan-out has nothing to fan out from. What is
|
||||
// still worth pinning is that the credit text survives intact - a
|
||||
// collaboration must still *read* as one - and that the file resolves
|
||||
// to exactly one row wherever it is asked for.
|
||||
func TestOneRowPerTrackForAMultiArtistCredit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := NewTestDB(t)
|
||||
|
||||
id := InsertTestTrack(t, db, TestTrack{
|
||||
FilePath: "/lib/collab.mp3",
|
||||
Title: "Collab Song",
|
||||
Artist: "A feat. B",
|
||||
ArtistMBID: "mbid-a",
|
||||
Album: "An Album",
|
||||
LengthMs: 200000,
|
||||
})
|
||||
|
||||
t.Run("one row in the view", func(t *testing.T) {
|
||||
var n int
|
||||
if err := db.QueryRowWriter(
|
||||
`SELECT COUNT(*) FROM track_metadata WHERE id = ?`, id,
|
||||
).Scan(&n); err != nil {
|
||||
t.Fatalf("count: %v", err)
|
||||
}
|
||||
|
||||
if n != 1 {
|
||||
t.Errorf("track_metadata rows = %d, want 1", n)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the credit is preserved and the artist resolved", func(t *testing.T) {
|
||||
rows, err := db.Queries.GetTracks(db.Ctx, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("get tracks: %v", err)
|
||||
}
|
||||
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("tracks = %d, want 1", len(rows))
|
||||
}
|
||||
|
||||
if rows[0].ArtistName != "A feat. B" {
|
||||
t.Errorf("artist credit = %q, want %q", rows[0].ArtistName, "A feat. B")
|
||||
}
|
||||
|
||||
if rows[0].ArtistMbid != "mbid-a" {
|
||||
t.Errorf("artist mbid = %q, want %q", rows[0].ArtistMbid, "mbid-a")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("one row per album track", func(t *testing.T) {
|
||||
var albumID int64
|
||||
if err := db.QueryRowWriter(
|
||||
`SELECT album_id FROM audio_files WHERE id = ?`, id,
|
||||
).Scan(&albumID); err != nil {
|
||||
t.Fatalf("album id: %v", err)
|
||||
}
|
||||
|
||||
rows, err := db.Queries.GetTracks(db.Ctx, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("album tracks: %v", err)
|
||||
}
|
||||
|
||||
if len(rows) != 1 {
|
||||
t.Errorf("album tracks = %d, want 1", len(rows))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// writeVerb matches the first SQL keyword of a statement that mutates.
|
||||
// Anchored to the start of the trimmed line, because a subquery or a
|
||||
// column named "update" is not a write.
|
||||
var writeVerb = regexp.MustCompile(
|
||||
`^\s*` + "`" + `?\s*(?i:INSERT|UPDATE|DELETE|REPLACE|CREATE|DROP|ALTER)\s`,
|
||||
)
|
||||
|
||||
// queryCall matches a call to one of the read-pool helpers. These
|
||||
// route to DB.reader(), which in a real app is a second sql.DB opened
|
||||
// query-only over the same file.
|
||||
var queryCall = regexp.MustCompile(
|
||||
`\.Query(?:Context|ContextWith|Row)\s*\(`,
|
||||
)
|
||||
|
||||
// TestNoWritesOnTheReadPool fails if a mutating statement is issued
|
||||
// through one of the query-only read helpers.
|
||||
//
|
||||
// This is worth a test of its own because the failure mode is invisible
|
||||
// to every other tier. `CreateSmartPlaylist` ran an
|
||||
// `INSERT ... RETURNING` through `QueryContext` — a write wearing a
|
||||
// query's shape — and failed at runtime with "attempt to write a
|
||||
// readonly database (8)", i.e. no smart playlist could be created at
|
||||
// all. Nothing caught it: `NewTestDB` shares one in-memory connection
|
||||
// and sets `readDB` to nil, so `reader()` returns the *writer* there
|
||||
// and every unit test of that path passed against a handle the app does
|
||||
// not have.
|
||||
//
|
||||
// A text walk rather than a lint rule, for the same reason as
|
||||
// TestNoDirectRuntimeEmits: golangci-lint runs once per build
|
||||
// configuration and would not see a call in a tagged file.
|
||||
func TestNoWritesOnTheReadPool(t *testing.T) {
|
||||
root := filepath.Join("..", "..")
|
||||
|
||||
skipDirs := map[string]bool{
|
||||
".git": true,
|
||||
"node_modules": true,
|
||||
"frontend": true,
|
||||
"build": true,
|
||||
".dev": true,
|
||||
}
|
||||
|
||||
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
if skipDirs[d.Name()] {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if filepath.Ext(path) != ".go" ||
|
||||
strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
|
||||
rel, relErr := filepath.Rel(root, path)
|
||||
if relErr != nil {
|
||||
return relErr
|
||||
}
|
||||
|
||||
src, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
|
||||
lines := strings.Split(string(src), "\n")
|
||||
|
||||
for i, line := range lines {
|
||||
if !queryCall.MatchString(line) ||
|
||||
strings.Contains(line, "QueryRowWriter") {
|
||||
continue
|
||||
}
|
||||
|
||||
// The statement is usually on the following line, in a raw
|
||||
// string literal. Look a little way ahead rather than only
|
||||
// at the call itself.
|
||||
for j := i; j < min(i+3, len(lines)); j++ {
|
||||
if writeVerb.MatchString(lines[j]) {
|
||||
t.Errorf(
|
||||
"%s:%d issues a write through a read-pool helper; "+
|
||||
"use ExecContext or QueryRowWriter\n\t%s",
|
||||
rel, j+1, strings.TrimSpace(lines[j]),
|
||||
)
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("walking %s: %v", root, err)
|
||||
}
|
||||
}
|
||||
+55
-176
@@ -5,6 +5,8 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"yellowjacket/backend/database/sql/sqlcgen"
|
||||
)
|
||||
|
||||
// SearchRow holds a single result from an FTS5 or basename search.
|
||||
@@ -183,203 +185,80 @@ func (d *DB) RebuildSearchIndex() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SearchTrackRow holds a full track result from an FTS5 search,
|
||||
// matching all 16 columns returned by GetAllTracksWithFullMetadata.
|
||||
type SearchTrackRow struct {
|
||||
FilePath string
|
||||
LengthMilliseconds int64
|
||||
Title string
|
||||
ArtistName string
|
||||
TrackNumber sql.NullInt64
|
||||
DiscNumber sql.NullInt64
|
||||
Album string
|
||||
Genre string
|
||||
Year int64
|
||||
Composer string
|
||||
FileType string
|
||||
SampleRate int64
|
||||
BitDepth int64
|
||||
Channels int64
|
||||
Bitrate int64
|
||||
FileSize int64
|
||||
// trackMetadataColumns is the column list of the track_metadata view,
|
||||
// in the order sqlc generates TrackMetadatum's fields. The FTS
|
||||
// searches below cannot be sqlc queries (MATCH is not in its grammar),
|
||||
// so this is the one place the view's shape is written out by hand.
|
||||
const trackMetadataColumns = `
|
||||
tm.id, tm.file_path, tm.length_milliseconds, tm.title, tm.artist_name,
|
||||
tm.track_number, tm.disc_number, tm.album, tm.genre, tm.year,
|
||||
tm.release_year, tm.composer, tm.file_type, tm.sample_rate,
|
||||
tm.bit_depth, tm.channels, tm.bitrate, tm.file_size, tm.library_id,
|
||||
tm.play_count, tm.last_played, tm.cover_art_path, tm.artist_mbid,
|
||||
tm.release_group_mbid, tm.recording_mbid, tm.album_id, tm.artist_id`
|
||||
|
||||
// scanTrackMetadata reads track_metadata rows into the generated row
|
||||
// type, so an FTS hit and an ordinary query produce the same Track.
|
||||
func scanTrackMetadata(rows *sql.Rows) ([]sqlcgen.TrackMetadatum, error) {
|
||||
var out []sqlcgen.TrackMetadatum
|
||||
|
||||
for rows.Next() {
|
||||
var r sqlcgen.TrackMetadatum
|
||||
|
||||
if err := rows.Scan(
|
||||
&r.ID, &r.FilePath, &r.LengthMilliseconds, &r.Title, &r.ArtistName,
|
||||
&r.TrackNumber, &r.DiscNumber, &r.Album, &r.Genre, &r.Year,
|
||||
&r.ReleaseYear, &r.Composer, &r.FileType, &r.SampleRate,
|
||||
&r.BitDepth, &r.Channels, &r.Bitrate, &r.FileSize, &r.LibraryID,
|
||||
&r.PlayCount, &r.LastPlayed, &r.CoverArtPath, &r.ArtistMbid,
|
||||
&r.ReleaseGroupMbid, &r.RecordingMbid, &r.AlbumID, &r.ArtistID,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scan track metadata: %w", err)
|
||||
}
|
||||
|
||||
out = append(out, r)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterate track metadata: %w", err)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SearchFTSTracks performs a full-text search and returns full track
|
||||
// metadata for each match. Unlike SearchFTS (which returns only 5
|
||||
// columns), this includes all 16 fields needed for library.Track.
|
||||
// SearchFTSTracks performs a full-text search and returns whole tracks.
|
||||
//
|
||||
// A library id of 0 means every library. There were two of these, one
|
||||
// per case, each with its own copy of a sixteen-column projection that
|
||||
// silently dropped the MBIDs and the play count - which is why the
|
||||
// caller used to pass zeros for them.
|
||||
func (d *DB) SearchFTSTracks(
|
||||
query string, limit int,
|
||||
) ([]SearchTrackRow, error) {
|
||||
query string, libraryID int64, limit int,
|
||||
) ([]sqlcgen.TrackMetadatum, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ftsQuery := buildFTSQuery(query)
|
||||
|
||||
// SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation.
|
||||
rows, err := d.db.QueryContext(d.Ctx, `
|
||||
SELECT
|
||||
tm.file_path,
|
||||
tm.length_milliseconds,
|
||||
tm.title,
|
||||
tm.artist_name,
|
||||
tm.track_number,
|
||||
tm.disc_number,
|
||||
tm.album,
|
||||
tm.genre,
|
||||
tm.year,
|
||||
tm.composer,
|
||||
tm.file_type,
|
||||
tm.sample_rate,
|
||||
tm.bit_depth,
|
||||
tm.channels,
|
||||
tm.bitrate,
|
||||
tm.file_size
|
||||
rows, err := d.reader().QueryContext(d.Ctx, `
|
||||
SELECT`+trackMetadataColumns+`
|
||||
FROM search_index si
|
||||
JOIN track_metadata tm ON tm.id = si.rowid
|
||||
WHERE search_index MATCH ?
|
||||
AND (? = 0 OR tm.library_id = ?)
|
||||
ORDER BY rank
|
||||
LIMIT ?
|
||||
`, ftsQuery, limit)
|
||||
`, buildFTSQuery(query), libraryID, libraryID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"FTS track search failed: %w", err,
|
||||
)
|
||||
return nil, fmt.Errorf("FTS track search failed: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var results []SearchTrackRow
|
||||
|
||||
for rows.Next() {
|
||||
var r SearchTrackRow
|
||||
|
||||
if err := rows.Scan(
|
||||
&r.FilePath,
|
||||
&r.LengthMilliseconds,
|
||||
&r.Title,
|
||||
&r.ArtistName,
|
||||
&r.TrackNumber,
|
||||
&r.DiscNumber,
|
||||
&r.Album,
|
||||
&r.Genre,
|
||||
&r.Year,
|
||||
&r.Composer,
|
||||
&r.FileType,
|
||||
&r.SampleRate,
|
||||
&r.BitDepth,
|
||||
&r.Channels,
|
||||
&r.Bitrate,
|
||||
&r.FileSize,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"could not scan search track row: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
results = append(results, r)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"search track row iteration error: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
return scanTrackMetadata(rows)
|
||||
}
|
||||
|
||||
// SearchFTSTracksByLibrary performs a full-text search scoped to a
|
||||
// specific library and returns full track metadata for each match.
|
||||
func (d *DB) SearchFTSTracksByLibrary(
|
||||
query string, limit int, libraryID int64,
|
||||
) ([]SearchTrackRow, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
ftsQuery := buildFTSQuery(query)
|
||||
|
||||
// SAFETY: FTS5 MATCH syntax unsupported by sqlc. Query is parameterized; no string interpolation.
|
||||
rows, err := d.db.QueryContext(d.Ctx, `
|
||||
SELECT
|
||||
tm.file_path,
|
||||
tm.length_milliseconds,
|
||||
tm.title,
|
||||
tm.artist_name,
|
||||
tm.track_number,
|
||||
tm.disc_number,
|
||||
tm.album,
|
||||
tm.genre,
|
||||
tm.year,
|
||||
tm.composer,
|
||||
tm.file_type,
|
||||
tm.sample_rate,
|
||||
tm.bit_depth,
|
||||
tm.channels,
|
||||
tm.bitrate,
|
||||
tm.file_size
|
||||
FROM search_index si
|
||||
JOIN track_metadata tm ON tm.id = si.rowid
|
||||
WHERE search_index MATCH ? AND tm.library_id = ?
|
||||
ORDER BY rank
|
||||
LIMIT ?
|
||||
`, ftsQuery, libraryID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"FTS library track search failed: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var results []SearchTrackRow
|
||||
|
||||
for rows.Next() {
|
||||
var r SearchTrackRow
|
||||
|
||||
if err := rows.Scan(
|
||||
&r.FilePath,
|
||||
&r.LengthMilliseconds,
|
||||
&r.Title,
|
||||
&r.ArtistName,
|
||||
&r.TrackNumber,
|
||||
&r.DiscNumber,
|
||||
&r.Album,
|
||||
&r.Genre,
|
||||
&r.Year,
|
||||
&r.Composer,
|
||||
&r.FileType,
|
||||
&r.SampleRate,
|
||||
&r.BitDepth,
|
||||
&r.Channels,
|
||||
&r.Bitrate,
|
||||
&r.FileSize,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"could not scan library search track row: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
results = append(results, r)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"library search track row iteration error: %w",
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// scanSearchRows reads all rows from a query result into a slice.
|
||||
func scanSearchRows(
|
||||
rows interface {
|
||||
Next() bool
|
||||
|
||||
+81
-265
@@ -3,6 +3,8 @@ package database
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"yellowjacket/backend/database/sql/sqlcgen"
|
||||
)
|
||||
|
||||
// seedSearchData inserts ~7 tracks with the full FK chain required for
|
||||
@@ -84,128 +86,44 @@ func seedSearchData(t *testing.T, db *DB) {
|
||||
},
|
||||
}
|
||||
|
||||
// Build unique sets.
|
||||
artistMap := map[string]int64{}
|
||||
albumMap := map[string]int64{}
|
||||
|
||||
var artistID, albumID int64
|
||||
|
||||
for _, tr := range tracks {
|
||||
if _, ok := artistMap[tr.artist]; !ok {
|
||||
artistID++
|
||||
artistMap[tr.artist] = artistID
|
||||
}
|
||||
|
||||
if _, ok := albumMap[tr.album]; !ok {
|
||||
albumID++
|
||||
albumMap[tr.album] = albumID
|
||||
}
|
||||
}
|
||||
|
||||
// Insert artist_credit rows.
|
||||
for text, id := range artistMap {
|
||||
_, err := db.ExecContext(
|
||||
"INSERT INTO artist_credit (id, text) VALUES (?, ?)",
|
||||
id, text,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert artist_credit %q: %v", text, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert release_groups.
|
||||
for name, id := range albumMap {
|
||||
_, err := db.ExecContext(
|
||||
"INSERT INTO release_groups (id, name) VALUES (?, ?)",
|
||||
id, name,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert release_group %q: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert genres + recording_genres.
|
||||
genreMap := map[string]int64{}
|
||||
|
||||
var genreID int64
|
||||
|
||||
for _, tr := range tracks {
|
||||
if tr.genre == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, ok := genreMap[tr.genre]; !ok {
|
||||
genreID++
|
||||
genreMap[tr.genre] = genreID
|
||||
|
||||
_, err := db.ExecContext(
|
||||
"INSERT INTO genres (id, name) VALUES (?, ?)",
|
||||
genreID, tr.genre,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert genre %q: %v", tr.genre, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, tr := range tracks {
|
||||
acID := artistMap[tr.artist]
|
||||
rgID := albumMap[tr.album]
|
||||
|
||||
// Insert recording.
|
||||
_, err := db.ExecContext(
|
||||
"INSERT INTO recordings (id, name, artist_credit_id, "+
|
||||
"track_number, disc_number, year, genre, composer) "+
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
tr.id, tr.title, acID, tr.trackNum, tr.discNum,
|
||||
tr.year, tr.genre, tr.composer,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert recording %d %q: %v", tr.id, tr.title, err)
|
||||
}
|
||||
|
||||
// Insert audio_files.
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO audio_files (id, file_path, "+
|
||||
"length_milliseconds, file_type_id, recording_id, "+
|
||||
"sample_rate, bit_depth, channels, bitrate, file_size) "+
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
tr.id, tr.filePath, tr.lenMs, tr.ftID, tr.id,
|
||||
tr.sr, tr.bd, tr.ch, tr.br, tr.fsize,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert audio_file %d: %v", tr.id, err)
|
||||
}
|
||||
|
||||
// Link recording to release_group.
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO release_group_recordings "+
|
||||
"(release_group_id, recording_id, track_number, disc_number) "+
|
||||
"VALUES (?, ?, ?, ?)",
|
||||
rgID, tr.id, tr.trackNum, tr.discNum,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert release_group_recordings %d→%d: %v", rgID, tr.id, err)
|
||||
}
|
||||
|
||||
// Insert search_index entry (rowid must match audio_files.id).
|
||||
if err := db.InsertSearchIndex(
|
||||
tr.id, tr.filePath, tr.title, tr.artist, tr.album,
|
||||
); err != nil {
|
||||
t.Fatalf("insert search_index for %d: %v", tr.id, err)
|
||||
}
|
||||
|
||||
// Insert recording_genres link.
|
||||
var genres []string
|
||||
if tr.genre != "" {
|
||||
gID := genreMap[tr.genre]
|
||||
genres = []string{tr.genre}
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO recording_genres (recording_id, genre_id) VALUES (?, ?)",
|
||||
tr.id, gID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert recording_genres %d→%d: %v", tr.id, gID, err)
|
||||
}
|
||||
var trackNum, discNum int64
|
||||
if tr.trackNum != nil {
|
||||
trackNum = *tr.trackNum
|
||||
}
|
||||
|
||||
if tr.discNum != nil {
|
||||
discNum = *tr.discNum
|
||||
}
|
||||
|
||||
id := InsertTestTrack(t, db, TestTrack{
|
||||
FilePath: tr.filePath,
|
||||
Title: tr.title,
|
||||
Artist: tr.artist,
|
||||
Album: tr.album,
|
||||
Genres: genres,
|
||||
TrackNumber: trackNum,
|
||||
DiscNumber: discNum,
|
||||
Year: tr.year,
|
||||
LengthMs: tr.lenMs,
|
||||
})
|
||||
|
||||
// The fixtures assert on audio properties and the composer,
|
||||
// which InsertTestTrack does not carry - they are not part of
|
||||
// what a seeder should have to know about a track.
|
||||
if _, err := db.ExecContext(
|
||||
`UPDATE audio_files
|
||||
SET file_type_id = ?, sample_rate = ?, bit_depth = ?,
|
||||
channels = ?, bitrate = ?, file_size = ?, composer = ?
|
||||
WHERE id = ?`,
|
||||
tr.ftID, tr.sr, tr.bd, tr.ch, tr.br, tr.fsize, tr.composer, id,
|
||||
); err != nil {
|
||||
t.Fatalf("set audio properties for %q: %v", tr.filePath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -553,7 +471,7 @@ func TestSearchFTSTracks(t *testing.T) {
|
||||
db := NewTestDB(t)
|
||||
seedSearchData(t, db)
|
||||
|
||||
results, err := db.SearchFTSTracks("queen", 10)
|
||||
results, err := db.SearchFTSTracks("queen", 0, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchFTSTracks: %v", err)
|
||||
}
|
||||
@@ -563,7 +481,7 @@ func TestSearchFTSTracks(t *testing.T) {
|
||||
}
|
||||
|
||||
// Find the Bohemian Rhapsody result and verify all 16 fields.
|
||||
var br *SearchTrackRow
|
||||
var br *sqlcgen.TrackMetadatum
|
||||
|
||||
for i, r := range results {
|
||||
if r.Title == "Bohemian Rhapsody" {
|
||||
@@ -635,26 +553,12 @@ func TestInsertAndDeleteSearchIndex(t *testing.T) {
|
||||
db := NewTestDB(t)
|
||||
|
||||
// Set up minimal FK chain for a single track.
|
||||
_, err := db.ExecContext(
|
||||
"INSERT INTO artist_credit (id, text) VALUES (1, 'Test Artist')",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert artist_credit: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO recordings (id, name, artist_credit_id) VALUES (1, 'Test Track', 1)",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert recording: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) VALUES (1, '/test/track.mp3', 180000, 0, 1)",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert audio_file: %v", err)
|
||||
}
|
||||
InsertTestTrack(t, db, TestTrack{
|
||||
FilePath: "/test/track.mp3",
|
||||
Title: "Test Track",
|
||||
Artist: "Test Artist",
|
||||
LengthMs: 180000,
|
||||
})
|
||||
|
||||
// Insert into search index.
|
||||
if err := db.InsertSearchIndex(
|
||||
@@ -698,41 +602,15 @@ func TestRebuildSearchIndex(t *testing.T) {
|
||||
|
||||
db := NewTestDB(t)
|
||||
|
||||
// Seed the full entity graph WITHOUT inserting into search_index.
|
||||
_, err := db.ExecContext(
|
||||
"INSERT INTO artist_credit (id, text) VALUES (1, 'Rebuild Artist')",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert artist_credit: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO recordings (id, name, artist_credit_id) VALUES (1, 'Rebuild Track', 1)",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert recording: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) VALUES (1, '/rebuild/track.mp3', 200000, 0, 1)",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert audio_file: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO release_groups (id, name) VALUES (1, 'Rebuild Album')",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert release_group: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO release_group_recordings (release_group_id, recording_id) VALUES (1, 1)",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert release_group_recordings: %v", err)
|
||||
}
|
||||
// Seed the file WITHOUT putting it in search_index.
|
||||
InsertTestTrack(t, db, TestTrack{
|
||||
FilePath: "/rebuild/track.mp3",
|
||||
Title: "Rebuild Track",
|
||||
Artist: "Rebuild Artist",
|
||||
Album: "Rebuild Album",
|
||||
LengthMs: 200000,
|
||||
SkipSearchIndex: true,
|
||||
})
|
||||
|
||||
// Search should return nothing before rebuild.
|
||||
results, err := db.SearchFTS("Rebuild", 10)
|
||||
@@ -887,36 +765,22 @@ func TestSearchIndexUpdateCycle(t *testing.T) {
|
||||
db := NewTestDB(t)
|
||||
|
||||
// Set up minimal FK chain for a single track at rowid 100.
|
||||
_, err := db.ExecContext(
|
||||
"INSERT INTO artist_credit (id, text) VALUES (100, 'Old Artist')",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert artist_credit: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO recordings (id, name, artist_credit_id) VALUES (100, 'Old Title', 100)",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert recording: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO audio_files (id, file_path, length_milliseconds, file_type_id, recording_id) " +
|
||||
"VALUES (100, '/test/update_cycle.mp3', 200000, 0, 100)",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert audio_file: %v", err)
|
||||
}
|
||||
id := InsertTestTrack(t, db, TestTrack{
|
||||
FilePath: "/test/update_cycle.mp3",
|
||||
Title: "Old Title",
|
||||
Artist: "Old Artist",
|
||||
LengthMs: 200000,
|
||||
SkipSearchIndex: true,
|
||||
})
|
||||
|
||||
// 1. Insert with old metadata.
|
||||
if err := db.InsertSearchIndex(
|
||||
100, "/test/update_cycle.mp3", "Old Title", "Old Artist", "Old Album",
|
||||
id, "/test/update_cycle.mp3", "Old Title", "Old Artist", "Old Album",
|
||||
); err != nil {
|
||||
t.Fatalf("InsertSearchIndex (old): %v", err)
|
||||
}
|
||||
|
||||
// Verify search for "Old Title" returns rowid 100.
|
||||
// Verify search for "Old Title" finds it.
|
||||
results, err := db.SearchFTS("Old Title", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchFTS(Old Title): %v", err)
|
||||
@@ -926,9 +790,9 @@ func TestSearchIndexUpdateCycle(t *testing.T) {
|
||||
t.Fatal("SearchFTS(Old Title): got 0 results after insert")
|
||||
}
|
||||
|
||||
// 2. Delete rowid 100.
|
||||
if err := db.DeleteSearchIndex(100); err != nil {
|
||||
t.Fatalf("DeleteSearchIndex(100): %v", err)
|
||||
// 2. Delete the row.
|
||||
if err := db.DeleteSearchIndex(id); err != nil {
|
||||
t.Fatalf("DeleteSearchIndex(%d): %v", id, err)
|
||||
}
|
||||
|
||||
// Verify "Old Title" no longer found.
|
||||
@@ -944,25 +808,17 @@ func TestSearchIndexUpdateCycle(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Update the recording name in the DB to simulate tag edit.
|
||||
// 3. Update the file's title in the DB to simulate a tag edit.
|
||||
_, err = db.ExecContext(
|
||||
"UPDATE recordings SET name = 'New Title' WHERE id = 100",
|
||||
"UPDATE audio_files SET title = 'New Title' WHERE file_path = '/test/update_cycle.mp3'",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("update recording: %v", err)
|
||||
t.Fatalf("update title: %v", err)
|
||||
}
|
||||
|
||||
// Also add a new artist_credit for the new artist.
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO artist_credit (id, text) VALUES (101, 'New Artist')",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert new artist_credit: %v", err)
|
||||
}
|
||||
|
||||
// 4. Re-insert rowid 100 with new metadata.
|
||||
// 4. Re-insert the row with new metadata.
|
||||
if err := db.InsertSearchIndex(
|
||||
100, "/test/update_cycle.mp3", "New Title", "New Artist", "New Album",
|
||||
id, "/test/update_cycle.mp3", "New Title", "New Artist", "New Album",
|
||||
); err != nil {
|
||||
t.Fatalf("InsertSearchIndex (new): %v", err)
|
||||
}
|
||||
@@ -1062,70 +918,30 @@ func TestClearSearchIndexPreservesSchema(t *testing.T) {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Migration test
|
||||
// Schema constraint tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestMigrationsApplied(t *testing.T) {
|
||||
func TestSearchIndexSchema(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := NewTestDB(t)
|
||||
|
||||
// Verify user_version >= 3 (all 3 migrations applied).
|
||||
// Use QueryContext + immediate Scan + Close to release the
|
||||
// single connection before subsequent ExecContext calls.
|
||||
var version int
|
||||
|
||||
rows, err := db.QueryContext("PRAGMA user_version")
|
||||
if err != nil {
|
||||
t.Fatalf("PRAGMA user_version: %v", err)
|
||||
}
|
||||
|
||||
if !rows.Next() {
|
||||
_ = rows.Close()
|
||||
|
||||
t.Fatal("PRAGMA user_version: no row returned")
|
||||
}
|
||||
|
||||
if err := rows.Scan(&version); err != nil {
|
||||
_ = rows.Close()
|
||||
|
||||
t.Fatalf("scan user_version: %v", err)
|
||||
}
|
||||
|
||||
_ = rows.Close()
|
||||
|
||||
if version < 3 {
|
||||
t.Errorf("user_version = %d, want >= 3", version)
|
||||
}
|
||||
|
||||
// Verify the UNIQUE index from migration 3 exists by attempting
|
||||
// a duplicate insert. First, create the prerequisite rows.
|
||||
_, err = db.ExecContext(
|
||||
// Verify the UNIQUE index on artist_credit_artist exists by
|
||||
// attempting a duplicate insert. First, create the prerequisites.
|
||||
_, err := db.ExecContext(
|
||||
"INSERT INTO artists (id, name) VALUES (1, 'Test')",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert artist: %v", err)
|
||||
}
|
||||
|
||||
// The credit tables this used to assert a UNIQUE constraint on are
|
||||
// gone; a file names its artist directly, and artists are unique by
|
||||
// name, which is asserted below.
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO artist_credit (id, text) VALUES (1, 'Test Credit')",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("insert artist_credit: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO artist_credit_artist (artist_id, credit_id) VALUES (1, 1)",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("first insert artist_credit_artist: %v", err)
|
||||
}
|
||||
|
||||
// Duplicate insert should fail with UNIQUE constraint.
|
||||
_, err = db.ExecContext(
|
||||
"INSERT INTO artist_credit_artist (artist_id, credit_id) VALUES (1, 1)",
|
||||
"INSERT INTO artists (id, name) VALUES (2, 'Test')",
|
||||
)
|
||||
if err == nil {
|
||||
t.Error("duplicate artist_credit_artist insert should fail, got nil error")
|
||||
t.Error("duplicate artist name should fail, got nil error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
-- Queries over albums (formerly release_groups).
|
||||
--
|
||||
-- The two-copy pattern is gone here too: one query answers both the
|
||||
-- whole-library and the single-library case. The `fallback_ac`
|
||||
-- subquery every album read used to carry -- "if the album has no album
|
||||
-- artist credit, borrow one from any of its recordings" -- is gone with
|
||||
-- it, because the album carries its own credit text now.
|
||||
|
||||
-- name: UpsertAlbum :one
|
||||
INSERT INTO albums (name, artist_credit, artist_id, year, cover_art_id)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(name, artist_credit) DO UPDATE SET
|
||||
artist_id = COALESCE(excluded.artist_id, albums.artist_id),
|
||||
year = COALESCE(excluded.year, albums.year),
|
||||
cover_art_id = COALESCE(excluded.cover_art_id, albums.cover_art_id)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetAlbum :one
|
||||
SELECT * FROM albums WHERE id = ? LIMIT 1;
|
||||
|
||||
-- name: SetAlbumMBID :exec
|
||||
UPDATE albums SET mbid = ? WHERE id = ?;
|
||||
|
||||
-- name: SetAlbumOriginalYear :exec
|
||||
UPDATE albums SET original_year = ? WHERE id = ?;
|
||||
|
||||
-- name: SetAlbumCoverArt :exec
|
||||
UPDATE albums SET cover_art_id = ? WHERE id = ?;
|
||||
|
||||
-- name: SetAlbumPendingReleaseMBID :exec
|
||||
UPDATE albums SET pending_release_mbid = ? WHERE id = ?;
|
||||
|
||||
-- name: ResolveAlbumPendingReleaseMBID :exec
|
||||
-- Clears the pending marker once the release-group MBID it stood in for
|
||||
-- has been resolved. Guarded so a real MBID is never overwritten.
|
||||
UPDATE albums
|
||||
SET mbid = ?, pending_release_mbid = NULL
|
||||
WHERE id = ? AND (mbid IS NULL OR mbid = '');
|
||||
|
||||
-- name: GetAlbumsWithPendingReleaseMBID :many
|
||||
SELECT id, pending_release_mbid FROM albums
|
||||
WHERE pending_release_mbid IS NOT NULL AND pending_release_mbid != ''
|
||||
AND (mbid IS NULL OR mbid = '');
|
||||
|
||||
-- name: DeleteAlbum :exec
|
||||
DELETE FROM albums WHERE id = ?;
|
||||
|
||||
-- name: DeleteAllAlbums :exec
|
||||
DELETE FROM albums;
|
||||
|
||||
-- name: GetEmptyAlbumIDs :many
|
||||
-- Albums with no file left behind them. Under the old schema this was
|
||||
-- one of three orphan sweeps that had to run by hand and did not;
|
||||
-- audio_files is the only thing that can leave an album empty now, so
|
||||
-- this is the whole of it.
|
||||
SELECT id FROM albums al
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM audio_files af WHERE af.album_id = al.id
|
||||
);
|
||||
|
||||
-- name: GetAlbums :many
|
||||
SELECT
|
||||
al.id,
|
||||
al.name,
|
||||
COALESCE(al.original_year, al.year) AS year,
|
||||
COALESCE(al.year, 0) AS release_year,
|
||||
al.mbid,
|
||||
al.artist_credit AS artist_name,
|
||||
CAST(COALESCE(ar.mbid, '') AS TEXT) AS artist_mbid,
|
||||
COALESCE(ca.file_path, '') AS cover_art_path
|
||||
FROM albums al
|
||||
LEFT JOIN artists ar ON ar.id = al.artist_id
|
||||
LEFT JOIN cover_art ca ON ca.id = al.cover_art_id
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM audio_files af
|
||||
WHERE af.album_id = al.id
|
||||
AND af.library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), af.library_id)
|
||||
)
|
||||
ORDER BY al.name;
|
||||
|
||||
-- name: GetAlbumsByArtistName :many
|
||||
SELECT
|
||||
al.id,
|
||||
al.name,
|
||||
COALESCE(al.original_year, al.year) AS year,
|
||||
COALESCE(al.year, 0) AS release_year,
|
||||
al.mbid,
|
||||
al.artist_credit AS artist_name,
|
||||
CAST(COALESCE(ar.mbid, '') AS TEXT) AS artist_mbid,
|
||||
COALESCE(ca.file_path, '') AS cover_art_path
|
||||
FROM albums al
|
||||
LEFT JOIN artists ar ON ar.id = al.artist_id
|
||||
LEFT JOIN cover_art ca ON ca.id = al.cover_art_id
|
||||
WHERE (al.artist_credit = sqlc.arg(artist) OR ar.name = sqlc.arg(artist))
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM audio_files af
|
||||
WHERE af.album_id = al.id
|
||||
AND af.library_id = COALESCE(NULLIF(CAST(sqlc.arg(library_id) AS INTEGER), 0), af.library_id)
|
||||
)
|
||||
ORDER BY year, al.name;
|
||||
|
||||
-- name: GetAlbumCompleteness :one
|
||||
-- "Do I have all of this album", answered from the tags on disk.
|
||||
--
|
||||
-- The expectation is a **sum over discs**, not one number: totals are
|
||||
-- declared per disc ("5/12" on disc 2 means 12 tracks on disc 2), so a
|
||||
-- multi-disc album's expectation is the sum of each disc's declared
|
||||
-- total. A disc whose files declared nothing leaves the whole album
|
||||
-- unknowable rather than being covered by the discs that did -- which is
|
||||
-- what `known` reports.
|
||||
--
|
||||
-- Owned counts DISTINCT track numbers: this app detects duplicates, and
|
||||
-- counting two files of track 3 twice would report a short album as
|
||||
-- complete.
|
||||
SELECT
|
||||
-- Distinct (disc, track) pairs: this app detects duplicates, and
|
||||
-- counting two files of track 3 twice would report a short album as
|
||||
-- complete. A file with no track number falls back to its own id,
|
||||
-- because three untagged files are three tracks, not one.
|
||||
CAST(COUNT(DISTINCT CAST(COALESCE(a.disc_number, 1) AS TEXT) || ':' ||
|
||||
COALESCE(CAST(a.track_number AS TEXT), 'f' || a.id)
|
||||
) AS INTEGER) AS owned,
|
||||
CAST(COALESCE((
|
||||
SELECT SUM(per_disc.total)
|
||||
FROM (
|
||||
SELECT MAX(b.total_tracks) AS total
|
||||
FROM audio_files b
|
||||
WHERE b.album_id = sqlc.arg(album_id) AND b.total_tracks IS NOT NULL
|
||||
GROUP BY COALESCE(b.disc_number, 1)
|
||||
) per_disc
|
||||
), 0) AS INTEGER) AS expected,
|
||||
CAST((
|
||||
SELECT COUNT(*) = 0 FROM audio_files c
|
||||
WHERE c.album_id = sqlc.arg(album_id) AND c.total_tracks IS NULL
|
||||
) AS INTEGER) AS known
|
||||
FROM audio_files a
|
||||
WHERE a.album_id = sqlc.arg(album_id);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user