Surface a confident autotag match on the album page #121

Merged
logan merged 4 commits from feat/28-autotag-match-on-album into main 2026-08-19 06:49:49 +00:00
Collaborator

Closes #28 — the last item in #73's Phase 1.

Commits

commit issue what
21b303b fix(ui): stop a closing dialog answering the next question #28 a late wa-hide cancelled the next confirmAction()
fe67849 feat(autotag): name the confidence tier two features have to share #28, #90 ConfidentTier / Confident()
9118c16 feat(autotag): answer whether an album has a confident match #28 MatchForAlbum(albumID)
b5d70ac feat(explore): offer the autotag match on the album page #28 the banner, the apply, the review hop, ICON_AUTOTAG

The three Direction points

1. A backend query cheap enough to call on page open. It costs no
MusicBrainz request at all. Everything MatchForAlbum needs is already
on disk: tagging_items carries the top score and release from the
background prefetch, and tagging_candidates durably holds the scored
list. A folder nobody has scored yet answers "nothing" rather than
scoring it now — the limiters here are shared with every page the user
can open, and a page that quietly spends a minute of somebody's
MusicBrainz budget to draw a banner is worse than a page that says
nothing.

2. The banner, beside catalog-scope-notice where the issue
suggested, with "Apply tags" and "Review in Autotag".

3. The threshold defined once. autotag.ConfidentTier — because
#90 asks for exactly this: the album page and strict auto-accept must
agree on what "high confidence" means rather than computing it twice. A
page that claims confidence the auto-accept pass would decline is the
app contradicting itself. What they do not share is written down
beside it: surfacing is a suggestion behind a confirm dialog,
auto-accept is an irreversible rewrite gated on further conditions the
tier cannot express, so this is the floor both stand on rather than the
whole of either test.

Four decisions worth the review

The tier is computed, not read. tagging_items.score is the raw
number; Recommend is what turns it into a claim, capping it for an
ambiguous runner-up, an incomplete alignment, or a folder too small to
corroborate itself. Filtering on the stored score would promise
confidence the scorer had explicitly withheld — pinned by the two-track
test, which scores 0.99 and is correctly not surfaced.

Applying is offered only where it would do the whole album. A
tagging group is a folder, so a multi-disc album is several. One button
applying to the best-scoring group would leave the album holding a mix
of old and new tags, which is the case the app's Blocking notification
level exists for. groupCount is the test; the answer there is review.

Nothing is said about an album the user already answered for.
confirmed covers both a finished apply and an explicit "leave as is",
and arguing with the second would be actively wrong.

The join is audio_files.group_key, not a path-derived key. A
group carved out of a mixed-bag folder is keyed on its tags, so a
path-derived key finds nothing for exactly the messiest libraries this
is meant to help.

A bug it found

confirm-dialog is one singleton for every confirmation in the app,
and wa-dialog reports its close asynchronouslyopen = false
starts an animation and wa-hide arrives after it. So a hide belonging
to a question already answered lands after the next question has
opened and cancels it: the user is asked something, the dialog vanishes
on its own, and the call site is told they said no.

Found by writing two confirmAction() tests in one file — the second
could not be accepted at all. Reaching it in the app needs two
confirmations close together, which "Apply tags" makes possible. Each
ask carries an id now; only the wa-hide handler needs one, because
only wa-hide can arrive late.

One vocabulary addition

ICON_AUTOTAG, on ICON_PLAYLIST's rule — an icon names the noun it
acts on, so a suggestion pointing at Autotag wears the Autotag
destination's own mark. It was written inline in the sidebar; two call
sites is where a name stops being one component's detail, so
icon-language.test.ts governs it now.

Verification actually run

make lint            # 0 issues, all three tag sets
make test            # all three passes green
make ui-test         # 898 passed (79 files), up from 883
make e2e             # 114 passed, chromium, against a seeded app
make bindings-check  # current
make skill-check     # 47 targets
frontend/node_modules/.bin/tsc --noEmit   # clean

16 new specs across three files. Names go through page.getByRole(...).

And it was driven in the running app, with a confident match staged
into a real database through /__test/sql: MatchForAlbum answered
strong end to end, the banner rendered, the confirm dialog's impact
line read correctly, and "Review in Autotag" landed on that group key
with the request attribute consumed (hasAttribute('group-key') === false) — which is the half no component test can see, since Autotag is
a cached primary view and the router is index.ts.

Note for #90

