Files
plezy/test/utils/episode_collection_test.dart
T

458 lines
20 KiB
Dart

import '../test_helpers/paged_fakes.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/library_query.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_part.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/media_version.dart';
import 'package:plezy/utils/download_version_utils.dart';
import 'package:plezy/media/episode_collection.dart';
import '../test_helpers/media_items.dart';
MediaItem _season(String id, {int index = 1, int? leafCount, int? viewedLeafCount}) => testMediaItem(
id: id,
backend: MediaBackend.plex,
kind: MediaKind.season,
title: 'Season $index',
index: index,
leafCount: leafCount,
viewedLeafCount: viewedLeafCount,
);
MediaItem _episode(
String id, {
List<MediaVersion>? versions,
String? parentId,
int? parentIndex,
int? index,
String? grandparentId,
int? viewCount,
int? viewOffsetMs,
int? durationMs,
String? originallyAvailableAt,
}) => testMediaItem(
id: id,
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode',
mediaVersions: versions,
parentId: parentId,
parentIndex: parentIndex,
index: index,
grandparentId: grandparentId,
viewCount: viewCount,
viewOffsetMs: viewOffsetMs,
durationMs: durationMs,
originallyAvailableAt: originallyAvailableAt,
);
MediaItem _clip(String id) => testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.clip, title: 'Clip');
class _RecordingClient implements MediaServerClient {
_RecordingClient({this.childrenByParent = const {}, this.childrenPageByParent = const {}, this.itemsById = const {}});
final Map<String, List<MediaItem>> childrenByParent;
final Map<String, List<MediaItem>> childrenPageByParent;
final Map<String, MediaItem> itemsById;
final childrenCalls = <String>[];
final childrenPageCalls = <({String parentId, int? start, int? size})>[];
@override
Future<List<MediaItem>> fetchChildren(String parentId) async {
childrenCalls.add(parentId);
return childrenByParent[parentId] ?? const [];
}
@override
Future<LibraryPage<MediaItem>> fetchChildrenPage(String parentId, {int? start, int? size, abort}) async {
childrenPageCalls.add((parentId: parentId, start: start, size: size));
final all = childrenPageByParent[parentId] ?? const <MediaItem>[];
return fakeLibraryPage(all, start: start, size: size);
}
@override
Future<MediaItem?> fetchItem(String id) async => itemsById[id];
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _SeasonPagingRecordingClient extends _RecordingClient implements SeasonEpisodePagingClient {
_SeasonPagingRecordingClient({this.seasonPageBySeason = const {}});
final Map<({String seriesId, String seasonId}), List<MediaItem>> seasonPageBySeason;
final seasonEpisodePageCalls = <({String seriesId, String seasonId, int? start, int? size})>[];
@override
Future<LibraryPage<MediaItem>> fetchSeasonEpisodesPage(
String seriesId,
String seasonId, {
int? start,
int? size,
abort,
}) async {
seasonEpisodePageCalls.add((seriesId: seriesId, seasonId: seasonId, start: start, size: size));
final all = seasonPageBySeason[(seriesId: seriesId, seasonId: seasonId)] ?? const <MediaItem>[];
return fakeLibraryPage(all, start: start, size: size);
}
}
class _LeavesClient implements MediaServerClient {
_LeavesClient(this.leaves);
final List<MediaItem> leaves;
@override
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async => leaves;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
void main() {
test('collectEpisodes drops Specials when includeSpecials is false', () async {
final client = _LeavesClient([
_episode('s1e1', parentIndex: 1, index: 1, originallyAvailableAt: '2022-10-05'),
_episode('s0e1', parentIndex: 0, index: 1, originallyAvailableAt: '2022-10-27'),
_episode('s1e2', parentIndex: 1, index: 2, originallyAvailableAt: '2022-11-02'),
]);
final withoutSpecials = <MediaItem>[];
await collectEpisodes(client, 'show-1', unwatchedOnly: false, out: withoutSpecials, includeSpecials: false);
expect(withoutSpecials.map((e) => e.id), ['s1e1', 's1e2']);
// Default keeps Specials, interleaved into aired order.
final withSpecials = <MediaItem>[];
await collectEpisodes(client, 'show-1', unwatchedOnly: false, out: withSpecials);
expect(withSpecials.map((e) => e.id), ['s1e1', 's0e1', 's1e2']);
});
test('defaultPlaybackSeason skips specials when a regular season exists', () {
final special = _season('specials', index: 0);
final season1 = _season('season-1');
final episodeRow = _episode('episode-row', parentIndex: 99);
expect(defaultPlaybackSeason([special, season1]), same(season1));
expect(defaultPlaybackSeasonIndex([special, season1]), 1);
expect(preferredSeasonIndex([episodeRow, special, season1], initialSeasonIndex: 99), 2);
});
test('preferredSeasonIndex honors explicit and on-deck season choices', () {
final special = _season('specials', index: 0);
final season1 = _season('season-1');
final season2 = _season('season-2', index: 2);
expect(preferredSeasonIndex([special, season1, season2], initialSeasonId: season2.id, initialSeasonIndex: 1), 2);
expect(preferredSeasonIndex([special, season1, season2], initialSeasonIndex: 2), 2);
expect(
preferredSeasonIndex([special, season1, season2], onDeckEpisode: _episode('episode-2', parentId: season2.id)),
2,
);
expect(preferredSeasonIndex([special, season1, season2], onDeckEpisode: _episode('episode-1', parentIndex: 1)), 1);
});
test('firstUnwatchedSeasonIndex prefers the first regular season with unwatched episodes', () {
final special = _season('specials', index: 0, leafCount: 3, viewedLeafCount: 0);
final season1 = _season('season-1', index: 1, leafCount: 5, viewedLeafCount: 5); // fully watched
final season2 = _season('season-2', index: 2, leafCount: 5, viewedLeafCount: 2); // partially watched
final season3 = _season('season-3', index: 3, leafCount: 5, viewedLeafCount: 0);
expect(firstUnwatchedSeasonIndex([special, season1, season2, season3]), 2);
});
test('firstUnwatchedSeasonIndex falls back to specials only when no regular season qualifies', () {
final special = _season('specials', index: 0, leafCount: 3, viewedLeafCount: 1);
final season1 = _season('season-1', index: 1, leafCount: 4, viewedLeafCount: 4);
expect(firstUnwatchedSeasonIndex([special, season1]), 0);
});
test('firstUnwatchedSeasonIndex returns null when fully watched or counts are missing', () {
final season1 = _season('season-1', index: 1, leafCount: 4, viewedLeafCount: 4);
final season2 = _season('season-2', index: 2, leafCount: 6, viewedLeafCount: 6);
expect(firstUnwatchedSeasonIndex([season1, season2]), isNull);
// No leaf counts at all → can't tell, so returns null and callers fall back.
expect(firstUnwatchedSeasonIndex([_season('season-1'), _season('season-2', index: 2)]), isNull);
});
test('preferredSeasonIndex falls back to the first unwatched season without an on-deck episode', () {
final special = _season('specials', index: 0, leafCount: 2, viewedLeafCount: 0);
final season1 = _season('season-1', index: 1, leafCount: 5, viewedLeafCount: 5);
final season2 = _season('season-2', index: 2, leafCount: 5, viewedLeafCount: 1);
// No explicit target, no backend on-deck → first season with unwatched episodes.
expect(preferredSeasonIndex([special, season1, season2]), 2);
});
test('preferredSeasonIndex keeps the default season for a fully-unwatched show', () {
final special = _season('specials', index: 0, leafCount: 2, viewedLeafCount: 0);
final season1 = _season('season-1', index: 1, leafCount: 5, viewedLeafCount: 0);
final season2 = _season('season-2', index: 2, leafCount: 5, viewedLeafCount: 0);
// Identical to defaultPlaybackSeasonIndex (first regular season).
expect(preferredSeasonIndex([special, season1, season2]), 1);
expect(preferredSeasonIndex([special, season1, season2]), defaultPlaybackSeasonIndex([special, season1, season2]));
});
test('firstUnwatchedEpisode skips watched but keeps in-progress episodes', () {
final watched = _episode('e1', index: 1, viewCount: 1);
final inProgress = _episode('e2', index: 2, viewCount: 1, viewOffsetMs: 500, durationMs: 1000);
final unwatched = _episode('e3', index: 3);
// In-progress is returned even though it is flagged watched.
expect(firstUnwatchedEpisode([watched, inProgress, unwatched]), same(inProgress));
// Otherwise the first fully-unwatched episode wins.
expect(firstUnwatchedEpisode([watched, unwatched]), same(unwatched));
// Non-episode rows are ignored.
expect(firstUnwatchedEpisode([_season('season-1'), unwatched]), same(unwatched));
});
test('firstUnwatchedEpisode returns null when every episode is watched', () {
expect(
firstUnwatchedEpisode([_episode('e1', index: 1, viewCount: 1), _episode('e2', index: 2, viewCount: 2)]),
isNull,
);
});
test('sortEpisodesByWatchOrder interleaves Specials into aired order by air date', () {
// Mirrors a real interleaved-Specials show (e.g. The Eminence in Shadow):
// S00E01 aired between S01E02 and S01E05, so it plays there — not after the
// whole season. This is what Plex's own play queue returns (#1416).
final s1e1 = _episode('s1e1', parentIndex: 1, index: 1, originallyAvailableAt: '2022-10-05');
final s1e2 = _episode('s1e2', parentIndex: 1, index: 2, originallyAvailableAt: '2022-10-12');
final s0e1 = _episode('s0e1', parentIndex: 0, index: 1, originallyAvailableAt: '2022-10-27');
final s1e5 = _episode('s1e5', parentIndex: 1, index: 5, originallyAvailableAt: '2022-11-02');
final s0e2 = _episode('s0e2', parentIndex: 0, index: 2, originallyAvailableAt: '2022-11-03');
// Raw container order would clump the Specials folder first.
final episodes = [s0e1, s0e2, s1e1, s1e2, s1e5];
sortEpisodesByWatchOrder(episodes);
expect(episodes.map((e) => e.id), ['s1e1', 's1e2', 's0e1', 's1e5', 's0e2']);
});
test('sortEpisodesByWatchOrder falls back to Specials-last when episodes have no air dates', () {
final s0e1 = _episode('s0e1', parentIndex: 0, index: 1);
final s0e2 = _episode('s0e2', parentIndex: 0, index: 2);
final s1e1 = _episode('s1e1', parentIndex: 1, index: 1);
final s1e2 = _episode('s1e2', parentIndex: 1, index: 2);
final s2e1 = _episode('s2e1', parentIndex: 2, index: 1);
// Raw /grandchildren order would lead with the Specials folder.
final episodes = [s0e1, s0e2, s1e1, s1e2, s2e1];
sortEpisodesByWatchOrder(episodes);
// With no air dates, a "next 2 unwatched" cut still takes S01E01/S01E02.
expect(episodes.map((e) => e.id), ['s1e1', 's1e2', 's2e1', 's0e1', 's0e2']);
});
test('sortEpisodesByWatchOrder trails undated Specials after dated episodes', () {
// A dated regular run with an undated Special: the Special can't be placed
// in the aired timeline, so it sorts last rather than wedging into the run.
final s1e1 = _episode('s1e1', parentIndex: 1, index: 1, originallyAvailableAt: '2022-10-05');
final s1e2 = _episode('s1e2', parentIndex: 1, index: 2, originallyAvailableAt: '2022-10-12');
final s0e1 = _episode('s0e1', parentIndex: 0, index: 1);
final episodes = [s0e1, s1e2, s1e1];
sortEpisodesByWatchOrder(episodes);
expect(episodes.map((e) => e.id), ['s1e1', 's1e2', 's0e1']);
});
test('compareEpisodesByWatchOrder breaks index ties on id for deterministic cuts', () {
final a = _episode('a', parentIndex: 1, index: 1);
final b = _episode('b', parentIndex: 1, index: 1);
expect(compareEpisodesByWatchOrder(a, b), lessThan(0));
expect(compareEpisodesByWatchOrder(b, a), greaterThan(0));
expect(compareEpisodesByWatchOrder(a, a), 0);
});
test('isSpecialSeasonNumber treats season 0 and missing numbers as Specials', () {
expect(isSpecialSeasonNumber(0), isTrue);
expect(isSpecialSeasonNumber(null), isTrue);
expect(isSpecialSeasonNumber(1), isFalse);
expect(isSpecialSeasonNumber(2), isFalse);
});
test('isUnwatchedOrInProgress keeps unwatched and resumable episodes', () {
// Unwatched.
expect(_episode('a', viewCount: 0).isUnwatchedOrInProgress, isTrue);
// Watched with no resume point — counts as done.
expect(_episode('b', viewCount: 1).isUnwatchedOrInProgress, isFalse);
// Watched but still resumable (re-watching) — counts as to-watch.
expect(_episode('c', viewCount: 1, viewOffsetMs: 500, durationMs: 1000).isUnwatchedOrInProgress, isTrue);
});
test('fetchFirstEpisodeForSeason requests only the first children page', () async {
final episode = _episode('episode-1');
final client = _RecordingClient(
childrenPageByParent: {
'season-1': [episode, _episode('episode-2')],
},
);
final result = await fetchFirstEpisodeForSeason(client, 'season-1');
expect(result, same(episode));
expect(client.childrenCalls, isEmpty);
expect(client.childrenPageCalls, [(parentId: 'season-1', start: 0, size: 1)]);
});
test('fetchFirstEpisodeForSeason uses season episode paging when available', () async {
final episode = _episode('episode-1');
final client = _SeasonPagingRecordingClient(
seasonPageBySeason: {
(seriesId: 'show-1', seasonId: 'season-1'): [episode, _episode('episode-2')],
},
);
final result = await fetchFirstEpisodeForSeason(client, 'season-1', seriesId: 'show-1');
expect(result, same(episode));
expect(client.childrenPageCalls, isEmpty);
expect(client.seasonEpisodePageCalls, [(seriesId: 'show-1', seasonId: 'season-1', start: 0, size: 1)]);
});
test('fetchSeasonEpisodePage normalizes show and season identity', () async {
final show = testMediaItem(
id: 'show-1',
backend: MediaBackend.plex,
kind: MediaKind.show,
title: 'Show',
serverId: 'server-1',
serverName: 'Server',
libraryId: 'lib-1',
libraryTitle: 'Library',
);
final season = _season('season-1').copyWith(index: 1, libraryId: show.libraryId, libraryTitle: show.libraryTitle);
final row = _episode('episode-1');
final client = _RecordingClient(
childrenPageByParent: {
season.id: [row],
},
);
final page = await fetchSeasonEpisodePage(client, show: show, season: season, start: 0, size: 200);
expect(client.childrenCalls, isEmpty);
expect(client.childrenPageCalls, [(parentId: 'season-1', start: 0, size: 200)]);
expect(page.totalCount, 1);
expect(page.items.single.serverId, show.serverId);
expect(page.items.single.serverName, show.serverName);
expect(page.items.single.grandparentId, show.id);
expect(page.items.single.grandparentTitle, show.title);
expect(page.items.single.parentId, season.id);
expect(page.items.single.parentIndex, season.index);
expect(page.items.single.libraryId, show.libraryId);
});
test('fetchSeasonEpisodePage uses season episode paging when available', () async {
final show = testMediaItem(id: 'show-1', backend: MediaBackend.plex, kind: MediaKind.show, title: 'Show');
final season = _season('season-1');
final row = _episode('episode-1');
final client = _SeasonPagingRecordingClient(
seasonPageBySeason: {
(seriesId: show.id, seasonId: season.id): [row],
},
);
final page = await fetchSeasonEpisodePage(client, show: show, season: season, start: 0, size: 10);
expect(client.childrenPageCalls, isEmpty);
expect(client.seasonEpisodePageCalls, [(seriesId: show.id, seasonId: season.id, start: 0, size: 10)]);
expect(page.items.single.id, row.id);
});
test('normalizeSeasonEpisodes ignores non-episode rows', () {
final show = testMediaItem(id: 'show-1', backend: MediaBackend.plex, kind: MediaKind.show, title: 'Show');
final season = _season('season-1');
final normalized = normalizeSeasonEpisodes([_clip('extra-1'), _episode('episode-1')], show: show, season: season);
expect(normalized.map((item) => item.id), ['episode-1']);
});
test('fetchRepresentativeVersions uses paged lookup for season metadata', () async {
final versions = [const MediaVersion(id: '1080', videoResolution: '1080')];
final episodeRow = _episode('episode-1');
final fullEpisode = _episode('episode-1', versions: versions);
final client = _RecordingClient(
childrenPageByParent: {
'season-1': [episodeRow],
},
itemsById: {'episode-1': fullEpisode},
);
final result = await fetchRepresentativeVersions(client, _season('season-1'));
expect(result, same(versions));
expect(client.childrenCalls, isEmpty);
expect(client.childrenPageCalls, [(parentId: 'season-1', start: 0, size: 1)]);
});
test('fetchRepresentativeVersions keeps full season lookup but pages selected season episodes', () async {
final versions = [const MediaVersion(id: '1080', videoResolution: '1080')];
final show = testMediaItem(id: 'show-1', backend: MediaBackend.plex, kind: MediaKind.show, title: 'Show');
final special = _season('specials', index: 0);
final firstRegularSeason = _season('season-1');
final episodeRow = _episode('episode-1');
final fullEpisode = _episode('episode-1', versions: versions);
final client = _RecordingClient(
childrenByParent: {
'show-1': [special, firstRegularSeason],
},
childrenPageByParent: {
'season-1': [episodeRow],
},
itemsById: {'episode-1': fullEpisode},
);
final result = await fetchRepresentativeVersions(client, show);
expect(result, same(versions));
expect(client.childrenCalls, ['show-1']);
expect(client.childrenPageCalls, [(parentId: 'season-1', start: 0, size: 1)]);
});
group('same-file adjacency (#1500)', () {
// Episodes of a Plex multi-episode file (S02E24-E25.mkv) are distinct
// items with distinct part ids but the same Part.file: e24/e25 here.
// e23 and e26 are their own files.
MediaVersion version(String key, String file) => MediaVersion(
id: 'v-$key',
parts: [MediaPart(id: 'part-$key', file: file)],
);
late final episodes = [
_episode('e23', versions: [version('e23', '/tv/S02E23.mkv')]),
_episode('e24', versions: [version('e24', '/tv/S02E24-E25.mkv')]),
_episode('e25', versions: [version('e25', '/tv/S02E24-E25.mkv')]),
_episode('e26', versions: [version('e26', '/tv/S02E26-E27.mkv')]),
];
test('nextEpisodeSkippingSameFile skips same-file siblings', () {
expect(nextEpisodeSkippingSameFile(episodes, 1)!.id, 'e26');
expect(nextEpisodeSkippingSameFile(episodes, 0)!.id, 'e24');
expect(nextEpisodeSkippingSameFile(episodes, 3), isNull);
// No same-file sibling left before the end → null.
expect(nextEpisodeSkippingSameFile(episodes.sublist(0, 3), 1), isNull);
});
test('nextEpisodeSkippingSameFile degrades to plain adjacency without part data', () {
final plain = [_episode('a'), _episode('b')];
expect(nextEpisodeSkippingSameFile(plain, 0)!.id, 'b');
});
test('previousEpisodeSkippingSameFile collapses to the group head', () {
// From e26, previous is the e24-e25 file, entered at e24.
expect(previousEpisodeSkippingSameFile(episodes, 3)!.id, 'e24');
// From inside the group (e25), previous skips the same file entirely.
expect(previousEpisodeSkippingSameFile(episodes, 2)!.id, 'e23');
expect(previousEpisodeSkippingSameFile(episodes, 0), isNull);
});
});
}