Jellyfin answers PlaybackInfo with a null DefaultSubtitleStreamIndex when the user's SubtitleMode is None: the index is the server's whole answer, and null means it picked no subtitle. The mapper read null as "the server did not say" and promoted the container's default/forced flags to a server selection instead, which outranks the profile subtitle mode in the selection ladder. A viewer who had turned subtitles off for their Jellyfin user got them switched back on by every item that carried a default or forced row. Only the row the server names is selected now. A stream the viewer picks, and an explicit off, still survive per item because Plezy reports the index through playback progress and the server hands it back as that index or -1. close #1779
702 lines
25 KiB
Dart
702 lines
25 KiB
Dart
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);
|
|
|
|
// Non-default audio
|
|
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]);
|
|
// The row metadata itself is untouched; only the selection claim is.
|
|
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<String, dynamic> 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
|
|
// `{<sourceId>: {<resolution>: 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<String, dynamic> _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,
|
|
};
|