Skip to content

Repair the tags on recordings that already exist - #44

Merged
revtex merged 2 commits into
mainfrom
metadata-manager
Sep 1, 2026
Merged

Repair the tags on recordings that already exist#44
revtex merged 2 commits into
mainfrom
metadata-manager

Conversation

@revtex

@revtex revtex commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Offstream tags a recording while it makes it, and only then. A file recorded before a metadata
source was configured, recorded while Last.fm was down, or recorded from a window title the parser
could not split, keeps its thin tags for good — and the recordings worth keeping are exactly the
old ones. This adds a Metadata page that scans a folder, reads what each file already carries,
looks up what is missing, shows the result for review, and writes the approved tags back.

Three steps in one direction, and only the last one writes. An automatic match is wrong often
enough that the review step is the feature rather than friction in front of it.

Three premises that did not survive contact with the code

Each one looked settled going in. There is no downloads directory — recordings go to the
configured output path, so the page defaults there and offers a picker. The existing Spotify
provider cannot look up a file: it asks what is playing right now, which is useless for a file at
rest, so search is genuinely new code. And there was no Spotify-then-Last.fm fallback anywhere; the
provider is a single user choice, so the chain is new and deliberately confined to this page. The
recording pipeline keeps its single-provider behaviour and is untouched.

LastFmMetadataProvider needed no change at all — it already looks up by artist and title, which
is exactly what a filename or a partial tag gives it.

Retagging cannot go through ffmpeg

This is the constraint the change touches, so it is restated rather than quietly bent. ffmpeg
cannot edit a tag in place; changing one string means remuxing the file, rewriting audio that had
nothing wrong with it. TagLib# writes this path instead — which makes it the second tag writer in
the codebase, and two rules follow:

  • It writes tags and cover art in one TagLib# session rather than calling CoverArtWriter
    after itself, which would save the file twice for one edit.
  • It pins Id3v2.Tag.DefaultVersion = 3. TagLib# defaults to v2.4 and this project ships v2.3, so
    a retag that silently upgraded the tag would make tags vanish from Explorer and Media Player on
    files that displayed correctly until Offstream touched them.

"ffmpeg writes every textual tag" stays true of the recording pipeline and is now false of this
page. CLAUDE.md says so.

/search costs no user scope, so DefaultScopes stays at two. While in that file: the rule said
Offstream makes exactly three calls while the code already made four, so it now names the five
and states the thing that actually matters — the scope list is what to guard, not the count.

The first layout was unreadable, and the cause was measurement

It was a seven-column table. Long filenames were the visible symptom; the cause is that a
ScrollViewer with HorizontalScrollBarVisibility="Auto" measures its content at infinite
width
, so a * column asks for the full unwrapped width of its longest string and TextTrimming
never engages. The trimming was already in the XAML and had never once run — one long filename
widened the table and pushed everything else out of the window.

The row is now compact and shows what will be written — title, artist · album, filename —
and opens on demand into labelled fields, with a was … line only under a field that actually
changes. Three of a hundred and twenty-seven files fitted on screen before; the whole list does
now. Scrolling is Disabled, which is what forces measurement at the real available width.

Two defects only the running app exposed

Both had passing tests on either side of them.

HasChanges ended by asking whether the suggestion had cover art at all — true of every
well-tagged file, since the scan reads the file's own picture into Existing and the copy
constructor carries it into Suggested. The visible half was a "will change" badge on all 127
rows. The invisible half was that the same comparison is what LibraryTagWriter skips on, so
Save would have rewritten every one of those files to embed the artwork it already had. The
whole suite passed, including the test named for skipping a row with no changes, because no fixture
gave a file a picture — a fixture that omits the field a rule is about confirms the rule either way.

A correction typed over a fetched match was discarded at the point of writing. Track keeps a
scraped value and an API value per field and the getter prefers the API one. That is right while a
provider fills gaps and wrong the moment a person corrects the provider. Both tiers are set now.
The existing test asserted the right thing in the wrong scenario: it edited without fetching first,
so the API tier was null and the ordinary setter was enough.

