Files
plezy/test/services/jellyfin_playback_bundle_test.dart
T
edde746 6663353895 fix(player): retry episode advances that fail on a transient server blip
An EOF-driven advance does one cold metadata fetch with a single endpoint
failover and no transient retry. When connectivity to the server drops for
the ~20s that fetch needs (issue log: both plex.direct endpoints connect
timed out, then the running stream's own TLS socket died), the reload
rolled back to the finished episode's last frame: black screen, progress
bar parked at the end, no way forward but the transport controls - while
pressing Next by hand seconds later succeeded. The per-item metadata cache
row could not absorb the blip either, because adjacency comes from queue
containers, so the next episode's row is cold at the exact moment the
transition needs it.

Three changes:

- A failed in-place reload now records its classified failure reason, and
  an advance that ran with the completion latch set re-presents the Play
  Next prompt when that reason is serverUnavailable. With auto-play
  enabled the countdown re-fires the advance up to two times before the
  prompt goes manual-only; Watch Together sessions and mid-episode Next
  presses (whose rolled-back stream is still valid) keep the existing
  handling. playNextRetryPresentation owns the decision and is unit-tested.

- Committing adjacency now best-effort prefetches the next episode's full
  metadata row through fetchItem, which writes the exact row playback
  initialization falls back to on both backends (Plex: same cache key and
  full playback query shape; Jellyfin: the /Users/{uid}/Items/{id} row the
  playback bundle reads). A warm row turns a blip at the transition into a
  normal start.

- JellyfinClient.fetchItem's documented "pure transport error -> cached
  row" fallback was dead code: the HTTP layer wraps transport errors into
  MediaServerHttpException, which the first catch rethrew unconditionally.
  Status-less, non-cancelled failures now take the fallback; answered
  requests (401/403/5xx) and cancellations surface unchanged.

Verified with new contract tests (Plex: cold row fails transiently ->
fetchItem primes -> the same failing fetch serves playback from cache;
Jellyfin: primed row survives a transport failure into fetchPlaybackBundle)
plus the full test/screens/video_player and test/services suites and
analyzer parity.

close #1867
2026-08-11 09:08:07 +02:00

262 lines
8.8 KiB
Dart

import 'dart:convert';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:plezy/connection/connection.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/services/jellyfin_api_cache.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/plex_api_cache.dart';
import '../test_helpers/backend_client_fixtures.dart';
JellyfinConnection _conn() => testJellyfinConnection(
userName: 'edde',
accessToken: 'tok-abc',
deviceId: 'dev-xyz',
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
);
/// Pin the playback bundle accessor for [PlaybackInitializationService].
/// The bundle replaces the previous pattern of reaching into
/// `MediaItem.raw['MediaSources']` / `raw['Chapters']` from outside the
/// client, so any regression here would silently re-leak that abstraction.
void main() {
late AppDatabase db;
setUp(() {
db = AppDatabase.forTesting(NativeDatabase.memory());
// JellyfinClient routes its `cache` getter through JellyfinApiCache, so
// both backend caches need to be registered (PlexApiCache for any Plex
// code paths the test setup happens to touch; JellyfinApiCache for the
// shared MediaServerCacheMixin used by JellyfinClient.fetchItem).
PlexApiCache.initialize(db);
JellyfinApiCache.initialize(db);
});
tearDown(() async {
await db.close();
});
JellyfinClient buildClient(String body) {
return testJellyfinClient(
connection: _conn(),
handler: (_) async => http.Response(body, 200, headers: {'content-type': 'application/json'}),
);
}
group('JellyfinClient.fetchPlaybackBundle', () {
test('returns null when item has no MediaSources', () async {
final client = buildClient(jsonEncode({'Id': 'item-1', 'Type': 'Movie'}));
final bundle = await client.fetchPlaybackBundle('item-1');
expect(bundle, isNull);
client.close();
});
test('returns null when item is missing MediaSources field entirely', () async {
final client = buildClient(jsonEncode({'Id': 'item-1', 'Name': 'X', 'Type': 'Movie', 'MediaSources': []}));
final bundle = await client.fetchPlaybackBundle('item-1');
// Empty MediaSources also means there's nothing to play.
expect(bundle, isNull);
client.close();
});
test('parses single source with chapters and forwards container/sourceId', () async {
final body = jsonEncode({
'Id': 'item-1',
'Name': 'Example',
'Type': 'Movie',
'MediaSources': [
{
'Id': 'src-1',
'Container': 'mkv',
'Bitrate': 5000000,
'MediaStreams': [
{'Type': 'Video', 'Codec': 'h264', 'Width': 1920, 'Height': 1080},
{'Type': 'Audio', 'Codec': 'eac3', 'Language': 'eng', 'IsDefault': true},
],
},
],
'Chapters': [
{'Name': 'Cold Open', 'StartPositionTicks': 0},
{'Name': 'Act 1', 'StartPositionTicks': 6000000000},
],
});
final client = buildClient(body);
final bundle = await client.fetchPlaybackBundle('item-1');
expect(bundle, isNotNull);
expect(bundle!.availableVersions, hasLength(1));
expect(bundle.container, 'mkv');
expect(bundle.selectedSourceId, 'src-1');
expect(bundle.selectedSource['Id'], 'src-1');
expect(bundle.chapters, hasLength(2));
client.close();
});
test('clamps out-of-range sourceIndex to 0', () async {
final body = jsonEncode({
'Id': 'item-2',
'Type': 'Movie',
'MediaSources': [
{
'Id': 'src-A',
'Container': 'mkv',
'MediaStreams': [
{'Type': 'Video', 'Codec': 'h264', 'Height': 720, 'Width': 1280},
],
},
{
'Id': 'src-B',
'Container': 'mp4',
'MediaStreams': [
{'Type': 'Video', 'Codec': 'hevc', 'Height': 1080, 'Width': 1920},
],
},
],
});
final client = buildClient(body);
// Negative index → clamps to 0.
final lo = await client.fetchPlaybackBundle('item-2', sourceIndex: -3);
expect(lo!.selectedSourceId, 'src-A');
// Out-of-range high → clamps to 0 (mirrors Plex's
// parseVideoPlaybackDataFromJson behaviour).
final hi = await client.fetchPlaybackBundle('item-2', sourceIndex: 7);
expect(hi!.selectedSourceId, 'src-A');
// In-range picks the requested source.
final mid = await client.fetchPlaybackBundle('item-2', sourceIndex: 1);
expect(mid!.selectedSourceId, 'src-B');
expect(mid.container, 'mp4');
client.close();
});
test('selects sourceId before sourceIndex when both are provided', () async {
final body = jsonEncode({
'Id': 'item-4',
'Type': 'Movie',
'MediaSources': [
{
'Id': 'src-4k',
'Container': 'mkv',
'MediaStreams': [
{'Type': 'Video', 'Codec': 'hevc', 'Height': 1608, 'Width': 3840},
],
},
{
'Id': 'src-1080',
'Container': 'mp4',
'MediaStreams': [
{'Type': 'Video', 'Codec': 'h264', 'Height': 804, 'Width': 1920},
],
},
],
});
final client = buildClient(body);
final bundle = await client.fetchPlaybackBundle('item-4', sourceIndex: 0, sourceId: 'src-1080');
expect(bundle!.selectedSourceId, 'src-1080');
expect(bundle.container, 'mp4');
expect(bundle.availableVersions.map((version) => version.id), ['src-4k', 'src-1080']);
client.close();
});
test('selects source by preferred signature when no sourceId pins one', () async {
final body = jsonEncode({
'Id': 'item-5',
'Type': 'Movie',
'MediaSources': [
{
'Id': 'src-1080',
'Container': 'mp4',
'MediaStreams': [
{'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920},
],
},
{
'Id': 'src-4k',
'Container': 'mkv',
'MediaStreams': [
{'Type': 'Video', 'Codec': 'hevc', 'Height': 2160, 'Width': 3840},
],
},
],
});
final client = buildClient(body);
// Grab the real signature of the 4K source, as a saved preference would
// have captured it on a previous play.
final probe = await client.fetchPlaybackBundle('item-5');
final signature = probe!.availableVersions[1].signature;
final bundle = await client.fetchPlaybackBundle('item-5', sourceIndex: 0, preferredSignature: signature);
expect(bundle!.selectedSourceId, 'src-4k');
expect(bundle.selectedSourceIndex, 1);
// An explicit sourceId still wins over the signature.
final pinned = await client.fetchPlaybackBundle(
'item-5',
sourceIndex: 0,
sourceId: 'src-1080',
preferredSignature: signature,
);
expect(pinned!.selectedSourceId, 'src-1080');
client.close();
});
test('chapters defaults to empty list when item has no Chapters field', () async {
final body = jsonEncode({
'Id': 'item-3',
'Type': 'Movie',
'MediaSources': [
{
'Id': 'src',
'Container': 'mkv',
'MediaStreams': [
{'Type': 'Video', 'Codec': 'h264'},
],
},
],
});
final client = buildClient(body);
final bundle = await client.fetchPlaybackBundle('item-3');
expect(bundle!.chapters, isEmpty);
client.close();
});
test('fetchItem-primed row serves fetchPlaybackBundle across a transport failure (#1867)', () async {
final body = jsonEncode({
'Id': 'item-9',
'Type': 'Movie',
'MediaSources': [
{'Id': 'src-9', 'Container': 'mkv'},
],
});
var failNetwork = false;
final client = testJellyfinClient(
connection: _conn(),
handler: (_) async {
if (failNetwork) throw http.ClientException('connect refused');
return http.Response(body, 200, headers: {'content-type': 'application/json'});
},
);
addTearDown(client.close);
// Adjacency discovery primes the per-item row.
expect(await client.fetchItem('item-9'), isNotNull);
// A pure transport failure (wrapped by the HTTP layer into a
// status-less MediaServerHttpException) must fall back to that row
// instead of failing the transition.
failNetwork = true;
final bundle = await client.fetchPlaybackBundle('item-9');
expect(bundle, isNotNull);
expect(bundle!.selectedSourceId, 'src-9');
});
});
}