import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/media_display_criteria.dart'; import 'package:plezy/services/jellyfin_media_info_mapper.dart'; /// Field-mapping pin for the Jellyfin → Plex `MediaInfo` translator. The /// player's track picker and auto-track-selection both consume the /// resulting [MediaAudioTrack] / [MediaSubtitleTrack] lists, so the field /// shape needs to be stable across iterations. void main() { group('jellyfinMediaSourceToMediaSourceInfo', () { test('maps audio + subtitle streams and preserves Jellyfin default selection hints', () { final source = { 'Id': 'src-1', 'Container': 'mkv', 'MediaStreams': [ {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'RealFrameRate': 23.976}, { 'Index': 1, 'Type': 'Audio', 'Codec': 'eac3', 'Language': 'eng', 'DisplayLanguage': 'English', 'Title': 'Surround 5.1', 'DisplayTitle': 'English (EAC3 5.1)', 'Channels': 6, 'IsDefault': true, }, { 'Index': 2, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'jpn', 'DisplayLanguage': 'Japanese', 'Channels': 2, 'IsDefault': false, }, { 'Index': 3, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng', 'DisplayLanguage': 'English', 'IsDefault': false, 'IsForced': true, 'IsExternal': true, 'DeliveryUrl': '/Videos/src-1/Subtitles/3/Stream.srt', }, ], }; final info = jellyfinMediaSourceToMediaSourceInfo(source); expect(info.audioTracks.length, 2); expect(info.subtitleTracks.length, 1); expect(info.displayCriteria?.fps, closeTo(23.976, 0.001)); // Plex partId is null on Jellyfin because Jellyfin persists selected // stream indexes through playback progress reports instead. expect(info.getPartId(), isNull); // Jellyfin exposes the default server choice through IsDefault. final eng = info.audioTracks[0]; expect(eng.id, 1); expect(eng.index, 1); expect(eng.codec, 'eac3'); expect(eng.language, 'English'); expect(eng.languageCode, 'eng'); expect(eng.title, 'Surround 5.1'); expect(eng.displayTitle, 'English (EAC3 5.1)'); expect(eng.channels, 6); expect(eng.selected, isTrue); final jpn = info.audioTracks[1]; expect(jpn.id, 2); expect(jpn.languageCode, 'jpn'); expect(jpn.selected, isFalse); // Subtitle, external + forced. Forced is metadata about the row, not a // selection: this source carries no DefaultSubtitleStreamIndex, so // nothing may claim the server picked it. final sub = info.subtitleTracks.single; expect(sub.id, 3); expect(sub.codec, 'srt'); expect(sub.languageCode, 'eng'); expect(sub.forced, isTrue); expect(sub.selected, isFalse); expect(sub.isExternal, isTrue); expect(sub.key, '/Videos/src-1/Subtitles/3/Stream.srt'); }); test('no DefaultSubtitleStreamIndex means no server-selected subtitle (#1779)', () { // What Jellyfin answers for a user whose SubtitleMode is None: the // container still flags a default/forced row, the server still declines // to select one. Promoting those flags to a selection outranked the // user's own "no subtitles" setting in the selection ladder. final info = jellyfinMediaSourceToMediaSourceInfo({ 'Id': 'src-1', 'DefaultSubtitleStreamIndex': null, 'MediaStreams': [ {'Index': 1, 'Type': 'Audio', 'Language': 'eng', 'IsDefault': true}, {'Index': 3, 'Type': 'Subtitle', 'Language': 'eng', 'IsDefault': true, 'IsForced': true}, {'Index': 4, 'Type': 'Subtitle', 'Language': 'eng'}, ], }); expect(info.defaultSubtitleStreamIndex, isNull); expect(info.subtitleTracks.map((track) => track.selected), [false, false]); expect(info.subtitleTracks.first.forced, isTrue); // Audio keeps the container default: something always has to play. expect(info.audioTracks.single.selected, isTrue); }); test('maps display criteria from Jellyfin video stream metadata', () { final info = jellyfinMediaSourceToMediaSourceInfo({ 'Id': 'src-1', 'MediaStreams': [ { 'Index': 0, 'Type': 'Video', 'RealFrameRate': 23.976025, 'Width': 3840, 'Height': 2160, 'VideoRangeType': 'DOVIWithHDR10Plus', 'DvProfile': 8, 'DvLevel': 10, 'DvBlSignalCompatibilityId': 1, }, ], }); final criteria = info.displayCriteria; expect(criteria, isNotNull); expect(criteria!.fps, closeTo(23.976, 0.001)); expect(criteria.width, 3840); expect(criteria.height, 2160); expect(criteria.doviProfile, 8); expect(criteria.doviLevel, 10); expect(criteria.doviCompatibilityId, 1); expect(criteria.transfer, 'smpte2084'); expect(criteria.primaries, 'bt2020'); expect(criteria.matrix, 'bt2020nc'); }); test('fills missing HDR color tags from partial Jellyfin transfer metadata', () { final info = jellyfinMediaSourceToMediaSourceInfo({ 'Id': 'src-1', 'MediaStreams': [ { 'Index': 0, 'Type': 'Video', 'RealFrameRate': 23.976, 'Width': 3840, 'Height': 2160, 'ColorTransfer': 'smpte2084', }, ], }); final criteria = info.displayCriteria; expect(criteria, isNotNull); expect(criteria!.transfer, 'smpte2084'); expect(criteria.primaries, 'bt2020'); expect(criteria.matrix, 'bt2020nc'); }); test('maps each Jellyfin color metadata class', () { final cases = <({Map stream, MediaDisplayColorType type, MediaDisplayColorTags tags})>[ ( stream: {'VideoRangeType': 'DOVI', 'DvProfile': 5}, type: MediaDisplayColorType.dolbyVision, tags: (transfer: null, primaries: null, matrix: null), ), ( stream: {'VideoRangeType': 'HLG'}, type: MediaDisplayColorType.hlg, tags: (transfer: 'arib-std-b67', primaries: 'bt2020', matrix: 'bt2020nc'), ), ( stream: {'VideoRangeType': 'HDR10'}, type: MediaDisplayColorType.pq, tags: (transfer: 'smpte2084', primaries: 'bt2020', matrix: 'bt2020nc'), ), ( stream: {'VideoRange': 'SDR'}, type: MediaDisplayColorType.sdr, tags: (transfer: 'bt709', primaries: 'bt709', matrix: 'bt709'), ), ( stream: {'VideoRangeType': 'Unknown'}, type: MediaDisplayColorType.unknown, tags: (transfer: null, primaries: null, matrix: null), ), ]; for (final testCase in cases) { final info = jellyfinMediaSourceToMediaSourceInfo({ 'Id': 'src-1', 'Width': 1920, 'Height': 1080, 'MediaStreams': [ {'Index': 0, 'Type': 'Video', 'RealFrameRate': 24, ...testCase.stream}, ], }); final criteria = info.displayCriteria; expect(criteria, isNotNull); expect(criteria!.colorType, testCase.type); expect( (transfer: criteria.transfer, primaries: criteria.primaries, matrix: criteria.matrix), testCase.tags, reason: testCase.type.name, ); } }); test('handles missing MediaStreams gracefully', () { final info = jellyfinMediaSourceToMediaSourceInfo({'Id': 'x'}); expect(info.audioTracks, isEmpty); expect(info.subtitleTracks, isEmpty); expect(info.displayCriteria, isNull); }); test('uses Jellyfin default stream indexes over per-stream default flags', () { final info = jellyfinMediaSourceToMediaSourceInfo({ 'Id': 'src-1', 'DefaultAudioStreamIndex': 2, 'DefaultSubtitleStreamIndex': -1, 'MediaStreams': [ {'Index': 0, 'Type': 'Video'}, {'Index': 1, 'Type': 'Audio', 'Language': 'eng', 'IsDefault': true}, {'Index': 2, 'Type': 'Audio', 'Language': 'jpn', 'IsDefault': false}, {'Index': 3, 'Type': 'Subtitle', 'Language': 'eng', 'IsDefault': true}, ], }); expect(info.audioTracks.map((t) => t.selected), [false, true]); expect(info.subtitleTracks.single.selected, isFalse); expect(info.defaultAudioStreamIndex, 2); expect(info.defaultSubtitleStreamIndex, -1); }); test('selects subtitle matching Jellyfin default stream index', () { final info = jellyfinMediaSourceToMediaSourceInfo({ 'Id': 'src-1', 'DefaultSubtitleStreamIndex': 4, 'MediaStreams': [ {'Index': 3, 'Type': 'Subtitle', 'Language': 'eng', 'IsDefault': true}, {'Index': 4, 'Type': 'Subtitle', 'Language': 'jpn', 'IsDefault': false}, ], }); expect(info.subtitleTracks.map((track) => track.selected), [false, true]); expect(info.defaultSubtitleStreamIndex, 4); }); test('coerces Jellyfin string and numeric stream scalars', () { final info = jellyfinMediaSourceToMediaSourceInfo({ 'Id': 'src-flexible', 'DefaultAudioStreamIndex': '2', 'DefaultSubtitleStreamIndex': 3.0, 'MediaStreams': [ {'Index': '2', 'Type': 'Audio', 'Channels': '6'}, {'Index': 3.0, 'Type': 'Subtitle'}, ], }); expect(info.defaultAudioStreamIndex, 2); expect(info.defaultSubtitleStreamIndex, 3); expect(info.audioTracks.single.id, 2); expect(info.audioTracks.single.channels, 6); expect(info.audioTracks.single.selected, isTrue); expect(info.subtitleTracks.single.id, 3); expect(info.subtitleTracks.single.selected, isTrue); }); test('falls back to Language when DisplayLanguage absent', () { final info = jellyfinMediaSourceToMediaSourceInfo({ 'MediaStreams': [ {'Index': 0, 'Type': 'Audio', 'Language': 'fra'}, ], }); expect(info.audioTracks.single.language, 'fra'); expect(info.audioTracks.single.languageCode, 'fra'); }); test('embedded subtitle (IsExternal=false) leaves key null', () { final info = jellyfinMediaSourceToMediaSourceInfo({ 'MediaStreams': [ {'Index': 0, 'Type': 'Subtitle', 'IsExternal': false, 'DeliveryUrl': '/should-be-ignored'}, ], }); expect(info.subtitleTracks.single.key, isNull); expect(info.subtitleTracks.single.isExternal, isFalse); }); test('DeliveryMethod External marks negotiated subtitles as external', () { final info = jellyfinMediaSourceToMediaSourceInfo({ 'MediaStreams': [ { 'Index': 2, 'Type': 'Subtitle', 'Codec': 'srt', 'DeliveryMethod': 'External', 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/2/Stream.srt', }, ], }); final sub = info.subtitleTracks.single; expect(sub.key, '/Videos/item-1/src-1/Subtitles/2/Stream.srt'); expect(sub.isExternal, isTrue); expect(sub.isExternalFile, isFalse); expect(sub.usesExternalDelivery, isTrue); }); test('preserves external Jellyfin audio streams', () { final info = jellyfinMediaSourceToMediaSourceInfo({ 'MediaStreams': [ {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'}, { 'Index': 2, 'Type': 'Audio', 'Codec': 'flac', 'Language': 'jpn', 'DeliveryMethod': 'External', 'DeliveryUrl': '/Videos/item-1/src-1/Audio/2/Stream.flac', }, {'Index': 3, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'spa', 'IsExternal': true}, ], }); expect(info.audioTracks.map((track) => track.isExternal), [false, true, true]); expect(info.audioTracks[1].id, 2); expect(info.audioTracks[1].codec, 'flac'); expect(info.audioTracks[2].id, 3); }); test('external subtitle without DeliveryUrl remains external for URL fallback', () { final info = jellyfinMediaSourceToMediaSourceInfo({ 'MediaStreams': [ {'Index': 3, 'Type': 'Subtitle', 'Codec': 'srt', 'IsExternal': true}, ], }); final sub = info.subtitleTracks.single; expect(sub.key, isNull); expect(sub.isExternal, isTrue); expect(sub.isExternalFile, isTrue); expect(sub.usesExternalDelivery, isFalse); }); test('captures mediaSourceId from source Id field', () { final info = jellyfinMediaSourceToMediaSourceInfo({'Id': 'src-abc', 'MediaStreams': []}); expect(info.mediaSourceId, 'src-abc'); }); test('parses flat trickplay manifest (per OpenAPI shape)', () { // BaseItemDto.Trickplay shape per Jellyfin OpenAPI: keys are resolution // widths (often strings in the JSON), values are TrickplayInfoDto. final info = jellyfinMediaSourceToMediaSourceInfo( {'Id': 'src-1', 'MediaStreams': []}, trickplay: { '320': { 'Width': 320, 'Height': 180, 'TileWidth': 10, 'TileHeight': 10, 'ThumbnailCount': 250, 'Interval': 10000, 'Bandwidth': 500000, }, }, ); final t = info.trickplayByWidth?[320]; expect(t, isNotNull); expect(t!.width, 320); expect(t.height, 180); expect(t.tileWidth, 10); expect(t.tileHeight, 10); expect(t.thumbnailCount, 250); expect(t.interval, 10000); expect(t.bandwidth, 500000); }); test('parses nested-by-source trickplay manifest (Streamyfin shape)', () { // Some Jellyfin variants serialise Trickplay as // `{: {: TrickplayInfoDto}}`. The mapper picks // the inner map matching the chosen source. final info = jellyfinMediaSourceToMediaSourceInfo( {'Id': 'src-2', 'MediaStreams': []}, trickplay: { 'src-1': {'160': _info(width: 160, height: 90, tw: 8, th: 8, count: 64, interval: 10000)}, 'src-2': {'320': _info(width: 320, height: 180, tw: 8, th: 8, count: 64, interval: 10000)}, }, ); expect(info.trickplayByWidth?.keys.toList(), [320]); expect(info.trickplayByWidth![320]!.width, 320); }); test('does not attach another source trickplay when selected source is absent', () { final info = jellyfinMediaSourceToMediaSourceInfo( {'Id': 'unknown', 'MediaStreams': []}, trickplay: { 'src-1': {'160': _info(width: 160, height: 90, tw: 4, th: 4, count: 16, interval: 10000)}, }, ); expect(info.mediaSourceId, 'unknown'); expect(info.trickplayByWidth, isNull); }); test('source-less media accepts exactly one nested trickplay candidate', () { final info = jellyfinMediaSourceToMediaSourceInfo( {'MediaStreams': []}, trickplay: { 'src-1': {'160': _info(width: 160, height: 90, tw: 4, th: 4, count: 16, interval: 10000)}, }, ); expect(info.trickplayByWidth?.keys.single, 160); expect(info.trickplayByWidth?[160]?.interval, 10000); }); test('source-less media rejects ambiguous nested trickplay candidates', () { final info = jellyfinMediaSourceToMediaSourceInfo( {'MediaStreams': []}, trickplay: { 'src-1': {'160': _info(width: 160, height: 90, tw: 4, th: 4, count: 16, interval: 10000)}, 'src-2': {'320': _info(width: 320, height: 180, tw: 4, th: 4, count: 16, interval: 10000)}, }, ); expect(info.trickplayByWidth, isNull); }); test('returns null trickplayByWidth when manifest missing', () { final info = jellyfinMediaSourceToMediaSourceInfo({'Id': 'x', 'MediaStreams': []}); expect(info.trickplayByWidth, isNull); }); test('skips malformed trickplay entries (missing required ints)', () { final info = jellyfinMediaSourceToMediaSourceInfo( {'Id': 'x', 'MediaStreams': []}, trickplay: { '320': { // missing Height 'Width': 320, 'TileWidth': 10, 'TileHeight': 10, 'ThumbnailCount': 50, 'Interval': 10000, }, '160': _info(width: 160, height: 90, tw: 4, th: 4, count: 16, interval: 10000), }, ); expect(info.trickplayByWidth?.keys.toList(), [160]); }); test('coerces numeric-string resolution keys', () { final info = jellyfinMediaSourceToMediaSourceInfo( {'Id': 'x', 'MediaStreams': []}, trickplay: {'320': _info(width: 320, height: 180, tw: 4, th: 4, count: 16, interval: 10000)}, ); expect(info.trickplayByWidth?.containsKey(320), isTrue); }); }); group('jellyfinSourcesToVersions', () { test('reads resolution + codec from the video stream when source omits them', () { // The list endpoint returns Width/Height as null on MediaSource; the // detail endpoint may include them on the stream only. Either way the // version label needs a real number. final versions = jellyfinSourcesToVersions([ { 'Id': 'src-1', 'Name': 'Movie (2024)', 'Container': 'mkv', 'Bitrate': 5000000, 'MediaStreams': [ {'Type': 'Video', 'Codec': 'hevc', 'Width': 3840, 'Height': 2160}, {'Type': 'Audio', 'Codec': 'eac3'}, ], }, ]); expect(versions, hasLength(1)); expect(versions.single.id, 'src-1'); expect(versions.single.parts.single.streamPath, 'src-1'); expect(versions.single.videoResolution, '4k'); expect(versions.single.videoCodec, 'hevc'); expect(versions.single.container, 'mkv'); expect(versions.single.width, 3840); expect(versions.single.height, 2160); // Single-source items have Name == item title; suppress to avoid // redundant prefixes in the picker. expect(versions.single.name, isNull); }); test('forwards distinct Names so the picker can disambiguate equal-spec versions', () { final versions = jellyfinSourcesToVersions([ { 'Id': 'src-theatrical', 'Name': 'Theatrical Cut', 'Container': 'mkv', 'Bitrate': 8000000, 'MediaStreams': [ {'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920}, ], }, { 'Id': 'src-directors', 'Name': "Director's Cut", 'Container': 'mkv', 'Bitrate': 12000000, 'MediaStreams': [ {'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920}, ], }, ]); expect(versions, hasLength(2)); expect(versions[0].name, 'Theatrical Cut'); expect(versions[1].name, "Director's Cut"); expect(versions[0].id, 'src-theatrical'); expect(versions[1].id, 'src-directors'); expect(versions[0].parts.single.streamPath, 'src-theatrical'); expect(versions[1].parts.single.streamPath, 'src-directors'); // displayLabel prefixes the name for disambiguation. expect(versions[0].displayLabel, contains('Theatrical Cut')); expect(versions[0].displayLabel, contains('1080')); }); test('drops Name when all sources share it (typical single-version case)', () { final versions = jellyfinSourcesToVersions([ { 'Id': 'a', 'Name': 'Movie (2024)', 'Container': 'mkv', 'MediaStreams': [ {'Type': 'Video', 'Codec': 'h264', 'Height': 720, 'Width': 1280}, ], }, { 'Id': 'b', 'Name': 'Movie (2024)', 'Container': 'mp4', 'MediaStreams': [ {'Type': 'Video', 'Codec': 'hevc', 'Height': 1080, 'Width': 1920}, ], }, ]); expect(versions[0].name, isNull); expect(versions[1].name, isNull); expect(versions[0].videoResolution, '720'); expect(versions[1].videoResolution, '1080'); }); test('uses width for scope-cropped 4k and 1080p labels', () { final versions = jellyfinSourcesToVersions([ { 'Id': 'scope-4k', 'Container': 'mkv', 'MediaStreams': [ {'Type': 'Video', 'Codec': 'hevc', 'Height': 1608, 'Width': 3840}, ], }, { 'Id': 'scope-1080', 'Container': 'mkv', 'MediaStreams': [ {'Type': 'Video', 'Codec': 'h264', 'Height': 804, 'Width': 1920}, ], }, ]); expect(versions[0].videoResolution, '4k'); expect(versions[1].videoResolution, '1080'); }); test('coerces source scalars and rounds bps to kbps without zero sentinels', () { final versions = jellyfinSourcesToVersions([ { 'Id': 'rounded', 'Width': '1920', 'Height': 1080.9, 'Bitrate': '1500', 'Size': '987654321', 'MediaStreams': [ {'Type': 'Video', 'Codec': 'h264'}, ], }, {'Id': 'missing-bitrate'}, {'Id': 'zero-bitrate', 'Bitrate': 0}, {'Id': 'negative-bitrate', 'Bitrate': -1000}, ]); expect(versions.first.width, 1920); expect(versions.first.height, 1080); expect(versions.first.bitrate, 2); expect(versions.first.parts.single.sizeBytes, 987654321); expect(versions.skip(1).map((version) => version.bitrate), everyElement(isNull)); }); test('handles missing MediaStreams + missing Height gracefully', () { final versions = jellyfinSourcesToVersions([ {'Id': 'x', 'Name': 'X', 'Container': 'mkv'}, ]); expect(versions, hasLength(1)); expect(versions.single.videoResolution, isNull); expect(versions.single.videoCodec, isNull); expect(versions.single.height, isNull); }); }); group('jellyfinPlaybackExtrasFromRaw', () { test('path-encodes chapter thumbnail item id and image tag', () { final extras = jellyfinPlaybackExtrasFromRaw({ 'Chapters': [ {'StartPositionTicks': 0, 'ImageTag': 'chapter/tag ?x'}, ], }, 'folder/item #1?x'); expect(extras.chapters.single.thumb, '/Items/folder%2Fitem%20%231%3Fx/Images/Chapter/0?tag=chapter%2Ftag%20%3Fx'); }); test('uses default OP and ED chapter patterns for skip markers', () { final extras = jellyfinPlaybackExtrasFromRaw({ 'RunTimeTicks': _ticks(150000), 'Chapters': [ {'Name': 'Prologue', 'StartPositionTicks': _ticks(0)}, {'Name': 'OP', 'StartPositionTicks': _ticks(10000)}, {'Name': 'Part A', 'StartPositionTicks': _ticks(90000)}, {'Name': 'ED', 'StartPositionTicks': _ticks(120000)}, ], }, 'item-1'); expect(extras.markers.map((m) => m.type), ['intro', 'credits']); expect(extras.markers[0].startTimeOffset, 10000); expect(extras.markers[0].endTimeOffset, 90000); expect(extras.markers[1].startTimeOffset, 120000); expect(extras.markers[1].endTimeOffset, 150000); }); test('parses native Jellyfin media segments into skip markers', () { final markers = jellyfinMediaSegmentsToMarkers({ 'Items': [ {'Type': 'Intro', 'StartTicks': _ticks(5000), 'EndTicks': _ticks(45000)}, {'Type': 'Outro', 'StartTicks': _ticks(90000), 'EndTicks': _ticks(100000)}, {'Type': 'Recap', 'StartTicks': _ticks(0), 'EndTicks': _ticks(4000)}, ], }); expect(markers.map((m) => m.type), ['intro', 'credits']); expect(markers[0].startTimeOffset, 5000); expect(markers[0].endTimeOffset, 45000); expect(markers[1].startTimeOffset, 90000); expect(markers[1].endTimeOffset, 100000); }); test('keeps native segments and fills missing marker types from chapters', () { final extras = jellyfinPlaybackExtrasFromRaw( { 'RunTimeTicks': _ticks(120000), 'Chapters': [ {'Name': 'Episode', 'StartPositionTicks': _ticks(0)}, {'Name': 'ED', 'StartPositionTicks': _ticks(90000)}, ], }, 'item-1', markers: jellyfinMediaSegmentsToMarkers({ 'Items': [ {'Type': 'Intro', 'StartTicks': _ticks(10000), 'EndTicks': _ticks(30000)}, ], }), ); expect(extras.markers.map((m) => m.type), ['intro', 'credits']); expect(extras.markers[0].startTimeOffset, 10000); expect(extras.markers[0].endTimeOffset, 30000); expect(extras.markers[1].startTimeOffset, 90000); expect(extras.markers[1].endTimeOffset, 120000); }); }); } int _ticks(int ms) => ms * 10000; /// Build a Jellyfin TrickplayInfoDto-shaped JSON map for a fixture. Map _info({ required int width, required int height, required int tw, required int th, required int count, required int interval, }) => { 'Width': width, 'Height': height, 'TileWidth': tw, 'TileHeight': th, 'ThumbnailCount': count, 'Interval': interval, 'Bandwidth': 0, };