Files
plezy/test/services/plex_mappers_test.dart
T

871 lines
30 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_display_criteria.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_stream.dart';
import 'package:plezy/services/plex_mappers.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
const _serverId = 'plex-machine-1';
const _serverName = 'Home';
void _expectSafePlexMapperEvent(
SentryEvent event, {
required Iterable<String> responseMarkers,
required int topLevelFieldCount,
}) {
final eventJson = event.toJson();
final serializedEvent = jsonEncode(eventJson);
for (final marker in responseMarkers) {
expect(serializedEvent, isNot(contains(marker)), reason: marker);
}
final contexts = Map<String, dynamic>.from(eventJson['contexts'] as Map);
expect(contexts.containsKey('json'), isFalse);
expect(Map<String, dynamic>.from(contexts['plex_mapper'] as Map), {
'backend': 'plex',
'dto': 'PlexMetadataDto',
'topLevelFieldCount': topLevelFieldCount,
});
final exception = Map<String, dynamic>.from(((eventJson['exception'] as Map)['values'] as List).single as Map);
expect(exception['type'], contains('TypeError'));
final stackTrace = Map<String, dynamic>.from(exception['stacktrace'] as Map);
expect(stackTrace['frames'], isNotEmpty);
}
void main() {
group('Plex metadata Sentry diagnostics', () {
late Completer<SentryEvent> capturedEvent;
setUp(() async {
capturedEvent = Completer<SentryEvent>();
await Sentry.init((options) {
options
..dsn = 'https://public@example.com/1'
..beforeSend = (event, hint) {
capturedEvent.complete(event);
return null;
};
});
addTearDown(Sentry.close);
});
test('captures only a closed projection and rethrows malformed metadata', () async {
const responseMarkers = [
'cc004-rating-marker',
'cc004-title-marker',
'cc004-summary-marker',
'cc004-unmodeled-key-marker',
'cc004-nested-marker',
'cc004-file-marker',
];
final malformedMetadata = <String, dynamic>{
'ratingKey': responseMarkers[0],
'title': responseMarkers[1],
'summary': responseMarkers[2],
responseMarkers[3]: {'value': responseMarkers[4]},
'Media': [
{
'id': 1,
'Part': [
{'id': 1, 'file': responseMarkers[5]},
],
},
],
'guid': 7,
};
expect(() => PlexMetadataDto.fromJson(malformedMetadata), throwsA(isA<TypeError>()));
final event = await capturedEvent.future;
_expectSafePlexMapperEvent(event, responseMarkers: responseMarkers, topLevelFieldCount: malformedMetadata.length);
});
test('captures diagnostics while a hub omits only the malformed sibling', () async {
const responseMarkers = [
'cc004-hub-rating-marker',
'cc004-hub-title-marker',
'cc004-hub-summary-marker',
'cc004-hub-unmodeled-key-marker',
'cc004-hub-nested-marker',
'cc004-hub-file-marker',
];
final malformedMetadata = <String, dynamic>{
'ratingKey': responseMarkers[0],
'title': responseMarkers[1],
'summary': responseMarkers[2],
responseMarkers[3]: {'value': responseMarkers[4]},
'Media': [
{
'id': 1,
'Part': [
{'id': 1, 'file': responseMarkers[5]},
],
},
],
'guid': 7,
};
final hub = PlexMappers.mediaHubFromJson({
'key': '/hubs/cc004',
'title': 'Synthetic hub',
'type': 'movie',
'Metadata': [
{'ratingKey': 'valid-sibling', 'type': 'movie', 'title': 'Valid sibling'},
malformedMetadata,
],
});
expect(hub.items, hasLength(1));
expect(hub.items.single.id, 'valid-sibling');
expect(hub.items.single.title, 'Valid sibling');
final event = await capturedEvent.future;
_expectSafePlexMapperEvent(event, responseMarkers: responseMarkers, topLevelFieldCount: malformedMetadata.length);
});
});
test('PlexMetadataDto accepts string ratings', () {
final dto = PlexMetadataDto.fromJson({
'ratingKey': '1',
'rating': '8.8',
'audienceRating': '9.1',
'userRating': '9.5',
});
expect(dto.rating, 8.8);
expect(dto.audienceRating, 9.1);
expect(dto.userRating, 9.5);
});
test('display criteria maps each Plex color metadata class', () {
final cases = <({Map<String, dynamic> stream, MediaDisplayColorType type, MediaDisplayColorTags tags})>[
(
stream: {'DOVIProfile': 5, 'DOVIPresent': 1},
type: MediaDisplayColorType.dolbyVision,
tags: (transfer: null, primaries: null, matrix: null),
),
(
stream: {'DOVIProfile': 8, 'DOVIBLCompatID': 4},
type: MediaDisplayColorType.hlg,
tags: (transfer: 'arib-std-b67', primaries: 'bt2020', matrix: 'bt2020nc'),
),
(
stream: {'colorTrc': 'smpte2084'},
type: MediaDisplayColorType.pq,
tags: (transfer: 'smpte2084', primaries: 'bt2020', matrix: 'bt2020nc'),
),
(
stream: <String, dynamic>{},
type: MediaDisplayColorType.sdr,
tags: (transfer: 'bt709', primaries: 'bt709', matrix: 'bt709'),
),
];
for (final testCase in cases) {
final criteria = PlexMappers.displayCriteriaFromJson({'width': 1920, 'height': 1080}, testCase.stream);
expect(criteria, isNotNull);
expect(criteria!.colorType, testCase.type);
expect(
(transfer: criteria.transfer, primaries: criteria.primaries, matrix: criteria.matrix),
testCase.tags,
reason: testCase.type.name,
);
}
});
group('PlexMappers.mediaItem (movie)', () {
test('maps a Plex movie with watch state, ratings, genres, and people', () {
final json = {
'ratingKey': '12345',
'key': '/library/metadata/12345',
'guid': 'plex://movie/5d776b59ad5437001f7be94b',
'studio': 'Warner Bros.',
'type': 'movie',
'title': 'Inception',
'titleSort': 'Inception',
'originalTitle': 'Inception',
'tagline': 'Your mind is the scene of the crime.',
'contentRating': 'PG-13',
'summary': 'Dom Cobb is a thief.',
'rating': 8.8,
'audienceRating': 9.1,
'userRating': 9.5,
'year': 2010,
'originallyAvailableAt': '2010-07-16',
'thumb': '/library/metadata/12345/thumb/1700000000',
'art': '/library/metadata/12345/art/1700000000',
'duration': 8880000,
'addedAt': 1600000000,
'updatedAt': 1700000000,
'lastViewedAt': 1750000000,
'viewOffset': 3000000,
'viewCount': 1,
'librarySectionID': 1,
'librarySectionTitle': 'Movies',
'ratingImage': 'rottentomatoes://image.rating.ripe',
'audienceRatingImage': 'rottentomatoes://image.rating.upright',
'Genre': [
{'tag': 'Action'},
{'tag': 'Sci-Fi'},
],
'Director': [
{'tag': 'Christopher Nolan'},
],
'Writer': [
{'tag': 'Christopher Nolan'},
],
'Producer': [
{'tag': 'Emma Thomas'},
],
'Country': [
{'tag': 'United States'},
],
'Role': [
{'id': 1, 'tag': 'Leonardo DiCaprio', 'role': 'Cobb', 'thumb': '/library/metadata/role/1/thumb'},
{'id': 2, 'tag': 'Joseph Gordon-Levitt', 'role': 'Arthur'},
],
};
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId), serverName: _serverName);
expect(item.id, '12345');
expect(item.backend, MediaBackend.plex);
expect(item.kind, MediaKind.movie);
expect(item.guid, 'plex://movie/5d776b59ad5437001f7be94b');
expect(item.title, 'Inception');
expect(item.titleSort, 'Inception');
expect(item.originalTitle, 'Inception');
expect(item.tagline, 'Your mind is the scene of the crime.');
expect(item.summary, 'Dom Cobb is a thief.');
expect(item.studio, 'Warner Bros.');
expect(item.year, 2010);
expect(item.originallyAvailableAt, '2010-07-16');
expect(item.contentRating, 'PG-13');
expect(item.rating, 8.8);
expect(item.audienceRating, 9.1);
expect(item.userRating, 9.5);
expect(item.ratingImage, 'rottentomatoes://image.rating.ripe');
expect(item.audienceRatingImage, 'rottentomatoes://image.rating.upright');
// Plex stores all temporal fields in milliseconds — pass-through.
expect(item.durationMs, 8880000);
expect(item.viewOffsetMs, 3000000);
expect(item.viewCount, 1);
expect(item.lastViewedAt, 1750000000);
expect(item.addedAt, 1600000000);
expect(item.updatedAt, 1700000000);
// Image paths kept relative — token-aware resolution lives on the client.
expect(item.thumbPath, '/library/metadata/12345/thumb/1700000000');
expect(item.artPath, '/library/metadata/12345/art/1700000000');
// Tag lists from the heterogeneous `[{tag: ...}, ...]` shape.
expect(item.genres, ['Action', 'Sci-Fi']);
expect(item.directors, ['Christopher Nolan']);
expect(item.writers, ['Christopher Nolan']);
expect(item.producers, ['Emma Thomas']);
expect(item.countries, ['United States']);
// Roles preserve role string and thumb path.
expect(item.roles, isNotNull);
expect(item.roles!.length, 2);
expect(item.roles![0].id, '1');
expect(item.roles![0].tag, 'Leonardo DiCaprio');
expect(item.roles![0].role, 'Cobb');
expect(item.roles![0].thumbPath, '/library/metadata/role/1/thumb');
expect(item.roles![1].thumbPath, isNull);
// Library identification.
expect(item.libraryId, '1');
expect(item.libraryTitle, 'Movies');
// Server-tagging.
expect(item.serverId, _serverId);
expect(item.serverName, _serverName);
});
});
group('PlexMappers.mediaItem (show + season + episode)', () {
test('show preserves leaf counts and child counts', () {
final json = {
'ratingKey': '500',
'key': '/library/metadata/500',
'type': 'show',
'title': 'Breaking Bad',
'leafCount': 62,
'viewedLeafCount': 62,
'childCount': 5,
'year': 2008,
};
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId));
expect(item.kind, MediaKind.show);
expect(item.leafCount, 62);
expect(item.viewedLeafCount, 62);
expect(item.childCount, 5);
expect(item.isWatched, isTrue);
});
test('show preserves Plex season display flags in raw metadata', () {
final json = {
'ratingKey': '500',
'key': '/library/metadata/500',
'type': 'show',
'title': 'Breaking Bad',
'skipChildren': '1',
'flattenSeasons': '1',
};
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId));
expect(item.raw, containsPair('key', '/library/metadata/500'));
expect(item.raw, containsPair('skipChildren', true));
expect(item.raw, containsPair('flattenSeasons', 1));
});
test('season carries parent (show) reference', () {
final json = {
'ratingKey': '510',
'type': 'season',
'title': 'Season 1',
'index': 1,
'parentRatingKey': '500',
'parentTitle': 'Breaking Bad',
'parentThumb': '/library/metadata/500/thumb/1',
'leafCount': 7,
'viewedLeafCount': 3,
};
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId));
expect(item.kind, MediaKind.season);
expect(item.index, 1);
expect(item.parentId, '500');
expect(item.parentTitle, 'Breaking Bad');
expect(item.parentThumbPath, '/library/metadata/500/thumb/1');
expect(item.leafCount, 7);
expect(item.viewedLeafCount, 3);
expect(item.isPartiallyWatched, isTrue);
});
test('episode carries parent (season) and grandparent (show) refs', () {
final json = {
'ratingKey': '520',
'type': 'episode',
'title': 'Pilot',
'index': 1,
'parentIndex': 1,
'parentRatingKey': '510',
'parentTitle': 'Season 1',
'parentYear': 2008,
'parentThumb': '/library/metadata/510/thumb/1',
'grandparentRatingKey': '500',
'grandparentTitle': 'Breaking Bad',
'grandparentThumb': '/library/metadata/500/thumb/1',
'grandparentArt': '/library/metadata/500/art/1',
'duration': 2820000,
'viewOffset': 1410000,
};
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId));
expect(item.kind, MediaKind.episode);
expect(item.index, 1);
expect(item.parentIndex, 1);
expect(item.parentId, '510');
expect(item.parentTitle, 'Season 1');
expect(item.year, isNull);
expect(item.parentThumbPath, '/library/metadata/510/thumb/1');
expect(item.grandparentId, '500');
expect(item.grandparentTitle, 'Breaking Bad');
expect(item.grandparentThumbPath, '/library/metadata/500/thumb/1');
expect(item.grandparentArtPath, '/library/metadata/500/art/1');
expect(item.durationMs, 2820000);
expect(item.viewOffsetMs, 1410000);
});
});
group('PlexMappers.mediaItem (music)', () {
test('album preserves studio and parent (artist) reference', () {
final json = {
'ratingKey': '700',
'type': 'album',
'title': 'Random Access Memories',
'parentRatingKey': '699',
'parentTitle': 'Daft Punk',
'studio': 'Columbia Records',
'year': 2013,
'leafCount': 13,
};
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId));
expect(item.kind, MediaKind.album);
expect(item.title, 'Random Access Memories');
expect(item.parentId, '699');
expect(item.parentTitle, 'Daft Punk');
expect(item.studio, 'Columbia Records');
expect(item.year, 2013);
expect(item.leafCount, 13);
});
test('track maps "audio" type to MediaKind.track', () {
final json = {
'ratingKey': '710',
'type': 'track',
'title': 'Get Lucky',
'index': 8,
'parentRatingKey': '700',
'parentTitle': 'Random Access Memories',
'parentYear': 2013,
'grandparentRatingKey': '699',
'grandparentTitle': 'Daft Punk',
'duration': 369000,
};
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId));
expect(item.kind, MediaKind.track);
expect(item.title, 'Get Lucky');
expect(item.index, 8);
expect(item.durationMs, 369000);
expect(item.parentId, '700');
expect(item.parentTitle, 'Random Access Memories');
expect(item.year, 2013);
expect(item.albumYear, 2013);
expect(item.grandparentId, '699');
expect(item.grandparentTitle, 'Daft Punk');
});
});
group('PlexMappers.mediaItem (media versions + image arrays)', () {
test('Media + Part list yields a MediaVersion with one MediaPart', () {
final json = {
'ratingKey': '12345',
'type': 'movie',
'title': 'Inception',
'Media': [
{
'id': 1,
'videoResolution': '1080',
'videoCodec': 'h264',
'bitrate': 8000,
'width': 1920,
'height': 1080,
'container': 'mkv',
'Part': [
{
'key': '/library/parts/1/file.mkv',
'Stream': [
{
'id': 10,
'streamType': 1,
'codec': 'hevc',
'frameRate': 23.976,
'DOVIProfile': 8,
'DOVIPresent': 1,
'DOVIBLCompatID': 1,
'colorTrc': 'smpte2084',
'colorPrimaries': 'bt2020',
'colorSpace': 'bt2020nc',
},
{'id': 11, 'streamType': 2, 'codec': 'eac3', 'channels': 6, 'languageCode': 'eng', 'selected': true},
],
},
],
},
],
};
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId));
expect(item.mediaVersions, isNotNull);
final v = item.mediaVersions!.single;
expect(v.id, '1');
expect(v.videoResolution, '1080');
expect(v.videoCodec, 'h264');
expect(v.bitrate, 8000);
expect(v.width, 1920);
expect(v.height, 1080);
expect(v.container, 'mkv');
expect(v.parts.single.streamPath, '/library/parts/1/file.mkv');
final video = v.parts.single.streams.firstWhere((stream) => stream.kind == MediaStreamKind.video);
expect(video.codec, 'hevc');
expect(video.frameRate, closeTo(23.976, 0.001));
expect(video.hdr, isTrue);
expect(video.dolbyVision, isTrue);
expect(video.dolbyVisionProfile, 8);
final audio = v.parts.single.streams.firstWhere((stream) => stream.kind == MediaStreamKind.audio);
expect(audio.codec, 'eac3');
expect(audio.channels, 6);
expect(audio.selected, isTrue);
});
test('Media-level audio and file hints backfill streams when Part.Stream is absent', () {
final json = {
'ratingKey': '6048',
'type': 'episode',
'title': 'Hello, Ms. Cobel',
'Media': [
{
'id': '6136',
'videoResolution': '4k',
'videoCodec': 'hevc',
'audioCodec': 'eac3',
'audioChannels': '6',
'width': '3840',
'height': '1606',
'container': 'mkv',
'Part': [
{
'id': '6154',
'key': '/library/parts/6154/file.mkv',
'file': '/tv/Severance.S02.Hybrid.MULTI.2160p.WEB-DL.DV.HDR.H265-AOC/S02/S02E01.mkv',
},
],
},
],
};
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId));
final part = item.mediaVersions!.single.parts.single;
final video = part.streams.firstWhere((stream) => stream.kind == MediaStreamKind.video);
final audio = part.streams.firstWhere((stream) => stream.kind == MediaStreamKind.audio);
expect(video.codec, 'hevc');
expect(video.hdr, isTrue);
expect(video.dolbyVision, isTrue);
expect(video.dolbyVisionProfile, isNull);
expect(audio.codec, 'eac3');
expect(audio.channels, 6);
expect(audio.selected, isTrue);
});
test('Image array (clearLogo, backgroundSquare) is hoisted onto top-level fields', () {
final json = {
'ratingKey': '12345',
'type': 'movie',
'title': 'Inception',
'Image': [
{'type': 'clearLogo', 'url': '/library/metadata/12345/clearLogo'},
{'type': 'backgroundSquare', 'url': '/library/metadata/12345/squareBg'},
{'type': 'snapshot', 'url': '/library/metadata/12345/snap'},
],
};
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId));
expect(item.clearLogoPath, '/library/metadata/12345/clearLogo');
expect(item.backgroundSquarePath, '/library/metadata/12345/squareBg');
});
});
group('PlexMappers.mediaLibrary', () {
test('library Directory entry maps to MediaLibrary with kind from type', () {
final json = {
'key': '1',
'title': 'Movies',
'type': 'movie',
'agent': 'tv.plex.agents.movie',
'language': 'en-US',
'updatedAt': 1700000000,
'createdAt': 1600000000,
'hidden': 0,
};
final lib = PlexMappers.mediaLibraryFromJson(json, serverId: ServerId(_serverId), serverName: _serverName);
expect(lib.id, '1');
expect(lib.backend, MediaBackend.plex);
expect(lib.title, 'Movies');
expect(lib.kind, MediaKind.movie);
expect(lib.language, 'en-US');
expect(lib.updatedAt, 1700000000);
expect(lib.createdAt, 1600000000);
expect(lib.hidden, isFalse);
expect(lib.isShared, isFalse);
expect(lib.serverId, _serverId);
expect(lib.serverName, _serverName);
});
test('shared library is marked isShared', () {
final json = {'key': 'shared', 'title': 'Shared with you', 'type': 'movie'};
final lib = PlexMappers.mediaLibraryFromJson(json, serverId: ServerId(_serverId), isShared: true);
expect(lib.isShared, isTrue);
});
test('hidden=1 maps to true', () {
final json = {'key': '2', 'title': 'Hidden', 'type': 'show', 'hidden': 1};
final lib = PlexMappers.mediaLibraryFromJson(json, serverId: ServerId(_serverId));
expect(lib.hidden, isTrue);
});
test('library DTO keeps generated parsing flexible and ignores client-only fields', () {
final dto = PlexLibraryDto.fromJson({
'key': 7,
'title': 'Music',
'type': 'artist',
'updatedAt': '1700000000',
'createdAt': '1600000000',
'hidden': '1',
'serverId': 'ignored-server',
'serverName': 'Ignored Server',
'isShared': true,
});
expect(dto.key, '7');
expect(dto.title, 'Music');
expect(dto.type, 'artist');
expect(dto.updatedAt, 1700000000);
expect(dto.createdAt, 1600000000);
expect(dto.hidden, 1);
expect(dto.serverId, isNull);
expect(dto.serverName, isNull);
expect(dto.isShared, isFalse);
});
test('library with missing title/type falls back to empty strings', () {
// Past regression: bare `as String` casts on title/type in
// PlexLibraryDto.fromJson would throw TypeError when Plex omitted
// either field. Confirms graceful degradation.
final json = {'key': '99'};
final lib = PlexMappers.mediaLibraryFromJson(json, serverId: ServerId(_serverId));
expect(lib.id, '99');
expect(lib.title, '');
expect(lib.kind, MediaKind.unknown);
});
});
group('PlexMappers.mediaHub', () {
test('hub with mixed-type items maps to MediaHub with neutral items', () {
final json = {
'key': '/hubs/movie.recentlyAdded',
'title': 'Recently Added Movies',
'type': 'movie',
'hubIdentifier': 'movie.recentlyAdded.1',
'size': 2,
'more': true,
'Metadata': [
{'ratingKey': '1', 'type': 'movie', 'title': 'Movie A'},
{'ratingKey': '2', 'type': 'movie', 'title': 'Movie B'},
],
};
final hub = PlexMappers.mediaHubFromJson(json, serverId: ServerId(_serverId), serverName: _serverName);
expect(hub.id, '/hubs/movie.recentlyAdded');
expect(hub.identifier, 'movie.recentlyAdded.1');
expect(hub.title, 'Recently Added Movies');
expect(hub.type, 'movie');
expect(hub.size, 2);
expect(hub.more, isTrue);
expect(hub.items.length, 2);
expect(hub.items[0].title, 'Movie A');
expect(hub.items[0].kind, MediaKind.movie);
expect(hub.items[1].title, 'Movie B');
expect(hub.items[0].serverId, _serverId);
expect(hub.items[0].serverName, _serverName);
expect(hub.serverId, _serverId);
expect(hub.serverName, _serverName);
});
test('Directory entries without type are inferred (folder vs show)', () {
final json = {
'key': '/hubs/foo',
'title': 'Foo',
'type': 'mixed',
'Directory': [
// Has leafCount → looks like a show
{'ratingKey': '10', 'title': 'A Show', 'leafCount': 24},
// Plain folder
{'ratingKey': '11', 'title': 'A Folder'},
],
};
final hub = PlexMappers.mediaHubFromJson(json, serverId: ServerId(_serverId));
expect(hub.items.length, 2);
expect(hub.items[0].kind, MediaKind.show);
expect(hub.items[1].kind, MediaKind.folder);
});
test('parses Metadata + Directory together', () {
final json = {
'key': '/hubs/foo',
'title': 'Foo',
'type': 'mixed',
'Metadata': [
{'ratingKey': '1', 'type': 'movie', 'title': 'Movie A'},
],
'Directory': [
{'ratingKey': '10', 'type': 'show', 'title': 'Show B'},
],
};
final hub = PlexMappers.mediaHubFromJson(json, serverId: ServerId(_serverId));
expect(hub.items.length, 2);
expect(hub.items[0].kind, MediaKind.movie);
expect(hub.items[1].kind, MediaKind.show);
});
});
group('PlexMappers.mediaPlaylist', () {
test('video playlist maps with summary and counts', () {
final json = {
'ratingKey': '999',
'key': '/playlists/999/items',
'type': 'playlist',
'title': 'Date Night',
'summary': 'Movies for date night',
'smart': false,
'playlistType': 'video',
'duration': 14400000,
'leafCount': 5,
'composite': '/playlists/999/composite/1700000000',
'addedAt': 1600000000,
'updatedAt': 1700000000,
'lastViewedAt': 1750000000,
'viewCount': 3,
'thumb': '/playlists/999/thumb',
};
final p = PlexMappers.mediaPlaylistFromJson(json, serverId: ServerId(_serverId), serverName: _serverName);
expect(p.id, '999');
expect(p.backend, MediaBackend.plex);
expect(p.title, 'Date Night');
expect(p.summary, 'Movies for date night');
expect(p.smart, isFalse);
expect(p.playlistType, 'video');
expect(p.durationMs, 14400000);
expect(p.leafCount, 5);
expect(p.viewCount, 3);
expect(p.addedAt, 1600000000);
expect(p.updatedAt, 1700000000);
expect(p.lastViewedAt, 1750000000);
expect(p.compositeImagePath, '/playlists/999/composite/1700000000');
expect(p.thumbPath, '/playlists/999/thumb');
expect(p.serverId, _serverId);
expect(p.serverName, _serverName);
});
test('smart playlist preserves the smart flag', () {
final json = {
'ratingKey': '888',
'key': '/playlists/888/items',
'type': 'playlist',
'title': 'Recently Added',
'smart': true,
'playlistType': 'audio',
};
final p = PlexMappers.mediaPlaylistFromJson(json, serverId: ServerId(_serverId));
expect(p.smart, isTrue);
expect(p.playlistType, 'audio');
});
test('playlist with missing key/type/smart/playlistType falls back to safe defaults', () {
// Past regression: bare `as String` / `as bool` casts in
// PlexPlaylistDto.fromJson would throw TypeError when Plex omitted
// optional fields. Confirms graceful degradation.
final json = {'ratingKey': '777', 'title': 'Bare', 'summary': null};
final p = PlexMappers.mediaPlaylistFromJson(json, serverId: ServerId(_serverId));
expect(p.id, '777');
expect(p.title, 'Bare');
expect(p.smart, isFalse);
expect(p.playlistType, '');
});
test('playlist DTO keeps generated parsing flexible and ignores client-only fields', () {
final dto = PlexPlaylistDto.fromJson({
'ratingKey': 888,
'title': 'Recently Added',
'duration': '14400000',
'leafCount': '5',
'addedAt': '1600000000',
'updatedAt': '1700000000',
'lastViewedAt': '1750000000',
'viewCount': '3',
'serverId': 'ignored-server',
'serverName': 'Ignored Server',
});
expect(dto.ratingKey, '888');
expect(dto.key, '');
expect(dto.type, '');
expect(dto.title, 'Recently Added');
expect(dto.smart, isFalse);
expect(dto.playlistType, '');
expect(dto.duration, 14400000);
expect(dto.leafCount, 5);
expect(dto.addedAt, 1600000000);
expect(dto.updatedAt, 1700000000);
expect(dto.lastViewedAt, 1750000000);
expect(dto.viewCount, 3);
expect(dto.serverId, isNull);
expect(dto.serverName, isNull);
});
});
group('PlexMappers DTO direct entry points', () {
test('mediaItem (DTO) preserves data identical to JSON path', () {
final json = {'ratingKey': '1', 'type': 'movie', 'title': 'Test', 'year': 2024};
final dto = PlexMetadataDto.fromJsonWithImages(json).copyWith(serverId: ServerId(_serverId));
final item = PlexMappers.mediaItem(dto);
expect(item.id, '1');
expect(item.title, 'Test');
expect(item.year, 2024);
expect(item.serverId, _serverId);
});
test('mediaVersion (DTO) maps version + part', () {
final json = {
'id': 42,
'videoResolution': '4k',
'videoCodec': 'hevc',
'bitrate': 25000,
'width': 3840,
'height': 2160,
'container': 'mp4',
'Part': [
{'key': '/library/parts/42/file.mp4'},
],
};
final v = PlexMappers.mediaVersionFromJson(json);
expect(v.id, '42');
expect(v.videoResolution, '4k');
expect(v.videoCodec, 'hevc');
expect(v.bitrate, 25000);
expect(v.width, 3840);
expect(v.height, 2160);
expect(v.container, 'mp4');
expect(v.parts.single.streamPath, '/library/parts/42/file.mp4');
});
test('metadata toJson remains scalar-only for cache overlays', () {
const dto = PlexMetadataDto(
ratingKey: '1',
title: 'Test',
serverId: _serverId,
role: [PlexRoleDto(tag: 'Actor')],
genre: ['Action'],
mediaVersions: [PlexMediaVersionDto(id: 42, partKey: '/library/parts/42/file.mp4')],
);
final json = dto.toJson();
expect(json, containsPair('ratingKey', '1'));
expect(json, containsPair('title', 'Test'));
expect(json, isNot(contains('serverId')));
expect(json, isNot(contains('Role')));
expect(json, isNot(contains('Genre')));
expect(json, isNot(contains('Media')));
});
test('metadata copyWith preserves nullable values when null is passed', () {
const dto = PlexMetadataDto(ratingKey: '1', title: 'Test', summary: 'Summary');
final copied = dto.copyWith(title: null, summary: null);
expect(copied.title, 'Test');
expect(copied.summary, 'Summary');
});
});
}