Skip to content

Repair the tags on files already recorded: search, a detail pane, and every field the recorder writes - #46

Merged
revtex merged 4 commits into
mainfrom
metadata-manual-match
Sep 1, 2026
Merged

Repair the tags on files already recorded: search, a detail pane, and every field the recorder writes#46
revtex merged 4 commits into
mainfrom
metadata-manual-match

Conversation

@revtex

@revtex revtex commented Sep 1, 2026

Copy link
Copy Markdown
Owner

Supersedes #45, which GitHub closed when its base branch metadata-manager was deleted on
merging #44; a closed pull request cannot be retargeted, so this is the same branch, rebased
onto main and reopened. The four commits and their diff are unchanged.

Four commits, and the last three are consequences of the first. Correcting a bad match needed a
search; the search needed somewhere to put its results; and giving the editor that room made it
obvious how few of a file's tags the page could actually reach.

Correcting a match the automatic path cannot reach

There was no way to fix a wrong match. The three fields were editable, so a wrong title could be
typed over — and doing that forfeits the year, the genre and the artwork that come with a real
match, which is most of why anyone opens this page.

Re-fetch could not help, and the reason is structural rather than a bug. The automatic lookup
builds its query from the file's own artist and title, then discards any result whose artist
disagrees with them. Both halves are right: Spotify always returns something, and the top hit for
a misparsed filename is routinely a different song, so the gate is what stops the page writing a
confident wrong answer into a correctly named file. But it means the one case the automatic path
can never reach is a file whose artist is the thing that's wrong — pressing Re-fetch asks the
identical question and gets the identical answer, and Auto-Fetch skips the row entirely because all
three fields are filled in.

Not the right track? is a second lookup with a deliberately different shape from the first: the
user's words instead of the file's, several results instead of a verdict, and nothing filtered on
the way out
. A person reading a list can tell a live take from a studio one, and a filter that
could hide the right answer is worse here than a list with a few wrong ones in it. The box is
seeded from the row as it stands, results carry the year so a remaster can be told from the
original, and Use this fills the row in. Nothing reaches a file until Save.

Three decisions there worth the review time:

  • The chosen candidate is fetched again through /tracks/{id} rather than tagged from the search
    result.
    A search result is not a full track — the release date lives on the album and the genre
    on the artist — and a hand-picked match arriving thinner than an automatic one would be a strange
    thing to have built. Both paths share one enrichment step (SpotifyCatalogEnricher).
  • Picking a result drops the file's own cover art. The one place the "an embedded picture beats
    a provider's URL" rule is inverted. That rule protects artwork while a lookup confirms what a
    file already says; choosing a different song says the opposite, and keeping the old sleeve would
    write correct tags and the previous artist's cover into the same file.
  • Last.fm is not offered here. Its track.getInfo answers a question about a named artist and
    title — a lookup, not a search — so it has nothing to offer someone who doesn't yet know what the
    track is called. An empty box that can never return anything is worse than saying it needs Spotify.

/tracks/{id} is public catalogue data and costs no user scope, so DefaultScopes stays at two and
SpotifyAuthOptionsTests is untouched. CLAUDE.md's call list is updated to six.

The editor moved out of the list row, because it never fitted inside one

The row editor was rebuilt twice and tightened once, and every version was judged by looking at it.
Measuring it settled the question in one reading. At the shell's minimum window the track list
viewport is 347 DIP and one expanded row was 539 — an editor 1.55× the height of the
container it opens in. The page owns only ~130 DIP of the chrome above the list, so spending all of
it still leaves a deficit, and the costed alternative (search in a popover) lands the row at exactly
347, which is the same failure at one decimal place.

So the editor is a pane beside the list. The list holds 5 rows at the minimum window and 11
maximized, the pane is the same size and in the same place whatever is picked, and choosing a
different track reflows nothing — measured at 373 DIP before and after a search returning ten
candidates.

The general rule is worth more than the fix: an expander is usable only when the thing it opens is
smaller than the space it opens into.
That is a measurement available before the layout is
written, and it is the check two rebuilds skipped.

Three second-order notes:

  • Selection had to come back, and removing it had been right on its own terms. A highlight
    following the arrow keys while nothing acted on the selected row was noise. In a split view the
    selection is the pane's input, so the row has to say which one is showing — a tinted background
    and a narrow accent edge, not the themed container's saturated fill.
  • Re-filtering nulls the list's SelectedItem. ApplyFilter clears and refills
    VisibleTracks, and the clear travels out through the two-way binding, so without capturing the
    pick and restoring it the editor emptied on every keystroke in the filter box — including the
    keystrokes narrowing the list toward the row being edited.
  • One scroll region was the wrong instinct. Letting the whole pane scroll put ten results below
    the fold, where the only evidence the search had worked was the scroll bar getting shorter. The
    fields scroll; the search box and its results are pinned to the foot.

