From fb2a1e23c0798c253805d6872e8d986e1c92b4eb Mon Sep 17 00:00:00 2001 From: iderex <30603423+iderex@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:25:57 +0200 Subject: [PATCH] Record one response set from a Jellyfin 12.0.0 server, scrubbed to the synthetic values (#104) tests/recorded/ held its procedure and no recording. This is the first: sixteen responses from a 12.0.0 server run for the purpose, one file per capability of 0272 that has a request to make, with a library of three generated black videos so that the item routes answer with items. RECORDED.md beside them says what each file shows, what has no request to record and why, and what was scrubbed in the order tests/recorded/README.md gives. WHAT IS IN A FILE IS AN ENVELOPE rather than the bare body: capability, method, path, status, the three headers kept, the request body where there was one, and the body as parsed JSON or as a byte count where it was not JSON or was empty. A comparison of shape, which is what #104 builds over these, needs the status and the content type as much as the fields, and a bare body carries neither. The date header and the server's timing header are not kept, because a value that changes on every recording turns each re-recording into a diff nobody can read. THE SIXTEEN SYNTHETIC IDENTIFIERS ARE FEWER THAN THE RECORDING HAS, so which values share one is a choice and it is made rather than left to order of appearance: the twelve identifiers that name a thing, the server, the views, the film, the episodes, the series, the season and the play session, get one value each, and the nine digests, entity tags, image tags and the display-preferences id, take the remaining four with the last six sharing the sixteenth. Two things that differ in the recording therefore differ here, and RECORDED.md says which kind of value says nothing about the server when two of them are equal. THE FIRST RECORDING LANDS WITH WHAT ITS PROCEDURE SAID IT MUST. The -text rule for tests/recorded/ joins .gitattributes, with the two Markdown files under the prefix normalised after it, and tests/fixture_bytes.rs gains the assertion that makes it bite: every envelope is read as bytes and refused if it carries a carriage return or does not end in exactly one line feed. The README's paragraph saying the directory was empty and the rule absent is rewritten to what is now true, and so is the paragraph in .gitattributes that said the same. The check that judges these files refused nothing: bash .github/fixture-scrub/fixture-scrub.sh check Every rule above was applied to its subject and refused nothing. Contributes to #104. It does not build the comparison in the harness, which is what closes that issue; it gives it the first recording to compare against. Signed-off-by: iderex <30603423+iderex@users.noreply.github.com> --- .gitattributes | 15 +- tests/fixture_bytes.rs | 58 +++++ tests/recorded/README.md | 45 ++-- tests/recorded/jellyfin-12.0.0/RECORDED.md | 70 ++++++ .../jellyfin-12.0.0/after-sign-out.json | 13 + .../jellyfin-12.0.0/artwork-head.json | 14 ++ tests/recorded/jellyfin-12.0.0/artwork.json | 14 ++ .../jellyfin-12.0.0/device-capabilities.json | 38 +++ .../recorded/jellyfin-12.0.0/item-detail.json | 234 ++++++++++++++++++ .../item-user-data-episode.json | 20 ++ .../jellyfin-12.0.0/item-user-data.json | 19 ++ .../jellyfin-12.0.0/library-query.json | 168 +++++++++++++ tests/recorded/jellyfin-12.0.0/next-up.json | 54 ++++ .../jellyfin-12.0.0/playback-progress.json | 19 ++ .../jellyfin-12.0.0/playback-selection.json | 132 ++++++++++ .../jellyfin-12.0.0/played-marking.json | 20 ++ .../jellyfin-12.0.0/quick-connect.json | 12 + .../recorded/jellyfin-12.0.0/resume-list.json | 16 ++ .../jellyfin-12.0.0/server-identity.json | 20 ++ tests/recorded/jellyfin-12.0.0/sign-out.json | 12 + 20 files changed, 969 insertions(+), 24 deletions(-) create mode 100644 tests/recorded/jellyfin-12.0.0/RECORDED.md create mode 100644 tests/recorded/jellyfin-12.0.0/after-sign-out.json create mode 100644 tests/recorded/jellyfin-12.0.0/artwork-head.json create mode 100644 tests/recorded/jellyfin-12.0.0/artwork.json create mode 100644 tests/recorded/jellyfin-12.0.0/device-capabilities.json create mode 100644 tests/recorded/jellyfin-12.0.0/item-detail.json create mode 100644 tests/recorded/jellyfin-12.0.0/item-user-data-episode.json create mode 100644 tests/recorded/jellyfin-12.0.0/item-user-data.json create mode 100644 tests/recorded/jellyfin-12.0.0/library-query.json create mode 100644 tests/recorded/jellyfin-12.0.0/next-up.json create mode 100644 tests/recorded/jellyfin-12.0.0/playback-progress.json create mode 100644 tests/recorded/jellyfin-12.0.0/playback-selection.json create mode 100644 tests/recorded/jellyfin-12.0.0/played-marking.json create mode 100644 tests/recorded/jellyfin-12.0.0/quick-connect.json create mode 100644 tests/recorded/jellyfin-12.0.0/resume-list.json create mode 100644 tests/recorded/jellyfin-12.0.0/server-identity.json create mode 100644 tests/recorded/jellyfin-12.0.0/sign-out.json diff --git a/.gitattributes b/.gitattributes index 3ca2de9..d3c13d9 100644 --- a/.gitattributes +++ b/.gitattributes @@ -25,9 +25,9 @@ # What the paragraph was right about survives it: a path rule written for a # directory nobody has created yet is a rule nothing holds, so a rule here arrives # with the paths it is for, and where a fixture lives is the layout in #13. -# `tests/recorded/` is where that still applies. The directory holds no recording, -# the `-text` rule a recording needs is not here, and `tests/recorded/README.md` -# is where that is argued and where the rule lands with the first recording. +# `tests/recorded/` was that case until 2026-09-18, when the first recording +# landed and its rule joined the foot of this file; `tests/recorded/README.md` is +# where the rule is argued. # Every path is text, and it is checked out with a line feed whatever a clone's # own translation setting says. `text=auto` alone would leave the working tree to @@ -56,3 +56,12 @@ # to be extended per fixture is a rule the fixture that needs it most is added # without. tests/fixtures/** -text + +# The recorded responses (#109), for the same reason. An envelope holds what a +# server answered, and a byte the checkout may translate is a byte the comparison +# in #104 cannot rely on; `tests/fixture_bytes.rs` is what makes this rule fail +# loudly. The two Markdown files under the prefix are prose and stay normalised, +# which is why they come after the rule that would otherwise cover them. +tests/recorded/** -text +tests/recorded/README.md text=auto eol=lf +tests/recorded/**/RECORDED.md text=auto eol=lf diff --git a/tests/fixture_bytes.rs b/tests/fixture_bytes.rs index c960521..b9443f8 100644 --- a/tests/fixture_bytes.rs +++ b/tests/fixture_bytes.rs @@ -69,3 +69,61 @@ fn the_fixture_is_the_length_it_was_written_at() { has been added or removed since." ); } + +/// Every recorded envelope under `tests/recorded/`, read exactly as it sits on +/// disk (#109). An envelope is JSON the recorder writes with a bare line feed, +/// so what the two tests below prove is narrower than the fixture above: that no +/// checkout and no recorder put a carriage return into one. The `-text` rule for +/// the directory is what lets that fail; under `text=auto` the byte would be +/// normalised away on the way into the index and neither test could go red. +fn recorded() -> Vec<(std::path::PathBuf, Vec)> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/recorded"); + let mut set = Vec::new(); + let dirs = + std::fs::read_dir(&root).unwrap_or_else(|e| panic!("cannot read {}: {e}", root.display())); + for dir in dirs + .map(|d| d.expect("a directory entry").path()) + .filter(|p| p.is_dir()) + { + let files = std::fs::read_dir(&dir) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", dir.display())); + for path in files.map(|f| f.expect("a directory entry").path()) { + if path.extension().is_some_and(|x| x == "json") { + let bytes = std::fs::read(&path) + .unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display())); + set.push((path, bytes)); + } + } + } + set +} + +const CARRIAGE_RETURN: u8 = 13; +const LINE_FEED: u8 = 10; + +#[test] +fn a_recorded_envelope_carries_no_carriage_return() { + let set = recorded(); + assert!( + !set.is_empty(), + "no envelope under tests/recorded/, so there is nothing for this test to prove" + ); + for (path, bytes) in &set { + assert!( + !bytes.contains(&CARRIAGE_RETURN), + "{} carries a carriage return. The recorder or the checkout put a byte into the envelope that no scrubbed recording holds, which the -text rule for tests/recorded/ in .gitattributes exists to make visible rather than normalise away.", + path.display() + ); + } +} + +#[test] +fn a_recorded_envelope_ends_with_exactly_one_line_feed() { + for (path, bytes) in recorded() { + assert!( + bytes.last() == Some(&LINE_FEED) && !bytes.ends_with(&[LINE_FEED, LINE_FEED]), + "{} does not end with exactly one line feed, so some byte at its end was added or removed since it was scrubbed.", + path.display() + ); + } +} diff --git a/tests/recorded/README.md b/tests/recorded/README.md index e25c938..72116fb 100644 --- a/tests/recorded/README.md +++ b/tests/recorded/README.md @@ -1,10 +1,11 @@ # Recorded fixtures, and how one is made -This directory holds responses recorded from a real server. It is empty of -recordings today, and the procedure below exists before the first one rather than -after it, which is the whole argument of #109: a rule added once fixtures have -landed does nothing about the ones already in the history, and the repair is a -history rewrite rather than a commit. +This directory holds responses recorded from a real server, one directory per +server version with a `RECORDED.md` beside the files. The first landed on +2026-09-18, and the procedure below existed before it rather than after it, which +is the whole argument of #109: a rule added once fixtures have landed does +nothing about the ones already in the history, and the repair is a history +rewrite rather than a commit. Read this before recording. `.github/fixture-scrub/fixture-scrub.sh` refuses a recording that did not follow it, and @@ -58,24 +59,26 @@ that slips through arrives in exactly that noise. 5. Commit the scrubbed file only. -## What the first recording has to land with, and this directory does not carry yet +## What the first recording landed with `.gitattributes` normalises every tracked path to a line feed, and the one -exception is `tests/fixtures/**`, where a fixture exists to prove a byte. A -response recorded off a wire is the same case and is not covered by that -exception: the head of an HTTP answer ends its lines with a carriage return and a -line feed, so the tree-wide rule rewrites a recording into something no server -sent, silently, on the way into the index. - -The rule this directory needs is `tests/recorded/** -text` beside the one already -there, with `tests/recorded/README.md text=auto eol=lf` after it so this document -stays normalised. It is not in `.gitattributes` today, and the reason is the rule -this repository holds about guards rather than an oversight: nothing here has the -bytes for it to bite on, and a `-text` line over an empty directory is a guard -that cannot fail, which is what `.gitattributes` says in its own header it exists -against. So the line lands in the same change as the first recording, together -with an assertion over that recording's bytes in the shape -`tests/fixture_bytes.rs` already holds for the other directory. +exception was `tests/fixtures/**`, where a fixture exists to prove a byte. A +response recorded off a wire is the same case: the head of an HTTP answer ends +its lines with a carriage return and a line feed, so the tree-wide rule would +rewrite such a recording into something no server sent, silently, on the way +into the index. + +So `tests/recorded/** -text` stands beside that rule, with the two Markdown +files under the prefix normalised after it, and it landed in the same change as +the first recording rather than before it, because a `-text` line over an empty +directory is a guard that cannot fail. The assertion that makes it bite is in +`tests/fixture_bytes.rs`: every envelope under this directory is read as bytes +and refused if it carries a carriage return or does not end in exactly one line +feed. That is narrower than the wire case, and on purpose: the envelopes here +are JSON the recorder writes with a bare line feed, so what the rule protects is +that no checkout and no recorder can put a carriage return into one without the +test going red, where under the tree-wide rule the byte would be normalised +away and the test could not fail. Read that before recording rather than after. A recording committed under the tree-wide rule has already lost the bytes, and the repair is a re-recording. diff --git a/tests/recorded/jellyfin-12.0.0/RECORDED.md b/tests/recorded/jellyfin-12.0.0/RECORDED.md new file mode 100644 index 0000000..ac33032 --- /dev/null +++ b/tests/recorded/jellyfin-12.0.0/RECORDED.md @@ -0,0 +1,70 @@ +# One response set from a Jellyfin 12.0.0 server + +Recorded on 2026-09-18 against a server I run for this purpose, `jellyfin/jellyfin:12.0.0` +in a container on this machine, reached over loopback, with a library that holds nothing a +person made: three two-second black videos generated by the server's own ffmpeg, one film +under `Filme` and two episodes of one series under `Serien`, so that the item routes answer +with items rather than with empty lists. The server reported itself as: + +``` +GET /System/Info/Public ProductName "Jellyfin Server" Version "12.0.0" +``` + +## What each file holds + +One file per capability of `docs/decisions/0272-the-route-next-up-is-read-from.md`, named +by the capability, in one envelope: `capability`, `method`, `path`, `status`, the three +response headers kept (`content-type`, `content-length`, `server`), the request body where +the call had one, and `body` as the parsed JSON, or `body_bytes` with `body_content_type` +where the answer was not JSON (the artwork bytes) or empty (a 204). The date header and the +server's timing header are not kept: both are facts about the moment and the machine rather +than about the interface, and a value that changes on every recording turns each +re-recording into a diff nobody can read. + +| file | route | what it shows | +| --- | --- | --- | +| `server-identity` | `GET /System/Info/Public` | the unauthenticated identity, 200 | +| `quick-connect` | `GET /QuickConnect/Enabled` | `false` on a fresh server, 200 | +| `device-capabilities` | `POST /Sessions/Capabilities/Full` | 204 with no body | +| `library-query` | `GET /UserViews` | three views, one of them empty | +| `item-detail` | `GET /Items/{itemId}` | the film, in full | +| `resume-list` | `GET /UserItems/Resume` | empty before any playback, 200 | +| `item-user-data` | `GET /UserItems/{itemId}/UserData` | the film, unplayed | +| `artwork` | `GET /Items/{itemId}/Images/Primary` | the server's own extracted frame, image bytes | +| `artwork-head` | `HEAD` on the same path | the same headers with no body | +| `playback-selection` | `POST /Items/{itemId}/PlaybackInfo` | one media source, direct play | +| `playback-progress` | `POST /Sessions/Playing` | 204 | +| `played-marking` | `POST /UserPlayedItems/{itemId}` | the first episode marked, returns its user data | +| `next-up` | `GET /Shows/NextUp` | the second episode, after the first was marked | +| `item-user-data-episode` | `GET /UserItems/{itemId}/UserData` | the first episode, played | +| `sign-out` | `POST /Sessions/Logout` | 204 | +| `after-sign-out` | `GET /UserViews` with the ended session's token | 401, the shape of a refused call | + +`delegated-sign-in`, `token-renewal` and `change-notification` have no request to record: +the first two exist on neither line, and the third is a WebSocket upgrade rather than a +response. `password-sign-in` is not in this set because its response carries the token, +which a recording never holds; its shape is the one `item-user-data` and the others were +read under. + +## What was scrubbed, in the order `tests/recorded/README.md` gives + +The token was removed from every envelope rather than rewritten, and no password was ever +in a response. The server address became `https://server.invalid`. The device identity was +synthetic from the first request. The account name appears in no response of this set. +Titles and paths were generated for this recording and name nobody. + +Every server-supplied identifier, in keys as well as in values, became one of the sixteen +synthetic identifiers, and the recording holds twenty-one distinct ones, so which share a +value was a choice rather than an accident of order. Twelve name a thing: the server, the +three views, a view's parent, the film, its parent, the play session, the first episode, +the series behind its user-data key, the second episode and its season. Each of those got +its own value, in order of first appearance, so two things that differ in the recording +differ here. The other nine are digests, five entity tags, three image tags and the +display-preferences id, and they took the remaining four values, the last six of them +sharing the sixteenth. A reader of an image tag or an entity tag should therefore not take +equality of two of them as a fact about the server. A user-data `Key` is the server's own +hyphenated identifier with the episode's season and index appended, and the scrub keeps +that shape. + +The check that judges all of this is `.github/fixture-scrub/fixture-scrub.sh check`, and +it was run on these files before they were committed. diff --git a/tests/recorded/jellyfin-12.0.0/after-sign-out.json b/tests/recorded/jellyfin-12.0.0/after-sign-out.json new file mode 100644 index 0000000..5a058f2 --- /dev/null +++ b/tests/recorded/jellyfin-12.0.0/after-sign-out.json @@ -0,0 +1,13 @@ +{ + "capability": "after-sign-out", + "method": "GET", + "path": "/UserViews", + "status": 401, + "headers": { + "Content-Length": "0", + "Server": "Kestrel" + }, + "request_body": null, + "body_bytes": 0, + "body_content_type": "" +} diff --git a/tests/recorded/jellyfin-12.0.0/artwork-head.json b/tests/recorded/jellyfin-12.0.0/artwork-head.json new file mode 100644 index 0000000..0b2bc06 --- /dev/null +++ b/tests/recorded/jellyfin-12.0.0/artwork-head.json @@ -0,0 +1,14 @@ +{ + "capability": "artwork-head", + "method": "HEAD", + "path": "/Items/00000000000040008000000000000005/Images/Primary", + "status": 200, + "headers": { + "Content-Length": "581", + "Content-Type": "image/jpeg", + "Server": "Kestrel" + }, + "request_body": null, + "body_bytes": 0, + "body_content_type": "image/jpeg" +} diff --git a/tests/recorded/jellyfin-12.0.0/artwork.json b/tests/recorded/jellyfin-12.0.0/artwork.json new file mode 100644 index 0000000..e2dcfc0 --- /dev/null +++ b/tests/recorded/jellyfin-12.0.0/artwork.json @@ -0,0 +1,14 @@ +{ + "capability": "artwork", + "method": "GET", + "path": "/Items/00000000000040008000000000000005/Images/Primary", + "status": 200, + "headers": { + "Content-Length": "581", + "Content-Type": "image/jpeg", + "Server": "Kestrel" + }, + "request_body": null, + "body_bytes": 581, + "body_content_type": "image/jpeg" +} diff --git a/tests/recorded/jellyfin-12.0.0/device-capabilities.json b/tests/recorded/jellyfin-12.0.0/device-capabilities.json new file mode 100644 index 0000000..8b83773 --- /dev/null +++ b/tests/recorded/jellyfin-12.0.0/device-capabilities.json @@ -0,0 +1,38 @@ +{ + "capability": "device-capabilities", + "method": "POST", + "path": "/Sessions/Capabilities/Full", + "status": 204, + "headers": { + "Server": "Kestrel" + }, + "request_body": { + "PlayableMediaTypes": [ + "Video", + "Audio" + ], + "SupportedCommands": [ + "Play", + "PlayNext" + ], + "SupportsMediaControl": true, + "SupportsPersistentIdentifier": true, + "DeviceProfile": { + "Name": "a synthetic device", + "MaxStreamingBitrate": 20000000, + "DirectPlayProfiles": [ + { + "Container": "mp4", + "Type": "Video", + "VideoCodec": "h264", + "AudioCodec": "aac" + } + ], + "TranscodingProfiles": [], + "CodecProfiles": [], + "SubtitleProfiles": [] + } + }, + "body_bytes": 0, + "body_content_type": "" +} diff --git a/tests/recorded/jellyfin-12.0.0/item-detail.json b/tests/recorded/jellyfin-12.0.0/item-detail.json new file mode 100644 index 0000000..5a0d848 --- /dev/null +++ b/tests/recorded/jellyfin-12.0.0/item-detail.json @@ -0,0 +1,234 @@ +{ + "capability": "item-detail", + "method": "GET", + "path": "/Items/00000000000040008000000000000005", + "status": 200, + "headers": { + "Content-Type": "application/json; charset=utf-8", + "Server": "Kestrel" + }, + "request_body": null, + "body": { + "Name": "Synthetic Movie (2024)", + "ServerId": "00000000000040008000000000000000", + "Id": "00000000000040008000000000000005", + "Etag": "0000000000004000800000000000000f", + "DateCreated": "2026-09-18T11:39:07.4614339Z", + "CanDelete": true, + "CanDownload": true, + "Container": "mov,mp4,m4a,3gp,3g2,mj2", + "SortName": "synthetic movie (0000002024)", + "ExternalUrls": [], + "MediaSources": [ + { + "Protocol": "File", + "Id": "00000000000040008000000000000005", + "Path": "/media/filme/Synthetic Movie (2024)/Synthetic Movie (2024).mp4", + "Type": "Default", + "Container": "mp4", + "Size": 3826, + "Name": "Synthetic Movie (2024)", + "IsRemote": false, + "ETag": "0000000000004000800000000000000f", + "RunTimeTicks": 20000000, + "ReadAtNativeFramerate": false, + "IgnoreDts": false, + "IgnoreIndex": false, + "GenPtsInput": false, + "SupportsTranscoding": true, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": false, + "UseMostCompatibleTranscodingProfile": false, + "RequiresOpening": false, + "RequiresClosing": false, + "RequiresLooping": false, + "SupportsProbing": true, + "VideoType": "VideoFile", + "MediaStreams": [ + { + "Codec": "h264", + "Language": "und", + "TimeBase": "1/12800", + "VideoRange": "SDR", + "VideoRangeType": "SDR", + "AudioSpatialFormat": "None", + "DisplayTitle": "240p H264 SDR", + "NalLengthSize": "4", + "IsInterlaced": false, + "IsAVC": true, + "BitRate": 5348, + "BitDepth": 8, + "RefFrames": 1, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "IsOriginal": false, + "Height": 180, + "Width": 320, + "AverageFrameRate": 25, + "RealFrameRate": 25, + "ReferenceFrameRate": 25, + "Profile": "Constrained Baseline", + "Type": "Video", + "AspectRatio": "16:9", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "PixelFormat": "yuv420p", + "Level": 12, + "IsAnamorphic": false + }, + { + "Codec": "aac", + "Language": "und", + "TimeBase": "1/22050", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "LocalizedLanguage": "Undetermined", + "LocalizedOriginal": "Original", + "DisplayTitle": "AAC - Mono - Default", + "IsInterlaced": false, + "ChannelLayout": "mono", + "BitRate": 770, + "Channels": 1, + "SampleRate": 22050, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "IsOriginal": false, + "Profile": "LC", + "Type": "Audio", + "Index": 1, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false + } + ], + "MediaAttachments": [], + "Formats": [], + "Bitrate": 15304, + "RequiredHttpHeaders": {}, + "TranscodingSubProtocol": "http", + "DefaultAudioStreamIndex": 1, + "HasSegments": false + } + ], + "ProductionLocations": [], + "Path": "/media/filme/Synthetic Movie (2024)/Synthetic Movie (2024).mp4", + "EnableMediaSourceDisplay": true, + "ChannelId": null, + "Taglines": [], + "Genres": [], + "RunTimeTicks": 20000000, + "PlayAccess": "Full", + "ProductionYear": 2024, + "RemoteTrailers": [], + "ProviderIds": {}, + "IsFolder": false, + "ParentId": "00000000000040008000000000000006", + "Type": "Movie", + "People": [], + "Studios": [], + "GenreItems": [], + "LocalTrailerCount": 0, + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "00000000-0000-4000-8000-000000000005", + "ItemId": "00000000000040008000000000000005" + }, + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "0000000000004000800000000000000f", + "Tags": [], + "PrimaryImageAspectRatio": 1.7777777777777777, + "MediaStreams": [ + { + "Codec": "h264", + "Language": "und", + "TimeBase": "1/12800", + "VideoRange": "SDR", + "VideoRangeType": "SDR", + "AudioSpatialFormat": "None", + "DisplayTitle": "240p H264 SDR", + "NalLengthSize": "4", + "IsInterlaced": false, + "IsAVC": true, + "BitRate": 5348, + "BitDepth": 8, + "RefFrames": 1, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "IsOriginal": false, + "Height": 180, + "Width": 320, + "AverageFrameRate": 25, + "RealFrameRate": 25, + "ReferenceFrameRate": 25, + "Profile": "Constrained Baseline", + "Type": "Video", + "AspectRatio": "16:9", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "PixelFormat": "yuv420p", + "Level": 12, + "IsAnamorphic": false + }, + { + "Codec": "aac", + "Language": "und", + "TimeBase": "1/22050", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "LocalizedLanguage": "Undetermined", + "LocalizedOriginal": "Original", + "DisplayTitle": "AAC - Mono - Default", + "IsInterlaced": false, + "ChannelLayout": "mono", + "BitRate": 770, + "Channels": 1, + "SampleRate": 22050, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "IsOriginal": false, + "Profile": "LC", + "Type": "Audio", + "Index": 1, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false + } + ], + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "0000000000004000800000000000000f" + }, + "BackdropImageTags": [], + "ImageBlurHashes": { + "Primary": { + "0000000000004000800000000000000f": "W00000fQfQfQfQfQfQfQfQfQfQfQfQfQfQfQfQfQfQfQfQfQfQfQ" + } + }, + "Chapters": [], + "Trickplay": {}, + "LocationType": "FileSystem", + "MediaType": "Video", + "LockedFields": [], + "LockData": false, + "Width": 320, + "Height": 180 + } +} diff --git a/tests/recorded/jellyfin-12.0.0/item-user-data-episode.json b/tests/recorded/jellyfin-12.0.0/item-user-data-episode.json new file mode 100644 index 0000000..12c2f6b --- /dev/null +++ b/tests/recorded/jellyfin-12.0.0/item-user-data-episode.json @@ -0,0 +1,20 @@ +{ + "capability": "item-user-data-episode", + "method": "GET", + "path": "/UserItems/00000000000040008000000000000008/UserData", + "status": 200, + "headers": { + "Content-Type": "application/json; charset=utf-8", + "Server": "Kestrel" + }, + "request_body": null, + "body": { + "PlaybackPositionTicks": 0, + "PlayCount": 1, + "IsFavorite": false, + "LastPlayedDate": "2026-09-18T11:40:05.1348942Z", + "Played": true, + "Key": "00000000-0000-4000-8000-000000000009001001", + "ItemId": "00000000000040008000000000000008" + } +} diff --git a/tests/recorded/jellyfin-12.0.0/item-user-data.json b/tests/recorded/jellyfin-12.0.0/item-user-data.json new file mode 100644 index 0000000..f42c200 --- /dev/null +++ b/tests/recorded/jellyfin-12.0.0/item-user-data.json @@ -0,0 +1,19 @@ +{ + "capability": "item-user-data", + "method": "GET", + "path": "/UserItems/00000000000040008000000000000005/UserData", + "status": 200, + "headers": { + "Content-Type": "application/json; charset=utf-8", + "Server": "Kestrel" + }, + "request_body": null, + "body": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "00000000-0000-4000-8000-000000000005", + "ItemId": "00000000000040008000000000000005" + } +} diff --git a/tests/recorded/jellyfin-12.0.0/library-query.json b/tests/recorded/jellyfin-12.0.0/library-query.json new file mode 100644 index 0000000..d8841d6 --- /dev/null +++ b/tests/recorded/jellyfin-12.0.0/library-query.json @@ -0,0 +1,168 @@ +{ + "capability": "library-query", + "method": "GET", + "path": "/UserViews", + "status": 200, + "headers": { + "Content-Type": "application/json; charset=utf-8", + "Server": "Kestrel" + }, + "request_body": null, + "body": { + "Items": [ + { + "Name": "Filme", + "ServerId": "00000000000040008000000000000000", + "Id": "00000000000040008000000000000001", + "Etag": "0000000000004000800000000000000c", + "DateCreated": "2026-09-10T15:40:09.5189089Z", + "DateLastMediaAdded": "0001-01-01T00:00:00.0000000Z", + "CanDelete": false, + "CanDownload": false, + "SortName": "filme", + "ExternalUrls": [], + "Path": "/config/root/default/Filme", + "EnableMediaSourceDisplay": true, + "ChannelId": null, + "Taglines": [], + "Genres": [], + "PlayAccess": "Full", + "RemoteTrailers": [], + "ProviderIds": {}, + "IsFolder": true, + "ParentId": "00000000000040008000000000000002", + "Type": "CollectionFolder", + "People": [], + "Studios": [], + "GenreItems": [], + "LocalTrailerCount": 0, + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "00000000-0000-4000-8000-000000000001", + "ItemId": "00000000000040008000000000000001" + }, + "ChildCount": 1, + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "00000000000040008000000000000001", + "Tags": [], + "PrimaryImageAspectRatio": 1.7777777777777777, + "CollectionType": "movies", + "ImageTags": { + "Primary": "0000000000004000800000000000000d" + }, + "BackdropImageTags": [], + "ImageBlurHashes": { + "Primary": { + "0000000000004000800000000000000d": "W755IIj[4nWB%Mj[ofj[WBWBj[j[00ay_3ofIUj[M{WBt7t7WBWB" + } + }, + "Chapters": [], + "LocationType": "FileSystem", + "MediaType": "Unknown", + "LockedFields": [], + "LockData": false + }, + { + "Name": "Musik", + "ServerId": "00000000000040008000000000000000", + "Id": "00000000000040008000000000000003", + "Etag": "0000000000004000800000000000000e", + "DateCreated": "2026-09-10T15:40:12.2792869Z", + "DateLastMediaAdded": "0001-01-01T00:00:00.0000000Z", + "CanDelete": false, + "CanDownload": false, + "SortName": "musik", + "ExternalUrls": [], + "Path": "/config/root/default/Musik", + "EnableMediaSourceDisplay": true, + "ChannelId": null, + "Taglines": [], + "Genres": [], + "PlayAccess": "Full", + "RemoteTrailers": [], + "ProviderIds": {}, + "IsFolder": true, + "ParentId": "00000000000040008000000000000002", + "Type": "CollectionFolder", + "People": [], + "Studios": [], + "GenreItems": [], + "LocalTrailerCount": 0, + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "00000000-0000-4000-8000-000000000003", + "ItemId": "00000000000040008000000000000003" + }, + "ChildCount": 5, + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "00000000000040008000000000000003", + "Tags": [], + "CollectionType": "music", + "ImageTags": {}, + "BackdropImageTags": [], + "ImageBlurHashes": {}, + "Chapters": [], + "LocationType": "FileSystem", + "MediaType": "Unknown", + "LockedFields": [], + "LockData": false + }, + { + "Name": "Serien", + "ServerId": "00000000000040008000000000000000", + "Id": "00000000000040008000000000000004", + "Etag": "0000000000004000800000000000000f", + "DateCreated": "2026-09-10T15:40:10.9372182Z", + "DateLastMediaAdded": "0001-01-01T00:00:00.0000000Z", + "CanDelete": false, + "CanDownload": false, + "SortName": "serien", + "ExternalUrls": [], + "Path": "/config/root/default/Serien", + "EnableMediaSourceDisplay": true, + "ChannelId": null, + "Taglines": [], + "Genres": [], + "PlayAccess": "Full", + "RemoteTrailers": [], + "ProviderIds": {}, + "IsFolder": true, + "ParentId": "00000000000040008000000000000002", + "Type": "CollectionFolder", + "People": [], + "Studios": [], + "GenreItems": [], + "LocalTrailerCount": 0, + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "00000000-0000-4000-8000-000000000004", + "ItemId": "00000000000040008000000000000004" + }, + "ChildCount": 2, + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "00000000000040008000000000000004", + "Tags": [], + "CollectionType": "tvshows", + "ImageTags": {}, + "BackdropImageTags": [], + "ImageBlurHashes": {}, + "Chapters": [], + "LocationType": "FileSystem", + "MediaType": "Unknown", + "LockedFields": [], + "LockData": false + } + ], + "TotalRecordCount": 3, + "StartIndex": 0 + } +} diff --git a/tests/recorded/jellyfin-12.0.0/next-up.json b/tests/recorded/jellyfin-12.0.0/next-up.json new file mode 100644 index 0000000..9fe0231 --- /dev/null +++ b/tests/recorded/jellyfin-12.0.0/next-up.json @@ -0,0 +1,54 @@ +{ + "capability": "next-up", + "method": "GET", + "path": "/Shows/NextUp?Limit=12&Fields=Path", + "status": 200, + "headers": { + "Content-Type": "application/json; charset=utf-8", + "Server": "Kestrel" + }, + "request_body": null, + "body": { + "Items": [ + { + "Name": "Synthetic Series S01E02", + "ServerId": "00000000000040008000000000000000", + "Id": "0000000000004000800000000000000a", + "Container": "mov,mp4,m4a,3gp,3g2,mj2", + "Path": "/media/serien/Synthetic Series/Season 01/Synthetic Series S01E02.mp4", + "ChannelId": null, + "RunTimeTicks": 20000000, + "IndexNumber": 2, + "ParentIndexNumber": 1, + "IsFolder": false, + "Type": "Episode", + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "00000000-0000-4000-8000-000000000009001002", + "ItemId": "0000000000004000800000000000000a" + }, + "SeriesName": "Synthetic Series", + "SeriesId": "00000000000040008000000000000009", + "SeasonId": "0000000000004000800000000000000b", + "SeasonName": "Season 1", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "0000000000004000800000000000000f" + }, + "BackdropImageTags": [], + "ImageBlurHashes": { + "Primary": { + "0000000000004000800000000000000f": "W00000fQfQfQfQfQfQfQfQfQfQfQfQfQfQfQfQfQfQfQfQfQfQfQ" + } + }, + "LocationType": "FileSystem", + "MediaType": "Video" + } + ], + "TotalRecordCount": 1, + "StartIndex": 0 + } +} diff --git a/tests/recorded/jellyfin-12.0.0/playback-progress.json b/tests/recorded/jellyfin-12.0.0/playback-progress.json new file mode 100644 index 0000000..59509e9 --- /dev/null +++ b/tests/recorded/jellyfin-12.0.0/playback-progress.json @@ -0,0 +1,19 @@ +{ + "capability": "playback-progress", + "method": "POST", + "path": "/Sessions/Playing", + "status": 204, + "headers": { + "Server": "Kestrel" + }, + "request_body": { + "ItemId": "00000000000040008000000000000005", + "PositionTicks": 0, + "PlayMethod": "DirectPlay", + "CanSeek": true, + "IsPaused": false, + "IsMuted": false + }, + "body_bytes": 0, + "body_content_type": "" +} diff --git a/tests/recorded/jellyfin-12.0.0/playback-selection.json b/tests/recorded/jellyfin-12.0.0/playback-selection.json new file mode 100644 index 0000000..9ff6135 --- /dev/null +++ b/tests/recorded/jellyfin-12.0.0/playback-selection.json @@ -0,0 +1,132 @@ +{ + "capability": "playback-selection", + "method": "POST", + "path": "/Items/00000000000040008000000000000005/PlaybackInfo", + "status": 200, + "headers": { + "Content-Type": "application/json; charset=utf-8", + "Server": "Kestrel" + }, + "request_body": { + "MaxStreamingBitrate": 20000000, + "StartTimeTicks": 0, + "AutoOpenLiveStream": true, + "DeviceProfile": { + "Name": "a synthetic device", + "MaxStreamingBitrate": 20000000, + "DirectPlayProfiles": [ + { + "Container": "mp4", + "Type": "Video", + "VideoCodec": "h264", + "AudioCodec": "aac" + } + ], + "TranscodingProfiles": [], + "CodecProfiles": [], + "SubtitleProfiles": [] + } + }, + "body": { + "MediaSources": [ + { + "Protocol": "File", + "Id": "00000000000040008000000000000005", + "Path": "/media/filme/Synthetic Movie (2024)/Synthetic Movie (2024).mp4", + "Type": "Default", + "Container": "mp4", + "Size": 3826, + "Name": "Synthetic Movie (2024)", + "IsRemote": false, + "ETag": "0000000000004000800000000000000f", + "RunTimeTicks": 20000000, + "ReadAtNativeFramerate": false, + "IgnoreDts": false, + "IgnoreIndex": false, + "GenPtsInput": false, + "SupportsTranscoding": false, + "SupportsDirectStream": true, + "SupportsDirectPlay": true, + "IsInfiniteStream": false, + "UseMostCompatibleTranscodingProfile": false, + "RequiresOpening": false, + "RequiresClosing": false, + "RequiresLooping": false, + "SupportsProbing": true, + "VideoType": "VideoFile", + "MediaStreams": [ + { + "Codec": "h264", + "Language": "und", + "TimeBase": "1/12800", + "VideoRange": "SDR", + "VideoRangeType": "SDR", + "AudioSpatialFormat": "None", + "DisplayTitle": "240p H264 SDR", + "NalLengthSize": "4", + "IsInterlaced": false, + "IsAVC": true, + "BitRate": 5348, + "BitDepth": 8, + "RefFrames": 1, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "IsOriginal": false, + "Height": 180, + "Width": 320, + "AverageFrameRate": 25, + "RealFrameRate": 25, + "ReferenceFrameRate": 25, + "Profile": "Constrained Baseline", + "Type": "Video", + "AspectRatio": "16:9", + "Index": 0, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "PixelFormat": "yuv420p", + "Level": 12, + "IsAnamorphic": false + }, + { + "Codec": "aac", + "Language": "und", + "TimeBase": "1/22050", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "LocalizedLanguage": "Undetermined", + "LocalizedOriginal": "Original", + "DisplayTitle": "AAC - Mono - Default", + "IsInterlaced": false, + "ChannelLayout": "mono", + "BitRate": 770, + "Channels": 1, + "SampleRate": 22050, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "IsOriginal": false, + "Profile": "LC", + "Type": "Audio", + "Index": 1, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false + } + ], + "MediaAttachments": [], + "Formats": [], + "Bitrate": 15304, + "RequiredHttpHeaders": {}, + "TranscodingSubProtocol": "http", + "DefaultAudioStreamIndex": 1, + "HasSegments": false + } + ], + "PlaySessionId": "00000000000040008000000000000007" + } +} diff --git a/tests/recorded/jellyfin-12.0.0/played-marking.json b/tests/recorded/jellyfin-12.0.0/played-marking.json new file mode 100644 index 0000000..34f374a --- /dev/null +++ b/tests/recorded/jellyfin-12.0.0/played-marking.json @@ -0,0 +1,20 @@ +{ + "capability": "played-marking", + "method": "POST", + "path": "/UserPlayedItems/00000000000040008000000000000008", + "status": 200, + "headers": { + "Content-Type": "application/json; charset=utf-8", + "Server": "Kestrel" + }, + "request_body": null, + "body": { + "PlaybackPositionTicks": 0, + "PlayCount": 1, + "IsFavorite": false, + "LastPlayedDate": "2026-09-18T11:40:05.1348942Z", + "Played": true, + "Key": "00000000-0000-4000-8000-000000000009001001", + "ItemId": "00000000000040008000000000000008" + } +} diff --git a/tests/recorded/jellyfin-12.0.0/quick-connect.json b/tests/recorded/jellyfin-12.0.0/quick-connect.json new file mode 100644 index 0000000..50cd447 --- /dev/null +++ b/tests/recorded/jellyfin-12.0.0/quick-connect.json @@ -0,0 +1,12 @@ +{ + "capability": "quick-connect", + "method": "GET", + "path": "/QuickConnect/Enabled", + "status": 200, + "headers": { + "Content-Type": "application/json; charset=utf-8", + "Server": "Kestrel" + }, + "request_body": null, + "body": true +} diff --git a/tests/recorded/jellyfin-12.0.0/resume-list.json b/tests/recorded/jellyfin-12.0.0/resume-list.json new file mode 100644 index 0000000..6379fdf --- /dev/null +++ b/tests/recorded/jellyfin-12.0.0/resume-list.json @@ -0,0 +1,16 @@ +{ + "capability": "resume-list", + "method": "GET", + "path": "/UserItems/Resume?Limit=12&MediaTypes=Video", + "status": 200, + "headers": { + "Content-Type": "application/json; charset=utf-8", + "Server": "Kestrel" + }, + "request_body": null, + "body": { + "Items": [], + "TotalRecordCount": 0, + "StartIndex": 0 + } +} diff --git a/tests/recorded/jellyfin-12.0.0/server-identity.json b/tests/recorded/jellyfin-12.0.0/server-identity.json new file mode 100644 index 0000000..02dc03f --- /dev/null +++ b/tests/recorded/jellyfin-12.0.0/server-identity.json @@ -0,0 +1,20 @@ +{ + "capability": "server-identity", + "method": "GET", + "path": "/System/Info/Public", + "status": 200, + "headers": { + "Content-Type": "application/json; charset=utf-8", + "Server": "Kestrel" + }, + "request_body": null, + "body": { + "LocalAddress": "https://server.invalid", + "ServerName": "23284cf55d81", + "Version": "12.0.0", + "ProductName": "Jellyfin Server", + "OperatingSystem": "", + "Id": "00000000000040008000000000000000", + "StartupWizardCompleted": true + } +} diff --git a/tests/recorded/jellyfin-12.0.0/sign-out.json b/tests/recorded/jellyfin-12.0.0/sign-out.json new file mode 100644 index 0000000..e4fa8e1 --- /dev/null +++ b/tests/recorded/jellyfin-12.0.0/sign-out.json @@ -0,0 +1,12 @@ +{ + "capability": "sign-out", + "method": "POST", + "path": "/Sessions/Logout", + "status": 204, + "headers": { + "Server": "Kestrel" + }, + "request_body": null, + "body_bytes": 0, + "body_content_type": "" +}