Two listings put a different row first than upstream, where a large library has ties #78

Closed
opened 2026-08-04 20:21:04 +02:00 by Claude · 2 comments
Collaborator

Two listings put a different row first than upstream does, on a library large
enough to have ties.

Measured against a database copied from a running Jellyfin — 55,523 items,
43,466 UserData rows, 14 users — with both servers given their own copy.

Filters=IsPlayed&SortBy=DatePlayed&SortOrder=Descending disagrees on the
first row outright:

GET /Items?Recursive=true&IncludeItemTypes=Movie,Episode&Filters=IsPlayed
    &SortBy=DatePlayed&SortOrder=Descending&Limit=50
  Items[0].Id: upstream 7f518b162077cf5dd4226da4b87650fb,
               ours     2fe9b4dcb348d3c0f0fbc8f07cc50861

Everything downstream of that — CommunityRating, the blur hashes, the whole
page — differs because it is a different item, not because any field is mapped
wrong.

/Shows/NextUp disagrees at index 4, in all three of its forms
(/Shows/NextUp, ?SeriesId=…, ?EnableResumable=false):

Items[4].Id: upstream 6d000fb1c073deb628b1caca0ab2876c,
             ours     8869a6e1b12204b3f8188026395386bb

DatePlayed across 43k rows of real play state has ties that a fixture with no
play state cannot produce, and NextUp orders by a date too. D15 settles the
ties upstream's ORDER BY leaves open with the id; whether that is what these
two paths do, and whether upstream's tiebreak for these particular sorts is the
same one, is what wants checking.

Neither is visible against the committed snapshot: with 25 items and no play
state there is nothing to tie.

To reproduce. Point both servers at their own copy of a library with real
play state across several users, then:

curl -s -H "Authorization: MediaBrowser Token=\"$TOKEN\"" \
    'http://127.0.0.1:8096/Items?Recursive=true&IncludeItemTypes=Movie,Episode&Filters=IsPlayed&SortBy=DatePlayed&SortOrder=Descending&Limit=50' \
    | jq -r '.Items[].Id' > /tmp/upstream.ids
curl -s -H "Authorization: MediaBrowser Token=\"$TOKEN\"" \
    'http://127.0.0.1:8097/Items?Recursive=true&IncludeItemTypes=Movie,Episode&Filters=IsPlayed&SortBy=DatePlayed&SortOrder=Descending&Limit=50' \
    | jq -r '.Items[].Id' > /tmp/ours.ids
diff /tmp/upstream.ids /tmp/ours.ids

A third listing may have the same cause and is not confirmed:
/Items?Recursive=true&IncludeItemTypes=Audio&SortBy=AlbumArtist,SortName shows
Items[0] differing on AlbumArtists, ArtistItems and AlbumId, which is
either a mapping difference or a different row in first place. It needs the ids
compared before it is called either.

