Core: pure-Dart Jellyfin client

Auth (Quick Connect incl. the Initiate 400 fix: Content-Length + full
client header; password), server identity and link parsing, browsing
(views, resume, next up, latest, paged items, search, seasons,
episodes, next episode), images, PlaybackInfo and stream URLs,
progress reporting and user data, and a track matcher for carrying
audio/subtitle choices across episodes. Fixture tests plus env-gated
live tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-07 01:42:03 +03:00
1 parent d8d487c189
commit 4814bfb561
29 files changed
+9973

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
{
"User": {
"Name": "testuser",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "f00703a1d1ac44fe8a8a0ddf2ca05757",
"HasPassword": true,
"Policy": {"IsAdministrator": false}
},
"SessionInfo": {"Id": "0b9e1c2d3e4f5a6b7c8d9e0f1a2b3c4d", "UserId": "f00703a1d1ac44fe8a8a0ddf2ca05757", "Client": "WatchTogether", "DeviceId": "wt-test-device"},
"AccessToken": "TEST_ACCESS_TOKEN",
"ServerId": "122fce17c5b9413088af4be266bad6f4"
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+147
View File
@@ -0,0 +1,147 @@
[
{
"Name": "Monogatari",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "05eb67447682ff150ec4952fc50bbead",
"PremiereDate": "2009-07-02T23:00:00.0000000Z",
"OfficialRating": "GB-18",
"ChannelId": null,
"CommunityRating": 8.094,
"RunTimeTicks": 14400000000,
"IsFolder": true,
"Type": "Series",
"UserData": {
"UnplayedItemCount": 86,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "05eb6744-7682-ff15-0ec4-952fc50bbead",
"ItemId": "05eb67447682ff150ec4952fc50bbead"
},
"ChildCount": 30,
"Status": "Continuing",
"AirDays": [],
"PrimaryImageAspectRatio": 0.6666666666666666,
"DisplayOrder": "",
"ImageTags": {
"Primary": "bee58daaf7a98f8da69135796548201c",
"Thumb": "07435d43fff966521a761ea691ef4444",
"Logo": "02b7f5f02fa177a425225c7d1021ab57"
},
"BackdropImageTags": [
"1ee218592588355cd5c10daed8faa720"
],
"LocationType": "FileSystem",
"MediaType": "Unknown",
"EndDate": "2024-10-18T23:00:00.0000000Z"
},
{
"Name": "Urusei Yatsura",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "2f1ac9b2fb1d60bdcb3948d213683661",
"PremiereDate": "2022-10-14T00:00:00.0000000Z",
"OfficialRating": "TV-14",
"ChannelId": null,
"CommunityRating": 6.9,
"RunTimeTicks": 13800000000,
"ProductionYear": 2022,
"IsFolder": true,
"Type": "Series",
"UserData": {
"UnplayedItemCount": 46,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "2f1ac9b2-fb1d-60bd-cb39-48d213683661",
"ItemId": "2f1ac9b2fb1d60bdcb3948d213683661"
},
"ChildCount": 23,
"Status": "Ended",
"AirDays": [],
"PrimaryImageAspectRatio": 0.6666666666666666,
"ImageTags": {
"Thumb": "c41e45b912a3685ee32499997fecc72b",
"Primary": "9c117448be3ca1f61fe026395fa437d4",
"Logo": "5470a8b93b5d43af3e2046bb4dab4ced"
},
"BackdropImageTags": [
"7c71bb4b01b4efff8dedbecbf3061cb2"
],
"LocationType": "FileSystem",
"MediaType": "Unknown",
"EndDate": "2024-06-21T00:00:00.0000000Z"
},
{
"Name": "Chained Soldier",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "6665dafb65dd99ace40bf84cefdab825",
"PremiereDate": "2024-01-04T00:00:00.0000000Z",
"OfficialRating": "TV-MA",
"ChannelId": null,
"CommunityRating": 7.643,
"RunTimeTicks": 14400000000,
"ProductionYear": 2024,
"IsFolder": true,
"Type": "Series",
"UserData": {
"UnplayedItemCount": 12,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "6665dafb-65dd-99ac-e40b-f84cefdab825",
"ItemId": "6665dafb65dd99ace40bf84cefdab825"
},
"ChildCount": 12,
"Status": "Continuing",
"AirDays": [],
"PrimaryImageAspectRatio": 0.6666666666666666,
"ImageTags": {
"Primary": "f8180f9631f8358a1aaf49747ae7cbd7",
"Thumb": "e83392a654c5a6fc5d42519b7c3621ac",
"Logo": "f90535d1adef8b99c7132b13aa3d07e4"
},
"BackdropImageTags": [
"05115da7b6eecf209426de7b137ffdd2"
],
"LocationType": "FileSystem",
"MediaType": "Unknown",
"EndDate": "2024-03-21T00:00:00.0000000Z"
},
{
"Name": "So What's Wrong with Getting Reborn as a Goblin?",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "525d4771d42b2f4426d16102e1bca69e",
"PremiereDate": "2026-10-05T00:00:00.0000000Z",
"ChannelId": null,
"RunTimeTicks": 0,
"IsFolder": true,
"Type": "Series",
"UserData": {
"UnplayedItemCount": 2,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "525d4771-d42b-2f44-26d1-6102e1bca69e",
"ItemId": "525d4771d42b2f4426d16102e1bca69e"
},
"ChildCount": 2,
"Status": "Continuing",
"AirDays": [],
"PrimaryImageAspectRatio": 0.6666666666666666,
"ImageTags": {
"Primary": "280f93626b92c12d03115d7121f2be3e",
"Logo": "44cc78e2021ca513586f8d48489198e8",
"Thumb": "cd23731a47d1c2e8d3973da1b99e564d"
},
"BackdropImageTags": [
"6dbbcc9ecb1f43bbf1e621d44196b51f"
],
"LocationType": "FileSystem",
"MediaType": "Unknown",
"EndDate": "2026-10-05T00:00:00.0000000Z"
}
]
+11
View File
@@ -0,0 +1,11 @@
{
"Name": "testuser",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "f00703a1d1ac44fe8a8a0ddf2ca05757",
"HasPassword": true,
"HasConfiguredPassword": true,
"HasConfiguredEasyPassword": false,
"EnableAutoLogin": false,
"LastLoginDate": "2026-05-02T17:06:16.9449873Z",
"LastActivityDate": "2026-10-06T17:16:17.677702Z"
}
File diff suppressed because it is too large. Load diff
+106
View File
@@ -0,0 +1,106 @@
{
"Items": [
{
"Name": "Kingdom.of.Heaven",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "0f438cd4d983a9a36c3db3070464449e",
"HasSubtitles": true,
"Container": "mkv",
"ChannelId": null,
"RunTimeTicks": 86766720000,
"ProductionYear": 2005,
"IsFolder": false,
"Type": "Movie",
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "0f438cd4-d983-a9a3-6c3d-b3070464449e",
"ItemId": "0f438cd4d983a9a36c3db3070464449e"
},
"PrimaryImageAspectRatio": 1.7777777777777777,
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "6b5a5abf06bda07dd30dca56c95d3bbb"
},
"BackdropImageTags": [],
"LocationType": "FileSystem",
"MediaType": "Video"
},
{
"Name": "Dragon Ball: The Path to Power",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "38be917d9f88517f7e300f45d1408ccf",
"HasSubtitles": true,
"Container": "mkv",
"PremiereDate": "1996-03-04T00:00:00.0000000Z",
"OfficialRating": "Unrated",
"ChannelId": null,
"Overview": "A retelling of Dragon Ball's origin with a different take on the meeting of Goku, Bulma, and Kame-Sen'nin. It also retells the Red Ribbon Army story; but this time they find Goku rather than Goku finding them.",
"CommunityRating": 7.371,
"RunTimeTicks": 47779399999,
"ProductionYear": 1996,
"IsFolder": false,
"Type": "Movie",
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "38be917d-9f88-517f-7e30-0f45d1408ccf",
"ItemId": "38be917d9f88517f7e300f45d1408ccf"
},
"PrimaryImageAspectRatio": 0.7103825136612022,
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "50e9b29ee301f3822b263ae8d5f05cdd",
"Thumb": "b2f758f6bbe3a0f2c1b625434ef76746",
"Logo": "d8f0c21f570422cc5827af4cbb77443f"
},
"BackdropImageTags": [
"2a104d1fb000cb9a05869f669d72740d"
],
"LocationType": "FileSystem",
"MediaType": "Video"
},
{
"Name": "A Minecraft Movie",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "dcc7f3e1edc1a6ddd5f6115840373dd1",
"HasSubtitles": true,
"Container": "mkv",
"PremiereDate": "2025-03-31T00:00:00.0000000Z",
"OfficialRating": "PG",
"ChannelId": null,
"Overview": "Four misfits find themselves struggling with ordinary problems when they are suddenly pulled through a mysterious portal into the Overworld: a bizarre, cubic wonderland that thrives on imagination. To get back home, they'll have to master this world while embarking on a magical quest with an unexpected, expert crafter, Steve.",
"CommunityRating": 6.531,
"RunTimeTicks": 60538810000,
"ProductionYear": 2025,
"IsFolder": false,
"Type": "Movie",
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "dcc7f3e1-edc1-a6dd-d5f6-115840373dd1",
"ItemId": "dcc7f3e1edc1a6ddd5f6115840373dd1"
},
"PrimaryImageAspectRatio": 0.6666666666666666,
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "85010a7ad576af2af569bfd16cef7e84",
"Logo": "7569b768391168d3b7cacae652973101",
"Thumb": "6da12b5a347e998da21409be24b6ee7e"
},
"BackdropImageTags": [
"3e59bb10ac7eb85d83e15af8eaa0dfa2"
],
"LocationType": "FileSystem",
"MediaType": "Video"
}
],
"TotalRecordCount": 3,
"StartIndex": 0
}
+141
View File
@@ -0,0 +1,141 @@
{
"Items": [
{
"Name": "Rice and Thieves and Heroes",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "b18826be994070a79f9b52bc0f66f052",
"HasSubtitles": true,
"Container": "mkv",
"PremiereDate": "2022-08-12T23:00:00.0000000Z",
"ChannelId": null,
"Overview": "Kelvin sets off on a new journey in search of more powerful foes... and rice?!",
"CommunityRating": 7.143,
"RunTimeTicks": 14201120000,
"ProductionYear": 2022,
"IndexNumber": 6,
"ParentIndexNumber": 1,
"IsFolder": false,
"Type": "Episode",
"ParentLogoItemId": "e9aec9c14a1fcab775111844112b4cf7",
"ParentBackdropItemId": "e9aec9c14a1fcab775111844112b4cf7",
"ParentBackdropImageTags": [
"0cac7765a1364090b549bde6aa29cd58"
],
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "416588001006",
"ItemId": "b18826be994070a79f9b52bc0f66f052"
},
"SeriesName": "Black Summoner",
"SeriesId": "e9aec9c14a1fcab775111844112b4cf7",
"SeasonId": "fb0b77cfda15445f9319ed9d753dba23",
"PrimaryImageAspectRatio": 1.7777777777777777,
"SeriesPrimaryImageTag": "e62d963bb15310945fadffaef0334c35",
"SeasonName": "Season 1",
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "3caf8126c9a3e5dd0dc6f50317707808"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "fe59b8bb26fe8d676956311969a816a4",
"ParentThumbItemId": "e9aec9c14a1fcab775111844112b4cf7",
"ParentThumbImageTag": "8e40321ea0aca027e3407eb6b76c21ba",
"LocationType": "FileSystem",
"MediaType": "Video"
},
{
"Name": "The Desert-Born Outlaws",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "28465756997333b4a6aa4b0039013da8",
"HasSubtitles": true,
"Container": "mkv",
"PremiereDate": "2026-10-01T23:00:00.0000000Z",
"ChannelId": null,
"CommunityRating": 1,
"RunTimeTicks": 15120940000,
"ProductionYear": 2026,
"IndexNumber": 3,
"ParentIndexNumber": 6,
"IsFolder": false,
"Type": "Episode",
"ParentLogoItemId": "5055eea092a576ea09eee3b6527ba857",
"ParentBackdropItemId": "5055eea092a576ea09eee3b6527ba857",
"ParentBackdropImageTags": [
"a79a786b6c84dc70b1214a1955d8274a"
],
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "262954006003",
"ItemId": "28465756997333b4a6aa4b0039013da8"
},
"SeriesName": "JoJo's Bizarre Adventure",
"SeriesId": "5055eea092a576ea09eee3b6527ba857",
"SeasonId": "026b88dad1068c1478fc998c397fed12",
"PrimaryImageAspectRatio": 1.7777777777777777,
"SeriesPrimaryImageTag": "86496a70700d7e488e87443bb89132bd",
"SeasonName": "Season 6",
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "54546a638b2ae556f29bfe309da867bf"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "fcaf370687c63e4291adfa3d1525e7b0",
"ParentThumbItemId": "5055eea092a576ea09eee3b6527ba857",
"ParentThumbImageTag": "3e85d0ca4c63b5ad7399f9e8660cde37",
"LocationType": "FileSystem",
"MediaType": "Video"
},
{
"Name": "The 100 Girlfriends Who Really, Really, Really, Really, REALLY Love You - S03E08 - Kusuri-Senpai's Grandmother WEBDL",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "c03340f1656bcc4299b6f7512edb685d",
"HasSubtitles": true,
"Container": "mkv",
"PremiereDate": "1970-01-01T00:00:00.0000000Z",
"ChannelId": null,
"RunTimeTicks": 14301630000,
"ProductionYear": 1970,
"IndexNumber": 8,
"ParentIndexNumber": 3,
"IsFolder": false,
"Type": "Episode",
"ParentLogoItemId": "ef10dde161235f90e8e74a08d9e7a5d2",
"ParentBackdropItemId": "ef10dde161235f90e8e74a08d9e7a5d2",
"ParentBackdropImageTags": [
"f803fa33c65061f1db7a9a828d471cee"
],
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "432050003008",
"ItemId": "c03340f1656bcc4299b6f7512edb685d"
},
"SeriesName": "The 100 Girlfriends Who Really, Really, Really, Really, REALLY Love You",
"SeriesId": "ef10dde161235f90e8e74a08d9e7a5d2",
"SeasonId": "9ef59421412abf02c4533b389ed40495",
"PrimaryImageAspectRatio": 1.7777777777777777,
"SeriesPrimaryImageTag": "68864a1bebb104897cc86a99895e8605",
"SeasonName": "Season 3",
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "741aaebd6279f7f08c58ccfe11008ea2"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "7054282a1c940755ae402362a9c02640",
"ParentThumbItemId": "ef10dde161235f90e8e74a08d9e7a5d2",
"ParentThumbImageTag": "ef9d6c7d5ff08671067df0d3d596528e",
"LocationType": "FileSystem",
"MediaType": "Video"
}
],
"TotalRecordCount": 44,
"StartIndex": 0
}
+622
View File
@@ -0,0 +1,622 @@
{
"MediaSources": [
{
"Protocol": "File",
"Id": "b18826be994070a79f9b52bc0f66f052",
"Path": "/media/Black Summoner - S01E06 - Rice and Thieves and Heroes WEBDL-1080p.mkv",
"Type": "Default",
"Container": "mkv",
"Size": 1525319390,
"Name": "Black Summoner - S01E06 - Rice and Thieves and Heroes WEBDL-1080p",
"IsRemote": false,
"ETag": "d5e0caa01d1fda0b9da06368887e6afb",
"RunTimeTicks": 14201120000,
"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",
"ColorSpace": "bt709",
"ColorTransfer": "bt709",
"ColorPrimaries": "bt709",
"TimeBase": "1/1000",
"Title": "CR WEB-DL",
"VideoRange": "SDR",
"VideoRangeType": "SDR",
"AudioSpatialFormat": "None",
"DisplayTitle": "CR WEB-DL - 1080p - H264 - SDR",
"NalLengthSize": "4",
"IsInterlaced": false,
"IsAVC": true,
"BitRate": 8592670,
"BitDepth": 8,
"RefFrames": 1,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 1080,
"Width": 1920,
"AverageFrameRate": 23.976025,
"RealFrameRate": 23.976025,
"ReferenceFrameRate": 23.976025,
"Profile": "High",
"Type": "Video",
"AspectRatio": "16:9",
"Index": 0,
"IsExternal": false,
"IsTextSubtitleStream": false,
"SupportsExternalStream": false,
"PixelFormat": "yuv420p",
"Level": 40,
"IsAnamorphic": false
},
{
"Codec": "eac3",
"Language": "jpn",
"TimeBase": "1/1000",
"Title": "Japanese",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedDefault": "Default",
"LocalizedExternal": "External",
"DisplayTitle": "Japanese - Dolby Digital+ - Stereo - Default",
"IsInterlaced": false,
"IsAVC": false,
"ChannelLayout": "stereo",
"BitRate": 224000,
"Channels": 2,
"SampleRate": 48000,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Type": "Audio",
"Index": 1,
"IsExternal": false,
"IsTextSubtitleStream": false,
"SupportsExternalStream": false,
"Level": 0
},
{
"Codec": "eac3",
"Language": "eng",
"TimeBase": "1/1000",
"Title": "English",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedDefault": "Default",
"LocalizedExternal": "External",
"DisplayTitle": "English - Dolby Digital+ - Stereo - Default",
"IsInterlaced": false,
"IsAVC": false,
"ChannelLayout": "stereo",
"BitRate": 224000,
"Channels": 2,
"SampleRate": 48000,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Type": "Audio",
"Index": 2,
"IsExternal": false,
"IsTextSubtitleStream": false,
"SupportsExternalStream": false,
"Level": 0
},
{
"Codec": "ass",
"Language": "eng",
"TimeBase": "1/1000",
"Title": "English (Full Subtitle)",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "English (Full Subtitle) - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 3,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "eng",
"TimeBase": "1/1000",
"Title": "English (Signs) [Generated]",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "English (Signs) [Generated] - Forced - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": false,
"IsForced": true,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 4,
"Score": 121221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "subrip",
"Language": "eng",
"TimeBase": "1/1000",
"Title": "English (CC)",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "English (CC) - Hearing Impaired - SUBRIP",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": false,
"IsForced": false,
"IsHearingImpaired": true,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 5,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "ara",
"TimeBase": "1/1000",
"Title": "Arabic (Saudi Arabia)",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "Arabic (Saudi Arabia) - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 6,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "deu",
"TimeBase": "1/1000",
"Title": "German",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "German - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 7,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "spa",
"TimeBase": "1/1000",
"Title": "Spanish (Latin America)",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "Spanish (Latin America) - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 8,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "spa",
"TimeBase": "1/1000",
"Title": "Spanish (Spain)",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "Spanish (Spain) - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 9,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "fra",
"TimeBase": "1/1000",
"Title": "French",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "French - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 10,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "ind",
"TimeBase": "1/1000",
"Title": "Indonesian",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "Indonesian - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 11,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "ita",
"TimeBase": "1/1000",
"Title": "Italian",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "Italian - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 12,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "msa",
"TimeBase": "1/1000",
"Title": "Malay",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "Malay - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 13,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "por",
"TimeBase": "1/1000",
"Title": "Portuguese (Brazil)",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "Portuguese (Brazil) - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 14,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "rus",
"TimeBase": "1/1000",
"Title": "Russian",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "Russian - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 15,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "tha",
"TimeBase": "1/1000",
"Title": "Thai",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "Thai - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 16,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "vie",
"TimeBase": "1/1000",
"Title": "Vietnamese",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "Vietnamese - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 17,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "zho",
"TimeBase": "1/1000",
"Title": "Chinese (Simplified)",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "Chinese (Simplified) - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 18,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
},
{
"Codec": "ass",
"Language": "zho",
"TimeBase": "1/1000",
"Title": "Chinese (Traditional)",
"VideoRange": "Unknown",
"VideoRangeType": "Unknown",
"AudioSpatialFormat": "None",
"LocalizedUndefined": "Undefined",
"LocalizedDefault": "Default",
"LocalizedForced": "Forced",
"LocalizedExternal": "External",
"LocalizedHearingImpaired": "Hearing Impaired",
"DisplayTitle": "Chinese (Traditional) - Default - ASS",
"IsInterlaced": false,
"IsAVC": false,
"IsDefault": true,
"IsForced": false,
"IsHearingImpaired": false,
"Height": 0,
"Width": 0,
"Type": "Subtitle",
"Index": 19,
"Score": 112221,
"IsExternal": false,
"IsTextSubtitleStream": true,
"SupportsExternalStream": true,
"Level": 0
}
],
"Formats": [],
"Bitrate": 9040670,
"RequiredHttpHeaders": {},
"TranscodingSubProtocol": "http",
"DefaultAudioStreamIndex": 1,
"DefaultSubtitleStreamIndex": 3,
"HasSegments": false
}
],
"PlaySessionId": "fc428dbbeb9c46a7885fa053bd46f7e7"
}
+9
View File
@@ -0,0 +1,9 @@
{
"LocalAddress": "https://example.invalid",
"ServerName": "Example Server",
"Version": "10.11.11",
"ProductName": "Jellyfin Server",
"OperatingSystem": "",
"Id": "122fce17c5b9413088af4be266bad6f4",
"StartupWizardCompleted": true
}
@@ -0,0 +1 @@
{"Authenticated":false,"Secret":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","Code":"437392","DeviceId":"wt-test-device","DeviceName":"test","AppName":"WatchTogether","AppVersion":"0.1.0","DateAdded":"2026-10-06T22:29:05.6874903Z"}
+147
View File
@@ -0,0 +1,147 @@
{
"Items": [
{
"Name": "A Secret Shared with the Guild, and a Slave Girl",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "b79037c4a4c2707287dd4a29965220f3",
"HasSubtitles": true,
"Container": "mkv",
"PremiereDate": "2022-07-22T23:00:00.0000000Z",
"ChannelId": null,
"Overview": "After defeating the Blackghost Knight, Kelvin now has powerful servants at his command. But he needs another party member, and he has his eyes set on a certain half-elf slave...",
"CommunityRating": 7.375,
"RunTimeTicks": 14200480000,
"ProductionYear": 2022,
"IndexNumber": 3,
"ParentIndexNumber": 1,
"IsFolder": false,
"Type": "Episode",
"ParentLogoItemId": "e9aec9c14a1fcab775111844112b4cf7",
"ParentBackdropItemId": "e9aec9c14a1fcab775111844112b4cf7",
"ParentBackdropImageTags": [
"0cac7765a1364090b549bde6aa29cd58"
],
"UserData": {
"PlayedPercentage": 85.06994129775894,
"PlaybackPositionTicks": 12080340000,
"PlayCount": 2,
"IsFavorite": false,
"LastPlayedDate": "2026-10-06T12:10:32.978Z",
"Played": true,
"Key": "b79037c4-a4c2-7072-87dd-4a29965220f3",
"ItemId": "b79037c4a4c2707287dd4a29965220f3"
},
"SeriesName": "Black Summoner",
"SeriesId": "e9aec9c14a1fcab775111844112b4cf7",
"SeasonId": "fb0b77cfda15445f9319ed9d753dba23",
"PrimaryImageAspectRatio": 1.7777777777777777,
"SeriesPrimaryImageTag": "e62d963bb15310945fadffaef0334c35",
"SeasonName": "Season 1",
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "35d3062104a44d07f4d444847d2d847f"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "fe59b8bb26fe8d676956311969a816a4",
"ParentThumbItemId": "e9aec9c14a1fcab775111844112b4cf7",
"ParentThumbImageTag": "8e40321ea0aca027e3407eb6b76c21ba",
"LocationType": "FileSystem",
"MediaType": "Video"
},
{
"Name": "From Old Country Bumpkin to Master Swordsman - S01E10 - The Old Country Bumpkin Guards a Princess",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "43c0f58d4f0c13e4df01d737b695fae7",
"HasSubtitles": true,
"Container": "mkv",
"PremiereDate": "2025-06-07T00:00:00.0000000Z",
"ChannelId": null,
"Overview": "The Old Country Bumpkin Guards a Princess",
"RunTimeTicks": 14190920000,
"ProductionYear": 2025,
"IndexNumber": 10,
"ParentIndexNumber": 1,
"IsFolder": false,
"Type": "Episode",
"ParentLogoItemId": "4ec83fddae279cb643ee43fd98b46fae",
"ParentBackdropItemId": "4ec83fddae279cb643ee43fd98b46fae",
"ParentBackdropImageTags": [
"dc43552819fa7d366704ff8095c9ade3"
],
"UserData": {
"PlayedPercentage": 71.43748044524246,
"PlaybackPositionTicks": 10137635700,
"PlayCount": 2,
"IsFavorite": false,
"LastPlayedDate": "2026-08-07T07:33:27.4085253Z",
"Played": false,
"Key": "43c0f58d-4f0c-13e4-df01-d737b695fae7",
"ItemId": "43c0f58d4f0c13e4df01d737b695fae7"
},
"SeriesName": "From Old Country Bumpkin to Master Swordsman",
"SeriesId": "4ec83fddae279cb643ee43fd98b46fae",
"SeasonId": "dd6bcdb07a02ca4703cf0bbbbb8756dd",
"PrimaryImageAspectRatio": 1.7777777777777777,
"SeriesPrimaryImageTag": "706c2f150135aca34013456b52f2e831",
"SeasonName": "Season 1",
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "cd3442706567c380bd407071fabd8fe4"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "e084254c0c748aab184892550afbb359",
"ParentThumbItemId": "4ec83fddae279cb643ee43fd98b46fae",
"ParentThumbImageTag": "5fa59d330afc0100a2f98689abab3895",
"LocationType": "FileSystem",
"MediaType": "Video"
},
{
"Name": "The Old Country Bumpkin Watches Them Grow",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "3719831143416b4b4a99e1d7767863c4",
"HasSubtitles": true,
"Container": "mkv",
"PremiereDate": "2026-07-14T23:00:00.0000000Z",
"ChannelId": null,
"RunTimeTicks": 14130560000,
"ProductionYear": 2026,
"IndexNumber": 2,
"ParentIndexNumber": 2,
"IsFolder": false,
"Type": "Episode",
"ParentLogoItemId": "4ec83fddae279cb643ee43fd98b46fae",
"ParentBackdropItemId": "4ec83fddae279cb643ee43fd98b46fae",
"ParentBackdropImageTags": [
"dc43552819fa7d366704ff8095c9ade3"
],
"UserData": {
"PlayedPercentage": 75.48837837990851,
"PlaybackPositionTicks": 10666930600,
"PlayCount": 1,
"IsFavorite": false,
"LastPlayedDate": "2026-08-07T07:30:29.3856702Z",
"Played": false,
"Key": "37198311-4341-6b4b-4a99-e1d7767863c4",
"ItemId": "3719831143416b4b4a99e1d7767863c4"
},
"SeriesName": "From Old Country Bumpkin to Master Swordsman",
"SeriesId": "4ec83fddae279cb643ee43fd98b46fae",
"SeasonId": "bd5bfb3cf98a2b4459c1822c3ed05708",
"PrimaryImageAspectRatio": 1.7777777777777777,
"SeriesPrimaryImageTag": "706c2f150135aca34013456b52f2e831",
"SeasonName": "Season 2",
"VideoType": "VideoFile",
"ImageTags": {
"Primary": "ff28bd9900f55a4ebdddefcfa1fdfa94"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "e084254c0c748aab184892550afbb359",
"ParentThumbItemId": "4ec83fddae279cb643ee43fd98b46fae",
"ParentThumbImageTag": "5fa59d330afc0100a2f98689abab3895",
"LocationType": "FileSystem",
"MediaType": "Video"
}
],
"TotalRecordCount": 6,
"StartIndex": 0
}
+41
View File
@@ -0,0 +1,41 @@
{
"Items": [
{
"Name": "Arifureta: From Commonplace to World's Strongest",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "550eff23b37ec61082c988678ccc214c",
"PremiereDate": "2019-07-08T00:00:00.0000000Z",
"OfficialRating": "TV-14",
"ChannelId": null,
"CommunityRating": 7,
"RunTimeTicks": 14400000000,
"ProductionYear": 2019,
"IsFolder": true,
"Type": "Series",
"UserData": {
"UnplayedItemCount": 41,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "550eff23-b37e-c610-82c9-88678ccc214c",
"ItemId": "550eff23b37ec61082c988678ccc214c"
},
"Status": "Continuing",
"AirDays": [],
"ImageTags": {
"Thumb": "75adcf7a63337d9888fa5d994779c879",
"Primary": "dec730fd3bd5791ff7bb4eb9be416d4a",
"Logo": "8c0b809b58b8d300454e6d5aac22c334"
},
"BackdropImageTags": [
"a47f1e94c9e55509901bc1ea5c6fc743"
],
"LocationType": "FileSystem",
"MediaType": "Unknown",
"EndDate": "2025-02-17T00:00:00.0000000Z"
}
],
"TotalRecordCount": 1,
"StartIndex": 0
}
+80
View File
@@ -0,0 +1,80 @@
{
"Items": [
{
"Name": "Season 2",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "331a23499d051bc288d281f2c72557a9",
"PremiereDate": "2022-01-13T00:00:00.0000000Z",
"ChannelId": null,
"ProductionYear": 2022,
"IndexNumber": 2,
"IsFolder": true,
"Type": "Season",
"ParentLogoItemId": "550eff23b37ec61082c988678ccc214c",
"ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c",
"ParentBackdropImageTags": [
"a47f1e94c9e55509901bc1ea5c6fc743"
],
"UserData": {
"UnplayedItemCount": 12,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "357019002",
"ItemId": "331a23499d051bc288d281f2c72557a9"
},
"SeriesName": "Arifureta: From Commonplace to World's Strongest",
"SeriesId": "550eff23b37ec61082c988678ccc214c",
"SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a",
"ImageTags": {
"Primary": "a9324a83f2d95bd0e355af4d6bae8d8e"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334",
"ParentThumbItemId": "550eff23b37ec61082c988678ccc214c",
"ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879",
"LocationType": "FileSystem",
"MediaType": "Unknown"
},
{
"Name": "Season 3",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "fc4506ab4a72e877de564512df943912",
"PremiereDate": "2024-10-14T00:00:00.0000000Z",
"ChannelId": null,
"ProductionYear": 2024,
"IndexNumber": 3,
"IsFolder": true,
"Type": "Season",
"ParentLogoItemId": "550eff23b37ec61082c988678ccc214c",
"ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c",
"ParentBackdropImageTags": [
"a47f1e94c9e55509901bc1ea5c6fc743"
],
"UserData": {
"UnplayedItemCount": 29,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "357019003",
"ItemId": "fc4506ab4a72e877de564512df943912"
},
"SeriesName": "Arifureta: From Commonplace to World's Strongest",
"SeriesId": "550eff23b37ec61082c988678ccc214c",
"SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a",
"ImageTags": {
"Primary": "1aa8960ccda05eacdb423d7681959e68"
},
"BackdropImageTags": [],
"ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334",
"ParentThumbItemId": "550eff23b37ec61082c988678ccc214c",
"ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879",
"LocationType": "FileSystem",
"MediaType": "Unknown"
}
],
"TotalRecordCount": 2,
"StartIndex": 0
}
+136
View File
@@ -0,0 +1,136 @@
{
"Name": "Arifureta: From Commonplace to World's Strongest",
"OriginalTitle": "ありふれた職業で世界最強",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "550eff23b37ec61082c988678ccc214c",
"Etag": "d4a24baa76616db3b2b8b8c1e0d71abb",
"DateCreated": "2025-05-07T14:25:18.703198Z",
"DateLastMediaAdded": "2026-05-02T23:41:55.0996881Z",
"CanDelete": false,
"CanDownload": false,
"SortName": "arifureta: from commonplace to worlds strongest",
"PremiereDate": "2019-07-08T00:00:00.0000000Z",
"Path": "/media/Arifureta - From Commonplace to World's Strongest",
"EnableMediaSourceDisplay": true,
"OfficialRating": "TV-14",
"ChannelId": null,
"Overview": "Seventeen-year-old Hajime Nagumo is your average, everyday otaku. However, his simple life of pulling all-nighters and sleeping in school is suddenly turned upside down when he, along with the rest of his class, is summoned to a fantasy world! They're treated like heroes and tasked with the duty of saving the human race from utter extinction. But what should have been any otaku's wet dream quickly turns into Hajime's nightmare. While the rest of his class are blessed with godlike powers, Hajime's job, Synergist, only has a single transmutation skill. Ridiculed and bullied by his classmates for being weak, he soon finds himself in despair. Will he be able to survive in this dangerous world of monsters and demons with only a glorified blacksmith's level of strength?",
"Taglines": [],
"Genres": [
"Action",
"Adventure",
"Animation",
"Anime",
"Comedy",
"Fantasy",
"Romance"
],
"CommunityRating": 7,
"CumulativeRunTimeTicks": 14400000000,
"RunTimeTicks": 14400000000,
"PlayAccess": "Full",
"ProductionYear": 2019,
"RemoteTrailers": [
{
"Url": "https://www.youtube.com/watch?v=LHmdL6x9-kw"
}
],
"ProviderIds": {
"Imdb": "tt10431290",
"Tmdb": "86034",
"TvMaze": "42647",
"Tvdb": "357019"
},
"IsFolder": true,
"ParentId": "703778056c3d84357a1071566e9e67f0",
"Type": "Series",
"Studios": [
{
"Name": "AT-X",
"Id": "8846d500e2ea8fab0f33f774bbd3dae8"
}
],
"GenreItems": [
{
"Name": "Action",
"Id": "ce06903d834d2c3417e0889dd4049f3b"
},
{
"Name": "Adventure",
"Id": "51cec9645b896084d12b259acd05ccb1"
},
{
"Name": "Animation",
"Id": "074229225b7254292f131f9f49f8dcd6"
},
{
"Name": "Anime",
"Id": "f89b4d4d7733020ed2721d8fec37f26c"
},
{
"Name": "Comedy",
"Id": "08d31605d366d63a7a924f944b4417f1"
},
{
"Name": "Fantasy",
"Id": "a30dcc65be22eb3c21c03f7c1c7a57d1"
},
{
"Name": "Romance",
"Id": "1ffc72e19987e5fa4047c6a6870646cf"
}
],
"LocalTrailerCount": 0,
"UserData": {
"PlayedPercentage": 0,
"UnplayedItemCount": 41,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "550eff23-b37e-c610-82c9-88678ccc214c",
"ItemId": "550eff23b37ec61082c988678ccc214c"
},
"RecursiveItemCount": 41,
"ChildCount": 2,
"SpecialFeatureCount": 0,
"DisplayPreferencesId": "f63033ff6886ecc7083a696cbeced1b0",
"Status": "Continuing",
"AirDays": [],
"Tags": [
"vampire",
"high school",
"harem",
"eye patch",
"adventurer",
"anime",
"sword and sorcery",
"japanese high school",
"isekai",
"human vampire relationship",
"guns",
"vampiress (female vampire)",
"based on light novel",
"adventure",
"classmates relationship",
"teen fantasy",
"transported to another world",
"marie antoinette syndrome",
"progression fantasy"
],
"PrimaryImageAspectRatio": 0.68,
"ImageTags": {
"Banner": "65c09ab8820443a6f4e3901761afe309",
"Thumb": "75adcf7a63337d9888fa5d994779c879",
"Primary": "dec730fd3bd5791ff7bb4eb9be416d4a",
"Logo": "8c0b809b58b8d300454e6d5aac22c334"
},
"BackdropImageTags": [
"a47f1e94c9e55509901bc1ea5c6fc743"
],
"LocationType": "FileSystem",
"MediaType": "Unknown",
"EndDate": "2025-02-17T00:00:00.0000000Z",
"LockedFields": [],
"LockData": false
}
+178
View File
@@ -0,0 +1,178 @@
{
"Items": [
{
"Name": "The \"Hentai\" Prince and the Stony Cat.",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "641983c75927d5537627e94c17b3e64b",
"PremiereDate": "2013-04-13T00:00:00.0000000Z",
"OfficialRating": "16+",
"ChannelId": null,
"Overview": "The story centers around a second-year high school boy named Yokodera Youto. Youto is always thinking about his carnal desires, but no one acknowledges him as a pervert. He learns about a cat statue that supposedly grants wishes. The boy goes to pray that he will be able to express his lustful thoughts whenever and wherever he wants. At the statue, Youto encounters Tsutsukakushi Tsukiko, a girl from his high school with her own wish - that she would not display her real intentions so readily.",
"CommunityRating": 6.7,
"RunTimeTicks": 14400000000,
"ProductionYear": 2013,
"IsFolder": true,
"Type": "Series",
"UserData": {
"UnplayedItemCount": 12,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "641983c7-5927-d553-7627-e94c17b3e64b",
"ItemId": "641983c75927d5537627e94c17b3e64b"
},
"Status": "Ended",
"AirDays": [],
"PrimaryImageAspectRatio": 0.68,
"ImageTags": {
"Logo": "f80eeb7006d8c06ca6f6c2aa7ac7d4f7",
"Primary": "ab8565151c2041121d6d029dd887b4bc",
"Thumb": "5d8cd55d4fcd3d4e30c2a14b8a2b0b01"
},
"BackdropImageTags": [
"936ef50544a0ec3c90cd0b87933380ab"
],
"LocationType": "FileSystem",
"MediaType": "Unknown",
"EndDate": "2013-06-29T00:00:00.0000000Z"
},
{
"Name": "The 100 Girlfriends Who Really, Really, Really, Really, REALLY Love You",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "ef10dde161235f90e8e74a08d9e7a5d2",
"PremiereDate": "2023-10-07T23:00:00.0000000Z",
"OfficialRating": "TV-14",
"ChannelId": null,
"Overview": "Rentaro Aijo was rejected 100 times in middle school. He visits a shrine and prays for better luck in high school. The God of Love appears and promises that he'll soon meet 100 people he's destined to date. But there's a catch—once destiny introduces someone to him, the two must happily love each other. If they don't, they'll die. What will befall Rentaro and his 100 girlfriends in high school?",
"CommunityRating": 8.191,
"RunTimeTicks": 13800000000,
"IsFolder": true,
"Type": "Series",
"UserData": {
"UnplayedItemCount": 5,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "432050",
"ItemId": "ef10dde161235f90e8e74a08d9e7a5d2"
},
"Status": "Continuing",
"AirDays": [],
"PrimaryImageAspectRatio": 0.6666666666666666,
"ImageTags": {
"Logo": "7054282a1c940755ae402362a9c02640",
"Primary": "68864a1bebb104897cc86a99895e8605",
"Thumb": "ef9d6c7d5ff08671067df0d3d596528e"
},
"BackdropImageTags": [
"f803fa33c65061f1db7a9a828d471cee"
],
"LocationType": "FileSystem",
"MediaType": "Unknown",
"EndDate": "2026-07-04T23:00:00.0000000Z"
},
{
"Name": "An Adventurer's Daily Grind at Age 29",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "d8f4c16028dfbe5fceea58fbca1e5dde",
"PremiereDate": "2026-01-07T00:00:00.0000000Z",
"ChannelId": null,
"Overview": "Born and raised in the slums, 29-year-old adventurer Hajime Shinonome now lives a quiet life in Komai Village. But his peaceful days take a turn when he discovers Lirui, an abandoned young girl, being attacked by a slime. Moved by her desperation and reminded of his own past, he takes her back to the village. Hajime and Lirui begin a new chapter filled with unexpected challenges and gentle days!",
"RunTimeTicks": 0,
"IsFolder": true,
"Type": "Series",
"UserData": {
"UnplayedItemCount": 1,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "d8f4c160-28df-be5f-ceea-58fbca1e5dde",
"ItemId": "d8f4c16028dfbe5fceea58fbca1e5dde"
},
"Status": "Continuing",
"AirDays": [],
"PrimaryImageAspectRatio": 0.6666666666666666,
"ImageTags": {
"Thumb": "c719cde4ea4edfe7c6af878176b97893",
"Primary": "e9232151950ab8b77b1ad75a146b8ad1",
"Logo": "4ba4c8e585bf1d69258c69275dc9dace"
},
"BackdropImageTags": [
"a1fbebbc6001c31b5d31d1618bc4e02f"
],
"LocationType": "FileSystem",
"MediaType": "Unknown"
},
{
"Name": "All Purpose Cultural Cat Girl Nuku Nuku",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "5dc14d44923d13244827cb8dea8b021b",
"PremiereDate": "1992-10-20T00:00:00.0000000Z",
"OfficialRating": "TV-14",
"ChannelId": null,
"Overview": "A scientist, his son and an incomplete biotech-type android are sneaking away from the scientist's psychotic wife.",
"CommunityRating": 6.9,
"RunTimeTicks": 16800000000,
"ProductionYear": 1992,
"IsFolder": true,
"Type": "Series",
"UserData": {
"UnplayedItemCount": 6,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "5dc14d44-923d-1324-4827-cb8dea8b021b",
"ItemId": "5dc14d44923d13244827cb8dea8b021b"
},
"Status": "Ended",
"AirDays": [],
"PrimaryImageAspectRatio": 0.68,
"ImageTags": {
"Logo": "a30fabfe01b6d52bf8517a3f587acc3d",
"Primary": "10bf60033861fbb49f3d8b9114e0574c"
},
"BackdropImageTags": [
"31617fcc4d1ac4d07bafaca9aa78bf4a"
],
"LocationType": "FileSystem",
"MediaType": "Unknown",
"EndDate": "1998-12-09T00:00:00.0000000Z"
},
{
"Name": "Alma-chan Wants to Be a Family!",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "9650f99aff2a6f8c0760f1960e6f3e2e",
"PremiereDate": "2025-10-05T00:00:00.0000000Z",
"ChannelId": null,
"Overview": "A body that combines the appearance of an adorable young girl with overwhelming combat efficiency. An AI with intelligence and decision-making skills that surpass human capabilities—the perfect autonomous robot, \"Alma\" was created by the overlooked young genius scientists Enji and Suzume whose brilliance should've been recognized by the world! Alma calls Enji and Suzume \"Father\" and \"Mother\" which leaves them completely flustered. Regardless, the three begin a life together as a family in order to nurture Alma's powerful AI and help her evolve into the ultimate robot. Alma's curiosity has no limits as everything she sees and touches is new to her. Alma keeps Enji and Suzume on their toes with her unmatched learning capabilities and unstoppable energy as she explores the bigger world. A brand-new kind of family comedy (?!) begins!!",
"RunTimeTicks": 0,
"IsFolder": true,
"Type": "Series",
"UserData": {
"UnplayedItemCount": 9,
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "9650f99a-ff2a-6f8c-0760-f1960e6f3e2e",
"ItemId": "9650f99aff2a6f8c0760f1960e6f3e2e"
},
"Status": "Continuing",
"AirDays": [],
"PrimaryImageAspectRatio": 0.6666666666666666,
"ImageTags": {
"Logo": "a82211f52d8ba53e372c2d2b1ee4f875",
"Primary": "131e571c94242ad5988b6720fb6bfb73"
},
"BackdropImageTags": [],
"LocationType": "FileSystem",
"MediaType": "Unknown"
}
],
"TotalRecordCount": 218,
"StartIndex": 0
}
+147
View File
@@ -0,0 +1,147 @@
{
"Items": [
{
"Name": "Home Videos and Photos",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "ee75511ae395034b1e7e657707b15125",
"Etag": "dfde42d71f54840418f9dc0dbdeba7f7",
"DateCreated": "2026-07-29T11:24:09.6344657Z",
"DateLastMediaAdded": "0001-01-01T00:00:00.0000000Z",
"CanDelete": false,
"CanDownload": false,
"SortName": "home videos and photos",
"Path": "/media/Home Videos and Photos",
"EnableMediaSourceDisplay": true,
"ChannelId": null,
"Taglines": [],
"Genres": [],
"PlayAccess": "Full",
"RemoteTrailers": [],
"ProviderIds": {},
"IsFolder": true,
"ParentId": "e9d5075a555c1cbc394eec4cef295274",
"Type": "CollectionFolder",
"Studios": [],
"GenreItems": [],
"LocalTrailerCount": 0,
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "ee75511a-e395-034b-1e7e-657707b15125",
"ItemId": "ee75511ae395034b1e7e657707b15125"
},
"ChildCount": 5,
"SpecialFeatureCount": 0,
"DisplayPreferencesId": "ee75511ae395034b1e7e657707b15125",
"Tags": [],
"PrimaryImageAspectRatio": 1.7777777777777777,
"CollectionType": "homevideos",
"ImageTags": {
"Primary": "5362f3bd0bdcc65ba270901419b25f5a"
},
"BackdropImageTags": [],
"LocationType": "FileSystem",
"MediaType": "Unknown",
"LockedFields": [],
"LockData": false
},
{
"Name": "Movies",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "f137a2dd21bbc1b99aa5c0f6bf02a805",
"Etag": "0caf9bb08808c2d953883faf04ad1f24",
"DateCreated": "2025-05-25T14:55:08.1257922Z",
"DateLastMediaAdded": "0001-01-01T00:00:00.0000000Z",
"CanDelete": false,
"CanDownload": false,
"SortName": "movies",
"Path": "/media/Movies",
"EnableMediaSourceDisplay": true,
"ChannelId": null,
"Taglines": [],
"Genres": [],
"PlayAccess": "Full",
"RemoteTrailers": [],
"ProviderIds": {},
"IsFolder": true,
"ParentId": "e9d5075a555c1cbc394eec4cef295274",
"Type": "CollectionFolder",
"Studios": [],
"GenreItems": [],
"LocalTrailerCount": 0,
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "f137a2dd-21bb-c1b9-9aa5-c0f6bf02a805",
"ItemId": "f137a2dd21bbc1b99aa5c0f6bf02a805"
},
"ChildCount": 8,
"SpecialFeatureCount": 0,
"DisplayPreferencesId": "f137a2dd21bbc1b99aa5c0f6bf02a805",
"Tags": [],
"PrimaryImageAspectRatio": 1.7777777777777777,
"CollectionType": "movies",
"ImageTags": {
"Primary": "79616b543c8fab3f8a13d958ab093aad"
},
"BackdropImageTags": [],
"LocationType": "FileSystem",
"MediaType": "Unknown",
"LockedFields": [],
"LockData": false
},
{
"Name": "Shows",
"ServerId": "122fce17c5b9413088af4be266bad6f4",
"Id": "a656b907eb3a73532e40e44b968d0225",
"Etag": "e68ec49fd39c5d0e50d5f07bf0d5c239",
"DateCreated": "2025-05-25T14:54:44.9810996Z",
"DateLastMediaAdded": "0001-01-01T00:00:00.0000000Z",
"CanDelete": false,
"CanDownload": false,
"SortName": "shows",
"Path": "/media/Shows",
"EnableMediaSourceDisplay": true,
"ChannelId": null,
"Taglines": [],
"Genres": [],
"PlayAccess": "Full",
"RemoteTrailers": [],
"ProviderIds": {},
"IsFolder": true,
"ParentId": "e9d5075a555c1cbc394eec4cef295274",
"Type": "CollectionFolder",
"Studios": [],
"GenreItems": [],
"LocalTrailerCount": 0,
"UserData": {
"PlaybackPositionTicks": 0,
"PlayCount": 0,
"IsFavorite": false,
"Played": false,
"Key": "a656b907-eb3a-7353-2e40-e44b968d0225",
"ItemId": "a656b907eb3a73532e40e44b968d0225"
},
"ChildCount": 1,
"SpecialFeatureCount": 0,
"DisplayPreferencesId": "a656b907eb3a73532e40e44b968d0225",
"Tags": [],
"PrimaryImageAspectRatio": 1.7777777777777777,
"CollectionType": "tvshows",
"ImageTags": {
"Primary": "b9a66a993f6c977d989d8daf213c6a59"
},
"BackdropImageTags": [],
"LocationType": "FileSystem",
"MediaType": "Unknown",
"LockedFields": [],
"LockData": false
}
],
"TotalRecordCount": 3,
"StartIndex": 0
}
@@ -0,0 +1,373 @@
import 'dart:convert';
import 'dart:io';
import 'package:sync_relay_core/core.dart';
import 'package:test/test.dart';
/// Records requests and answers from captured fixtures (test/fixtures/jellyfin,
/// captured from a Jellyfin 10.11.11 server, personal data stripped).
class FakeHttp implements JellyfinHttp {
final requests = <({String method, Uri uri, Map<String, String> headers, String? body})>[];
final routes = <String, JellyfinHttpResponse>{};
void on(String method, String path, {String? fixture, String body = '', int status = 200}) {
routes['$method $path'] =
JellyfinHttpResponse(status, fixture == null ? body : _fixture(fixture));
}
@override
Future<JellyfinHttpResponse> send(String method, Uri uri,
{Map<String, String> headers = const {}, String? body}) async {
requests.add((method: method, uri: uri, headers: headers, body: body));
return routes['$method ${uri.path}'] ?? const JellyfinHttpResponse(404, '');
}
@override
void close() {}
({String method, Uri uri, Map<String, String> headers, String? body}) get last => requests.last;
}
String _fixture(String name) => File('test/fixtures/jellyfin/$name.json').readAsStringSync();
const info = JellyfinClientInfo(
client: 'Watch Together', device: 'Test PC', deviceId: 'dev-1', version: '1.2.3');
const uid = 'f00703a1d1ac44fe8a8a0ddf2ca05757';
void main() {
late FakeHttp http;
late JellyfinClient client;
setUp(() {
http = FakeHttp();
client = JellyfinClient(
baseUrl: 'https://example.invalid/', clientInfo: info, accessToken: 'TOK', userId: uid, http: http);
});
group('auth header', () {
test('has all four client fields, token only when given, values encoded', () {
expect(info.authorizationHeader(),
'MediaBrowser Client="Watch%20Together", Device="Test%20PC", DeviceId="dev-1", Version="1.2.3"');
expect(info.authorizationHeader('abc'), endsWith(', Token="abc"'));
});
});
group('server + auth', () {
test('publicInfo', () async {
http.on('GET', '/System/Info/Public', fixture: 'pub');
final s = await client.publicInfo();
expect(s.id, '122fce17c5b9413088af4be266bad6f4');
expect(s.version, '10.11.11');
expect(s.name, 'Example Server');
expect(http.last.headers['Authorization'], isNot(contains('Token=')));
});
test('Quick Connect: initiate is a POST without token, then sign-in stores the token', () async {
final anon = JellyfinClient(baseUrl: 'https://example.invalid', clientInfo: info, http: http);
http.on('POST', '/QuickConnect/Initiate', fixture: 'quickconnect_initiate');
final qc = await anon.quickConnectInitiate();
expect(qc.code, '437392');
expect(qc.secret, hasLength(64));
expect(http.last.method, 'POST');
expect(http.last.headers['Authorization'], info.authorizationHeader());
var polls = 0;
http.on('GET', '/QuickConnect/Connect', body: jsonEncode({'Authenticated': false, 'Secret': qc.secret, 'Code': qc.code}));
http.on('POST', '/Users/AuthenticateWithQuickConnect', fixture: 'auth_result');
final result = await anon.waitForQuickConnect(qc, interval: Duration.zero, isCancelled: () {
if (++polls == 3) {
http.on('GET', '/QuickConnect/Connect',
body: jsonEncode({'Authenticated': true, 'Secret': qc.secret, 'Code': qc.code}));
}
return false;
});
expect(http.requests.where((r) => r.uri.path == '/QuickConnect/Connect').first.uri.queryParameters['secret'],
qc.secret);
expect(jsonDecode(http.last.body!), {'Secret': qc.secret});
expect(result!.accessToken, 'TEST_ACCESS_TOKEN');
expect(result.user.name, 'testuser');
expect(result.serverId, '122fce17c5b9413088af4be266bad6f4');
expect(anon.accessToken, 'TEST_ACCESS_TOKEN');
expect(anon.userId, uid);
});
test('Quick Connect wait can be cancelled', () async {
http.on('GET', '/QuickConnect/Connect', body: jsonEncode({'Authenticated': false}));
final r = await client.waitForQuickConnect(
const QuickConnectSession(code: '1', secret: 's'),
interval: Duration.zero,
isCancelled: () => http.requests.length >= 2);
expect(r, isNull);
});
test('password sign-in', () async {
final anon = JellyfinClient(baseUrl: 'https://example.invalid', clientInfo: info, http: http);
http.on('POST', '/Users/AuthenticateByName', fixture: 'auth_result');
await anon.authenticateByName('testuser', 'pw "x"');
expect(jsonDecode(http.last.body!), {'Username': 'testuser', 'Pw': 'pw "x"'});
expect(anon.isAuthenticated, isTrue);
});
test('validateToken: user on 200, null on 401, throws otherwise', () async {
http.on('GET', '/Users/Me', fixture: 'me');
expect((await client.validateToken())!.name, 'testuser');
expect(http.last.headers['Authorization'], contains('Token="TOK"'));
http.on('GET', '/Users/Me', status: 401);
expect(await client.validateToken(), isNull);
http.on('GET', '/Users/Me', status: 502);
expect(client.validateToken(), throwsA(isA<JellyfinException>()));
});
test('logout posts and forgets the token', () async {
http.on('POST', '/Sessions/Logout', status: 204);
await client.logout();
expect(http.last.uri.path, '/Sessions/Logout');
expect(client.accessToken, isNull);
expect(client.isAuthenticated, isFalse);
});
});
group('browsing', () {
test('user views', () async {
http.on('GET', '/UserViews', fixture: 'views');
final views = await client.userViews();
expect(views.map((v) => v.collectionType), ['homevideos', 'movies', 'tvshows']);
expect(views[2].kind, JellyfinItemKind.collectionFolder);
expect(http.last.uri.queryParameters['userId'], uid);
});
test('resume: episodes with position and parent art fallbacks', () async {
http.on('GET', '/UserItems/Resume', fixture: 'resume');
final items = await client.resume(limit: 3);
final e = items.first;
expect(http.last.uri.queryParameters, containsPair('mediaTypes', 'Video'));
expect(e.kind, JellyfinItemKind.episode);
expect(e.seriesName, 'Black Summoner');
expect(e.userData.isResumable, isTrue);
expect(e.userData.position, ticksToDuration(12080340000));
expect(e.episodeLabel, 'S1E3');
// Own primary (episode still); backdrop/logo/thumb come from the series.
expect(e.image(JellyfinImageType.primary)!.itemId, e.id);
expect(e.image(JellyfinImageType.backdrop)!.itemId, e.seriesId);
expect(e.image(JellyfinImageType.logo)!.tag, 'fe59b8bb26fe8d676956311969a816a4');
expect(e.image(JellyfinImageType.thumb)!.itemId, e.seriesId);
});
test('next up and latest', () async {
http.on('GET', '/Shows/NextUp', fixture: 'nextup');
http.on('GET', '/Items/Latest', fixture: 'latest_shows');
expect((await client.nextUp()).first.kind, JellyfinItemKind.episode);
expect(http.last.uri.queryParameters['enableResumable'], 'false');
final latest = await client.latest('a656b907eb3a73532e40e44b968d0225', limit: 4);
expect(latest, hasLength(4));
expect(latest.every((i) => i.kind == JellyfinItemKind.series), isTrue);
expect(http.last.uri.queryParameters['parentId'], 'a656b907eb3a73532e40e44b968d0225');
});
test('library page: query params and paging', () async {
http.on('GET', '/Items', fixture: 'series_page');
const q = JellyfinItemQuery(
parentId: 'a656b907eb3a73532e40e44b968d0225',
includeItemTypes: [JellyfinItemType.series],
recursive: true,
filters: [JellyfinFilter.isUnplayed],
);
final page = await client.items(q.page(0, 5));
final qp = http.last.uri.queryParameters;
expect(qp['includeItemTypes'], 'Series');
expect(qp['recursive'], 'true');
expect(qp['sortBy'], 'SortName');
expect(qp['sortOrder'], 'Ascending');
expect(qp['filters'], 'IsUnplayed');
expect(qp['startIndex'], '0');
expect(qp['limit'], '5');
expect(page.totalRecordCount, 218);
expect(page.items, hasLength(5));
expect(page.hasMore, isTrue);
final s = page.items.first;
expect(s.kind, JellyfinItemKind.series);
expect(s.userData.unplayedItemCount, 12);
expect(s.image(JellyfinImageType.backdrop)!.itemId, s.id);
});
test('search', () async {
http.on('GET', '/Items', fixture: 'search');
final r = await client.search('arifureta');
expect(http.last.uri.queryParameters['searchTerm'], 'arifureta');
expect(http.last.uri.queryParameters['includeItemTypes'], 'Movie,Series,Episode');
expect(r.single.name, startsWith('Arifureta'));
});
test('item detail: media source and streams', () async {
http.on('GET', '/Items/b18826be994070a79f9b52bc0f66f052', fixture: 'episode_detail');
final e = await client.item('b18826be994070a79f9b52bc0f66f052');
expect(e.runTime, const Duration(seconds: 1420, microseconds: 112000));
final src = e.mediaSources.single;
expect(src.supportsDirectPlay, isTrue);
expect(src.size, 1525319390);
expect(src.streamsOf(JellyfinStreamType.audio).map((s) => s.language), ['jpn', 'eng']);
expect(src.streamsOf(JellyfinStreamType.subtitle), hasLength(17));
expect(e.mediaStreams, hasLength(20));
});
test('seasons and episodes with user data', () async {
http.on('GET', '/Shows/550eff23b37ec61082c988678ccc214c/Seasons', fixture: 'seasons');
http.on('GET', '/Shows/550eff23b37ec61082c988678ccc214c/Episodes', fixture: 'episodes');
final seasons = await client.seasons('550eff23b37ec61082c988678ccc214c');
expect(seasons.map((s) => s.indexNumber), [2, 3]);
expect(seasons.first.userData.unplayedItemCount, 12);
final eps = await client.episodes('550eff23b37ec61082c988678ccc214c');
expect(http.last.uri.queryParameters['isMissing'], 'false');
expect(eps, hasLength(41));
expect(eps.every((e) => !e.userData.played && e.userData.playbackPositionTicks == 0), isTrue);
});
test('nextEpisode crosses seasons and skips other copies of the same episode', () async {
http.on('GET', '/Shows/550eff23b37ec61082c988678ccc214c/Episodes', fixture: 'episodes');
final eps = await client.episodes('550eff23b37ec61082c988678ccc214c');
final s2e12 = eps.firstWhere((e) => e.episodeLabel == 'S2E12');
final next = await client.nextEpisode(s2e12);
expect(next!.episodeLabel, 'S3E1');
expect(next.id, 'bbc737c39115b7fb0d23e45ed7b1ddb1');
// S3E1 exists twice (two files); the successor of either is S3E2.
expect(episodeAfter(eps, 'bbc737c39115b7fb0d23e45ed7b1ddb1')!.episodeLabel, 'S3E2');
expect(episodeAfter(eps, '96d4408695909a6b8a23f0a4a09ab553')!.episodeLabel, 'S3E2');
expect(episodeAfter(eps, eps.last.id), isNull);
expect(episodeAfter(eps, 'nope'), isNull);
});
});
group('images', () {
test('url with tag and maxWidth, no token', () {
final url = client.imageUrl(
const JellyfinImageRef('abc', JellyfinImageType.primary, 'tag1'), maxWidth: 300);
expect(url.toString(),
'https://example.invalid/Items/abc/Images/Primary?tag=tag1&maxWidth=300&quality=90');
final bd = client.imageUrl(
const JellyfinImageRef('abc', JellyfinImageType.backdrop, 't', index: 1));
expect(bd.path, '/Items/abc/Images/Backdrop/1');
});
test('base path is kept', () {
final c = JellyfinClient(baseUrl: 'https://example.invalid/jf/web/', clientInfo: info, http: http);
expect(c.baseUrl, 'https://example.invalid/jf');
expect(c.imageUrl(const JellyfinImageRef('a', JellyfinImageType.logo, 't')).path,
'/jf/Items/a/Images/Logo');
});
});
group('playback', () {
test('playbackInfo asks for direct play and parses the session', () async {
http.on('POST', '/Items/b18826be994070a79f9b52bc0f66f052/PlaybackInfo', fixture: 'playback_info');
final pi = await client.playbackInfo('b18826be994070a79f9b52bc0f66f052');
expect(jsonDecode(http.last.body!), {
'UserId': uid,
'EnableDirectPlay': true,
'EnableDirectStream': true,
'EnableTranscoding': false,
});
expect(pi.playSessionId, isNotEmpty);
final src = pi.primarySource!;
expect(src.id, 'b18826be994070a79f9b52bc0f66f052');
expect(src.defaultAudioStreamIndex, 1);
final eng = src.streamAt(3)!;
expect(eng.type, JellyfinStreamType.subtitle);
expect(eng.title, 'English (Full Subtitle)');
expect(eng.codec, 'ass');
expect(eng.isExternal, isFalse);
final jpn = src.streamAt(1)!;
expect((jpn.language, jpn.codec, jpn.channels, jpn.isDefault), ('jpn', 'eac3', 2, true));
// mpv ids are per type, 1-based.
expect(src.mpvTrackId(1), 1);
expect(src.mpvTrackId(2), 2);
expect(src.mpvTrackId(3), 1);
expect(src.mpvTrackId(5), 3);
expect(src.jellyfinIndexForMpvTrack(JellyfinStreamType.subtitle, 3), 5);
});
test('stream URLs: with token for mpv, without for sharing', () {
final shared = client.shareableStreamUrl('item1', mediaSourceId: 'src1');
expect(shared.toString(),
'https://example.invalid/Videos/item1/stream?static=true&mediaSourceId=src1');
expect(client.streamUrl('item1', mediaSourceId: 'src1').queryParameters,
{'static': 'true', 'mediaSourceId': 'src1', 'api_key': 'TOK'});
expect(client.streamUrl('item1').queryParameters['mediaSourceId'], 'item1');
final other = JellyfinClient(
baseUrl: 'https://example.invalid', clientInfo: info, accessToken: 'OTHER', http: http);
expect(other.authorizeUrl(client.streamUrl('item1')).queryParameters['api_key'], 'OTHER');
expect(client.authHeaders['Authorization'], contains('Token="TOK"'));
});
});
group('progress', () {
const r = JellyfinPlaybackReport(
itemId: 'i',
mediaSourceId: 'm',
playSessionId: 'p',
positionTicks: 30 * jellyfinTicksPerSecond,
audioStreamIndex: 1,
subtitleStreamIndex: 3,
);
test('playing / progress / stopped bodies', () async {
for (final p in ['/Sessions/Playing', '/Sessions/Playing/Progress', '/Sessions/Playing/Stopped']) {
http.on('POST', p, status: 204);
}
await client.reportPlaybackStart(r);
expect(http.last.uri.path, '/Sessions/Playing');
expect(jsonDecode(http.last.body!), {
'ItemId': 'i',
'MediaSourceId': 'm',
'PlaySessionId': 'p',
'PositionTicks': 300000000,
'IsPaused': false,
'CanSeek': true,
'PlayMethod': 'DirectPlay',
'AudioStreamIndex': 1,
'SubtitleStreamIndex': 3,
});
await client.reportPlaybackProgress(r.copyWith(isPaused: true, subtitleStreamIndex: -1));
expect(http.last.uri.path, '/Sessions/Playing/Progress');
final body = jsonDecode(http.last.body!) as Map;
expect((body['IsPaused'], body['SubtitleStreamIndex']), (true, -1));
await client.reportPlaybackStopped(r);
expect(jsonDecode(http.last.body!),
{'ItemId': 'i', 'MediaSourceId': 'm', 'PlaySessionId': 'p', 'PositionTicks': 300000000});
});
test('played / unplayed / resume position', () async {
const ud = '{"Played":false,"PlaybackPositionTicks":0,"PlayCount":0}';
http.on('POST', '/UserPlayedItems/x', body: '{"Played":true,"PlayCount":1}');
http.on('DELETE', '/UserPlayedItems/x', body: ud);
http.on('POST', '/UserItems/x/UserData', body: ud);
expect((await client.markPlayed('x')).played, isTrue);
expect(http.last.uri.queryParameters['userId'], uid);
expect((await client.markUnplayed('x')).played, isFalse);
expect(http.last.method, 'DELETE');
await client.setResumePosition('x', 42);
expect(jsonDecode(http.last.body!), {'PlaybackPositionTicks': 42});
await client.clearResumePosition('x');
expect(jsonDecode(http.last.body!), {'PlaybackPositionTicks': 0});
});
test('ticks conversion', () {
expect(durationToTicks(const Duration(seconds: 1)), jellyfinTicksPerSecond);
expect(ticksToDuration(14201120000), const Duration(milliseconds: 1420112));
});
});
test('errors carry the status code', () async {
http.on('GET', '/Items/zzz', status: 404, body: 'Not found');
await expectLater(
client.item('zzz'),
throwsA(isA<JellyfinException>()
.having((e) => e.statusCode, 'statusCode', 404)
.having((e) => e.isUnauthorized, 'isUnauthorized', isFalse)));
});
test('user-scoped calls need a user id', () {
final anon = JellyfinClient(baseUrl: 'https://example.invalid', clientInfo: info, http: http);
expect(() => anon.userViews(), throwsA(isA<JellyfinException>()));
expect(() => anon.streamUrl('x'), throwsA(isA<JellyfinException>()));
});
}
@@ -0,0 +1,73 @@
import 'package:sync_relay_core/core.dart';
import 'package:test/test.dart';
const id = 'b18826be994070a79f9b52bc0f66f052';
const sid = '122fce17c5b9413088af4be266bad6f4';
void main() {
group('normalizeServerUrl', () {
final cases = {
'jelly.example.com': 'https://jelly.example.com',
' https://jelly.example.com/ ': 'https://jelly.example.com',
'https://jelly.example.com/web/#/home.html': 'https://jelly.example.com',
'https://jelly.example.com/web/index.html': 'https://jelly.example.com',
'http://192.168.1.5:8096': 'http://192.168.1.5:8096',
'192.168.1.5:8096/web/': 'http://192.168.1.5:8096',
'localhost:8096': 'http://localhost:8096',
'https://example.com/jellyfin/web/#/home': 'https://example.com/jellyfin',
'https://example.com/jellyfin/': 'https://example.com/jellyfin',
'HTTPS://Jelly.Example.com': 'https://jelly.example.com',
'https://jelly.example.com/Items/$id/Download?api_key=x': 'https://jelly.example.com',
};
cases.forEach((input, want) {
test(input, () => expect(normalizeServerUrl(input), want));
});
test('rejects junk', () {
expect(normalizeServerUrl(''), isNull);
expect(normalizeServerUrl('ftp://x.com'), isNull);
});
});
group('parseJellyfinLink', () {
test('web details link with serverId', () {
expect(parseJellyfinLink('https://jelly.example.com/web/#/details?id=$id&serverId=$sid'),
const JellyfinLink(baseUrl: 'https://jelly.example.com', itemId: id, serverId: sid));
});
test('old web link (index.html#!/)', () {
final l = parseJellyfinLink('https://jelly.example.com/web/index.html#!/details?id=$id')!;
expect((l.baseUrl, l.itemId, l.serverId), ('https://jelly.example.com', id, null));
});
test('sub-path and port are kept', () {
expect(parseJellyfinLink('http://10.0.0.2:8096/jf/web/#/details?id=$id')!.baseUrl,
'http://10.0.0.2:8096/jf');
});
test('API links', () {
for (final url in [
'https://jelly.example.com/Items/$id/Download?api_key=k',
'https://jelly.example.com/Videos/$id/stream?static=true&mediaSourceId=$id',
'https://jelly.example.com/Videos/$id/stream.mkv',
'https://jelly.example.com/Items/$id/Images/Primary?tag=x',
'https://jelly.example.com/Users/f00703a1d1ac44fe8a8a0ddf2ca05757/Items/$id',
'https://jelly.example.com/Items/$id',
]) {
expect(parseJellyfinLink(url),
const JellyfinLink(baseUrl: 'https://jelly.example.com', itemId: id), reason: url);
}
});
test('dashed and upper-case GUIDs are normalised', () {
expect(parseJellyfinLink('https://j.example.com/Items/B18826BE-9940-70A7-9F9B-52BC0F66F052')!.itemId, id);
});
test('not Jellyfin', () {
expect(parseJellyfinLink('https://www.youtube.com/watch?v=abc'), isNull);
expect(parseJellyfinLink('https://jelly.example.com/web/#/home.html'), isNull);
expect(parseJellyfinLink('https://jelly.example.com/Items/notanid'), isNull);
expect(parseJellyfinLink('/local/file.mkv'), isNull);
});
});
}
+176
View File
@@ -0,0 +1,176 @@
// Live tests against a real Jellyfin server. Skipped unless configured:
//
// JELLYFIN_TEST_URL, JELLYFIN_TEST_TOKEN server and a user access token
// JELLYFIN_TEST_DEVICE, JELLYFIN_TEST_VERSION, JELLYFIN_TEST_DEVICE_ID,
// JELLYFIN_TEST_CLIENT the token's own device fields
//
// The device fields are required because Jellyfin overwrites a token's stored
// device name and app version with whatever the Authorization header says;
// borrowing another app's token with our own fields would rename that
// device on the user's account.
//
// The write test (Playing -> Progress -> Stopped, then restore) also needs
// JELLYFIN_TEST_WRITE_ITEM an episode id whose whole series is unwatched
// and refuses to run if the episode or any episode of its series has been
// played or has a resume position. Never commit these values.
library;
import 'dart:io';
import 'package:sync_relay_core/core.dart';
import 'package:test/test.dart';
void main() {
final env = Platform.environment;
final url = env['JELLYFIN_TEST_URL'];
final token = env['JELLYFIN_TEST_TOKEN'];
final device = env['JELLYFIN_TEST_DEVICE'];
final version = env['JELLYFIN_TEST_VERSION'];
final skip = (url == null || token == null || device == null || version == null)
? 'JELLYFIN_TEST_* not set'
: null;
late JellyfinClient client;
late JellyfinUser me;
setUpAll(() async {
if (skip != null) return;
client = JellyfinClient(
baseUrl: url!,
clientInfo: JellyfinClientInfo(
client: env['JELLYFIN_TEST_CLIENT'] ?? 'Watch Together',
device: device!,
deviceId: env['JELLYFIN_TEST_DEVICE_ID'] ?? 'wt-live-test',
version: version!,
),
accessToken: token,
);
me = (await client.validateToken())!;
});
tearDownAll(() {
if (skip == null) client.close();
});
test('server info and token', () async {
final info = await client.publicInfo();
expect(info.id, isNotEmpty);
expect(info.version, isNotEmpty);
expect(me.id, client.userId);
}, skip: skip);
test('Quick Connect initiate works (no sign-in completed)', () async {
final anon = JellyfinClient(
baseUrl: url!,
clientInfo: const JellyfinClientInfo(
client: 'Watch Together', device: 'live test', deviceId: 'wt-live-test-qc', version: '0.0.0'),
);
addTearDown(anon.close);
if (!await anon.quickConnectEnabled()) return markTestSkipped('Quick Connect disabled');
final qc = await anon.quickConnectInitiate();
expect(qc.code, matches(RegExp(r'^\d{6}$')));
expect(qc.secret, isNotEmpty);
final state = await anon.quickConnectCheck(qc.secret);
expect(state.authenticated, isFalse);
}, skip: skip);
test('browse: views, resume, next up, latest, library page, search', () async {
final views = await client.userViews();
expect(views, isNotEmpty);
await client.resume(limit: 5);
await client.nextUp(limit: 5);
final tv = views.where((v) => v.collectionType == 'tvshows').firstOrNull;
if (tv == null) return;
expect(await client.latest(tv.id, limit: 3), isNotEmpty);
final page = await client.items(JellyfinItemQuery(
parentId: tv.id, includeItemTypes: const [JellyfinItemType.series], recursive: true, limit: 3));
expect(page.items, hasLength(3));
expect(page.totalRecordCount, greaterThanOrEqualTo(3));
final second = await client.items(JellyfinItemQuery(
parentId: tv.id, includeItemTypes: const [JellyfinItemType.series], recursive: true)
.page(3, 3));
expect(second.items.first.id, isNot(page.items.first.id));
final hits = await client.search(page.items.first.name.split(' ').first);
expect(hits, isNotEmpty);
}, skip: skip);
test('series -> seasons -> episodes -> next, playback info, ranged stream', () async {
final views = await client.userViews();
final tv = views.firstWhere((v) => v.collectionType == 'tvshows');
final series = (await client.items(JellyfinItemQuery(
parentId: tv.id, includeItemTypes: const [JellyfinItemType.series], recursive: true, limit: 1)))
.items
.single;
expect(await client.seasons(series.id), isNotEmpty);
final eps = await client.episodes(series.id, withMediaStreams: true);
expect(eps, isNotEmpty);
if (eps.length > 1) expect((await client.nextEpisode(eps.first))!.id, isNot(eps.first.id));
final detail = await client.item(eps.first.id);
expect(detail.mediaSources, isNotEmpty);
final pi = await client.playbackInfo(eps.first.id);
expect(pi.playSessionId, isNotEmpty);
final src = pi.primarySource!;
expect(src.supportsDirectPlay, isTrue);
final http = HttpClient();
addTearDown(() => http.close(force: true));
final req = await http.getUrl(client.streamUrl(eps.first.id, mediaSourceId: src.id));
req.headers.set(HttpHeaders.rangeHeader, 'bytes=0-1023');
final res = await req.close();
await res.drain<void>();
expect(res.statusCode, 206);
expect(res.headers.value(HttpHeaders.contentRangeHeader), 'bytes 0-1023/${src.size}');
}, skip: skip);
final writeItem = env['JELLYFIN_TEST_WRITE_ITEM'];
test('progress reporting changes user data, then restore', () async {
final ep = await client.item(writeItem!);
final siblings = await client.episodes(ep.seriesId!);
final touched = siblings.where(
(e) => e.userData.played || e.userData.playbackPositionTicks > 0 || e.userData.playCount > 0);
if (touched.isNotEmpty || ep.userData.played || ep.userData.playbackPositionTicks > 0) {
fail('refusing: series ${ep.seriesName} has watched episodes (${touched.map((e) => e.id)})');
}
var restored = false;
Future<JellyfinUserData> restore() async {
await client.markUnplayed(ep.id);
await client.clearResumePosition(ep.id);
restored = true;
return client.userData(ep.id);
}
addTearDown(() async {
if (!restored) await restore();
});
final pi = await client.playbackInfo(ep.id);
final src = pi.primarySource!;
final pos = (ep.runTimeTicks! * 0.3).round();
final report = JellyfinPlaybackReport(
itemId: ep.id,
mediaSourceId: src.id,
playSessionId: pi.playSessionId,
audioStreamIndex: src.defaultAudioStreamIndex,
subtitleStreamIndex: src.defaultSubtitleStreamIndex ?? -1,
);
await client.reportPlaybackStart(report);
await client.reportPlaybackProgress(report.copyWith(positionTicks: pos));
await client.reportPlaybackProgress(report.copyWith(positionTicks: pos, isPaused: true));
await client.reportPlaybackStopped(report.copyWith(positionTicks: pos));
final after = await client.userData(ep.id);
printOnFailure('after: played=${after.played} pos=${after.playbackPositionTicks}');
expect(after.played, isFalse);
expect(after.playbackPositionTicks, closeTo(pos, jellyfinTicksPerSecond));
final back = await restore();
expect(back.played, isFalse);
expect(back.playbackPositionTicks, 0);
expect(back.playCount, 0);
print('live write test on ${ep.seriesName} ${ep.episodeLabel} (${ep.id}): '
'after=${after.playbackPositionTicks} restored pos=${back.playbackPositionTicks} '
'played=${back.played} playCount=${back.playCount} lastPlayed=${back.lastPlayedDate}');
}, skip: skip ?? (writeItem == null ? 'JELLYFIN_TEST_WRITE_ITEM not set' : null));
}
@@ -0,0 +1,131 @@
import 'dart:convert';
import 'dart:io';
import 'package:sync_relay_core/core.dart';
import 'package:test/test.dart';
JellyfinMediaStream audio(int i, String? lang, String? title, String codec, int ch, {bool def = false}) =>
JellyfinMediaStream(
index: i, type: JellyfinStreamType.audio, language: lang, title: title, codec: codec, channels: ch, isDefault: def);
JellyfinMediaStream sub(int i, String? lang, String? title, String codec,
{bool forced = false, bool def = false, bool hi = false}) =>
JellyfinMediaStream(
index: i,
type: JellyfinStreamType.subtitle,
language: lang,
title: title,
codec: codec,
isForced: forced,
isDefault: def,
isHearingImpaired: hi);
void main() {
// Black Summoner S1E6 -> S1E7 (captured): jpn+eng eac3 audio, 17 ass/srt subs.
final eps = (jsonDecode(File('test/fixtures/jellyfin/bs_episodes.json').readAsStringSync())['Items'] as List)
.map((e) => JellyfinItem.fromJson(e as Map<String, dynamic>))
.toList();
final e6 = eps.firstWhere((e) => e.indexNumber == 6).mediaStreams;
final e7 = eps.firstWhere((e) => e.indexNumber == 7).mediaStreams;
JellyfinMediaStream at(List<JellyfinMediaStream> s, int i) => s.firstWhere((x) => x.index == i);
group('captured episodes', () {
test('each track carries over to the same track', () {
for (final s in e6.where((s) => s.type != JellyfinStreamType.video)) {
expect(matchTrack(TrackPreference.fromStream(s), e7)!.index, s.index, reason: '$s');
}
});
test('jpn audio + Signs subs', () {
final r = matchTracks(audio: at(e6, 1), subtitle: at(e6, 4), streams: e7);
expect((r.audioStreamIndex, r.subtitleStreamIndex), (1, 4));
});
test('subtitles off carries over as -1', () {
final r = matchTracks(audio: at(e6, 2), subtitlesOff: true, streams: e7);
expect((r.audioStreamIndex, r.subtitleStreamIndex), (2, -1));
});
test('nothing picked: no override', () {
final r = matchTracks(streams: e7);
expect((r.audioStreamIndex, r.subtitleStreamIndex), (null, null));
});
});
group('different release next episode', () {
// Next episode from another group: indices shift, titles differ.
final next = [
const JellyfinMediaStream(index: 0, type: JellyfinStreamType.video, codec: 'hevc'),
audio(1, 'ja', null, 'aac', 2, def: true),
audio(2, 'en', 'English 5.1', 'eac3', 6),
audio(3, 'en', 'English Stereo', 'aac', 2),
sub(4, 'eng', 'Signs & Songs', 'ass', forced: true),
sub(5, 'eng', 'Full', 'ass', def: true),
sub(6, 'eng', 'Full [SDH]', 'subrip', hi: true),
sub(7, 'ger', 'Vollständig', 'ass'),
sub(8, 'spa', 'Español (España)', 'ass'),
sub(9, 'spa', 'Español (Latinoamérica)', 'ass'),
];
test('Full subtitles -> "Full", not "Signs & Songs"', () {
expect(matchTrack(TrackPreference.fromStream(at(e6, 3)), next)!.index, 5);
});
test('Signs -> "Signs & Songs"', () {
expect(matchTrack(TrackPreference.fromStream(at(e6, 4)), next)!.index, 4);
});
test('CC (hearing impaired, subrip) -> SDH when titles do not help', () {
expect(matchTrack(TrackPreference.fromStream(at(e6, 5)), next)!.index, 6);
});
test('language is required, 639-1 / 639-2 / B codes equated', () {
expect(matchTrack(TrackPreference.fromStream(at(e6, 1)), next)!.index, 1); // jpn -> ja
expect(matchTrack(TrackPreference.fromStream(at(e6, 7)), next)!.index, 7); // deu -> ger
expect(matchTrack(TrackPreference.fromStream(at(e6, 10)), next), isNull); // fra: none
});
test('title picks the regional variant', () {
expect(
matchTrack(const TrackPreference(type: JellyfinStreamType.subtitle, language: 'spa', title: 'Spanish (Latin America) Latinoamérica'), next)!
.index,
9);
});
test('codec then channels break ties', () {
// "English" eac3 stereo: title comes first, "English Stereo" is closer
// than "English 5.1" even though the codec differs.
expect(matchTrack(TrackPreference.fromStream(at(e6, 2)), next)!.index, 3);
// Same titles: codec beats channel count.
final same = [audio(1, 'eng', 'English', 'aac', 2), audio(2, 'eng', 'English', 'eac3', 6)];
expect(matchTrack(TrackPreference.fromStream(at(e6, 2)), same)!.index, 2);
// No codec match: closest channel count.
expect(
matchTrack(const TrackPreference(type: JellyfinStreamType.audio, language: 'eng', codec: 'opus', channels: 2), next)!
.index,
3);
expect(
matchTrack(const TrackPreference(type: JellyfinStreamType.audio, language: 'eng', codec: 'opus', channels: 8), next)!
.index,
2);
});
test('type is respected', () {
expect(matchTrack(const TrackPreference(type: JellyfinStreamType.audio, language: 'spa'), next), isNull);
});
});
test('normalizeLanguage', () {
expect(normalizeLanguage('en'), 'eng');
expect(normalizeLanguage('en-US'), 'eng');
expect(normalizeLanguage('FRE'), 'fra');
expect(normalizeLanguage('und'), isNull);
expect(normalizeLanguage(''), isNull);
expect(normalizeLanguage('tlh'), 'tlh');
});
test('undetermined language only matches undetermined', () {
final streams = [audio(1, null, null, 'aac', 2), audio(2, 'eng', null, 'aac', 2)];
expect(matchTrack(const TrackPreference(type: JellyfinStreamType.audio, language: 'und'), streams)!.index, 1);
});
}