Ten requests the id-free set finds, and the six bugs behind them #86

Closed
opened 2026-08-11 22:12:10 +02:00 by Claude · 3 comments
Collaborator

Ten requests the id-free set (#82) finds against a database copied off a running Jellyfin — 56,337 items, 43,466 UserData rows, both servers on one copy each and one program data directory. None of them belongs to #76, #78, #79, #80, #81 or #84, all now closed; these are what is left, and they are what the benchmark gate drops.

Measured on ba8f44e: 60 requests, 50 agree, 4,604 differences over the other ten.

Six look like distinct bugs, and four are one row or one field each.

A different page, from a filter

GET /Items?Recursive=true&NameStartsWith=A&SortBy=SortName&Limit=50
  Items[0].Name: upstream "A1 Dante's Inferno", ours "(a) period."
  Items[0].Id:   upstream "d9da98268c77170c0d94a2628ce255e3", ours "2f6ce7d68cebf5308b41ff54f04e83a2"

All fifty rows differ. Ours admits (a) period. to NameStartsWith=A, so the comparison is against something that has already had its punctuation folded away — upstream matches on a different string than we do. 1,933 differences, every one downstream of the page being a different page.

GET /Items?Recursive=true&SearchTerm=a&SortBy=SortName&Limit=50
  Items[0].Name: upstream "Ace", ours "\"Wuthering Heights\""

The same shape, 1,928 differences: our search admits a row whose name does not begin with the term and orders it first. Both of these were visible on the 25-item snapshot too — SearchTerm=a selects 17 rows there against upstream's 10 — so they are not a property of the big library.

/Items/Filters answers 400

GET /Items/Filters?IncludeItemTypes=Movie
  @status: upstream 200, ours 400

The route is answered; something in the query it is given is refused. Also reproduces on the committed snapshot.

/Items/Filters2 disagrees about languages

SubtitleLanguages[7]: upstream "Chinese (chi)"/"chi", ours "Chinese (Simplified) (zh-Hans)"/"zh-Hans"
AudioLanguages[4]:    upstream "es-ES", ours "French (fr)"

Both the display name and the value. Upstream reports the tag as the stream carries it and names it from its own table; we normalise it first. 209 differences.

/Years is ordered the other way

Items[0].Name: upstream "1950", ours "2026"

Ascending by sort name, upstream leads with the oldest. Ours leads with the newest, so the whole page is reversed. Also visible on the snapshot.

EnableImages=false and EnableUserData=false are ignored

GET /Items?…&Limit=25&EnableImages=false&EnableUserData=false
  Items[0].ImageTags:   we send {"Logo":…,"Primary":…,"Thumb":…}, upstream sends nothing
  Items[0].UserData:    we send {…}, upstream sends nothing
  Items[0].BackdropImageTags: we send […], upstream sends nothing

Two query parameters neither read nor honoured, on every row of the page.

Filters=IsResumable selects a different set

Items[0].Id: upstream "9abdd1ad8ffe2b845b5f69a963139f3a", ours "f88d863681e8ca388e58359ee0656ce6"

Not a page-order difference — the sets differ. On the snapshot it is starker: ours returns 17 rows where upstream returns none.

A media source is a placeholder, and has no etag

GET /Items?…&IncludeItemTypes=Episode&Limit=25&Fields=MediaStreams,MediaSources,Chapters
  Items[0].MediaSources[0].Type: upstream "Placeholder", ours "Default"
  Items[0].MediaSources[0].ETag: missing, upstream has "543b6ca4c9f21c87d81daf7a932499c0"

All 25 rows, two fields each. Upstream calls a source with no readable file behind it a placeholder; this anchor has none of its media on this host, so every source is one.

The last two are one row each

/Artists/AlbumArtists differs from index 23 — a different artist in that slot, 30 differences downstream of it. And /Items?…SortBy=AlbumArtist,SortName is down to three: Items[25] is missing ParentBackdropItemId, ParentBackdropImageTags and the blurhash keyed by it — an inherited parent backdrop, the residue left after #78.

Reproducing

bash ~/.cache/anchor/pair.sh <label> <upstream-port> <ours-port> <path to target/release/jellyfin-server>
target/release/jellyfin-harness replay --upstream $UPSTREAM --ours $OURS \
    --scenario crates/jellyfin-harness/scenarios/portable.json --out /tmp/run \
    --user admin --password ''

Four of the ten also reproduce against reference/snapshots/default, which is the cheaper place to work on them: /Items/Filters, SearchTerm, IsResumable and /Years.

Ten requests the id-free set (#82) finds against a database copied off a running Jellyfin — 56,337 items, 43,466 `UserData` rows, both servers on one copy each and one program data directory. None of them belongs to #76, #78, #79, #80, #81 or #84, all now closed; these are what is left, and they are what the benchmark gate drops. Measured on `ba8f44e`: **60 requests, 50 agree, 4,604 differences over the other ten.** Six look like distinct bugs, and four are one row or one field each. ## A different page, from a filter GET /Items?Recursive=true&NameStartsWith=A&SortBy=SortName&Limit=50 Items[0].Name: upstream "A1 Dante's Inferno", ours "(a) period." Items[0].Id: upstream "d9da98268c77170c0d94a2628ce255e3", ours "2f6ce7d68cebf5308b41ff54f04e83a2" All fifty rows differ. Ours admits `(a) period.` to `NameStartsWith=A`, so the comparison is against something that has already had its punctuation folded away — upstream matches on a different string than we do. 1,933 differences, every one downstream of the page being a different page. GET /Items?Recursive=true&SearchTerm=a&SortBy=SortName&Limit=50 Items[0].Name: upstream "Ace", ours "\"Wuthering Heights\"" The same shape, 1,928 differences: our search admits a row whose name does not begin with the term and orders it first. Both of these were visible on the 25-item snapshot too — `SearchTerm=a` selects 17 rows there against upstream's 10 — so they are not a property of the big library. ## `/Items/Filters` answers 400 GET /Items/Filters?IncludeItemTypes=Movie @status: upstream 200, ours 400 The route is answered; something in the query it is given is refused. Also reproduces on the committed snapshot. ## `/Items/Filters2` disagrees about languages SubtitleLanguages[7]: upstream "Chinese (chi)"/"chi", ours "Chinese (Simplified) (zh-Hans)"/"zh-Hans" AudioLanguages[4]: upstream "es-ES", ours "French (fr)" Both the display name and the value. Upstream reports the tag as the stream carries it and names it from its own table; we normalise it first. 209 differences. ## `/Years` is ordered the other way Items[0].Name: upstream "1950", ours "2026" Ascending by sort name, upstream leads with the oldest. Ours leads with the newest, so the whole page is reversed. Also visible on the snapshot. ## `EnableImages=false` and `EnableUserData=false` are ignored GET /Items?…&Limit=25&EnableImages=false&EnableUserData=false Items[0].ImageTags: we send {"Logo":…,"Primary":…,"Thumb":…}, upstream sends nothing Items[0].UserData: we send {…}, upstream sends nothing Items[0].BackdropImageTags: we send […], upstream sends nothing Two query parameters neither read nor honoured, on every row of the page. ## `Filters=IsResumable` selects a different set Items[0].Id: upstream "9abdd1ad8ffe2b845b5f69a963139f3a", ours "f88d863681e8ca388e58359ee0656ce6" Not a page-order difference — the sets differ. On the snapshot it is starker: ours returns 17 rows where upstream returns none. ## A media source is a placeholder, and has no etag GET /Items?…&IncludeItemTypes=Episode&Limit=25&Fields=MediaStreams,MediaSources,Chapters Items[0].MediaSources[0].Type: upstream "Placeholder", ours "Default" Items[0].MediaSources[0].ETag: missing, upstream has "543b6ca4c9f21c87d81daf7a932499c0" All 25 rows, two fields each. Upstream calls a source with no readable file behind it a placeholder; this anchor has none of its media on this host, so every source is one. ## The last two are one row each `/Artists/AlbumArtists` differs from index 23 — a different artist in that slot, 30 differences downstream of it. And `/Items?…SortBy=AlbumArtist,SortName` is down to three: `Items[25]` is missing `ParentBackdropItemId`, `ParentBackdropImageTags` and the blurhash keyed by it — an inherited parent backdrop, the residue left after #78. ## Reproducing bash ~/.cache/anchor/pair.sh <label> <upstream-port> <ours-port> <path to target/release/jellyfin-server> target/release/jellyfin-harness replay --upstream $UPSTREAM --ours $OURS \ --scenario crates/jellyfin-harness/scenarios/portable.json --out /tmp/run \ --user admin --password '' Four of the ten also reproduce against `reference/snapshots/default`, which is the cheaper place to work on them: `/Items/Filters`, `SearchTerm`, `IsResumable` and `/Years`.
Author
Collaborator

/Items/Filters2 is an ordering difference, not a normalisation one

The diagnosis above — "upstream reports the tag as the stream carries it and
names it from its own table; we normalise it first" — is wrong, and the sample
line is what made it look that way. SubtitleLanguages[7] differs in both the
name and the value because the two lists are ordered differently, so
index 7 is a different language on each side. Nothing is normalised.

Measured against the anchor on 74482f6, ?IncludeItemTypes=Series: both
servers return the same 95 subtitle codes and the same 32 audio codes, as sets,
value for value. ?IncludeItemTypes=Movie agrees on 51 and 16. All 209
differences are positional.

There were two real bugs behind it, both now fixed on wt-86d.

A code the table does not name was named twice. FilterController writes
culture is null ? language : $"{culture.DisplayName} ({language})", so a code
FindLanguageInfo resolves nothing for is its own name, bare. We wrote
es-ES (es-ES), khk (khk), scr (scr), unknown (unknown). That is the
only place the two disagreed on a name at all — five entries across the two
Series lists, none in the Movie ones.

The order is case-insensitive, not byte order. Upstream finishes with
.OrderBy(l => l.Name) in memory, so it is .NET's culture-aware string
comparison, not SQLite's BINARY. Ours did a.name.cmp(&b.name). That put
Chinese (Simplified) (zh-Hans) before Chinese (chi) — (S before (c —
and banished the three bare codes to the end of the list rather than
interleaving them (es-ES between English (eng) and Estonian (est),
khk between Kannada (kan) and Korean (ko), scr between
Russian (rus) and Serbian (srp)), which shifted every row from index 21 on.

A case-insensitive comparison reproduces upstream's sequence exactly on all
four lists. It was taken over a real ICU collator: the names this route can
emit are {display name} ({code}) over the whole generated language table plus
the bare codes, and over all 722 of them ICU's root and en-US collations
produce the identical total order — including Occitan (post 1500); Provençal,
Provençal, Old (to 1500) and Volapük, the only non-ASCII display names
there are. So a collator would buy no agreement and would tie the order to
whichever ICU the host links.

The other two, for the record

/Artists/AlbumArtists from index 23 was a tiebreak. Fall Out Boy and
Fall Out Boy both carry SortName fall out boy and their own
PresentationUniqueKey, so neither collapses into the other. ApplyOrder
carries ThenBy(e => e.Name) behind a leading sort-name ordering and the
by-name listing runs through it like any other query; ours settled the tie on
the id. Beside it, GetItemValues picks a MusicArtist group's representative
by whether its TopParentId is one the caller can reach before falling back
on the lowest id, where every other kind takes the lowest id outright — so
MØL, which has both a row under the music library and a row beside the
metadata, was listed as the metadata one: no path inside a library, and no
images. That is a second difference the 50-row page does not reach.

The ParentBackdrop residue was not the artist being read without images —
it is that we looked the artist up in the wrong set of names. GetBaseItemDtos
builds its artist batch from what the page's own items credit, every name
in Artists and AlbumArtists, and GetBatchedAlbumArtist looks an album's
first album artist up in that. FindArtists reads those rows without their
images, so an album whose artist the page credits really does inherit nothing —
which is why a page of albums shows no ParentBackdropItemId on either server.
A name the batch does not hold falls through to GetMusicArtist, the artist
above the album, which does carry its pictures; and the singular DTO path
passes no batch at all, so an album read by id always falls through. Ours keyed
the batch on the very names it then looked up, so the lookup could never miss
and no album ever reached the artist above it. Azblub sits on an album
crediting Spor, which none of the tracks on that page credits.

Numbers

portable.json against the anchor, both servers restarted first:

responses agreed differences
74482f6 60 50 4,604 over 10 requests
wt-86d 60 53 4,362 over 7 requests

The three gone are /Items/Filters2, /Artists/AlbumArtists and
/Items?…SortBy=AlbumArtist,SortName. Nothing new appeared.

## `/Items/Filters2` is an ordering difference, not a normalisation one The diagnosis above — "upstream reports the tag as the stream carries it and names it from its own table; we normalise it first" — is wrong, and the sample line is what made it look that way. `SubtitleLanguages[7]` differs in both the name *and* the value because the two lists are **ordered** differently, so index 7 is a different language on each side. Nothing is normalised. Measured against the anchor on `74482f6`, `?IncludeItemTypes=Series`: both servers return the same 95 subtitle codes and the same 32 audio codes, as sets, value for value. `?IncludeItemTypes=Movie` agrees on 51 and 16. All 209 differences are positional. There were two real bugs behind it, both now fixed on `wt-86d`. **A code the table does not name was named twice.** `FilterController` writes `culture is null ? language : $"{culture.DisplayName} ({language})"`, so a code `FindLanguageInfo` resolves nothing for is its own name, bare. We wrote `es-ES (es-ES)`, `khk (khk)`, `scr (scr)`, `unknown (unknown)`. That is the only place the two disagreed on a name at all — five entries across the two `Series` lists, none in the `Movie` ones. **The order is case-insensitive, not byte order.** Upstream finishes with `.OrderBy(l => l.Name)` **in memory**, so it is .NET's culture-aware string comparison, not SQLite's `BINARY`. Ours did `a.name.cmp(&b.name)`. That put `Chinese (Simplified) (zh-Hans)` before `Chinese (chi)` — `(S` before `(c` — and banished the three bare codes to the end of the list rather than interleaving them (`es-ES` between `English (eng)` and `Estonian (est)`, `khk` between `Kannada (kan)` and `Korean (ko)`, `scr` between `Russian (rus)` and `Serbian (srp)`), which shifted every row from index 21 on. A case-insensitive comparison reproduces upstream's sequence exactly on all four lists. It was taken over a real ICU collator: the names this route can emit are `{display name} ({code})` over the whole generated language table plus the bare codes, and over all 722 of them ICU's root and `en-US` collations produce the identical total order — including `Occitan (post 1500); Provençal`, `Provençal, Old (to 1500)` and `Volapük`, the only non-ASCII display names there are. So a collator would buy no agreement and would tie the order to whichever ICU the host links. ## The other two, for the record **`/Artists/AlbumArtists` from index 23** was a tiebreak. `Fall Out Boy` and `Fall Out Boy ` both carry `SortName` `fall out boy` and their own `PresentationUniqueKey`, so neither collapses into the other. `ApplyOrder` carries `ThenBy(e => e.Name)` behind a leading sort-name ordering and the by-name listing runs through it like any other query; ours settled the tie on the id. Beside it, `GetItemValues` picks a `MusicArtist` group's representative by whether its `TopParentId` is one the caller can reach *before* falling back on the lowest id, where every other kind takes the lowest id outright — so `MØL`, which has both a row under the music library and a row beside the metadata, was listed as the metadata one: no path inside a library, and no images. That is a second difference the 50-row page does not reach. **The `ParentBackdrop` residue** was not the artist being read without images — it is that we looked the artist up in the wrong set of names. `GetBaseItemDtos` builds its artist batch from what the **page's own items** credit, every name in `Artists` and `AlbumArtists`, and `GetBatchedAlbumArtist` looks an album's first album artist up in *that*. `FindArtists` reads those rows without their images, so an album whose artist the page credits really does inherit nothing — which is why a page of albums shows no `ParentBackdropItemId` on either server. A name the batch does not hold falls through to `GetMusicArtist`, the artist above the album, which does carry its pictures; and the singular DTO path passes no batch at all, so an album read by id always falls through. Ours keyed the batch on the very names it then looked up, so the lookup could never miss and no album ever reached the artist above it. `Azblub` sits on an album crediting `Spor`, which none of the tracks on that page credits. ## Numbers `portable.json` against the anchor, both servers restarted first: | | responses | agreed | differences | |---|---|---|---| | `74482f6` | 60 | 50 | 4,604 over 10 requests | | `wt-86d` | 60 | 53 | 4,362 over 7 requests | The three gone are `/Items/Filters2`, `/Artists/AlbumArtists` and `/Items?…SortBy=AlbumArtist,SortName`. Nothing new appeared.
Author
Collaborator

/Items/Filters and /Years, and what is left of each

Both are fixed on wt-86c. portable.json against reference/snapshots/default goes 161 differences over 5 requests → 150 over 4; against the anchor, each server on its own copy and one program data directory, 4,604 over 10 → 4,501 over 9 (50 → 51 agreements). Both anchor runs were measured on a pair restarted for the measurement.

/Items/Filters answered 400 — closed

The parameter is parentId, by its absence. GetParentItem (Emby.Server.Implementations/Library/LibraryManager.cs:3114) throws only for an id the request named and it could not find; with none it returns the user root, which is always a folder. Ours substituted the empty guid for "no parent" and then refused it. Two neighbouring cases in the same if went with it: includeItemTypes naming exactly Trailer or Program resolves no parent at all, and a parent that is a row but not a folder is nothing to draw from — both are the four empty lists, not a refusal.

3 → 0 differences on the snapshot, 3 → 0 on the anchor. Seven shapes checked live against the reference container, including a folder parent, which now agrees field for field.

/Years was ordered the other way — closed for the ordering, not closed for the count

YearsController gathers a year per item in walk order and then calls _libraryManager.Sort over the year items, whose SortName is ModifySortChunks of the name — digits padded to ten, so 0000001950 leads. Ours sorted nothing.

  • Snapshot: 9 → 1 difference. The one left is TotalRecordCount, upstream 18 against our 17. /Years?Limit=50&SortBy=SortName still differs on reference/snapshots/default, so this route is not fully agreed there. The mechanism is settled: upstream reports what Folder.AddChildrenFromCollection summed, which counts the folders it walked through with no de-duplication. Measured per folder on the reference — movies 3, shows 7, music 4, plus the user root's own four children (three collection folders and the Playlists folder) = 18. Ours counts the 17 rows its walk returned, which include the three physical folders and neither the collection folders nor the Playlists folder. Matching it means modelling that traversal, not changing a line.
  • Anchor: 201 → 101 differences. Settled by naming the fields: before, all 50 Items[n].Name and all 50 Items[n].UserData.Key differed; after, every Name agrees positionally and the two servers answer the same set of years. What is left is 50 × Items[n].Id, 50 × Items[n].UserData.ItemId, and 1 × TotalRecordCount (21,829 against 21,895). So the page is the right page, and the residue is an id difference rather than an ordering one.
  • The anchor id residue is the fixture, not the port. That database carries two rows per year: bc95b15a… is exactly MD5("MediaBrowser.Controller.Entities.Year" + "/home/alex/.cache/anchor/metadata/year/1950"), the seed path hashed whole, written by an earlier run whose metadata directory sat outside its program data directory. Ours derives the correct b24306b2… = metadata\year\1950, finds no such row in its copy, falls back to the by-name lookup and returns the stale duplicate; upstream creates a row at the derived id during the run and answers with that. On the snapshot pair, arranged correctly, the year ids agree.

Two things about /Years that no scenario asks and nothing here fixes

  • The unsorted page is a different page. With no SortBy, upstream leaves the walk's order, and so do we — but the two walks are not the same walk. Measured live against the reference: GET /Years is ["2020","2022","2021","2019"] upstream and ["2019","2020","2022","2021"] ours. Upstream's order is the DFS AddChildren builds over the user root (movies, then shows, then music); ours is whatever the SQL returns. Nothing in this fix addresses it, and no scenario sees it — portable.json always sends SortBy=SortName.
  • SortBy=Default rides on that. No comparer declares ItemSortBy.Default (LibraryManager.cs:2282), so upstream's Sort skips it and the walk order survives; ours read Default as the sort name and sorted, which made /Years?SortBy=Default a different page from /Years. That is fixed — the two now behave alike on each server — but because the walk orders differ, the two servers still disagree on both, for the reason above.

Only two of upstream's comparers are modelled for this listing, the sort name and the name. Any other field leaves the walk's order, which is upstream's answer only where its own comparer holds the year items equal: Random shuffles, and a year's DateCreated is its metadata directory's creation time (LibraryManager.cs:1321), a tie on the snapshot only by accident of one scan.

## `/Items/Filters` and `/Years`, and what is left of each Both are fixed on `wt-86c`. `portable.json` against `reference/snapshots/default` goes **161 differences over 5 requests → 150 over 4**; against the anchor, each server on its own copy and one program data directory, **4,604 over 10 → 4,501 over 9** (50 → 51 agreements). Both anchor runs were measured on a pair restarted for the measurement. ### `/Items/Filters` answered 400 — closed The parameter is `parentId`, by its absence. `GetParentItem` (`Emby.Server.Implementations/Library/LibraryManager.cs:3114`) throws only for an id the request *named* and it could not find; with none it returns the user root, which is always a folder. Ours substituted the empty guid for "no parent" and then refused it. Two neighbouring cases in the same `if` went with it: `includeItemTypes` naming exactly `Trailer` or `Program` resolves no parent at all, and a parent that is a row but not a folder is nothing to draw from — both are the four empty lists, not a refusal. 3 → 0 differences on the snapshot, 3 → 0 on the anchor. Seven shapes checked live against the reference container, including a folder parent, which now agrees field for field. ### `/Years` was ordered the other way — closed for the ordering, **not closed for the count** `YearsController` gathers a year per item in walk order and then calls `_libraryManager.Sort` over the **year items**, whose `SortName` is `ModifySortChunks` of the name — digits padded to ten, so `0000001950` leads. Ours sorted nothing. - **Snapshot: 9 → 1 difference.** The one left is `TotalRecordCount`, upstream 18 against our 17. **`/Years?Limit=50&SortBy=SortName` still differs on `reference/snapshots/default`, so this route is not fully agreed there.** The mechanism is settled: upstream reports what `Folder.AddChildrenFromCollection` summed, which counts the folders it walked *through* with no de-duplication. Measured per folder on the reference — movies 3, shows 7, music 4, plus the user root's own four children (three collection folders and the Playlists folder) = 18. Ours counts the 17 rows its walk returned, which include the three physical folders and neither the collection folders nor the Playlists folder. Matching it means modelling that traversal, not changing a line. - **Anchor: 201 → 101 differences.** Settled by naming the fields: before, all 50 `Items[n].Name` and all 50 `Items[n].UserData.Key` differed; after, **every `Name` agrees positionally and the two servers answer the same set of years**. What is left is 50 × `Items[n].Id`, 50 × `Items[n].UserData.ItemId`, and 1 × `TotalRecordCount` (21,829 against 21,895). So the page is the right page, and the residue is an id difference rather than an ordering one. - **The anchor id residue is the fixture, not the port.** That database carries **two rows per year**: `bc95b15a…` is exactly `MD5("MediaBrowser.Controller.Entities.Year" + "/home/alex/.cache/anchor/metadata/year/1950")`, the seed path hashed whole, written by an earlier run whose metadata directory sat outside its program data directory. Ours derives the correct `b24306b2…` = `metadata\year\1950`, finds no such row in its copy, falls back to the by-name lookup and returns the stale duplicate; upstream creates a row at the derived id during the run and answers with that. On the snapshot pair, arranged correctly, the year ids agree. ### Two things about `/Years` that no scenario asks and nothing here fixes - **The unsorted page is a different page.** With no `SortBy`, upstream leaves the walk's order, and so do we — but the two walks are not the same walk. Measured live against the reference: `GET /Years` is `["2020","2022","2021","2019"]` upstream and `["2019","2020","2022","2021"]` ours. Upstream's order is the DFS `AddChildren` builds over the user root (movies, then shows, then music); ours is whatever the SQL returns. Nothing in this fix addresses it, and no scenario sees it — `portable.json` always sends `SortBy=SortName`. - **`SortBy=Default` rides on that.** No comparer declares `ItemSortBy.Default` (`LibraryManager.cs:2282`), so upstream's `Sort` skips it and the walk order survives; ours read `Default` as the sort name and sorted, which made `/Years?SortBy=Default` a different page from `/Years`. That is fixed — the two now behave alike on each server — but because the walk orders differ, the two *servers* still disagree on both, for the reason above. Only two of upstream's comparers are modelled for this listing, the sort name and the name. Any other field leaves the walk's order, which is upstream's answer only where its own comparer holds the year items equal: `Random` shuffles, and a year's `DateCreated` is its metadata directory's creation time (`LibraryManager.cs:1321`), a tie on the snapshot only by accident of one scan.
Author
Collaborator

All ten requests are answered. Measured on main, one pair, portable.json against the anchor:

responses agreed differences
ba8f44e (this issue) 60 50 4,604 over 10 requests
main 60 59 101 over 1 request

The one request left is /Years, and it is the residue recorded below rather than any of the six bugs.

What each turned out to be

NameStartsWith matched the wrong column. ApplyNameFilters matches SortName, not the punctuation-stripped CleanName — which is how (a) period. was admitted to A. 1,933 → 0.

SearchTerm and Filters=IsResumable were never read at all. Neither field was set by the route, so both requests answered the plain unfiltered listing. "Wuthering Heights" was not a ranking bug; it is simply row one of the whole library. Once the route set them, the predicates behind both had to be built: the provider's 100/80/75/50 scoring over CleanName, the limit * 3 candidate cut, the in-memory re-sort that puts the sort name ahead of the requested one, and for resume a position past zero, a version group cut to the version played last, and a series read from its episodes only where the request asked for series. 1,928 → 0 and 87 → 0.

EnableImages and EnableUserData were never read either. Both now drop what upstream drops, including that ImageTags is omitted rather than sent empty while ImageBlurHashes stays as {}, and that the series' and album's posters survive an otherwise-emptied map. RecursiveItemCount goes with the user's state regardless of Fields, because upstream assigns it only inside FillUserDataDtoValues.

A media source with no file behind it is a placeholder, and a file source always carries an etag. The etag of a row with no modification date is the MD5 of "0" — hashed UTF-16LE and byte-swapped into a guid, which is 543b6ca4c9f21c87d81daf7a932499c0, the value upstream returned on all 25 rows. That constant is pinned in a test.

/Items/Filters answered 400 because of parentId — by its absence. GetParentItem throws only for an id it was given and could not find; with none it returns the user root. We substituted the empty guid and then refused it.

/Years was ordered the other way because upstream sorts the Year items in memory, and a year's sort name is its digits zero-padded to ten — so 0000001950 leads.

/Items/Filters2 was not the bug this issue described. The issue said we normalise the language before naming it. We do not: both servers return the same 95 subtitle and 32 audio pairs, name for name and value for value. All 209 differences were positional, from .OrderBy(l => l.Name) running in memory — .NET's culture-aware comparison against our byte order. There was one real naming bug beside it: a code FindLanguageInfo resolves nothing for is named by the code alone, where we wrote es-ES (es-ES).

/Artists/AlbumArtists was a SortName tie, between Fall Out Boy and Fall Out Boy — upstream appends ThenBy(e => e.Name) after a sort-name ordering, and our by-name listing did not. A second bug fell out of it: upstream picks a MusicArtist group's representative by whether its TopParentId is reachable before the lowest id, so we were listing a metadata row with no library path and no images where upstream lists the one inside the library.

The ParentBackdrop residue was the opposite of what it looked like. Giving the named album artist its images makes the run worse — 4,604 → 4,679 — because upstream builds its artist batch from what the page's own items credit and reads those rows without images. An album whose artist the page credits inherits nothing; a name the batch does not hold falls through to the artist above the album, which does carry pictures; and the by-id path passes no batch at all. The batch is also keyed on the name as spelled, ASCII-lowercased — not folded — so MØL is reached by nothing, exactly as upstream reaches nothing.

What is left, and where it went

/Years still differs on 101: 50 Id and 50 UserData.ItemId, plus TotalRecordCount. Every Name agrees positionally and both servers answer the same set of years, so the page is the right page — the ids are an artefact of this fixture, which carries two rows per year from an earlier run whose metadata directory sat outside its program data directory. TotalRecordCount is real and is not the fixture: upstream counts the folders its recursive walk passed through, with no de-duplication.

/Years is also not fully closed on reference/snapshots/default, for the same TotalRecordCount reason, and the unsorted page order still differs on both fixtures — upstream's is the depth-first order AddChildren builds, ours is whatever SQL returns. portable.json never sees it because it always sends SortBy=SortName.

Five further findings went to issues of their own rather than into these branches: #87 covers the by-name listings that ignore SortBy/SortOrder, and the rest are filed separately.

All ten requests are answered. Measured on `main`, one pair, `portable.json` against the anchor: | | responses | agreed | differences | |---|---|---|---| | `ba8f44e` (this issue) | 60 | 50 | **4,604** over 10 requests | | `main` | 60 | **59** | **101** over 1 request | The one request left is `/Years`, and it is the residue recorded below rather than any of the six bugs. ## What each turned out to be **`NameStartsWith` matched the wrong column.** `ApplyNameFilters` matches `SortName`, not the punctuation-stripped `CleanName` — which is how `(a) period.` was admitted to `A`. 1,933 → 0. **`SearchTerm` and `Filters=IsResumable` were never read at all.** Neither field was set by the route, so both requests answered the plain unfiltered listing. `"Wuthering Heights"` was not a ranking bug; it is simply row one of the whole library. Once the route set them, the predicates behind both had to be built: the provider's 100/80/75/50 scoring over `CleanName`, the `limit * 3` candidate cut, the in-memory re-sort that puts the sort name ahead of the requested one, and for resume a position past zero, a version group cut to the version played last, and a series read from its episodes only where the request asked for series. 1,928 → 0 and 87 → 0. **`EnableImages` and `EnableUserData` were never read either.** Both now drop what upstream drops, including that `ImageTags` is omitted rather than sent empty while `ImageBlurHashes` stays as `{}`, and that the series' and album's posters survive an otherwise-emptied map. `RecursiveItemCount` goes with the user's state regardless of `Fields`, because upstream assigns it only inside `FillUserDataDtoValues`. **A media source with no file behind it is a placeholder, and a file source always carries an etag.** The etag of a row with no modification date is the MD5 of `"0"` — hashed UTF-16LE and byte-swapped into a guid, which is `543b6ca4c9f21c87d81daf7a932499c0`, the value upstream returned on all 25 rows. That constant is pinned in a test. **`/Items/Filters` answered 400 because of `parentId` — by its absence.** `GetParentItem` throws only for an id it was *given* and could not find; with none it returns the user root. We substituted the empty guid and then refused it. **`/Years` was ordered the other way** because upstream sorts the Year *items* in memory, and a year's sort name is its digits zero-padded to ten — so `0000001950` leads. **`/Items/Filters2` was not the bug this issue described.** The issue said we normalise the language before naming it. We do not: both servers return the same 95 subtitle and 32 audio pairs, name for name and value for value. All 209 differences were positional, from `.OrderBy(l => l.Name)` running in memory — .NET's culture-aware comparison against our byte order. There was one real naming bug beside it: a code `FindLanguageInfo` resolves nothing for is named by the code alone, where we wrote `es-ES (es-ES)`. **`/Artists/AlbumArtists` was a `SortName` tie**, between `Fall Out Boy` and `Fall Out Boy ` — upstream appends `ThenBy(e => e.Name)` after a sort-name ordering, and our by-name listing did not. A second bug fell out of it: upstream picks a `MusicArtist` group's representative by whether its `TopParentId` is reachable *before* the lowest id, so we were listing a metadata row with no library path and no images where upstream lists the one inside the library. **The `ParentBackdrop` residue was the opposite of what it looked like.** Giving the named album artist its images makes the run *worse* — 4,604 → 4,679 — because upstream builds its artist batch from what the page's own items credit and reads those rows without images. An album whose artist the page credits inherits nothing; a name the batch does not hold falls through to the artist above the album, which does carry pictures; and the by-id path passes no batch at all. The batch is also keyed on the name as spelled, ASCII-lowercased — not folded — so `MØL` is reached by nothing, exactly as upstream reaches nothing. ## What is left, and where it went `/Years` still differs on 101: 50 `Id` and 50 `UserData.ItemId`, plus `TotalRecordCount`. Every `Name` agrees positionally and both servers answer the same set of years, so the page is the right page — the ids are an artefact of this fixture, which carries two rows per year from an earlier run whose metadata directory sat outside its program data directory. `TotalRecordCount` is real and is not the fixture: upstream counts the folders its recursive walk passed *through*, with no de-duplication. `/Years` is also not fully closed on `reference/snapshots/default`, for the same `TotalRecordCount` reason, and the *unsorted* page order still differs on both fixtures — upstream's is the depth-first order `AddChildren` builds, ours is whatever SQL returns. `portable.json` never sees it because it always sends `SortBy=SortName`. Five further findings went to issues of their own rather than into these branches: #87 covers the by-name listings that ignore `SortBy`/`SortOrder`, and the rest are filed separately.
Sign in to join this conversation.
No description provided.