Two listings put a different row first than upstream does, on a library large enough to have ties. Measured against a database copied from a running Jellyfin — 55,523 items, 43,466 `UserData` rows, 14 users — with both servers given their own copy. **`Filters=IsPlayed&SortBy=DatePlayed&SortOrder=Descending`** disagrees on the first row outright: GET /Items?Recursive=true&IncludeItemTypes=Movie,Episode&Filters=IsPlayed &SortBy=DatePlayed&SortOrder=Descending&Limit=50 Items[0].Id: upstream 7f518b162077cf5dd4226da4b87650fb, ours 2fe9b4dcb348d3c0f0fbc8f07cc50861 Everything downstream of that — `CommunityRating`, the blur hashes, the whole page — differs because it is a different item, not because any field is mapped wrong. **`/Shows/NextUp`** disagrees at index 4, in all three of its forms (`/Shows/NextUp`, `?SeriesId=…`, `?EnableResumable=false`): Items[4].Id: upstream 6d000fb1c073deb628b1caca0ab2876c, ours 8869a6e1b12204b3f8188026395386bb `DatePlayed` across 43k rows of real play state has ties that a fixture with no play state cannot produce, and `NextUp` orders by a date too. D15 settles the ties upstream's `ORDER BY` leaves open with the id; whether that is what these two paths do, and whether upstream's tiebreak for these particular sorts is the same one, is what wants checking. Neither is visible against the committed snapshot: with 25 items and no play state there is nothing to tie. **To reproduce.** Point both servers at their own copy of a library with real play state across several users, then: curl -s -H "Authorization: MediaBrowser Token=\"$TOKEN\"" \ 'http://127.0.0.1:8096/Items?Recursive=true&IncludeItemTypes=Movie,Episode&Filters=IsPlayed&SortBy=DatePlayed&SortOrder=Descending&Limit=50' \ | jq -r '.Items[].Id' > /tmp/upstream.ids curl -s -H "Authorization: MediaBrowser Token=\"$TOKEN\"" \ 'http://127.0.0.1:8097/Items?Recursive=true&IncludeItemTypes=Movie,Episode&Filters=IsPlayed&SortBy=DatePlayed&SortOrder=Descending&Limit=50' \ | jq -r '.Items[].Id' > /tmp/ours.ids diff /tmp/upstream.ids /tmp/ours.ids A third listing may have the same cause and is not confirmed: `/Items?Recursive=true&IncludeItemTypes=Audio&SortBy=AlbumArtist,SortName` shows `Items[0]` differing on `AlbumArtists`, `ArtistItems` and `AlbumId`, which is either a mapping difference or a different row in first place. It needs the ids compared before it is called either.
Author
Collaborator

The third listing is confirmed: it is a different row in first place, not a mapping difference.

From bench run 20260805T083531Z, same anchor. Id and Name differ on all fifty rows, which settles it — nothing is mapped wrong, the page is simply a different page:

GET /Items?Recursive=true&IncludeItemTypes=Audio&SortBy=AlbumArtist,SortName&Limit=50
  Items[0].Id:   upstream "063e4b8f79842bb4af793d8d5aa5f256", ours "c0bc02dfb01e778c362b3cab7e0919d1"
  Items[0].Name: upstream "3rd Prototype & Lex Dave - Time (feat. Yohamna Solange) [NCS Release]", ours "Baby"
  Items[1].Id:   upstream "58659c0857b3cb8cbdf76bc655c238de", ours "3dd8be53ff7f857ae1c9c83eee7b1ec5"
  Items[1].Name: upstream "Absolve_313775811 - Chronic Crew", ours "Evil Twin"

1,200 differences over the page, and AlbumArtists/ArtistItems/AlbumId — the fields this issue was unsure about — are just the consequence.

It is not the same tiebreak problem as the other two, though. Upstream's leading rows carry no album artist at all:

  Items[0].AlbumArtist: we send "Kylie Minogue", upstream sends nothing
  Items[1].AlbumArtist: we send "Lindsey Stirling", upstream sends nothing

Upstream leads an ascending AlbumArtist sort with the tracks that have none, then orders those among themselves by SortName — "3rd Prototype…", "Absolve_313775811…" is alphabetical. We put a named artist first, so we are either sorting empty album artists last or excluding them from the ordering key. That is a null-ordering difference rather than an unstable tiebreak, and it will not be fixed by whatever settles DatePlayed and NextUp. Worth splitting out if the other two turn out to share a cause.

It gates a benchmark request. audio-by-album-artist is one of the eight the bench gate drops, so its cost is currently unmeasured — see #83.