Every tag the recorder writes is now editable

The page shipped with three editable fields and showed year, genre and artwork read-only beside
them. "Which fields belong here" has an answer that is not a matter of taste: whatever the
recording path writes.
FFmpegArguments.MetadataArguments covers title, artist, album artist,
album, genre, date, track, disc and copyright — so a recording could carry a wrong disc number that
no part of Offstream could correct. Composer, comment and BPM are in neither list, which is what
keeps this from drifting into a general-purpose tag editor.

Ten fields do not stack: 10 × 65 DIP is 650 in a 347 DIP pane, the same arithmetic as the section
above, met a day later on the pane that fixed it. Paired into two columns it is 390 — seven of ten
visible at the minimum window, all ten at any real size. Numeric boxes read fine at half width,
which is what makes the pairing free.

Filling the gap turned up three defects underneath it, none visible while the fields were absent:

  • Writing one artist back over a tag that held two destroyed data. ID3v2.3 separates artists
    with a slash, so a file recorded as AC/DC holds the two values AC and DC; Read took
    FirstPerformer, the box showed AC, and Write did tag.Performers = [track.Artist]
    narrowing the tag on the page whose purpose is repairing tags. Pre-existing since the writer was
    written, and invisible until an album artist box appeared beside the artist box showing AC, DC
    next to AC. The original list now goes back verbatim while its first entry still matches the
    box, since that is where the box was filled from; a match satisfies this by construction, because
    the mapper sets the artist from performers[0] — now pinned by a test, as the whole fix leans on
    it.
  • The tempting fix there is worse than the bug. Splitting the box on commas the way the genre
    box splits reads Earth, Wind & Fire as three artists. Genres can be edited as a comma-separated
    line because a genre list really is a list; names cannot, because commas inside one name are
    ordinary.
  • An emptied box claimed a change it would not make. HasChanges compared before against
    after, so clearing the album box lit Will change and then saved nothing — the writer has never
    written a blank over a value. The predicate now asks whether saving would alter the file, which is
    the question the badge was always claiming to answer.

The never-erase rule became one helper, LibraryLookup, rather than two hand-written copies, and
grew from genre and year to all seven fields a lookup can leave empty. Still two call sites and both
still load-bearing, but the rule exists once — which matters, because the original bug survived its
first fix precisely by existing twice.

A display bug the artwork decision exposed

The thumbnail was bound to decoded bytes — but a lookup normally returns a URL and no bytes, and
the writer downloads it at save time. So it showed the artwork the file already had while claiming
to show what saving would do, and on a hand-picked match it put the replaced track's sleeve on
both sides of the before-and-after. It now binds to either an image or a Uri and lets WPF's
Image.Source fetch the second, which also keeps the network out of the view model and off whatever
thread a fetch happened to finish on.

Every time on this page, the thing that made a bug visible was drawing the value rather than testing
it.

Verification

.\build.ps1 -Clean -Test1,269 passing (262 UI + 1,007 Core), 0 warnings. -VerifyFormat
clean. Resource keys 210/210 across en and fr.

Driven in the running app against a real library, because that is where every bug on this page has
come from. The search was exercised on a row tagged AC — Who Made Who against daft punk one more time, an artist the automatic path structurally cannot reach: applying the result rewrote title,
artist and album with a was … line under each, moved the year, and replaced the sleeve on both the
row and the after side. Layout was measured by UIAutomation at the minimum window and maximized
rather than eyeballed.

The save path was verified on a copy of a real recording rather than the library, since Save is
a write to the user's own files: with one unrelated field edited, the file came back with
Performers still AC || DC, the edit applied, and the tag still at ID3v2.3. Before the artist
fix, that same run left AC.

🤖 Generated with Claude Code

revtex and others added 4 commits August 31, 2026 20:39
There was no way to correct a bad match. The three fields are editable,
so a wrong title could be typed over - and doing that forfeits the year,
the genre and the artwork that come with a real match, which is most of
why anyone opens this page. Re-fetch could not help, and the reason is
structural rather than a bug: the automatic lookup builds its query from
the file's own artist and title, then discards any result whose artist
disagrees with them. Both halves are right. Spotify always returns
something, and the top hit for a misparsed filename is routinely a
different song, so the gate is what stops the page writing a confident
wrong answer into a correctly named file. But it means the one case the
automatic path can never reach is a file whose artist is the thing that
is wrong, and pressing Re-fetch asks the identical question and gets the
identical answer. Auto-fetch skips the row too, because all three fields
are filled in.

