Repository navigation
v0.2.0 — filters that cannot silently widen
Four features on the screens where a selection feeds a write, and one of them changed a rule that was quietly wrong.
Narrowing that cannot silently widen
ArtistIds=<32hex> albums=2 songs=21
ArtistIds=abc albums=502 songs=5442 ← filter vanished
ArtistIds=<32hex>|<32hex> albums=502 songs=5442 ← filter vanished
ArtistIds=<40hex> albums=502 songs=5442 ← filter vanished
That is a live Jellyfin 12.2.0. An id list it cannot parse is discarded in full, and the server answers with the unfiltered library. A single typo turns "narrow to these three artists" into "the entire library" — and on these screens that set is what feeds a write, so the failure is a batch you did not mean to run.
Ids are now checked against the shape Jellyfin actually parses (32 hex characters, or a dashed GUID) and refused with a 422 that says why. The browser drops a bad id from a URL instead, deliberately: a link you edited should show the unfiltered list, not an error page.
Related, and the same class of quiet wrongness: ArtistIds on an artist query returns zero, not an error. An empty table reads as a library with nothing in it, so an inapplicable filter is now refused rather than answered.
Album and artist filters
Filter albums by artist, and songs by artist and/or album. These are Jellyfin's own parameters, so they are free: nothing is scanned, and total is genuinely the filtered count. ArtistIds=<ABBA> returns 2 of 502 albums and 21 of 5,442 songs; the two parameters union within themselves and intersect across each other.
Exclusion patterns
A pattern is a case-insensitive glob (*, ?) — not a substring, and not a regex.
It matches the item's name, its album and its album artist. All three, because Various Artists is a compilation marker that lives in the album artist and appears in no track or album name, so a matcher reading only the name would leave every compilation in the selection.
Literal, so live hides exactly live and *live* is what hides a substring. Implicit substring matching is how Rock comes to swallow Rockabilly one field over, and the same mistake here would drop items nobody meant to drop. Glob rather than regex because the input is typed into a box by hand, and fnmatch escapes everything but the wildcards — so a pathological pattern cannot become a catastrophic-backtracking regex pointed at your own server.
Jellyfin has no parameter for this, so it costs a read, and the response says what the read covered: scanned, matched, excluded, truncated, limit. A truncated scan is reported as a lower bound rather than presented as a total.
Pagination above the table
As well as below it. A long page is read from the middle, and an operator who has scrolled to the last row should not have to travel back up to move on. The two controls carry distinct accessible names, so they are not ambiguous to a screen reader.
Also
- A filtered batch or removal now fills its limit.
limit: 25withmissing=genrespreviously returned however many of the first 25 happened to lack genres — maybe four — with nothing to explain the difference. The selection keeps reading until it has 25 that pass, andexcludedexplains a short result. - The same three narrowings apply to batch diffs, genre removals and harvests, so one selection means one thing across the app.
- Patterns narrow a view or a selection. They never delete anything — a pattern that hides an album from the browse screen does not remove a genre from it. Deleting is still the separate, reviewed, revertible Remove genre tool.
- Changing any filter clears the current selection, because that selection feeds a write.
Upgrading
No migration and no configuration change. METAEDIT_BROWSE_SCAN_CAP (default 2,000) bounds how much a read-backed filter will look at, and is now documented as an operator knob rather than a test hook — exclusion patterns make scans routine. Raise it on a large library and the filter gets slower rather than wrong, which is the direction to prefer.
Verification
| Check | Result |
|---|---|
| Backend suite (CI) | 1035 passed, 20 deselected |
e2e, clean stack via scripts/e2e.sh |
52 passed (12 new) |
| Frontend unit | 28 passed |
ruff / mypy / contract --check |
clean |
| CodeQL, image build and health | pass |
Both new mechanisms are mutation-tested rather than trusted. Deleting the facet parameter fails exactly the five facet specs and leaves the seven exclusion specs green, so they are covered independently; removing patterns from the selection key fails exactly the "selection is cleared" spec.
Five defects were found and fixed during this work, all in the author's own code — including a spec that asserted toHaveCount(0) on text that never renders while a selection exists, so it passed whatever the screen did. It had passed twice before anyone noticed. Reasoning for all of the above is in ADR 0017.
Known gap, stated rather than implied: the end-to-end suite is not a CI job. Both features are things you click, and the central guarantee — that filtering narrows rather than widens — lives in specs no CI run executes.