The third listing is confirmed: it is a different row in first place, not a mapping difference. From bench run `20260805T083531Z`, same anchor. `Id` and `Name` differ on **all fifty** rows, which settles it — nothing is mapped wrong, the page is simply a different page: GET /Items?Recursive=true&IncludeItemTypes=Audio&SortBy=AlbumArtist,SortName&Limit=50 Items[0].Id: upstream "063e4b8f79842bb4af793d8d5aa5f256", ours "c0bc02dfb01e778c362b3cab7e0919d1" Items[0].Name: upstream "3rd Prototype & Lex Dave - Time (feat. Yohamna Solange) [NCS Release]", ours "Baby" Items[1].Id: upstream "58659c0857b3cb8cbdf76bc655c238de", ours "3dd8be53ff7f857ae1c9c83eee7b1ec5" Items[1].Name: upstream "Absolve_313775811 - Chronic Crew", ours "Evil Twin" 1,200 differences over the page, and `AlbumArtists`/`ArtistItems`/`AlbumId` — the fields this issue was unsure about — are just the consequence. **It is not the same tiebreak problem as the other two, though.** Upstream's leading rows carry *no album artist at all*: Items[0].AlbumArtist: we send "Kylie Minogue", upstream sends nothing Items[1].AlbumArtist: we send "Lindsey Stirling", upstream sends nothing Upstream leads an ascending `AlbumArtist` sort with the tracks that have none, then orders those among themselves by `SortName` — "3rd Prototype…", "Absolve_313775811…" is alphabetical. We put a named artist first, so we are either sorting empty album artists last or excluding them from the ordering key. That is a null-ordering difference rather than an unstable tiebreak, and it will not be fixed by whatever settles `DatePlayed` and `NextUp`. Worth splitting out if the other two turn out to share a cause. **It gates a benchmark request.** `audio-by-album-artist` is one of the eight the bench gate drops, so its cost is currently unmeasured — see #83.
Author
Collaborator

Two of the three were one bug, and it was not a tiebreak. The third does not reproduce.

Fixed on main in 44c07ad, 08cf310 and ba9156d.

The cause

dateplayed and albumartist were both missing from the sort-name table in crates/jellyfin-http/src/routes/items.rs. A name that table does not know is dropped, so the query fell back to SortName ASC, Id ASC and answered a different page. Nothing was tied and nothing was ordered wrong — the ordering asked for was never applied.

played-descending came back in exactly reversed order, on rows four months apart:

upstream ours, before
[0] L'Invisible bd17017e — 2025-10-31T20:17 Camera Shy a9972238 — 2025-07-28T19:06
[1] Backseat Driver 2a83e1e5 — 2025-07-28T21:10 Backseat Driver 2a83e1e5
[2] Camera Shy a9972238 — 2025-07-28T19:06 L'Invisible bd17017e

audio-by-album-artist shared 0 of 50 ids with upstream's page. Upstream orders by the CleanValue of the first credited album artist (OrderMapper.MapOrderByField), which is NULL for an uncredited track and therefore leads ascending — the null-ordering difference this issue's later comment described, and it falls out of applying the sort at all.

Two related things came with it: DatePlayed now takes the newest of an item's own progress and that of its alternate versions, as upstream's Concat(...).Max() does; and SortOrder is paired positionally with SortBy, so SortBy=AlbumArtist,SortName&SortOrder=Descending,Ascending no longer silently sorts ascending.

/Shows/NextUp does not reproduce

Against a correctly arranged pair the two sides agree id for id, in all three forms — plain, ?EnableResumable=false, and ?SeriesId= for each of the four series. Upstream returns four rows (TotalRecordCount: 4), so the reported disagreement "at index 4" cannot exist here. That measurement came from the fixture layout described in the comment above.

After

before after
played-descending id order equal false true
audio-by-album-artist id order equal false (0/50 shared) true (50/50)
whole scenario 2,318 differences 741

played-descending produces no difference at all now. audio-by-album-artist has three left, all on one row (Items[25].ParentBackdropItemId, ParentBackdropImageTags, and the blurhash keyed by it) — an inherited parent backdrop, unrelated to ordering and not tracked anywhere yet.

What the fix had to survive

The obvious way to write the alternate-version leg is quadratic. Without sqlite_stat1 SQLite drives it from UserData rather than from the partial index on PrimaryVersionId, scanning the user's whole UserData table once per sorted row: 4.46 s against 0.005 s at 5,000 items, and past 120 s at 50,000. It is planned with a CROSS JOIN naming BaseItems first instead, which does not depend on statistics — 0.038 s at 50,000 rows with none.

