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 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.from(eventJson['contexts'] as Map); expect(contexts.containsKey('json'), isFalse); expect(Map.from(contexts['plex_mapper'] as Map), { 'backend': 'plex', 'dto': 'PlexMetadataDto', 'topLevelFieldCount': topLevelFieldCount, }); final exception = Map.from(((eventJson['exception'] as Map)['values'] as List).single as Map); expect(exception['type'], contains('TypeError')); final stackTrace = Map.from(exception['stacktrace'] as Map); expect(stackTrace['frames'], isNotEmpty); } void main() { group('Plex metadata Sentry diagnostics', () { late Completer capturedEvent; setUp(() async { capturedEvent = Completer(); 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 = { '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())); 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 = { '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 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: {}, 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', 'imdbRatingCount': 250858, // The detail endpoint adds the multi-source array; the TMDB entry // duplicates the audienceRating scalar and must not appear twice. 'Rating': [ {'image': 'imdb://image.rating', 'type': 'audience', 'value': 8.4}, {'image': 'rottentomatoes://image.rating.ripe', 'type': 'critic', 'value': 8.8}, {'image': 'themoviedb://image.rating', 'type': 'audience', 'value': 9.1}, ], '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.userRating, 9.5); // Headline scalar first, then the array, deduped on (source, value): // the RT critic entry repeats `rating`/`ratingImage`, and the TMDB entry // is a distinct source so it survives alongside the RT audience scalar. expect(item.ratings?.map((rating) => rating.source).toList(), [ 'rottenTomatoesCritic', 'rottenTomatoesAudience', 'imdb', 'tmdb', ]); expect(item.ratings?.map((rating) => rating.value).toList(), [8.8, 9.1, 8.4, 9.1]); // Vote counts are IMDb-only; Plex reports none for the other sources. expect(item.ratings?.map((rating) => rating.votes).toList(), [null, null, 250858, null]); // 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); expect(item.libraryId, '1'); expect(item.libraryTitle, 'Movies'); expect(item.serverId, _serverId); expect(item.serverName, _serverName); }); test('a section listing yields the scalar pair alone', () { // Plex sends no `Rating[]` outside /library/metadata/{id}, and no query // parameter adds it, so listing-backed cards get one or two scores. final item = PlexMappers.mediaItemFromJson({ 'ratingKey': 'listing-1', 'type': 'movie', 'rating': 9.4, 'ratingImage': 'rottentomatoes://image.rating.ripe', 'audienceRating': 8.3, 'audienceRatingImage': 'themoviedb://image.rating', }, serverId: ServerId(_serverId)); expect(item.ratings?.map((rating) => rating.source).toList(), ['rottenTomatoesCritic', 'tmdb']); expect(item.ratings?.map((rating) => rating.value).toList(), [9.4, 8.3]); }); test('an unrated item reports no ratings rather than an empty list', () { final item = PlexMappers.mediaItemFromJson({ 'ratingKey': 'unrated-1', 'type': 'movie', }, serverId: ServerId(_serverId)); expect(item.rating, isNull); expect(item.ratings, isNull); }); test('scores outside the reportable range are dropped, not folded', () { final item = PlexMappers.mediaItemFromJson({ 'ratingKey': 'noisy-1', 'type': 'movie', 'rating': 140, 'audienceRating': -1, 'Rating': [ {'image': 'imdb://image.rating', 'type': 'audience', 'value': '8.5'}, ], }, serverId: ServerId(_serverId)); // Only the IMDb entry survives, and its string value still parses. expect(item.ratings?.single.source, 'imdb'); expect(item.ratings?.single.value, 8.5); }); test('normalizes aggregate watch counts off leaf items', () { final item = PlexMappers.mediaItemFromJson({ 'ratingKey': 'leaf-with-counts', 'type': 'movie', 'viewCount': 0, 'leafCount': 1, 'viewedLeafCount': 1, }, serverId: ServerId(_serverId)); expect(item.leafCount, 1); expect(item.viewedLeafCount, isNull); expect(item.isWatched, isFalse); expect(item.unwatchedCount, isNull); }); }); 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'); }); }); }