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
@@ -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);
});
});
// ===========================================================