Repair the tags on recordings that already exist - #44
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
downloadsdirectory — recordings go to theconfigured 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.
LastFmMetadataProviderneeded no change at all — it already looks up by artist and title, whichis 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:
CoverArtWriterafter itself, which would save the file twice for one edit.
Id3v2.Tag.DefaultVersion = 3. TagLib# defaults to v2.4 and this project ships v2.3, soa 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.mdsays so./searchcosts no user scope, soDefaultScopesstays at two. While in that file: the rule saidOffstream 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
ScrollViewerwithHorizontalScrollBarVisibility="Auto"measures its content at infinitewidth, so a
*column asks for the full unwrapped width of its longest string andTextTrimmingnever 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 actuallychanges. 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.
HasChangesended by asking whether the suggestion had cover art at all — true of everywell-tagged file, since the scan reads the file's own picture into
Existingand the copyconstructor carries it into
Suggested. The visible half was a "will change" badge on all 127rows. The invisible half was that the same comparison is what
LibraryTagWriterskips on, soSave 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.
Trackkeeps ascraped 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
Savedis what suppresses it. Thewrite was always correct — the writer re-checks — so the page was only lying about there being
nothing left to do.
Other decisions worth knowing
few requests and curated tags are never silently replaced. Per-row Re-fetch overrides it.
.wavis 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.
one row and leave the run going, and Spotify's own error message is surfaced rather than replaced.
Verification
.\build.ps1 -Clean -Test— 1,214 passing (231 UI + 983 Core), 0 warnings.-VerifyFormatclean. 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 …lineappears under the one field that changed. The shell's
MinWidthis 1024 DIP and the display runsat 150%, so every frame was already at the narrowest width the page can ever render.
🤖 Generated with Claude Code