Editing a row after saving it also left the badge dark, since Saved is what suppresses it. The
write was always correct — the writer re-checks — so the page was only lying about there being
nothing left to do.

Other decisions worth knowing

  • A fully-tagged file is skipped by Auto-Fetch and shown as Matched, so a large library costs
    few requests and curated tags are never silently replaced. Per-row Re-fetch overrides it.
  • .wav is excluded on purpose. Offstream records it, so those files will be in the folder,
    but WAV has no dependable tag container — listing them would show rows that look taggable and
    then fail on save. The page says how many it skipped rather than hiding them.
  • Failures are per-row, never fatal. A locked file, a 429, a quota 403 and a no-match each mark
    one row and leave the run going, and Spotify's own error message is surfaced rather than replaced.

Verification

.\build.ps1 -Clean -Test1,214 passing (231 UI + 983 Core), 0 warnings. -VerifyFormat
clean. Resource keys 182/182 across en and fr.

Beyond the suite, the page was driven in the running app against a real 127-file library with
UIAutomation and real mouse input, because a clean build and a green suite are not verification for
a WPF page — that is how both defects above were found. Confirmed there: trimming engages on long
filenames, click-to-expand fires on the row text (119px → 395px) without fighting the checkbox,
a live Re-fetch returns Suggested with the badge lit on that row alone, and the was … line
appears under the one field that changed. The shell's MinWidth is 1024 DIP and the display runs
at 150%, so every frame was already at the narrowest width the page can ever render.

🤖 Generated with Claude Code

revtex and others added 2 commits August 30, 2026 10:45
Offstream tags a recording while it makes it, and only then. A file
recorded before a metadata source was configured, recorded while Last.fm
was down, or recorded from a window title the parser could not split,
keeps its thin tags for good. There has been no way to fix one
afterwards, and the recordings worth keeping are exactly the old ones.

The Metadata page scans a folder, reads what each file already carries,
looks up what is missing, shows the result for review, and writes the
approved tags back. Three steps in one direction and only the last one
writes, because an automatic match is wrong often enough that the review
step is the feature rather than friction in front of it.

Three premises in the original request did not survive contact with the
code and are worth recording, because each one looked settled. There is
no downloads directory - recordings go to the configured output path, so
the page defaults there and offers a picker. The existing Spotify
provider cannot look up a file: it asks what is playing right now, which
is useless for a file at rest, so search is genuinely new code. And
there was no Spotify-then-Last.fm fallback anywhere; the provider is a
single user choice. The chain is new and deliberately confined to this
page, so the recording pipeline keeps its single-provider behaviour.

Retagging cannot go through ffmpeg, which is the rule this touches. ffmpeg
cannot edit a tag in place, so changing one string means remuxing the
file - rewriting audio that had nothing wrong with it. TagLib# writes
this path instead, which makes it the second tag writer in the codebase,
and two rules follow from that. It writes tags and cover art in one
session rather than calling CoverArtWriter after itself, which would save
the file twice for one edit. And it pins ID3v2.3, because TagLib#
defaults to 2.4 while this project ships 2.3 - a retag that silently
upgraded the tag would make tags vanish from Explorer and Media Player on
files that displayed correctly until Offstream touched them.

/search costs no scope, so the scope list stays at two. The line in
CLAUDE.md said Offstream makes exactly three calls while the code already
made four, so the rule is restated against the thing that matters: the
scope list is what to guard, not the count. Adding a call to an endpoint
that needs no user scope is ordinary work.

The first version of the page was a seven-column table, and it was
unreadable. Long filenames were the visible symptom and the cause was
measurement: a ScrollViewer with horizontal scrolling set to Auto
measures its content at infinite width, so a star-width column asks for
the full unwrapped width of its longest string and TextTrimming never
engages. The trimming was already there and had never once run. One long
filename widened the table and pushed everything else out of the window.