Worth recording for future measurements: the anchor carries sqlite_stat1 with 80 rows, because it came off a server that had run ANALYZE. It could never have shown this. A synthetic fixture built from the committed baseline schema was the only thing that could.

Album is now ordered by as well, since the canonical music sort a client sends is AlbumArtist,Album,SortName and leaving the middle key out hides the divergence. Artist and Studio use the same ItemValues mechanism and are still unmapped — no request exercises them.

**Two of the three were one bug, and it was not a tiebreak. The third does not reproduce.** Fixed on `main` in `44c07ad`, `08cf310` and `ba9156d`. ## The cause `dateplayed` and `albumartist` were both missing from the sort-name table in `crates/jellyfin-http/src/routes/items.rs`. A name that table does not know is dropped, so the query fell back to `SortName ASC, Id ASC` and answered a different page. Nothing was tied and nothing was ordered wrong — the ordering asked for was never applied. `played-descending` came back in exactly reversed order, on rows four months apart: | | upstream | ours, before | |---|---|---| | [0] | L'Invisible `bd17017e` — 2025-10-31T20:17 | Camera Shy `a9972238` — 2025-07-28T19:06 | | [1] | Backseat Driver `2a83e1e5` — 2025-07-28T21:10 | Backseat Driver `2a83e1e5` | | [2] | Camera Shy `a9972238` — 2025-07-28T19:06 | L'Invisible `bd17017e` | `audio-by-album-artist` shared **0 of 50** ids with upstream's page. Upstream orders by the `CleanValue` of the first credited album artist (`OrderMapper.MapOrderByField`), which is NULL for an uncredited track and therefore leads ascending — the null-ordering difference this issue's later comment described, and it falls out of applying the sort at all. Two related things came with it: `DatePlayed` now takes the newest of an item's own progress and that of its alternate versions, as upstream's `Concat(...).Max()` does; and `SortOrder` is paired positionally with `SortBy`, so `SortBy=AlbumArtist,SortName&SortOrder=Descending,Ascending` no longer silently sorts ascending. ## `/Shows/NextUp` does not reproduce Against a correctly arranged pair the two sides agree id for id, in all three forms — plain, `?EnableResumable=false`, and `?SeriesId=` for each of the four series. Upstream returns four rows (`TotalRecordCount: 4`), so the reported disagreement "at index 4" cannot exist here. That measurement came from the fixture layout described in the comment above. ## After | | before | after | |---|---|---| | `played-descending` id order equal | false | **true** | | `audio-by-album-artist` id order equal | false (0/50 shared) | **true** (50/50) | | whole scenario | 2,318 differences | **741** | `played-descending` produces no difference at all now. `audio-by-album-artist` has three left, all on one row (`Items[25].ParentBackdropItemId`, `ParentBackdropImageTags`, and the blurhash keyed by it) — an inherited parent backdrop, unrelated to ordering and not tracked anywhere yet. ## What the fix had to survive The obvious way to write the alternate-version leg is quadratic. Without `sqlite_stat1` SQLite drives it from `UserData` rather than from the partial index on `PrimaryVersionId`, scanning the user's whole `UserData` table once per sorted row: 4.46 s against 0.005 s at 5,000 items, and past 120 s at 50,000. It is planned with a `CROSS JOIN` naming `BaseItems` first instead, which does not depend on statistics — 0.038 s at 50,000 rows with none. Worth recording for future measurements: **the anchor carries `sqlite_stat1` with 80 rows**, because it came off a server that had run `ANALYZE`. It could never have shown this. A synthetic fixture built from the committed baseline schema was the only thing that could. `Album` is now ordered by as well, since the canonical music sort a client sends is `AlbumArtist,Album,SortName` and leaving the middle key out hides the divergence. `Artist` and `Studio` use the same `ItemValues` mechanism and are still unmapped — no request exercises them.
Sign in to join this conversation.
No description provided.