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": "" +}