From 74c98a7f4ef65d7d37ec3325e26336a788dcb16d Mon Sep 17 00:00:00 2001 From: Nils Lehnen <30603423+iderex@users.noreply.github.com> Date: Tue, 1 Sep 2026 06:47:45 +0200 Subject: [PATCH] Add the route next up is read from, superseding 0010 (#272) 0010 is the authority for which paths the core may reach and it has no row for next up. The kickoff names next up on the first screen a television shows, so the shelf gets built either way; without a row it is built from a path in no enumeration, and #70's test then judges a smaller surface than the client uses. 0039 already met the absence and had to refuse half of #39's scope over it. 0272 carries 0010's table with one row added - next-up, GET /Shows/NextUp, assertion - and the argument the row is for: the route exists on both supported lines and answers with the item type and the page 0039 already fixes, a 404 on it is capability-absent because the path carries no caller-supplied identifier, and its enableResumable default asks for a set that overlaps the resume list, which is a cost of the route stated here and a parameter left to #39. The whole table is carried because whole-record supersession is the only shape 0001 offers; #267 is where a partial one is asked for and this does not invent it. The readings behind the sixteen carried rows were not retaken, which the record says of itself. 0010 keeps its text and gains Superseded by 0272, which is the edit 0001 permits when a replacing record lands. Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com> --- ...server-surface-and-what-an-absence-does.md | 2 +- .../0272-the-route-next-up-is-read-from.md | 385 ++++++++++++++++++ docs/decisions/README.md | 1 + 3 files changed, 387 insertions(+), 1 deletion(-) create mode 100644 docs/decisions/0272-the-route-next-up-is-read-from.md diff --git a/docs/decisions/0010-the-server-surface-and-what-an-absence-does.md b/docs/decisions/0010-the-server-surface-and-what-an-absence-does.md index 79e27a3..6918b9b 100644 --- a/docs/decisions/0010-the-server-surface-and-what-an-absence-does.md +++ b/docs/decisions/0010-the-server-surface-and-what-an-absence-does.md @@ -2,7 +2,7 @@ Date: 2026-08-27 -Status: accepted. Supersedes nothing. Superseded by nothing. +Status: accepted. Supersedes nothing. Superseded by 0272. Issue: #10 diff --git a/docs/decisions/0272-the-route-next-up-is-read-from.md b/docs/decisions/0272-the-route-next-up-is-read-from.md new file mode 100644 index 0000000..04f8fca --- /dev/null +++ b/docs/decisions/0272-the-route-next-up-is-read-from.md @@ -0,0 +1,385 @@ +# 0272. The route next up is read from + +Date: 2026-09-01 + +Status: accepted. Supersedes 0010. Superseded by nothing. + +Issue: #272 + +## The decision + +The core reaches a Jellyfin server through the seventeen named capabilities +enumerated below and through no other part of its interface; a capability name is +what `capability-absent` carries in +[0004](0004-the-error-vocabulary.md), a 404 means an absent route on a path this +record lists that carries no caller-supplied identifier and a missing resource on +one that does, and the core establishes no capability by probing except where a +landed record already required a read before the call for a reason of its own. + +The seventeenth is `next-up`, read from `GET /Shows/NextUp`. + +## What this record changes, and what it does not + +One row is added to +[0010](0010-the-server-surface-and-what-an-absence-does.md)'s table. Every other +row, the fallback rule, the two capabilities no supported line offers, what is +read before a call, and what that record does not decide are carried unchanged. + +**The whole table is carried here because that is the only shape supersession +has.** [0001](0001-decision-records.md) offers a record that is added or +superseded and three edits that are neither, and a partial supersession is not +among them. A row added to +[0010](0010-the-server-surface-and-what-an-absence-does.md)'s own text would be an +edit of the kind that record forbids, and a record naming only the new row would +leave the other sixteen capabilities with no live authority. #267 is where the +shape of a partial supersession is asked for and this record does not invent one. + +**THE READINGS BEHIND THE SIXTEEN CARRIED ROWS WERE NOT RETAKEN HERE.** They were +taken in [0010](0010-the-server-surface-and-what-an-absence-does.md) against the +two commits that record names, that record keeps its text, and a reader who wants +the derivation of a carried row reads it there. What is retaken below is what this +record asserts on its own account: that the two commits are the two lines they are +said to be, and everything about the route being added. A reader who takes the +carried rows for readings made today is reading this record for something it does +not claim. + +## What was read, and where + +The two lines are the ones entry 3 of #1 answered with, and this record is written +against the same two commits +[0010](0010-the-server-surface-and-what-an-absence-does.md) names, so that the row +added below sits beside the sixteen carried ones rather than beside a different +pair of tips: + + $ A=1fbd8739292cce610231be93daf43368733edf63 # the 10.11 line + $ B=c3ed1407ca698b0905de99da87b67415e6a62dbd # the 12.0 line + $ for r in $A $B; do + > gh api "repos/jellyfin/jellyfin/contents/SharedVersion.cs?ref=$r" --jq '.content' \ + > | base64 -d | grep AssemblyVersion | head -1 + > done + [assembly: AssemblyVersion("10.11.11")] + [assembly: AssemblyVersion("12.0.0")] + +The route reached through the server's own repository over the network rather than +through a clone, which is a second way to the same two commits rather than a +second reading: a commit identifier names one tree, and both routes are asking for +that tree by name. + +## The seventeen capabilities, and the paths that carry them + +Every path is joined to the base address +[0028](0028-the-address-a-person-typed.md) resolved, by the rule that record +already fixes. Every authenticated call carries the four parts +[0036](0036-the-device-identity-and-who-supplies-it.md) names in the authorization +value. + +The column headed `repeat` says what a second identical call leaves behind: an +`assertion` leaves the server in the state the first call intended, and an +`accumulation` leaves something the first one did not. It is here because +[0038](0038-retry-and-backoff.md) says in so many words that which calls change +server state is this record's and not that one's. +[0038](0038-retry-and-backoff.md) retries by error kind and never reads this +column; what reads it is #47, deciding what a queue may replay. + + +| Capability | Method and path | Purpose | repeat | +| --- | --- | --- | --- | +| `server-identity` | `GET /System/Info/Public` | The identifier and version the server reports about itself. 0041's server part. | assertion | +| `password-sign-in` | `POST /Users/AuthenticateByName` | 0030's route. | accumulation | +| `quick-connect` | `GET /QuickConnect/Enabled` | Whether the operator has this route on. Read before an exchange is started. | assertion | +| | `POST /QuickConnect/Initiate` | Begin an exchange and receive a code and a secret. | accumulation | +| | `GET /QuickConnect/Connect` | Ask whether the exchange was approved. 0031's poll. | assertion | +| | `POST /Users/AuthenticateWithQuickConnect` | Exchange an approved secret for a session. | accumulation | +| `delegated-sign-in` | none on either line | 0032's route. | not applicable | +| `token-renewal` | none on either line | 0034's exchange of a live token for a fresh one. | not applicable | +| `sign-out` | `POST /Sessions/Logout` | 0114's ending of one session. | assertion | +| `device-capabilities` | `POST /Sessions/Capabilities/Full` | The description 0036 says a client supplies. | assertion | +| `library-query` | `GET /UserViews` | The top of the library, which is a first screen's first request. | assertion | +| | `GET /Items` | The query surface #39 builds on. | assertion | +| `item-detail` | `GET /Items/{itemId}` | One item, in full. | assertion | +| `resume-list` | `GET /UserItems/Resume` | What 0058 resumes into. | assertion | +| `next-up` | `GET /Shows/NextUp` | The episode a series is continued at, which is a first screen's second shelf. 0039's page and 0039's item type. | assertion | +| `item-user-data` | `GET /UserItems/{itemId}/UserData` | The position and the watched mark, read. 0056 and 0060. | assertion | +| | `POST /UserItems/{itemId}/UserData` | The same, written. | assertion | +| `artwork` | `GET /Items/{itemId}/Images/{imageType}` | The bytes #49 builds an address for. `HEAD` on the same path answers the same. | assertion | +| `playback-selection` | `POST /Items/{itemId}/PlaybackInfo` | 0111's choice of source, carrying 0036's description in the body. | accumulation | +| `playback-progress` | `POST /Sessions/Playing` | Playback started. | accumulation | +| | `POST /Sessions/Playing/Progress` | 0057's cadence. | assertion | +| | `POST /Sessions/Playing/Stopped` | Playback ended. | accumulation | +| `played-marking` | `POST /UserPlayedItems/{itemId}` | 0060's mark, set. | assertion | + +`POST /Items/{itemId}/PlaybackInfo` is an accumulation rather than an assertion +because its body carries a field that makes the call open a live stream, so a +repeat can open a second one, which is +[0010](0010-the-server-surface-and-what-an-absence-does.md)'s reading and is not +retaken here. The `GET` form of the same path, `POST /Sessions/Playing/Ping`, and +the spellings the server marks obsolete under a `Users/{userId}` prefix are out of +the table for the reasons that record gives. + +## The row this record adds + +### The route exists on both lines and answers with the type 0039 already fixes + + $ for r in $A $B; do + > gh api "repos/jellyfin/jellyfin/contents/Jellyfin.Api/Controllers/TvShowsController.cs?ref=$r" \ + > --jq '.content' | base64 -d | grep -n -A2 'HttpGet("NextUp")' | grep 'HttpGet\|ActionResult' + > done + 76: [HttpGet("NextUp")] + 78- public ActionResult> GetNextUp( + 75: [HttpGet("NextUp")] + 77- public ActionResult> GetNextUp( + +That answers the question #272 asks last, and the answer is the ordinary one: +next up is the item type +[0039](0039-the-page-the-item-and-what-next-up-is-not.md) fixes, in the page that +record fixes, and not a fourth shape. The envelope is +`QueryResult` - the same one `GET /Items` and `GET /UserItems/Resume` +answer with - so the core's promise that a client written against one read can +display the result of another survives this row untouched, because nothing new is +being mapped. + +The two paging parameters and the total flag are on both lines: + + $ for r in $A $B; do + > gh api "repos/jellyfin/jellyfin/contents/Jellyfin.Api/Controllers/TvShowsController.cs?ref=$r" \ + > --jq '.content' | base64 -d | sed -n '/HttpGet("NextUp")/,/^ {/p' \ + > | grep -cE 'FromQuery\] int\? startIndex|FromQuery\] int\? limit|FromQuery\] bool enableTotalRecordCount = true' + > done + 3 + 3 + +so this is a paged library read like the other three, and +[0039](0039-the-page-the-item-and-what-next-up-is-not.md)'s offset, its total that +may be a page length, and its rule that the core always asks for the total apply +to it with nothing added. + +The route is under a base of its own and carries the authorization every +authenticated call in the table carries, so it is a capability of a session rather +than of a server. `GET /System/Info/Public` is the one row that is not, and it is +not this one: + + $ for r in $A $B; do + > gh api "repos/jellyfin/jellyfin/contents/Jellyfin.Api/Controllers/TvShowsController.cs?ref=$r" \ + > --jq '.content' | base64 -d | grep -nE '^\[Route|^\[Authorize' + > done + 29:[Route("Shows")] + 30:[Authorize] + 28:[Route("Shows")] + 29:[Authorize] + +### `assertion`, for the reason every other read in the table carries it + +A second identical `GET /Shows/NextUp` leaves the server in the state the first one +intended. Nothing in it is written, and #47 may replay it. + +### The absence semantics are the ones the fallback rule already gives + +`GET /Shows/NextUp` carries no caller-supplied identifier in its path, so a 404 on +it is `capability-absent` and never `not-found`, by the same sentence that decides +`GET /UserViews` and `POST /Sessions/Logout`. Nothing in this row is an exception +to that rule, and the row is in the table rather than beside it for exactly that +reason. + +## What a first screen loses without the row + +The kickoff names next up on the first screen a television shows, and +[0039](0039-the-page-the-item-and-what-next-up-is-not.md) took it out of #39's +scope rather than out of the plan. Without a row, a client that wants the shelf +has three moves and each one costs something this board has already paid to avoid. + +It reaches the route itself, outside the core. The path then exists in a client +rather than in the enumeration, the core's list of what it depends on is wrong by +one, and #70's test - which fails when the core reaches a host nobody configured - +is judging a smaller surface than the client actually uses. + +It draws the shelf out of `GET /UserItems/Resume`. That is the resume list, which +is a different question: resume answers what was left part-watched, and next up +answers what a series is continued at, including a series whose last episode was +finished. On both supported lines the finished-series case is the route's ordinary +answer and the resume list has nothing to say about it. + +It draws no shelf. That is a product decision the kickoff already took the other +way, and #272 says in its own words that what is open here is the route and its +cost rather than the feature. + +## What the route costs against both supported lines + +**One request, on the cold start #62 is measuring.** It is a second paged read +beside the resume list on the first screen, and +[0009](0009-the-concurrency-model.md) already lets the two be in flight together, +so the cost is a request rather than a round trip added to the critical path. That +is a statement about the shape of the calls and not a measurement: nothing in this +tree makes a request, and what the shelf costs in milliseconds is #62's to measure +rather than this record's to assert. + +**One parameter the core must send, and this is the part that is easy to get +wrong.** On both lines the route includes resumable episodes unless it is told not +to: + + $ for r in $A $B; do + > gh api "repos/jellyfin/jellyfin/contents/Jellyfin.Api/Controllers/TvShowsController.cs?ref=$r" \ + > --jq '.content' | base64 -d | sed -n '/HttpGet("NextUp")/,/^ {/p' \ + > | grep -E 'enableResumable|enableRewatching' | sed -E 's/^ +//' + > echo "--" + > done + [FromQuery] bool enableResumable = true, + [FromQuery] bool enableRewatching = false) + -- + [FromQuery] bool enableResumable = true, + [FromQuery] bool enableRewatching = false) + -- + +A core that sends nothing therefore asks for a set that overlaps the resume list, +and a first screen drawing both shelves shows the same episode in both of them. +This record states that cost and does not fix the parameter. What a call asks for +is #39's, by the same sentence +[0010](0010-the-server-surface-and-what-an-absence-does.md) already applies to +`GET /Items`, and fixing it here would decide one issue inside another. What is +this record's is that the cost exists and is a default rather than a server +behaviour: a caller who sends nothing gets the duplicate, so the value has to be +chosen rather than left to whoever writes the first request. + +**One parameter the two lines disagree about, and it is the harmless direction.** +10.11 carries a parameter the server has already marked obsolete and 12.0 has +removed it: + + $ for r in $A $B; do + > gh api "repos/jellyfin/jellyfin/contents/Jellyfin.Api/Controllers/TvShowsController.cs?ref=$r" \ + > --jq '.content' | base64 -d | sed -n '/HttpGet("NextUp")/,/^ {/p' \ + > | grep -n 'disableFirstEpisode' | sed -E 's/^ +//' + > echo "--" + > done + 16: [FromQuery][ParameterObsolete] bool disableFirstEpisode = false, + -- + -- + +The core sends neither spelling, so the removal reaches nothing here. It is +recorded because it is the second instance of the pattern +[0010](0010-the-server-surface-and-what-an-absence-does.md) already names once: on +this interface a thing marked obsolete is a thing with a date on it, and the marked +thing is what disappears between two supported lines. A core that had sent it would +have been sending a parameter one supported line ignores. + +## What an absence means for a client that has already drawn the shelf + +The core reports `capability-absent` carrying the name `next-up`, on the call, +because nothing in this table is probed and the answer to a call is what says +whether the capability is there. A client that reserved a rectangle for the shelf +has therefore already reserved it when it learns, and that is the cost rather than +a defect: the alternative is a probe per session, which +[0010](0010-the-server-surface-and-what-an-absence-does.md) priced against the +cold start and refused for every capability rather than for this one. + +What the name buys is the difference between an empty shelf and an unexplained +one. A client that gets a named capability back can say why the shelf has nothing +in it and can stop drawing it on the next start; a client that gets a bare 404 +learns that something was not found and has to guess whether the server has no +such route or the person has nothing to continue. That is +[0004](0004-the-error-vocabulary.md)'s own reason for splitting the row, applied +to the row this record adds. + +**Neither supported line is that case**, and the sentence above is written for a +case that does not arise on 10.11 or 12.0. The route is on both, read above at both +commits. What it is written for is a self-hosted build that removed it and a third +line nobody has read yet, and it is written now because the alternative is the +first caller deciding it at a call site. + +An empty answer is not an absence and the two must not be collapsed. A person with +no series in progress gets a page whose items are empty and whose total is zero, +which is a successful read, and a shelf drawn for it is a shelf with nothing in it +rather than a capability that is gone. + +## What this record does not decide + +Everything [0010](0010-the-server-surface-and-what-an-absence-does.md) leaves +open, unchanged: which endpoints an operator's plugins add, whether the core holds +a surface for a line older than 10.11, what the query parameters on `GET /Items` +are, what the vocabulary of the description in `device-capabilities` is, and +whether a write may be replayed. + +Whether the shelf is drawn at all, and where. The kickoff named next up and #272 +says the feature is not what is open here. +[0003](0003-what-the-core-does-not-do.md) puts the drawing outside in any case. + +Which fields the call asks for, what the page size is, what value +`enableResumable` is sent with, and whether the shelf is fetched at the same moment +as the resume list. Those are #39's and #62's, and deciding them here would decide +two issues inside a third, which is the sentence +[0010](0010-the-server-surface-and-what-an-absence-does.md) already applies to the +same pair. The overlap the parameter produces is stated above as a cost of the +route, which is this record's, and the value is not chosen here. + +Nothing here is a statement about what a running server returns. Every line above +was read out of the server's source at two commits, and no request was made to any +server. The comparison against a real server is #104. + +## Why this is written down before the code + +Nothing in this tree makes a request, so this is the last moment the route is a +decision rather than a discovery. #39 met it as a discovery already: its body asked +for resume and next up together, the resume row was in the table and the next-up +row was not, and the record it produced had to refuse half its own scope and say so. +That is the cheap version of the failure, caught in a record. The expensive version +is a call site: whoever writes the first next-up request writes a path that is in +no enumeration, and #70's test then passes over a host and a route the core really +reaches, which is a check that has stopped being about the thing it names. + +The parameter is the second half and it is the half that would not have been caught +at all. `enableResumable` defaults to including what the resume list already +returns, so a first caller who sends nothing gets a working shelf with a duplicate +in it, on every server, and the duplicate reads as a server behaviour rather than +as a default nobody chose. + +## Alternatives, and what each cost + +**Leaving the route out and drawing the shelf from `GET /UserItems/Resume`.** No +row, no new call, and one fewer thing on the cold start. It answers a different +question: a series whose last episode was watched to the end is not in the resume +list and is exactly what next up is for. It also quietly makes the core's answer +depend on what a client asked for rather than on what it is called. + +**A row that names the route and fixes `enableResumable` here as well.** One +record instead of two, and the duplicate above closed in the same change. It +decides what a call asks for, which +[0010](0010-the-server-surface-and-what-an-absence-does.md) places with #39 for +every other row in this table, and a surface record that starts choosing query +parameters for one row is the shape the next row inherits. + +**Adding the row to +[0010](0010-the-server-surface-and-what-an-absence-does.md)'s own text.** One line +changed, no reproduction of a table, nothing to keep in step. It is the edit +[0001](0001-decision-records.md) forbids: a reader afterwards finds a record that +was always right about a route it did not carry when #39 needed it, and the +refusal that record produced reads as a mistake rather than as the state of the +surface at the time. + +**Waiting for #267 to decide the shape of a partial supersession**, so that the +sixteen carried rows do not have to be reproduced. It is the cheaper shape and it +does not exist yet; taking it now would mean inventing it, which +[0243](0243-the-means-a-certificate-is-validated-with.md) declined to do for the +same reason on the same open question. What this record pays instead is a +reproduced table and a paragraph saying which readings it did not retake, and +#267 landing is what retires that cost rather than reversing this decision. + +## What would reverse this + +The route stops answering on a supported line, or its answer stops being +`QueryResult`. The core then owes a mapping rather than a row, which +is the condition +[0039](0039-the-page-the-item-and-what-next-up-is-not.md) already states for the +other three reads, and the two records move together. + +`enableResumable` changes its default on a supported line, or the resume list +begins answering the finished-series case. The overlap this record states as a cost +is then gone, and #39 is deciding a parameter against a reading that no longer +reproduces. It is checkable the same way it was read: at the two commits a +superseding record names. + +A third server line is added, or either of the two named here moves far enough that +the readings above no longer reproduce. That is +[0010](0010-the-server-surface-and-what-an-absence-does.md)'s own reversal +condition carried forward and it now covers seventeen rows rather than sixteen. + +#267 decides a shape that lets a record add a row without carrying the whole table. +This record is then the instance to re-shape, and what changes is how the addition +is written rather than what was decided about the route. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index e6ad389..d48aaf1 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -62,3 +62,4 @@ allocated, and why a record is superseded rather than edited are in - [0115. Creating the core, stopping it, and a host that suspends it](0115-creating-and-stopping-the-core.md) - [0116. Learning that something cached has changed](0116-learning-that-something-cached-has-changed.md) - [0243. The means a certificate is validated with, and what it costs](0243-the-means-a-certificate-is-validated-with.md) +- [0272. The route next up is read from](0272-the-route-next-up-is-read-from.md)