/Items: six id filters are accepted and never applied, genreIds matches nothing, and a parentless listing returns the whole library #91
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found while replaying
crates/jellyfin-harness/scenarios/clients.json— the scenario derived from Swiftfin's and Findroid's own source. Six id-valued filters of/Itemsare accepted and never applied, one is applied and matches nothing, and a listing that names no parent returns the wrong set. Swiftfin sends three of these on ordinary screens, so this is not a corner.None of it is hypothetical: every line below was measured on a live pair on 2026-08-13, reference (
v12.0-rc5,6d501ba4) on:8096and ours on:8097, fixture library,Recursive=trueunless stated.What differs
/Items?Recursive=true&ids=<Alien>/Items?Recursive=true&excludeItemIds=<Alien>/Items?Recursive=true&personIds=<Ridley Scott>/Items?Recursive=true&studioIds=<Brandywine>/Items?Recursive=true&genreIds=<Science Fiction>/Items(noparentId, not recursive)albumIdsandartistIdsare the same shape asstudioIdsand are untested only because the fixture's music library is thin.22 is every row in the library. A filter that matches everything is worse than one that errors: a client asking "which items are these three ids" gets the whole library back and displays it.
Why
The six that are ignored
Paramsincrates/jellyfin-http/src/routes/items.rs:37has no field forids,personIds,studioIds,albumIds,artistIdsorexcludeItemIds. The struct is#[serde(default)]with nodeny_unknown_fields, so the parameters are accepted and dropped in silence.Two of them need no new plumbing at all —
jellyfin_core::query::ItemQueryalready carriesitem_ids(crates/jellyfin-core/src/query.rs:24) andexclude_item_ids(:45), andcrates/jellyfin-db/src/query.rsalready translates both. They are simply never populated from this route.item_idsis populated on two other paths (items.rs:405and:466), which is why the field exists and passes tests.Upstream:
Jellyfin.Api/Controllers/ItemsController.cs:206,230,235,239,240,256— each bound withCommaDelimitedCollectionModelBinder, so comma-delimited (the doc comment onalbumIds/studioIdssays pipe-delimited; the binder is comma. Trust the binder).ItemsController.cs:426-445.Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs:ItemIds:942,ExcludeItemIds:947,PersonIds:363,AlbumIds:584,StudioIds:620.Note
AlbumIdsis not a by-name lookup —:586ise.ParentId.HasValue && filter.AlbumIds.Contains(e.ParentId.Value).PersonIds:366goes throughPeopleBaseItemMap.StudioIdsgoes throughWhereReferencedItem, same as genres — see below.genreIds, which is applied and wrongitems.rs:736maps the parameter straight intoItemQuery::item_value_ids, whichcrates/jellyfin-core/src/query.rsdocuments as "byItemValues.Id". ButgenreIdsnames the genre's ownBaseItemsid, not anItemValuesrow. Two different id spaces, so nothing ever matches — hence 0 against upstream's 2.Upstream resolves it in
WhereReferencedItem,src/Jellyfin.Database/Jellyfin.Database.Implementations/JellyfinQueryHelperExtensions.cs:130. Read the remark above it before porting — it is load-bearing:So the lookup is: id → that
BaseItemsrow'sCleanName→ItemValuesof the given type(s) with thatCleanValue→ItemValuesMap. A studio id and a genre id that clean to the same string select the same rows upstream, and reproducing that is the point.The parentless listing
ItemsController.cs:304-313: when noparentIdresolves to a folder, upstream substitutes_libraryManager.GetUserRootFolder()and lists its children — the libraries plus Playlists — rather than everything. Ours returns the whole library. This is Findroid's first library request, so it is the first screen it draws.Reproducing
Both servers are normally up; if not,
cargo xtask reference upand./target/debug/jellyfin-server config.toml.Item ids are path-derived and identical on both servers, so an id from one can be sent to the other.
Alienis1d1b052a6ce2c7f930ea6ab81f263023, Science Fiction0430d514c35e1f158fa93f1eeb8361ea, Brandywine Productionsd0e2aad659a9d003ce81400a2980f955, Ridley Scotta19d5c522c32f2b3ad5f203c978a3cbf.Done when
genreIdsmatches by cleaned name throughItemValues, and a parentless non-recursive/Itemslists the user root's children.clients.jsonreplays with these differences gone:target/release/jellyfin-harness replay --upstream http://127.0.0.1:8096 --ours http://127.0.0.1:8097 --scenario crates/jellyfin-harness/scenarios/clients.json --out /tmp/run.JELLYFIN_RS_REQUIRE_FIXTURES=1 cargo nextest run --workspacegreen (2092 at the time of writing).Before you start
CLAUDE.mdis the contract.cargo nextest, notcargo test. Comments describe the current state, never the history, and carry no milestone terminology.upstream/in this repo — it is the pinned checkout. Not../jellyfin, which is on a divergent branch.git add -A, and never symlinkreference/var,reference/snapshots,reference/mediaorreference/publishedinto a worktree — they are gitignored, and committing such a link deletes the real fixture. That has happened.:8096. To exercise a write path against it, post to an id that does not exist:404means the body was accepted,400means it was refused.Done, on
main.ca86d1954f4b75937381e5eabf87e506618Every request in the issue's table now answers what upstream answers, measured on the live pair and re-measured independently by a reviewer:
ids=<Alien>excludeItemIds=<Alien>personIds=<Ridley Scott>studioIds=<Brandywine>genreIds=<Science Fiction>artistIds=<Sine Ensemble>albumIds=<First Signal>/Items(no parent)albumIdsandartistIdsare measured rather than assumed. The issue expected the fixture's music library to be too thin for them; it is not —artistIdsreturning 3 is the Artist ∪ AlbumArtist union doing real work, andalbumIds=<a film>answers 0 on both.genreIdsgoes through theWhereReferencedItemshape rather than an id space of its own:ItemValues.CleanValuematched against the referenced row'sCleanName, with the referenced row'sTypenever consulted. The remark aboveJellyfinQueryHelperExtensions.cs:130is the rule and is restated where the code implements it. A route test pins the cross-type case with a Studio row named "Science Fiction" whose id, given asgenreIds, returns the genre's films. That leg is held by the fixture rather than by the pair: the reference library has no name collision across types, and making one is a write.What the review changed
A listing naming several ids came back sorted, where upstream returns the rows in the order the ids were named (
Folder.GetItems:996-1008, whenItemIds.Length > 1andOrderByis empty). The scenario written to guard this change pinnedSortBy=SortName, which is exactly the condition that turns upstream's request-order sort off — so it could not have caught it. It now names its ids out of sort-name order with noSortBy, and replaying the new scenario against the pre-fix binary reports 301 differences over 4 requests. ThesortBy=Nonsensecorner falls out for free on both servers: an enum binder that drops a name it cannot read leavesOrderByempty, and the request order applies.The new parentless branch answered the wrong kind of thing.
narrows()stands in forHasFilters, and it can only see the parameters this route binds — so any filter the route does not bind sent the request to the libraries branch, and a client asking for science-fiction films gotCollectionFolderrows to render as library tiles. It now also reads the raw query for the 53 namesHasFilterscounts and the route does not bind.maxOfficialRatingis measured rather than assumed to be one of them: upstream counts the minimum parental rating and not the maximum, so it correctly still reaches the libraries.Those requests now answer a superset of items rather than library tiles — the pre-existing unbound-filter gap, filed as #120.
Genres=andnameLessThan=are deliberately not in the scenario, because they still differ for that reason and a committed scenario that cannot replay clean is worse than no coverage.The widening this introduces, faithfully
narrow_to_every_librarynow exempts a query namingitem_ids, which isLibraryManager.cs:2019-2026's own precondition for settingTopParentIds. Measured: an account withEnableAllFolders=falsecan reach a row outside its libraries by id — on both servers. Upstream has the same hole and the controller calls the DTO layer withskipVisibilityCheck: true. Reproduced rather than fixed, and it is the second half of #111.The exemption is shared with
movies.rsandby_name.rs, which pass through the same choke point;ca86d19's message says so.Numbers
JELLYFIN_RS_REQUIRE_FIXTURES=1 cargo nextest run --workspaceonmain: 2129 passed, 0 skipped. Clippy clean.items.jsonwent 12 → 28 requests at 0 differences.clients.json— the scenario that found this — went from 264 differences over 12 requests to 98 over 7, and every one of the seven is a separately filed defect or an environmental difference the harness README already names.No ignore-list entry was added.
ignore.rsand both lists are untouched; the README's move of two findings is out ofclients.json's open list intoitems.json, which measures them at 0.Browser pass on the worktree binary: the person and studio pages now list one film each, agreeing with upstream. The genre page is still wrong, and not because of this: the pinned bundle sends
Genres=, neverGenreIds=, so it lands on the unbound-filter gap — #120, which is where it belongs and where nothing but driving a browser would have found it.Five defects this turned up are filed: #120, #121, #122, #123, #124.