So there is a second lookup with a deliberately different shape: the
user's words instead of the file's, several results instead of a
verdict, and nothing filtered on the way out. A person reading a list
can tell a live take from a studio one, and a filter that could hide the
right answer is worse here than a list with a few wrong ones in it. Each
result carries its release year, because a search for a song recorded
twice returns the original and the remaster with identical titles by
identical artists, and the year is the only thing on the row that
separates them.

The chosen candidate is looked up again rather than tagged from the
search result. A search result is not a full track - the release date
lives on the album and the genre on the artist - and a hand-picked match
arriving thinner than an automatic one would be a strange thing to have
built. Both paths share one enrichment step for the same reason: the
rule about putting back what Spotify does not offer is the kind that
goes wrong quietly, and it would drift the moment there were two copies.

Picking a result also drops the file's own cover art, which is the one
place the rule that an embedded picture beats a provider's URL is
inverted. That rule protects artwork while a lookup confirms what a file
already says; choosing a different song says the opposite, and keeping
the old sleeve would write correct tags and the previous artist's cover
into the same file.

That decision then exposed the display bug underneath it. The thumbnail
was bound to decoded bytes, but a lookup normally returns a URL and no
bytes - the writer downloads it when saving - so it showed the artwork
the file already had while claiming to show what saving would do. On a
hand-picked match it put the replaced track's sleeve on both sides of
the before-and-after. It now binds to either an image or a URI and lets
WPF fetch the second, which also keeps the network out of the view model
and off whatever thread a fetch happened to finish on.

Last.fm is not offered here. Its lookup answers a question about a named
artist and title, so it has no results for someone who does not yet know
what the track is called, and an empty box that can never return
anything would be worse than saying it needs Spotify.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The panel that opens under a track was laid out as a form when what it
shows is a comparison. Labels hung in a right-aligned gutter of the
page's own invention, one field per line, eight rows deep — a shape no
other page in the application uses, so it read as a foreign object, and
it pushed the answer to "is this match right?" off the bottom of the
list. It is now the recessed well already used elsewhere for "here is
what was found", with the label above its field the way every other page
writes one, the four fields paired into two columns, and what the match
found beside them instead of in a section of its own.

Capping the panel's width turned out to be the interesting part. A
MaxWidth on a panel aligned Left arranges it at its *desired* width, and
a Grid's desired width is what its columns' content asked for — so every
box shrank to the word inside it, a 130px box for "Thank Me" beside a
270px one for "Able Heart, Qveen Herby". The cap is now a star column
carrying MaxWidth beside an empty Auto one, which is the idiom the rest
of the file already uses.

Underneath that, three defaults were wrong. The list scrolled by item,
so a wheel notch moved three rows and a row here is a three-line block —
most of a screen at a time; pixel scrolling moves 48 DIP instead, which
was measured rather than assumed. A row had a selected state that
nothing on the page acts on, drawn brightly over rows that were neither
ticked nor open; removing it takes a template of a bare ContentPresenter,
because the themed one paints an accent bar the brush setters do not
reach. Focus still moves, and the focus rectangle still shows where it
is. And the search box was seeded from the toggle command, which only
the row's title runs, so opening a row by its chevron — the control that
looks like the way to do it — left the box empty and searching from
there asked Spotify for nothing.

The last one is a fourth member of the genre false-positive family, and
the earlier fix had been made in the wrong place. Putting the file's own
genre and year back when the lookup has none was done inside
SpotifyCatalogEnricher, which is no help when the chain falls through to
Last.fm — its EnrichAsync assigns the artist's tags just as
unconditionally and returns nothing for an artist nobody has tagged. The
rule now sits at the call site every library lookup passes through, so
it holds for whichever provider answers and for any provider added
later. Nothing was ever written wrongly — the writer skips empty values
— but the row claimed a change Save would not make, and the rebuilt
panel spelled it out as an offer to erase a curated tag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The row editor was rebuilt twice and tightened once, and each version was
judged by looking at it. Measuring it ended the argument. At the shell's
minimum window an expanded row is 539 DIP tall and the list viewport it opens
inside is 347 — the editor is 1.55x the height of its own container, so opening
one file buried the library it was chosen from and pushed the search results off
the bottom edge.

