From 4814bfb5616882e2a400a32469d98a7a0c6da511 Mon Sep 17 00:00:00 2001 From: foxyz Date: Wed, 7 Oct 2026 01:42:03 +0300 Subject: [PATCH] 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 --- packages/core/lib/core.dart | 6 + packages/core/lib/src/jellyfin/client.dart | 596 ++++++ .../core/lib/src/jellyfin/client_info.dart | 38 + packages/core/lib/src/jellyfin/http.dart | 79 + packages/core/lib/src/jellyfin/links.dart | 132 ++ packages/core/lib/src/jellyfin/models.dart | 557 +++++ .../core/lib/src/jellyfin/track_match.dart | 172 ++ .../test/fixtures/jellyfin/auth_result.json | 12 + .../test/fixtures/jellyfin/bs_episodes.json | 1230 +++++++++++ .../fixtures/jellyfin/episode_detail.json | 1278 +++++++++++ .../core/test/fixtures/jellyfin/episodes.json | 1880 +++++++++++++++++ .../test/fixtures/jellyfin/latest_shows.json | 147 ++ packages/core/test/fixtures/jellyfin/me.json | 11 + .../test/fixtures/jellyfin/movie_detail.json | 1474 +++++++++++++ .../test/fixtures/jellyfin/movies_page.json | 106 + .../core/test/fixtures/jellyfin/nextup.json | 141 ++ .../test/fixtures/jellyfin/playback_info.json | 622 ++++++ packages/core/test/fixtures/jellyfin/pub.json | 9 + .../jellyfin/quickconnect_initiate.json | 1 + .../core/test/fixtures/jellyfin/resume.json | 147 ++ .../core/test/fixtures/jellyfin/search.json | 41 + .../core/test/fixtures/jellyfin/seasons.json | 80 + .../test/fixtures/jellyfin/series_detail.json | 136 ++ .../test/fixtures/jellyfin/series_page.json | 178 ++ .../core/test/fixtures/jellyfin/views.json | 147 ++ packages/core/test/jellyfin_client_test.dart | 373 ++++ packages/core/test/jellyfin_links_test.dart | 73 + packages/core/test/jellyfin_live_test.dart | 176 ++ .../core/test/jellyfin_track_match_test.dart | 131 ++ 29 files changed, 9973 insertions(+) create mode 100644 packages/core/lib/src/jellyfin/client.dart create mode 100644 packages/core/lib/src/jellyfin/client_info.dart create mode 100644 packages/core/lib/src/jellyfin/http.dart create mode 100644 packages/core/lib/src/jellyfin/links.dart create mode 100644 packages/core/lib/src/jellyfin/models.dart create mode 100644 packages/core/lib/src/jellyfin/track_match.dart create mode 100644 packages/core/test/fixtures/jellyfin/auth_result.json create mode 100644 packages/core/test/fixtures/jellyfin/bs_episodes.json create mode 100644 packages/core/test/fixtures/jellyfin/episode_detail.json create mode 100644 packages/core/test/fixtures/jellyfin/episodes.json create mode 100644 packages/core/test/fixtures/jellyfin/latest_shows.json create mode 100644 packages/core/test/fixtures/jellyfin/me.json create mode 100644 packages/core/test/fixtures/jellyfin/movie_detail.json create mode 100644 packages/core/test/fixtures/jellyfin/movies_page.json create mode 100644 packages/core/test/fixtures/jellyfin/nextup.json create mode 100644 packages/core/test/fixtures/jellyfin/playback_info.json create mode 100644 packages/core/test/fixtures/jellyfin/pub.json create mode 100644 packages/core/test/fixtures/jellyfin/quickconnect_initiate.json create mode 100644 packages/core/test/fixtures/jellyfin/resume.json create mode 100644 packages/core/test/fixtures/jellyfin/search.json create mode 100644 packages/core/test/fixtures/jellyfin/seasons.json create mode 100644 packages/core/test/fixtures/jellyfin/series_detail.json create mode 100644 packages/core/test/fixtures/jellyfin/series_page.json create mode 100644 packages/core/test/fixtures/jellyfin/views.json create mode 100644 packages/core/test/jellyfin_client_test.dart create mode 100644 packages/core/test/jellyfin_links_test.dart create mode 100644 packages/core/test/jellyfin_live_test.dart create mode 100644 packages/core/test/jellyfin_track_match_test.dart diff --git a/packages/core/lib/core.dart b/packages/core/lib/core.dart index 66cffa9..099a51e 100644 --- a/packages/core/lib/core.dart +++ b/packages/core/lib/core.dart @@ -6,3 +6,9 @@ export 'src/config.dart'; export 'src/protocol.dart'; export 'src/media_source.dart'; export 'src/sync_engine.dart'; +export 'src/jellyfin/client.dart'; +export 'src/jellyfin/client_info.dart'; +export 'src/jellyfin/http.dart'; +export 'src/jellyfin/links.dart'; +export 'src/jellyfin/models.dart'; +export 'src/jellyfin/track_match.dart'; diff --git a/packages/core/lib/src/jellyfin/client.dart b/packages/core/lib/src/jellyfin/client.dart new file mode 100644 index 0000000..53428b0 --- /dev/null +++ b/packages/core/lib/src/jellyfin/client.dart @@ -0,0 +1,596 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'client_info.dart'; +import 'http.dart'; +import 'links.dart'; +import 'models.dart'; + +/// Sort keys for [JellyfinItemQuery.sortBy] (Jellyfin `ItemSortBy`). +abstract final class JellyfinSortBy { + static const sortName = 'SortName'; + static const name = 'Name'; + static const dateCreated = 'DateCreated'; + static const datePlayed = 'DatePlayed'; + static const premiereDate = 'PremiereDate'; + static const productionYear = 'ProductionYear'; + static const communityRating = 'CommunityRating'; + static const runtime = 'Runtime'; + static const random = 'Random'; + static const dateLastContentAdded = 'DateLastContentAdded'; + static const seriesDatePlayed = 'SeriesDatePlayed'; +} + +/// Filters for [JellyfinItemQuery.filters] (Jellyfin `ItemFilter`). +abstract final class JellyfinFilter { + static const isFolder = 'IsFolder'; + static const isNotFolder = 'IsNotFolder'; + static const isPlayed = 'IsPlayed'; + static const isUnplayed = 'IsUnplayed'; + static const isFavorite = 'IsFavorite'; + static const isResumable = 'IsResumable'; +} + +/// Item types for [JellyfinItemQuery.includeItemTypes]. +abstract final class JellyfinItemType { + static const movie = 'Movie'; + static const series = 'Series'; + static const season = 'Season'; + static const episode = 'Episode'; + static const folder = 'Folder'; + static const boxSet = 'BoxSet'; + static const video = 'Video'; +} + +/// A library listing (`GET /Items`). Typical uses: +/// - all series of a TV library: `parentId: libId, includeItemTypes: +/// [JellyfinItemType.series], recursive: true`; +/// - all movies: same with `[JellyfinItemType.movie]`; +/// - browsing a folder library level by level: `parentId: folderId`, +/// `recursive: false` (sub-folders and videos mixed; tell them apart by +/// [JellyfinItem.isFolder]); +/// - paging: `query.page(startIndex, limit)` until [JellyfinItemPage.hasMore] +/// is false. +class JellyfinItemQuery { + final String? parentId; + final List includeItemTypes; + final bool recursive; + final String? searchTerm; + final List sortBy; + final bool descending; + final List filters; + final int startIndex; + final int? limit; + const JellyfinItemQuery({ + this.parentId, + this.includeItemTypes = const [], + this.recursive = false, + this.searchTerm, + this.sortBy = const [JellyfinSortBy.sortName], + this.descending = false, + this.filters = const [], + this.startIndex = 0, + this.limit, + }); + + JellyfinItemQuery page(int startIndex, int limit) => JellyfinItemQuery( + parentId: parentId, + includeItemTypes: includeItemTypes, + recursive: recursive, + searchTerm: searchTerm, + sortBy: sortBy, + descending: descending, + filters: filters, + startIndex: startIndex, + limit: limit, + ); +} + +/// One Playing / Progress / Stopped report. Build it from +/// [JellyfinPlaybackInfo] (playSessionId, source id) and the player state. +class JellyfinPlaybackReport { + final String itemId; + final String mediaSourceId; + final String playSessionId; + final int positionTicks; + final bool isPaused; + /// Jellyfin stream index ([JellyfinMediaStream.index]) of the audio track. + final int? audioStreamIndex; + /// Jellyfin stream index of the subtitle track; -1 = subtitles off. + final int? subtitleStreamIndex; + final bool canSeek; + /// Always "DirectPlay" for this app (no transcoding). + final String playMethod; + const JellyfinPlaybackReport({ + required this.itemId, + required this.mediaSourceId, + required this.playSessionId, + this.positionTicks = 0, + this.isPaused = false, + this.audioStreamIndex, + this.subtitleStreamIndex, + this.canSeek = true, + this.playMethod = 'DirectPlay', + }); + + JellyfinPlaybackReport copyWith({ + int? positionTicks, + bool? isPaused, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) => + JellyfinPlaybackReport( + itemId: itemId, + mediaSourceId: mediaSourceId, + playSessionId: playSessionId, + positionTicks: positionTicks ?? this.positionTicks, + isPaused: isPaused ?? this.isPaused, + audioStreamIndex: audioStreamIndex ?? this.audioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex ?? this.subtitleStreamIndex, + canSeek: canSeek, + playMethod: playMethod, + ); + + /// Body of /Sessions/Playing and /Sessions/Playing/Progress. + Map toProgressJson() => { + 'ItemId': itemId, + 'MediaSourceId': mediaSourceId, + 'PlaySessionId': playSessionId, + 'PositionTicks': positionTicks, + 'IsPaused': isPaused, + 'CanSeek': canSeek, + 'PlayMethod': playMethod, + if (audioStreamIndex != null) 'AudioStreamIndex': audioStreamIndex, + if (subtitleStreamIndex != null) 'SubtitleStreamIndex': subtitleStreamIndex, + }; + + /// Body of /Sessions/Playing/Stopped. + Map toStoppedJson() => { + 'ItemId': itemId, + 'MediaSourceId': mediaSourceId, + 'PlaySessionId': playSessionId, + 'PositionTicks': positionTicks, + }; +} + +/// Jellyfin REST client for one server and (once signed in) one user. +/// +/// Lifecycle: +/// 1. `JellyfinClient(baseUrl: normalizeServerUrl(input)!, clientInfo: ...)`; +/// [publicInfo] gives the ServerId to key the account by. +/// 2. Sign in with [quickConnectInitiate] + [waitForQuickConnect], or +/// [authenticateByName]. Both store the token and user id on the client +/// and return them in a [JellyfinAuthResult] to persist. A saved login is +/// restored with the constructor's [accessToken] / [userId] and checked +/// with [validateToken]. +/// 3. Browse, get [playbackInfo], play [streamUrl] in mpv, report with +/// [reportPlaybackStart] / [reportPlaybackProgress] / +/// [reportPlaybackStopped] (progress every ~10 s and on pause/seek/track +/// change, like Jellyfin's own clients). +/// +/// Every user-scoped call needs [userId]; calls that need a token throw +/// [JellyfinException] (401) from the server when it is missing or revoked. +/// [close] releases the HTTP client. +class JellyfinClient { + /// Normalised server base URL, no trailing slash. + final String baseUrl; + final JellyfinClientInfo clientInfo; + String? accessToken; + String? userId; + final JellyfinHttp _http; + + /// Fields requested for list items: enough for cards and detail headers. + static const listFields = 'PrimaryImageAspectRatio,Overview,ChildCount,RecursiveItemCount'; + static const _imageParams = { + 'enableImageTypes': 'Primary,Backdrop,Thumb,Logo', + 'imageTypeLimit': '1', + }; + + JellyfinClient({ + required String baseUrl, + required this.clientInfo, + this.accessToken, + this.userId, + JellyfinHttp? http, + }) : baseUrl = normalizeServerUrl(baseUrl) ?? baseUrl, + _http = http ?? IoJellyfinHttp(); + + bool get isAuthenticated => accessToken != null && userId != null; + + void close() => _http.close(); + + // ---------------------------------------------------------------- server + + /// `GET /System/Info/Public` (no auth): ServerId, name, version. Also the + /// cheap way to check that an address is a Jellyfin server. + Future publicInfo() async => + JellyfinServerInfo.fromJson(_map(await _json('GET', '/System/Info/Public', auth: false))); + + // ------------------------------------------------------------------ auth + + /// Whether the server has Quick Connect turned on. + Future quickConnectEnabled() async => + await _json('GET', '/QuickConnect/Enabled', auth: false) == true; + + /// `POST /QuickConnect/Initiate`: starts a request and returns the code + /// to show. Needs the full client header (see [JellyfinClientInfo]) and a + /// Content-Length (see [JellyfinHttp.send]); 401 means Quick Connect is + /// disabled on the server. + Future quickConnectInitiate() async => + QuickConnectSession.fromJson(_map(await _json('POST', '/QuickConnect/Initiate', auth: false))); + + /// `GET /QuickConnect/Connect?secret=`: current state of the request. + /// Throws 404 once the request expired (Jellyfin drops them after ~10 min). + Future quickConnectCheck(String secret) async => + QuickConnectSession.fromJson(_map(await _json('GET', '/QuickConnect/Connect', + query: {'secret': secret}, auth: false))); + + /// `POST /Users/AuthenticateWithQuickConnect` once the request is + /// authorised. Stores the token on this client. + Future authenticateWithQuickConnect(String secret) async => + _applyAuth(JellyfinAuthResult.fromJson(_map(await _json( + 'POST', '/Users/AuthenticateWithQuickConnect', + body: {'Secret': secret}, auth: false)))); + + /// Polls [quickConnectCheck] every [interval] until the user approves the + /// code, then signs in. Returns null if [isCancelled] turns true (checked + /// before each poll); throws [TimeoutException] after [timeout]. + Future waitForQuickConnect( + QuickConnectSession session, { + Duration interval = const Duration(seconds: 3), + Duration timeout = const Duration(minutes: 10), + bool Function()? isCancelled, + }) async { + final deadline = DateTime.now().add(timeout); + while (true) { + if (isCancelled?.call() ?? false) return null; + final state = await quickConnectCheck(session.secret); + if (isCancelled?.call() ?? false) return null; + if (state.authenticated) return authenticateWithQuickConnect(session.secret); + if (DateTime.now().isAfter(deadline)) { + throw TimeoutException('Quick Connect code was not approved', timeout); + } + await Future.delayed(interval); + } + } + + /// `POST /Users/AuthenticateByName`. Stores the token on this client. + /// Wrong credentials give a [JellyfinException] with status 401. + Future authenticateByName(String username, String password) async => + _applyAuth(JellyfinAuthResult.fromJson(_map(await _json( + 'POST', '/Users/AuthenticateByName', + body: {'Username': username, 'Pw': password}, auth: false)))); + + /// `GET /Users/Me`: the token's user. Also sets [userId]. + Future currentUser() async { + final user = JellyfinUser.fromJson(_map(await _json('GET', '/Users/Me'))); + userId = user.id; + return user; + } + + /// Checks a saved token: the user, or null when the server says 401 + /// (expired or revoked; sign in again). Other failures throw, so an + /// offline server does not look like a logout. + Future validateToken() async { + try { + return await currentUser(); + } on JellyfinException catch (e) { + if (e.isUnauthorized) return null; + rethrow; + } + } + + /// `POST /Sessions/Logout`: revokes the token server-side, then forgets it. + Future logout() async { + try { + await _json('POST', '/Sessions/Logout'); + } finally { + accessToken = null; + userId = null; + } + } + + JellyfinAuthResult _applyAuth(JellyfinAuthResult r) { + accessToken = r.accessToken; + userId = r.user.id; + return r; + } + + // -------------------------------------------------------------- browsing + + /// The user's libraries (`GET /UserViews`). [JellyfinItem.collectionType] + /// tells movies / tvshows / homevideos / ... apart. + Future> userViews() async => + JellyfinItemPage.fromJson(_map(await _json('GET', '/UserViews', query: {'userId': _uid}))).items; + + /// Continue Watching: videos with a resume position, most recent first. + Future> resume({int limit = 24, String? parentId}) async => + JellyfinItemPage.fromJson(_map(await _json('GET', '/UserItems/Resume', query: { + 'userId': _uid, + 'mediaTypes': 'Video', + 'limit': '$limit', + if (parentId != null) 'parentId': parentId, + 'fields': listFields, + ..._imageParams, + }))).items; + + /// Next Up: per series, the first unwatched episode after the last + /// watched one. Not "the episode after X": use [nextEpisode] for that. + /// [enableResumable] false leaves out episodes already in [resume]. + Future> nextUp({ + int limit = 24, + String? seriesId, + String? parentId, + bool enableResumable = false, + }) async => + JellyfinItemPage.fromJson(_map(await _json('GET', '/Shows/NextUp', query: { + 'userId': _uid, + 'limit': '$limit', + if (seriesId != null) 'seriesId': seriesId, + if (parentId != null) 'parentId': parentId, + 'enableResumable': '$enableResumable', + 'fields': listFields, + ..._imageParams, + }))).items; + + /// Recently added in one library. Episodes are grouped into their series + /// (as the web UI shows them) unless [groupItems] is false. + Future> latest(String parentId, {int limit = 16, bool groupItems = true}) async { + final list = await _json('GET', '/Items/Latest', query: { + 'userId': _uid, + 'parentId': parentId, + 'limit': '$limit', + 'groupItems': '$groupItems', + 'fields': listFields, + ..._imageParams, + }); + return (list as List? ?? const []) + .whereType() + .map((e) => JellyfinItem.fromJson(e.cast())) + .toList(); + } + + /// A page of library items; see [JellyfinItemQuery]. + Future items(JellyfinItemQuery q) async { + final page = JellyfinItemPage.fromJson(_map(await _json('GET', '/Items', query: { + 'userId': _uid, + if (q.parentId != null) 'parentId': q.parentId!, + if (q.includeItemTypes.isNotEmpty) 'includeItemTypes': q.includeItemTypes.join(','), + 'recursive': '${q.recursive}', + if (q.searchTerm != null && q.searchTerm!.isNotEmpty) 'searchTerm': q.searchTerm!, + if (q.sortBy.isNotEmpty) 'sortBy': q.sortBy.join(','), + 'sortOrder': q.descending ? 'Descending' : 'Ascending', + if (q.filters.isNotEmpty) 'filters': q.filters.join(','), + 'startIndex': '${q.startIndex}', + if (q.limit != null) 'limit': '${q.limit}', + 'enableTotalRecordCount': 'true', + 'fields': listFields, + ..._imageParams, + }))); + // Jellyfin echoes StartIndex; older servers omit it. + return JellyfinItemPage(page.items, + totalRecordCount: page.totalRecordCount, startIndex: q.startIndex); + } + + /// Search movies, series and episodes by name across all libraries. + Future> search( + String term, { + int limit = 30, + List types = const [ + JellyfinItemType.movie, + JellyfinItemType.series, + JellyfinItemType.episode, + ], + }) async => + (await items(JellyfinItemQuery( + searchTerm: term, + recursive: true, + includeItemTypes: types, + limit: limit, + ))) + .items; + + /// Full item (detail page): overview, user data, media sources/streams. + Future item(String itemId) async => + JellyfinItem.fromJson(_map(await _json('GET', '/Items/$itemId', query: {'userId': _uid}))); + + /// Seasons of a series, with user data (played, unplayed count). + Future> seasons(String seriesId) async => + JellyfinItemPage.fromJson(_map(await _json('GET', '/Shows/$seriesId/Seasons', query: { + 'userId': _uid, + 'enableUserData': 'true', + 'fields': listFields, + ..._imageParams, + }))).items; + + /// Episodes of a series in airing order (all seasons, or one + /// [seasonId]), with user data. Missing (virtual) episodes are left out. + /// Pass [withMediaStreams] to get each episode's tracks in the same call. + Future> episodes(String seriesId, + {String? seasonId, bool withMediaStreams = false}) async => + JellyfinItemPage.fromJson(_map(await _json('GET', '/Shows/$seriesId/Episodes', query: { + 'userId': _uid, + if (seasonId != null) 'seasonId': seasonId, + 'isMissing': 'false', + 'enableUserData': 'true', + 'fields': withMediaStreams ? '$listFields,MediaStreams' : listFields, + ..._imageParams, + }))).items; + + /// The episode after [episode] in its series (crossing seasons), or null + /// for the last one. See [episodeAfter] for the rule. + Future nextEpisode(JellyfinItem episode, {bool withMediaStreams = false}) async { + final seriesId = episode.seriesId; + if (seriesId == null) return null; + return episodeAfter( + await episodes(seriesId, withMediaStreams: withMediaStreams), episode.id); + } + + // -------------------------------------------------------------- images + + /// Image URL for caching and display. Images need no token on Jellyfin, + /// so this URL is safe to share. [tag] makes the URL change when the image + /// does; [maxWidth] / [maxHeight] let the server scale down. + Uri imageUrl(JellyfinImageRef ref, {int? maxWidth, int? maxHeight, int quality = 90}) { + final idx = ref.type == JellyfinImageType.backdrop ? '/${ref.index}' : ''; + return _uri('/Items/${ref.itemId}/Images/${ref.type.apiName}$idx', { + 'tag': ref.tag, + if (maxWidth != null) 'maxWidth': '$maxWidth', + if (maxHeight != null) 'maxHeight': '$maxHeight', + 'quality': '$quality', + }); + } + + /// User avatar URL, or null if the user has none. + Uri? userImageUrl(JellyfinUser user, {int maxWidth = 96}) => user.primaryImageTag == null + ? null + : _uri('/Users/${user.id}/Images/Primary', {'tag': user.primaryImageTag!, 'maxWidth': '$maxWidth'}); + + // ------------------------------------------------------------ playback + + /// `POST /Items/{id}/PlaybackInfo` asking for direct play only. The + /// [JellyfinPlaybackInfo.playSessionId] ties the progress reports + /// together; pick the source with [JellyfinPlaybackInfo.primarySource]. + Future playbackInfo(String itemId, {String? mediaSourceId}) async => + JellyfinPlaybackInfo.fromJson(_map(await _json('POST', '/Items/$itemId/PlaybackInfo', query: { + 'userId': _uid, + if (mediaSourceId != null) 'mediaSourceId': mediaSourceId, + }, body: { + 'UserId': _uid, + if (mediaSourceId != null) 'MediaSourceId': mediaSourceId, + 'EnableDirectPlay': true, + 'EnableDirectStream': true, + 'EnableTranscoding': false, + }))); + + /// The original file, byte-range capable (206), for mpv: + /// `/Videos/{id}/stream?static=true&mediaSourceId=...&api_key=TOKEN`. + /// The token is in the URL, so do not log or share it; mpv can instead + /// take [shareableStreamUrl] plus [authHeaders] as http-header-fields. + Uri streamUrl(String itemId, {String? mediaSourceId}) => + authorizeUrl(shareableStreamUrl(itemId, mediaSourceId: mediaSourceId)); + + /// Same as [streamUrl] without credentials: safe to send to other viewers + /// or the sync server, each of which adds its own ([authorizeUrl]). + Uri shareableStreamUrl(String itemId, {String? mediaSourceId}) => + _uri('/Videos/$itemId/stream', { + 'static': 'true', + 'mediaSourceId': mediaSourceId ?? itemId, + }); + + /// [url] plus this client's token as `api_key` (replacing any present). + Uri authorizeUrl(Uri url) { + final token = accessToken; + if (token == null) throw const JellyfinException('not signed in', statusCode: 401); + return url.replace(queryParameters: {...url.queryParameters, 'api_key': token}); + } + + /// Headers that authenticate a request without a token in the URL. + Map get authHeaders => + {'Authorization': clientInfo.authorizationHeader(accessToken)}; + + // ------------------------------------------------------------ progress + + /// `POST /Sessions/Playing`: playback started (Jellyfin shows the session + /// as playing and starts tracking the item). + Future reportPlaybackStart(JellyfinPlaybackReport r) => + _json('POST', '/Sessions/Playing', body: r.toProgressJson()); + + /// `POST /Sessions/Playing/Progress`: position, pause state and tracks. + /// Jellyfin saves the resume position from these. + Future reportPlaybackProgress(JellyfinPlaybackReport r) => + _json('POST', '/Sessions/Playing/Progress', body: r.toProgressJson()); + + /// `POST /Sessions/Playing/Stopped`: ends the session. The server then + /// marks the item played if the position is past its "max resume" share + /// (90% by default), keeps it as a resume point between 5% and 90%, and + /// clears the position below 5%. + Future reportPlaybackStopped(JellyfinPlaybackReport r) => + _json('POST', '/Sessions/Playing/Stopped', body: r.toStoppedJson()); + + /// The user's state for one item. + Future userData(String itemId) async => JellyfinUserData.fromJson( + _map(await _json('GET', '/UserItems/$itemId/UserData', query: {'userId': _uid}))); + + /// Marks an item (or a whole season/series) watched. + Future markPlayed(String itemId) async => JellyfinUserData.fromJson( + _map(await _json('POST', '/UserPlayedItems/$itemId', query: {'userId': _uid}))); + + /// Marks an item unwatched. This also resets play count, last played date + /// and resume position. + Future markUnplayed(String itemId) async => JellyfinUserData.fromJson( + _map(await _json('DELETE', '/UserPlayedItems/$itemId', query: {'userId': _uid}))); + + /// Sets the resume position without a playback session. + Future setResumePosition(String itemId, int positionTicks) => + _json('POST', '/UserItems/$itemId/UserData', + query: {'userId': _uid}, body: {'PlaybackPositionTicks': positionTicks}); + + /// Removes the item from Continue Watching. + Future clearResumePosition(String itemId) => setResumePosition(itemId, 0); + + // ------------------------------------------------------------- plumbing + + String get _uid { + final id = userId; + if (id == null) throw const JellyfinException('not signed in', statusCode: 401); + return id; + } + + Uri _uri(String path, [Map? query]) { + final uri = Uri.parse('$baseUrl$path'); + return query == null || query.isEmpty ? uri : uri.replace(queryParameters: query); + } + + Future _json( + String method, + String path, { + Map? query, + Object? body, + bool auth = true, + }) async { + final uri = _uri(path, query); + final res = await _http.send( + method, + uri, + headers: { + 'Authorization': clientInfo.authorizationHeader(auth ? accessToken : null), + 'Accept': 'application/json', + }, + body: body == null ? null : jsonEncode(body), + ); + if (!res.ok) { + final detail = res.body.length > 200 ? res.body.substring(0, 200) : res.body; + throw JellyfinException('$method $path -> ${res.statusCode} $detail'.trim(), + statusCode: res.statusCode); + } + if (res.body.trim().isEmpty) return null; + try { + return jsonDecode(res.body); + } on FormatException { + throw JellyfinException('$method $path: response is not JSON', statusCode: res.statusCode); + } + } + + static Map _map(Object? v) => + v is Map ? v.cast() : const {}; +} + +/// The successor of [episodeId] in [episodes] (a series' episode list in +/// airing order, as `/Shows/{id}/Episodes` returns it), crossing seasons. +/// Other copies of the same episode (same season and episode number, e.g. +/// two files of one episode) are skipped. Null when [episodeId] is last or +/// not in the list. +JellyfinItem? episodeAfter(List episodes, String episodeId) { + final at = episodes.indexWhere((e) => e.id == episodeId); + if (at < 0) return null; + final cur = episodes[at]; + for (var i = at + 1; i < episodes.length; i++) { + final e = episodes[i]; + final sameEpisode = cur.indexNumber != null && + e.indexNumber == cur.indexNumber && + e.parentIndexNumber == cur.parentIndexNumber; + if (!sameEpisode) return e; + } + return null; +} diff --git a/packages/core/lib/src/jellyfin/client_info.dart b/packages/core/lib/src/jellyfin/client_info.dart new file mode 100644 index 0000000..a156984 --- /dev/null +++ b/packages/core/lib/src/jellyfin/client_info.dart @@ -0,0 +1,38 @@ +/// Identifies this app to Jellyfin. Always send all four fields: on 10.11 +/// an unauthenticated request (Quick Connect, sign-in) whose +/// `Authorization: MediaBrowser ...` header lacks any of them (e.g. no +/// Version) is rejected with 400 and no body. Token-carrying requests are +/// more lenient, but progress reports are attributed by these fields. +/// +/// [deviceId] must be stable per install (persist a random id once). +/// Jellyfin keys devices, sessions and tokens by it: signing the same user +/// in again with the same DeviceId reuses that device entry instead of +/// piling up devices on the user's dashboard. +class JellyfinClientInfo { + /// App name shown in Jellyfin's dashboard ("Watch Together"). + final String client; + /// Device name shown in the dashboard (e.g. the hostname). + final String device; + final String deviceId; + final String version; + const JellyfinClientInfo({ + required this.client, + required this.device, + required this.deviceId, + required this.version, + }); + + /// The `Authorization` header value. Values are percent-encoded (the + /// server decodes them), so names with quotes, commas or non-ASCII + /// characters are safe. + String authorizationHeader([String? token]) { + final parts = [ + 'Client="${Uri.encodeComponent(client)}"', + 'Device="${Uri.encodeComponent(device)}"', + 'DeviceId="${Uri.encodeComponent(deviceId)}"', + 'Version="${Uri.encodeComponent(version)}"', + if (token != null && token.isNotEmpty) 'Token="${Uri.encodeComponent(token)}"', + ]; + return 'MediaBrowser ${parts.join(', ')}'; + } +} diff --git a/packages/core/lib/src/jellyfin/http.dart b/packages/core/lib/src/jellyfin/http.dart new file mode 100644 index 0000000..8705f4e --- /dev/null +++ b/packages/core/lib/src/jellyfin/http.dart @@ -0,0 +1,79 @@ +import 'dart:convert'; +import 'dart:io'; + +import '../errors.dart'; + +/// A failed Jellyfin request. [statusCode] is null for network errors. +class JellyfinException extends SyncRelayException { + final int? statusCode; + const JellyfinException(super.message, {this.statusCode}); + + /// 401: the token is missing, expired or was revoked (sign in again). + bool get isUnauthorized => statusCode == 401; + + @override + String toString() => + 'JellyfinException${statusCode == null ? '' : ' $statusCode'}: $message'; +} + +class JellyfinHttpResponse { + final int statusCode; + final String body; + const JellyfinHttpResponse(this.statusCode, this.body); + + bool get ok => statusCode >= 200 && statusCode < 300; +} + +/// The transport [JellyfinClient] talks through. Tests inject a fake; the +/// default is [IoJellyfinHttp]. +abstract class JellyfinHttp { + /// Sends one request. [body] is sent as-is (JSON when non-null). A POST + /// always carries a Content-Length, even when empty: Jellyfin behind nginx + /// answers 400 to a body-less POST without one (seen on + /// /QuickConnect/Initiate). + Future send( + String method, + Uri uri, { + Map headers = const {}, + String? body, + }); + + void close(); +} + +class IoJellyfinHttp implements JellyfinHttp { + final HttpClient _client; + final Duration timeout; + + IoJellyfinHttp({HttpClient? client, this.timeout = const Duration(seconds: 20)}) + : _client = client ?? HttpClient(); + + @override + Future send( + String method, + Uri uri, { + Map headers = const {}, + String? body, + }) async { + try { + final req = await _client.openUrl(method, uri).timeout(timeout); + headers.forEach(req.headers.set); + final bytes = body == null ? const [] : utf8.encode(body); + if (body != null) { + req.headers.contentType = ContentType.json; + } + if (method != 'GET' && method != 'HEAD') { + req.contentLength = bytes.length; + req.add(bytes); + } + final res = await req.close().timeout(timeout); + final text = await res.transform(utf8.decoder).join().timeout(timeout); + return JellyfinHttpResponse(res.statusCode, text); + } on Object catch (e) { + throw JellyfinException('$method ${uri.path}: $e'); + } + } + + @override + void close() => _client.close(force: true); +} diff --git a/packages/core/lib/src/jellyfin/links.dart b/packages/core/lib/src/jellyfin/links.dart new file mode 100644 index 0000000..16029bd --- /dev/null +++ b/packages/core/lib/src/jellyfin/links.dart @@ -0,0 +1,132 @@ +/// Server URL normalisation and Jellyfin link recognition. +library; + +/// Normalises what a user typed as a server address into a base URL without +/// a trailing slash, e.g. `jelly.example.com/web/#/home` -> +/// `https://jelly.example.com`. A base path (reverse proxy sub-path such as +/// `https://example.com/jellyfin`) is kept. No scheme means https, except +/// for localhost and IP addresses, which get http. Returns null when the +/// input is not a usable http(s) URL. +String? normalizeServerUrl(String input) { + var text = input.trim(); + if (text.isEmpty) return null; + if (!text.contains('://')) { + final host = text.split(RegExp(r'[/:#?]')).first; + final local = host == 'localhost' || + RegExp(r'^\d{1,3}(\.\d{1,3}){3}$').hasMatch(host) || + text.startsWith('['); + text = '${local ? 'http' : 'https'}://$text'; + } + final link = parseJellyfinLink(text); + if (link != null) return link.baseUrl; + final Uri uri; + try { + uri = Uri.parse(text); + } on FormatException { + return null; + } + if ((uri.scheme != 'http' && uri.scheme != 'https') || uri.host.isEmpty) { + return null; + } + return _base(uri, _basePath(uri.pathSegments)); +} + +/// A Jellyfin item recognised in a pasted link. [serverId] is only known +/// when the link carried it (web UI links do); otherwise resolve it with +/// `JellyfinClient.publicInfo()` on [baseUrl]. +class JellyfinLink { + final String baseUrl; + final String itemId; + final String? serverId; + const JellyfinLink({required this.baseUrl, required this.itemId, this.serverId}); + + @override + bool operator ==(Object other) => + other is JellyfinLink && + other.baseUrl == baseUrl && + other.itemId == itemId && + other.serverId == serverId; + + @override + int get hashCode => Object.hash(baseUrl, itemId, serverId); + + @override + String toString() => 'JellyfinLink($baseUrl, $itemId, serverId: $serverId)'; +} + +final _idPattern = RegExp( + r'^([0-9a-fA-F]{32}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$'); + +/// Jellyfin ids are GUIDs; the API prints them as 32 lowercase hex digits. +/// Returns that form, or null when [id] is not a GUID. +String? normalizeItemId(String id) { + if (!_idPattern.hasMatch(id)) return null; + return id.replaceAll('-', '').toLowerCase(); +} + +/// Recognises a Jellyfin item link and reduces it to (baseUrl, itemId): +/// - web UI: `/web/#/details?id=X&serverId=S`, `/web/index.html#!/details?id=X`, +/// any `#/...?id=X` route; +/// - API: `/Items/X/...`, `/Users/U/Items/X`, `/Videos/X/stream...`, +/// `/Audio/X/...`, `/UserItems/X/...`. +/// Returns null for anything else (including non-Jellyfin URLs, where it +/// cannot be sure). +JellyfinLink? parseJellyfinLink(String input) { + final text = input.trim(); + if (!text.startsWith('http://') && !text.startsWith('https://')) return null; + final Uri uri; + try { + uri = Uri.parse(text); + } on FormatException { + return null; + } + if (uri.host.isEmpty) return null; + final segs = uri.pathSegments.where((s) => s.isNotEmpty).toList(); + + // Web UI: the route lives in the fragment, e.g. "/details?id=...". + final webAt = segs.indexWhere((s) => s.toLowerCase() == 'web'); + if (webAt >= 0 && uri.fragment.isNotEmpty) { + final frag = uri.fragment.replaceFirst(RegExp(r'^!'), ''); + final q = frag.indexOf('?'); + if (q >= 0) { + final params = Uri.splitQueryString(frag.substring(q + 1)); + final id = normalizeItemId(params['id'] ?? ''); + if (id != null) { + final sid = params['serverId']; + return JellyfinLink( + baseUrl: _base(uri, segs.sublist(0, webAt)), + itemId: id, + serverId: sid == null ? null : normalizeItemId(sid) ?? sid, + ); + } + } + } + + // API paths: /(Users//)?(Items|Videos|Audio|UserItems)//... + for (var i = 0; i < segs.length - 1; i++) { + final s = segs[i].toLowerCase(); + if (s != 'items' && s != 'videos' && s != 'audio' && s != 'useritems') continue; + final id = normalizeItemId(segs[i + 1]); + if (id == null) continue; + var baseEnd = i; + if (i >= 2 && segs[i - 2].toLowerCase() == 'users') baseEnd = i - 2; + return JellyfinLink(baseUrl: _base(uri, segs.sublist(0, baseEnd)), itemId: id); + } + return null; +} + +/// Path segments of the server base: everything before a `web` segment +/// (the web UI lives at `/web/`), otherwise the whole path. +List _basePath(List segments) { + final segs = segments.where((s) => s.isNotEmpty).toList(); + final webAt = segs.indexWhere((s) => s.toLowerCase() == 'web'); + return webAt >= 0 ? segs.sublist(0, webAt) : segs; +} + +String _base(Uri uri, List pathSegments) { + final port = uri.hasPort ? ':${uri.port}' : ''; + final path = pathSegments.isEmpty + ? '' + : '/${pathSegments.map(Uri.encodeComponent).join('/')}'; + return '${uri.scheme}://${uri.host}$port$path'; +} diff --git a/packages/core/lib/src/jellyfin/models.dart b/packages/core/lib/src/jellyfin/models.dart new file mode 100644 index 0000000..c1a7133 --- /dev/null +++ b/packages/core/lib/src/jellyfin/models.dart @@ -0,0 +1,557 @@ +/// Typed views of Jellyfin API JSON. Only the fields a client UI and the +/// player need; everything is parsed leniently (missing fields become null, +/// false or 0) because Jellyfin omits fields freely. +library; + +/// Jellyfin positions and durations are in ticks: 10,000,000 per second. +const int jellyfinTicksPerSecond = 10000000; + +int durationToTicks(Duration d) => d.inMicroseconds * 10; +Duration ticksToDuration(int ticks) => Duration(microseconds: ticks ~/ 10); + +typedef JellyfinJson = Map; + +String? _str(Object? v) => v is String && v.isNotEmpty ? v : null; +int? _int(Object? v) => v is num ? v.toInt() : null; +double? _double(Object? v) => v is num ? v.toDouble() : null; +bool _bool(Object? v) => v == true; +DateTime? _date(Object? v) => v is String ? DateTime.tryParse(v) : null; +List _list(Object? v) => + v is List ? v.whereType().map((e) => e.cast()).toList() : const []; + +/// /System/Info/Public. [id] is the ServerId: the stable key for a linked +/// server (URLs may change, the id does not). +class JellyfinServerInfo { + final String id; + final String name; + final String version; + const JellyfinServerInfo({required this.id, required this.name, required this.version}); + + factory JellyfinServerInfo.fromJson(JellyfinJson j) => JellyfinServerInfo( + id: _str(j['Id']) ?? '', + name: _str(j['ServerName']) ?? '', + version: _str(j['Version']) ?? '', + ); +} + +class JellyfinUser { + final String id; + final String name; + final String? serverId; + final String? primaryImageTag; + final bool isAdministrator; + const JellyfinUser({ + required this.id, + required this.name, + this.serverId, + this.primaryImageTag, + this.isAdministrator = false, + }); + + factory JellyfinUser.fromJson(JellyfinJson j) => JellyfinUser( + id: _str(j['Id']) ?? '', + name: _str(j['Name']) ?? '', + serverId: _str(j['ServerId']), + primaryImageTag: _str(j['PrimaryImageTag']), + isAdministrator: _bool((j['Policy'] as Map?)?['IsAdministrator']), + ); +} + +/// Result of a sign-in. Persist [accessToken], [user].id and [serverId] +/// (keyed by ServerId); the token stays valid until logout or revocation. +class JellyfinAuthResult { + final String accessToken; + final JellyfinUser user; + final String serverId; + const JellyfinAuthResult({required this.accessToken, required this.user, required this.serverId}); + + factory JellyfinAuthResult.fromJson(JellyfinJson j) { + final user = JellyfinUser.fromJson((j['User'] as Map? ?? const {}).cast()); + return JellyfinAuthResult( + accessToken: _str(j['AccessToken']) ?? '', + user: user, + serverId: _str(j['ServerId']) ?? user.serverId ?? '', + ); + } +} + +/// A pending Quick Connect request. Show [code] to the user (they enter it +/// in Jellyfin: user menu > Quick Connect); keep [secret] private, it is +/// what the app polls and signs in with. +class QuickConnectSession { + final String code; + final String secret; + final bool authenticated; + const QuickConnectSession({required this.code, required this.secret, this.authenticated = false}); + + factory QuickConnectSession.fromJson(JellyfinJson j) => QuickConnectSession( + code: _str(j['Code']) ?? '', + secret: _str(j['Secret']) ?? '', + authenticated: _bool(j['Authenticated']), + ); +} + +/// Per-user state of an item. +class JellyfinUserData { + final bool played; + final int playbackPositionTicks; + final int playCount; + final bool isFavorite; + /// Folders (series, seasons) only: episodes left to watch. + final int? unplayedItemCount; + final double? playedPercentage; + final DateTime? lastPlayedDate; + const JellyfinUserData({ + this.played = false, + this.playbackPositionTicks = 0, + this.playCount = 0, + this.isFavorite = false, + this.unplayedItemCount, + this.playedPercentage, + this.lastPlayedDate, + }); + + factory JellyfinUserData.fromJson(JellyfinJson j) => JellyfinUserData( + played: _bool(j['Played']), + playbackPositionTicks: _int(j['PlaybackPositionTicks']) ?? 0, + playCount: _int(j['PlayCount']) ?? 0, + isFavorite: _bool(j['IsFavorite']), + unplayedItemCount: _int(j['UnplayedItemCount']), + playedPercentage: _double(j['PlayedPercentage']), + lastPlayedDate: _date(j['LastPlayedDate']), + ); + + Duration get position => ticksToDuration(playbackPositionTicks); + + /// Has a resume point (Continue Watching). + bool get isResumable => playbackPositionTicks > 0; +} + +/// Image kinds a UI uses. Backdrop can have several (index), the rest one. +enum JellyfinImageType { + primary('Primary'), + backdrop('Backdrop'), + thumb('Thumb'), + logo('Logo'); + + final String apiName; + const JellyfinImageType(this.apiName); +} + +/// A concrete image: which item owns it and its tag. The tag changes when +/// the image changes, so `(itemId, type, index, tag, maxWidth)` is a safe +/// cache key. Build the URL with `JellyfinClient.imageUrl`. +class JellyfinImageRef { + final String itemId; + final JellyfinImageType type; + final String tag; + final int index; + const JellyfinImageRef(this.itemId, this.type, this.tag, {this.index = 0}); + + @override + bool operator ==(Object other) => + other is JellyfinImageRef && + other.itemId == itemId && + other.type == type && + other.tag == tag && + other.index == index; + + @override + int get hashCode => Object.hash(itemId, type, tag, index); +} + +/// Item types the app distinguishes; anything else is [other]. +enum JellyfinItemKind { + movie, + series, + season, + episode, + folder, + collectionFolder, + boxSet, + video, + other; + + static JellyfinItemKind parse(String? type) => switch (type) { + 'Movie' => movie, + 'Series' => series, + 'Season' => season, + 'Episode' => episode, + 'Folder' => folder, + 'CollectionFolder' || 'UserView' => collectionFolder, + 'BoxSet' => boxSet, + 'Video' || 'MusicVideo' => video, + _ => other, + }; +} + +/// One library item (view, series, season, episode, movie, folder...). +class JellyfinItem { + final String id; + final String name; + /// Raw Jellyfin type ("Episode", "Series", ...); see [kind]. + final String type; + final String? serverId; + final bool isFolder; + /// Libraries only: "movies", "tvshows", "homevideos", ... + final String? collectionType; + final String? overview; + final int? productionYear; + final DateTime? premiereDate; + final double? communityRating; + final String? officialRating; + final int? runTimeTicks; + final String? container; + final String? mediaType; + + // Episodes / seasons. + final String? seriesId; + final String? seriesName; + final String? seasonId; + final String? seasonName; + /// Episode number, or season number for a season. + final int? indexNumber; + /// Season number of an episode. + final int? parentIndexNumber; + final int? childCount; + final int? recursiveItemCount; + + final JellyfinUserData userData; + final double? primaryImageAspectRatio; + final Map imageTags; + final List backdropImageTags; + final String? seriesPrimaryImageTag; + final String? parentBackdropItemId; + final List parentBackdropImageTags; + final String? parentThumbItemId; + final String? parentThumbImageTag; + final String? parentLogoItemId; + final String? parentLogoImageTag; + + /// Present on full item responses (`getItem`) for playable items. + final List mediaSources; + /// Streams of the default source; present on `getItem` and when + /// MediaStreams was requested in `fields`. + final List mediaStreams; + + const JellyfinItem({ + required this.id, + required this.name, + required this.type, + this.serverId, + this.isFolder = false, + this.collectionType, + this.overview, + this.productionYear, + this.premiereDate, + this.communityRating, + this.officialRating, + this.runTimeTicks, + this.container, + this.mediaType, + this.seriesId, + this.seriesName, + this.seasonId, + this.seasonName, + this.indexNumber, + this.parentIndexNumber, + this.childCount, + this.recursiveItemCount, + this.userData = const JellyfinUserData(), + this.primaryImageAspectRatio, + this.imageTags = const {}, + this.backdropImageTags = const [], + this.seriesPrimaryImageTag, + this.parentBackdropItemId, + this.parentBackdropImageTags = const [], + this.parentThumbItemId, + this.parentThumbImageTag, + this.parentLogoItemId, + this.parentLogoImageTag, + this.mediaSources = const [], + this.mediaStreams = const [], + }); + + factory JellyfinItem.fromJson(JellyfinJson j) => JellyfinItem( + id: _str(j['Id']) ?? '', + name: _str(j['Name']) ?? '', + type: _str(j['Type']) ?? '', + serverId: _str(j['ServerId']), + isFolder: _bool(j['IsFolder']), + collectionType: _str(j['CollectionType']), + overview: _str(j['Overview']), + productionYear: _int(j['ProductionYear']), + premiereDate: _date(j['PremiereDate']), + communityRating: _double(j['CommunityRating']), + officialRating: _str(j['OfficialRating']), + runTimeTicks: _int(j['RunTimeTicks']), + container: _str(j['Container']), + mediaType: _str(j['MediaType']), + seriesId: _str(j['SeriesId']), + seriesName: _str(j['SeriesName']), + seasonId: _str(j['SeasonId']), + seasonName: _str(j['SeasonName']), + indexNumber: _int(j['IndexNumber']), + parentIndexNumber: _int(j['ParentIndexNumber']), + childCount: _int(j['ChildCount']), + recursiveItemCount: _int(j['RecursiveItemCount']), + userData: JellyfinUserData.fromJson( + (j['UserData'] as Map? ?? const {}).cast()), + primaryImageAspectRatio: _double(j['PrimaryImageAspectRatio']), + imageTags: (j['ImageTags'] as Map? ?? const {}) + .map((k, v) => MapEntry(k.toString(), v.toString())), + backdropImageTags: _strings(j['BackdropImageTags']), + seriesPrimaryImageTag: _str(j['SeriesPrimaryImageTag']), + parentBackdropItemId: _str(j['ParentBackdropItemId']), + parentBackdropImageTags: _strings(j['ParentBackdropImageTags']), + parentThumbItemId: _str(j['ParentThumbItemId']), + parentThumbImageTag: _str(j['ParentThumbImageTag']), + parentLogoItemId: _str(j['ParentLogoItemId']), + parentLogoImageTag: _str(j['ParentLogoImageTag']), + mediaSources: _list(j['MediaSources']).map(JellyfinMediaSource.fromJson).toList(), + mediaStreams: _list(j['MediaStreams']).map(JellyfinMediaStream.fromJson).toList(), + ); + + JellyfinItemKind get kind => JellyfinItemKind.parse(type); + + Duration? get runTime => runTimeTicks == null ? null : ticksToDuration(runTimeTicks!); + + /// "S2E12" for episodes with both numbers, else null. + String? get episodeLabel => parentIndexNumber != null && indexNumber != null + ? 'S${parentIndexNumber}E$indexNumber' + : null; + + /// The item's own image of [type], if it has one. + JellyfinImageRef? ownImage(JellyfinImageType type, {int index = 0}) { + if (type == JellyfinImageType.backdrop) { + return index < backdropImageTags.length + ? JellyfinImageRef(id, type, backdropImageTags[index], index: index) + : null; + } + final tag = imageTags[type.apiName]; + return tag == null ? null : JellyfinImageRef(id, type, tag); + } + + /// Best image of [type] for display: the item's own, else the parent's + /// (series/season art that Jellyfin inlines on episodes and seasons). For + /// [JellyfinImageType.primary] on an episode or season without its own + /// image this falls back to the series poster. + JellyfinImageRef? image(JellyfinImageType type) { + final own = ownImage(type); + if (own != null) return own; + switch (type) { + case JellyfinImageType.primary: + if (seriesId != null && seriesPrimaryImageTag != null) { + return JellyfinImageRef(seriesId!, type, seriesPrimaryImageTag!); + } + case JellyfinImageType.backdrop: + if (parentBackdropItemId != null && parentBackdropImageTags.isNotEmpty) { + return JellyfinImageRef(parentBackdropItemId!, type, parentBackdropImageTags.first); + } + case JellyfinImageType.thumb: + if (parentThumbItemId != null && parentThumbImageTag != null) { + return JellyfinImageRef(parentThumbItemId!, type, parentThumbImageTag!); + } + case JellyfinImageType.logo: + if (parentLogoItemId != null && parentLogoImageTag != null) { + return JellyfinImageRef(parentLogoItemId!, type, parentLogoImageTag!); + } + } + return null; + } +} + +List _strings(Object? v) => + v is List ? v.map((e) => e.toString()).toList() : const []; + +/// A page of items. [startIndex] + items.length < [totalRecordCount] means +/// there is more. +class JellyfinItemPage { + final List items; + final int totalRecordCount; + final int startIndex; + const JellyfinItemPage(this.items, {required this.totalRecordCount, this.startIndex = 0}); + + factory JellyfinItemPage.fromJson(JellyfinJson j) { + final items = _list(j['Items']).map(JellyfinItem.fromJson).toList(); + return JellyfinItemPage( + items, + totalRecordCount: _int(j['TotalRecordCount']) ?? items.length, + startIndex: _int(j['StartIndex']) ?? 0, + ); + } + + bool get hasMore => startIndex + items.length < totalRecordCount; +} + +enum JellyfinStreamType { + video, + audio, + subtitle, + other; + + static JellyfinStreamType parse(String? s) => switch (s) { + 'Video' => video, + 'Audio' => audio, + 'Subtitle' => subtitle, + _ => other, + }; +} + +/// One track of a media source. [index] is Jellyfin's stream index: the +/// value for AudioStreamIndex / SubtitleStreamIndex in progress reports. +/// For embedded streams in an mkv it equals the ffprobe stream index, so +/// mpv's track id is the position among streams of the same type (mpv +/// counts audio and subtitle ids separately, from 1); see +/// [JellyfinMediaSource.mpvTrackId]. +class JellyfinMediaStream { + final int index; + final JellyfinStreamType type; + final String? codec; + final String? language; + final String? title; + final String? displayTitle; + final int? channels; + final String? channelLayout; + final bool isDefault; + final bool isForced; + final bool isExternal; + final bool isHearingImpaired; + final bool isTextSubtitleStream; + /// External subtitles only: path on the server, not reachable by clients. + final String? deliveryUrl; + const JellyfinMediaStream({ + required this.index, + required this.type, + this.codec, + this.language, + this.title, + this.displayTitle, + this.channels, + this.channelLayout, + this.isDefault = false, + this.isForced = false, + this.isExternal = false, + this.isHearingImpaired = false, + this.isTextSubtitleStream = false, + this.deliveryUrl, + }); + + factory JellyfinMediaStream.fromJson(JellyfinJson j) => JellyfinMediaStream( + index: _int(j['Index']) ?? -1, + type: JellyfinStreamType.parse(_str(j['Type'])), + codec: _str(j['Codec']), + language: _str(j['Language']), + title: _str(j['Title']), + displayTitle: _str(j['DisplayTitle']), + channels: _int(j['Channels']), + channelLayout: _str(j['ChannelLayout']), + isDefault: _bool(j['IsDefault']), + isForced: _bool(j['IsForced']), + isExternal: _bool(j['IsExternal']), + isHearingImpaired: _bool(j['IsHearingImpaired']), + isTextSubtitleStream: _bool(j['IsTextSubtitleStream']), + deliveryUrl: _str(j['DeliveryUrl']), + ); + + @override + String toString() => + 'JellyfinMediaStream($index ${type.name} $language "$title" $codec ${channels ?? ''})'; +} + +/// One playable version of an item (usually exactly one: the file). +class JellyfinMediaSource { + final String id; + final String? name; + final String? container; + final int? size; + final int? bitrate; + final int? runTimeTicks; + final bool supportsDirectPlay; + final bool supportsDirectStream; + final int? defaultAudioStreamIndex; + final int? defaultSubtitleStreamIndex; + final List mediaStreams; + const JellyfinMediaSource({ + required this.id, + this.name, + this.container, + this.size, + this.bitrate, + this.runTimeTicks, + this.supportsDirectPlay = false, + this.supportsDirectStream = false, + this.defaultAudioStreamIndex, + this.defaultSubtitleStreamIndex, + this.mediaStreams = const [], + }); + + factory JellyfinMediaSource.fromJson(JellyfinJson j) => JellyfinMediaSource( + id: _str(j['Id']) ?? '', + name: _str(j['Name']), + container: _str(j['Container']), + size: _int(j['Size']), + bitrate: _int(j['Bitrate']), + runTimeTicks: _int(j['RunTimeTicks']), + supportsDirectPlay: _bool(j['SupportsDirectPlay']), + supportsDirectStream: _bool(j['SupportsDirectStream']), + defaultAudioStreamIndex: _int(j['DefaultAudioStreamIndex']), + defaultSubtitleStreamIndex: _int(j['DefaultSubtitleStreamIndex']), + mediaStreams: _list(j['MediaStreams']).map(JellyfinMediaStream.fromJson).toList(), + ); + + List streamsOf(JellyfinStreamType type) => + mediaStreams.where((s) => s.type == type).toList(); + + JellyfinMediaStream? streamAt(int index) { + for (final s in mediaStreams) { + if (s.index == index) return s; + } + return null; + } + + /// mpv track id (`aid` / `sid`, 1-based per type) of the embedded stream + /// with Jellyfin [index], or null if it is external or unknown. Assumes + /// mpv sees the same embedded streams in the same order as Jellyfin's + /// probe (true for direct-played mkv/mp4). + int? mpvTrackId(int index) { + final s = streamAt(index); + if (s == null || s.isExternal) return null; + final sameType = mediaStreams + .where((m) => m.type == s.type && !m.isExternal) + .toList() + ..sort((a, b) => a.index.compareTo(b.index)); + final pos = sameType.indexWhere((m) => m.index == index); + return pos < 0 ? null : pos + 1; + } + + /// Inverse of [mpvTrackId]. + int? jellyfinIndexForMpvTrack(JellyfinStreamType type, int mpvId) { + final sameType = mediaStreams + .where((m) => m.type == type && !m.isExternal) + .toList() + ..sort((a, b) => a.index.compareTo(b.index)); + return mpvId >= 1 && mpvId <= sameType.length ? sameType[mpvId - 1].index : null; + } +} + +/// Result of PlaybackInfo. Report progress with [playSessionId] and the +/// chosen source's id. +class JellyfinPlaybackInfo { + final String playSessionId; + final List mediaSources; + const JellyfinPlaybackInfo({required this.playSessionId, required this.mediaSources}); + + factory JellyfinPlaybackInfo.fromJson(JellyfinJson j) => JellyfinPlaybackInfo( + playSessionId: _str(j['PlaySessionId']) ?? '', + mediaSources: _list(j['MediaSources']).map(JellyfinMediaSource.fromJson).toList(), + ); + + /// The source to direct-play: the first that supports it, else the first. + JellyfinMediaSource? get primarySource { + for (final s in mediaSources) { + if (s.supportsDirectPlay) return s; + } + return mediaSources.isEmpty ? null : mediaSources.first; + } +} diff --git a/packages/core/lib/src/jellyfin/track_match.dart b/packages/core/lib/src/jellyfin/track_match.dart new file mode 100644 index 0000000..2f3c35f --- /dev/null +++ b/packages/core/lib/src/jellyfin/track_match.dart @@ -0,0 +1,172 @@ +/// Carrying the viewer's audio/subtitle choice from one episode to the +/// next. Pure functions, no I/O. +library; + +import 'models.dart'; + +/// The traits of a chosen track that are compared against the next +/// episode's tracks. Stream indices are not compared: they shift between +/// releases. +class TrackPreference { + final JellyfinStreamType type; + final String? language; + final String? title; + final String? codec; + final int? channels; + final bool isForced; + final bool isHearingImpaired; + const TrackPreference({ + required this.type, + this.language, + this.title, + this.codec, + this.channels, + this.isForced = false, + this.isHearingImpaired = false, + }); + + factory TrackPreference.fromStream(JellyfinMediaStream s) => TrackPreference( + type: s.type, + language: s.language, + title: s.title, + codec: s.codec, + channels: s.channels, + isForced: s.isForced, + isHearingImpaired: s.isHearingImpaired, + ); +} + +/// Picks the track in [candidates] that best matches [preference]: +/// 1. same type and same language (required; ISO 639-1/639-2 codes are +/// equated, so "en" = "eng" and "ger" = "deu"; a missing or "und" +/// language only matches another missing/"und" one); +/// 2. most similar title (exact match first, then shared words, so "Full +/// Subtitles" keeps "Full" over "Signs & Songs"); +/// 3. same forced / hearing-impaired flags; +/// 4. same codec, then the closest channel count; +/// 5. the stream marked default, then the lowest index. +/// Returns null when no candidate has the language: the caller then keeps +/// the server/player default. +JellyfinMediaStream? matchTrack(TrackPreference preference, List candidates) { + final lang = normalizeLanguage(preference.language); + final pool = candidates + .where((s) => s.type == preference.type && normalizeLanguage(s.language) == lang) + .toList(); + if (pool.isEmpty) return null; + final prefTokens = _titleTokens(preference.title); + final prefTitle = _normTitle(preference.title); + + List score(JellyfinMediaStream s) => [ + _normTitle(s.title) == prefTitle ? 2 : _jaccard(prefTokens, _titleTokens(s.title)), + s.isForced == preference.isForced ? 1 : 0, + s.isHearingImpaired == preference.isHearingImpaired ? 1 : 0, + _eq(s.codec, preference.codec) ? 1 : 0, + (preference.channels == null || s.channels == null) + ? 0 + : -(s.channels! - preference.channels!).abs(), + s.isDefault ? 1 : 0, + -s.index, + ]; + + JellyfinMediaStream? best; + List? bestScore; + for (final s in pool) { + final sc = score(s); + if (bestScore == null || _compare(sc, bestScore) > 0) { + best = s; + bestScore = sc; + } + } + return best; +} + +/// Result of [matchTracks]. Indices are Jellyfin stream indices (also what +/// progress reports carry). [subtitleStreamIndex] is -1 for "subtitles +/// off"; null in either field means no match (use the default). +class TrackSelection { + final int? audioStreamIndex; + final int? subtitleStreamIndex; + const TrackSelection({this.audioStreamIndex, this.subtitleStreamIndex}); + + @override + String toString() => 'TrackSelection(audio: $audioStreamIndex, subtitle: $subtitleStreamIndex)'; +} + +/// Carries a whole choice over: [audio] and [subtitle] are the tracks +/// picked on the previous episode (null = nothing picked, keep defaults); +/// [subtitlesOff] means the viewer turned subtitles off, which carries over +/// as -1. [streams] are the next episode's streams. +TrackSelection matchTracks({ + JellyfinMediaStream? audio, + JellyfinMediaStream? subtitle, + bool subtitlesOff = false, + required List streams, +}) => + TrackSelection( + audioStreamIndex: + audio == null ? null : matchTrack(TrackPreference.fromStream(audio), streams)?.index, + subtitleStreamIndex: subtitlesOff + ? -1 + : subtitle == null + ? null + : matchTrack(TrackPreference.fromStream(subtitle), streams)?.index, + ); + +const _iso1To2 = { + 'en': 'eng', 'ja': 'jpn', 'de': 'deu', 'fr': 'fra', 'es': 'spa', 'it': 'ita', + 'pt': 'por', 'ru': 'rus', 'zh': 'zho', 'ko': 'kor', 'ar': 'ara', 'nl': 'nld', + 'pl': 'pol', 'sv': 'swe', 'no': 'nor', 'nb': 'nob', 'da': 'dan', 'fi': 'fin', + 'cs': 'ces', 'sk': 'slk', 'hu': 'hun', 'ro': 'ron', 'el': 'ell', 'tr': 'tur', + 'he': 'heb', 'hi': 'hin', 'th': 'tha', 'vi': 'vie', 'id': 'ind', 'ms': 'msa', + 'uk': 'ukr', 'bg': 'bul', 'hr': 'hrv', 'sr': 'srp', 'fa': 'fas', 'ca': 'cat', +}; + +// ISO 639-2/B (bibliographic) -> 639-2/T, which Jellyfin mostly reports. +const _bToT = { + 'ger': 'deu', 'fre': 'fra', 'chi': 'zho', 'dut': 'nld', 'cze': 'ces', 'slo': 'slk', + 'gre': 'ell', 'rum': 'ron', 'per': 'fas', 'may': 'msa', 'baq': 'eus', 'arm': 'hye', + 'geo': 'kat', 'ice': 'isl', 'mac': 'mkd', 'alb': 'sqi', 'bur': 'mya', 'wel': 'cym', + 'tib': 'bod', +}; + +/// Lower-case ISO 639-2/T code, or null for none / "und". +String? normalizeLanguage(String? language) { + if (language == null) return null; + var l = language.trim().toLowerCase(); + final dash = l.indexOf(RegExp(r'[-_]')); + if (dash > 0) l = l.substring(0, dash); // "en-US" -> "en" + if (l.isEmpty || l == 'und' || l == 'unk' || l == 'mis' || l == 'zxx') return null; + l = _iso1To2[l] ?? l; + return _bToT[l] ?? l; +} + +const _stopWords = {'and', 'the', 'of', 'a'}; + +String _normTitle(String? t) => _titleTokens(t).join(' '); + +List _titleTokens(String? title) { + if (title == null) return const []; + return title + .toLowerCase() + .split(RegExp(r'[^a-z0-9À-￿]+')) + .where((w) => w.isNotEmpty && !_stopWords.contains(w)) + .map((w) => w.length > 3 && w.endsWith('s') ? w.substring(0, w.length - 1) : w) + .toList(); +} + +double _jaccard(List a, List b) { + if (a.isEmpty && b.isEmpty) return 1; + final sa = a.toSet(), sb = b.toSet(); + final inter = sa.intersection(sb).length; + return inter / sa.union(sb).length; +} + +bool _eq(String? a, String? b) => a != null && b != null && a.toLowerCase() == b.toLowerCase(); + +int _compare(List a, List b) { + for (var i = 0; i < a.length; i++) { + final c = a[i].compareTo(b[i]); + if (c != 0) return c; + } + return 0; +} diff --git a/packages/core/test/fixtures/jellyfin/auth_result.json b/packages/core/test/fixtures/jellyfin/auth_result.json new file mode 100644 index 0000000..881ae09 --- /dev/null +++ b/packages/core/test/fixtures/jellyfin/auth_result.json @@ -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" +} diff --git a/packages/core/test/fixtures/jellyfin/bs_episodes.json b/packages/core/test/fixtures/jellyfin/bs_episodes.json new file mode 100644 index 0000000..adc852b --- /dev/null +++ b/packages/core/test/fixtures/jellyfin/bs_episodes.json @@ -0,0 +1,1230 @@ +{ + "Items": [ + { + "Name": "Rice and Thieves and Heroes", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "b18826be994070a79f9b52bc0f66f052", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2022-08-12T23:00:00.0000000Z", + "ChannelId": null, + "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", + "SeriesPrimaryImageTag": "e62d963bb15310945fadffaef0334c35", + "SeasonName": "Season 1", + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + } + ], + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "3caf8126c9a3e5dd0dc6f50317707808" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "fe59b8bb26fe8d676956311969a816a4", + "ParentThumbItemId": "e9aec9c14a1fcab775111844112b4cf7", + "ParentThumbImageTag": "8e40321ea0aca027e3407eb6b76c21ba", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "Battle with the Heroes", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "74c50bdbf83e8fef231dd7710058e197", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2022-08-19T23:00:00.0000000Z", + "ChannelId": null, + "CommunityRating": 7.429, + "RunTimeTicks": 14201120000, + "ProductionYear": 2022, + "IndexNumber": 7, + "ParentIndexNumber": 1, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "e9aec9c14a1fcab775111844112b4cf7", + "ParentBackdropItemId": "e9aec9c14a1fcab775111844112b4cf7", + "ParentBackdropImageTags": [ + "0cac7765a1364090b549bde6aa29cd58" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "416588001007", + "ItemId": "74c50bdbf83e8fef231dd7710058e197" + }, + "SeriesName": "Black Summoner", + "SeriesId": "e9aec9c14a1fcab775111844112b4cf7", + "SeasonId": "fb0b77cfda15445f9319ed9d753dba23", + "SeriesPrimaryImageTag": "e62d963bb15310945fadffaef0334c35", + "SeasonName": "Season 1", + "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": 8598042, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "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, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + } + ], + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "0a5e26c7086a04a06510d0d1012b89b3" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "fe59b8bb26fe8d676956311969a816a4", + "ParentThumbItemId": "e9aec9c14a1fcab775111844112b4cf7", + "ParentThumbImageTag": "8e40321ea0aca027e3407eb6b76c21ba", + "LocationType": "FileSystem", + "MediaType": "Video" + } + ], + "TotalRecordCount": 2, + "StartIndex": 0 +} \ No newline at end of file diff --git a/packages/core/test/fixtures/jellyfin/episode_detail.json b/packages/core/test/fixtures/jellyfin/episode_detail.json new file mode 100644 index 0000000..555e5ed --- /dev/null +++ b/packages/core/test/fixtures/jellyfin/episode_detail.json @@ -0,0 +1,1278 @@ +{ + "Name": "Rice and Thieves and Heroes", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "b18826be994070a79f9b52bc0f66f052", + "Etag": "f074999cc2354d5a213fe0941d803767", + "DateCreated": "2026-10-04T20:21:17.1948519Z", + "CanDelete": false, + "CanDownload": true, + "HasSubtitles": true, + "Container": "mkv", + "SortName": "001 - 0006 - Rice and Thieves and Heroes", + "PremiereDate": "2022-08-12T23:00:00.0000000Z", + "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 + } + ], + "Path": "/media/Black Summoner - S01E06 - Rice and Thieves and Heroes WEBDL-1080p.mkv", + "EnableMediaSourceDisplay": true, + "ChannelId": null, + "Overview": "Kelvin sets off on a new journey in search of more powerful foes... and rice?!", + "Taglines": [], + "Genres": [], + "CommunityRating": 7.143, + "RunTimeTicks": 14201120000, + "PlayAccess": "Full", + "ProductionYear": 2022, + "IndexNumber": 6, + "ParentIndexNumber": 1, + "RemoteTrailers": [], + "ProviderIds": { + "Imdb": "tt21743224", + "Tvdb": "9244417" + }, + "IsHD": true, + "IsFolder": false, + "ParentId": "fb0b77cfda15445f9319ed9d753dba23", + "Type": "Episode", + "Studios": [], + "GenreItems": [], + "ParentLogoItemId": "e9aec9c14a1fcab775111844112b4cf7", + "ParentBackdropItemId": "e9aec9c14a1fcab775111844112b4cf7", + "ParentBackdropImageTags": [ + "0cac7765a1364090b549bde6aa29cd58" + ], + "LocalTrailerCount": 0, + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "416588001006", + "ItemId": "b18826be994070a79f9b52bc0f66f052" + }, + "SeriesName": "Black Summoner", + "SeriesId": "e9aec9c14a1fcab775111844112b4cf7", + "SeasonId": "fb0b77cfda15445f9319ed9d753dba23", + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "26dd4a82b00c839c498471cdba80a99c", + "Tags": [], + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "e62d963bb15310945fadffaef0334c35", + "SeasonName": "Season 1", + "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 + } + ], + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "3caf8126c9a3e5dd0dc6f50317707808" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "fe59b8bb26fe8d676956311969a816a4", + "SeriesStudio": "Tokyo MX", + "ParentThumbItemId": "e9aec9c14a1fcab775111844112b4cf7", + "ParentThumbImageTag": "8e40321ea0aca027e3407eb6b76c21ba", + "Trickplay": {}, + "LocationType": "FileSystem", + "MediaType": "Video", + "LockedFields": [], + "LockData": false, + "Width": 1920, + "Height": 1080 +} diff --git a/packages/core/test/fixtures/jellyfin/episodes.json b/packages/core/test/fixtures/jellyfin/episodes.json new file mode 100644 index 0000000..7dd7d73 --- /dev/null +++ b/packages/core/test/fixtures/jellyfin/episodes.json @@ -0,0 +1,1880 @@ +{ + "Items": [ + { + "Name": "Irregular", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "5cf1c471c9bc0f52447bf3c12bc71317", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2022-01-13T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "Shizuku and the other students return to the royal capital. Their brush with death, and the dramatic reencounter with Hajime Nagumo, has had different effects on each student's mind. Hajime, now joined by Kaori, arrives at the Gruen Desert.", + "CommunityRating": 7, + "RunTimeTicks": 14220520000, + "ProductionYear": 2022, + "IndexNumber": 1, + "ParentIndexNumber": 2, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019002001", + "ItemId": "5cf1c471c9bc0f52447bf3c12bc71317" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "331a23499d051bc288d281f2c72557a9", + "PrimaryImageAspectRatio": 1.7793594306049823, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 2", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "f5b466f364ad9a6cd798cd7a1c743c78" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "Burning Light", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "f5600f84e2c02c1f30442bf679dcbb1d", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2022-01-20T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "Hajime's party reaches Ankaji. Hajime must perform multiple miracles to save this dukedom from certain doom before traveling to his real destination, the Grand Gruen Volcano, the next labyrinth on his list.", + "CommunityRating": 6.9, + "RunTimeTicks": 14220460000, + "ProductionYear": 2022, + "IndexNumber": 2, + "ParentIndexNumber": 2, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019002002", + "ItemId": "f5600f84e2c02c1f30442bf679dcbb1d" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "331a23499d051bc288d281f2c72557a9", + "PrimaryImageAspectRatio": 1.7761989342806395, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 2", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "a39249415e0bdd62f0007f5668c25671" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "Black and White", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "960b1e8ddf1418f2589c28b1f8ce3689", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2022-01-27T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "Hajime completes the grueling trial at the end of the Grand Gruen Volcano labyrinth, but at that exact moment, Freid, a demon commander, launches a surprise attack. Hajime is heavily injured, and a desperate battle begins.", + "CommunityRating": 7, + "RunTimeTicks": 14221290000, + "ProductionYear": 2022, + "IndexNumber": 3, + "ParentIndexNumber": 2, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019002003", + "ItemId": "960b1e8ddf1418f2589c28b1f8ce3689" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "331a23499d051bc288d281f2c72557a9", + "PrimaryImageAspectRatio": 1.7761989342806395, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 2", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "a0d4662f914df9feeb57ff778e72fdd3" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "Reunion", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "56d6b3b65c83213fcbaeaf85c9239189", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2022-02-03T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "Tio escapes the volcano alone and flies towards Ankaji. Meanwhile, Hajime and the others, still in the volcano, hatch a plan to escape the incoming lava without sacrificing any of their goals.", + "CommunityRating": 6.6, + "RunTimeTicks": 14220880000, + "ProductionYear": 2022, + "IndexNumber": 4, + "ParentIndexNumber": 2, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019002004", + "ItemId": "56d6b3b65c83213fcbaeaf85c9239189" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "331a23499d051bc288d281f2c72557a9", + "PrimaryImageAspectRatio": 1.7761989342806395, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 2", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "42d15dba598f0df0a974918a386f22f2" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "Memories from the Bottom of the Sea", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "595c9d323f49255cb29e8fb59c0eaa0f", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2022-02-10T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "Hajime and his party departs the city of Erisen to head towards the Sunken Ruins of Melusine. They solve multiple puzzles to reach the labyrinth, and are confronted by a mysterious illusion.", + "CommunityRating": 6.5, + "RunTimeTicks": 14220040000, + "ProductionYear": 2022, + "IndexNumber": 5, + "ParentIndexNumber": 2, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019002005", + "ItemId": "595c9d323f49255cb29e8fb59c0eaa0f" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "331a23499d051bc288d281f2c72557a9", + "PrimaryImageAspectRatio": 1.7761989342806395, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 2", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "db62dfc6d4a0b5453c6903bef3cd737a" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "Someone Important", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "699bb1ffb4dfa426be8fab3ff275cad9", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2022-02-17T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "Hajime and Kaori, separated from the others, move deeper into the labyrinth. They encounter an even more disturbing illusion, but Kaori has more important concerns - does she really want to continue this relationship with Hajime?", + "CommunityRating": 7, + "RunTimeTicks": 14220880000, + "ProductionYear": 2022, + "IndexNumber": 6, + "ParentIndexNumber": 2, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019002006", + "ItemId": "699bb1ffb4dfa426be8fab3ff275cad9" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "331a23499d051bc288d281f2c72557a9", + "PrimaryImageAspectRatio": 1.7761989342806395, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 2", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "ebc0a725a6349e7568cd4c2134435b37" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "A New Vow", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "a699b1979d45458e39ca1100471ff4ac", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2022-02-24T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "Several days have passed since Hajime and his party returned safely from the Sunken Ruins of Melusine. Though Hajime knows he needs to get going, he doesn't want to break Myu's heart.", + "CommunityRating": 7, + "RunTimeTicks": 14220880000, + "ProductionYear": 2022, + "IndexNumber": 7, + "ParentIndexNumber": 2, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019002007", + "ItemId": "a699b1979d45458e39ca1100471ff4ac" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "331a23499d051bc288d281f2c72557a9", + "PrimaryImageAspectRatio": 1.7761989342806395, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 2", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "995e865f1a69d0c5b36f683636a03dfb" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "Disturbing Shadow", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "6c3b54f3270305c071116945acf167be", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2022-03-03T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "Daisuke and his shadowy co-conspirator overtly take action against Commander Meld, triggering the final phase of their nefarious plan. Meanwhile, Hajime encounters a group of bandits attacking someone he has met before.", + "CommunityRating": 7, + "RunTimeTicks": 14221290000, + "ProductionYear": 2022, + "IndexNumber": 8, + "ParentIndexNumber": 2, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019002008", + "ItemId": "6c3b54f3270305c071116945acf167be" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "331a23499d051bc288d281f2c72557a9", + "PrimaryImageAspectRatio": 1.7761989342806395, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 2", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "e40300ca75b4df099c22791a7e063c27" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "Invasion of the Royal Capital", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "2a6817c6d4b91f7686982129aeaf5e55", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2022-03-10T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "Hajime arrives at the Divine Mountain to rescue Aiko, but is immediately attacked by Noint. Meanwhile, a massive force of Demons and monsters suddenly appear just outside the capital city of Heiligh and immediately begin sieging the city.", + "CommunityRating": 7.3, + "RunTimeTicks": 14220880000, + "ProductionYear": 2022, + "IndexNumber": 9, + "ParentIndexNumber": 2, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019002009", + "ItemId": "2a6817c6d4b91f7686982129aeaf5e55" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "331a23499d051bc288d281f2c72557a9", + "PrimaryImageAspectRatio": 1.7761989342806395, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 2", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "2861c5671679cc506aafb5bca6b37406" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "God's Apostle", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "be026d864ce4ae26cab1b851335decb7", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2022-03-17T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "As Hajime's deadly duel versus Noint continues, Aiko and Tio hatch a plan to assist Hajime. The other students also notice the alarming noise and gather together to figure out what's going on.", + "CommunityRating": 6.8, + "RunTimeTicks": 14220880000, + "ProductionYear": 2022, + "IndexNumber": 10, + "ParentIndexNumber": 2, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019002010", + "ItemId": "be026d864ce4ae26cab1b851335decb7" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "331a23499d051bc288d281f2c72557a9", + "PrimaryImageAspectRatio": 1.7761989342806395, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 2", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "17942758883bd865c20e23ef08e5204f" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "Betrayal", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "a8cf073a2a9e1fc74c999f980993d2ad", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2022-03-24T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "With her trap sprung and all of the students rendered unable to fight, Eri reveals her true nature and sets the rest of her plan in motion.", + "CommunityRating": 7.7, + "RunTimeTicks": 14220880000, + "ProductionYear": 2022, + "IndexNumber": 11, + "ParentIndexNumber": 2, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019002011", + "ItemId": "a8cf073a2a9e1fc74c999f980993d2ad" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "331a23499d051bc288d281f2c72557a9", + "PrimaryImageAspectRatio": 1.7761989342806395, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 2", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "8eda7dee5f7afbd0f19d30b1bb22e808" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "A New Journey", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "0de0fa996104f03995c677b317b5bc3c", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2022-03-30T23:00:00.0000000Z", + "ChannelId": null, + "Overview": "Hajime once again saved the day, thwarting Eri's evil plans and the demons' invasion. With the royal capital now safe, Hajime discusses future plans with his companions and classmates.", + "CommunityRating": 7.6, + "RunTimeTicks": 14220040000, + "ProductionYear": 2022, + "IndexNumber": 12, + "ParentIndexNumber": 2, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019002012", + "ItemId": "0de0fa996104f03995c677b317b5bc3c" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "331a23499d051bc288d281f2c72557a9", + "PrimaryImageAspectRatio": 1.7761989342806395, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 2", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "c56bd1fa37e923b173a4e4e0f3a5b16b" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "Haulias Assemble", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "bbc737c39115b7fb0d23e45ed7b1ddb1", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2024-10-13T23:00:00.0000000Z", + "ChannelId": null, + "Overview": "With the Heiligh Kingdom heavily damaged, Princess Liliana travels to the Hoelscher Empire to request their aid. On their way, Hajime spots Haulia tribe members fighting imperial soldiers. The tribe members bring news of a coming invasion.", + "CommunityRating": 7.5, + "RunTimeTicks": 14226400000, + "ProductionYear": 2024, + "IndexNumber": 1, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003001", + "ItemId": "bbc737c39115b7fb0d23e45ed7b1ddb1" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "3385b278ca0385a70b0c2bbb723a5d10" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "Haulias Assemble", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "96d4408695909a6b8a23f0a4a09ab553", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2024-10-14T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "With the Heiligh Kingdom heavily damaged, Princess Liliana travels to the Hoelscher Empire to request their aid. On their way, Hajime spots Haulia tribe members fighting imperial soldiers. The tribe members bring news of a coming invasion.", + "RunTimeTicks": 14200630000, + "ProductionYear": 2024, + "IndexNumber": 1, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003001", + "ItemId": "96d4408695909a6b8a23f0a4a09ab553" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "7b9fa3a5eee271586ceb099ee7afa294" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "Roar of Revolution", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "03cde2f3c87bdb86aa4ff53a24beb673", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2024-10-20T23:00:00.0000000Z", + "ChannelId": null, + "Overview": "Hajime catches wind of conflict brewing between the empire and the rabbit beastmen.", + "CommunityRating": 7.5, + "RunTimeTicks": 14220880000, + "ProductionYear": 2024, + "IndexNumber": 2, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003002", + "ItemId": "03cde2f3c87bdb86aa4ff53a24beb673" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "abf52be1cda3fe29591338b5f3f1f0a6" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "Roar of Revolution", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "b81a04063040b6cc77d74ec80539dd9f", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2024-10-21T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "Hajime catches wind of conflict brewing between the empire and the rabbit beastmen.", + "RunTimeTicks": 14214400000, + "ProductionYear": 2024, + "IndexNumber": 2, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003002", + "ItemId": "b81a04063040b6cc77d74ec80539dd9f" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "a3f63faede3475b9285f1dcbf1dc6223" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "The Princess's Ordeal", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "98e9db7885a76c8aa5da050df0a03933", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2024-10-28T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "Liliana meets the emperor to discuss receiving aid from him. She must make a great personal sacrifice to solidify this. She is ready to do so, but Hajime seems to have other plans.", + "CommunityRating": 8.333, + "RunTimeTicks": 14220880000, + "ProductionYear": 2024, + "IndexNumber": 3, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003003", + "ItemId": "98e9db7885a76c8aa5da050df0a03933" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "451f86e3c985a8f84cd2debd96936a8a" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "The Princess's Ordeal", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "ef2c998d5732dc989a4da9407f9b6d21", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2024-10-28T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "Liliana meets the emperor to discuss receiving aid from him. She must make a great personal sacrifice to solidify this. She is ready to do so, but Hajime seems to have other plans.", + "CommunityRating": 10, + "RunTimeTicks": 14201320000, + "ProductionYear": 2024, + "IndexNumber": 3, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003003", + "ItemId": "ef2c998d5732dc989a4da9407f9b6d21" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "4d7d055f34576059b41d66a9e00a5d34" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "The Empire Versus The Terrifying Rabbits", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "e6d4167b5106381fcaee04b3270ab033", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2024-11-04T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "The party to celebrate Liliana's visit to the imperial capital and announce her engagement to the imperial crown prince begins. However, the Haulia Tribe has a whole different event planned for the night.", + "RunTimeTicks": 14200630000, + "ProductionYear": 2024, + "IndexNumber": 4, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003004", + "ItemId": "e6d4167b5106381fcaee04b3270ab033" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "c551ad410b062ca8323e9c3cc88014d2" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "The Empire vs. the Strongest Rabbits", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "6a293b93ee4c3abed15dc4f13f20897b", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2024-11-04T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "The party to celebrate Liliana's visit to the imperial capital and announce her engagement to the imperial crown prince begins. However, the Haulia Tribe has a whole different event planned for the night.", + "CommunityRating": 7, + "RunTimeTicks": 14220880000, + "ProductionYear": 2024, + "IndexNumber": 4, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003004", + "ItemId": "6a293b93ee4c3abed15dc4f13f20897b" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "3a9a1346bd2840917780f2de7ae75632" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "The Hero Returns", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "b82a2063a57c4fc1572bd6ba019ab805", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2024-11-11T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "After the Haulia Tribe's victory over the Empire, Hajime and the others travel to Verbergen to prepare to take on a new Great Labyrinth. During his visit, Hajime gets an opportunity to deepen his bond with Shea.", + "RunTimeTicks": 14201090000, + "ProductionYear": 2024, + "IndexNumber": 5, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003005", + "ItemId": "b82a2063a57c4fc1572bd6ba019ab805" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "2fc92172853ff0904c031c7d11723445" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "The Hero Returns", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "f92dd11dbd24549144a5618997216d0c", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2024-11-11T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "After the Haulia Tribe's victory over the Empire, Hajime and the others travel to Verbergen to prepare to take on a new Great Labyrinth. During his visit, Hajime gets an opportunity to deepen his bond with Shea.", + "CommunityRating": 7.5, + "RunTimeTicks": 14220880000, + "ProductionYear": 2024, + "IndexNumber": 5, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003005", + "ItemId": "f92dd11dbd24549144a5618997216d0c" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "51a0980f550067cf637ca68bb5a2d86c" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "Haltina's Labyrinth", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "25ae25ebfa2d5503481369199d76cfe4", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2024-11-18T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "It's now time to tackle the next Great Labyrinth, located deep within the Haltina Sea of Trees. Immediately upon entering, it is made clear to Hajime that this is not going to be a straightforward hack-and-slash adventure.", + "CommunityRating": 7, + "RunTimeTicks": 14220880000, + "ProductionYear": 2024, + "IndexNumber": 6, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003006", + "ItemId": "25ae25ebfa2d5503481369199d76cfe4" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "edb23c05f25f7b01fffd4bd0e1b91930" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "Haltina's Labyrinth", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "5ce576333e925b5dccb39e34d35369be", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2024-11-18T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "It's now time to tackle the next Great Labyrinth, located deep within the Haltina Sea of Trees. Immediately upon entering, it is made clear to Hajime that this is not going to be a straightforward hack-and-slash adventure.", + "RunTimeTicks": 14201090000, + "ProductionYear": 2024, + "IndexNumber": 6, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003006", + "ItemId": "5ce576333e925b5dccb39e34d35369be" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "916c2cf4ab0ff268099fc3bab14f571e" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "A Devilish Trial", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "41d88a50346c23aecb9ce0c03e74a53e", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2024-11-25T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "Hajime wakes up from the dream trap and waits for the others to break free as well. As they progress deeper into the labyrinth, many more mind-rending attacks are thrown against them.", + "RunTimeTicks": 14201090000, + "ProductionYear": 2024, + "IndexNumber": 7, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003007", + "ItemId": "41d88a50346c23aecb9ce0c03e74a53e" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "3b3a4dbf916aa98ee818461bf5ad8187" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "A Devilish Trial", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "6d83a58d359f4555d597dcde24178bea", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2024-11-25T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "Hajime wakes up from the dream trap and waits for the others to break free as well. As they progress deeper into the labyrinth, many more mind-rending attacks are thrown against them.", + "CommunityRating": 7, + "RunTimeTicks": 14220880000, + "ProductionYear": 2024, + "IndexNumber": 7, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003007", + "ItemId": "6d83a58d359f4555d597dcde24178bea" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "04307877bb3f13ea67461b5a83d0c353" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "Grab Hold of Hope", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "03a221c791332223522233f83255ee57", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2024-12-02T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "Another trap activates, reversing the emotions each party member holds against the others. Hajime and Yue hate each other, while Kouki has nothing but respect towards Hajime. Moreover, they all fall in love with the enemy.", + "CommunityRating": 7, + "RunTimeTicks": 14220880000, + "ProductionYear": 2024, + "IndexNumber": 8, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003008", + "ItemId": "03a221c791332223522233f83255ee57" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "b18f2393b2206fecf9dd36914e44569c" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "Grab Hold of Hope", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "dfe0c32648b2a826b7cfcf2a55b117c6", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2024-12-02T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "Another trap activates, reversing the emotions each party member holds against the others. Hajime and Yue hate each other, while Kouki has nothing but respect towards Hajime. Moreover, they all fall in love with the enemy.", + "RunTimeTicks": 14215000000, + "ProductionYear": 2024, + "IndexNumber": 8, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003008", + "ItemId": "dfe0c32648b2a826b7cfcf2a55b117c6" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "c21b09a06ecc56c4e4f2a46076ea895b" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "Changing Heart", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "98b75aee0fdfd760d72f331d6f692b3d", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2024-12-09T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "Hajime finally confirms that learning all of the ancient magics can help him return to Earth. With this newfound hope in heart, he reaffirms the bond between him and his friends - including someone whom he now treats as more than a friend.", + "CommunityRating": 7, + "RunTimeTicks": 14220880000, + "ProductionYear": 2024, + "IndexNumber": 9, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003009", + "ItemId": "98b75aee0fdfd760d72f331d6f692b3d" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "1ace10ba1c9087572d984e2dc16290f9" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "Changing Heart", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "9ffd79e5fec5a82b9b7bddecdcb810e3", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2024-12-09T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "Hajime finally confirms that learning all of the ancient magics can help him return to Earth. With this newfound hope in heart, he reaffirms the bond between him and his friends - including someone whom he now treats as more than a friend.", + "RunTimeTicks": 14201090000, + "ProductionYear": 2024, + "IndexNumber": 9, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003009", + "ItemId": "9ffd79e5fec5a82b9b7bddecdcb810e3" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "c88112510876eb7b5bb8f6cd241329c8" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "The Final Labyrinth", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "1e4c16c14a60788332edbc892aae7247", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2024-12-23T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "With all but one Great Labyrinth conquered, Hajime and the group travel to the Schnee Snow Fields to tackle the final labyrinth. Meanwhile, it becomes clear that certain members of the group have internal issues to deal with.", + "CommunityRating": 7.5, + "RunTimeTicks": 14220880000, + "ProductionYear": 2024, + "IndexNumber": 10, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003010", + "ItemId": "1e4c16c14a60788332edbc892aae7247" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "782c29fefe20f203874603601dc15466" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "The Final Labyrinth", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "9a9402461e0aa218f8cd3bd393847c02", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2024-12-23T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "With all but one Great Labyrinth conquered, Hajime and the group travel to the Schnee Snow Fields to tackle the final labyrinth. Meanwhile, it becomes clear that certain members of the group have internal issues to deal with.", + "RunTimeTicks": 14200160000, + "ProductionYear": 2024, + "IndexNumber": 10, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003010", + "ItemId": "9a9402461e0aa218f8cd3bd393847c02" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "db42fe7ce541e7943a99317a376aa50f" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "Charging Emotions", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "046f33d7c857599646eafd0fac25fc82", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2024-12-30T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "As the party pushes deeper into the labyrnth and reach an area where all of the icy walls are reflective, like a mirror. The mind games begin, and Kouki is the first to fall prey to the labyrinth's tricks.", + "CommunityRating": 7.5, + "RunTimeTicks": 14220880000, + "ProductionYear": 2024, + "IndexNumber": 11, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003011", + "ItemId": "046f33d7c857599646eafd0fac25fc82" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "aed1abd5f1c6afda6e2e1b3f88637cc5" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "Charging Emotions", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "126e57879017a8b3914375e3f73e577a", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2024-12-30T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "As the party pushes deeper into the labyrnth and reach an area where all of the icy walls are reflective, like a mirror. The mind games begin, and Kouki is the first to fall prey to the labyrinth's tricks.", + "RunTimeTicks": 14200630000, + "ProductionYear": 2024, + "IndexNumber": 11, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003011", + "ItemId": "126e57879017a8b3914375e3f73e577a" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "bf25de40ed827063a33de469441d9e97" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "True Heart", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "40ed47e5564476afbab729649ffcac12", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2025-01-20T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "Aside from pointing out what a huge edgelord Hajime is, the labyrinth begins pointing out other painful realities that Hajime was trying to look away from. Meanwhile, Shizuku is subjected to the same ordeals.", + "RunTimeTicks": 14200200000, + "ProductionYear": 2025, + "IndexNumber": 12, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003012", + "ItemId": "40ed47e5564476afbab729649ffcac12" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "f41eb48bf3afe74a80d5fb990c7b3a23" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "True Heart", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "5ad6c8a4ee90cb7efa320ecd253fd38c", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2025-01-20T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "Aside from pointing out what a huge edgelord Hajime is, the labyrinth begins pointing out other painful realities that Hajime was trying to look away from. Meanwhile, Shizuku is subjected to the same ordeals.", + "CommunityRating": 7.5, + "RunTimeTicks": 14220880000, + "ProductionYear": 2025, + "IndexNumber": 12, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003012", + "ItemId": "5ad6c8a4ee90cb7efa320ecd253fd38c" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "054aac4e821eefda4e7a7674d771f4b6" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "Thank God She's Still a Pervert!", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "61690551598f8bf8865beef4ba017908", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2025-01-27T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "Hajime rescues Shizuku in the nick of time, just as she was about to succumb to the labyrinth's trial. Suzu, Ryutarou and Tio also face their own tests.", + "CommunityRating": 7.5, + "RunTimeTicks": 14220880000, + "ProductionYear": 2025, + "IndexNumber": 13, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003013", + "ItemId": "61690551598f8bf8865beef4ba017908" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "42d1835b006bc064401f4e8623dc2ff6" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "Thank God She's Still a Pervert!", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "d85955ef88bc400984055f1b8a002006", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2025-01-27T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "Hajime rescues Shizuku in the nick of time, just as she was about to succumb to the labyrinth's trial. Suzu, Ryutarou and Tio also face their own tests.", + "RunTimeTicks": 14208400000, + "ProductionYear": 2025, + "IndexNumber": 13, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003013", + "ItemId": "d85955ef88bc400984055f1b8a002006" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "93444872a30628ec075757085e001a2c" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "The Overpowered Vampire Princess and the Godlike Rabbit's Grand Battle", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "ac5cb1d112e017db42f628536eac1591", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2025-02-03T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "Yue notices something during her test and wants to warn Shea about it, but Yue's phrasing triggers Shea's outrage.", + "CommunityRating": 7.5, + "RunTimeTicks": 14220880000, + "ProductionYear": 2025, + "IndexNumber": 14, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003014", + "ItemId": "ac5cb1d112e017db42f628536eac1591" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "43a3ae951675ad7244fcd8803f670030" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "What Makes a Hero", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "7d42c6b56d28fa030358dc426c860e48", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2025-02-10T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "Kouki's test pushes him to face his insecurities about Hajime head-on.", + "CommunityRating": 7.5, + "RunTimeTicks": 14220880000, + "ProductionYear": 2025, + "IndexNumber": 15, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003015", + "ItemId": "7d42c6b56d28fa030358dc426c860e48" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "cdf606201baeb4f1f85b8141af9548ac" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + }, + { + "Name": "The Key to the World", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "9b66d81699fb3e27c46510b9a57aefe0", + "HasSubtitles": true, + "Container": "mkv", + "PremiereDate": "2025-02-17T00:00:00.0000000Z", + "ChannelId": null, + "Overview": "Hajime and Yue attempt to harness their new power to realize their ultimate goal - creating a way home.", + "CommunityRating": 7.5, + "RunTimeTicks": 14821570000, + "ProductionYear": 2025, + "IndexNumber": 16, + "ParentIndexNumber": 3, + "IsFolder": false, + "Type": "Episode", + "ParentLogoItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropItemId": "550eff23b37ec61082c988678ccc214c", + "ParentBackdropImageTags": [ + "a47f1e94c9e55509901bc1ea5c6fc743" + ], + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "357019003016", + "ItemId": "9b66d81699fb3e27c46510b9a57aefe0" + }, + "SeriesName": "Arifureta: From Commonplace to World's Strongest", + "SeriesId": "550eff23b37ec61082c988678ccc214c", + "SeasonId": "fc4506ab4a72e877de564512df943912", + "PrimaryImageAspectRatio": 1.7777777777777777, + "SeriesPrimaryImageTag": "dec730fd3bd5791ff7bb4eb9be416d4a", + "SeasonName": "Season 3", + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "67a2bbcba2ffc05f85b522cbf59fd902" + }, + "BackdropImageTags": [], + "ParentLogoImageTag": "8c0b809b58b8d300454e6d5aac22c334", + "ParentThumbItemId": "550eff23b37ec61082c988678ccc214c", + "ParentThumbImageTag": "75adcf7a63337d9888fa5d994779c879", + "LocationType": "FileSystem", + "MediaType": "Video" + } + ], + "TotalRecordCount": 41, + "StartIndex": 0 +} diff --git a/packages/core/test/fixtures/jellyfin/latest_shows.json b/packages/core/test/fixtures/jellyfin/latest_shows.json new file mode 100644 index 0000000..7a14479 --- /dev/null +++ b/packages/core/test/fixtures/jellyfin/latest_shows.json @@ -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" + } +] diff --git a/packages/core/test/fixtures/jellyfin/me.json b/packages/core/test/fixtures/jellyfin/me.json new file mode 100644 index 0000000..8c7b876 --- /dev/null +++ b/packages/core/test/fixtures/jellyfin/me.json @@ -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" +} diff --git a/packages/core/test/fixtures/jellyfin/movie_detail.json b/packages/core/test/fixtures/jellyfin/movie_detail.json new file mode 100644 index 0000000..8021735 --- /dev/null +++ b/packages/core/test/fixtures/jellyfin/movie_detail.json @@ -0,0 +1,1474 @@ +{ + "Name": "Kingdom.of.Heaven", + "ServerId": "122fce17c5b9413088af4be266bad6f4", + "Id": "0f438cd4d983a9a36c3db3070464449e", + "Etag": "2deae75a38b846060abd42856048e3c6", + "DateCreated": "2026-05-08T17:19:10.9154147Z", + "CanDelete": false, + "CanDownload": true, + "HasSubtitles": true, + "Container": "mkv", + "SortName": "kingdom of heaven", + "MediaSources": [ + { + "Protocol": "File", + "Id": "0f438cd4d983a9a36c3db3070464449e", + "Path": "/media/Kingdom.of.Heaven.2005.1080p.DSNP.WEB-DL.DDP5.1.H.264-GPRS.mkv", + "Type": "Default", + "Container": "mkv", + "Size": 7582095845, + "Name": "Kingdom.of.Heaven.2005.1080p.DSNP.WEB-DL.DDP5.1.H.264-GPRS", + "IsRemote": false, + "ETag": "c13ec52a7fa6cb0bcde04626c60646af", + "RunTimeTicks": 86766720000, + "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", + "VideoRange": "SDR", + "VideoRangeType": "SDR", + "AudioSpatialFormat": "None", + "DisplayTitle": "1080p H264 SDR", + "NalLengthSize": "4", + "IsInterlaced": false, + "IsAVC": true, + "BitRate": 6990787, + "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": "eng", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "English - Dolby Digital+ - 5.1 - Default", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "5.1", + "BitRate": 256000, + "Channels": 6, + "SampleRate": 48000, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Type": "Audio", + "Index": 1, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "ces", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Czech - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 2, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "dan", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Danish - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 3, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "deu", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "German - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 4, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "eng", + "TimeBase": "1/1000", + "Title": "SDH", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "SDH - English - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 5, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "spa", + "TimeBase": "1/1000", + "Title": "Latin American", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Latin American - Spanish - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 6, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "spa", + "TimeBase": "1/1000", + "Title": "European", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "European - Spanish - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 7, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "fin", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Finnish - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 8, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "fra", + "TimeBase": "1/1000", + "Title": "European", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "European - French - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 9, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "hun", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Hungarian - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 10, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "isl", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Icelandic - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 11, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "jpn", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Japanese - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 12, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "kor", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Korean - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 13, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "nld", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Dutch - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 14, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "nor", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Norwegian - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 15, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "pol", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Polish - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 16, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "por", + "TimeBase": "1/1000", + "Title": "Brazilian", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Brazilian - Portuguese - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 17, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "por", + "TimeBase": "1/1000", + "Title": "European", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "European - Portuguese - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 18, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "ron", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Romanian - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 19, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "swe", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Swedish - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 20, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "tur", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Turkish - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 21, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "zho", + "TimeBase": "1/1000", + "Title": "Hong Kong", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Hong Kong - Chinese - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 22, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "zho", + "TimeBase": "1/1000", + "Title": "Simplified", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Simplified - Chinese - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 23, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "zho", + "TimeBase": "1/1000", + "Title": "Traditional", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Traditional - Chinese - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 24, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + } + ], + "Formats": [], + "Bitrate": 7246787, + "RequiredHttpHeaders": {}, + "TranscodingSubProtocol": "http", + "DefaultAudioStreamIndex": 1, + "HasSegments": false + } + ], + "ProductionLocations": [], + "Path": "/media/Kingdom.of.Heaven.2005.1080p.DSNP.WEB-DL.DDP5.1.H.264-GPRS.mkv", + "EnableMediaSourceDisplay": true, + "ChannelId": null, + "Taglines": [], + "Genres": [], + "RunTimeTicks": 86766720000, + "PlayAccess": "Full", + "ProductionYear": 2005, + "RemoteTrailers": [], + "ProviderIds": {}, + "IsHD": true, + "IsFolder": false, + "ParentId": "a60b3822105fcf91755a77d530a1e2a8", + "Type": "Movie", + "Studios": [], + "GenreItems": [], + "LocalTrailerCount": 0, + "UserData": { + "PlaybackPositionTicks": 0, + "PlayCount": 0, + "IsFavorite": false, + "Played": false, + "Key": "0f438cd4-d983-a9a3-6c3d-b3070464449e", + "ItemId": "0f438cd4d983a9a36c3db3070464449e" + }, + "SpecialFeatureCount": 0, + "DisplayPreferencesId": "dbf7709c41faaa746463d67978eb863d", + "Tags": [], + "PrimaryImageAspectRatio": 1.7777777777777777, + "MediaStreams": [ + { + "Codec": "h264", + "ColorSpace": "bt709", + "ColorTransfer": "bt709", + "ColorPrimaries": "bt709", + "TimeBase": "1/1000", + "VideoRange": "SDR", + "VideoRangeType": "SDR", + "AudioSpatialFormat": "None", + "DisplayTitle": "1080p H264 SDR", + "NalLengthSize": "4", + "IsInterlaced": false, + "IsAVC": true, + "BitRate": 6990787, + "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": "eng", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedDefault": "Default", + "LocalizedExternal": "External", + "DisplayTitle": "English - Dolby Digital+ - 5.1 - Default", + "IsInterlaced": false, + "IsAVC": false, + "ChannelLayout": "5.1", + "BitRate": 256000, + "Channels": 6, + "SampleRate": 48000, + "IsDefault": true, + "IsForced": false, + "IsHearingImpaired": false, + "Type": "Audio", + "Index": 1, + "IsExternal": false, + "IsTextSubtitleStream": false, + "SupportsExternalStream": false, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "ces", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Czech - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 2, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "dan", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Danish - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 3, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "deu", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "German - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 4, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "eng", + "TimeBase": "1/1000", + "Title": "SDH", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "SDH - English - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 5, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "spa", + "TimeBase": "1/1000", + "Title": "Latin American", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Latin American - Spanish - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 6, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "spa", + "TimeBase": "1/1000", + "Title": "European", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "European - Spanish - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 7, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "fin", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Finnish - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 8, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "fra", + "TimeBase": "1/1000", + "Title": "European", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "European - French - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 9, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "hun", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Hungarian - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 10, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "isl", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Icelandic - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 11, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "jpn", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Japanese - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 12, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "kor", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Korean - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 13, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "nld", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Dutch - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 14, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "nor", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Norwegian - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 15, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "pol", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Polish - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 16, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "por", + "TimeBase": "1/1000", + "Title": "Brazilian", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Brazilian - Portuguese - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 17, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "por", + "TimeBase": "1/1000", + "Title": "European", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "European - Portuguese - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 18, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "ron", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Romanian - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 19, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "swe", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Swedish - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 20, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "tur", + "TimeBase": "1/1000", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Turkish - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 21, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "zho", + "TimeBase": "1/1000", + "Title": "Hong Kong", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Hong Kong - Chinese - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 22, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "zho", + "TimeBase": "1/1000", + "Title": "Simplified", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Simplified - Chinese - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 23, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + }, + { + "Codec": "subrip", + "Language": "zho", + "TimeBase": "1/1000", + "Title": "Traditional", + "VideoRange": "Unknown", + "VideoRangeType": "Unknown", + "AudioSpatialFormat": "None", + "LocalizedUndefined": "Undefined", + "LocalizedDefault": "Default", + "LocalizedForced": "Forced", + "LocalizedExternal": "External", + "LocalizedHearingImpaired": "Hearing Impaired", + "DisplayTitle": "Traditional - Chinese - SUBRIP", + "IsInterlaced": false, + "IsAVC": false, + "IsDefault": false, + "IsForced": false, + "IsHearingImpaired": false, + "Height": 0, + "Width": 0, + "Type": "Subtitle", + "Index": 24, + "IsExternal": false, + "IsTextSubtitleStream": true, + "SupportsExternalStream": true, + "Level": 0 + } + ], + "VideoType": "VideoFile", + "ImageTags": { + "Primary": "6b5a5abf06bda07dd30dca56c95d3bbb" + }, + "BackdropImageTags": [], + "Trickplay": {}, + "LocationType": "FileSystem", + "MediaType": "Video", + "LockedFields": [], + "LockData": false, + "Width": 1920, + "Height": 1080 +} diff --git a/packages/core/test/fixtures/jellyfin/movies_page.json b/packages/core/test/fixtures/jellyfin/movies_page.json new file mode 100644 index 0000000..793e472 --- /dev/null +++ b/packages/core/test/fixtures/jellyfin/movies_page.json @@ -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 +} diff --git a/packages/core/test/fixtures/jellyfin/nextup.json b/packages/core/test/fixtures/jellyfin/nextup.json new file mode 100644 index 0000000..9c2f917 --- /dev/null +++ b/packages/core/test/fixtures/jellyfin/nextup.json @@ -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 +} diff --git a/packages/core/test/fixtures/jellyfin/playback_info.json b/packages/core/test/fixtures/jellyfin/playback_info.json new file mode 100644 index 0000000..a356f90 --- /dev/null +++ b/packages/core/test/fixtures/jellyfin/playback_info.json @@ -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" +} diff --git a/packages/core/test/fixtures/jellyfin/pub.json b/packages/core/test/fixtures/jellyfin/pub.json new file mode 100644 index 0000000..bea0394 --- /dev/null +++ b/packages/core/test/fixtures/jellyfin/pub.json @@ -0,0 +1,9 @@ +{ + "LocalAddress": "https://example.invalid", + "ServerName": "Example Server", + "Version": "10.11.11", + "ProductName": "Jellyfin Server", + "OperatingSystem": "", + "Id": "122fce17c5b9413088af4be266bad6f4", + "StartupWizardCompleted": true +} diff --git a/packages/core/test/fixtures/jellyfin/quickconnect_initiate.json b/packages/core/test/fixtures/jellyfin/quickconnect_initiate.json new file mode 100644 index 0000000..4c711ff --- /dev/null +++ b/packages/core/test/fixtures/jellyfin/quickconnect_initiate.json @@ -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"} diff --git a/packages/core/test/fixtures/jellyfin/resume.json b/packages/core/test/fixtures/jellyfin/resume.json new file mode 100644 index 0000000..b4fc9e1 --- /dev/null +++ b/packages/core/test/fixtures/jellyfin/resume.json @@ -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 +} diff --git a/packages/core/test/fixtures/jellyfin/search.json b/packages/core/test/fixtures/jellyfin/search.json new file mode 100644 index 0000000..c16f6c2 --- /dev/null +++ b/packages/core/test/fixtures/jellyfin/search.json @@ -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 +} diff --git a/packages/core/test/fixtures/jellyfin/seasons.json b/packages/core/test/fixtures/jellyfin/seasons.json new file mode 100644 index 0000000..044ec88 --- /dev/null +++ b/packages/core/test/fixtures/jellyfin/seasons.json @@ -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 +} diff --git a/packages/core/test/fixtures/jellyfin/series_detail.json b/packages/core/test/fixtures/jellyfin/series_detail.json new file mode 100644 index 0000000..b2dee81 --- /dev/null +++ b/packages/core/test/fixtures/jellyfin/series_detail.json @@ -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 +} diff --git a/packages/core/test/fixtures/jellyfin/series_page.json b/packages/core/test/fixtures/jellyfin/series_page.json new file mode 100644 index 0000000..0e4380d --- /dev/null +++ b/packages/core/test/fixtures/jellyfin/series_page.json @@ -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 +} diff --git a/packages/core/test/fixtures/jellyfin/views.json b/packages/core/test/fixtures/jellyfin/views.json new file mode 100644 index 0000000..7641161 --- /dev/null +++ b/packages/core/test/fixtures/jellyfin/views.json @@ -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 +} diff --git a/packages/core/test/jellyfin_client_test.dart b/packages/core/test/jellyfin_client_test.dart new file mode 100644 index 0000000..2e6fe72 --- /dev/null +++ b/packages/core/test/jellyfin_client_test.dart @@ -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 headers, String? body})>[]; + final routes = {}; + + 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 send(String method, Uri uri, + {Map 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 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())); + }); + + 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() + .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())); + expect(() => anon.streamUrl('x'), throwsA(isA())); + }); +} diff --git a/packages/core/test/jellyfin_links_test.dart b/packages/core/test/jellyfin_links_test.dart new file mode 100644 index 0000000..cf133de --- /dev/null +++ b/packages/core/test/jellyfin_links_test.dart @@ -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); + }); + }); +} diff --git a/packages/core/test/jellyfin_live_test.dart b/packages/core/test/jellyfin_live_test.dart new file mode 100644 index 0000000..cd091c3 --- /dev/null +++ b/packages/core/test/jellyfin_live_test.dart @@ -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(); + 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 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)); +} diff --git a/packages/core/test/jellyfin_track_match_test.dart b/packages/core/test/jellyfin_track_match_test.dart new file mode 100644 index 0000000..ee3071e --- /dev/null +++ b/packages/core/test/jellyfin_track_match_test.dart @@ -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)) + .toList(); + final e6 = eps.firstWhere((e) => e.indexNumber == 6).mediaStreams; + final e7 = eps.firstWhere((e) => e.indexNumber == 7).mediaStreams; + JellyfinMediaStream at(List 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); + }); +}