/UserItems/Resume applies parameters upstream's resume action never binds #124

Closed
opened 2026-08-14 00:15:44 +02:00 by Claude · 1 comment
Collaborator

/UserItems/Resume shares build with /Items in crates/jellyfin-http/src/routes/items.rs, so it reads and applies genreIds, years, nameStartsWith, filters, isFavorite, isPlayed, seriesId and parentIndexNumber. Upstream's GetResumeItems binds none of them: they are not in the action's signature, so a client sending one gets it ignored and we narrow on it.

This is argued from the two signatures, not measured. The reviewer tried and could not: the fixture has no resume rows, so both servers answer an empty page whatever is sent. Confirming it needs a playback position written to the reference first — crates/jellyfin-harness/scenarios/playstate.json is the scenario that writes one, and the harness README's ordering rules apply, since a read scenario running first warms the cache that makes a replay unrepeatable.

Two of the six new id filters were deliberately kept off this path during #91 for exactly this reason, so the precedent is set; what is left is the ones that predate it.

Where to start

crates/jellyfin-http/src/routes/items.rs — the shared build, and which of its parameters each caller is allowed to reach. Upstream: ItemsController.GetResumeItems.

The shape to prefer: make the route say what it binds rather than sharing everything and subtracting, since subtracting is what left this behind.

How to know it is done

With a resume position written, a request naming one of these parameters answers the same on both servers, and the scenario that writes the position carries the check.

House rules

Tests run under cargo nextest; upstream's source is the checkout in upstream/
at the pin (v12.0-rc5, 6d501ba4); generated files are regenerated rather
than edited; comments say what the code does, not how it got there. The
reference pair is cargo xtask reference up, a run is
cargo run -p jellyfin-harness -- replay --upstream URL --ours URL --scenario FILE --out DIR,
and crates/jellyfin-harness/README.md says what a diff means. A server started
off current main binds UDP 7359 for discovery — set [discovery] enabled = false
in a scratch config if another one is already running.

`/UserItems/Resume` shares `build` with `/Items` in `crates/jellyfin-http/src/routes/items.rs`, so it reads and applies `genreIds`, `years`, `nameStartsWith`, `filters`, `isFavorite`, `isPlayed`, `seriesId` and `parentIndexNumber`. Upstream's `GetResumeItems` binds none of them: they are not in the action's signature, so a client sending one gets it ignored and we narrow on it. **This is argued from the two signatures, not measured.** The reviewer tried and could not: the fixture has no resume rows, so both servers answer an empty page whatever is sent. Confirming it needs a playback position written to the reference first — `crates/jellyfin-harness/scenarios/playstate.json` is the scenario that writes one, and the harness README's ordering rules apply, since a read scenario running first warms the cache that makes a replay unrepeatable. Two of the six new id filters were deliberately kept off this path during #91 for exactly this reason, so the precedent is set; what is left is the ones that predate it. ## Where to start `crates/jellyfin-http/src/routes/items.rs` — the shared `build`, and which of its parameters each caller is allowed to reach. Upstream: `ItemsController.GetResumeItems`. The shape to prefer: make the route say what it binds rather than sharing everything and subtracting, since subtracting is what left this behind. ## How to know it is done With a resume position written, a request naming one of these parameters answers the same on both servers, and the scenario that writes the position carries the check. ### House rules Tests run under `cargo nextest`; upstream's source is the checkout in `upstream/` **at the pin** (`v12.0-rc5`, `6d501ba4`); generated files are regenerated rather than edited; comments say what the code does, not how it got there. The reference pair is `cargo xtask reference up`, a run is `cargo run -p jellyfin-harness -- replay --upstream URL --ours URL --scenario FILE --out DIR`, and `crates/jellyfin-harness/README.md` says what a diff means. A server started off current `main` binds UDP 7359 for discovery — set `[discovery] enabled = false` in a scratch config if another one is already running.
Author
Collaborator

Done, on main.

/UserItems/Resume shared build with /Items, so it applied filters upstream's GetResumeItems never binds. Each route now declares what upstream's action takes — sharing everything and subtracting is what left this behind.

GetResumeItems takes fifteen parameters. Thirteen are read; imageTypeLimit and enableImageTypes are ignored here as they are on /Items. The audit was one short on the first pass: excludeActiveSessions is a genuine narrowing parameter and was neither bound nor registered, so a client sending it got back in-progress items upstream would have removed. It is bound now, and expands each active session's item to every version — without that, an alternate cut of what is playing stays in the listing, which is exactly the row a client would offer to resume.

And the route was missing most of a Continue Watching row. TranslateQuery.cs:495-527 applies folderIsResumableFilter unconditionally whenever IsResumable is set, over [Series, Season]. Ours gated it on the request having asked for series, and a comment claimed that was what upstream branches on — it does not. Measured on an enriched pair: 2 rows against upstream's 4, now 4 and identical — Test Pattern S01E03, Night Signal, Season 1, Test Pattern. The descendant test runs through AncestorIds rather than SeriesId, which is what lets one expression answer for a season as well as the series above it.


The batch this closed in

