Genre index: sort A–Z, and repair casing damage at scan time #124

Merged
bvandeusen merged 2 commits from dev into main 2026-08-07 21:40:33 -04:00
Owner

Closes #2468. Two commits. Server + web, no migration.

CI green: web lane on 4509f740 (run 3519), Go lane and integration on 8e1d25a7 (run 3520).

The taxonomy this task proposed is cancelled

Operator decision, once the repaired library could actually be measured: 391 genres, 90,774 tag applications over ~24,185 tracks — about 3.7 genres per track.

"since items can easily fall under multiple categories this is fine even with the volume of labels we have. better sorting and search box would be nice."

That ratio is the argument. Multi-membership already places every track under everything it claims, so grouping adds nothing it doesn't have — and would destroy the specificity that makes the tail worth keeping (Neurofunk, Wassoulou, Soukous, Emoviolence are not noise).

The task's premise was also wrong. It argued from case variants, Alt. Rock abbreviations and a junk tail; none of those exist. That apparent mess was #2499 (the scanner welding multi-value tags) plus #2523 (ghost rows from deleted files), both ours, both fixed. A category system would have papered over both.

Measured confirmation those fixes landed: zero weld artifacts, zero bare numerics, and Rock went 3413 → 8974.

What actually shipped

Sort (4509f740). The search box already existed — QuickFilter, with its own no-matches state — so this adds only the sort: Most tracks (the server's order, and the right default since the head is where you're going) or A–Z for when you can name the thing but can't find it among 391 rows. The count line now reads "12 of 391" when filtered.

Client-side only: /api/library/genres is unpaged and already returns the whole set (~12KB), so neither control needs a round trip.

Two things the tests pin down:

  • The sort copies before sorting. With no filter applied the derived list is the array held by the query cache, and Array.sort mutates in place — sorting it directly would reorder cached data under every other consumer.
  • Count mode passes the server's order through rather than re-sorting. The fixture is deliberately not in count order, so the test asserts pass-through instead of coincidence.

Casing (8e1d25a7). The operator's library carries Edm, Idm, Aor, Uk Garage, Uk Hardcore, Trap Edm, Glitch Hop Edm, Children'S Music — an external tag editor title-cased the whole genre field. The 'S is the giveaway.

Fixed at scan time, not in the display layer: taste_profile.sql reads tracks.genre directly, so a cosmetic fix would leave the taste vocabulary holding Edm while the UI showed EDM, and a correctly-tagged file would contribute a second, separate tag.

trueUpCasing only ever changes case, never letters — that boundary is what keeps it from being the label remapping this task rejected. Two narrow rules:

  • A short, evidence-led acronym list, matched case-insensitively so edm/Edm/EDM all land on EDM. A deliberate exception to the project's rule that genre case is exposed as the file says it: Rock and rock still stay separate rows, because folding those is a judgement about labels, whereas there is no genre named "Edm".
  • Apostrophe suffixes from a fixed contraction list, so Children'S is repaired while O'Brien and D'Angelo keep their capital. A blanket "lowercase after an apostrophe" would have broken both.

Matching uses the word's letter core rather than the raw word, so (Edm) and Edm, are repaired with their punctuation re-attached, while interior punctuation stays in the core so Lo-Fi and R&B are compared whole. My first version missed this; a test expecting (Live EDM) caught it.

Operator's other decision: keep the ID3v1 table canonical. Entry 40's AlternRock stays as the table spells it — with a test pinning that, so a well-meant future "fix" doesn't quietly change it.

Migration-free, but it does re-read

tagReadVersion 1 → 2, so the repair reaches the existing library on the next scan rather than new files only. That re-read reuses stored durations, so it costs tag reads and no ffprobe — the self-healing mechanism from #2499 doing what it was built for.

Deliberately not done

Nine non-music labels (Non-Music, Interview, Nature Sounds, Field Recording, Spoken Word, …) are accurate descriptions of those files and stay. Case folding across arbitrary labels stays off. Anything needing a letter change is a different feature.

Verified locally before pushing: svelte-check 0 errors, 110 web files / 788 tests, and Go vet + golangci-lint + tests clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01N6vZoJ4Se5YyaqdtGVkap5

