The sheet collapsed an item to `Media.first` / `MediaSources.first` and
rendered a fixed set of rows, so split files, extra versions, per-track
properties, HDR classification and Dolby Vision were all invisible.
Model the payload the way both servers shape it — versions own parts,
parts own streams — and project every property either backend populates
onto `MediaStreamDetails`. The field set comes from sweeping both test
servers in full through the clients' own request shapes (Plex 6842
Media / 6842 Part / 39864 Stream entries, Jellyfin 6349 sources / 37763
streams / 61224 attachments), so file presence, Dolby Vision layers,
dynamic range, sample rate, spatial audio, sidecar provenance, embedded
attachments, rotation and the lyric stream type all survive. A coverage
test fails when a server key is neither carried, folded into a sibling,
nor excluded with a reason.
File Info also stopped trusting the shared `/library/metadata/{id}`
cache row: `getPlaybackExtras` writes it without `includeStreams` /
`checkFiles`, so the sheet could render with no stream table at all.
Detect that shape and refetch once under the request context captured
before the cache read, so the outgoing token and the cache namespace
stay on one profile.
Rework the layout to match: a summary chip row, then flat tonal cards
with a two-column field grid that collapses to one column on narrow
viewports, per-stream cards with flag chips, and a copyable monospace
path row. The card fill is a tonal step off the text colour rather than
the `bg` token, which is one shade from the sheet surface on OLED.
479 lines
16 KiB
Dart
479 lines
16 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:plezy/media/media_file_info.dart';
|
|
import 'package:plezy/services/plex_playback_mapper.dart';
|
|
|
|
void main() {
|
|
group('parsePlexVideoPlaybackDataFromJson', () {
|
|
test('falls back from inaccessible selected version to playable version', () {
|
|
late (int, int) fallback;
|
|
|
|
final result = parsePlexVideoPlaybackDataFromJson(
|
|
{
|
|
'Media': [
|
|
{
|
|
'id': 1,
|
|
'videoResolution': '2160',
|
|
'Part': [
|
|
{'id': 10, 'key': '/library/parts/10/file.mkv', 'accessible': 0, 'exists': 1},
|
|
],
|
|
},
|
|
{
|
|
'id': 2,
|
|
'videoResolution': '1080',
|
|
'Part': [
|
|
{
|
|
'id': 20,
|
|
'key': '/library/parts/20/file.mkv',
|
|
'accessible': 1,
|
|
'exists': 1,
|
|
'Stream': [
|
|
{'streamType': 1, 'frameRate': 23.976},
|
|
{'streamType': 2, 'id': 201, 'index': 0, 'languageCode': 'eng', 'selected': 1},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
baseUrl: 'http://plex:32400',
|
|
token: 'tok',
|
|
onVersionFallback: (requested, selected) => fallback = (requested, selected),
|
|
);
|
|
|
|
expect(fallback, (0, 1));
|
|
expect(result.videoUrl, 'http://plex:32400/library/parts/20/file.mkv?X-Plex-Token=tok');
|
|
expect(result.availableVersions, hasLength(2));
|
|
expect(result.availableVersions.first.isPlayable, isFalse);
|
|
expect(result.mediaInfo?.partId, 20);
|
|
expect(result.mediaInfo?.displayCriteria?.fps, 23.976);
|
|
expect(result.mediaInfo?.audioTracks.single.languageCode, 'eng');
|
|
expect(result.selectedMediaIndex, 1);
|
|
expect(result.selectedPartIndex, 0);
|
|
});
|
|
|
|
test('falls back when first Plex media has unavailable part flags', () {
|
|
final result = parsePlexVideoPlaybackDataFromJson(
|
|
{
|
|
'Media': [
|
|
{
|
|
'id': 9773,
|
|
'videoResolution': '1080',
|
|
'Part': [
|
|
{'id': 9815, 'key': '/library/parts/9815/1774877382/file.mp4', 'accessible': false, 'exists': false},
|
|
],
|
|
},
|
|
{
|
|
'id': 9766,
|
|
'videoResolution': '720',
|
|
'Part': [
|
|
{'id': 9808, 'key': '/library/parts/9808/1775431760/file.mp4', 'accessible': true, 'exists': true},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
baseUrl: 'http://plex:32400',
|
|
token: 'tok',
|
|
);
|
|
|
|
expect(result.videoUrl, 'http://plex:32400/library/parts/9808/1775431760/file.mp4?X-Plex-Token=tok');
|
|
expect(result.selectedMediaIndex, 1);
|
|
expect(result.selectedPartIndex, 0);
|
|
expect(result.availableVersions.first.isPlayable, isFalse);
|
|
expect(result.availableVersions.last.isPlayable, isTrue);
|
|
});
|
|
|
|
test('uses playable part when the first part is unavailable', () {
|
|
final result = parsePlexVideoPlaybackDataFromJson(
|
|
{
|
|
'Media': [
|
|
{
|
|
'id': 1,
|
|
'videoResolution': '1080',
|
|
'Part': [
|
|
{'id': 10, 'key': '/library/parts/10/file.mkv', 'accessible': 0, 'exists': 1},
|
|
{
|
|
'id': 20,
|
|
'key': '/library/parts/20/file.mkv',
|
|
'accessible': 1,
|
|
'exists': 1,
|
|
'Stream': [
|
|
{'streamType': 1, 'frameRate': 24},
|
|
{'streamType': 2, 'id': 201, 'index': 0, 'languageCode': 'eng', 'selected': 1},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
baseUrl: 'http://plex:32400',
|
|
token: 'tok',
|
|
);
|
|
|
|
expect(result.videoUrl, 'http://plex:32400/library/parts/20/file.mkv?X-Plex-Token=tok');
|
|
expect(result.selectedMediaIndex, 0);
|
|
expect(result.selectedPartIndex, 1);
|
|
expect(result.mediaInfo?.partId, 20);
|
|
expect(result.mediaInfo?.displayCriteria?.fps, 24);
|
|
expect(result.availableVersions.single.parts, hasLength(2));
|
|
expect(result.availableVersions.single.parts.first.isPlayable, isFalse);
|
|
expect(result.availableVersions.single.parts.last.isPlayable, isTrue);
|
|
});
|
|
|
|
test('selects version by media source id over the requested index', () {
|
|
final result = parsePlexVideoPlaybackDataFromJson(
|
|
{
|
|
'Media': [
|
|
{
|
|
'id': 101,
|
|
'videoResolution': '1080',
|
|
'videoCodec': 'h264',
|
|
'container': 'mkv',
|
|
'Part': [
|
|
{'id': 10, 'key': '/library/parts/10/file.mkv', 'accessible': 1, 'exists': 1},
|
|
],
|
|
},
|
|
{
|
|
'id': 102,
|
|
'videoResolution': '4k',
|
|
'videoCodec': 'hevc',
|
|
'container': 'mkv',
|
|
'Part': [
|
|
{'id': 20, 'key': '/library/parts/20/file.mkv', 'accessible': 1, 'exists': 1},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
baseUrl: 'http://plex:32400',
|
|
token: 'tok',
|
|
mediaIndex: 0,
|
|
selectedMediaSourceId: '102',
|
|
);
|
|
|
|
expect(result.selectedMediaIndex, 1);
|
|
expect(result.videoUrl, 'http://plex:32400/library/parts/20/file.mkv?X-Plex-Token=tok');
|
|
expect(result.mediaInfo?.mediaSourceId, '102');
|
|
});
|
|
|
|
test('selects version by preferred signature when the id misses', () {
|
|
final result = parsePlexVideoPlaybackDataFromJson(
|
|
{
|
|
'Media': [
|
|
{
|
|
'id': 201,
|
|
'videoResolution': '1080',
|
|
'videoCodec': 'h264',
|
|
'container': 'mkv',
|
|
'Part': [
|
|
{'id': 10, 'key': '/library/parts/10/file.mkv', 'accessible': 1, 'exists': 1},
|
|
],
|
|
},
|
|
{
|
|
'id': 202,
|
|
'videoResolution': '4k',
|
|
'videoCodec': 'hevc',
|
|
'container': 'mkv',
|
|
'Part': [
|
|
{'id': 20, 'key': '/library/parts/20/file.mkv', 'accessible': 1, 'exists': 1},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
baseUrl: 'http://plex:32400',
|
|
token: 'tok',
|
|
mediaIndex: 0,
|
|
// Sibling episode's id — meaningless here; the signature must decide.
|
|
selectedMediaSourceId: '999',
|
|
preferredVersionSignature: '4k:hevc:mkv',
|
|
);
|
|
|
|
expect(result.selectedMediaIndex, 1);
|
|
expect(result.mediaInfo?.mediaSourceId, '202');
|
|
});
|
|
|
|
test('keeps the requested index when id and signature both miss', () {
|
|
final result = parsePlexVideoPlaybackDataFromJson(
|
|
{
|
|
'Media': [
|
|
{
|
|
'id': 301,
|
|
'videoResolution': '1080',
|
|
'Part': [
|
|
{'id': 10, 'key': '/library/parts/10/file.mkv', 'accessible': 1, 'exists': 1},
|
|
],
|
|
},
|
|
{
|
|
'id': 302,
|
|
'videoResolution': '720',
|
|
'Part': [
|
|
{'id': 20, 'key': '/library/parts/20/file.mkv', 'accessible': 1, 'exists': 1},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
baseUrl: 'http://plex:32400',
|
|
token: 'tok',
|
|
mediaIndex: 1,
|
|
preferredVersionSignature: '4k:av1:mp4',
|
|
);
|
|
|
|
expect(result.selectedMediaIndex, 1);
|
|
expect(result.mediaInfo?.mediaSourceId, '302');
|
|
});
|
|
|
|
test('signature-resolved version still falls back when unplayable', () {
|
|
late (int, int) fallback;
|
|
final result = parsePlexVideoPlaybackDataFromJson(
|
|
{
|
|
'Media': [
|
|
{
|
|
'id': 401,
|
|
'videoResolution': '1080',
|
|
'videoCodec': 'h264',
|
|
'container': 'mkv',
|
|
'Part': [
|
|
{'id': 10, 'key': '/library/parts/10/file.mkv', 'accessible': 1, 'exists': 1},
|
|
],
|
|
},
|
|
{
|
|
'id': 402,
|
|
'videoResolution': '4k',
|
|
'videoCodec': 'hevc',
|
|
'container': 'mkv',
|
|
'Part': [
|
|
{'id': 20, 'key': '/library/parts/20/file.mkv', 'accessible': 0, 'exists': 0},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
baseUrl: 'http://plex:32400',
|
|
token: 'tok',
|
|
mediaIndex: 0,
|
|
preferredVersionSignature: '4k:hevc:mkv',
|
|
onVersionFallback: (requested, selected) => fallback = (requested, selected),
|
|
);
|
|
|
|
expect(fallback, (1, 0));
|
|
expect(result.selectedMediaIndex, 0);
|
|
});
|
|
|
|
test('maps server display criteria from selected video stream', () {
|
|
final result = parsePlexVideoPlaybackDataFromJson(
|
|
{
|
|
'Media': [
|
|
{
|
|
'id': 1,
|
|
'width': 3840,
|
|
'height': 2160,
|
|
'videoResolution': '4k',
|
|
'Part': [
|
|
{
|
|
'id': 10,
|
|
'key': '/library/parts/10/file.mkv',
|
|
'accessible': 1,
|
|
'exists': 1,
|
|
'Stream': [
|
|
{
|
|
'streamType': 1,
|
|
'frameRate': '23.976',
|
|
'DOVIProfile': '7',
|
|
'DOVILevel': '6',
|
|
'DOVIBLCompatID': '6',
|
|
'colorTrc': 'smpte2084',
|
|
'colorPrimaries': 'bt2020',
|
|
'colorSpace': 'bt2020nc',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
baseUrl: 'http://plex:32400',
|
|
token: null,
|
|
);
|
|
|
|
final criteria = result.mediaInfo?.displayCriteria;
|
|
expect(criteria, isNotNull);
|
|
expect(criteria!.fps, closeTo(23.976, 0.001));
|
|
expect(criteria.width, 3840);
|
|
expect(criteria.height, 2160);
|
|
expect(criteria.doviProfile, 7);
|
|
expect(criteria.doviLevel, 6);
|
|
expect(criteria.doviCompatibilityId, 6);
|
|
expect(criteria.transfer, 'smpte2084');
|
|
expect(criteria.primaries, 'bt2020');
|
|
expect(criteria.matrix, 'bt2020nc');
|
|
});
|
|
|
|
test('fills missing HDR color tags from partial Plex transfer metadata', () {
|
|
final result = parsePlexVideoPlaybackDataFromJson(
|
|
{
|
|
'Media': [
|
|
{
|
|
'id': 1,
|
|
'width': 3840,
|
|
'height': 2160,
|
|
'Part': [
|
|
{
|
|
'id': 10,
|
|
'key': '/library/parts/10/file.mkv',
|
|
'accessible': 1,
|
|
'exists': 1,
|
|
'Stream': [
|
|
{'streamType': 1, 'frameRate': 23.976, 'colorTrc': 'smpte2084'},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
baseUrl: 'http://plex:32400',
|
|
token: null,
|
|
);
|
|
|
|
final criteria = result.mediaInfo?.displayCriteria;
|
|
expect(criteria, isNotNull);
|
|
expect(criteria!.transfer, 'smpte2084');
|
|
expect(criteria.primaries, 'bt2020');
|
|
expect(criteria.matrix, 'bt2020nc');
|
|
});
|
|
|
|
test('skips unidentifiable subtitle streams without blocking playback', () {
|
|
final result = parsePlexVideoPlaybackDataFromJson(
|
|
{
|
|
'Media': [
|
|
{
|
|
'id': 1,
|
|
'Part': [
|
|
{
|
|
'id': 10,
|
|
'key': '/library/parts/10/file.mp4',
|
|
'accessible': 1,
|
|
'exists': 1,
|
|
'Stream': [
|
|
{'streamType': 1, 'id': 100},
|
|
{'streamType': 2, 'id': 301, 'selected': true},
|
|
{'streamType': 3, 'languageCode': 'eng'},
|
|
{'streamType': 3, 'id': 'cc1', 'languageCode': 'eng'},
|
|
{'streamType': 3, 'id': '401', 'languageCode': 'spa', 'selected': true},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
baseUrl: 'http://plex:32400',
|
|
token: 'token',
|
|
);
|
|
|
|
expect(result.videoUrl, 'http://plex:32400/library/parts/10/file.mp4?X-Plex-Token=token');
|
|
expect(result.mediaInfo, isNotNull);
|
|
expect(result.mediaInfo!.audioTracks.map((track) => track.id), [301]);
|
|
expect(result.mediaInfo!.subtitleTracks.map((track) => track.id), [401]);
|
|
expect(result.mediaInfo!.subtitleTracks.single.selected, isTrue);
|
|
});
|
|
});
|
|
|
|
group('parsePlexFileInfoFromJson', () {
|
|
test('maps media, part, and stream fields', () {
|
|
final info = parsePlexFileInfoFromJson({
|
|
'Media': [
|
|
{
|
|
'container': 'mkv',
|
|
'videoCodec': 'h264',
|
|
'videoResolution': '1080',
|
|
'width': '1920',
|
|
'height': '1080',
|
|
'aspectRatio': '1.78',
|
|
'bitrate': '8000',
|
|
'duration': '120000',
|
|
'audioCodec': 'aac',
|
|
'audioChannels': '2',
|
|
'optimizedForStreaming': '1',
|
|
'has64bitOffsets': 0,
|
|
'Part': [
|
|
{
|
|
'file': '/media/movie.mkv',
|
|
'size': '123456',
|
|
'Stream': [
|
|
{'streamType': '1', 'frameRate': '24', 'colorSpace': 'bt709', 'bitDepth': '8', 'bitrate': '7000'},
|
|
{
|
|
'streamType': '2',
|
|
'id': '301',
|
|
'index': '0',
|
|
'language': 'English',
|
|
'languageCode': 'eng',
|
|
'channels': '2',
|
|
'selected': true,
|
|
'audioChannelLayout': 'stereo',
|
|
},
|
|
{
|
|
'streamType': '3',
|
|
'id': '401',
|
|
'index': '0',
|
|
'languageCode': 'eng',
|
|
'forced': 0,
|
|
'key': '/subtitles/401',
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
final version = info!.versions.single;
|
|
final part = version.parts.single;
|
|
expect(version.container, 'mkv');
|
|
expect(version.videoCodec, 'h264');
|
|
expect(part.filePath, '/media/movie.mkv');
|
|
expect(part.fileSize, 123456);
|
|
expect(version.optimizedForStreaming, isTrue);
|
|
expect(version.has64bitOffsets, isFalse);
|
|
|
|
final video = part.streamsOfKind(MediaStreamKind.video).single;
|
|
expect(video.frameRate, 24);
|
|
expect(video.bitDepth, 8);
|
|
expect(video.colorSpace, 'bt709');
|
|
|
|
final audio = part.streamsOfKind(MediaStreamKind.audio).single;
|
|
expect(audio.id, '301');
|
|
expect(audio.isSelected, isTrue);
|
|
expect(audio.channelLayout, 'stereo');
|
|
|
|
expect(part.streamsOfKind(MediaStreamKind.subtitle).single.id, '401');
|
|
});
|
|
|
|
test('keeps subtitle streams that the playback reader would reject', () {
|
|
// The playback path needs a numeric stream id and drops the rest; the
|
|
// file-info view is purely descriptive, so embedded caption tracks with
|
|
// no usable id still belong in the table.
|
|
final info = parsePlexFileInfoFromJson({
|
|
'Media': [
|
|
{
|
|
'container': 'mp4',
|
|
'Part': [
|
|
{
|
|
'file': '/media/movie.mp4',
|
|
'Stream': [
|
|
{'streamType': 1, 'id': 100},
|
|
{'streamType': 3, 'id': null, 'languageCode': 'eng'},
|
|
{'streamType': 3, 'id': 'cea-608', 'languageCode': 'eng'},
|
|
{'streamType': 3, 'id': 402, 'languageCode': 'spa'},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(info, isNotNull);
|
|
final part = info!.versions.single.parts.single;
|
|
expect(part.filePath, '/media/movie.mp4');
|
|
final subtitles = part.streamsOfKind(MediaStreamKind.subtitle).toList();
|
|
expect(subtitles.map((stream) => stream.id), [null, 'cea-608', '402']);
|
|
expect(subtitles.map((stream) => stream.ordinal), [1, 2, 3]);
|
|
});
|
|
});
|
|
}
|