Three things a listing sends that upstream does not #81

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

Three things a listing sends that upstream does not.

Measured against a database copied from a running Jellyfin — 55,523 items,
31,206 image rows, 116,813 stream rows — with both servers given their own copy.
Each of these is a field present on our side and absent on upstream's, on
requests both servers otherwise answer identically.

People, wherever it is asked for. Upstream sends an empty array and we
send the cast:

GET /Items?Recursive=true&IncludeItemTypes=Movie&SortBy=SortName&Limit=50
    &Fields=Overview,People,MediaStreams,ProviderIds
  Items[0].People: upstream has 0 entries, ours 22

GET /Items?…&IncludeItemTypes=Episode&…&StartIndex=10000&Fields=Overview,People,MediaStreams
  Items[0].People: upstream has 0 entries, ours 3
  Items[1].People: upstream has 0 entries, ours 20
  Items[2].People: upstream has 0 entries, ours 9

Every item on the page, not a few. Upstream evidently does not populate People
on a list even when the field is requested — plausibly it only fills it for a
single-item read — and we fill it every time. Worth pinning down which, because
the cast is 22 rows of join per item and this is also the read path's cost.

UserData on by-name items.

GET /Genres?Limit=50&SortBy=SortName
  Items[0].UserData: we send {"IsFavorite":false,"ItemId":"ce06903d…",
                              "Key":"Genre-Action","PlayCount":0,
                              "PlaybackPositionTicks":0,"Played":false},
                     upstream sends nothing

All fifty rows. A genre has no play state, so upstream omits the object; we
synthesise an empty one.

MediaStreams[].Score.

Items[1].MediaStreams[2].Score: we send 10112221, upstream sends nothing

Upstream leaves it null on a listing.

None of the three shows against the committed snapshot: it has one person, no
genres worth paging, and few enough streams that the row never appears.

To reproduce. Any library with cast attached to its items.

for port in 8096 8097; do
  curl -s -H "Authorization: MediaBrowser Token=\"$TOKEN\"" \
    "http://127.0.0.1:$port/Items?Recursive=true&IncludeItemTypes=Movie&SortBy=SortName&Limit=5&Fields=People" \
    | jq '[.Items[].People | length]'
done
Three things a listing sends that upstream does not. Measured against a database copied from a running Jellyfin — 55,523 items, 31,206 image rows, 116,813 stream rows — with both servers given their own copy. Each of these is a field present on our side and absent on upstream's, on requests both servers otherwise answer identically. **`People`, wherever it is asked for.** Upstream sends an empty array and we send the cast: GET /Items?Recursive=true&IncludeItemTypes=Movie&SortBy=SortName&Limit=50 &Fields=Overview,People,MediaStreams,ProviderIds Items[0].People: upstream has 0 entries, ours 22 GET /Items?…&IncludeItemTypes=Episode&…&StartIndex=10000&Fields=Overview,People,MediaStreams Items[0].People: upstream has 0 entries, ours 3 Items[1].People: upstream has 0 entries, ours 20 Items[2].People: upstream has 0 entries, ours 9 Every item on the page, not a few. Upstream evidently does not populate `People` on a *list* even when the field is requested — plausibly it only fills it for a single-item read — and we fill it every time. Worth pinning down which, because the cast is 22 rows of join per item and this is also the read path's cost. **`UserData` on by-name items.** GET /Genres?Limit=50&SortBy=SortName Items[0].UserData: we send {"IsFavorite":false,"ItemId":"ce06903d…", "Key":"Genre-Action","PlayCount":0, "PlaybackPositionTicks":0,"Played":false}, upstream sends nothing All fifty rows. A genre has no play state, so upstream omits the object; we synthesise an empty one. **`MediaStreams[].Score`.** Items[1].MediaStreams[2].Score: we send 10112221, upstream sends nothing Upstream leaves it null on a listing. None of the three shows against the committed snapshot: it has one person, no genres worth paging, and few enough streams that the row never appears. **To reproduce.** Any library with cast attached to its items. for port in 8096 8097; do curl -s -H "Authorization: MediaBrowser Token=\"$TOKEN\"" \ "http://127.0.0.1:$port/Items?Recursive=true&IncludeItemTypes=Movie&SortBy=SortName&Limit=5&Fields=People" \ | jq '[.Items[].People | length]' done
Author
Collaborator

These gate three benchmark requests. In bench run 20260805T083531Z the anchor gate dropped the following, and the differences are exactly the three things this issue names — classified over every difference in the run, not sampled:

request id differences which of the three
movies-page-1-fields 50× People, 13× MediaStreams[].Score, 33× UserData.Key People, Score — and a third that is not this issue: #84
episodes-deep-page-fields 44× People, 9× MediaStreams[].Score People, Score
genres 45× UserData UserData on by-name items