Closes #2468. Two commits. Server + web, **no migration**. CI green: web lane on `4509f740` (run 3519), Go lane and integration on `8e1d25a7` (run 3520). ## The taxonomy this task proposed is cancelled Operator decision, once the repaired library could actually be measured: **391 genres, 90,774 tag applications over ~24,185 tracks — about 3.7 genres per track.** > *"since items can easily fall under multiple categories this is fine even with the volume of labels we have. better sorting and search box would be nice."* That ratio is the argument. Multi-membership already places every track under everything it claims, so grouping adds nothing it doesn't have — and would *destroy* the specificity that makes the tail worth keeping (`Neurofunk`, `Wassoulou`, `Soukous`, `Emoviolence` are not noise). The task's premise was also wrong. It argued from case variants, `Alt. Rock` abbreviations and a junk tail; **none of those exist.** That apparent mess was #2499 (the scanner welding multi-value tags) plus #2523 (ghost rows from deleted files), both ours, both fixed. A category system would have papered over both. Measured confirmation those fixes landed: zero weld artifacts, zero bare numerics, and `Rock` went 3413 → 8974. ## What actually shipped **Sort** (`4509f740`). The search box already existed — `QuickFilter`, with its own no-matches state — so this adds only the sort: *Most tracks* (the server's order, and the right default since the head is where you're going) or *A–Z* for when you can name the thing but can't find it among 391 rows. The count line now reads "12 of 391" when filtered. Client-side only: `/api/library/genres` is unpaged and already returns the whole set (~12KB), so neither control needs a round trip. Two things the tests pin down: - The sort **copies before sorting.** With no filter applied the derived list *is* the array held by the query cache, and `Array.sort` mutates in place — sorting it directly would reorder cached data under every other consumer. - Count mode passes the server's order through rather than re-sorting. The fixture is deliberately not in count order, so the test asserts pass-through instead of coincidence. **Casing** (`8e1d25a7`). The operator's library carries `Edm`, `Idm`, `Aor`, `Uk Garage`, `Uk Hardcore`, `Trap Edm`, `Glitch Hop Edm`, `Children'S Music` — an external tag editor title-cased the whole genre field. The `'S` is the giveaway. Fixed at **scan time**, not in the display layer: `taste_profile.sql` reads `tracks.genre` directly, so a cosmetic fix would leave the taste vocabulary holding `Edm` while the UI showed `EDM`, and a correctly-tagged file would contribute a second, separate tag. `trueUpCasing` only ever changes case, never letters — that boundary is what keeps it from being the label remapping this task rejected. Two narrow rules: - A short, evidence-led acronym list, matched case-insensitively so `edm`/`Edm`/`EDM` all land on `EDM`. A deliberate exception to the project's rule that genre case is exposed as the file says it: `Rock` and `rock` still stay separate rows, because folding *those* is a judgement about labels, whereas there is no genre named "Edm". - Apostrophe suffixes from a **fixed** contraction list, so `Children'S` is repaired while `O'Brien` and `D'Angelo` keep their capital. A blanket "lowercase after an apostrophe" would have broken both. Matching uses the word's **letter core** rather than the raw word, so `(Edm)` and `Edm,` are repaired with their punctuation re-attached, while interior punctuation stays in the core so `Lo-Fi` and `R&B` are compared whole. My first version missed this; a test expecting `(Live EDM)` caught it. Operator's other decision: **keep the ID3v1 table canonical.** Entry 40's `AlternRock` stays as the table spells it — with a test pinning that, so a well-meant future "fix" doesn't quietly change it. ## Migration-free, but it does re-read `tagReadVersion` 1 → 2, so the repair reaches the existing library on the next scan rather than new files only. That re-read reuses stored durations, so it costs tag reads and **no ffprobe** — the self-healing mechanism from #2499 doing what it was built for. ## Deliberately not done Nine non-music labels (`Non-Music`, `Interview`, `Nature Sounds`, `Field Recording`, `Spoken Word`, …) are accurate descriptions of those files and stay. Case folding across arbitrary labels stays off. Anything needing a *letter* change is a different feature. Verified locally before pushing: svelte-check 0 errors, 110 web files / 788 tests, and Go vet + golangci-lint + tests clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01N6vZoJ4Se5YyaqdtGVkap5
bvandeusen added 2 commits 2026-08-07 21:40:25 -04:00
Operator decision: the taxonomy this task proposed is cancelled. With their
repaired library measured — 391 genres, 90,774 tag applications over ~24,185
tracks, so ~3.7 genres per track — multi-membership already puts each track
under everything it claims, and grouping would add nothing while destroying
real specificity (Neurofunk, Wassoulou, Soukous are not noise).

