Files
plezy/test/services/jellyfin_mappers_test.dart
T

663 lines
24 KiB
Dart

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_kind.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_stream.dart';
import 'package:plezy/services/jellyfin_mappers.dart';
import 'package:plezy/services/settings_service.dart' show EpisodePosterMode;
const _serverId = 'jf-machine-1';
void main() {
group('JellyfinMappers.mediaItem', () {
test('maps a movie with watch state, ratings, genres, and people', () {
final json = {
'Id': 'abc123',
'Name': 'Inception',
'OriginalTitle': 'Inception',
'Type': 'Movie',
'Overview': 'Dream within a dream.',
'Taglines': ['Your mind is the scene of the crime.'],
'ProductionYear': 2010,
'PremiereDate': '2010-07-16T00:00:00.0000000Z',
'OfficialRating': 'PG-13',
'CommunityRating': 8.8,
'Genres': ['Action', 'Sci-Fi'],
'People': [
{'Type': 'Actor', 'Name': 'Leo', 'Id': 'p1', 'PrimaryImageTag': 'tag1', 'Role': 'Cobb'},
{'Type': 'Director', 'Name': 'Christopher Nolan'},
],
'Studios': [
{'Name': 'Warner Bros'},
],
'ProductionLocations': ['United States'],
'RunTimeTicks': 88800000000, // 8880 sec * 10_000_000
'UserData': {
'PlayCount': 1,
'PlaybackPositionTicks': 30000000000, // 3000 sec
'Played': true,
'LastPlayedDate': '2026-04-25T20:00:00.0000000Z',
},
'DateCreated': '2025-01-15T10:00:00.0000000Z',
'DateLastSaved': '2026-03-01T10:00:00.0000000Z',
'ImageTags': {'Primary': 'thumbtag', 'Logo': 'logotag'},
'BackdropImageTags': ['backtag', 'backtag-2', 'backtag-3'],
};
final item = JellyfinMappers.mediaItem(
json,
serverId: ServerId(_serverId),
serverName: 'Home',
absolutizer: null,
)!;
expect(item.id, 'abc123');
expect(item.backend, MediaBackend.jellyfin);
expect(item.kind, MediaKind.movie);
expect(item.title, 'Inception');
expect(item.summary, 'Dream within a dream.');
expect(item.tagline, 'Your mind is the scene of the crime.');
expect(item.year, 2010);
expect(item.originallyAvailableAt, '2010-07-16');
expect(item.contentRating, 'PG-13');
expect(item.studio, 'Warner Bros');
expect(item.rating, 8.8);
expect(item.genres, ['Action', 'Sci-Fi']);
expect(item.directors, ['Christopher Nolan']);
expect(item.countries, ['United States']);
expect(item.roles, isNotNull);
expect(item.roles!.length, 1);
expect(item.roles![0].tag, 'Leo');
expect(item.roles![0].role, 'Cobb');
expect(item.roles![0].thumbPath, '/Items/p1/Images/Primary?tag=tag1');
// Tick conversion: 100ns ticks → ms.
expect(item.durationMs, 8880000); // 8880s in ms
expect(item.viewOffsetMs, 3000000); // 3000s in ms
expect(item.viewCount, 1);
// Image paths.
expect(item.thumbPath, '/Items/abc123/Images/Primary?tag=thumbtag');
expect(item.artPath, '/Items/abc123/Images/Backdrop/0?tag=backtag');
expect(item.backdropPaths, [
'/Items/abc123/Images/Backdrop/0?tag=backtag',
'/Items/abc123/Images/Backdrop/1?tag=backtag-2',
'/Items/abc123/Images/Backdrop/2?tag=backtag-3',
]);
expect(item.clearLogoPath, '/Items/abc123/Images/Logo?tag=logotag');
// Multi-server fields.
expect(item.serverId, _serverId);
expect(item.serverName, 'Home');
});
test('preserves backdrop indices, deduplicates tags, and absolutizes every valid path', () {
const absolutizer = JellyfinImageAbsolutizer(baseUrl: 'https://jellyfin.example', accessToken: 'secret');
final item = JellyfinMappers.mediaItem(
{
'Id': 'movie-1',
'Type': 'Movie',
'BackdropImageTags': ['first', 42, '', 'first', 'fifth'],
},
serverId: ServerId(_serverId),
absolutizer: absolutizer,
)!;
expect(item.artPath, 'https://jellyfin.example/Items/movie-1/Images/Backdrop/0?tag=first&api_key=secret');
expect(item.backdropPaths, [
'https://jellyfin.example/Items/movie-1/Images/Backdrop/0?tag=first&api_key=secret',
'https://jellyfin.example/Items/movie-1/Images/Backdrop/4?tag=fifth&api_key=secret',
]);
});
test('does not treat Jellyfin PlayCount as watched when Played is false', () {
final json = {
'Id': 'started-only',
'Name': 'Started Only',
'Type': 'Movie',
'UserData': {'PlayCount': 1, 'Played': false},
};
final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!;
expect(item.viewCount, 0);
expect(item.isWatched, isFalse);
});
test('maps UserData.IsFavorite and ignores the legacy Likes flag', () {
final json = {
'Id': 'fav-1',
'Name': 'Favorited Movie',
'Type': 'Movie',
'UserData': {'IsFavorite': true, 'Likes': true},
};
final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!;
expect(item.isFavorite, isTrue);
// Likes used to map to userRating 10.0/0.0; the rate sheet now uses
// IsFavorite for Jellyfin, so Likes must no longer surface anywhere.
expect(item.userRating, isNull);
});
test('missing UserData leaves isFavorite null', () {
final item = JellyfinMappers.mediaItem(
{'Id': 'no-userdata', 'Name': 'No UserData', 'Type': 'Movie'},
serverId: ServerId(_serverId),
absolutizer: null,
)!;
expect(item.isFavorite, isNull);
});
test('maps generic Jellyfin video types to playable clips', () {
final video = JellyfinMappers.mediaItem(
{'Id': 'home-video', 'Name': 'Home Video', 'Type': 'Video'},
serverId: ServerId(_serverId),
absolutizer: null,
)!;
final musicVideo = JellyfinMappers.mediaItem(
{'Id': 'music-video', 'Name': 'Music Video', 'Type': 'MusicVideo'},
serverId: ServerId(_serverId),
absolutizer: null,
)!;
expect(video.kind, MediaKind.clip);
expect(musicVideo.kind, MediaKind.clip);
expect(video.kind.isVideo, isTrue);
expect(musicVideo.kind.isVideo, isTrue);
});
test('music video watch state ignores recursive and child counts', () {
MediaItem mapMusicVideo(bool played) => JellyfinMappers.mediaItem(
{
'Id': 'music-video',
'Name': 'Music Video',
'Type': 'MusicVideo',
'RecursiveItemCount': '1',
'ChildCount': 1,
'UserData': {'Played': played, 'PlayCount': played ? '1' : '0', 'UnplayedItemCount': '0'},
},
serverId: ServerId(_serverId),
absolutizer: null,
)!;
final unplayed = mapMusicVideo(false);
final played = mapMusicVideo(true);
expect(unplayed.leafCount, 1);
expect(unplayed.viewedLeafCount, isNull);
expect(unplayed.isWatched, isFalse);
expect(played.isWatched, isTrue);
});
test('episode preserves series/season hierarchy', () {
final json = {
'Id': 'ep1',
'Name': 'Pilot',
'Type': 'Episode',
'IndexNumber': 1,
'ParentIndexNumber': 1,
'SeriesId': 'series-1',
'SeriesName': 'Breaking Bad',
'SeriesPrimaryImageTag': 'seriesPrimary',
'SeasonId': 'season-1',
'SeasonName': 'Season 1',
'SeasonPrimaryImageTag': 'seasonPrimary',
'UserData': {'UnplayedItemCount': 0},
};
final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!;
expect(item.kind, MediaKind.episode);
expect(item.index, 1);
expect(item.parentIndex, 1);
expect(item.parentId, 'season-1');
expect(item.parentTitle, 'Season 1');
expect(item.parentThumbPath, '/Items/season-1/Images/Primary?tag=seasonPrimary');
expect(item.grandparentId, 'series-1');
expect(item.grandparentTitle, 'Breaking Bad');
expect(item.grandparentThumbPath, '/Items/series-1/Images/Primary?tag=seriesPrimary');
expect(item.grandparentArtPath, '/Items/series-1/Images/Backdrop/0');
});
test('episode maps every inherited series backdrop', () {
final item = JellyfinMappers.mediaItem(
{
'Id': 'ep-parent-art',
'Type': 'Episode',
'SeriesId': 'series-fallback',
'ParentBackdropItemId': 'series-parent',
'ParentBackdropImageTags': ['parent-0', 'parent-1', 'parent-2'],
},
serverId: ServerId(_serverId),
absolutizer: null,
)!;
expect(item.grandparentArtPath, '/Items/series-parent/Images/Backdrop/0?tag=parent-0');
expect(item.grandparentBackdropPaths, [
'/Items/series-parent/Images/Backdrop/0?tag=parent-0',
'/Items/series-parent/Images/Backdrop/1?tag=parent-1',
'/Items/series-parent/Images/Backdrop/2?tag=parent-2',
]);
expect(item.heroBackdropPaths, item.grandparentBackdropPaths);
});
test('episode season poster falls back to series poster when season image tag is absent', () {
final json = {
'Id': 'ep1',
'Name': 'Pilot',
'Type': 'Episode',
'SeriesId': 'series-1',
'SeriesName': 'Breaking Bad',
'SeriesPrimaryImageTag': 'seriesPrimary',
'SeasonId': 'season-1',
'SeasonName': 'Season 1',
};
final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!;
expect(item.parentThumbPath, '/Items/season-1/Images/Primary');
expect(item.grandparentThumbPath, '/Items/series-1/Images/Primary?tag=seriesPrimary');
expect(item.posterThumb(mode: EpisodePosterMode.seasonPoster), '/Items/season-1/Images/Primary');
expect(
item.posterThumbFallback(mode: EpisodePosterMode.seasonPoster),
'/Items/series-1/Images/Primary?tag=seriesPrimary',
);
});
test('series viewedLeafCount derived from total - UnplayedItemCount', () {
final json = {
'Id': 's1',
'Name': 'Show',
'Type': 'Series',
'ChildCount': 12,
'RecursiveItemCount': 12,
'UserData': {'UnplayedItemCount': 4},
};
final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!;
expect(item.leafCount, 12);
expect(item.viewedLeafCount, 8);
expect(item.isPartiallyWatched, isTrue);
expect(item.isWatched, isFalse);
});
test('container leaf counts tolerate scalar drift and clamp invalid unplayed totals', () {
final item = JellyfinMappers.mediaItem(
{
'Id': 's-invalid-counts',
'Name': 'Show',
'Type': 'Series',
'RecursiveItemCount': '5',
'ChildCount': 2.0,
'UserData': {'UnplayedItemCount': '8'},
},
serverId: ServerId(_serverId),
absolutizer: null,
)!;
expect(item.leafCount, 5);
expect(item.childCount, 2);
expect(item.viewedLeafCount, 0);
expect(item.unwatchedCount, 5);
expect(item.isWatched, isFalse);
});
test('path-encodes image ids and tag query values', () {
final item = JellyfinMappers.mediaItem(
{
'Id': 'folder/item #1?x',
'Type': 'Episode',
'Name': 'Reserved IDs',
'SeriesId': 'series/id #1?x',
'SeriesPrimaryImageTag': 'series/tag ?x',
'ParentLogoItemId': 'logo/id #1?x',
'ParentLogoImageTag': 'logo/tag ?x',
'ImageTags': {'Primary': 'primary/tag ?x'},
'People': [
{'Type': 'Actor', 'Name': 'Actor', 'Id': 'person/id #1?x', 'PrimaryImageTag': 'person/tag ?x'},
],
},
serverId: ServerId(_serverId),
absolutizer: null,
)!;
expect(item.thumbPath, '/Items/folder%2Fitem%20%231%3Fx/Images/Primary?tag=primary%2Ftag%20%3Fx');
expect(item.grandparentThumbPath, '/Items/series%2Fid%20%231%3Fx/Images/Primary?tag=series%2Ftag%20%3Fx');
expect(item.clearLogoPath, '/Items/logo%2Fid%20%231%3Fx/Images/Logo?tag=logo%2Ftag%20%3Fx');
expect(item.roles!.single.thumbPath, '/Items/person%2Fid%20%231%3Fx/Images/Primary?tag=person%2Ftag%20%3Fx');
});
test('series leafCount uses RecursiveItemCount over ChildCount', () {
// Realistic Jellyfin shape for a Series: ChildCount = season count,
// RecursiveItemCount = total episode count. Plex `leafCount` semantics
// are leaves (episodes), so we must prefer the recursive total or the
// unwatched badge ends up showing seasons instead of episodes.
final json = {
'Id': 's2',
'Name': 'Show with seasons',
'Type': 'Series',
'ChildCount': 4, // 4 seasons
'RecursiveItemCount': 50, // 50 episodes
'UserData': {'UnplayedItemCount': 7},
};
final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!;
expect(item.leafCount, 50);
expect(item.viewedLeafCount, 43);
});
test('media versions map MediaSources + MediaStreams faithfully', () {
final json = {
'Id': 'movie-1',
'Name': 'Movie',
'Type': 'Movie',
'MediaSources': [
{
'Id': 'src-1',
'Container': 'mkv',
'Bitrate': 8000000,
'Size': 10737418240,
'RunTimeTicks': 60000000000,
'MediaStreams': [
{
'Index': 0,
'Type': 'Video',
'Codec': 'h264',
'IsDefault': true,
'RealFrameRate': 23.976,
'Width': 1920,
'Height': 1080,
},
{
'Index': 1,
'Type': 'Audio',
'Codec': 'eac3',
'Language': 'eng',
'DisplayLanguage': 'English',
'Channels': 6,
'IsDefault': true,
},
{
'Index': 2,
'Type': 'Subtitle',
'Codec': 'srt',
'Language': 'eng',
'IsExternal': true,
'IsForced': true,
'DeliveryUrl': '/Videos/movie-1/movie-1/Subtitles/2/Stream.srt',
},
],
},
],
};
final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!;
expect(item.mediaVersions, isNotNull);
final v = item.mediaVersions!.single;
expect(v.id, 'src-1');
expect(v.width, 1920);
expect(v.height, 1080);
expect(v.videoResolution, '1080');
expect(v.videoCodec, 'h264');
// Jellyfin's `Bitrate` (8 Mbps in bps) is converted to kbps to match
// MediaVersion.bitrate's contract (and Plex's encoding).
expect(v.bitrate, 8000);
expect(v.container, 'mkv');
final part = v.parts.single;
expect(part.id, 'src-1');
expect(part.streamPath, '/Videos/src-1/stream');
expect(part.sizeBytes, 10737418240);
expect(part.durationMs, 6000000); // 6000s
final video = part.streams.firstWhere((s) => s.kind == MediaStreamKind.video);
expect(video.codec, 'h264');
expect(video.frameRate, closeTo(23.976, 0.001));
expect(video.selected, isTrue);
final audio = part.streams.firstWhere((s) => s.kind == MediaStreamKind.audio);
expect(audio.codec, 'eac3');
expect(audio.channels, 6);
expect(audio.languageCode, 'eng');
final subtitle = part.streams.firstWhere((s) => s.kind == MediaStreamKind.subtitle);
expect(subtitle.forced, isTrue);
expect(subtitle.isExternal, isTrue);
expect(subtitle.sidecarPath, '/Videos/movie-1/movie-1/Subtitles/2/Stream.srt');
});
test('media streams map Jellyfin Dolby Vision, HDR, and source default audio', () {
final json = {
'Id': 'movie-1',
'Name': 'Movie',
'Type': 'Movie',
'MediaSources': [
{
'Id': 'src-1',
'DefaultAudioStreamIndex': 2,
'MediaStreams': [
{
'Index': 0,
'Type': 'Video',
'Codec': 'hevc',
'Width': 3840,
'Height': 2160,
'VideoRangeType': 'DOVI',
'VideoRange': 'HDR',
'VideoDoViTitle': 'Dolby Vision Profile 8',
'DvProfile': 8,
'DvLevel': 6,
'DvBlSignalCompatibilityId': 1,
},
{'Index': 1, 'Type': 'Audio', 'Codec': 'eac3', 'Channels': 6, 'IsDefault': true},
{'Index': 2, 'Type': 'Audio', 'Codec': 'aac', 'Channels': 2},
],
},
],
};
final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!;
final streams = item.mediaVersions!.single.parts.single.streams;
final video = streams.firstWhere((stream) => stream.kind == MediaStreamKind.video);
final firstAudio = streams.firstWhere((stream) => stream.index == 1);
final selectedAudio = streams.firstWhere((stream) => stream.index == 2);
expect(video.codec, 'hevc');
expect(video.hdr, isTrue);
expect(video.dolbyVision, isTrue);
expect(video.dolbyVisionProfile, 8);
expect(firstAudio.selected, isFalse);
expect(selectedAudio.selected, isTrue);
});
test('media streams map Jellyfin HDR without Dolby Vision', () {
final json = {
'Id': 'movie-1',
'Name': 'Movie',
'Type': 'Movie',
'MediaSources': [
{
'Id': 'src-1',
'MediaStreams': [
{'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'VideoRangeType': 'HDR10', 'VideoRange': 'HDR'},
],
},
],
};
final item = JellyfinMappers.mediaItem(json, serverId: ServerId(_serverId), absolutizer: null)!;
final video = item.mediaVersions!.single.parts.single.streams.single;
expect(video.hdr, isTrue);
expect(video.dolbyVision, isFalse);
expect(video.dolbyVisionProfile, isNull);
});
});
group('JellyfinMappers.library', () {
test('translates Jellyfin CollectionType to neutral MediaKind', () {
final cases = {
'movies': MediaKind.movie,
'tvshows': MediaKind.show,
'music': MediaKind.artist,
'photos': MediaKind.photo,
'boxsets': MediaKind.collection,
};
for (final entry in cases.entries) {
final lib = JellyfinMappers.library({
'Id': 'view-${entry.key}',
'Name': 'Library',
'CollectionType': entry.key,
}, serverId: ServerId(_serverId))!;
expect(lib.kind, entry.value, reason: 'CollectionType ${entry.key}');
expect(lib.backend, MediaBackend.jellyfin);
}
});
test('maps content-type-less collection folders to a movie and show root browse', () {
for (final view in [
{'Id': 'view-missing-type', 'Name': 'Mixed', 'Type': 'CollectionFolder', 'IsFolder': true},
{'Id': 'view-empty-type', 'Name': 'Mixed', 'Type': 'CollectionFolder', 'CollectionType': '', 'IsFolder': true},
]) {
final mixed = JellyfinMappers.library(view, serverId: ServerId(_serverId))!;
expect(mixed.kind, MediaKind.folder);
expect(mixed.defaultBrowseKinds, const [MediaKind.movie, MediaKind.show]);
}
final unrecognised = JellyfinMappers.library({
'Id': 'view-books',
'Name': 'Books',
'Type': 'CollectionFolder',
'CollectionType': 'books',
}, serverId: ServerId(_serverId))!;
expect(unrecognised.kind, MediaKind.unknown);
expect(unrecognised.defaultBrowseKinds, isEmpty);
});
});
// Past regression: a Jellyfin server can omit any of these fields when
// the item is freshly created or the user has restricted permissions.
// Confirms the mapper degrades gracefully — none of these inputs should
// throw and every output field should have a sane fallback.
group('JellyfinMappers.mediaItem null-tolerance', () {
test('minimal payload (just Id + Type) yields a MediaItem with sane defaults', () {
final item = JellyfinMappers.mediaItem(
{'Id': 'bare-1', 'Type': 'Movie'},
serverId: ServerId(_serverId),
serverName: 'Home',
absolutizer: null,
)!;
expect(item.id, 'bare-1');
expect(item.kind, MediaKind.movie);
expect(item.summary, isNull);
expect(item.year, isNull);
expect(item.isWatched, isFalse);
// Optional list fields can be null OR empty — both are sane.
expect(item.genres, anyOf(isNull, isEmpty));
expect(item.directors, anyOf(isNull, isEmpty));
});
test('missing UserData leaves watch state nullable without throwing', () {
final item = JellyfinMappers.mediaItem(
{'Id': 'i', 'Type': 'Movie', 'Name': 'X'},
serverId: ServerId(_serverId),
absolutizer: null,
)!;
// Either 0 or null is acceptable as long as we don't crash.
expect(item.viewCount, anyOf(isNull, 0));
expect(item.viewOffsetMs, isNull);
expect(item.lastViewedAt, isNull);
});
test('null People array does not crash', () {
final item = JellyfinMappers.mediaItem(
{'Id': 'i', 'Type': 'Movie', 'Name': 'X', 'People': null},
serverId: ServerId(_serverId),
absolutizer: null,
)!;
expect(item.directors, anyOf(isNull, isEmpty));
expect(item.writers, anyOf(isNull, isEmpty));
expect(item.roles, anyOf(isNull, isEmpty));
});
test('null Genres / Studios / ProductionLocations degrade gracefully', () {
final item = JellyfinMappers.mediaItem(
{'Id': 'i', 'Type': 'Movie', 'Name': 'X', 'Genres': null, 'Studios': null, 'ProductionLocations': null},
serverId: ServerId(_serverId),
absolutizer: null,
)!;
expect(item.genres, anyOf(isNull, isEmpty));
expect(item.studio, isNull);
expect(item.countries, anyOf(isNull, isEmpty));
});
test('malformed RunTimeTicks does not throw — duration left null', () {
final item = JellyfinMappers.mediaItem(
{'Id': 'i', 'Type': 'Movie', 'Name': 'X', 'RunTimeTicks': 'not-a-number'},
serverId: ServerId(_serverId),
absolutizer: null,
)!;
expect(item.durationMs, isNull);
});
test('null MediaSources does not crash', () {
final item = JellyfinMappers.mediaItem(
{'Id': 'i', 'Type': 'Movie', 'Name': 'X', 'MediaSources': null},
serverId: ServerId(_serverId),
absolutizer: null,
)!;
expect(item.mediaVersions, anyOf(isNull, isEmpty));
});
});
group('JellyfinMappers.mediaItem missing-Id rejection', () {
test('returns null when Id is absent', () {
expect(
JellyfinMappers.mediaItem({'Type': 'Movie', 'Name': 'noId'}, serverId: ServerId(_serverId), absolutizer: null),
isNull,
);
});
test('returns null when Id is empty string', () {
expect(
JellyfinMappers.mediaItem(
{'Id': '', 'Type': 'Movie', 'Name': 'emptyId'},
serverId: ServerId(_serverId),
absolutizer: null,
),
isNull,
);
});
test('drops MediaSources entries with missing Id', () {
final item = JellyfinMappers.mediaItem(
{
'Id': 'movie-x',
'Type': 'Movie',
'MediaSources': [
{'Container': 'mkv', 'Bitrate': 8000000, 'MediaStreams': []},
{'Id': 'src-ok', 'Container': 'mp4', 'Bitrate': 4000000, 'MediaStreams': []},
],
},
serverId: ServerId(_serverId),
absolutizer: null,
)!;
expect(item.mediaVersions!.length, 1);
expect(item.mediaVersions!.single.id, 'src-ok');
});
});
group('JellyfinMappers.library missing-Id rejection', () {
test('returns null when Id is absent', () {
expect(
JellyfinMappers.library({'Name': 'Library', 'CollectionType': 'movies'}, serverId: ServerId(_serverId)),
isNull,
);
});
});
}