This leaves ConfidentTier in place as the shared floor, and
MatchForAlbum as a worked example of reading the durable candidate
cache without touching the network — which is the same constraint #90's
auto-accept rule states ("uses already-cached candidate data — no
additional MusicBrainz calls").

Releases are manual (#115), so merging cuts no version.

Closes #28

Closes #28 — the last item in #73's Phase 1. ## Commits | commit | issue | what | | --- | --- | --- | | `21b303b` fix(ui): stop a closing dialog answering the next question | #28 | a late `wa-hide` cancelled the *next* `confirmAction()` | | `fe67849` feat(autotag): name the confidence tier two features have to share | #28, #90 | `ConfidentTier` / `Confident()` | | `9118c16` feat(autotag): answer whether an album has a confident match | #28 | `MatchForAlbum(albumID)` | | `b5d70ac` feat(explore): offer the autotag match on the album page | #28 | the banner, the apply, the review hop, `ICON_AUTOTAG` | ## The three Direction points **1. A backend query cheap enough to call on page open.** It costs *no* MusicBrainz request at all. Everything `MatchForAlbum` needs is already on disk: `tagging_items` carries the top score and release from the background prefetch, and `tagging_candidates` durably holds the scored list. A folder nobody has scored yet answers "nothing" rather than scoring it now — the limiters here are shared with every page the user can open, and a page that quietly spends a minute of somebody's MusicBrainz budget to draw a banner is worse than a page that says nothing. **2. The banner**, beside `catalog-scope-notice` where the issue suggested, with "Apply tags" and "Review in Autotag". **3. The threshold defined once.** `autotag.ConfidentTier` — because #90 asks for exactly this: the album page and strict auto-accept must agree on what "high confidence" means rather than computing it twice. A page that claims confidence the auto-accept pass would decline is the app contradicting itself. What they do *not* share is written down beside it: surfacing is a suggestion behind a confirm dialog, auto-accept is an irreversible rewrite gated on further conditions the tier cannot express, so this is the floor both stand on rather than the whole of either test. ## Four decisions worth the review **The tier is computed, not read.** `tagging_items.score` is the raw number; `Recommend` is what turns it into a claim, capping it for an ambiguous runner-up, an incomplete alignment, or a folder too small to corroborate itself. Filtering on the stored score would promise confidence the scorer had explicitly withheld — pinned by the two-track test, which scores 0.99 and is correctly not surfaced. **Applying is offered only where it would do the whole album.** A tagging group is a folder, so a multi-disc album is several. One button applying to the best-scoring group would leave the album holding a mix of old and new tags, which is the case the app's Blocking notification level exists for. `groupCount` is the test; the answer there is review. **Nothing is said about an album the user already answered for.** `confirmed` covers both a finished apply and an explicit "leave as is", and arguing with the second would be actively wrong. **The join is `audio_files.group_key`, not a path-derived key.** A group carved out of a mixed-bag folder is keyed on its tags, so a path-derived key finds nothing for exactly the messiest libraries this is meant to help. ## A bug it found `confirm-dialog` is one singleton for every confirmation in the app, and `wa-dialog` reports its close **asynchronously** — `open = false` starts an animation and `wa-hide` arrives after it. So a hide belonging to a question already answered lands after the *next* question has opened and cancels it: the user is asked something, the dialog vanishes on its own, and the call site is told they said no. Found by writing two `confirmAction()` tests in one file — the second could not be accepted at all. Reaching it in the app needs two confirmations close together, which "Apply tags" makes possible. Each ask carries an id now; only the `wa-hide` handler needs one, because only `wa-hide` can arrive late. ## One vocabulary addition `ICON_AUTOTAG`, on `ICON_PLAYLIST`'s rule — an icon names the noun it acts on, so a suggestion pointing at Autotag wears the Autotag destination's own mark. It was written inline in the sidebar; two call sites is where a name stops being one component's detail, so `icon-language.test.ts` governs it now. ## Verification actually run ``` make lint # 0 issues, all three tag sets make test # all three passes green make ui-test # 898 passed (79 files), up from 883 make e2e # 114 passed, chromium, against a seeded app make bindings-check # current make skill-check # 47 targets frontend/node_modules/.bin/tsc --noEmit # clean ``` 16 new specs across three files. Names go through `page.getByRole(...)`. **And it was driven in the running app**, with a confident match staged into a real database through `/__test/sql`: `MatchForAlbum` answered `strong` end to end, the banner rendered, the confirm dialog's impact line read correctly, and "Review in Autotag" landed on *that* group key with the request attribute consumed (`hasAttribute('group-key') === false`) — which is the half no component test can see, since Autotag is a cached primary view and the router is `index.ts`. ## Note for #90 This leaves `ConfidentTier` in place as the shared floor, and `MatchForAlbum` as a worked example of reading the durable candidate cache without touching the network — which is the same constraint #90's auto-accept rule states ("uses already-cached candidate data — no additional MusicBrainz calls"). Releases are manual (#115), so merging cuts no version. Closes #28
logan added 4 commits 2026-08-19 06:37:03 +00:00
`confirm-dialog` is one singleton for every confirmation in the app,
and `wa-dialog` reports its close asynchronously: `open = false` starts
an animation and `wa-hide` arrives after it. So a hide belonging to a
question already answered can land after the *next* question has
opened, and cancel it — the user is asked something, the dialog
vanishes on its own, and the call site is told they said no.

Each ask now carries an id. `close` ignores an id that no longer names
the question on screen, the button handlers pass none (they always mean
the current one), and only the `wa-hide` handler carries one, because
only `wa-hide` can arrive late.

Found by writing two `confirmAction()` tests in one file: the second
could not be accepted at all, because the first one's hide had
cancelled it before the click landed. Reaching it in the app needs two
confirmations close together, which the album page's "Apply tags" makes
possible.
`ConfidentTier` and `Confident()` are a name for what was about to be
written as `== RecommendationStrong` at two call sites: the album page
telling the user unprompted that there is a match for what they are
looking at (#28), and strict auto-accept rewriting files without asking
(#90). A page that claims confidence the auto-accept pass would decline
is the app contradicting itself, and #90 asks for exactly this — that
the two agree on what "high confidence" means rather than computing it
twice.

What they do not share is written down beside it. Surfacing a match is
a suggestion with a confirm dialog behind it; auto-accept is an
irreversible on-disk rewrite gated on further conditions the tier
cannot express — exact track count, every title matching, lengths
within a couple of seconds, no cover replacement, no MBID conflict. So
this is the floor both stand on, not the whole of either test.

`Confident` is a rank comparison rather than an equality, so a tier
added above "strong" later does not silently stop qualifying.
`MatchForAlbum(albumID)` is the question the album detail page needs to
ask on open: does the autotagger already have something confident to
say about this album, and what would applying it do.

**It costs no MusicBrainz request.** Everything it needs is on disk —
`tagging_items` carries the top score and release from the background
prefetch, `tagging_candidates` durably holds the scored list. The rate
limiters here are shared with every page the user can open, so a lookup
that fires on page load must not join that queue; a folder nobody has
scored yet answers "nothing", rather than scoring it now.

**The tier is computed, not read.** `tagging_items.score` is the raw
number and `Recommend` is what turns it into a claim, capping it for an
ambiguous runner-up, an incomplete alignment or a folder too small to
corroborate itself. Filtering on the stored score would promise
confidence the scorer had explicitly withheld — which the two-track
test pins.

**Nothing is said about an album the user has already answered for.**
Only a `pending` group qualifies: `confirmed` covers both a finished
apply and an explicit "leave as is", and arguing with the second would
be actively wrong.

The join is `audio_files.group_key`, not a key derived from the folder
path, because a group carved out of a mixed-bag folder is keyed on its
tags — so a path-derived key would find nothing for exactly the
messiest libraries this helps. `GroupCount` is returned because a
multi-disc album is one group per disc: a caller that applied to "the
album" from a single button would retag one disc of three.
feat(explore): offer the autotag match on the album page
CI / check (push) Skipped
CI / e2e (push) Skipped
CI / check (pull_request) Successful in 2m49s
CI / e2e (pull_request) Successful in 6m12s
b5d70ac1cd
The complaint was having to notice the metadata was missing, then go
and hunt the album down on the Autotag page. The album page now says it
while you are looking at the thing: "MusicBrainz has a match for this
album: <release> by <artist>", with Apply tags and Review in Autotag.

Four things about it are load-bearing.

**Applying is offered only where it would do the whole album.** A
tagging group is a folder, so a multi-disc album is several, and one
button that applied to the best-scoring group would leave the album
holding a mix of old and new tags — the exact case the app's Blocking
notification level exists for. `groupCount` is the test, and the answer
there is review rather than apply.

**It rewrites files, so it asks.** `confirmAction()` with an impact
line that says it cannot be undone and that nothing is moved or
deleted, because "rewrites your files" reads worse than it is. The
apply goes through `ApplyAsync`, the registered-job path, so progress
belongs to the jobs indicator and this page does not grow a second one
— what it owes the user is the acknowledgement, because the button is
here. The suggestion clears itself on success rather than inviting a
second click while the job runs.

**The banner does not quote a percentage.** The backend has a score and
deliberately keeps it out of the sentence: 0.95 reads as a probability
and is not one. Which release it is, is the part a person can judge.

**"Review in Autotag" lands on that album.** The queue is sorted by
score so the intended folder is often near the top, and "often" is a
link that sometimes opens a different album. Autotag is a cached
primary view, so there is no construction to hand a payload to: the
request goes on as an attribute and the view *consumes* it, or every
later visit would reopen a folder the user finished with long ago.

`ICON_AUTOTAG` joins the vocabulary at the same time, on the rule
`ICON_PLAYLIST` was chosen by — an icon names the noun it acts on, so a
suggestion pointing at Autotag wears the Autotag destination's own
mark. It was written inline in the sidebar; two call sites is where a
name stops being one component's detail, so the sweep governs it now.

Verified against the running app with a staged match: the banner, the
confirm dialog's wording, and the navigation landing on the right
folder with the attribute consumed.

Closes #28
logan merged commit cc9df4004c into main 2026-08-19 06:49:49 +00:00
Sign in to join this conversation.