feat: merge jellyfin support

This commit is contained in:
edde746
2026-05-01 01:54:56 +02:00
408 changed files with 56042 additions and 13763 deletions
+19
View File
@@ -0,0 +1,19 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/models/livetv_channel.dart';
void main() {
test('favoriteChannelKey includes source and id', () {
expect(favoriteChannelKey('server://a/provider', '101'), isNot(favoriteChannelKey('server://b/provider', '101')));
expect(
FavoriteChannel(source: 'server://a/provider', id: '101').stableKey,
favoriteChannelKey('server://a/provider', '101'),
);
});
test('liveTvChannelScopeKey includes server, dvr, and channel key', () {
final a = LiveTvChannel(key: '101', serverId: 'server-1', liveDvrKey: 'dvr-a');
final b = LiveTvChannel(key: '101', serverId: 'server-1', liveDvrKey: 'dvr-b');
expect(liveTvChannelScopeKey(a), isNot(liveTvChannelScopeKey(b)));
});
}
+37 -29
View File
@@ -1,5 +1,7 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/models/plex_media_version.dart';
import 'package:plezy/media/media_part.dart';
import 'package:plezy/media/media_version.dart';
import 'package:plezy/services/plex_mappers.dart';
Map<String, dynamic> _media({
int id = 1,
@@ -20,11 +22,11 @@ Map<String, dynamic> _media({
}
void main() {
group('PlexMediaVersion accessibility parsing', () {
group('Plex media version accessibility parsing', () {
test('accessible/exists are null when Plex did not include them', () {
final v = PlexMediaVersion.fromJson(_media());
expect(v.accessible, isNull);
expect(v.exists, isNull);
final v = PlexMappers.mediaVersionFromJson(_media());
expect(v.parts.single.accessible, isNull);
expect(v.parts.single.exists, isNull);
expect(
v.isPlayable,
isTrue,
@@ -33,38 +35,41 @@ void main() {
});
test('parses int 0/1 from Plex JSON output', () {
final notExists = PlexMediaVersion.fromJson(_media(partExtras: {'exists': 0, 'accessible': 1}));
expect(notExists.exists, isFalse);
expect(notExists.accessible, isTrue);
final notExists = PlexMappers.mediaVersionFromJson(_media(partExtras: {'exists': 0, 'accessible': 1}));
expect(notExists.parts.single.exists, isFalse);
expect(notExists.parts.single.accessible, isTrue);
expect(notExists.isPlayable, isFalse);
final notAccessible = PlexMediaVersion.fromJson(_media(partExtras: {'exists': 1, 'accessible': 0}));
expect(notAccessible.exists, isTrue);
expect(notAccessible.accessible, isFalse);
final notAccessible = PlexMappers.mediaVersionFromJson(_media(partExtras: {'exists': 1, 'accessible': 0}));
expect(notAccessible.parts.single.exists, isTrue);
expect(notAccessible.parts.single.accessible, isFalse);
expect(notAccessible.isPlayable, isFalse);
final ok = PlexMediaVersion.fromJson(_media(partExtras: {'exists': 1, 'accessible': 1}));
final ok = PlexMappers.mediaVersionFromJson(_media(partExtras: {'exists': 1, 'accessible': 1}));
expect(ok.isPlayable, isTrue);
});
test('parses native bool', () {
final v = PlexMediaVersion.fromJson(_media(partExtras: {'exists': false, 'accessible': true}));
expect(v.exists, isFalse);
expect(v.accessible, isTrue);
final v = PlexMappers.mediaVersionFromJson(_media(partExtras: {'exists': false, 'accessible': true}));
expect(v.parts.single.exists, isFalse);
expect(v.parts.single.accessible, isTrue);
expect(v.isPlayable, isFalse);
});
test('parses string "0"/"1" forms (XML-to-JSON conversion)', () {
final v = PlexMediaVersion.fromJson(_media(partExtras: {'exists': '0', 'accessible': '1'}));
expect(v.exists, isFalse);
expect(v.accessible, isTrue);
final v = PlexMappers.mediaVersionFromJson(_media(partExtras: {'exists': '0', 'accessible': '1'}));
expect(v.parts.single.exists, isFalse);
expect(v.parts.single.accessible, isTrue);
});
test('isPlayable truth table mirrors Plex web semantics', () {
// Mirrors plex-web.js:28926: !1 !== e.exists && !1 !== e.accessible
// Anything but explicit `false` for both fields → playable.
bool playable({bool? acc, bool? ex}) {
return PlexMediaVersion(id: 1, partKey: '/k', accessible: acc, exists: ex).isPlayable;
return MediaVersion(
id: '1',
parts: [MediaPart(id: '1', streamPath: '/k', accessible: acc, exists: ex)],
).isPlayable;
}
expect(playable(acc: null, ex: null), isTrue);
@@ -87,30 +92,33 @@ void main() {
'container': 'mkv',
'Part': {'id': 101, 'key': '/library/parts/1/file.mkv', 'exists': 0},
};
final v = PlexMediaVersion.fromJson(json);
expect(v.exists, isFalse);
final v = PlexMappers.mediaVersionFromJson(json);
expect(v.parts.single.exists, isFalse);
expect(v.isPlayable, isFalse);
});
test('missing Part array leaves accessibility fields null', () {
final json = {'id': 1, 'videoResolution': '1080', 'videoCodec': 'h264', 'container': 'mkv'};
final v = PlexMediaVersion.fromJson(json);
expect(v.accessible, isNull);
expect(v.exists, isNull);
final v = PlexMappers.mediaVersionFromJson(json);
expect(v.parts.single.accessible, isNull);
expect(v.parts.single.exists, isNull);
expect(v.isPlayable, isTrue);
});
});
group('PlexMediaVersion displayLabel', () {
group('MediaVersion displayLabel', () {
test('formats Plex videoResolution for display', () {
expect(PlexMediaVersion(id: 1, partKey: '/k', videoResolution: '1080').displayLabel, startsWith('1080p '));
String displayLabel(String resolution) =>
MediaVersion(id: '1', videoResolution: resolution, videoCodec: 'h264').displayLabel;
expect(displayLabel('1080'), startsWith('1080p '));
for (final resolution in ['4k', '4K']) {
expect(PlexMediaVersion(id: 1, partKey: '/k', videoResolution: resolution).displayLabel, startsWith('4K '));
expect(displayLabel(resolution), startsWith('4K '));
}
for (final resolution in ['8k', '8K']) {
expect(PlexMediaVersion(id: 1, partKey: '/k', videoResolution: resolution).displayLabel, startsWith('8K '));
expect(displayLabel(resolution), startsWith('8K '));
}
expect(PlexMediaVersion(id: 1, partKey: '/k', videoResolution: 'sd').displayLabel, startsWith('sd '));
expect(displayLabel('sd'), startsWith('sd '));
});
});
}
-396
View File
@@ -1,396 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/models/plex_metadata.dart';
import 'package:plezy/services/settings_service.dart' show EpisodePosterMode;
PlexMetadata _make({
String ratingKey = '1',
String? key,
String? type,
String? title,
String? parentTitle,
String? grandparentTitle,
String? thumb,
String? parentThumb,
String? grandparentThumb,
String? art,
String? parentRatingKey,
String? grandparentRatingKey,
int? duration,
int? viewOffset,
int? viewCount,
int? leafCount,
int? viewedLeafCount,
String? serverId,
}) {
return PlexMetadata(
ratingKey: ratingKey,
key: key,
type: type,
title: title,
parentTitle: parentTitle,
grandparentTitle: grandparentTitle,
thumb: thumb,
parentThumb: parentThumb,
grandparentThumb: grandparentThumb,
art: art,
parentRatingKey: parentRatingKey,
grandparentRatingKey: grandparentRatingKey,
duration: duration,
viewOffset: viewOffset,
viewCount: viewCount,
leafCount: leafCount,
viewedLeafCount: viewedLeafCount,
serverId: serverId,
);
}
void main() {
group('PlexMetadata.mediaType', () {
test('maps known lowercase type strings', () {
for (final pair in const [
('movie', PlexMediaType.movie),
('show', PlexMediaType.show),
('season', PlexMediaType.season),
('episode', PlexMediaType.episode),
('artist', PlexMediaType.artist),
('album', PlexMediaType.album),
('track', PlexMediaType.track),
('collection', PlexMediaType.collection),
('playlist', PlexMediaType.playlist),
('clip', PlexMediaType.clip),
('photo', PlexMediaType.photo),
]) {
expect(_make(type: pair.$1).mediaType, pair.$2, reason: 'type=${pair.$1}');
}
});
test('case-insensitive', () {
expect(_make(type: 'MOVIE').mediaType, PlexMediaType.movie);
expect(_make(type: 'Episode').mediaType, PlexMediaType.episode);
});
test('unknown / null -> PlexMediaType.unknown', () {
expect(_make(type: null).mediaType, PlexMediaType.unknown);
expect(_make(type: '').mediaType, PlexMediaType.unknown);
expect(_make(type: 'weird').mediaType, PlexMediaType.unknown);
});
});
group('PlexMediaType enum extensions', () {
test('isVideo', () {
expect(PlexMediaType.movie.isVideo, isTrue);
expect(PlexMediaType.episode.isVideo, isTrue);
expect(PlexMediaType.clip.isVideo, isTrue);
expect(PlexMediaType.show.isVideo, isFalse);
expect(PlexMediaType.season.isVideo, isFalse);
expect(PlexMediaType.track.isVideo, isFalse);
});
test('isShowRelated', () {
expect(PlexMediaType.show.isShowRelated, isTrue);
expect(PlexMediaType.season.isShowRelated, isTrue);
expect(PlexMediaType.episode.isShowRelated, isTrue);
expect(PlexMediaType.movie.isShowRelated, isFalse);
expect(PlexMediaType.clip.isShowRelated, isFalse);
});
test('isMusic', () {
expect(PlexMediaType.artist.isMusic, isTrue);
expect(PlexMediaType.album.isMusic, isTrue);
expect(PlexMediaType.track.isMusic, isTrue);
expect(PlexMediaType.movie.isMusic, isFalse);
});
test('isPlayable', () {
expect(PlexMediaType.movie.isPlayable, isTrue);
expect(PlexMediaType.episode.isPlayable, isTrue);
expect(PlexMediaType.clip.isPlayable, isTrue);
expect(PlexMediaType.track.isPlayable, isTrue);
expect(PlexMediaType.show.isPlayable, isFalse);
expect(PlexMediaType.artist.isPlayable, isFalse);
});
test('typeNumber for API-addressable types', () {
expect(PlexMediaType.movie.typeNumber, 1);
expect(PlexMediaType.show.typeNumber, 2);
expect(PlexMediaType.season.typeNumber, 3);
expect(PlexMediaType.episode.typeNumber, 4);
expect(PlexMediaType.artist.typeNumber, 8);
expect(PlexMediaType.album.typeNumber, 9);
expect(PlexMediaType.track.typeNumber, 10);
});
test('typeNumber fallback is 0 for collection/playlist/clip/photo/unknown', () {
expect(PlexMediaType.collection.typeNumber, 0);
expect(PlexMediaType.playlist.typeNumber, 0);
expect(PlexMediaType.clip.typeNumber, 0);
expect(PlexMediaType.photo.typeNumber, 0);
expect(PlexMediaType.unknown.typeNumber, 0);
});
});
group('globalKey', () {
test('joins serverId:ratingKey when serverId present', () {
expect(_make(ratingKey: '42', serverId: 'srv').globalKey, 'srv:42');
});
test('falls back to bare ratingKey when serverId is null', () {
expect(_make(ratingKey: '42').globalKey, '42');
});
});
group('parentChain', () {
test('movie (no parents) -> empty list', () {
expect(_make(type: 'movie').parentChain, isEmpty);
});
test('season (show parent only) -> [show]', () {
expect(_make(type: 'season', grandparentRatingKey: 's1').parentChain, ['s1']);
});
test('episode (season + show) -> [season, show]', () {
expect(_make(type: 'episode', parentRatingKey: 'se1', grandparentRatingKey: 'sh1').parentChain, ['se1', 'sh1']);
});
test('omits null entries (only parent, no grandparent)', () {
expect(_make(parentRatingKey: 'p').parentChain, ['p']);
});
});
group('isLibrarySection & librarySectionKey', () {
test('non-library-section key', () {
final m = _make(key: '/library/metadata/12345');
expect(m.isLibrarySection, isFalse);
expect(m.librarySectionKey, isNull);
});
test('library-section key extracts numeric id', () {
final m = _make(key: '/library/sections/7/all');
expect(m.isLibrarySection, isTrue);
expect(m.librarySectionKey, '7');
});
test('library-section without trailing path still extracts id', () {
final m = _make(key: '/library/sections/12');
expect(m.isLibrarySection, isTrue);
expect(m.librarySectionKey, '12');
});
test('null key -> not a library section', () {
final m = _make();
expect(m.isLibrarySection, isFalse);
expect(m.librarySectionKey, isNull);
});
});
group('displayTitle / displaySubtitle', () {
test('episode with grandparentTitle prefers show name', () {
final m = _make(type: 'episode', title: 'Pilot', grandparentTitle: 'My Show');
expect(m.displayTitle, 'My Show');
expect(m.displaySubtitle, 'Pilot');
});
test('episode without grandparentTitle falls back to title', () {
final m = _make(type: 'episode', title: 'Pilot');
expect(m.displayTitle, 'Pilot');
expect(m.displaySubtitle, isNull);
});
test('season with grandparentTitle shows show name as title, season name as subtitle', () {
final m = _make(type: 'season', title: 'Season 1', grandparentTitle: 'My Show');
expect(m.displayTitle, 'My Show');
expect(m.displaySubtitle, 'Season 1');
});
test('season without grandparent falls back to parentTitle', () {
final m = _make(type: 'season', title: 'Season 1', parentTitle: 'My Show');
expect(m.displayTitle, 'My Show');
expect(m.displaySubtitle, 'Season 1');
});
test('movie uses its own title, no subtitle', () {
final m = _make(type: 'movie', title: 'Inception');
expect(m.displayTitle, 'Inception');
expect(m.displaySubtitle, isNull);
});
test('missing title returns empty displayTitle', () {
final m = _make(type: 'movie');
expect(m.displayTitle, '');
});
});
group('posterThumb', () {
test('episode + seriesPoster -> grandparentThumb, fallback thumb', () {
expect(
_make(type: 'episode', thumb: 't', grandparentThumb: 'g').posterThumb(mode: EpisodePosterMode.seriesPoster),
'g',
);
expect(_make(type: 'episode', thumb: 't').posterThumb(mode: EpisodePosterMode.seriesPoster), 't');
});
test('episode + seasonPoster -> parentThumb → grandparentThumb → thumb', () {
expect(
_make(
type: 'episode',
thumb: 't',
parentThumb: 'p',
grandparentThumb: 'g',
).posterThumb(mode: EpisodePosterMode.seasonPoster),
'p',
);
expect(
_make(type: 'episode', thumb: 't', grandparentThumb: 'g').posterThumb(mode: EpisodePosterMode.seasonPoster),
'g',
);
expect(_make(type: 'episode', thumb: 't').posterThumb(mode: EpisodePosterMode.seasonPoster), 't');
});
test('episode + episodeThumbnail -> thumb', () {
expect(
_make(
type: 'episode',
thumb: 't',
parentThumb: 'p',
grandparentThumb: 'g',
).posterThumb(mode: EpisodePosterMode.episodeThumbnail),
't',
);
});
test('season -> grandparentThumb when available', () {
expect(_make(type: 'season', thumb: 't', grandparentThumb: 'g').posterThumb(), 'g');
});
test('season without grandparent -> thumb', () {
expect(_make(type: 'season', thumb: 't').posterThumb(), 't');
});
test('season + mixed hub + episodeThumbnail -> art fallback thumb', () {
expect(
_make(
type: 'season',
thumb: 't',
art: 'a',
grandparentThumb: 'g',
).posterThumb(mode: EpisodePosterMode.episodeThumbnail, mixedHubContext: true),
'a',
);
expect(
_make(
type: 'season',
thumb: 't',
grandparentThumb: 'g',
).posterThumb(mode: EpisodePosterMode.episodeThumbnail, mixedHubContext: true),
't',
);
});
test('movie/show in mixed hub + episodeThumbnail -> art, fallback thumb', () {
expect(
_make(
type: 'movie',
thumb: 't',
art: 'a',
).posterThumb(mode: EpisodePosterMode.episodeThumbnail, mixedHubContext: true),
'a',
);
expect(
_make(type: 'show', thumb: 't').posterThumb(mode: EpisodePosterMode.episodeThumbnail, mixedHubContext: true),
't',
);
});
test('movie/show outside mixed hub -> thumb regardless of mode', () {
expect(_make(type: 'movie', thumb: 't', art: 'a').posterThumb(mode: EpisodePosterMode.episodeThumbnail), 't');
});
test('other types default to thumb', () {
expect(_make(type: 'artist', thumb: 't').posterThumb(), 't');
expect(_make(type: 'track', thumb: 't').posterThumb(), 't');
});
});
group('usesWideAspectRatio', () {
test('clips always wide', () {
expect(_make(type: 'clip').usesWideAspectRatio(EpisodePosterMode.seriesPoster), isTrue);
expect(_make(type: 'clip').usesWideAspectRatio(EpisodePosterMode.seasonPoster), isTrue);
});
test('episode + episodeThumbnail is wide', () {
expect(_make(type: 'episode').usesWideAspectRatio(EpisodePosterMode.episodeThumbnail), isTrue);
});
test('episode + other modes is not wide', () {
expect(_make(type: 'episode').usesWideAspectRatio(EpisodePosterMode.seriesPoster), isFalse);
expect(_make(type: 'episode').usesWideAspectRatio(EpisodePosterMode.seasonPoster), isFalse);
});
test('movie/show/season in mixed hub + episodeThumbnail is wide', () {
for (final t in const ['movie', 'show', 'season']) {
expect(
_make(type: t).usesWideAspectRatio(EpisodePosterMode.episodeThumbnail, mixedHubContext: true),
isTrue,
reason: 'type=$t',
);
}
});
test('movie/show/season outside mixed hub is not wide', () {
expect(_make(type: 'movie').usesWideAspectRatio(EpisodePosterMode.episodeThumbnail), isFalse);
});
});
group('watch-state predicates', () {
test('hasActiveProgress requires both duration and viewOffset set, with 0 < vo < dur', () {
expect(_make().hasActiveProgress, isFalse);
expect(_make(duration: 100).hasActiveProgress, isFalse);
expect(_make(viewOffset: 10).hasActiveProgress, isFalse);
expect(_make(duration: 100, viewOffset: 0).hasActiveProgress, isFalse);
expect(_make(duration: 100, viewOffset: 100).hasActiveProgress, isFalse);
expect(_make(duration: 100, viewOffset: 50).hasActiveProgress, isTrue);
expect(_make(duration: 100, viewOffset: 1).hasActiveProgress, isTrue);
expect(_make(duration: 100, viewOffset: 99).hasActiveProgress, isTrue);
});
test('isPartiallyWatched requires leaf counts with 0 < viewed < total', () {
expect(_make().isPartiallyWatched, isFalse);
expect(_make(leafCount: 10).isPartiallyWatched, isFalse);
expect(_make(viewedLeafCount: 3).isPartiallyWatched, isFalse);
expect(_make(leafCount: 10, viewedLeafCount: 0).isPartiallyWatched, isFalse);
expect(_make(leafCount: 10, viewedLeafCount: 10).isPartiallyWatched, isFalse);
expect(_make(leafCount: 10, viewedLeafCount: 3).isPartiallyWatched, isTrue);
});
test('isWatched prefers leaf counts when both present', () {
expect(_make(leafCount: 10, viewedLeafCount: 10).isWatched, isTrue);
expect(_make(leafCount: 10, viewedLeafCount: 11).isWatched, isTrue);
expect(_make(leafCount: 10, viewedLeafCount: 9).isWatched, isFalse);
});
test('isWatched uses viewCount when leaf counts absent', () {
expect(_make(viewCount: 1).isWatched, isTrue);
expect(_make(viewCount: 0).isWatched, isFalse);
expect(_make().isWatched, isFalse);
});
});
group('heroArt', () {
test('uses backgroundSquare when container is squarer than ~1.39', () {
final m = PlexMetadata(ratingKey: '1', art: 'wide.jpg', backgroundSquare: 'square.jpg');
expect(m.heroArt(containerAspectRatio: 1.0), 'square.jpg');
expect(m.heroArt(containerAspectRatio: 1.38), 'square.jpg');
});
test('uses art when container is wider', () {
final m = PlexMetadata(ratingKey: '1', art: 'wide.jpg', backgroundSquare: 'square.jpg');
expect(m.heroArt(containerAspectRatio: 1.39), 'wide.jpg');
expect(m.heroArt(containerAspectRatio: 1.78), 'wide.jpg');
});
test('falls back to art when backgroundSquare is null regardless of aspect', () {
final m = PlexMetadata(ratingKey: '1', art: 'wide.jpg');
expect(m.heroArt(containerAspectRatio: 1.0), 'wide.jpg');
});
});
}
-80
View File
@@ -1,80 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/models/plex_sort.dart';
void main() {
group('PlexSort.getSortKey', () {
test('returns plain key for ascending', () {
final s = PlexSort(key: 'titleSort', title: 'Title');
expect(s.getSortKey(), 'titleSort');
expect(s.getSortKey(descending: false), 'titleSort');
});
test('appends :desc when no descKey is provided', () {
final s = PlexSort(key: 'addedAt', title: 'Recently Added');
expect(s.getSortKey(descending: true), 'addedAt:desc');
});
test('uses explicit descKey when provided', () {
final s = PlexSort(key: 'titleSort', descKey: 'titleSort:desc', title: 'Title');
expect(s.getSortKey(descending: true), 'titleSort:desc');
final custom = PlexSort(key: 'rating', descKey: 'rating.desc.custom', title: 'Rating');
expect(custom.getSortKey(descending: true), 'rating.desc.custom');
});
});
group('PlexSort.isDefaultDescending', () {
test('true for "desc" (case-insensitive)', () {
expect(PlexSort(key: 'k', title: 't', defaultDirection: 'desc').isDefaultDescending, isTrue);
expect(PlexSort(key: 'k', title: 't', defaultDirection: 'DESC').isDefaultDescending, isTrue);
expect(PlexSort(key: 'k', title: 't', defaultDirection: 'Desc').isDefaultDescending, isTrue);
});
test('false for "asc", null, or other values', () {
expect(PlexSort(key: 'k', title: 't', defaultDirection: 'asc').isDefaultDescending, isFalse);
expect(PlexSort(key: 'k', title: 't').isDefaultDescending, isFalse);
expect(PlexSort(key: 'k', title: 't', defaultDirection: '').isDefaultDescending, isFalse);
});
});
group('PlexSort.fromJson', () {
test('parses all fields', () {
final s = PlexSort.fromJson({
'key': 'titleSort',
'descKey': 'titleSort:desc',
'title': 'Title',
'defaultDirection': 'asc',
});
expect(s.key, 'titleSort');
expect(s.descKey, 'titleSort:desc');
expect(s.title, 'Title');
expect(s.defaultDirection, 'asc');
});
test('tolerates missing optional fields', () {
final s = PlexSort.fromJson({'key': 'k', 'title': 't'});
expect(s.descKey, isNull);
expect(s.defaultDirection, isNull);
});
});
group('PlexSort equality & hashCode', () {
test('equality is based on key only (matches current contract)', () {
final a = PlexSort(key: 'k', descKey: 'k:desc', title: 'A', defaultDirection: 'asc');
final b = PlexSort(key: 'k', descKey: 'other', title: 'B', defaultDirection: 'desc');
expect(a, equals(b));
expect(a.hashCode, b.hashCode);
});
test('different keys are not equal', () {
final a = PlexSort(key: 'k1', title: 'A');
final b = PlexSort(key: 'k2', title: 'A');
expect(a, isNot(equals(b)));
});
test('identity short-circuit', () {
final a = PlexSort(key: 'k', title: 't');
expect(a == a, isTrue);
});
});
}