episodes-deep-page-fields and genres are dropped for these differences alone, so fixing this issue takes both into the timed set. movies-page-1-fields also needs #84.

Two things worth carrying back into the fix:

  • The People case is the expensive one. movies-page-1 — the same fifty films with no Fields — is in the timed set and agrees, so the entire cost difference between the two is work upstream does not do. Not filling People on a listing is a correctness fix and a performance fix at once.
  • genres is now a 104.5 ms read, down from 403 ms (#83), and none of that is measured against upstream because of a synthesised empty UserData object on fifty rows.
**These gate three benchmark requests.** In bench run `20260805T083531Z` the anchor gate dropped the following, and the differences are exactly the three things this issue names — classified over every difference in the run, not sampled: | request id | differences | which of the three | |---|---|---| | `movies-page-1-fields` | 50× `People`, 13× `MediaStreams[].Score`, 33× `UserData.Key` | People, Score — **and a third that is not this issue: #84** | | `episodes-deep-page-fields` | 44× `People`, 9× `MediaStreams[].Score` | People, Score | | `genres` | 45× `UserData` | `UserData` on by-name items | `episodes-deep-page-fields` and `genres` are dropped **for these differences alone**, so fixing this issue takes both into the timed set. `movies-page-1-fields` also needs #84. Two things worth carrying back into the fix: - The `People` case is the expensive one. `movies-page-1` — the same fifty films with no `Fields` — *is* in the timed set and agrees, so the entire cost difference between the two is work upstream does not do. Not filling `People` on a listing is a correctness fix and a performance fix at once. - `genres` is now a 104.5 ms read, down from 403 ms (#83), and none of that is measured against upstream because of a synthesised empty `UserData` object on fifty rows.
Author
Collaborator

The runs behind this issue gave the two servers a metadata directory neither of them could use, and that is most of what they measured.

A by-name item's id — person, genre, studio, artist — is the MD5 of <type FullName><metadata path> over UTF-16, lowercased, with the path taken relative to the program data directory when it sits underneath one. A picture's tag is the MD5 of that same path whole. Two things are hashed over one directory, and they answer to different parts of it.

cargo xtask bench copied the anchor's metadata directory to state/metadata — outside both servers' data directories — deliberately, so that both would name one path and agree on picture tags. That satisfies the second rule and breaks the first. Upstream then derives every by-name id from the absolute path, reaches none of the rows the anchor was written with, and:

  • answers People: [] for every item, on a listing and on a by-id read alike;
  • writes a second Person row for each name it touches, with an id of its own;
  • re-derives the user's views instead of finding the ones stored.

Ours joins a person by name and keeps answering, so the pair looks like a port that invents a cast, loses a view, and miscounts.

Measured just now against the anchor scenario, same host, same binaries, only the layout changed:

differences suppressed by the ignore list
metadata outside the data directory 3,025 3,670
metadata inside it, one path for both 1,910 562

The 3,108 suppressions that stopped firing are the People[*].PrimaryImageTag and blurhash entries — the ignore list was absorbing a setup fault, which is exactly what it is not for.

Fixed in f064438: one program data directory, named to both servers, with the one metadata copy linked inside it. crates/jellyfin-harness/README.md now states both rules.

So the numbers in this issue want re-reading, not trusting. What survives the corrected layout is written up per issue.

**The runs behind this issue gave the two servers a metadata directory neither of them could use, and that is most of what they measured.** A by-name item's id — person, genre, studio, artist — is the MD5 of `<type FullName><metadata path>` over UTF-16, lowercased, with the path taken **relative to the program data directory** when it sits underneath one. A picture's tag is the MD5 of that same path *whole*. Two things are hashed over one directory, and they answer to different parts of it. `cargo xtask bench` copied the anchor's metadata directory to `state/metadata` — outside both servers' data directories — deliberately, so that both would name one path and agree on picture tags. That satisfies the second rule and breaks the first. Upstream then derives every by-name id from the absolute path, reaches **none** of the rows the anchor was written with, and: - answers `People: []` for every item, on a listing and on a by-id read alike; - writes a *second* `Person` row for each name it touches, with an id of its own; - re-derives the user's views instead of finding the ones stored. Ours joins a person by name and keeps answering, so the pair looks like a port that invents a cast, loses a view, and miscounts. Measured just now against the anchor scenario, same host, same binaries, only the layout changed: | | differences | suppressed by the ignore list | |---|---|---| | metadata outside the data directory | 3,025 | 3,670 | | metadata inside it, one path for both | **1,910** | **562** | The 3,108 suppressions that stopped firing are the `People[*].PrimaryImageTag` and blurhash entries — the ignore list was absorbing a setup fault, which is exactly what it is not for. Fixed in `f064438`: one program data directory, named to both servers, with the one metadata copy linked inside it. `crates/jellyfin-harness/README.md` now states both rules. **So the numbers in this issue want re-reading, not trusting.** What survives the corrected layout is written up per issue.
Author
Collaborator

One of the three was this fixture's own fault; the other two were real, and the first hid a different bug underneath it.

Fixed on main in 951461f, f462fb3, f26232d and the two commits beside them.

People — the reported symptom was the layout, the bug underneath was the lookup

Upstream does populate People on a listing. It answered [] for every item because it could not resolve a single person: AttachPeople emits a credit only when LibraryManager.GetPerson(name) finds an item, and that lookup is by an id hashed from the person's metadata path — see the comment above on the fixture layout.

Arranged correctly, upstream fills the cast, and what was left was ours sending one credit too many on some items and dropping others' ids. Three causes, all in the same lookup:

  • Duplicates. We joined the person's item on PresentationUniqueKey = 'Person-' || Peoples.Name. The anchor holds two Person rows for many names — one written under each historical layout — so the join multiplied the credit. Upstream reaches exactly one row, by id.
  • Diacritics. PresentationUniqueKey is stored with marks stripped (Person-Reiner Schone) while Peoples.Name keeps them (Reiner Schöne), so the join missed and we sent Id: 00000000…. Upstream resolves it: the id of metadata\people\r\reiner schöne is f2dc447c…, which is what it serves.
  • Name drift. Five names in the anchor have an item whose Name carries trailing whitespace while its id is the hash of the trimmed name. Upstream's dictionary lookup fails and it drops the credit; we listed it.

We now derive the id the way upstream derives it and check the item's own name, as upstream's OrdinalIgnoreCase dictionary does. A credit whose item is unreachable is dropped, as upstream drops it.

A second divergence in the same array came out of it: PersonInfo.IsType matches a credit's role as well as its stored type, so a credit stored Unknown with Role='Producer' ranks as a producer. Ours ranked on the type alone.

And a third, which the id derivation exposed. Our fold was Rust's str::to_lowercase, which is full Unicode casing; upstream's is ToLowerInvariant, which is length-preserving one-to-one. Fifteen probe names were pushed through a live upstream and read back to settle it rather than reading documentation:

name upstream folds to str::to_lowercase
ΟΔΥΣΣΕΑΣ οδυσσεασ οδυσσεας
İnci İnci — unchanged i̇nci
ẞeta ßeta same
𐐀deseret 𐐨deseret same

A Greek name ending in Σ therefore hashed to an id upstream never writes. Harmless while the lookup was by name; once the lookup is by id it costs the credit its listing. The fold is now applied at the four sites where upstream calls ToLowerInvariant on a key — every by-name id, CleanName, SortName, and a series' presentation key — and pinned by the measured vectors. No row in this library folds differently under the two rules, so this is carried by upstream's own answers rather than by a diff.

UserData on /Genres — real

GenresController and MusicGenresController are the only two controllers in Jellyfin.Api that pass a literal false for enableUserData. /Studios, /Persons, /Artists, /Years and /Genres/{name} all pass the query parameter, which defaults to true — so the asymmetry is exactly two routes wide, and it is now ours too.

MediaStreams[].Score — real

Score is written in one place, MediaStreamSelector.SetSubtitleStreamScores, reached only when a media source is built for a user. A request asking for MediaStreams without MediaSources re-reads the sources with no user, so nothing is scored. Ours now scores only when MediaSources was asked for.

Verified in both directions: over 200 movies with Fields=MediaStreams,MediaSources, upstream scores a stream on 76 of them and all 200 agree exactly — the gate suppresses only what upstream suppresses.

After

Against the id-free 61-request set, fresh pair per run: 5,188 → 4,632 differences, five requests changed, nothing else moved.

before after request
292 0 Fields=People on movies
158 0 a deep episode page with Overview,People,MediaStreams
50 0 /MusicGenres
46 0 /Genres
10 0 /Genres page two

Credit counts went 515/521 → 515/515 and 342/350 → 342/342; items whose People differ on name, id, type or role went 13/25 → 0/25 and 8/50 → 0/50.

Left standing

  • Upstream also filters credits by IsVisible(user) in AttachPeople; neither side models it. It cannot bite for a Person on this library, but it is a real gap.
  • ItemValueRow.by_name_id still resolves through a presentation-key join — the same shape of lookup that was wrong here. It produced no differences in either scenario set; it is the first place to look if a by-name id ever disagrees.
**One of the three was this fixture's own fault; the other two were real, and the first hid a different bug underneath it.** Fixed on `main` in `951461f`, `f462fb3`, `f26232d` and the two commits beside them. ## `People` — the reported symptom was the layout, the bug underneath was the lookup Upstream does populate `People` on a listing. It answered `[]` for every item because it could not resolve a single person: `AttachPeople` emits a credit only when `LibraryManager.GetPerson(name)` finds an item, and that lookup is by an id hashed from the person's metadata path — see the comment above on the fixture layout. Arranged correctly, upstream fills the cast, and what was left was ours sending **one credit too many** on some items and dropping others' ids. Three causes, all in the same lookup: - **Duplicates.** We joined the person's item on `PresentationUniqueKey = 'Person-' || Peoples.Name`. The anchor holds two `Person` rows for many names — one written under each historical layout — so the join multiplied the credit. Upstream reaches exactly one row, by id. - **Diacritics.** `PresentationUniqueKey` is stored with marks stripped (`Person-Reiner Schone`) while `Peoples.Name` keeps them (`Reiner Schöne`), so the join missed and we sent `Id: 00000000…`. Upstream resolves it: the id of `metadata\people\r\reiner schöne` is `f2dc447c…`, which is what it serves. - **Name drift.** Five names in the anchor have an item whose `Name` carries trailing whitespace while its id is the hash of the trimmed name. Upstream's dictionary lookup fails and it drops the credit; we listed it. We now derive the id the way upstream derives it and check the item's own name, as upstream's `OrdinalIgnoreCase` dictionary does. A credit whose item is unreachable is dropped, as upstream drops it. A second divergence in the same array came out of it: `PersonInfo.IsType` matches a credit's **role** as well as its stored type, so a credit stored `Unknown` with `Role='Producer'` ranks as a producer. Ours ranked on the type alone. **And a third, which the id derivation exposed.** Our fold was Rust's `str::to_lowercase`, which is full Unicode casing; upstream's is `ToLowerInvariant`, which is length-preserving one-to-one. Fifteen probe names were pushed through a live upstream and read back to settle it rather than reading documentation: | name | upstream folds to | `str::to_lowercase` | |---|---|---| | `ΟΔΥΣΣΕΑΣ` | `οδυσσεασ` | `οδυσσεας` | | `İnci` | `İnci` — unchanged | `i̇nci` | | `ẞeta` | `ßeta` | same | | `𐐀deseret` | `𐐨deseret` | same | A Greek name ending in Σ therefore hashed to an id upstream never writes. Harmless while the lookup was by name; once the lookup is by id it costs the credit its listing. The fold is now applied at the four sites where upstream calls `ToLowerInvariant` on a key — every by-name id, `CleanName`, `SortName`, and a series' presentation key — and pinned by the measured vectors. No row in this library folds differently under the two rules, so this is carried by upstream's own answers rather than by a diff. ## `UserData` on `/Genres` — real `GenresController` and `MusicGenresController` are the **only two** controllers in `Jellyfin.Api` that pass a literal `false` for `enableUserData`. `/Studios`, `/Persons`, `/Artists`, `/Years` and `/Genres/{name}` all pass the query parameter, which defaults to true — so the asymmetry is exactly two routes wide, and it is now ours too. ## `MediaStreams[].Score` — real `Score` is written in one place, `MediaStreamSelector.SetSubtitleStreamScores`, reached only when a media source is built **for a user**. A request asking for `MediaStreams` without `MediaSources` re-reads the sources with no user, so nothing is scored. Ours now scores only when `MediaSources` was asked for. Verified in both directions: over 200 movies with `Fields=MediaStreams,MediaSources`, upstream scores a stream on 76 of them and **all 200 agree exactly** — the gate suppresses only what upstream suppresses. ## After Against the id-free 61-request set, fresh pair per run: **5,188 → 4,632 differences**, five requests changed, nothing else moved. | before | after | request | |---:|---:|---| | 292 | **0** | `Fields=People` on movies | | 158 | **0** | a deep episode page with `Overview,People,MediaStreams` | | 50 | **0** | `/MusicGenres` | | 46 | **0** | `/Genres` | | 10 | **0** | `/Genres` page two | Credit counts went 515/521 → **515/515** and 342/350 → **342/342**; items whose `People` differ on name, id, type or role went 13/25 → **0/25** and 8/50 → **0/50**. ## Left standing - Upstream also filters credits by `IsVisible(user)` in `AttachPeople`; neither side models it. It cannot bite for a `Person` on this library, but it is a real gap. - `ItemValueRow.by_name_id` still resolves through a presentation-key join — the same shape of lookup that was wrong here. It produced no differences in either scenario set; it is the first place to look if a by-name id ever disagrees.
Sign in to join this conversation.
No description provided.