So the row is compact and shows what will be written - title, artist and
album on one line, filename beneath - and opens on demand into labelled
fields, with a "was ..." line only under a field that actually changes.
Three of a hundred and twenty-seven files fitted on screen before; the
whole list does now. Scrolling is Disabled rather than Auto, which is
what forces measurement at the real available width.

Two defects only the running app exposed, and both had passing tests
either side of them. HasChanges ended by asking whether the suggestion
had cover art at all, which is true of every well-tagged file: the scan
reads the file's own picture into Existing and the copy constructor
carries it into Suggested. The visible half was a "will change" badge on
all 127 rows. The invisible half was that the same comparison is what the
writer skips on, so Save would have rewritten every one of those files to
embed the artwork it already had. The whole suite passed, including the
test named for skipping a row with no changes, because no fixture gave a
file a picture - a fixture that omits the field a rule is about confirms
the rule either way.

The second: Track keeps a scraped value and an API value per field and
the getter prefers the API one. That is right while a provider fills
gaps and wrong the moment a person corrects the provider, so a
correction typed over a wrong match was accepted by the box, shown in
the row, and discarded at the point of writing. Both tiers are set now.
The existing test asserted the right thing in the wrong scenario: it
edited without fetching first, so the API tier was null and the ordinary
setter was enough.

Editing a row after saving it also left the badge dark, since Saved is
what suppresses it. The write was always correct - the writer re-checks -
so the page was only lying about there being nothing left to do.

WAV is excluded on purpose. Offstream records it, so those files will be
in the folder, but WAV has no dependable tag container and listing them
would show rows that look taggable and then fail on save. The page says
how many it skipped rather than hiding them.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Three complaints about the page, and the third of them found a bug.

The scroll bar sat on top of the expand chevron. WPF-UI paints it as an
overlay - over the viewport rather than taking width from it - so the
rows are measured as though it were not there and it lands on whatever
is at the right-hand edge of every row it covers. No layout pass can
discover that, so the room is reserved deliberately. Worth measuring
before reaching for padding, because padding hides a real layout fault
just as well: the list and a row both ended at x=2390, so the bar
occupies no layout width at all and the diagnosis is settled.

A hundred and twenty-seven files is a scroll and a real library is a
search, so there is a Filter box. It narrows what is drawn and nothing
else - Save still writes every ticked row it is hiding, which is the one
thing about it worth a test, since a filter that quietly reduced the
write would lose work the user had already approved. The counts stay
counts of the library, and a filter matching nothing says so rather than
collapsing the page back to the state it was in before the scan.

The expanded row now shows what a match found that has no box of its
own. Year, genre and artwork are all written by Save and appeared
nowhere, so a row could announce "will change" with title, artist and
album each identical to the file and nothing on screen saying why. That
is the complaint. They are read-only: they come from the match or they
do not come at all, and a text box for a genre invites typing one
Spotify has never heard of. Artwork gets a before-and-after pair of
thumbnails, because there is no other way to see a picture change.

The first thing that block drew against a real library was "Genre - was
Hip-Hop, rap, hip hop" - an offer to erase a tag nobody had asked to
erase. The album mapping assigns genres unconditionally, which is right
for the recording path, where the track starts empty and Spotify is the
only source there is, and wrong here, where the track starts as the
file's own tags. Spotify has returned an empty genre list for most of
its catalogue since late 2024, so a lookup blanked a curated genre, the
comparison then saw a difference, and the row claimed a change that Save
would never make - the writer only writes a genre it actually has. Year
had the identical hole for an album with no release date. Both are put
back when Spotify offers nothing, ahead of the artist-genre fallback,
which also saves a request for every file that already had one.

This is the same false positive as the artwork one from the last change,
found a different way. That one needed someone to look at the running
app. This one was invisible there too, because the value it corrupted
was written on Save and rendered nowhere - it took building the display
to see it, and the suite could not have caught it either: the fixtures
asserted what a lookup adds and never what it silently takes away.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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