feat(library): remove a track from the library without deleting the file

RemoveFromLibrary deletes the audio_files rows the way the scan's own
orphan cleanup does and records each path in excluded_paths. The
exclusion is not an enhancement: without it the next scan finds the
file, sees no row and imports it again, so the button undoes itself.

The soft scan compares files on disk against rows in the database, so
surveyAudioFiles and countAudioFiles both take the exclusion set —
otherwise an excluded path makes the two disagree forever and queues a
full scan on every launch. Deleting a row cascades to queue_tracks, so
the removal calls the same CompactQueue hook RemoveLibrary does.

Also lands the requested badge: library-status-indicator is a button
again where it can act, utils/library-status.ts states once what owning
and wanting mean, and the long-declared queued state finally has a
producer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UDCbcCZQepnpSQYJ6SxxZm
This commit is contained in:
2026-08-14 13:12:01 -04:00
co-authored by Claude Opus 5
parent dcc40b1781
commit dc890d1fcc
48 changed files with 3450 additions and 103 deletions
+16 -6
View File
@@ -18,7 +18,7 @@ here has disappeared.
## Read this part before you fail
Fourteen things cost a cycle each the first time. They are here, not in a
Fifteen things cost a cycle each the first time. They are here, not in a
reference, because you need them *before* the failure, not after.
- **Time out every binding call.** A bound Go method called with wrong
@@ -54,6 +54,16 @@ reference, because you need them *before* the failure, not after.
`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`,
@@ -66,7 +76,7 @@ reference, because you need them *before* the failure, not after.
`--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** (54 specs each) since the
- **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,
@@ -74,9 +84,9 @@ reference, because you need them *before* the failure, not after.
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 13
of 36 fail, in a list that reads exactly like a regression in
whatever you are holding. `make dev-headless SEED=default` first.
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.
- **…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
@@ -180,7 +190,7 @@ and a CI step over every commit in a push) rejects a subject that is not
`--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 36 specs share one backend process in file order**, so a spec
**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
@@ -5,7 +5,21 @@ chain got wrong, and when squashing is legitimate — is in `CLAUDE.md`
under *Backend packages → database*. Read it once. This is the
checklist.
A schema change needs **two** files, not one:
**A brand-new table needs one file, not two.** The rule below is about
a *column added to a table that already exists*. `applySchema` runs
every file in `sql/schemas/` on every open, so a
`CREATE TABLE IF NOT EXISTS` reaches an existing install verbatim and a
migration for it would be a second description of the same table — the
thing the third rule forbids. Its indexes go in the schema file too,
because the column and the index arrive together.
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.
Adding a **column** to an existing table needs **two** files, not one:
1. **`backend/database/sql/schemas/*.sql`** — `CREATE TABLE ... IF NOT
EXISTS`, the literal target shape, what sqlc reads and what a fresh
+298
View File
@@ -2028,3 +2028,301 @@ Six more things worth keeping:
palette rewrite, twice, because the component tier has no `:root` and
renders the fallbacks. The tier that *did* catch things was a unit
test over the palette table and a probe against the running app.
## A name lives where the role is, and neither the audit nor the sweep looks there
Plan 008 phase 3: the tail of `a11y.md`, which closes it — and with it
all four audits from 2026-08-11.
The generalisation, and it is the whole of this pass: **an accessible
name is computed on the element carrying the role, and every way we
have of checking one looks somewhere else.** The audit read the
*source* and credited a name that was never computed. My AX sweep read
the *tree* and reported a weak name as no problem. A component test
asserted the *attribute* and pinned the bug it was written to prevent.
Three tiers, three different wrong answers, all about the same
property.
Concretely, and each of these is a finding:
| where it was written | where the role is | computed name |
|---|---|---|
| `aria-label` on `<wa-slider>` | a div in its shadow root, `aria-labelledby="label"` | `""` |
| `<label>` beside a `<select>` in `config-field` | the select | `""` |
| `placeholder` on Explore's search input | the input | the placeholder |
| `label` on `<wa-progress-bar>` | inner div's `aria-label` | correct |
The first is `wa-dialog`'s trap one component over and cost two
sessions in 007. The fix is different, though, and the difference is
worth keeping: `wa-progress-bar`'s `label` *is* an `aria-label` and is
invisible, so it is just the right API; `wa-slider`'s `label` is
**visible**, so the name comes from the library's own property and
`styles/wa-slider-label.css.ts` hides it by part. That is preferred
over `name-dialog.ts`'s reach into the shadow root for one reason —
if Web Awesome renames the part, the label becomes *visible* and
correctly named, rather than silently nameless again. Choose the
failure you would rather have.
Nine more things worth keeping:
- **A sweep for empty names cannot see a weak one.** A `placeholder`
is an accname fallback, so `getFullAXTree` reported the whole Explore
view *clean* — which is why `a11y.26` survived four phases of people
looking for exactly this class of bug. "0 unnamed" answers a
narrower question than it reads as, which is the third time in this
plan a *count* has done that.
- **The count that sent Phase 1 hunting was wrong in both halves.**
"Two unnamed native `<select>`s, one of them the page header's sort
control on nine views": the sort control is named `Sort: ` by its
wrapping `<label>` (`from: relatedElement`) on all nine, and the two
unnamed roles were one `config-field` select and the *seek bar*.
Recorded as a finding in the plan, believed for a phase, false.
- **…and the thing it was pointing at was nine times bigger.** With
Settings' sections expanded: **24 of 93 controls unnamed**, every
`config-field` select and toggle and all eighteen column checkboxes.
No finding names it, and `a11y.6` is not wrong — it says in its own
line that it scanned every `<button>`. Same shape as phase 2's
contrast number.
- **A fix's own test can be pinning the bug.** `transport.test.ts`
asserted `aria-label` on the `wa-slider` host under the title
"carries an accessible name". It passed for six phases. *Run the
existing tests* found it — third plan running that this is the rule
that pays.
- **`a11y.21`'s mechanism does not exist, and the real one is on the
other axis.** "The 4em bars grow while the viewport does not and
anything that no longer fits is clipped" — the middle grid row is
`1fr` and absorbs them exactly: at 200% text on 800×600 the bars go
64 → 128px, the panel 472 → 344px, and the footer still lands on 600.
Nothing is clipped vertically. Horizontally the shell is 784px inside
a 320px viewport (400% page zoom, the width 1.4.10 names) with 464px
of it behind `overflow: hidden`. Measure the axis the finding does
not mention.
- **`overflow: hidden` still permits programmatic scrolling**, so
`scrollLeft = 9999` returns a healthy 464 on the build that has the
bug. My first spec passed against the broken build for that reason.
A wheel gesture is the probe. Fifth entry in this plan's "the probe
was wrong, not the code" column, and the tell was the oldest one
there is: **it could not fail.**
- **A synthetic `MouseEvent` does not reach a delegated handler.**
Three probes in a row reported a queue row as never becoming active;
`getByTestId('queue-row').dblclick()` made it active immediately.
Delegation reads things a hand-built event does not carry.
- **A finding can be half-closed by a phase that was not about it,
and the half that remains is smaller than the sentence.** `a11y.34`
reads "the sort direction is a 10px glyph *or nothing*" — Phase 1's
`aria-sort` closed the *or nothing*, leaving one declaration. Second
time in this plan (`a11y.11` was the first), and both times reading
the sentence rather than the residue would have built something that
already existed.
- **The state a fix lands in, again, and it was three pixels.**
`a11y.29` takes the subtitle's bottom margin away with the `<h3>`,
which *shortens* the flex-centred title block and moves it **down**
into the bar's clip — the hgroup had measured 67px inside a 64px bar
since before any of this, and the descenders of "meant to bee." were
cut. Found by reading a screenshot of the fix, which is the fifth
regression in three plans that only a PNG has caught.
And one thing that went right and is worth copying: **the marker for
`a11y.22` is a shape drawn in padding the row already had.** The track
list's grid columns are computed from the host width, so anything in
the flow moves every cell on the playing row and nothing else. A
`::before` triangle in the 8px left padding costs no layout, and both
tiers assert it is *absent* on the other rows — a marker that renders
everywhere satisfies "the playing row has one" for free.
## A guard is only a feature if everything that counts agrees with it
Plan 008 phase 4: "remove from library" — the row goes, the path is
excluded from future scans, the file is never touched — and with it
`tracklist.delete`, which had been advertised in Settings for six
phases with nothing on the other end of it.
The generalisation: **an operation that changes what counts as "in the
library" has to be applied everywhere that number is computed, and the
places that compute it do not look like the feature.** The scan walk is
the obvious one, and skipping an excluded path there is the whole
feature as written in the plan. But the *startup soft scan* decides
whether to scan at all by comparing files on disk against rows in the
database, and an excluded path is on disk and deliberately not a row —
so the fix as specified would have left the two counts disagreeing
forever and queued a full scan of the entire library on **every
launch**. Nothing fails, nothing renders differently, and no tier looks
at it; the app is just permanently rescanning. The same shape one step
over: deleting an `audio_files` row cascades to `queue_tracks`, so the
queue's in-memory copy — and the playing track — goes stale unless the
removal calls the reload hook `RemoveLibrary` has had all along.
Six more things worth keeping:
- **A new table needs one schema file, and the two-file discipline is
not about it.** `applySchema` runs every file in `sql/schemas/` on
every open, so `CREATE TABLE IF NOT EXISTS` reaches an existing
install verbatim. The migration the plan asked for would have been a
*second description of the same table*, which is precisely what tore
out the old 48-step chain. Column order and "no index on a migrated
column" are rules about `ALTER TABLE ADD COLUMN`, and neither applies
when nothing is being altered.
- **The repo asked the question the plan did not.** `backend/datamap`
failed the build twice for the new table: once for having no entry at
all, then again because an *authored* table that cascades needs an
argued exemption rather than a default. Two gates, both right, and
neither in `references/schema-change.md` until now. A catalogue that
fails the build is worth more than a catalogue that is accurate.
- **Reversibility is a claim until something implements it.** The
decision picked shape A over deleting the file partly because it is
reversible — and nothing in the plan made it so. An exclusion with no
UI to clear it is a one-way door with the file sitting on disk the
whole time. A full rescan clears the table, which is the escape hatch
until there is a list to manage, and it is now written down instead
of assumed.
- **The copy was wrong for the case it will be used in most.** The
confirmation's message and impact were written for a multi-select and
used for both, so removing one track said "**They** are removed" under
a singular title. Nothing failed. Read in the first screenshot of the
dialog — sixth regression in four plans that only a PNG has caught,
and the one where it mattered most, since the copy is the only thing
standing between this feature and a user's music.
- **Both halves of a guard need their own test, or one of them is
decorative.** The walk's exclusion and the survey's exclusion are two
lines in two functions; neutering each in turn failed exactly one
test. Had they shared a test, either could have rotted invisibly.
Same reason the e2e case asserts a *control* path still returns from
the same scan: a guard that excluded everything passes "the removed
path did not come back" for free.
- **A Playwright hook gets 30 seconds regardless of the test's
timeout.** A `db/restore` in `afterAll` passed in isolation and timed
out in the full suite, where earlier specs have staged an explore
catalog and the copy takes longer. `test.setTimeout()` *inside the
hook* is what raises it — and a spec that spends the shared database
has to give it back, since the 90 specs share one backend in file
order.
## A state nothing produces is a state nobody has checked
Plan 009 phase 1: `library-status-indicator`'s third state, wired.
The generalisation: **an enum whose last value is never constructed is
not unfinished, it is wrong** — because everything around it has been
written, reviewed and tested against the two values that do occur, and
the code reads as complete from every angle except the one that
produces the third. `LibraryStatus` has had `queued` since it was
written: styled amber, given an hourglass, given the sentence "… is
queued for download". All eight call sites were a two-way ternary. So
an album on the request list rendered a plus and announced "is not in
your library" — on the same page, forty pixels from a filled button
reading "Wanted".
Nothing was going to find that. `make ui-test` and `make e2e` both
covered the badge; both asserted the states it produced. 007 phase 6
had rewritten this exact component, and the note it left behind
("when the download-client integration lands…") was itself the reason
nobody looked: it names a *future* condition for work that was already
possible, since `backend/download` was 16 541 lines and 20 bound
methods on the day it was written. **A written-down reason not to look
ages worse than the code it is about.**
Six more things worth keeping:
- **The second bug was in the screenshot of the first.** The "Wanted"
button rendered a question mark — the missing-icon fallback —
because `bookmark-check` is Font Awesome **Pro** and has never been
bundled. `offline-icons.spec.ts` asserts `__yjIconMisses` is empty
and passed the whole time: no spec had ever put the app in a state
where an album was requested. The bundled-icon design anticipated
exactly this ("twenty call sites compute their icon name from
state") and the *sweep* still could not see it, because a sweep only
sees the states it visits. Seventh regression in five plans that
only a PNG has caught, and the first found in a PNG taken of a
different bug.
- **A property that does not change does not re-render a child.**
`top-results-row` reads the request list, and its host handing back
the same `results` array means Lit stops at the property — the row
keeps its old badges while the store holds the right answer. The
virtualizer rule (`requestUpdate()` on host state) one level milder,
and the same fix: subscribe where the state is *read*.
- **A spec that gives state back has to be run twice to know it did.**
The `afterAll` cleanup called `callBinding`, which goes through
`window.__yjEvents` — installed by the `app` fixture, not by a bare
`browser.newPage()`. It threw where nothing was watching, left the
request behind and failed the *next* run with a stale `queued`. One
run proves the assertions; the second proves the teardown.
- **A freshly launched app cannot search its own catalog for ~40 s.**
The core artifact merge has to land (`core artifact: merge complete`
in `.dev/app.log`), and until it does Explore's search returns
nothing at all — *including for rows staged directly into
`explore_index` a moment earlier*, which makes it look like the
staging failed. Cost a cycle here reading as a failure of the neuter
the run was under. Budget 60 s, or wait for the log line.
- **The neuter has to be per line, not per feature.** Two fixes landed
together and each got its own one-line neuter, which is what made
the two failures distinguishable: one spec reported the wrong badge
status, the other reported `["bookmark-check"]`. Neutered together
they would both have failed and either could have been decorative.
- **The fix is where the rule is, and the rule was in eight places.**
Every one of the eight sites was individually reasonable; the third
state was missing from all of them because no site owns the
question. Same shape as `getCoverUrl()`, `track-index.ts` and
`page-header` — when a rule is written per call site, the call sites
do not disagree, they are all incomplete in the same way.
## A decision phase earns its keep by finding it was not a decision
Plan 009 phases 2 and 3: the badge becomes a button where it can act.
The generalisation: **the questions worth taking a phase over are the
ones the code can answer, and you cannot tell which those are without
asking them.** Phase 2 was written as three judgement calls. Two turned
out not to be:
- "An artist badge would commit a user to a whole discography" —
describing a badge that **does not exist**. `top-results-row` renders
`nothing` for an artist and no other site passes
`entity-type="artist"` to the component at all. Artist subscription
already had a labelled Follow button.
- "Should a track inside a requested album show something different" —
evaporated. It read as noise only while a plus on a track meant
nothing; once it means *want just this one*, the mixed row is the
interface working.
The third — whether a track can be requested at all — went the other
way and is the more useful lesson. **`EntityRecording` reads like a
placeholder and is load-bearing.** It would have cost nothing to rule
tracks out as unsupported, and `Reconciler.tracklistFor` 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. A feature removed by assumption leaves no trace that
it was ever there.
Five more things worth keeping:
- **A test that passes on the neutered build is not a test, and the
vacuous ones are the negative assertions.** "Keeps its click off the
card it sits on" asserted that nothing bubbled — free when there is
no button, since `?.click()` on null is a silent no-op. It passed on
the neutered build while its seven neighbours failed. It asserts the
click *did the thing it was swallowed for* as well now. Same family
as `overflow: hidden` permitting programmatic scrolling, and the tell
was identical: **it could not fail.**
- **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. A Playwright locator
re-resolves and waits for the element to stop moving. Prefer one to
`mouse.click(x, y)` whenever the thing being clicked is in a list
that is still loading, which is most lists here.
- **A fix moves its own assertions, and that is not churn.** Phase 1's
spec asserted the badge announced "… is queued for download". A
*control* is named after what activating it does, so two commits
later it is "Cancel the request for …". Naming a thing after its
state is correct right up until it grows an action.
- **An opt-in makes a redundancy visible.** The badge could have known
which pages have a "Want this" button; instead a call site passes
`request-mbid` or does not, so `explore-album-details`'s header
declines in its own template. The rule is greppable and the component
has no list of exceptions to go stale.
- **Verify a control with the gesture, not with the event.** A synthetic
`MouseEvent` does not prove hit-testing, and a `.click()` on a shadow
child does not prove the icon inside it is `pointer-events: none`.
Both were checked with a real mouse (`mousemove`/`mousedown`/
`mouseup`) and a real Tab/Enter before either was believed.
@@ -1,6 +1,7 @@
# 008 — The last audit, and the one binding that outlived six phases
**Status:** active — Phases 1 and 2 shipped.
**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
@@ -64,17 +65,20 @@ fixed until it has been reproduced in the running app.
| `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. |
| `22` | Minor | `queue-panel` gained `aria-current`; `track-list` did not, and neither has a non-colour marker. |
| `24` | Minor | No `title` on the truncating element in `track-info`, `playlist-view`, `queue-panel` or `track-list`. |
| `25` | Minor | `<wa-progress-bar value=…>` with no label, verbatim as filed. |
| — | new | **Two unnamed native `<select>`s**, one of them `page-header`'s sort control on nine views. Not in the audit: `a11y.6` scanned `<button>`. Found in the AX tree while reproducing `14`. Belongs with `26`. |
| `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. |
| `30` | Polish | No skip link anywhere. |
| `32` | Polish | `title="Remove from queue"`, not identifying the track. |
| `34` | Polish | The 10 px sort arrow, unchanged. |
| — | — | Colour contrast, never measured. |
| `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
@@ -400,6 +404,106 @@ Two of them are not one-liners and should be treated as such:
- **`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
@@ -457,6 +561,77 @@ 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
@@ -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.
+167 -14
View File
@@ -130,7 +130,7 @@ meaningless against the seed's one empty playlist, so it builds ten
It wraps every bound Go method, so "did that refetch the library" is a
fact rather than an inference. It is not a spec and does not run in CI.
**The cheapest tier needs none of that.** `make ui-test` runs 480
**The cheapest tier needs none of that.** `make ui-test` runs 672
Vitest tests in a real Chromium in ~2 s with no Wails, no backend, no
seeded library and no virtual display, because `frontend/wailsjs/` is a
pure passthrough to `window.go` / `window.runtime` and
@@ -156,7 +156,7 @@ See `.planning/plans/completed/005-agent-development-harness.md`.
**Backend packages** (under `backend/`):
- `player` — Audio playback via beep. `BufferedStreamer` provides a ring buffer for smooth seeking.
- `queue` — Track queue with shuffle (Fisher-Yates), repeat modes, auto-advance, and session persistence.
- `library` — Concurrent library scanning, metadata extraction, cover art deduplication, incremental rescan.
- `library` — Concurrent library scanning, metadata extraction, cover art deduplication, incremental rescan. Also **removal**, below.
- `database` — SQLite via pure-Go driver. Schema in `database/sql/schemas/`, queries in `database/sql/queries/`. **sqlc** generates Go code into `database/sql/sqlcgen/` — never edit that directory by hand.
**Schema changes need two things, not one.** `sql/schemas/*.sql` is
@@ -212,6 +212,20 @@ See `.planning/plans/completed/005-agent-development-harness.md`.
`TestNoWritesOnTheReadPool` walks the tree for it, in the same
spirit as `TestNoDirectRuntimeEmits` and for the same reason — a
lint pass only sees one build configuration.
- **A new table needs one file, not two.** The two-file rule is
about a column added to a table that already exists.
`applySchema` runs every file in `sql/schemas/` on *every* open,
so a `CREATE TABLE IF NOT EXISTS` reaches an existing install
verbatim and a migration for it would be a second description of
the same table — which is exactly what the third rule forbids.
`excluded_paths` is the worked example, index included, since the
column and its index arrive together.
- **A new table has to say what kind of data it holds.**
`backend/datamap` is a catalogue of every table's Kind and
Lifetime, and `TestCatalogCoversSchema` fails on a table missing
from it. `TestAuthoredCascadesAreDeliberate` then makes an
*authored* table that cascades an explicit, argued exemption —
authored data is what a user cannot get back.
- **Squashing is fine pre-1.0.** While this hasn't shipped to real
users, periodically folding `sql/migrations/` into `sql/schemas/`
and deleting the migration files (then wiping your own dev/sandbox
@@ -586,6 +600,76 @@ only ever moved by an arrow key, so a row reached by a click or by Tab
left it at 0 and `Enter` played the first track in the queue from any
focused row.
**A name is computed where the role is, and that is rarely where you
wrote it.** Four surfaces wrote a name somewhere the accessibility tree
never looked. `wa-slider` puts `role="slider"` on a div in its own
shadow root pointing `aria-labelledby` at an empty internal `<label>`,
which outranks the host's `aria-label` — so both sliders computed a
name of `""`, and `a11y.md` lists both under *what is already correct*.
The name comes from `label` now, the library's own API, and
`styles/wa-slider-label.css.ts` hides it: preferred over reaching into
the shadow root the way `name-dialog.ts` must, because if Web Awesome
renames the part the label becomes *visible and correctly named*
rather than silently nameless. Its second rule is load-bearing —
`#slider` takes an 8px margin the moment a label exists, which grows
the bar 6px → 14px and moves the transport with it.
`wa-progress-bar`'s `label` *is* an `aria-label` and is invisible, so
there it is just the right attribute.
The same thing in the light DOM: `config-field` rendered a `<label>`
as a **sibling** with no `for`, so **24 of 93 controls on Settings**
computed an empty name. They use `for`/`id` (a fixed id, safe only
because each field is its own shadow root) rather than `aria-label`,
for what it buys beyond the name — the label text becomes a click
target. And three surfaces are named but identify nothing, which is
the same fault one step milder: three shortcut buttons announced
themselves as "S", thirty-six column arrows as "Move up", and every
queue row's remove button as "Remove from queue".
**Checking any of this needs the browser's own answer, and "0 unnamed"
is not it.** A `placeholder` is an accname fallback, so an
`Accessibility.getFullAXTree` sweep of all eleven views reported
Explore's search box — the audit's own `a11y.26` — as clean. A sweep
for *empty* names cannot see a *weak* one.
**The shell scrolls sideways and not down.** `body` is
`overflow-x: auto; overflow-y: hidden`, and both halves are measured.
Vertically there is nothing to fix: the middle grid row is `1fr` and
absorbs the 4em bars exactly — at 200% text on an 800×600 window the
bars go 64 → 128px, the main panel 472 → 344px, and the footer still
lands on 600. Horizontally the shell is 784px inside a 320px viewport
(400% page zoom, the width WCAG 1.4.10 names) and 464px of it,
including the job indicator and the queue button, used to sit behind
`overflow: hidden`. Keeping the vertical axis fixed is what keeps the
transport where a desktop player's transport belongs. At every size
this app promises, no scrollbar appears. Note that `overflow: hidden`
still permits *programmatic* scrolling, so a probe that sets
`scrollLeft` passes on the broken build; the spec uses a wheel gesture.
**The playing row is a shape, not a hue.** `track-list` and
`queue-panel` draw a `::before` triangle in each row's own left
padding, plus `aria-current` — before, both rows were a background tint
and a text colour and nothing else (WCAG 1.4.1). It is in the padding
because the track list's grid columns are computed from the host width,
so a marker in the flow moves every cell on the playing row and nothing
else. Both tiers assert it is **absent** on the other rows: a marker
that renders everywhere satisfies "the playing row has one" for free.
One thing to know before checking it — a track started from the *track
list* leaves the queue's `currentIndex` at 1, so the panel has no
current row at all in that flow, which reads exactly like the marker
not working.
**The first thing Tab reaches is a skip link.** Two details are
load-bearing and neither is the link's text. It is `position: absolute`
in **both** states, because `body` is a grid with named areas and an
in-flow extra child is auto-placed into one of them. And `<main>`
carries `tabindex="-1"`, or the fragment moves the scroll, leaves the
tab sequence exactly where it was, and looks like it worked. The
subtitle beside it is a `<p>`, which is also what an `hgroup` is
supposed to contain — and dropping the `<h3>`'s bottom margin shortened
the flex-centred title block enough to move it down into the 4em bar's
clip, so `.title` zeroes both margins.
**A selectable grid is a listbox.** The four grids that ctrl/shift-select
(`artists-view`, `genres-view`, `cover-grid`, and the queue) are
`role="listbox" aria-multiselectable` over `role="option"` cards, not
@@ -818,21 +902,57 @@ result would silently reorder a queue — and because `cover-grid`'s drag
cache stores them per album. A `libraryID` of 0 means "every library",
matching an unset library filter.
**A badge is not a button, and a control that cannot act is worse than
none.** `library-status-indicator` — the tick/plus on every Explore
**A badge is a button only where it can act, and it says which.**
`library-status-indicator` — the tick/hourglass/plus on every Explore
card and track row — was a `<button>` whose click handler was a
`stopPropagation()` and a comment saying to wire up the download client
later: 20 of the 66 tab stops on a results page announced themselves as
buttons and did nothing (46 and 0 after). It is `role="img"` with a
label until there is something to click, and its unowned label says
"… is not in your library" rather than "Add … to library", which was
the button's promise written into the copy. When the download client
lands, the change is a `<button>` *with* a handler — not a handler
bolted onto something already shaped like one. Two smaller things came
with it: a `<span>` does not get `box-sizing: border-box` from the UA
stylesheet the way a `<button>` does (the badge grew 36→38px, caught by
a stored screenshot), and with no click of its own the badge is part of
its card, so a click on it means what the card means.
buttons and did nothing. 007 made it `role="img"` on the rule that a
control which cannot act is worse than none, and named the condition
that would change the answer: a `<button>` again *with* a handler,
never a handler bolted onto something already shaped like one.
It is that now, and three rules hold it up. **A call site opts in** by
passing `request-mbid`, so a redundancy is visible in the template
rather than hidden in the component — `explore-album-details`'s header
has "Want this" in words directly below it and does not opt in. **An
owned entity is never a button**, because there is nothing left to ask
for, which is what stops the returned tab stops being spent on nothing.
And **the name is the action and the action is a request**: "Want album
X" / "Cancel the request for album X". Clicking adds a row to the
request list and nothing to the library, which is exactly what made the
original "Add … to library" a promise the control could not keep.
Two entities and not the third. A track is requestable because
`EntityRecording` is real work in the backend — `Reconciler.tracklistFor`
has a branch for it, since one expected title is what lets filename
matching score a single-track download at all. An artist is not: there
is no artist badge anywhere (`top-results-row` renders `nothing` for
one), and a discography subscription — never satisfied, expanding into
child requests — belongs on `explore-artist-details`'s Follow button,
which can say what it commits to.
Two smaller things, both still true: a `<span>` does not get
`box-sizing: border-box` from the UA stylesheet the way a `<button>`
does (the badge grew 36→38px, caught by a stored screenshot, and both
branches now set it), and the click is swallowed again — for the
opposite reason to before. With no action of its own the badge was part
of its card and a click on it meant what the card means; with one, it
does not. Enter and Space are stopped for the same reason, since every
card holding one is a `role="button"` or `role="option"` with its own
handler.
**Its third state was declared for a year and produced by nothing.**
`queued` was styled amber, given an hourglass and given the sentence
"… is queued for download", and all eight call sites were a two-way
ternary — so an album already on the request list showed a plus and
said it was not in the library, on the same page as a filled button
reading "Wanted". `utils/library-status.ts` is that rule written once:
`libraryStatusFor()` (owning outranks wanting; a *satisfied* request is
not queued, because nothing is coming; a request is by MBID, so a track
inside a requested album is not itself requested) and `toggleRequest()`
beside it, because the "Want this" button asks the same question and
two definitions of *what wanting means* is the fault this replaced.
**A grid moves by a row, and `offsetTop` cannot tell you how wide a row
is.** `utils/roving-grid.ts` measured columns by counting cards sharing
@@ -951,6 +1071,39 @@ reader and is created lazily, so neither the invalidation nor — more
expensively — the singleton's own construction warms a cache for a page
that may never open.
**"Remove from library" removes the row and excludes the path, and
never touches the file.** `RemoveFromLibrary(filePaths)` deletes the
`audio_files` rows the way the scan's own orphan cleanup does (tagging
group bookkeeping, FTS entry, `pruneOrphanedMetadata` for an album
whose last track just went) and records each path in `excluded_paths`.
The exclusion is not an enhancement — without it the next scan finds
the file, sees no row and imports it again, so the button undoes itself
and is worse than no button. It is reached from the track list's
context menu behind `confirmAction()`, whose **impact line says the
files are not deleted**, and from `tracklist.delete` (Delete), which is
bound to *opening that dialog* and nothing else: one keystroke from a
focused row, a key that asks is defensible and a key that acts is not.
Four things about it are load-bearing, and two are invisible from the
track list. **The soft scan compares files on disk against rows in the
database**, so an excluded path — on disk, deliberately not a row —
makes the two disagree forever and queues a full scan of the whole
library on *every* launch; `surveyAudioFiles` and `countAudioFiles`
therefore both take the exclusion set, because they answer "how many
files would a scan import", not "how many files are there". **Deleting
an `audio_files` row cascades to `queue_tracks`**, so the removal calls
the same `CompactQueue` hook `RemoveLibrary` does, which reloads the
queue and unloads the player if the removed track was the one playing.
**A full rescan clears the exclusions**, which is the only way back for
a path removed by mistake until there is a UI for the list. And
`TracksRemovedFromLibrary` carries `{filePaths, count}` so
`library-store` splices the tracks array in place and refetches only
the album/artist/genre summaries — falling back to a full invalidate
only when a tracks fetch is already in flight.
**Deleting the file from disk is deliberately not this**, and is not
foreclosed; it needs its own argument.
**An unchanged payload is not an event.** `explore`'s index status used
to be pushed on a 3 s ticker for the life of the process, byte-identical
once the index was ready, and `config-page` assigns it to a `@state`
@@ -343,3 +343,7 @@ JOIN audio_files af ON af.recording_id = r.id
WHERE r.mbid IN (sqlc.slice('mbids'))
AND af.library_id = ?
ORDER BY af.file_path;
-- name: GetAudioFilesByPaths :many
SELECT id, library_id, file_path, group_key FROM audio_files
WHERE file_path IN (sqlc.slice('paths'));
@@ -0,0 +1,15 @@
-- name: ExcludePath :exec
INSERT INTO excluded_paths (library_id, file_path)
VALUES (?, ?)
ON CONFLICT(library_id, file_path) DO NOTHING;
-- name: GetExcludedPathsByLibrary :many
SELECT file_path FROM excluded_paths
WHERE library_id = ?;
-- name: CountExcludedPathsByLibrary :one
SELECT COUNT(*) FROM excluded_paths
WHERE library_id = ?;
-- name: ClearExcludedPaths :exec
DELETE FROM excluded_paths;
@@ -0,0 +1,25 @@
-- Paths the user has removed from the library, which the scanner must
-- not import again.
--
-- "Remove from library" deletes the audio_files row and leaves the file
-- on disk. Without this table the next scan finds the file, sees no
-- row for it, and imports it again — so the exclusion is not an
-- enhancement, it is what makes the operation mean anything.
--
-- A row is keyed by (library_id, file_path) rather than by audio_file
-- id, because the row it names has just been deleted. ON DELETE
-- CASCADE from libraries means removing a library takes its exclusions
-- with it; a full rescan clears the table outright, which is the only
-- way back for a path removed by mistake until there is a UI for it.
CREATE TABLE IF NOT EXISTS excluded_paths (
id INTEGER PRIMARY KEY,
library_id INTEGER NOT NULL,
file_path TEXT NOT NULL,
excluded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(library_id, file_path),
FOREIGN KEY(library_id) REFERENCES libraries(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_excluded_paths_library
ON excluded_paths(library_id);
@@ -648,6 +648,56 @@ func (q *Queries) GetAudioFilesByLibrary(ctx context.Context, libraryID int64) (
return items, nil
}
const getAudioFilesByPaths = `-- name: GetAudioFilesByPaths :many
SELECT id, library_id, file_path, group_key FROM audio_files
WHERE file_path IN (/*SLICE:paths*/?)
`
type GetAudioFilesByPathsRow struct {
ID int64
LibraryID int64
FilePath string
GroupKey string
}
func (q *Queries) GetAudioFilesByPaths(ctx context.Context, paths []string) ([]GetAudioFilesByPathsRow, error) {
query := getAudioFilesByPaths
var queryParams []interface{}
if len(paths) > 0 {
for _, v := range paths {
queryParams = append(queryParams, v)
}
query = strings.Replace(query, "/*SLICE:paths*/?", strings.Repeat(",?", len(paths))[1:], 1)
} else {
query = strings.Replace(query, "/*SLICE:paths*/?", "NULL", 1)
}
rows, err := q.db.QueryContext(ctx, query, queryParams...)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAudioFilesByPathsRow
for rows.Next() {
var i GetAudioFilesByPathsRow
if err := rows.Scan(
&i.ID,
&i.LibraryID,
&i.FilePath,
&i.GroupKey,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getAudioFilesByReleaseGroup = `-- name: GetAudioFilesByReleaseGroup :many
SELECT
af.file_path,
@@ -0,0 +1,75 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.30.0
// source: excluded_paths.sql
package sqlcgen
import (
"context"
)
const clearExcludedPaths = `-- name: ClearExcludedPaths :exec
DELETE FROM excluded_paths
`
func (q *Queries) ClearExcludedPaths(ctx context.Context) error {
_, err := q.db.ExecContext(ctx, clearExcludedPaths)
return err
}
const countExcludedPathsByLibrary = `-- name: CountExcludedPathsByLibrary :one
SELECT COUNT(*) FROM excluded_paths
WHERE library_id = ?
`
func (q *Queries) CountExcludedPathsByLibrary(ctx context.Context, libraryID int64) (int64, error) {
row := q.db.QueryRowContext(ctx, countExcludedPathsByLibrary, libraryID)
var count int64
err := row.Scan(&count)
return count, err
}
const excludePath = `-- name: ExcludePath :exec
INSERT INTO excluded_paths (library_id, file_path)
VALUES (?, ?)
ON CONFLICT(library_id, file_path) DO NOTHING
`
type ExcludePathParams struct {
LibraryID int64
FilePath string
}
func (q *Queries) ExcludePath(ctx context.Context, arg ExcludePathParams) error {
_, err := q.db.ExecContext(ctx, excludePath, arg.LibraryID, arg.FilePath)
return err
}
const getExcludedPathsByLibrary = `-- name: GetExcludedPathsByLibrary :many
SELECT file_path FROM excluded_paths
WHERE library_id = ?
`
func (q *Queries) GetExcludedPathsByLibrary(ctx context.Context, libraryID int64) ([]string, error) {
rows, err := q.db.QueryContext(ctx, getExcludedPathsByLibrary, libraryID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []string
for rows.Next() {
var file_path string
if err := rows.Scan(&file_path); err != nil {
return nil, err
}
items = append(items, file_path)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
+7
View File
@@ -139,6 +139,13 @@ type DownloadRequest struct {
UpdatedAt time.Time
}
type ExcludedPath struct {
ID int64
LibraryID int64
FilePath string
ExcludedAt time.Time
}
type ExploreChampionFt struct {
Title string
ArtistName string
+8
View File
@@ -193,6 +193,14 @@ var tables = []Table{
Note: "Build metadata for explore_index: dump version, coverage " +
"tiers, last refresh.",
},
{
Name: "excluded_paths", Kind: Authored, Lifetime: Cascade,
Note: "Paths the user removed from the library, which the scanner " +
"must not import again. Authored: it is a decision, not " +
"derivable from disk. Cascades with its library, and a full " +
"rescan clears it \u2014 the only way back for a path removed by " +
"mistake.",
},
{
Name: "file_types", Kind: Derived, Lifetime: Retained,
Note: "Static lookup rows seeded from code, not user data.",
+8
View File
@@ -240,6 +240,14 @@ func TestAuthoredCascadesAreDeliberate(t *testing.T) {
// unsubscribing from an artist must stop the albums it queued
// on the user's behalf.
"download_requests": true,
// An exclusion says "do not import this path into library 3".
// Remove that library and no scan will ever visit the path
// again, so the row has nothing left to exclude it from — and
// the data it protects is the *absence* of a row, which the
// library removal has already achieved for everything. Adding
// the library back is the user asking to import it afresh.
"excluded_paths": true,
}
for _, entry := range datamap.ByKind(datamap.Authored) {
+13
View File
@@ -101,6 +101,19 @@ const (
BatchWriteProgress = "BatchWriteProgress"
)
// Track removal events.
//
// TracksRemovedFromLibrary means "these rows are gone and these paths
// will not be imported again", and like TrackPlayCountChanged it
// carries everything a consumer needs to patch rather than invalidate:
// {filePaths: []string, count: int}. The library store splices those
// paths out of its tracks array — which is the expensive collection —
// and refetches only the album/artist/genre summaries, whose counts
// really did change.
const (
TracksRemovedFromLibrary = "TracksRemovedFromLibrary"
)
// Play statistics events.
//
// TrackPlayCountChanged carries everything needed to patch the one
+92
View File
@@ -0,0 +1,92 @@
package library
import (
"fmt"
"path/filepath"
"strings"
"yellowjacket/backend/database/sql/sqlcgen"
)
// libraryRoot is one configured library's id and root directory.
type libraryRoot struct {
id int64
path string
}
// libraryRoots returns every configured library's id and root path.
func (l *Library) libraryRoots() ([]libraryRoot, error) {
libs, err := l.db.Queries.GetAllLibraries(l.ctx)
if err != nil {
return nil, fmt.Errorf("could not load libraries: %w", err)
}
roots := make([]libraryRoot, 0, len(libs))
for _, lib := range libs {
roots = append(roots, libraryRoot{id: lib.ID, path: lib.Path})
}
return roots, nil
}
// pathWithin reports whether path lies under root. Both are cleaned
// first, and the comparison keeps the separator so /music/rock does not
// swallow /music/rockabilly.
func pathWithin(root, path string) bool {
cleanRoot := filepath.Clean(root)
cleanPath := filepath.Clean(path)
if cleanRoot == cleanPath {
return true
}
return strings.HasPrefix(cleanPath, cleanRoot+string(filepath.Separator))
}
// excludeParams is the insert parameter for one exclusion.
func excludeParams(libraryID int64, filePath string) sqlcgen.ExcludePathParams {
return sqlcgen.ExcludePathParams{
LibraryID: libraryID,
FilePath: filePath,
}
}
// excludedPathSet loads a library's excluded paths as a set, cleaned
// the same way the walk builds its absolute paths so the two compare.
//
// A failure here returns an empty set and logs: a scan that cannot read
// the exclusions imports what it finds, which is the pre-exclusion
// behaviour rather than an empty library.
func (l *Library) excludedPathSet(libraryID int64) map[string]struct{} {
paths, err := l.db.Queries.GetExcludedPathsByLibrary(l.ctx, libraryID)
if err != nil {
l.logger.Warn("could not load excluded paths, scanning everything",
"libraryID", libraryID, "err", err)
return nil
}
if len(paths) == 0 {
return nil
}
set := make(map[string]struct{}, len(paths))
for _, p := range paths {
set[filepath.Clean(p)] = struct{}{}
}
return set
}
// isExcluded reports whether an absolute file path is in the set. A
// nil set excludes nothing, which is what every caller wants when
// there are no exclusions at all.
func isExcluded(set map[string]struct{}, absolutePath string) bool {
if len(set) == 0 {
return false
}
_, found := set[filepath.Clean(absolutePath)]
return found
}
+48 -7
View File
@@ -334,7 +334,13 @@ func (l *Library) scanInternal(
// --- Pre-walk: count audio files for progress reporting ---
emitProgress(mkProgress("counting", 0, 0, 0, 0, 0))
totalFiles := countAudioFiles(basePath)
// Paths the user has removed from the library. Loaded once per
// scan: the walk consults it per file, and the counts above and
// below must agree with it or the progress bar and the soft scan
// both describe a library that is not the one being built.
excluded := l.excludedPathSet(libraryID)
totalFiles := countAudioFiles(basePath, excluded)
l.logger.Debug(
"pre-walk file count complete",
@@ -449,6 +455,20 @@ func (l *Library) scanInternal(
return nil
}
// The user removed this path from the library. Leaving
// it out of existingPaths' LoadAndDelete as well is
// deliberate: if a row somehow exists for an excluded
// path, orphan cleanup below deletes it, which is the
// state the user asked for.
if isExcluded(excluded, absoluteFilePath) {
l.logger.Debug(
"skipping excluded path",
"path", absoluteFilePath,
)
return nil
}
// Stat the entry for the staleness comparison below.
// This happens before the file is read, so a file
// modified mid-scan records the pre-read mtime and is
@@ -1217,21 +1237,27 @@ func (l *Library) pruneOrphanedMetadata() {
// countAudioFiles performs a fast walk of the library directory,
// counting only files with supported audio extensions. No per-file
// I/O is performed — this reads only directory entries.
func countAudioFiles(basePath string) int64 {
func countAudioFiles(basePath string, excluded map[string]struct{}) int64 {
var count int64
_ = fs.WalkDir(
os.DirFS(basePath), ".",
func(_ string, d fs.DirEntry, err error) error {
func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
ext := filepath.Ext(d.Name())
if _, ok := metadata.GetSupportedFileType(ext); ok {
count++
if _, ok := metadata.GetSupportedFileType(ext); !ok {
return nil
}
if isExcluded(excluded, filepath.Join(basePath, path)) {
return nil
}
count++
return nil
},
)
@@ -1248,10 +1274,17 @@ func countAudioFiles(basePath string) int64 {
// Unlike countAudioFiles this stats every entry, so it is the more
// expensive of the two walks. Only the startup soft scan uses it —
// the in-scan progress total does not need mtimes.
func surveyAudioFiles(basePath string) (count, maxModTime int64) {
//
// Both walks take the library's excluded paths and skip them, because
// both answer "how many files would a scan import", not "how many
// files are there".
func surveyAudioFiles(
basePath string,
excluded map[string]struct{},
) (count, maxModTime int64) {
_ = fs.WalkDir(
os.DirFS(basePath), ".",
func(_ string, d fs.DirEntry, err error) error {
func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
@@ -1261,6 +1294,14 @@ func surveyAudioFiles(basePath string) (count, maxModTime int64) {
return nil
}
// An excluded path is not a file this scan would import,
// so it must not be counted: the soft scan compares this
// count against the database's, and a permanent
// disagreement queues a full scan on every launch.
if isExcluded(excluded, filepath.Join(basePath, path)) {
return nil
}
count++
info, infoErr := d.Info()
+199
View File
@@ -0,0 +1,199 @@
package library
import (
"errors"
"fmt"
"yellowjacket/backend/events"
)
// errNoPathsToRemove is returned when RemoveFromLibrary is called with
// nothing to remove. A static error because err113 forbids a dynamic
// one, and a sentinel because the frontend distinguishes it.
var errNoPathsToRemove = errors.New("no file paths given to remove")
// RemovalResult reports what one RemoveFromLibrary call did.
type RemovalResult struct {
// TracksRemoved is how many audio_files rows were deleted. It can
// be lower than len(filePaths) if a path was already gone.
TracksRemoved int64 `json:"tracksRemoved"`
// PathsExcluded is how many paths the scanner will now skip.
PathsExcluded int64 `json:"pathsExcluded"`
}
// RemoveFromLibrary deletes the database rows for the given file paths
// and records each path as excluded, so the next scan does not import
// it again. **It does not touch the files on disk** — that is the
// promise the confirmation dialog makes, and the reason this operation
// is safe to put one keystroke from a focused row.
//
// The exclusion is not an enhancement. Without it the next scan finds
// the file, sees no row, and imports it again — a button that undoes
// itself, which is worse than no button.
func (l *Library) RemoveFromLibrary(filePaths []string) (*RemovalResult, error) {
if len(filePaths) == 0 {
return nil, errNoPathsToRemove
}
rows, err := l.db.Queries.GetAudioFilesByPaths(l.ctx, filePaths)
if err != nil {
return nil, fmt.Errorf("could not resolve paths to remove: %w", err)
}
tx, err := l.db.BeginTx()
if err != nil {
return nil, fmt.Errorf("could not begin removal transaction: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
txq := l.db.Queries.WithTx(tx)
var result RemovalResult
// Exclude every path the caller named, including one whose row has
// already gone: the user asked for that file to stay out, and a row
// that disappeared between the click and the commit is not a reason
// to let the next scan bring it back. A path with no row at all is
// attributed to the library that contains it, resolved below.
rowByPath := make(map[string]int64, len(rows))
for _, row := range rows {
rowByPath[row.FilePath] = row.LibraryID
if err := txq.DeleteAudioFile(l.ctx, row.ID); err != nil {
return nil, fmt.Errorf("could not delete audio file row: %w", err)
}
result.TracksRemoved++
// Keep the file's tagging group in sync, exactly as the scan's
// orphan cleanup does: drop the count and clear the group once
// it is empty, or the autotag queue keeps a row counting files
// that no longer exist.
if row.GroupKey != "" {
if err := txq.DecrementTaggingItemTrackCount(l.ctx, row.GroupKey); err != nil {
return nil, fmt.Errorf("could not decrement tagging group: %w", err)
}
if err := txq.DeleteTaggingItemIfEmpty(l.ctx, row.GroupKey); err != nil {
return nil, fmt.Errorf("could not clear emptied tagging group: %w", err)
}
}
}
libraryIDs, err := l.libraryIDsForPaths(filePaths, rowByPath)
if err != nil {
return nil, err
}
for _, path := range filePaths {
libraryID, known := libraryIDs[path]
if !known {
// Outside every configured library: no scan will ever
// visit it, so there is nothing to exclude it from.
l.logger.Debug("removal: path belongs to no library, not excluding",
"path", path)
continue
}
if err := txq.ExcludePath(l.ctx, excludeParams(libraryID, path)); err != nil {
return nil, fmt.Errorf("could not exclude path: %w", err)
}
result.PathsExcluded++
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("could not commit removal: %w", err)
}
committed = true
// Post-commit, and best-effort: the rows are gone either way, and a
// failure here leaves stale index entries rather than a wrong
// library. The FTS5 index is contentless, so a stale entry is
// harmless — searches join track_metadata, which no longer has the
// row — but removing it keeps the index from growing forever.
for _, row := range rows {
if err := l.db.DeleteSearchIndex(row.ID); err != nil {
l.logger.Warn("could not delete FTS entry for removed track",
"path", row.FilePath, "id", row.ID, "err", err)
}
}
// Deleting an audio_files row cascades to queue_tracks, so the
// queue's in-memory copy now holds tracks the database does not —
// including, possibly, the one playing. This is the same reload
// RemoveLibrary does, and it unloads the player if the current
// track was among them.
if l.removalHooks.CompactQueue != nil {
l.removalHooks.CompactQueue()
}
// An album, artist or genre whose last track just went is now a row
// with nothing behind it, and the album list selects from
// release_groups rather than from audio_files — so it would keep
// rendering an album the user has no tracks of.
l.pruneOrphanedMetadata()
l.emit(events.TracksRemovedFromLibrary, map[string]any{
"filePaths": filePaths,
"count": result.TracksRemoved,
})
l.logger.Info("removed tracks from library",
"requested", len(filePaths),
"rowsDeleted", result.TracksRemoved,
"pathsExcluded", result.PathsExcluded,
)
return &result, nil
}
// libraryIDsForPaths maps each path to the library it belongs to. A
// path that still had a row takes that row's library_id; one that did
// not is matched against the configured library roots by prefix, which
// is what the scan walk would do with it.
func (l *Library) libraryIDsForPaths(
filePaths []string,
rowByPath map[string]int64,
) (map[string]int64, error) {
out := make(map[string]int64, len(filePaths))
var libs []libraryRoot
for _, path := range filePaths {
if libraryID, ok := rowByPath[path]; ok {
out[path] = libraryID
continue
}
if libs == nil {
var err error
libs, err = l.libraryRoots()
if err != nil {
return nil, err
}
}
for _, lib := range libs {
if pathWithin(lib.path, path) {
out[path] = lib.id
break
}
}
}
return out, nil
}
+296
View File
@@ -0,0 +1,296 @@
package library
import (
"log/slog"
"os"
"path/filepath"
"slices"
"testing"
"yellowjacket/backend/database"
"yellowjacket/backend/database/sql/sqlcgen"
"yellowjacket/backend/events"
"yellowjacket/internal/testfixtures"
)
// setupScanLibrary builds a Library over a temp directory holding
// copies of `count` real fixture tracks, plus the libraries row the
// scan needs. Real files, because the scan extracts tags from them.
func setupScanLibrary(
t *testing.T,
count int,
) (lib *Library, dir string, paths []string, rec *events.Recorder, libID int64) {
t.Helper()
m := testfixtures.Load(t)
sources := m.Case(t, testfixtures.CaseFLACAlbum)
if len(sources) < count {
t.Fatalf("fixture case has %d tracks, need %d", len(sources), count)
}
dir = t.TempDir()
paths = make([]string, 0, count)
for _, src := range sources[:count] {
data, err := os.ReadFile(src)
if err != nil {
t.Fatalf("read fixture %s: %v", src, err)
}
dst := filepath.Join(dir, filepath.Base(src))
if err := os.WriteFile(dst, data, 0o600); err != nil {
t.Fatalf("write fixture copy: %v", err)
}
paths = append(paths, dst)
}
db := database.NewTestDB(t)
rec = events.NewRecorder()
lib = &Library{
ctx: events.WithSink(t.Context(), rec),
logger: slog.Default(),
conf: &Config{},
db: db,
}
row, err := db.Queries.CreateLibrary(t.Context(), sqlcgen.CreateLibraryParams{
Name: "Test",
Path: dir,
})
if err != nil {
t.Fatalf("create library row: %v", err)
}
slices.Sort(paths)
return lib, dir, paths, rec, row.ID
}
// scannedPaths returns the file paths currently in the database, sorted.
func scannedPaths(t *testing.T, lib *Library) []string {
t.Helper()
rows, err := lib.db.Queries.GetAllAudioFilePaths(t.Context())
if err != nil {
t.Fatalf("read audio file paths: %v", err)
}
out := make([]string, 0, len(rows))
for _, row := range rows {
out = append(out, row.FilePath)
}
slices.Sort(out)
return out
}
// TestRemoveFromLibrary_SurvivesRescan is the assertion the whole phase
// rests on: a removed path stays removed across a real scan of the real
// directory, and the file it named is still on disk.
//
// Its positive half is not optional. A guard that excluded everything
// would satisfy "the removed path did not come back" for free, so the
// same scan must also put back a row deleted *without* an exclusion.
func TestRemoveFromLibrary_SurvivesRescan(t *testing.T) {
t.Parallel()
lib, dir, paths, _, libID := setupScanLibrary(t, 3)
lib.scanInternal(libID, "Test", dir)
if got := scannedPaths(t, lib); len(got) != 3 {
t.Fatalf("first scan imported %d tracks, want 3: %v", len(got), got)
}
removed := paths[0]
// The control: its row is deleted directly, with no exclusion, so
// the same scan has to bring it back.
control := paths[1]
result, err := lib.RemoveFromLibrary([]string{removed})
if err != nil {
t.Fatalf("RemoveFromLibrary: %v", err)
}
if result.TracksRemoved != 1 || result.PathsExcluded != 1 {
t.Fatalf(
"RemoveFromLibrary = %+v, want 1 removed and 1 excluded",
result,
)
}
controlRow, err := lib.db.Queries.GetAudioFileByPath(t.Context(), control)
if err != nil {
t.Fatalf("look up control track: %v", err)
}
if err := lib.db.Queries.DeleteAudioFile(t.Context(), controlRow.ID); err != nil {
t.Fatalf("delete control row: %v", err)
}
lib.scanInternal(libID, "Test", dir)
after := scannedPaths(t, lib)
if slices.Contains(after, removed) {
t.Errorf("excluded path came back after a rescan: %s\nrows: %v", removed, after)
}
if !slices.Contains(after, control) {
t.Errorf(
"the rescan did not re-import a path that was NOT excluded (%s)"+
" — the exclusion is skipping more than it was asked to\nrows: %v",
control, after,
)
}
// The promise the confirmation dialog makes.
if _, err := os.Stat(removed); err != nil {
t.Errorf("removed file is no longer on disk: %v", err)
}
}
// TestRemoveFromLibrary_SoftScanSeesNoChange pins the trap that would
// otherwise queue a full scan on every launch: the soft scan compares
// the number of audio files on disk against the number of rows, and an
// excluded path is on disk and deliberately not a row.
func TestRemoveFromLibrary_SoftScanSeesNoChange(t *testing.T) {
t.Parallel()
lib, dir, paths, _, libID := setupScanLibrary(t, 3)
lib.scanInternal(libID, "Test", dir)
if _, err := lib.RemoveFromLibrary([]string{paths[0]}); err != nil {
t.Fatalf("RemoveFromLibrary: %v", err)
}
dbCount, err := lib.db.Queries.CountAudioFilesByLibrary(t.Context(), libID)
if err != nil {
t.Fatalf("count rows: %v", err)
}
diskCount, _ := surveyAudioFiles(dir, lib.excludedPathSet(libID))
if diskCount != dbCount {
t.Errorf(
"soft scan would see disk %d vs db %d — every launch queues a full scan",
diskCount, dbCount,
)
}
// And the positive half: without the exclusion set the survey still
// counts the file, which is what makes the argument above real
// rather than a tautology about a function that counts nothing.
if raw, _ := surveyAudioFiles(dir, nil); raw != dbCount+1 {
t.Errorf("unfiltered survey = %d, want %d", raw, dbCount+1)
}
}
// TestRemoveFromLibrary_FullRescanClearsExclusions pins the only route
// back for a path removed by mistake.
func TestRemoveFromLibrary_FullRescanClearsExclusions(t *testing.T) {
t.Parallel()
lib, dir, paths, _, libID := setupScanLibrary(t, 2)
lib.scanInternal(libID, "Test", dir)
if _, err := lib.RemoveFromLibrary([]string{paths[0]}); err != nil {
t.Fatalf("RemoveFromLibrary: %v", err)
}
if err := lib.clearLibraryTables(); err != nil {
t.Fatalf("clearLibraryTables: %v", err)
}
count, err := lib.db.Queries.CountExcludedPathsByLibrary(t.Context(), libID)
if err != nil {
t.Fatalf("count exclusions: %v", err)
}
if count != 0 {
t.Fatalf("full rescan left %d exclusions behind", count)
}
lib.scanInternal(libID, "Test", dir)
if !slices.Contains(scannedPaths(t, lib), paths[0]) {
t.Error("a full rescan did not bring back a previously excluded path")
}
}
// TestRemoveFromLibrary_EmitsPatchablePayload checks the event carries
// what a store needs to patch rather than invalidate.
func TestRemoveFromLibrary_EmitsPatchablePayload(t *testing.T) {
t.Parallel()
lib, dir, paths, rec, libID := setupScanLibrary(t, 2)
lib.scanInternal(libID, "Test", dir)
if _, err := lib.RemoveFromLibrary([]string{paths[0]}); err != nil {
t.Fatalf("RemoveFromLibrary: %v", err)
}
ev, ok := rec.Last(events.TracksRemovedFromLibrary)
if !ok {
t.Fatalf("no TracksRemovedFromLibrary emitted; got %v", rec.Names())
}
payload, ok := ev.Payload().(map[string]any)
if !ok {
t.Fatalf("payload is %T, want a map", ev.Payload())
}
got, ok := payload["filePaths"].([]string)
if !ok || len(got) != 1 || got[0] != paths[0] {
t.Errorf("payload filePaths = %v, want [%s]", payload["filePaths"], paths[0])
}
if count, ok := payload["count"].(int64); !ok || count != 1 {
t.Errorf("payload count = %v, want 1", payload["count"])
}
}
// TestRemoveFromLibrary_CompactsTheQueue pins the half that is invisible
// from the track list: deleting an audio_files row cascades to
// queue_tracks, so the queue's in-memory copy — and the player, if it
// was the track playing — has to be told.
func TestRemoveFromLibrary_CompactsTheQueue(t *testing.T) {
t.Parallel()
lib, dir, paths, _, libID := setupScanLibrary(t, 2)
lib.scanInternal(libID, "Test", dir)
compacted := 0
lib.SetRemovalHooks(RemovalHooks{
CompactQueue: func() { compacted++ },
})
if _, err := lib.RemoveFromLibrary([]string{paths[0]}); err != nil {
t.Fatalf("RemoveFromLibrary: %v", err)
}
if compacted != 1 {
t.Errorf("CompactQueue called %d times, want 1", compacted)
}
}
// TestRemoveFromLibrary_RejectsAnEmptyRequest keeps a stray Delete on
// an empty selection from reaching the database at all.
func TestRemoveFromLibrary_RejectsAnEmptyRequest(t *testing.T) {
t.Parallel()
lib, _, _, _, _ := setupScanLibrary(t, 1)
if _, err := lib.RemoveFromLibrary(nil); err == nil {
t.Error("RemoveFromLibrary(nil) succeeded, want an error")
}
}
+9
View File
@@ -124,6 +124,15 @@ func (l *Library) clearLibraryTables() error {
return fmt.Errorf("could not clear queue tracks: %w", err)
}
// A full rescan is the "start over" button, and it is the only way
// back for a path removed from the library by mistake: the file is
// still on disk, but nothing else will ever import it again while
// its exclusion stands. Until there is a UI for managing the list,
// clearing it here is the escape hatch.
if err := txq.ClearExcludedPaths(l.ctx); err != nil {
return fmt.Errorf("could not clear excluded paths: %w", err)
}
// Preserve playlist tracks across rescan: populate phantom
// metadata for all linked tracks before audio_files are deleted.
// ON DELETE SET NULL will null out audio_file_id, converting them
+6 -1
View File
@@ -181,7 +181,12 @@ func (l *Library) SoftScanAllLibraries() error {
continue
}
diskCount, diskModTime := surveyAudioFiles(lib.Path)
// Excluded paths are on disk and deliberately not in the
// database, so the survey must skip them or the two counts
// disagree forever and every launch queues a full scan.
diskCount, diskModTime := surveyAudioFiles(
lib.Path, l.excludedPathSet(lib.ID),
)
// A newer file on disk than anything on record means something
// was edited in place since the last scan. An older newest-mtime
+3 -3
View File
@@ -130,7 +130,7 @@ func TestSurveyAudioFiles(t *testing.T) {
// the result, or every artwork change would trigger a rescan.
setModTime(t, filepath.Join(dir, "cover.jpg"), time.Now())
count, maxMod := surveyAudioFiles(dir)
count, maxMod := surveyAudioFiles(dir, nil)
if count != 2 {
t.Errorf("count = %d, want 2", count)
@@ -145,7 +145,7 @@ func TestSurveyAudioFiles(t *testing.T) {
touched := time.Now()
setModTime(t, filepath.Join(dir, "a.mp3"), touched)
_, afterMod := surveyAudioFiles(dir)
_, afterMod := surveyAudioFiles(dir, nil)
if afterMod != touched.Unix() {
t.Errorf(
@@ -158,7 +158,7 @@ func TestSurveyAudioFiles(t *testing.T) {
func TestSurveyAudioFiles_EmptyDir(t *testing.T) {
t.Parallel()
count, maxMod := surveyAudioFiles(t.TempDir())
count, maxMod := surveyAudioFiles(t.TempDir(), nil)
if count != 0 || maxMod != 0 {
t.Errorf(
+10 -9
View File
@@ -33,15 +33,16 @@ func DefaultBindings() map[string]string {
"app.selectAll": "Ctrl+A",
"app.shortcuts": "?",
// Panel-specific (track list). There is no `tracklist.delete`:
// it was bound to Delete and advertised in Settings as
// configurable while nothing listened for it, because "remove
// from library" does not exist and it is not clear what it would
// remove — 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). Advertise it again when it does
// something.
"tracklist.play": "Enter",
// Panel-specific (track list). `tracklist.delete` spent six
// phases advertised in Settings with nothing on the other end of
// it, because "remove from library" did not exist and it was not
// clear what it would remove. It now removes the row and
// excludes the path from future scans, and leaves the file on
// disk — and the key only *opens the confirmation*, never
// performs the removal, which is the only version defensible one
// keystroke from a focused row.
"tracklist.play": "Enter",
"tracklist.delete": "Delete",
// Panel-specific (autotag review). These are the keys the
// autotag page used to bind on its own document listener, which
+149
View File
@@ -0,0 +1,149 @@
import { existsSync } from 'node:fs';
import { test, expect, resetEvents, callBinding, eventNames } from '../support/fixtures.js';
/**
* "Remove from library" removes the row and leaves the file.
*
* Two assertions carry this spec and neither is about the row count.
* The first is that the **file is still on disk** — that is the promise
* the confirmation copy makes, and the only thing standing between this
* feature and a user's music. The second is that a **real scan does not
* bring the row back**: without the exclusion the operation undoes
* itself on the next scan, which is worse than not having it.
*
* The suite shares one backend process in file order, so this restores
* the database it spent.
*/
const SNAPSHOT = 'e2e-pre-remove';
/** The file paths of the first n rows, in list order. */
const firstPaths = (n: number): string[] => Array.from(
document.querySelector('track-list')
?.shadowRoot?.querySelectorAll('[data-file-path]') ?? [],
).map((r) => r.getAttribute('data-file-path') ?? '').slice(0, n);
test.describe('remove from library', () => {
test.beforeAll(async ({ baseURL }) => {
// VACUUM INTO copies the whole file and the restore copies every row
// back, which is well over the 30 s a hook gets by default once
// earlier specs have staged an explore catalog.
test.setTimeout(180_000);
const res = await fetch(`${baseURL}/__test/db/snapshot?name=${SNAPSHOT}`, {
method: 'POST',
signal: AbortSignal.timeout(120_000),
});
expect(res.ok, 'could not snapshot the database before spending it').toBe(true);
});
test.afterAll(async ({ baseURL }) => {
test.setTimeout(180_000);
const res = await fetch(`${baseURL}/__test/db/restore?name=${SNAPSHOT}`, {
method: 'POST',
signal: AbortSignal.timeout(120_000),
});
expect(res.ok, 'could not restore the database this spec spent').toBe(true);
});
test.beforeEach(async ({ app }) => {
await app.getByTestId('nav-tracks').click();
await expect(app.getByTestId('track-row').first()).toBeVisible();
});
/**
* Delete is bound to *opening* the confirmation and to nothing else.
* A key that asks is defensible one row from the user's music; a key
* that acts is not.
*/
test('Delete asks, and cancelling is a true no-op', async ({ app }) => {
const before = await app.getByTestId('track-row').count();
await resetEvents(app);
await app.evaluate(() => {
const rows = document.querySelector('track-list')
?.shadowRoot?.querySelectorAll('[data-testid="track-row"]');
rows?.[2]?.dispatchEvent(new MouseEvent('click', {
bubbles: true, composed: true,
}));
});
await app.keyboard.press('Delete');
const dialog = app.getByRole('dialog', { name: /from the library\?/ });
await expect(dialog).toBeVisible();
// The copy is the user's only protection, so it is asserted rather
// than assumed: it has to say the file is not deleted.
await expect(app.getByTestId('confirm-dialog')).toContainText(
/not deleted/,
);
await app.getByTestId('confirm-cancel').click();
await expect(dialog).toBeHidden();
expect(await app.getByTestId('track-row').count()).toBe(before);
expect((await eventNames(app))['TracksRemovedFromLibrary'] ?? 0).toBe(0);
});
test('confirming removes the row, keeps the file, and survives a scan', async ({
app,
}) => {
test.setTimeout(120_000);
const [target, control] = await app.evaluate(firstPaths, 2);
expect(target, 'no tracks in the library to remove').toBeTruthy();
expect(existsSync(target!), 'fixture file missing before the test').toBe(true);
const before = await app.getByTestId('track-row').count();
await resetEvents(app);
await app.getByTestId('track-row').first().click({ button: 'right' });
await app.getByRole('menuitem', { name: 'Remove from Library' }).click();
await app.getByTestId('confirm-accept').click();
const removed = await app.evaluate(
() => window.__yjEvents.wait('TracksRemovedFromLibrary', {
timeoutMs: 15_000,
}),
);
expect((removed.data as Array<Record<string, unknown>>)[0]).toMatchObject({
filePaths: [target],
count: 1,
});
await expect(app.getByTestId('track-row')).toHaveCount(before - 1);
// The promise the copy makes.
expect(existsSync(target!), 'the file was deleted from disk').toBe(true);
// And the half that makes the rest true: a real scan of the real
// directory must not import it again.
await resetEvents(app);
await callBinding(app, 'library.Library.ScanAllLibraries', []);
await app.evaluate(
() => window.__yjEvents.wait('LibraryScanComplete', { timeoutMs: 90_000 }),
);
await app.waitForTimeout(1000);
const paths = await app.evaluate(
() => Array.from(
document.querySelector('track-list')
?.shadowRoot?.querySelectorAll('[data-file-path]') ?? [],
).map((r) => r.getAttribute('data-file-path') ?? ''),
);
expect(paths, 'the excluded path came back on the next scan')
.not.toContain(target);
// The positive half: a guard that excluded everything would pass
// the assertion above for free.
expect(paths, 'the scan lost a path nobody excluded').toContain(control);
expect(existsSync(target!), 'the file was deleted from disk').toBe(true);
});
});
+339
View File
@@ -0,0 +1,339 @@
import { test, expect, callBinding } from '../support/fixtures.js';
/**
* The badge says an album is requested, and the button beside it agrees.
*
* `library-status-indicator` has had three states since it was written
* and produced two: every one of the eight call sites was a two-way
* ternary, so an album already on the request list showed a plus and
* said "is not in your library" — on the same page, forty pixels from a
* filled button reading "Wanted".
*
* This spec exists at this tier rather than only in the component one
* because of what it drags in with it: reaching the requested state is
* also the only way to render the requested *icon*, and the requested
* icon was `bookmark-check`, a Font Awesome **Pro** name that has never
* been bundled. `offline-icons.spec.ts` asserts `__yjIconMisses` is
* empty and passed anyway, because no spec had ever put the app in this
* state. A name computed from state is only checkable from the state.
*
* It gives back what it spends: the request is removed in `afterAll`,
* and the staged catalog rows are `INSERT OR IGNORE`d so a second run
* against the same backend is a no-op.
*/
/** A release group that exists whether or not this environment has a
* catalog — CI's `YJ_CORE_INDEX_URL` is deliberately dead. */
const MBID = 'e2e-rg-badge-0001';
const TITLE = 'Requested Album';
const ARTIST = 'Badge Artist';
let requestId = 0;
test.describe('the requested badge', () => {
test.beforeAll(async ({ browser, baseURL }) => {
const page = await browser.newPage();
await page.goto(baseURL!);
const res = await page.evaluate(
async (row) => {
const r = await fetch('/__test/sql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sql: `INSERT OR IGNORE INTO explore_index
(entity_type, mbid, title, artist_name, artist_mbid,
popularity, listener_count, primary_type)
VALUES ('release_group', ?, ?, ?, 'e2e-ar-badge', 10, 10, 'Album')`,
args: [row.mbid, row.title, row.artist],
}),
});
return { status: r.status, body: await r.text() };
},
{ mbid: MBID, title: TITLE, artist: ARTIST },
);
// A setup step whose failure is not checked is not setup.
expect(res.status, `staging failed: ${res.body}`).toBe(200);
// A previous run that died between adding and removing would leave
// this album requested, and the first assertion here is that it is
// not — so start from a known state rather than from the last run's
// luck. The 90 specs share one backend in file order.
await clearRequest(page);
await page.close();
});
test.afterAll(async ({ browser, baseURL }) => {
const page = await browser.newPage();
await page.goto(baseURL!);
await clearRequest(page);
await page.close();
requestId = 0;
});
test('a requested album is queued, not absent', async ({ app }) => {
const libraryId = 1;
// Before: the badge for an unrequested album.
await app.getByTestId('nav-explore').click();
await search(app, TITLE);
expect(await badgeStatus(app, TITLE)).toBe('not-in-library');
requestId = await addRequest(app, libraryId);
expect(requestId).toBeGreaterThan(0);
// The badge is told by the store, which is told by the backend —
// no navigation, no reload.
await expect
.poll(() => badgeStatus(app, TITLE), { timeout: 10_000 })
.toBe('queued');
// And it says so where it counts. The name is the whole point —
// the plus used to be accompanied by "is not in your library" —
// and since phase 3 it is a control, so the name is the action it
// performs rather than the state it is in.
expect(await badgeLabel(app, TITLE)).toBe(
`Cancel the request for album "${TITLE}"`,
);
});
test('clicking the badge wants the album and does not open it', async ({
app,
}) => {
// Only this tier can say this. The badge sits inside a card whose
// own click navigates, so the assertion is that a real gesture on
// the badge files a request *and* leaves the page where it was —
// and a synthetic MouseEvent is not evidence of either.
await clearRequest(app);
await app.getByTestId('nav-explore').click();
await search(app, TITLE);
// A locator rather than measured coordinates, and the difference
// is not style. The first version read a bounding box the moment
// the search settled and clicked it — but 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. A locator
// re-resolves and waits for the element to stop moving.
const button = app
.locator(`library-status-indicator[label="${TITLE}"] button`)
.first();
// A badge that is not a button makes every assertion below vacuous.
await expect(button, 'the badge is not a button').toBeVisible();
await button.click();
await expect
.poll(() => badgeStatus(app, TITLE), { timeout: 10_000 })
.toBe('queued');
expect(
await app.evaluate(() => !!document.querySelector('explore-album-details')),
'the click reached the card underneath',
).toBe(false);
requestId = 1; // so afterAll cleans up regardless of order
});
test('the requested state renders a real icon', async ({ app }) => {
// `bookmark-check` is Pro, so the button rendered the fallback
// glyph for as long as anything had been requested. The sweep in
// offline-icons.spec.ts could not see it: it never reached here.
// Runs after the test above in file order, which is where the
// request comes from — but a spec that only passes as part of a
// sequence is a spec that lies when it is run alone.
if (!requestId) requestId = await addRequest(app, 1);
await app.getByTestId('nav-explore').click();
await search(app, TITLE);
await openFirstAlbum(app);
await expect
.poll(
() =>
app.evaluate(() => {
const ds = document.querySelector('explore-album-details')
?.shadowRoot;
const btn = [...(ds?.querySelectorAll('wa-button') ?? [])].find(
(b) => /Wanted/.test(b.textContent ?? ''),
);
return btn?.querySelector('wa-icon')?.getAttribute('name') ?? '';
}),
{ timeout: 10_000 },
)
.not.toBe('');
const misses = await app.evaluate(
() =>
(window as unknown as { __yjIconMisses?: string[] }).__yjIconMisses ??
[],
);
expect(misses, 'an icon name that is not bundled').toEqual([]);
});
});
function addRequest(
app: import('@playwright/test').Page,
libraryId: number,
): Promise<number> {
return callBinding<number>(app, 'download.Service.AddRequest', [
{
mbid: MBID,
entity: 'release-group',
libraryId,
artist: ARTIST,
title: TITLE,
scope: 'future',
secondary: false,
},
]);
}
/**
* Drop any request for this album, through the raw binding.
*
* Deliberately not `callBinding`: that goes through `window.__yjEvents`,
* which only exists on a page the `app` fixture created — a bare
* `browser.newPage()` has no init script, so the bridge is undefined and
* the cleanup throws where nobody is looking. The first version of this
* did exactly that and left the request behind, which failed the *next*
* run of this same spec.
*/
async function clearRequest(page: import('@playwright/test').Page) {
await page.evaluate(async (mbid) => {
const go = (
window as unknown as {
go?: {
download?: {
Service?: {
ListRequests(): Promise<{ id: number; mbid: string }[]>;
RemoveRequest(id: number): Promise<void>;
};
};
};
}
).go;
const svc = go?.download?.Service;
if (!svc) return;
const rows = (await svc.ListRequests()) ?? [];
for (const row of rows) {
if (row.mbid?.toLowerCase() === mbid.toLowerCase()) {
await svc.RemoveRequest(row.id);
}
}
}, MBID);
}
/** Type into Explore's own search box and wait for it to settle. */
async function search(app: import('@playwright/test').Page, term: string) {
// A view is a chunk, and `document.createElement` on a tag that has
// not loaded yet yields an inert element rather than throwing — so
// the box being missing reads exactly like a selector bug.
await expect
.poll(
() =>
app.evaluate(
() =>
!!document
.querySelector('explore-view')
?.shadowRoot?.querySelector('input'),
),
{ timeout: 15_000 },
)
.toBe(true);
await app.evaluate((t) => {
const sr = document.querySelector('explore-view')?.shadowRoot;
const input = sr?.querySelector('input');
if (!input) throw new Error('explore search box not found');
input.value = t;
input.dispatchEvent(new Event('input', { bubbles: true }));
}, term);
// 60 s, and it is not paranoia. A freshly launched app spends its
// first ~40 s merging the core catalog artifact, and until that lands
// Explore's search returns nothing at all — including for rows staged
// directly into `explore_index` a moment ago. A shorter budget fails
// here for a reason that has nothing to do with what is being tested,
// which is how this spec first "failed" on a build that was fine.
await expect
.poll(() => cardTitles(app), { timeout: 60_000 })
.toContain(term);
}
function cardTitles(app: import('@playwright/test').Page): Promise<string[]> {
return app.evaluate(() =>
[
...(document
.querySelector('explore-view')
?.shadowRoot?.querySelectorAll('library-status-indicator') ?? []),
].map((b) => b.getAttribute('label') ?? ''),
);
}
function badgeStatus(
app: import('@playwright/test').Page,
label: string,
): Promise<string> {
return app.evaluate((wanted) => {
const badge = [
...(document
.querySelector('explore-view')
?.shadowRoot?.querySelectorAll('library-status-indicator') ?? []),
].find((b) => b.getAttribute('label') === wanted);
return badge?.getAttribute('status') ?? '(no badge)';
}, label);
}
function badgeLabel(
app: import('@playwright/test').Page,
label: string,
): Promise<string> {
return app.evaluate((wanted) => {
const badge = [
...(document
.querySelector('explore-view')
?.shadowRoot?.querySelectorAll('library-status-indicator') ?? []),
].find((b) => b.getAttribute('label') === wanted);
return (
badge?.shadowRoot?.querySelector('.badge')?.getAttribute('aria-label') ??
'(no badge)'
);
}, label);
}
async function openFirstAlbum(app: import('@playwright/test').Page) {
await app.evaluate((wanted) => {
const badge = [
...(document
.querySelector('explore-view')
?.shadowRoot?.querySelectorAll('library-status-indicator') ?? []),
].find((b) => b.getAttribute('label') === wanted);
(badge?.closest('.album-card') as HTMLElement | null)?.click();
}, TITLE);
await expect
.poll(
() => app.evaluate(() => !!document.querySelector('explore-album-details')),
{ timeout: 15_000 },
)
.toBe(true);
}
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><!--! Font Awesome Free 7.3.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2026 Fonticons, Inc. --><path fill="currentColor" d="M0 64C0 28.7 28.7 0 64 0L320 0c35.3 0 64 28.7 64 64l0 417.1c0 25.6-28.5 40.8-49.8 26.6L192 412.8 49.8 507.7C28.5 521.9 0 506.6 0 481.1L0 64zM64 48c-8.8 0-16 7.2-16 16l0 387.2 117.4-78.2c16.1-10.7 37.1-10.7 53.2 0L336 451.2 336 64c0-8.8-7.2-16-16-16L64 48z"/></svg>

After

Width:  |  Height:  |  Size: 566 B

@@ -26,6 +26,7 @@ import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '../library-status-indicator/library-status-indicator.js';
import { libraryStatusFor } from '@utils/library-status';
import type { LibraryStatus } from '../library-status-indicator/library-status-indicator.js';
import '../catalog-scope-notice/catalog-scope-notice.js';
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
@@ -623,6 +624,36 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
color: var(--yj-text-secondary, #b3b3b3);
font-weight: 400;
}
/* The request control is only offered where there is
* something to request, and only when the row is being
* attended to — a column of plus signs down a mostly-owned
* album is the clutter the green ticks were.
*
* Hidden with opacity, never display:none or visibility,
* so it keeps its place in the layout (rows do not reflow
* as the pointer moves) and stays in the tab order and the
* accessibility tree. focus-within is what makes it
* reachable without a mouse: tabbing to the button reveals
* it, and the row's own focus reveals it before you get
* there. */
.track-row .track-request {
flex-shrink: 0;
opacity: 0;
transition: opacity 0.12s ease;
}
.track-row:hover .track-request,
.track-row:focus-within .track-request,
.track-row .track-request:focus-visible {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.track-row .track-request {
transition: none;
}
}
`,
];
@@ -1902,7 +1933,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
* - cachedAlbums has MBID match → owned
* - any selected version has → owned (covers local-only albums
* a track marked inLibrary where releaseGroup may be null)
* - else → not owned
* - else → whatever the request list says
*
* Four different claims of decreasing confidence, OR'd together and
* reported as one tick — the last of which fires when a *single*
@@ -1911,8 +1942,9 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
* actions key off; this stays as it was, because the indicator's
* job is "is any of this yours" and that is what it answers.
*
* No queued state for now — that's reserved for future
* download-client integration.
* When none of them hold the answer is not automatically "no":
* the album may be on the request list, which the button directly
* below this badge has reported as "Wanted" all along.
*/
/**
* What the badge beside the album title shows.
@@ -1934,7 +1966,7 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
return 'in-library';
}
private albumLibraryStatus(): 'in-library' | 'not-in-library' {
private albumLibraryStatus(): LibraryStatus {
if (this.localAlbumId > 0) return 'in-library';
if (this.releaseGroup?.inLibrary) return 'in-library';
@@ -1956,7 +1988,11 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
}
}
return 'not-in-library';
// None of the five ownership claims held, so the badge falls
// through to the one thing this page already knew and never
// said: whether the album is on the request list. The button
// below it has read "Wanted" all along.
return libraryStatusFor(false, this.releaseGroupMBID);
}
/**
@@ -2465,9 +2501,14 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
appearance=${this.isRequested ? 'filled' : 'outlined'}
@click=${() => void this.toggleRequested(request?.id)}
>
<!-- The requested state used to ask for bookmark-check,
which is a Font Awesome *Pro* name: never bundled,
so this button has rendered the missing-icon
fallback in that state ever since. Outline and solid
of the same Free glyph carry the toggle instead. -->
<wa-icon
slot="start"
name=${this.isRequested ? 'bookmark-check' : 'bookmark'}
name=${this.isRequested ? 'solid/bookmark' : 'regular/bookmark'}
></wa-icon>
${this.isRequested ? 'Wanted' : 'Want this'}
</wa-button>
@@ -2906,6 +2947,21 @@ export class ExploreAlbumDetails extends LitElement implements ContextMenuHost {
track.length,
)}</span
>
${track.inLibrary
? nothing
: html`
<library-status-indicator
class="track-request"
status=${libraryStatusFor(
false,
track.mbid,
)}
entity-type="track"
label=${track.title}
request-mbid=${track.mbid}
request-artist=${this.artistName}
></library-status-indicator>
`}
</div>
`,
)}
@@ -39,6 +39,7 @@ import { EventsOn } from '@runtime/runtime';
import { Events } from '../../events';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import '../library-status-indicator/library-status-indicator.js';
import { libraryStatusFor } from '@utils/library-status';
import '../catalog-scope-notice/catalog-scope-notice.js';
import type { CatalogScope } from '../catalog-scope-notice/catalog-scope-notice.js';
import { queueStore } from '../../store/queue-store';
@@ -2471,9 +2472,11 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
${formatListenCount(t.totalListenCount)} plays
</span>
<library-status-indicator
status=${t.inLibrary || t.localId ? 'in-library' : 'not-in-library'}
status=${libraryStatusFor(Boolean(t.inLibrary || t.localId), t.recordingMbid)}
entity-type="track"
label=${t.trackName}
request-mbid=${t.recordingMbid}
request-artist=${t.artistName ?? ''}
></library-status-indicator>
</div>
`,
@@ -2583,9 +2586,11 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
${rg.date ? html`<span>${extractYear(rg.date)}</span>` : nothing}
</div>
<library-status-indicator
status=${rg.inLibrary || rg.localId ? 'in-library' : 'not-in-library'}
status=${libraryStatusFor(Boolean(rg.inLibrary || rg.localId), rg.releaseGroupMbid)}
entity-type="album"
label=${rg.title}
request-mbid=${rg.releaseGroupMbid}
request-artist=${this.artist?.name ?? ''}
size="18"
></library-status-indicator>
</div>
@@ -2678,9 +2683,7 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
const artURL = this.thumbnailURLs.get(rg.mbid) || '';
const year = extractYear(rg.firstReleaseDate);
const inLibrary = this.libraryMBIDs.has(rg.mbid) || Boolean(rg.inLibrary);
const status: 'in-library' | 'not-in-library' = inLibrary
? 'in-library'
: 'not-in-library';
const status = libraryStatusFor(inLibrary, rg.mbid);
return html`
<div
@@ -2717,6 +2720,8 @@ export class ExploreArtistDetails extends LitElement implements ContextMenuHost
status=${status}
entity-type="album"
label=${rg.title}
request-mbid=${rg.mbid}
request-artist=${this.artist?.name ?? ''}
></library-status-indicator>
</div>
</div>
@@ -1,4 +1,6 @@
import { avatarBackground } from '@utils/avatar-color';
import { libraryStatusFor } from '@utils/library-status';
import { downloadStore } from '@store/download-store';
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query as litQuery } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js';
@@ -786,6 +788,19 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
this.cancelIndexStatus = EventsOn(Events.IndexStatusChanged, () => {
if (this.shelves?.state !== 'ready') void this.loadShelves();
});
// The badges on every result card say whether something is
// already requested, which a background reconcile pass changes
// without this page doing anything. Registered `whileActive`
// rather than on connect: this view is cached and never
// unmounts, so a connect-time subscription would run for the
// life of the session from pages it is not on.
//
// `init()` is four fetches, and it happens on arrival for the
// same reason `loadShelves()` does — a user who never opens
// Explore should not pay for it.
this.whileActive(downloadStore.subscribe(() => this.requestUpdate()));
void downloadStore.init().then(() => this.requestUpdate());
}
/** A debounced search that lands after the user has left the page is
@@ -1816,6 +1831,9 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
<wa-icon class="search-icon" name="magnifying-glass"></wa-icon>
<input
type="text"
aria-label=${this.searchMode === 'lyrics'
? 'Search the catalog by a lyric'
: 'Search the catalog'}
placeholder=${placeholder}
.value=${this.searchQuery}
@input=${this.handleInput}
@@ -2146,9 +2164,11 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
${year ? html`<span>${year}</span>` : nothing}
</div>
<library-status-indicator
status=${this.libraryMBIDs.has(rg.mbid) || rg.inLibrary ? 'in-library' : 'not-in-library'}
status=${libraryStatusFor(this.libraryMBIDs.has(rg.mbid) || Boolean(rg.inLibrary), rg.mbid)}
entity-type="album"
label=${rg.title}
request-mbid=${rg.mbid}
request-artist=${rg.artistCredit ?? ''}
></library-status-indicator>
</div>
</div>
@@ -2207,9 +2227,11 @@ export class ExploreView extends ViewLifecycleMixin(LitElement) implements Conte
: nothing}
</div>
<library-status-indicator
status=${this.libraryMBIDs.has(r.mbid) || r.inLibrary ? 'in-library' : 'not-in-library'}
status=${libraryStatusFor(this.libraryMBIDs.has(r.mbid) || Boolean(r.inLibrary), r.mbid)}
entity-type="track"
label=${r.title}
request-mbid=${r.mbid}
request-artist=${r.artistCredit ?? ''}
></library-status-indicator>
</div>
`,
@@ -1,6 +1,9 @@
import { LitElement, html, css, nothing } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { customElement, property, state } from 'lit/decorators.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { toggleRequest } from '@utils/library-status';
import { notificationStore } from '@store/notification-store';
import { describeError } from '@utils/describe-error';
/**
* Library status for an entity (artist, album, or track).
@@ -25,18 +28,23 @@ export type LibraryStatus =
* Tri-state library status indicator: a small circular badge embedded
* in track rows, album cards, and artist cards.
*
* **It is a badge, not a control.** It was a `<button>` whose click
* handler was a `stopPropagation()` and a comment saying to wire up
* the download client later — so an Explore results page offered 20
* keyboard stops (of 66) that promised an action and performed none,
* and every one of them announced itself as a button. A control that
* cannot act is worse than no control: it costs the keyboard user the
* tab stop *and* the expectation.
* **It is a button only where it can act, and a badge everywhere
* else.** It used to be a `<button>` whose click handler was a
* `stopPropagation()` and a comment saying to wire up the download
* client later, so an Explore results page offered 20 keyboard stops
* (of 66) that promised an action and performed none. 007 made it
* `role="img"` for that reason and wrote down what would change the
* answer: a `<button>` again *with* a handler, never a handler bolted
* onto something already shaped like one.
*
* So it is `role="img"` with a label, until there is something to
* click. When the download-client integration lands, the right change
* is to make it a `<button>` again *with a handler* — not to add the
* handler to something already shaped like a button.
* A call site opts in by passing `request-mbid`. Where it does, this
* is a `<button>` that toggles a durable **request** — and the copy
* says so, because clicking still adds nothing to the library. Where
* it does not (`explore-album-details`'s header, which has "Want this"
* in words directly below it) it stays exactly what it was.
*
* An `in-library` badge is never a button under either: there is
* nothing left to ask for.
*
* Colours and glyphs:
* - in-library → green circle, check mark
@@ -89,6 +97,38 @@ export class LibraryStatusIndicator extends LitElement {
@property({ type: Number })
expected = 0;
/**
* MBID to request when this is clicked. Supplying it is what makes
* this a control; omitting it leaves a badge. Only `album` and
* `track` are requestable — see `utils/library-status.ts`.
*/
@property({ type: String, attribute: 'request-mbid' })
requestMbid = '';
/** Display-cache artist for the request list. Matching is by MBID. */
@property({ type: String, attribute: 'request-artist' })
requestArtist = '';
@state()
private busy = false;
/**
* True when this can act: a call site opted in, and there is
* something left to ask for.
*
* `partial` is deliberately actionable — an album you hold nine of
* twelve tracks of has three left to request, which is exactly the
* case worth asking about. Only `in-library` is complete enough to
* have nothing to ask for.
*/
private get actionable(): boolean {
return (
this.requestMbid !== '' &&
this.status !== 'in-library' &&
this.entityType !== 'artist'
);
}
static override styles = css`
:host {
display: inline-flex;
@@ -148,7 +188,8 @@ export class LibraryStatusIndicator extends LitElement {
/* A <button> gets box-sizing: border-box from the UA
* stylesheet and a <span> does not, so dropping the button
* grew the badge by its 1px border on each side — 36px to
* 38px, caught by the stored screenshot. */
* 38px, caught by the stored screenshot. Set explicitly so
* the two branches of render() are the same size. */
box-sizing: border-box;
width: var(--indicator-size);
height: var(--indicator-size);
@@ -168,6 +209,29 @@ export class LibraryStatusIndicator extends LitElement {
wa-icon {
font-size: calc(var(--indicator-size) * 0.55);
line-height: 1;
pointer-events: none;
}
button.badge {
cursor: pointer;
font: inherit;
}
button.badge:hover:not(:disabled) {
filter: brightness(1.25);
}
button.badge:disabled {
cursor: default;
opacity: 0.6;
}
/* The card underneath draws its own focus ring, and this sits
* inside it — so the badge needs one of its own or a keyboard
* user cannot tell which of the two has focus. */
button.badge:focus-visible {
outline: 2px solid var(--yj-accent, #ffd43b);
outline-offset: 2px;
}
/* Prevent the button from intercepting drag gestures on album
@@ -204,6 +268,17 @@ export class LibraryStatusIndicator extends LitElement {
: 'track';
const name = this.label ? ` "${this.label}"` : '';
// A control is named after what activating it does; a badge is
// named after what it is. Both are still deliberately about the
// *request list* rather than the library — clicking this adds a
// row to one and nothing to the other, and "Add … to library"
// was the old button's promise written into the copy.
if (this.actionable) {
return this.status === 'queued'
? `Cancel the request for ${kind}${name}`
: `Want ${kind}${name}`;
}
switch (this.status) {
case 'in-library':
return `${capitalize(kind)}${name} is in your library`;
@@ -214,12 +289,64 @@ export class LibraryStatusIndicator extends LitElement {
case 'queued':
return `${capitalize(kind)}${name} is queued for download`;
default:
// Not "Add … to library": nothing here adds anything.
// The old copy was the button's promise written out.
return `${capitalize(kind)}${name} is not in your library`;
}
}
/**
* Toggle the request.
*
* The click is swallowed, which it was before too — but for the
* opposite reason. 007 removed a `stopPropagation()` that guarded
* nothing, on the rule that with no action of its own the badge is
* part of its card and a click on it should mean what the card
* means. Now it has one, so it does not.
*/
private async onActivate(event: Event) {
event.stopPropagation();
event.preventDefault();
if (this.busy || !this.actionable) return;
this.busy = true;
try {
await toggleRequest({
mbid: this.requestMbid,
entity: this.entityType === 'album' ? 'album' : 'track',
title: this.label,
artist: this.requestArtist,
});
} catch (err) {
console.error('could not update the request list', err);
// Transient: the badge visibly stayed where it was, so
// there is nothing for the user to do about it that they
// are not already doing.
notificationStore.transient({
text: describeError(err, 'That request could not be updated.'),
tone: 'error',
});
} finally {
this.busy = false;
}
}
/**
* Keep Enter and Space from reaching the card underneath.
*
* A `<button>` fires `click` on both by itself, so this only has to
* stop the keydown propagating — every card holding one of these is
* a `role="button"` or `role="option"` with its own Enter/Space
* handler, and without this a keyboard activation would both file
* the request and open the page.
*/
private onKeydown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ' ') {
event.stopPropagation();
}
}
override render() {
// Sync the host CSS variable with the configured size.
if (this.size && this.size !== 20) {
@@ -228,13 +355,35 @@ export class LibraryStatusIndicator extends LitElement {
const title = this.tooltip();
// The ring stands in for the icon wherever the icon would go —
// including inside the button, because a partly-held album is
// actionable (it has tracks left to request) and must still
// show how much of it is here.
const glyph = this.status === 'partial'
? this.renderRing()
: this.iconName()
? html`<wa-icon name=${this.iconName()} aria-hidden="true"></wa-icon>`
: nothing;
if (this.actionable) {
return html`
<button
class="badge"
type="button"
title=${title}
aria-label=${title}
?disabled=${this.busy}
@click=${this.onActivate}
@keydown=${this.onKeydown}
>
${glyph}
</button>
`;
}
return html`
<span class="badge" role="img" title=${title} aria-label=${title}>
${this.status === 'partial'
? this.renderRing()
: this.iconName()
? html`<wa-icon name=${this.iconName()} aria-hidden="true"></wa-icon>`
: nothing}
${glyph}
</span>
`;
}
@@ -10,6 +10,8 @@ import {
import '../library-status-indicator/library-status-indicator.js';
import type { LibraryStatus } from '../library-status-indicator/library-status-indicator.js';
import { artistLink, exploreLinkStyles } from '../../utils/explore-link';
import { libraryStatusFor } from '../../utils/library-status';
import { downloadStore } from '../../store/download-store';
/** Format milliseconds as m:ss. */
function formatDuration(ms: number | undefined): string {
@@ -50,6 +52,28 @@ export class TopResultsRow extends LitElement {
// Per-card state: cover images.
private images = new Map<string, string>();
private unsubRequests?: () => void;
/**
* The badges here say whether something is already requested, and
* this row will not hear about a change from its host: `explore-view`
* re-rendering sets the same `results` array back, so Lit stops at
* the property and never updates this element. One subscription for
* the row, not one per card.
*/
override connectedCallback(): void {
super.connectedCallback();
this.unsubRequests = downloadStore.subscribe(() =>
this.requestUpdate(),
);
}
override disconnectedCallback(): void {
this.unsubRequests?.();
this.unsubRequests = undefined;
super.disconnectedCallback();
}
static override styles = [
designTokens,
exploreLinkStyles,
@@ -255,7 +279,10 @@ export class TopResultsRow extends LitElement {
? r.year || ''
: formatDuration(r.length) || '';
const status: LibraryStatus = r.inLibrary ? 'in-library' : 'not-in-library';
const status: LibraryStatus = libraryStatusFor(
Boolean(r.inLibrary),
r.mbid,
);
const entityType: 'artist' | 'album' | 'track' =
r.entityType === 'artist'
? 'artist'
@@ -314,6 +341,8 @@ export class TopResultsRow extends LitElement {
status=${status}
entity-type=${entityType}
label=${r.name}
request-mbid=${r.mbid}
request-artist=${r.artistCredit ?? ''}
size="22"
></library-status-indicator>`}
</div>
@@ -63,6 +63,9 @@ import type WaPopup from '@awesome.me/webawesome/dist/components/popup/popup.js'
import '@awesome.me/webawesome/dist/components/dropdown-item/dropdown-item.js';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { describeError } from '@utils/describe-error';
import { notificationStore } from '@store/notification-store';
import { confirmAction } from '@components/confirm-dialog/confirm-dialog';
import { RemoveFromLibrary } from '@go/library/Library';
import { loadTrackDetails } from '@utils/lazy-track-details.js';
import { tracksByFilePath, tracksForPaths } from '@utils/track-index.js';
import '@components/playlist-picker/playlist-picker.js';
@@ -1212,8 +1215,29 @@ export class TrackList
'shortcut:tracklist-play',
this.handleShortcutPlay,
);
this.listenWhileActive(
document,
'shortcut:tracklist-delete',
this.handleShortcutDelete,
);
}
/**
* Delete opens the confirmation and does nothing else.
*
* That is the whole design of the binding: one keystroke from a
* focused row, a key that *asks* is defensible and a key that
* *acts* is not — so this is the same dialog the menu command
* opens, reached by a different route.
*/
private handleShortcutDelete = (): void => {
const filePaths = this.selection.getSelectedKeysOrdered();
if (filePaths.length === 0) return;
void this.removeFromLibrary(filePaths);
};
/** Enter plays the selection — the `tracklist.play` binding, which
* has existed in the defaults and in Settings since it was written
* and has never had anything on the other end of it. */
@@ -1639,12 +1663,79 @@ export class TrackList
void this.openBatchTrackDetails(filePaths);
}
break;
case 'remove-from-library':
// The only destructive command in this menu: it asks
// first, and it keeps the selection until the user has
// answered — the dialog names a count, and clearing the
// selection under it would make that count a claim
// about nothing.
this.ctxMenu.close();
void this.removeFromLibrary(filePaths);
return;
}
this.selection.clear();
this.ctxMenu.close();
}
/**
* "Remove from library", behind a confirmation that says what it
* does *and* what it does not.
*
* The second half is the point. This deletes the database rows and
* stops the scanner importing those paths again; the audio files
* are left exactly where they are. A user who reads "remove" as
* "delete" and finds their music gone would have been failed by the
* copy, not by the operation — so the copy says so in the impact
* line, where the consequence of every other destructive action in
* the app is written.
*/
private async removeFromLibrary(filePaths: string[]) {
const count = filePaths.length;
const only =
count === 1
? tracksByFilePath(this.tracks).get(filePaths[0]!)
: undefined;
const ok = await confirmAction({
title:
count === 1
? `Remove “${only?.TrackName ?? filePaths[0]!}” from the library?`
: `Remove ${count.toLocaleString()} tracks from the library?`,
message:
count === 1
? 'It is removed from YellowJacket and will not be added' +
' back by a future scan.'
: 'They are removed from YellowJacket and will not be' +
' added back by a future scan.',
impact:
count === 1
? 'The file is not deleted — it stays on disk exactly' +
' where it is. A full rescan brings it back.'
: 'The files are not deleted — they stay on disk exactly' +
' where they are. A full rescan brings them back.',
confirmLabel:
count === 1
? 'Remove track'
: `Remove ${count.toLocaleString()} tracks`,
danger: true,
});
if (!ok) return;
try {
await RemoveFromLibrary(filePaths);
this.selection.clear();
} catch (error) {
console.error('Error removing tracks from library:', error);
notificationStore.persistent({
title: 'Could not remove from library',
text: `${count === 1 ? 'That track is' : `Those ${count.toLocaleString()} tracks are`} still in your library. ${describeError(error)}`,
});
}
}
private onContextMenuFavoriteToggle() {
const filePaths =
this.selection.getSelectedKeysOrdered();
@@ -2105,6 +2196,20 @@ export class TrackList
></wa-icon>
Track Details
</wa-dropdown-item>
<wa-dropdown-item
@click=${() =>
this.onContextMenuAction(
'remove-from-library',
)}
@mouseenter=${() =>
this.ctxMenu.closePlaylistSubmenu()}
>
<wa-icon
slot="icon"
name="trash"
></wa-icon>
Remove from Library
</wa-dropdown-item>
</div>
`
: nothing}
+11
View File
@@ -67,6 +67,17 @@ export const Events = {
TrackMetadataChanged: "TrackMetadataChanged",
BatchWriteProgress: "BatchWriteProgress",
// Track removal events.
//
// TracksRemovedFromLibrary means "these rows are gone and these paths
// will not be imported again", and like TrackPlayCountChanged it
// carries everything a consumer needs to patch rather than invalidate:
// {filePaths: []string, count: int}. The library store splices those
// paths out of its tracks array — which is the expensive collection —
// and refetches only the album/artist/genre summaries, whose counts
// really did change
TracksRemovedFromLibrary: "TracksRemovedFromLibrary",
// Play statistics events.
//
// TrackPlayCountChanged carries everything needed to patch the one
+1
View File
@@ -22,6 +22,7 @@ solid/arrow-rotate-right
solid/arrows-rotate
solid/arrow-up-short-wide
solid/backward-step
regular/bookmark
solid/bookmark
solid/box-open
solid/check
@@ -438,9 +438,14 @@ async function dispatch(action: string): Promise<void> {
);
break;
// No `tracklist.delete`: it dispatched an event nothing
// listened for, from a binding Settings advertised as
// configurable. See backend/shortcuts/config.go.
// `tracklist.delete` opens the confirmation and nothing else:
// the key is a request, not an action. See
// backend/shortcuts/config.go.
case 'tracklist.delete':
document.dispatchEvent(
new CustomEvent('shortcut:tracklist-delete'),
);
break;
// Panel-specific: autotag review. The view listens for these
// while it is the view on screen, and for nothing while it is
+6
View File
@@ -121,6 +121,12 @@ export const SHORTCUT_META: Record<string, ShortcutMeta> = {
scope: 'panel:track-list',
defaultKey: 'Enter',
},
'tracklist.delete': {
label: 'Remove from Library',
category: 'Navigation',
scope: 'panel:track-list',
defaultKey: 'Delete',
},
'autotag.apply': {
label: 'Apply Match',
category: 'Autotag',
+68
View File
@@ -108,6 +108,9 @@ class LibraryStore {
EventsOn(Events.TrackPlayCountChanged, (payload: unknown) => {
this.applyPlayCount(payload);
});
EventsOn(Events.TracksRemovedFromLibrary, (payload: unknown) => {
this.applyTracksRemoved(payload);
});
this.loadCoverSize();
this.deferEagerFetch();
@@ -559,6 +562,71 @@ class LibraryStore {
this.notify();
}
/**
* Splice removed tracks out in place, and refetch only the
* summaries whose counts changed.
*
* `invalidate()` would be correct and is the expensive answer: it
* nulls `tracks` and eagerly refetches it, which is ~37 MB across
* the IPC at 50 000 tracks for an operation that removed three
* rows. The event carries the paths precisely so this does not have
* to happen — the same bargain `TrackPlayCountChanged` makes.
*
* The album, artist and genre lists really do change (their track
* counts, and the row itself when its last track goes), so they are
* dropped and refetched. They are the small collections.
*/
private applyTracksRemoved(payload: unknown): void {
const p = payload as { filePaths?: string[] } | null;
const removed = p?.filePaths;
if (!removed || removed.length === 0) return;
// A tracks fetch already in flight would land holding the rows
// that were just deleted, and it captured the cache generation
// this patch is about to leave behind. There is no patch that
// is equivalent to that, so fall back.
if (this.inFlight.has('tracks')) {
this.invalidate();
return;
}
if (this.tracks !== null) {
const gone = new Set(removed);
const kept = this.tracks.filter((t) => !gone.has(t.FilePath));
// A new array identity even when nothing matched would
// invalidate every memoized filter/sort cache keyed on it
// for no reason.
if (kept.length !== this.tracks.length) {
this.tracks = kept;
}
}
this.albums = null;
this.artists = null;
this.genres = null;
// Bumping the cache generation is what stops an album fetch
// issued before the removal from committing its pre-removal
// answer. Safe for the tracks slot precisely because the guard
// above established there is nothing in flight for it.
this.cacheGen++;
this.inFlight.delete('albums');
this.inFlight.delete('artists');
this.inFlight.delete('genres');
this.changeGen++;
this.notify();
const logged = (what: string) => (err: unknown) =>
console.error(`library: could not reload ${what}`, err);
void this.getAlbums().catch(logged('albums'));
void this.getArtists().catch(logged('artists'));
void this.getGenres().catch(logged('genres'));
}
private invalidate(): void {
this.tracks = null;
this.albums = null;
+106
View File
@@ -0,0 +1,106 @@
import { downloadStore } from '@store/download-store';
import { libraryStore } from '@store/library-store';
import type { download } from '@go/models';
import type { LibraryStatus } from '../components/library-status-indicator/library-status-indicator';
/**
* What the tick/hourglass/plus badge should say about one entity.
*
* This exists because the rule was written at all eight call sites and
* so none of them had the whole of it: every one was a two-way ternary
* between `in-library` and `not-in-library`, and the badge's third
* state — `queued`, styled and labelled since it was written — was
* produced by nothing. An album the user had already asked for through
* the "Want this" button showed a plus and said it was not in their
* library, on the same page, forty pixels from a filled button reading
* "Wanted".
*
* Two rules decide the answer, and both are about honesty rather than
* precedence for its own sake:
*
* - **Owning outranks wanting.** A request that has been satisfied by
* any route — downloaded here, ripped, bought elsewhere — is not
* news; what the user has is.
* - **A request is by MBID, and the badge answers about the entity it
* is on.** A track inside a requested album is not itself requested,
* so it stays a plus. Saying otherwise would promise that clicking
* it later would find *that* recording.
*
* A `satisfied` request is deliberately not `queued`: nothing is coming.
* A `paused` one is, because the user did ask for it and it is still on
* the list — "queued" is a slight overstatement of a paused request and
* a much smaller one than "not in your library".
*/
export function libraryStatusFor(
owned: boolean,
mbid?: string | null,
): LibraryStatus {
if (owned) return 'in-library';
if (!mbid) return 'not-in-library';
const request = downloadStore.requestFor(mbid);
if (request && request.state !== 'satisfied') return 'queued';
return 'not-in-library';
}
/** What a badge can ask for. Artists are deliberately absent: a
* discography subscription is `explore-artist-details`'s Follow
* button, which can say what it is committing to. */
export type RequestableEntity = 'album' | 'track';
const ENTITY: Record<RequestableEntity, string> = {
album: 'release-group',
track: 'recording',
};
/**
* Add or drop a request for one entity, and report which way it went.
*
* The counterpart to `libraryStatusFor`, here rather than in the badge
* because the badge is one of several things that can ask —
* `explore-album-details`'s "Want this" button is the other, and two
* implementations of "what does wanting something mean" is exactly what
* phase 1 was about.
*
* Returns `'wanted'` or `'cancelled'` so a caller can announce what
* happened; throws if the backend refused, because a badge that
* silently does nothing is what this whole plan is about.
*/
export async function toggleRequest(input: {
mbid: string;
entity: RequestableEntity;
title: string;
artist?: string;
}): Promise<'wanted' | 'cancelled'> {
const existing = downloadStore.requestFor(input.mbid);
if (existing) {
await downloadStore.removeRequest(existing.id);
return 'cancelled';
}
// A request belongs to a library because that is where its files
// will land. There is always at least one by the time anything is
// on screen — the first-run wizard blocks every pointer event until
// there is — but an explicit failure beats a request filed against
// library 0, which no import would ever match.
const libraryId = await libraryStore.getDefaultLibraryId();
if (!libraryId) throw new Error('no library to add this to');
await downloadStore.addRequest({
mbid: input.mbid,
entity: ENTITY[input.entity],
libraryId,
artist: input.artist ?? '',
title: input.title,
scope: 'future',
secondary: false,
} as download.RequestInput);
return 'wanted';
}
+18 -2
View File
@@ -182,10 +182,26 @@ describe('a track the library does not have', () => {
expect(rows[11]?.getAttribute('aria-label')).toContain('not in your library');
});
it('no longer marks the owned ones with a badge', async () => {
/**
* The badge is only on rows that can act on it. An owned track has
* nothing to request, so it carries no mark at all — the undimmed row
* already says it is yours, which is what retired the green tick.
* An unowned one keeps the badge, because it is now a request
* control rather than a decoration, revealed on hover or focus so a
* mostly-owned album is not a column of plus signs.
*/
it('marks only the rows with something left to ask for', async () => {
const el = await withVersion(3, 12);
const rows = shadowAll(el, '.track-row');
expect(shadowAll(el, '.track-row library-status-indicator')).toHaveLength(0);
const badgeIn = (row: Element) =>
row.querySelector('library-status-indicator');
expect(badgeIn(rows[0]!)).toBeNull();
expect(badgeIn(rows[11]!)).not.toBeNull();
expect(
shadowAll(el, '.track-row library-status-indicator'),
).toHaveLength(9);
expect(shadow(el, '.tracklist-legend')).toBeNull();
});
});
@@ -0,0 +1,361 @@
/**
* Plan 009 phase 1: the badge tells the truth.
*
* `library-status-indicator` has had three states since it was written
* — a tick, an hourglass and a plus — and the hourglass was produced by
* nothing. All eight call sites were a two-way ternary, so an album the
* user had already asked for through "Want this" showed a plus and said
* it was not in their library, on the same page as a filled button
* reading "Wanted".
*
* Two tiers of assertion here, and the second is the one that would
* have failed:
*
* - the rule itself, which is now written once, and
* - a rendered Explore result whose release group is requested,
* because a helper nobody calls is a rule nobody follows.
*/
import { beforeEach, describe, expect, it } from 'vitest';
import '@components/explore-view/explore-view';
import type { Request } from '@store/download-store';
import { libraryStatusFor } from '@utils/library-status';
import { Events } from '../../src/events';
import { notificationStore } from '@store/notification-store';
import {
calls,
emit,
flush,
lastArgs,
stub,
stubFailure,
} from '@test/support/harness';
import { fixture, shadow, shadowAll, update } from '@test/support/render';
const SEARCH = 'explore.Service.SearchLocal';
function request(overrides: Partial<Request>): Request {
return {
id: 1,
mbid: 'rg-wanted',
entity: 'release-group',
libraryId: 1,
artist: 'An Artist',
title: 'Wanted Album',
scope: 'future',
secondary: false,
state: 'wanted',
attempts: 0,
...overrides,
} as Request;
}
/** Put a request list into the store the way the backend does. */
async function withRequests(rows: Request[]): Promise<void> {
stub('download.Service.ListRequests', rows);
emit(Events.RequestsChanged);
await flush();
}
const releaseGroup = (mbid: string, title: string) => ({
mbid,
title,
artistCredit: 'An Artist',
artistMbid: 'ar-1',
primaryType: 'Album',
firstReleaseDate: '1994-05-01',
popularity: 100,
listenerCount: 10,
inLibrary: false,
secondaryTypes: [],
});
describe('libraryStatusFor', () => {
beforeEach(async () => {
await withRequests([]);
});
it('says nothing about an entity with no MBID to ask about', () => {
expect(libraryStatusFor(false, '')).toBe('not-in-library');
expect(libraryStatusFor(false, undefined)).toBe('not-in-library');
});
it('reports a request as queued', async () => {
await withRequests([request({ mbid: 'rg-wanted' })]);
expect(libraryStatusFor(false, 'rg-wanted')).toBe('queued');
});
it('lets owning outrank wanting', async () => {
// Both are true of an album that has arrived but whose request has
// not been retired yet. What the user has is not news; what they
// have is.
await withRequests([request({ mbid: 'rg-wanted' })]);
expect(libraryStatusFor(true, 'rg-wanted')).toBe('in-library');
});
it('does not call a satisfied request queued', async () => {
// Nothing is coming: the request is history. An unowned entity with
// a satisfied request is a stale row, not a download in flight.
await withRequests([request({ mbid: 'rg-done', state: 'satisfied' })]);
expect(libraryStatusFor(false, 'rg-done')).toBe('not-in-library');
});
it('does count a paused request, which the user did ask for', async () => {
await withRequests([request({ mbid: 'rg-paused', state: 'paused' })]);
expect(libraryStatusFor(false, 'rg-paused')).toBe('queued');
});
it('answers about the entity it is on, not the one containing it', () => {
// A request is by MBID. A track inside a requested album is not
// itself requested, and saying otherwise promises that clicking it
// would find that recording.
expect(libraryStatusFor(false, 'recording-inside-rg-wanted')).toBe(
'not-in-library',
);
});
});
describe('<explore-view> badges', () => {
beforeEach(async () => {
stub('explore.Service.GetThumbnails', []);
stub('explore.Service.GetThumbnail', '');
stub('explore.Service.GetArtistImageURL', '');
stub('explore.Service.GetExploreShelves', { shelves: [], state: 'ready' });
stub('library.Library.GetAllAlbums', []);
stub('library.Library.GetAllTracks', []);
await withRequests([]);
});
async function searchFor(rows: ReturnType<typeof releaseGroup>[]) {
stub(SEARCH, {
artists: [],
releaseGroups: rows,
recordings: [],
topResults: [],
});
const el = await fixture('explore-view');
(el as unknown as { viewActivated(): void }).viewActivated();
await flush();
const input = shadow<HTMLInputElement>(el, 'input');
if (input) {
input.value = 'anything';
input.dispatchEvent(new Event('input', { bubbles: true }));
}
// The search box debounces, so waiting a frame measures the input
// echoing its own character.
await new Promise((resolve) => setTimeout(resolve, 300));
await flush();
await el.updateComplete;
return el;
}
it('shows a requested album as queued rather than as absent', async () => {
await withRequests([request({ mbid: 'rg-wanted' })]);
const el = await searchFor([
releaseGroup('rg-wanted', 'Wanted Album'),
releaseGroup('rg-other', 'Some Other Album'),
]);
const badges = shadowAll(el, 'library-status-indicator');
expect(badges.length).toBeGreaterThanOrEqual(2);
expect(badges.map((b) => b.getAttribute('status'))).toEqual([
'queued',
'not-in-library',
]);
});
it('re-renders when the request list changes underneath it', async () => {
// A background reconcile pass expands an artist or retires a want
// without this page doing anything, so the badge has to be told.
const el = await searchFor([releaseGroup('rg-wanted', 'Wanted Album')]);
expect(shadow(el, 'library-status-indicator')?.getAttribute('status')).toBe(
'not-in-library',
);
await withRequests([request({ mbid: 'rg-wanted' })]);
await el.updateComplete;
expect(shadow(el, 'library-status-indicator')?.getAttribute('status')).toBe(
'queued',
);
});
});
/**
* Plan 009 phase 3: the badge becomes a button where it can act.
*
* 007 made it `role="img"` because a control that cannot act is worse
* than none, and wrote down what would change the answer: a `<button>`
* *with* a handler. Both halves of that are asserted here — the badge
* branch is still a badge (the tests in `chrome.test.ts` pin it, and
* they pass unchanged because a call site has to opt in), and the
* button branch actually files a request.
*/
describe('<library-status-indicator> as a control', () => {
beforeEach(async () => {
stub('download.Service.ListRequests', []);
stub('download.Service.AddRequest', 7);
stub('download.Service.RemoveRequest', null);
stub('library.Library.GetAllLibrariesWithTrackCounts', [
{ id: 3, name: 'Music' },
]);
notificationStore.clear();
await withRequests([]);
});
const badge = (props: Record<string, unknown> = {}) =>
fixture('library-status-indicator', {
entityType: 'album',
label: 'Abbey Road',
...props,
});
it('is a button only where a call site opted in', async () => {
const inert = await badge();
const control = await badge({ requestMbid: 'rg-1' });
expect(shadow(inert, 'button')).toBeNull();
expect(shadow(inert, '.badge')?.getAttribute('role')).toBe('img');
expect(shadow(control, 'button')).not.toBeNull();
});
it('is never a button for something already owned', async () => {
// There is nothing left to ask for, so the tab stop would cost the
// keyboard user exactly what 007 gave back.
const el = await badge({ requestMbid: 'rg-1', status: 'in-library' });
expect(shadow(el, 'button')).toBeNull();
});
it('is named after what activating it does', async () => {
const el = await badge({ requestMbid: 'rg-1' });
expect(shadow(el, '.badge')?.getAttribute('aria-label')).toBe(
'Want album "Abbey Road"',
);
await update(el, { status: 'queued' });
expect(shadow(el, '.badge')?.getAttribute('aria-label')).toBe(
'Cancel the request for album "Abbey Road"',
);
});
it('still describes rather than offers where it cannot act', async () => {
const el = await badge();
expect(shadow(el, '.badge')?.getAttribute('aria-label')).toBe(
'Album "Abbey Road" is not in your library',
);
});
it('files a request for the entity it is on', async () => {
const el = await badge({ requestMbid: 'rg-1', requestArtist: 'The Beatles' });
shadow<HTMLElement>(el, 'button')?.click();
await flush();
expect(lastArgs('download.Service.AddRequest')?.[0]).toMatchObject({
mbid: 'rg-1',
entity: 'release-group',
title: 'Abbey Road',
artist: 'The Beatles',
libraryId: 3,
});
});
it('asks for a recording when it is on a track', async () => {
const el = await badge({
entityType: 'track',
label: 'Come Together',
requestMbid: 'rec-1',
});
shadow<HTMLElement>(el, 'button')?.click();
await flush();
expect(lastArgs('download.Service.AddRequest')?.[0]).toMatchObject({
entity: 'recording',
});
});
it('cancels a request it already made', async () => {
await withRequests([request({ id: 42, mbid: 'rg-1' })]);
const el = await badge({ requestMbid: 'rg-1', status: 'queued' });
shadow<HTMLElement>(el, 'button')?.click();
await flush();
expect(lastArgs('download.Service.RemoveRequest')).toEqual([42]);
expect(calls('download.Service.AddRequest')).toEqual([]);
});
it('keeps its click off the card it sits on', async () => {
// The inverse of the badge branch, and for the opposite reason:
// with an action of its own, a click on it no longer means what
// the card means.
const el = await badge({ requestMbid: 'rg-1' });
const button = shadow<HTMLElement>(el, 'button');
let bubbled = 0;
el.addEventListener('click', () => {
bubbled += 1;
});
button?.click();
await flush();
// Both halves, because "nothing bubbled" is free on a build with
// no button to click: `?.click()` on null is a silent no-op and
// this passed on the neutered build until it also asserted that
// the click did the thing it was swallowed for.
expect([button !== null, bubbled, calls('download.Service.AddRequest')
.length]).toEqual([true, 0, 1]);
});
it('keeps Enter and Space off it too', async () => {
// Every card holding one of these is a role=button or role=option
// with its own Enter/Space handler, so without this a keyboard
// activation would file the request *and* open the page.
const el = await badge({ requestMbid: 'rg-1' });
const seen: string[] = [];
el.addEventListener('keydown', (e) => seen.push((e as KeyboardEvent).key));
for (const key of ['Enter', ' ', 'ArrowDown']) {
shadow<HTMLElement>(el, 'button')?.dispatchEvent(
new KeyboardEvent('keydown', { key, bubbles: true, composed: true }),
);
}
// ArrowDown is not ours: the grid still moves by it.
expect(seen).toEqual(['ArrowDown']);
});
it('says so when the request could not be filed', async () => {
stubFailure('download.Service.AddRequest', 'nope');
const el = await badge({ requestMbid: 'rg-1' });
shadow<HTMLElement>(el, 'button')?.click();
await flush();
expect(notificationStore.getAll().map((n) => n.level)).toEqual([
'transient',
]);
// The badge is where it was, which is why the toast is transient.
expect(shadow(el, 'button')).not.toBeNull();
});
});
@@ -9,15 +9,20 @@
* checkboxes. In each case a `<label>` sat right beside the control
* with nothing associating the two.
*
* Two of the fixes here are about a name that exists and does not
* Three of the fixes here are about a name that *exists* and does not
* identify anything, which is `a11y.32`'s complaint one page over:
* three shortcut buttons announced themselves as "S", and thirty-six
* column arrows as "Move up" or "Move down".
* three shortcut buttons announced themselves as "S", thirty-six column
* arrows as "Move up" or "Move down", and — `a11y.26`, the audit's own
* finding — Explore's search box by a placeholder that disappears the
* moment anyone types into it. That last one is why the finding
* survived four phases: a placeholder is an accname fallback, so the
* AX sweep reported the view clean.
*/
import { describe, expect, it } from 'vitest';
import '@components/config-page/config-field';
import '@components/config-page/shortcut-capture';
import '@components/explore-view/explore-view';
import { fixture, shadow } from '@test/support/render';
/** What the `<label>` in this shadow root actually points at. */
@@ -114,3 +119,20 @@ describe('a shortcut button says what it binds', () => {
.toBe('Reset Next Track to N');
});
});
describe('a search box is labelled by more than its placeholder', () => {
it('names the catalog search, which loses its placeholder on typing', async () => {
const el = await fixture('explore-view');
await el.updateComplete;
// `a11y.26`. A placeholder *is* an accname fallback, so this box
// was never unnamed and the AX sweep reported the view clean —
// which is why the finding survived four phases. It is a weak name:
// it disappears the moment the user types, and it is the only
// thing distinguishing catalog search from lyric search.
const input = shadow(el, '.search-container input');
expect(input?.getAttribute('aria-label')).toBe('Search the catalog');
});
});
@@ -383,6 +383,35 @@ describe('shortcut dispatch: scope', () => {
expect(fired).toBe(1);
});
/**
* `tracklist.delete` was advertised in Settings for six phases with
* nothing dispatching for it. What it dispatches now only *opens* a
* confirmation, which is what makes a destructive action defensible
* on an unmodified key one row from the user's music.
*/
it('dispatches for tracklist.delete, which had nothing on the other end', () => {
bindings({ 'tracklist.delete': 'Delete' });
const panel = mount(document.createElement('div'));
const row = document.createElement('div');
row.tabIndex = 0;
panel.dataset['shortcutScope'] = 'tracklist';
panel.append(row);
row.focus();
let fired = 0;
const listener = (): void => {
fired += 1;
};
document.addEventListener('shortcut:tracklist-delete', listener);
press('Delete');
document.removeEventListener('shortcut:tracklist-delete', listener);
expect(fired).toBe(1);
});
});
// ===================================================================
@@ -175,6 +175,55 @@ describe('library store: caching', () => {
expect(calls()).toEqual([]);
});
/**
* Removing tracks is the same bargain the play count makes, one
* collection wider: the event carries the paths so the tracks array
* — the expensive one — is patched rather than refetched, while the
* album/artist/genre summaries, whose counts really did change, are
* dropped and reloaded.
*/
describe('tracks removed from the library', () => {
beforeEach(async () => {
emit(Events.TracksRemovedFromLibrary, {
filePaths: ['/a.mp3'],
count: 1,
});
await flush();
});
it('does not refetch the tracks', () => {
expect(calls('library.Library.GetAllTracks')).toHaveLength(0);
});
it('splices the removed track out in place', () => {
expect(libraryStore.getCachedTracks()?.map((t) => t.FilePath)).toEqual([
'/b.mp3',
]);
});
it('reloads the summaries, whose counts changed', () => {
expect(
[
'library.Library.GetAllAlbums',
'library.Library.GetAllArtists',
'library.Library.GetAllGenresWithCounts',
].map((path) => calls(path).length),
).toEqual([1, 1, 1]);
});
});
it('ignores a removal naming a track it does not hold, without dropping the array', async () => {
const before = libraryStore.getCachedTracks();
emit(Events.TracksRemovedFromLibrary, {
filePaths: ['/not-in-this-library.mp3'],
count: 1,
});
await flush();
expect(libraryStore.getCachedTracks()).toBe(before);
});
it('resets scroll positions on invalidation, so a shorter list is not scrolled past its end', async () => {
libraryStore.setScrollPosition('albums', 4200);
emit(Events.LibraryScanComplete);
+3 -3
View File
@@ -35,7 +35,7 @@ export function GetArtistImageCachedPath(arg1:string):Promise<string>;
export function GetArtistImageURL(arg1:string):Promise<string>;
export function GetArtistImages(arg1:Array<string>):Promise<Record<string, string>>;
export function GetArtistImagesCachedPaths(arg1:Array<string>):Promise<Record<string, string>>;
export function GetArtistMBID(arg1:string):Promise<string>;
@@ -49,8 +49,6 @@ export function GetIndexStatus():Promise<explore.IndexStatus>;
export function GetLibrarySimilarArtists(arg1:string):Promise<Array<explore.LBSimilarArtist>>;
export function GetPopularityBatch(arg1:Array<string>):Promise<Record<string, explore.PersonalizationResult>>;
export function GetThumbnail(arg1:string,arg2:string,arg3:string):Promise<string>;
export function GetThumbnails(arg1:Array<explore.ThumbnailRequest>):Promise<Record<string, string>>;
@@ -103,6 +101,8 @@ export function SearchLocal(arg1:string):Promise<explore.MBSearchResult>;
export function SearchLyrics(arg1:string):Promise<Array<explore.LyricsResult>>;
export function SetAlbumComplete(arg1:explore.AlbumCompleteFunc):Promise<void>;
export function SetContext(arg1:context.Context):Promise<void>;
export function SetJobRegistry(arg1:jobs.Registry):Promise<void>;
+6 -6
View File
@@ -62,8 +62,8 @@ export function GetArtistImageURL(arg1) {
return window['go']['explore']['Service']['GetArtistImageURL'](arg1);
}
export function GetArtistImages(arg1) {
return window['go']['explore']['Service']['GetArtistImages'](arg1);
export function GetArtistImagesCachedPaths(arg1) {
return window['go']['explore']['Service']['GetArtistImagesCachedPaths'](arg1);
}
export function GetArtistMBID(arg1) {
@@ -90,10 +90,6 @@ export function GetLibrarySimilarArtists(arg1) {
return window['go']['explore']['Service']['GetLibrarySimilarArtists'](arg1);
}
export function GetPopularityBatch(arg1) {
return window['go']['explore']['Service']['GetPopularityBatch'](arg1);
}
export function GetThumbnail(arg1, arg2, arg3) {
return window['go']['explore']['Service']['GetThumbnail'](arg1, arg2, arg3);
}
@@ -198,6 +194,10 @@ export function SearchLyrics(arg1) {
return window['go']['explore']['Service']['SearchLyrics'](arg1);
}
export function SetAlbumComplete(arg1) {
return window['go']['explore']['Service']['SetAlbumComplete'](arg1);
}
export function SetContext(arg1) {
return window['go']['explore']['Service']['SetContext'](arg1);
}
+2
View File
@@ -73,6 +73,8 @@ export function QueuedLibraryNames():Promise<Array<string>>;
export function ReleasePipelineLock():Promise<void>;
export function RemoveFromLibrary(arg1:Array<string>):Promise<library.RemovalResult>;
export function RemoveLibrary(arg1:number):Promise<library.RemovalSummary>;
export function RenameLibrary(arg1:number,arg2:string):Promise<void>;
+4
View File
@@ -138,6 +138,10 @@ export function ReleasePipelineLock() {
return window['go']['library']['Library']['ReleasePipelineLock']();
}
export function RemoveFromLibrary(arg1) {
return window['go']['library']['Library']['RemoveFromLibrary'](arg1);
}
export function RemoveLibrary(arg1) {
return window['go']['library']['Library']['RemoveLibrary'](arg1);
}
+14
View File
@@ -1780,6 +1780,20 @@ export namespace library {
this.queueItemCount = source["queueItemCount"];
}
}
export class RemovalResult {
tracksRemoved: number;
pathsExcluded: number;
static createFrom(source: any = {}) {
return new RemovalResult(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.tracksRemoved = source["tracksRemoved"];
this.pathsExcluded = source["pathsExcluded"];
}
}
export class RemovalSummary {
tracksDeleted: number;
artistsRemoved: number;