fix(playback): recover episode navigation without Plex queues

This commit is contained in:
edde746
2026-07-24 08:08:10 +02:00
parent 54273ab09c
commit 269bb7a322
13 changed files with 681 additions and 301 deletions
+117 -37
View File
@@ -133,31 +133,30 @@ void main() {
p.dispose();
});
test('setCurrentItem updates id only when in queue mode', () async {
test('setCurrentItem updates the cursor only for validated queue members', () async {
final p = PlaybackStateProvider();
// Not in queue mode → no-op
var notified = 0;
p.addListener(() => notified++);
p.setCurrentItem(_miItem('a', 5));
p.setCurrentItem(_miItem('a', 1001));
expect(p.currentPlayQueueItemID, isNull);
expect(notified, 0);
// Enter queue mode
await p.setPlaybackFromPlayQueue(
_queue(playQueueID: 1, selectedItemID: 1001, totalCount: 1, items: [_item('a', 1001)]),
_queue(playQueueID: 1, selectedItemID: 1001, totalCount: 2, items: [_item('a', 1001), _item('b', 1002)]),
null,
);
// setPlaybackFromPlayQueue notifies once
final preNotify = notified;
p.setCurrentItem(_miItem('b', 2002));
expect(p.currentPlayQueueItemID, 2002);
// A fresh copy of a real loaded member is accepted.
p.setCurrentItem(_miItem('b', 1002));
expect(p.currentPlayQueueItemID, 1002);
expect(notified, preNotify + 1);
// Item without playQueueItemId → no update, no notify
p.setCurrentItem(testMediaItem(id: 'd', backend: MediaBackend.plex, kind: MediaKind.episode));
expect(p.currentPlayQueueItemID, 2002);
// A stamped item outside this queue cannot poison the cursor.
p.setCurrentItem(_miItem('outsider', 2002));
expect(p.currentPlayQueueItemID, 1002);
expect(notified, preNotify + 1);
p.dispose();
});
@@ -168,9 +167,9 @@ void main() {
await p.setPlaybackFromPlayQueue(_queue(playQueueID: 1, selectedItemID: 1002, totalCount: 3, items: items), null);
final next = await p.getNextEpisode('b');
expect(next, isNotNull);
expect(next!.id, 'c');
expect((next as PlexMediaItem).playQueueItemId, 1003);
expect(next.status, QueueNavigationStatus.found);
expect(next.item!.id, 'c');
expect((next.item as PlexMediaItem).playQueueItemId, 1003);
// currentPlayQueueItemID is NOT updated by getNextEpisode (setCurrentItem does that).
expect(p.currentPlayQueueItemID, 1002);
@@ -178,17 +177,74 @@ void main() {
p.dispose();
});
test('getNextEpisode returns null at end of queue without loop', () async {
test('getNextEpisode reports the queue boundary at the end', () async {
final p = PlaybackStateProvider();
final items = [_item('a', 1001), _item('b', 1002)];
await p.setPlaybackFromPlayQueue(_queue(playQueueID: 1, selectedItemID: 1002, totalCount: 2, items: items), null);
final next = await p.getNextEpisode('b');
expect(next, isNull);
expect(next.status, QueueNavigationStatus.boundary);
expect(next.item, isNull);
p.dispose();
});
test('getNextEpisode anchors on the supplied media key instead of a stale cursor', () async {
final p = PlaybackStateProvider();
addTearDown(p.dispose);
final items = [_item('a', 1001), _item('b', 1002), _item('c', 1003)];
await p.setPlaybackFromPlayQueue(_queue(playQueueID: 1, selectedItemID: 1001, totalCount: 3, items: items), null);
final next = await p.getNextEpisode('b');
expect(next.status, QueueNavigationStatus.found);
expect(next.item!.id, 'c');
expect(p.currentPlayQueueItemID, 1001, reason: 'read-only lookup must not move the playback cursor');
});
test('server window extension uses opaque queue ids and the real anchor', () async {
final p = PlaybackStateProvider();
addTearDown(p.dispose);
final first = _item('a', 1001);
final nextItem = _item('b', 9007);
await p.setPlaybackFromPlayQueue(
_queue(playQueueID: 1, selectedItemID: 1001, totalCount: 2, items: [first]),
null,
);
String? requestedCenter;
p.setPlayQueueWindowFetcher((playQueueId, {center, window = 50}) async {
requestedCenter = center;
return _queue(playQueueID: playQueueId, selectedItemID: 1001, totalCount: 2, items: [first, nextItem]);
});
final next = await p.getNextEpisode('a');
expect(requestedCenter, '1001');
expect(next.status, QueueNavigationStatus.found);
expect(next.item!.id, 'b');
});
test('windowed queue confirms its global end with a centered fetch', () async {
final p = PlaybackStateProvider();
addTearDown(p.dispose);
final items = [_item('y', 5001), _item('z', 9007)];
await p.setPlaybackFromPlayQueue(
_queue(playQueueID: 1, selectedItemID: 9007, totalCount: 100, items: items),
null,
);
var fetchCount = 0;
p.setPlayQueueWindowFetcher((playQueueId, {center, window = 50}) async {
fetchCount++;
expect(center, '9007');
return _queue(playQueueID: playQueueId, selectedItemID: 9007, totalCount: 100, items: items);
});
final next = await p.getNextEpisode('z');
expect(next.status, QueueNavigationStatus.boundary);
expect(fetchCount, 1);
});
test('getNextEpisode does not retry recursively when loaded window misses target', () async {
final p = PlaybackStateProvider();
addTearDown(p.dispose);
@@ -201,14 +257,15 @@ void main() {
return _queue(playQueueID: playQueueId, selectedItemID: 1002, totalCount: 3, items: items);
});
expect(await p.getNextEpisode('b'), isNull);
expect((await p.getNextEpisode('b')).status, QueueNavigationStatus.boundary);
expect(fetchCount, 1);
});
test('getNextEpisode with no queue returns null (sequential mode)', () async {
test('getNextEpisode reports unavailable with no active queue', () async {
final p = PlaybackStateProvider();
final next = await p.getNextEpisode('any-key');
expect(next, isNull);
expect(next.status, QueueNavigationStatus.unavailable);
expect(next.item, isNull);
p.dispose();
});
@@ -217,10 +274,10 @@ void main() {
final items = [_item('a', 1001), _item('b', 1002), _item('c', 1003)];
await p.setPlaybackFromPlayQueue(_queue(playQueueID: 1, selectedItemID: 1002, totalCount: 3, items: items), null);
final prev = await p.getPreviousEpisode('b');
expect(prev, isNotNull);
expect(prev!.id, 'a');
expect((prev as PlexMediaItem).playQueueItemId, 1001);
final previous = await p.getPreviousEpisode('b');
expect(previous.status, QueueNavigationStatus.found);
expect(previous.item!.id, 'a');
expect((previous.item as PlexMediaItem).playQueueItemId, 1001);
p.dispose();
});
@@ -230,16 +287,18 @@ void main() {
final items = [_item('a', 1001), _item('b', 1002)];
await p.setPlaybackFromPlayQueue(_queue(playQueueID: 1, selectedItemID: 1001, totalCount: 2, items: items), null);
final prev = await p.getPreviousEpisode('a');
expect(prev, isNull);
final previous = await p.getPreviousEpisode('a');
expect(previous.status, QueueNavigationStatus.boundary);
expect(previous.item, isNull);
p.dispose();
});
test('getPreviousEpisode without queue mode returns null', () async {
final p = PlaybackStateProvider();
final prev = await p.getPreviousEpisode('any-key');
expect(prev, isNull);
final previous = await p.getPreviousEpisode('any-key');
expect(previous.status, QueueNavigationStatus.unavailable);
expect(previous.item, isNull);
p.dispose();
});
@@ -316,6 +375,20 @@ void main() {
expect(p.isItemInActiveQueue(outsider), isFalse);
});
test('isItemInActiveQueue rejects foreign server-stamped queue items', () async {
final p = PlaybackStateProvider();
addTearDown(p.dispose);
final member = _item('ep-in-queue', 5001);
await p.setPlaybackFromPlayQueue(
_queue(playQueueID: 77, selectedItemID: 5001, totalCount: 1, items: [member]),
'playlist-Z',
);
expect(p.isItemInActiveQueue(_item('ep-in-queue', 5001)), isTrue);
expect(p.isItemInActiveQueue(_item('foreign', 9001)), isFalse);
expect(p.isItemInActiveQueue(_item('foreign', 5001)), isFalse);
});
test('isItemInActiveQueue is false when no queue is active', () {
final p = PlaybackStateProvider();
addTearDown(p.dispose);
@@ -351,7 +424,8 @@ void main() {
addTearDown(p.dispose);
final next = await p.getNextEpisode('e24', playedPartId: 'part-e24');
expect(next!.id, 'e26');
expect(next.status, QueueNavigationStatus.found);
expect(next.item!.id, 'e26');
});
test('getNextEpisode skips the same-file sibling via file intersection without playedPartId', () async {
@@ -359,7 +433,8 @@ void main() {
addTearDown(p.dispose);
final next = await p.getNextEpisode('e24');
expect(next!.id, 'e26');
expect(next.status, QueueNavigationStatus.found);
expect(next.item!.id, 'e26');
});
test('getNextEpisode skips multiple siblings of a triple-episode file', () async {
@@ -381,7 +456,8 @@ void main() {
);
final next = await p.getNextEpisode('e1', playedPartId: 'part-e1');
expect(next!.id, 'e4');
expect(next.status, QueueNavigationStatus.found);
expect(next.item!.id, 'e4');
});
test('getNextEpisode returns null when only same-file siblings remain', () async {
@@ -397,7 +473,7 @@ void main() {
null,
);
expect(await p.getNextEpisode('e24', playedPartId: 'part-e24'), isNull);
expect((await p.getNextEpisode('e24', playedPartId: 'part-e24')).status, QueueNavigationStatus.boundary);
});
test('items without file data keep positional behavior even with playedPartId', () async {
@@ -407,7 +483,8 @@ void main() {
await p.setPlaybackFromPlayQueue(_queue(playQueueID: 1, selectedItemID: 1001, totalCount: 2, items: items), null);
final next = await p.getNextEpisode('a', playedPartId: 'part-a');
expect(next!.id, 'b');
expect(next.status, QueueNavigationStatus.found);
expect(next.item!.id, 'b');
});
test('skip past the loaded window extends it and lands on the next distinct file', () async {
@@ -432,7 +509,8 @@ void main() {
});
final next = await p.getNextEpisode('e24', playedPartId: 'part-e24');
expect(next!.id, 'e26');
expect(next.status, QueueNavigationStatus.found);
expect(next.item!.id, 'e26');
expect(fetchCount, 1);
});
@@ -441,8 +519,9 @@ void main() {
addTearDown(p.dispose);
// From e26, previous is the e24-e25 file, entered at e24 (not e25).
final prev = await p.getPreviousEpisode('e26', playedPartId: 'part-e26');
expect(prev!.id, 'e24');
final previous = await p.getPreviousEpisode('e26', playedPartId: 'part-e26');
expect(previous.status, QueueNavigationStatus.found);
expect(previous.item!.id, 'e24');
});
test('getPreviousEpisode skips same-file siblings of the playing item', () async {
@@ -450,8 +529,9 @@ void main() {
addTearDown(p.dispose);
// Playing the file as e25: previous must not land inside the same file.
final prev = await p.getPreviousEpisode('e25', playedPartId: 'part-e25');
expect(prev!.id, 'e23');
final previous = await p.getPreviousEpisode('e25', playedPartId: 'part-e25');
expect(previous.status, QueueNavigationStatus.found);
expect(previous.item!.id, 'e23');
});
test('sameFileSiblings returns the other episodes of the playing file', () async {
@@ -77,6 +77,26 @@ void main() {
expect(l.triggered, isFalse);
});
group('completionNavigationAction', () {
test('presents a resolved next episode', () {
expect(
completionNavigationAction(hasNext: true, adjacentLoadFailed: false),
CompletionNavigationAction.presentNext,
);
});
test('retries adjacency instead of exiting after a load failure', () {
expect(
completionNavigationAction(hasNext: false, adjacentLoadFailed: true),
CompletionNavigationAction.retryAdjacent,
);
});
test('exits only after the queue boundary was resolved', () {
expect(completionNavigationAction(hasNext: false, adjacentLoadFailed: false), CompletionNavigationAction.exit);
});
});
group('classifyEofSignal', () {
EofSignalClass classify(int positionMs, {int playerDurationMs = 0, int? metadataDurationMs}) => classifyEofSignal(
positionMs: positionMs,
@@ -26,6 +26,16 @@ MediaItem _jfEpisode(String id, {required String seriesId, ServerId? serverId})
grandparentId: seriesId,
);
MediaItem _plexEpisode(String id, {required String seriesId, int? viewCount}) => testMediaItem(
id: id,
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode $id',
serverId: 'srv-plex',
grandparentId: seriesId,
viewCount: viewCount,
);
/// MultiServerManager subclass that returns a pre-supplied client without
/// going through the production add-connection flow. The base class doesn't
/// expose a way to inject clients into its private `_clients` map, so we
@@ -40,18 +50,22 @@ class _StubManager extends MultiServerManager {
/// Recording client whose `fetchClientSideEpisodeQueue` is observable —
/// callers can assert it was (or wasn't) hit.
class _RecordingClient implements MediaServerClient {
_RecordingClient({required this.seriesEpisodes});
_RecordingClient({required this.seriesEpisodes, this.clientBackend = MediaBackend.jellyfin, this.fetchError});
final List<MediaItem> seriesEpisodes;
final MediaBackend clientBackend;
final Object? fetchError;
final List<String> seriesQueueCalls = [];
@override
Future<List<MediaItem>?> fetchClientSideEpisodeQueue(String seriesId) async {
seriesQueueCalls.add(seriesId);
final error = fetchError;
if (error != null) throw error;
return seriesEpisodes;
}
@override
MediaBackend get backend => MediaBackend.jellyfin;
MediaBackend get backend => clientBackend;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
@@ -88,31 +102,36 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('loadAdjacentEpisodes', () {
testWidgets('returns empty AdjacentEpisodes when no play queue is active', (tester) async {
// Bare provider — no setPlaybackFromPlayQueue() call → isQueueActive = false.
testWidgets('returns unavailable when no play queue is active for non-series media', (tester) async {
final playback = PlaybackStateProvider();
addTearDown(playback.dispose);
final manager = _StubManager(null);
final serverProvider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(serverProvider.dispose);
AdjacentEpisodes? result;
await tester.pumpWidget(
ChangeNotifierProvider<PlaybackStateProvider>.value(
value: playback,
MultiProvider(
providers: [
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
ChangeNotifierProvider<MultiServerProvider>.value(value: serverProvider),
],
child: _ProbeWidget(metadata: _meta('42'), onResult: (r) => result = r),
),
);
// Drain the post-frame callback and the awaited service call.
await tester.pump();
await tester.pump();
expect(result, isNotNull);
expect(result!.nextStatus, QueueNavigationStatus.unavailable);
expect(result!.hasNext, isFalse);
expect(result!.hasPrevious, isFalse);
expect(playback.isQueueActive, isFalse);
});
testWidgets('catches downstream exceptions and returns empty AdjacentEpisodes', (tester) async {
// PlaybackStateProvider not provided → context.read throws. The service
// wraps the entire body in try/catch and returns AdjacentEpisodes() so
// the UI never crashes when the queue subsystem is unavailable.
testWidgets('catches downstream exceptions and reports failed adjacency', (tester) async {
// Required providers are absent, so context.read throws. The service
// converts the exception into an explicit failed result.
AdjacentEpisodes? result;
await tester.pumpWidget(_ProbeWidget(metadata: _meta('42'), onResult: (r) => result = r));
await tester.pump();
@@ -121,6 +140,7 @@ void main() {
expect(result, isNotNull);
expect(result!.hasNext, isFalse);
expect(result!.hasPrevious, isFalse);
expect(result!.nextStatus, QueueNavigationStatus.failed);
});
testWidgets('preserves an active playlist/collection queue against series rebuild', (tester) async {
@@ -182,6 +202,96 @@ void main() {
expect(result!.next?.id, 'ep3');
expect(result!.previous?.id, 'ep1');
});
testWidgets('builds a Plex local fallback queue with watched episodes', (tester) async {
final ep1 = _plexEpisode('ep1', seriesId: 'series-P', viewCount: 1);
final ep2 = _plexEpisode('ep2', seriesId: 'series-P', viewCount: 1);
final ep3 = _plexEpisode('ep3', seriesId: 'series-P', viewCount: 1);
final playback = PlaybackStateProvider();
addTearDown(playback.dispose);
final client = _RecordingClient(seriesEpisodes: [ep1, ep2, ep3], clientBackend: MediaBackend.plex);
final manager = _StubManager(client);
final serverProvider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(serverProvider.dispose);
AdjacentEpisodes? result;
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
ChangeNotifierProvider<MultiServerProvider>.value(value: serverProvider),
],
child: _ProbeWidget(metadata: ep2, onResult: (r) => result = r),
),
);
await tester.pump();
await tester.pump();
expect(client.seriesQueueCalls, ['series-P']);
expect(playback.loadedItems.map((item) => item.id), ['ep1', 'ep2', 'ep3']);
expect(result!.nextStatus, QueueNavigationStatus.found);
expect(result!.next?.id, 'ep3');
expect(result!.previous?.id, 'ep1');
});
testWidgets('distinguishes a fallback fetch failure from the end of a series', (tester) async {
final current = _plexEpisode('ep2', seriesId: 'series-P');
final playback = PlaybackStateProvider();
addTearDown(playback.dispose);
final client = _RecordingClient(
seriesEpisodes: const [],
clientBackend: MediaBackend.plex,
fetchError: StateError('network unavailable'),
);
final manager = _StubManager(client);
final serverProvider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(serverProvider.dispose);
AdjacentEpisodes? result;
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
ChangeNotifierProvider<MultiServerProvider>.value(value: serverProvider),
],
child: _ProbeWidget(metadata: current, onResult: (r) => result = r),
),
);
await tester.pump();
await tester.pump();
expect(result!.nextStatus, QueueNavigationStatus.failed);
expect(result!.isEndConfirmed, isFalse);
expect(playback.isQueueActive, isFalse);
});
testWidgets('confirms the end only after loading a queue containing the current episode', (tester) async {
final ep1 = _plexEpisode('ep1', seriesId: 'series-P', viewCount: 1);
final ep2 = _plexEpisode('ep2', seriesId: 'series-P', viewCount: 1);
final playback = PlaybackStateProvider();
addTearDown(playback.dispose);
final client = _RecordingClient(seriesEpisodes: [ep1, ep2], clientBackend: MediaBackend.plex);
final manager = _StubManager(client);
final serverProvider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(serverProvider.dispose);
AdjacentEpisodes? result;
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
ChangeNotifierProvider<MultiServerProvider>.value(value: serverProvider),
],
child: _ProbeWidget(metadata: ep2, onResult: (r) => result = r),
),
);
await tester.pump();
await tester.pump();
expect(result!.nextStatus, QueueNavigationStatus.boundary);
expect(result!.isEndConfirmed, isTrue);
expect(result!.next, isNull);
});
});
// ===========================================================
@@ -585,6 +585,62 @@ void main() {
expect(requestUri!.queryParameters['X-Plex-Container-Size'], '10');
});
test('client-side episode fallback retains watched rows and sorts by watch order', () async {
Uri? requestUri;
final client = makeClient((request) async {
if (request.url.path == '/library/metadata/show-1/grandchildren') {
requestUri = request.url;
return http.Response(
jsonEncode({
'MediaContainer': {
'size': 3,
'totalSize': 3,
'Metadata': [
{
'ratingKey': 'special',
'type': 'episode',
'title': 'Special',
'parentIndex': 0,
'index': 1,
'originallyAvailableAt': '2024-01-02',
'viewCount': 1,
},
{
'ratingKey': 'ep-2',
'type': 'episode',
'title': 'Episode 2',
'parentIndex': 1,
'index': 2,
'originallyAvailableAt': '2024-01-03',
'viewCount': 1,
},
{
'ratingKey': 'ep-1',
'type': 'episode',
'title': 'Episode 1',
'parentIndex': 1,
'index': 1,
'originallyAvailableAt': '2024-01-01',
'viewCount': 1,
},
],
},
}),
200,
headers: {'content-type': 'application/json'},
);
}
return http.Response('not found', 404);
});
addTearDown(client.close);
final episodes = await client.fetchClientSideEpisodeQueue('show-1');
expect(requestUri!.path, '/library/metadata/show-1/grandchildren');
expect(episodes!.map((episode) => episode.id), ['ep-1', 'special', 'ep-2']);
expect(episodes.every((episode) => episode.isWatched), isTrue);
});
test('hub content pages by filtered video item offset', () async {
final requests = <Uri>[];
final client = makeClient((request) async {