That is not a spacing problem and no amount of trimming reaches it. The page
owns about 130 DIP of the chrome above the list; the rest is the shell's title
bar and nav strip. Spending all of it still leaves a deficit, and the best case
is a list showing exactly one file. The cheaper variant — moving the search into
a popover — lands an opened row at 347 DIP, which is precisely the viewport and
the same failure at one decimal place.

So the editor is a pane to the right of the list. The list holds five rows at
the minimum window and eleven maximized, the pane is the same size and in the
same place whatever is picked, and changing the pick reflows nothing: measured
before and after a search that returned ten candidates, the list viewport read
373 DIP both times.

Three things followed from the split rather than from taste.

The selection highlight comes back. Removing it was right for the old page,
where nothing acted on the selected row and a highlight tracking the arrow keys
competed with the tick box and the chevron for meaning. Here the selection is
what the pane shows, so the row has to say which one it is — a tinted background
and a narrow accent edge, not the themed container's saturated fill. It also
means ApplyFilter has to capture the pick and put it back, because clearing
VisibleTracks makes the list null its own SelectedItem, and without that the
editor emptied on every keystroke in the filter box, including the keystrokes
narrowing the list towards the row being edited.

The pane has two regions, not one scroller. Letting the whole thing scroll put
ten results below the fold, where the only evidence the search had worked was
the scroll bar getting shorter. The fields scroll; the search box and its
results are pinned to the foot of the pane, because the results are what the
user just asked for and the fields are what they will scroll back to.

Seeding the search box moved from the row's expand command to the page's
selection, which is the seam every route now crosses, and the intro paragraph
became a tooltip on the heading — the same trade the Advanced page makes, for
the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The page shipped with three editable fields — title, artist, album — and
showed year, genre and artwork read-only beside them. The scope question
"which fields belong here" has an answer that is not a matter of taste:
whatever the recording path writes. FFmpegArguments.MetadataArguments
writes title, artist, album artist, album, genre, date, track, disc and
copyright, so a recording could carry a wrong disc number that no part of
Offstream could correct. Composer, comment and BPM are in neither list,
which is what keeps this from drifting into a general-purpose tag editor.

Filling the gap turned up three defects underneath it, none of them
visible while the fields were absent.

Ten fields do not stack. 10 x 65 DIP is 650 in a 347 DIP pane — the same
arithmetic that moved the editor out of the list row yesterday, met again
a day later on the pane that fixed it. Paired into two columns it is 390
DIP: seven of ten visible at the minimum window and all ten at any real
size. Numeric boxes read fine at half width, which is what makes the
pairing free.

An emptied box was reported as a change. HasChanges compared before
against after, so clearing the album box lit "Will change" and then saved
nothing, because the writer has never written a blank over a value. The
predicate now asks whether saving would alter the file, which is the
question the badge was always claiming to answer.

Writing one artist back over a tag that held two destroyed data. ID3v2.3
separates artists with a slash, so the file recorded as AC/DC holds the
two values AC and DC; Read took FirstPerformer, the box showed AC, and
Write did tag.Performers = [track.Artist] — narrowing the tag on the page
whose purpose is repairing tags. It had been there since the writer was
written and stayed invisible until an album artist box appeared beside
the artist box, showing "AC, DC" next to "AC". The original list now goes
back verbatim when its first entry still matches the box, since that is
where the box was filled from; a match satisfies this by construction,
because the mapper sets the artist from the first performer it assigns.

The tempting fix there is worse than the bug: splitting the box on commas
the way the genre box splits reads "Earth, Wind & Fire" as three artists.
Genres can be edited as a comma-separated line because a genre list
really is a list; names cannot, because commas inside one name are
ordinary. Both artist boxes keep the array they were filled from while
the text still matches it, and take the whole line as a single value when
it does not.

The never-erase rule from yesterday became one helper, LibraryLookup,
rather than two hand-written copies, and grew from genre and year to all
seven fields a lookup can leave empty. There are still two call sites and
both are still load-bearing, but the rule itself now exists once, so the
next field cannot be added to one copy and forgotten in the other. That
is exactly how the bug survived its first fix.

Verified on a copy of a real recording rather than the library: a page
save with one unrelated field edited left Performers as AC || DC, applied
the edit, and kept the file at ID3v2.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@revtex
revtex merged commit 64d0bcc into main Sep 1, 2026
6 checks passed
@revtex
revtex deleted the metadata-manual-match branch September 1, 2026 00:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant