metaedit

0015. Genre matching is exact and case-insensitive; a comma is never guessed at

Context

Two features need to answer “is this stored genre the one the operator named?” — the blacklist, which decides what may be proposed for writing, and the removal tool, which deletes a value from a library. Both take operator input, and both are destructive when they answer wrongly.

Three facts about real data, all measured against Jellyfin 12.2.0 and a live 6,583-item music library, combine badly:

  1. Jellyfin’s own Genres parameter matches a whole value, case-insensitively. Verified: Genres=alternative rock returns 23 artists; Genres=ALTERNATIVE ROCK and Genres=AlTeRnAtIvE rOcK return the same 23; every one of the 123 items returned for Genres=rock genuinely carries rock and not merely a value containing it.

  2. Genre values legitimately contain commas and semicolons. Measured: 8 of 502 albums carry at least one, including EBM, Synth-pop, Rock, Reggae, and Hip Hop, Rock, Latin, Funk / Soul, Pop, Children's, Folk, World, & Country. The live genre-entity list has 39 entries for artists, including the distinct pairs Dark Wave/Darkwave and Goth/Gothic.

  3. The natural input format is comma-separated. The requested shape was literally rock, a blacklisted genre, etc.

So a comma means “separator” in the request and “part of the name” in the data, and the same character carries both meanings in the same feature. The difference is not cosmetic: Genres=Rock returns 67 albums where Genres=Rock, Reggae returns 1. An implementation that treated one as containing the other would delete 67 albums’ worth of curation for a request naming a genre that occurs once.

Additionally, TagPolicy.classify runs every Last.fm tag through SPLIT_PATTERN (,, ;, |, ` / `), so a proposed genre is already a piece, while a stored genre may not be. The two sides of the comparison are not symmetric, and assuming they are is the specific mistake this record exists to prevent.

Decision

Matching is exact equality after Unicode NFC normalisation, case folding and whitespace collapsing — never a substring, never a split. The comparison key is normalize_tag, the same function behind the archive’s tag_name_norm columns, so “the same genre” means one thing throughout the application rather than one thing per layer.

Comma-separated input is accepted but never guessed at. A line containing a comma is reported with both readings — the whole value it would match and the fragments it would become — and the caller decides. Splitting happens only when a caller explicitly asks (allow_commas=True), which the UI offers as a separate, labelled action after showing the consequence.

Two questions are kept distinct in code, because conflating them silently blacklists nothing:

Removal from a packed value is a separate, explicit operation. remove_component(value, target) decomposes, drops matching components and rebuilds with the separator the value already used — rewriting Reggae; Ska as Reggae, Ska would be an unrelated restyle of something the operator did not ask to change. It returns replacement=None when nothing survives rather than deciding whether that means “drop the value” or “leave it”; the caller decides.

Neither feature’s matching is applied to curated data implicitly. The blacklist governs what may be added: _merge carries existing values through without consulting it, so an item that already holds a blacklisted genre keeps it. Deleting a stored value is the separate, reviewed, revertible removal operation.

Consequences

Alternatives rejected