Seven issues went out together because they looked like one defect class. They turned out to be three roots:

  • #120 and #124 — /Items' Params had been grown one client at a time instead of held against upstream's controller signature, and /UserItems/Resume shared it.
  • #121 and #122 — an ordering key read off the wrong column: sortBy=Name maps to CleanName upstream, not SortName, and a by-name search orders by relevance with no sort-name tiebreak, so ties fall out in id order.
  • #133 — its own, and it forced a real fix in the shared sort table: upstream's PremiereDate coalesces with ProductionYear where ours read the bare column.

A reviewer re-derived the parameter audit mechanically — extracting every [FromQuery] name from GetItems and doing set arithmetic against what we bind — and got nothing bound that upstream does not take and nothing in the register that is not an upstream parameter. This class is closed rather than narrowed, which matters because it had been found three times.

Verified on a restored pair: browsing 34 → 0, items 33 requests at 0, clients 56 → 3, portable 115 → 6. JELLYFIN_RS_REQUIRE_FIXTURES=1 cargo nextest run --workspace: 2228 passed, 0 skipped. Clippy clean. No ignore-list entry was added or touched.

Two things the review caught that the batch had not

Five of the ten new scenario entries could not fail. The default snapshot has no genres, studios or tags, so five items.json entries compared two empty pages — reverting the whole fix would still have replayed clean. They moved into editing.json, after the save that gives one film a genre, a tag, a certificate and a studio and then takes them away, so each selects one row out of nineteen. That is the fifth time this session the same failure has turned up in a different layer, after M5's vacuous scenarios, M8's bundle.json, #111's all-refusals visibility.json and #137's silently-skipping tests.

Reading the reference can write to it. Studios=, Artists= and Albums= resolve through upstream's GetStudio/GetArtist, which call CreateItemByName — so a read files a BaseItems row. That is how a stray Brandywine Productions got into the shared fixture mid-session and made portable.json disagree by 149. Genres, Tags and OfficialRatings narrow through ItemValues and are safe. Both halves are now written into crates/jellyfin-harness/README.md.

Done, on `main`. `/UserItems/Resume` shared `build` with `/Items`, so it applied filters upstream's `GetResumeItems` never binds. Each route now declares what upstream's action takes — sharing everything and subtracting is what left this behind. `GetResumeItems` takes **fifteen** parameters. Thirteen are read; `imageTypeLimit` and `enableImageTypes` are ignored here as they are on `/Items`. The audit was one short on the first pass: **`excludeActiveSessions`** is a genuine narrowing parameter and was neither bound nor registered, so a client sending it got back in-progress items upstream would have removed. It is bound now, and expands each active session's item to every version — without that, an alternate cut of what is playing stays in the listing, which is exactly the row a client would offer to resume. **And the route was missing most of a Continue Watching row.** `TranslateQuery.cs:495-527` applies `folderIsResumableFilter` **unconditionally** whenever `IsResumable` is set, over `[Series, Season]`. Ours gated it on the request having asked for series, and a comment claimed that was what upstream branches on — it does not. Measured on an enriched pair: 2 rows against upstream's 4, now 4 and identical — `Test Pattern S01E03, Night Signal, Season 1, Test Pattern`. The descendant test runs through `AncestorIds` rather than `SeriesId`, which is what lets one expression answer for a season as well as the series above it. --- ## The batch this closed in Seven issues went out together because they looked like one defect class. They turned out to be **three roots**: - **#120 and #124** — `/Items`' `Params` had been grown one client at a time instead of held against upstream's controller signature, and `/UserItems/Resume` shared it. - **#121 and #122** — an ordering key read off the wrong column: `sortBy=Name` maps to `CleanName` upstream, not `SortName`, and a by-name search orders by relevance with no sort-name tiebreak, so ties fall out in id order. - **#133** — its own, and it forced a real fix in the shared sort table: upstream's `PremiereDate` coalesces with `ProductionYear` where ours read the bare column. A reviewer re-derived the parameter audit mechanically — extracting every `[FromQuery]` name from `GetItems` and doing set arithmetic against what we bind — and got nothing bound that upstream does not take and nothing in the register that is not an upstream parameter. **This class is closed rather than narrowed**, which matters because it had been found three times. Verified on a restored pair: `browsing` **34 → 0**, `items` 33 requests at 0, `clients` **56 → 3**, `portable` 115 → 6. `JELLYFIN_RS_REQUIRE_FIXTURES=1 cargo nextest run --workspace`: **2228 passed, 0 skipped**. Clippy clean. No ignore-list entry was added or touched. ### Two things the review caught that the batch had not **Five of the ten new scenario entries could not fail.** The `default` snapshot has no genres, studios or tags, so five `items.json` entries compared two empty pages — reverting the whole fix would still have replayed clean. They moved into `editing.json`, after the save that gives one film a genre, a tag, a certificate and a studio and then takes them away, so each selects one row out of nineteen. That is the fifth time this session the same failure has turned up in a different layer, after M5's vacuous scenarios, M8's `bundle.json`, #111's all-refusals `visibility.json` and #137's silently-skipping tests. **Reading the reference can write to it.** `Studios=`, `Artists=` and `Albums=` resolve through upstream's `GetStudio`/`GetArtist`, which call `CreateItemByName` — so a *read* files a `BaseItems` row. That is how a stray `Brandywine Productions` got into the shared fixture mid-session and made `portable.json` disagree by 149. `Genres`, `Tags` and `OfficialRatings` narrow through `ItemValues` and are safe. Both halves are now written into `crates/jellyfin-harness/README.md`.
Sign in to join this conversation.
No description provided.