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:
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.
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.
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.
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:
matches(value, entries) — “is this literal string blacklisted?”matches_any_piece(value, entries) — “does any component of this multi-valued tag
match?”, splitting with the policy’s own SPLIT_PATTERN. This is the write-path
question, because a proposed genre is a piece.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.
MAX_SCAN_ITEMS and reported, in the same spirit as the browse
missing filter. The exact-match path needs no scan at all, which is why it is the
default.Rock, Reggae — a genre that exists.contains matching. Removes Gothic Rock,
indie rock and rockabilly when the operator blacklists rock.rock does nothing at all to a tag supplied as "Rock, Reggae", because the policy
classified it into pieces before the blacklist saw it. The operator’s entry appears
saved and has no effect.Rock
and rock would occupy two rows, the second would silently do nothing, and the operator
would have no signal — the same argument ADR 0008’s tag edges make about
(entity, tag_name_norm).