Repository navigation
v0.0.4 — filtering, pagination and sorting
v0.0.4 — filtering, pagination and sorting
Browse screens you can actually narrow down. Library and Archive gain real paging, sortable headers, filter chips and a scan note, and /api/items grows sort, order and a per-media-type missing filter.
The feature is the smaller half. Five of the six commits in this release fix something that was already broken, and all five were found by using the tool rather than by its tests.
The archive was losing a row while showing another twice
ORDER BY name is not a total order. 8 album rows and 2 track rows share a name with another row, and OFFSET paging over a non-total order is incorrect by construction. Measured against a real archive:
109 rows fetched, 108 distinct
duplicated across pages: [90] 'Corazones' — Jorge González
never appearing: [8] 'Corazones' — Los Prisioneros
The count said 109. The walk yielded 109 entries. One of them was a duplicate — so the missing row was invisible from the UI, and no amount of looking at the screen would have found it.
Every ordering now ends with id, which the reindex assigns deterministically, so the tiebreaker survives a rebuild. Confirmed deterministic rather than flaky, five runs of each:
ORDER BY name 108 distinct, duplicate id 90 in EVERY run
ORDER BY name, id 109 distinct, no duplicates in ANY run
The missing-metadata filter was useless for songs
MusicArtist 500 items: overview in 254, provider ids in 414, genres in 392
Audio 500 items: overview in 1, provider ids in 69, genres in 314
A song without an Overview is the normal state of a song. The old filter counted that as missing metadata, so over a song library it reported 5,441 of 5,442 items incomplete — a wall of red that buried the 1,466 songs whose missing genres were actually fixable. It could not be fixed by filtering harder; it needed a per-media-type definition.
Jellyfin cannot express this for music (Filters=IsMissing is accepted and ignored), so matching items means reading them. That scan is capped, and the cap is reported:
?kind=song&missing=genres → 679 matched from 2,000 checked, truncated, 1.8s
A labelled lower bound, rather than a total that looks complete and is not.
The bulk and harvest selections had kept their own copy of the same broken test — on a screen whose output is a write. All three surfaces now share one definition.
Two more that shaped how the screens behave
- The library's total described a set the table could not show. It fetched one page of 200 and filtered in the browser, so the header read "641 artists" above 200 rows. With 5,442 songs, 96% of the library was unreachable and nothing said so.
- "Select all" meant "select the first page", on a path that feeds a write. It now says "Select these 50", reports how many of the selection are on the current page, and keeps a selection across pages — because selecting across pages is why paging exists — while still clearing it when the query changes.
Also in this release
NULLS LAST, argued from the data rather than from theory: all 383 artist rows have a NULLlisteners, against 0 of 109 albums. Postgres defaults toNULLS FIRSTonDESC, so "sort artists by listeners, descending" would have led with every row that has no listener count.- The end-to-end specs are typechecked for the first time.
tsconfig.jsonincludes onlysrcand Playwright transpiles without checking, so every spec was the only TypeScript in the app that nothing verified. Adding the config found seven strict-mode errors waiting in the pre-existing specs. - The frontend has unit tests, run by node's native TypeScript stripping so the paging arithmetic needs no test runner. The runner treats "no test files matched" as a failure, because
node --testexits 0 on an empty glob — a renamed file would otherwise have left the build green while checking nothing. - The e2e stub can now fail. It held 5 items, ignored
sortByentirely, and computedTotalRecordCountafter slicing — so it reported the wrong total for the one number this release is about. Rebuilt: 65 items, two artists named "Twin Peaks", sort applied before the window, real filters. The suite went from 19 to 27 passing specs.
Verification
| Backend | 790 passed, 20 deselected |
| Frontend unit | 17 passed |
| End-to-end | 27 passed (was 19) |
| Types | app + test + e2e configs, strict |
| Lint | ruff check, ruff format --check, mypy |
| Contract | export_contract.py --check passes |
Every fix was verified by reintroducing the defect and confirming the test fails — sort translation, filter passing, truncation reporting, the tiebreaker, NULLS LAST, per-kind aspects, list query encoding, and four paging arithmetic cases.
Six of the new tests initially passed for the wrong reason and are now fixed: a paging walk that clicked Next and re-read without waiting (collecting page 1 fifty times), an assertion comparing a page's distinct names to the library total, a sort injection the fixture could not detect because every row shared one listeners value, two truncation tests with the cap above the fixture size, and a query test that tested a copy of the function rather than the function.
Known limitations
- The end-to-end suite is not run in CI. It needs a browser and a compose stack. It was run locally six times and the final run is green, but a future change can break it without CI noticing.
- No sort tiebreaker is sent to Jellyfin. A second key could not be verified to do anything — appending
Id, or even a nonsense key, produced byte-identical responses on a live 12.2.0 server. Adding a key that does nothing would read as a guarantee it is not. The browse is safe today (all 6,583 items walk without loss, and ten identical requests return one identical order), but that guarantee is the server's, not ours. Recorded indomain/browse.py.