The task's premise was also wrong. It argued from case variants, "Alt. Rock"
abbreviations and a junk tail; none exist. That apparent mess was the scanner
welding multi-value tags (#2499) plus ghost rows from deleted files (#2523),
both ours, both now fixed. A category system would have papered over both.

So the ask reduces to sorting and search. The search box already existed
(QuickFilter, with its own no-matches state), so this adds only the sort:
count-first by default — the server's order, and the right default since the
head is where you're going — or A–Z for when you can already name the thing
but can't find it among 391 rows. The filtered count now reads "12 of 391"
so a filter's effect is visible.

Client-side only: /api/library/genres is unpaged and already returns the whole
set (~12KB), so neither control needs a round trip or a server change.

Two things the tests pin down:

- The sort COPIES before sorting. With no filter applied the derived list is
  the very array held by the query cache, and Array.sort mutates in place —
  sorting it directly would reorder cached data under every other consumer.
- Count mode passes the server's order through rather than re-sorting. The
  fixture is deliberately not in count order so the test asserts pass-through
  instead of coincidence.

Verified locally: svelte-check 0 errors, 110 files / 788 tests.
fix(scanner): repair acronym and apostrophe casing on genre tags — #2468
test-go / test (push) Successful in 53s
test-go / integration (push) Successful in 5m2s
8e1d25a772
Operator decision: keep the ID3v1 table canonical, fix the casing.

The operator's library carries "Edm", "Idm", "Aor", "Uk Garage", "Uk
Hardcore", "Trap Edm", "Glitch Hop Edm" and "Children'S Music" — an external
tag editor title-cased the whole genre field. The "'S" is the giveaway.

Fixed at SCAN time, not in the display layer: taste_profile.sql reads
tracks.genre directly, so a cosmetic-only fix would leave the taste
vocabulary holding "Edm" while the UI showed "EDM", and any correctly
tagged file would contribute a second, separate tag.

trueUpCasing only ever changes case, never letters, so it cannot silently
turn one genre into a different one — that is what separates it from the
label-remapping idea this task rejected. Two narrow rules:

- A short, evidence-led acronym list, matched case-insensitively so "edm",
  "Edm" and "EDM" all land on "EDM". This is a deliberate exception to the
  project's rule that genre case is exposed as the file says it: "Rock" and
  "rock" still stay separate rows, because folding those is a judgement about
  labels, whereas there is no genre named "Edm".
- Apostrophe suffixes from a FIXED contraction list, so "Children'S" is
  repaired while "O'Brien" and "D'Angelo" keep their capital. A blanket
  "lowercase after an apostrophe" would have broken both.

Matching uses the word's letter core rather than the raw word, so "(Edm)"
and "Edm," are repaired and their punctuation re-attached. Interior
punctuation stays in the core, so "Lo-Fi" and "R&B" are compared whole and
cannot match a fragment by accident. My first version missed this and a test
expecting "(Live EDM)" caught it.

Names resolved from the ID3v1 table are deliberately NOT re-cased, per the
operator's call — entry 40's "AlternRock" stays as the table spells it, with
a test pinning that so a later tidy-up doesn't quietly "fix" it.

tagReadVersion 1 -> 2, so this reaches the existing library on the next scan
rather than new files only. That re-read reuses stored durations, so it costs
tag reads and no ffprobe.
bvandeusen merged commit aa9f534f3c into main 2026-08-07 21:40:33 -04:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bvandeusen/minstrel#124