fix(livetv): allow cold MediaBrowser tuners time to open

Emby and Jellyfin Live TV could fail before playback when opening a cold tuner took longer than ten seconds. Use the existing thirty-second tune timeout only for live PlaybackInfo requests that open a source, preserving other request budgets and transport aborts.

Cover delayed initial tuning and recovery, bounded cancellation without replay, and unchanged VOD and metadata timeouts.

close #2274
This commit is contained in:
edde746
2026-09-07 20:32:16 +02:00
parent f9b9fbf611
commit c84b12eacd
3 changed files with 149 additions and 2 deletions
@@ -810,6 +810,9 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
final response = await _http.post(
'/Items/${_segment(itemId)}/PlaybackInfo',
queryParameters: query,
// Opening a cold tuner can delay response headers beyond the normal
// connect budget (#2274). Keep VOD and metadata-only requests unchanged.
timeout: isLiveTv && autoOpenLiveStream == true ? MediaServerTimeouts.tune : null,
body: {
'UserId': connection.userId,
'MaxStreamingBitrate': ?maxStreamingBitrate,
+3 -2
View File
@@ -56,8 +56,9 @@ class MediaServerTimeouts {
/// sum of phases.
static const perServerConnect = Duration(milliseconds: 6500);
/// HTTP timeout for the live-TV tune POST. Matches Plex web's value — the
/// default 10s connect budget is too tight on Fire-TV cold starts.
/// Per-phase HTTP timeout for Plex tune and MediaBrowser Live TV
/// PlaybackInfo that opens a source. Matches Plex web's value: a cold
/// tuner can take longer than the default 10s to return response headers.
static const tune = Duration(seconds: 30);
static const plexTvConnect = Duration(seconds: 15);
@@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:convert';
import 'package:drift/native.dart';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
@@ -477,6 +479,128 @@ void main() {
(testJellyfinConnection(), ['mp4', 'ts']),
(testEmbyConnection(), ['ts']),
]) {
test('${connection.dialect.productName} allows cold live tune and recovery beyond ten seconds', () {
fakeAsync((async) {
var negotiations = 0;
final client = JellyfinClient.forTesting(
connection: connection,
httpClient: MockClient((request) async {
if (!request.url.path.endsWith('/PlaybackInfo')) return http.Response('', 204);
negotiations++;
await Future<void>.delayed(const Duration(seconds: 11));
return jsonResponse({
'PlaySessionId': 'play-$negotiations',
'MediaSources': [
{
'Id': 'source-1',
'Container': 'ts',
'LiveStreamId': 'live-$negotiations',
'SupportsDirectPlay': negotiations == 1,
'TranscodingUrl': '/Videos/channel-1/live.m3u8',
},
],
});
}),
);
try {
String? url;
Object? failure;
unawaited(
client.liveTv
.startPlayback('channel-1')
.then((session) async {
url = await session!.streamUrlAt();
final recovered = await session.recover(directStream: false, directStreamAudio: true);
url = await recovered!.streamUrlAt();
await recovered.reportTimeline(state: 'stopped', positionMs: 0, durationMs: 0);
})
.catchError((Object error) {
failure = error;
}),
);
async.elapse(const Duration(seconds: 11));
expect(failure, isNull);
expect(Uri.parse(url!).path, '/Videos/channel-1/stream.ts');
async.elapse(const Duration(seconds: 11));
expect(failure, isNull);
expect(Uri.parse(url!).path, '/Videos/channel-1/live.m3u8');
expect(negotiations, 2, reason: 'one tune and one recovery, without replay');
} finally {
client.close();
async.flushMicrotasks();
}
});
});
test('${connection.dialect.productName} aborts a stuck live tune at thirty seconds without replay', () {
fakeAsync((async) {
final transport = _HangingLiveTuneClient();
final client = JellyfinClient.forTesting(connection: connection, httpClient: transport);
try {
Object? failure;
unawaited(
client.liveTv
.startPlayback('channel-1')
.then<void>(
(_) => fail('A stuck tuner must not start playback'),
onError: (Object error) {
failure = error;
},
),
);
async.elapse(const Duration(seconds: 29));
expect(failure, isNull);
expect(transport.aborted, isFalse);
async.elapse(const Duration(seconds: 1));
expect(
failure,
isA<MediaServerHttpException>().having((e) => e.type, 'type', MediaServerHttpErrorType.connectionTimeout),
);
expect(transport.aborted, isTrue);
expect(transport.requests, 1);
} finally {
client.close();
async.flushMicrotasks();
}
});
});
for (final (name, isLiveTv, autoOpen) in [
('VOD', false, null),
('VOD opening a source', false, true),
('live metadata without opening a source', true, false),
]) {
test('${connection.dialect.productName} keeps the ten-second timeout for $name', () {
fakeAsync((async) {
final transport = _HangingLiveTuneClient();
final client = JellyfinClient.forTesting(connection: connection, httpClient: transport);
try {
Object? failure;
unawaited(
client
.getPlaybackInfo('item-1', isLiveTv: isLiveTv, autoOpenLiveStream: autoOpen)
.then<void>(
(_) => fail('A stuck request must not succeed'),
onError: (Object error) {
failure = error;
},
),
);
async.elapse(const Duration(seconds: 10));
expect(
failure,
isA<MediaServerHttpException>().having((e) => e.type, 'type', MediaServerHttpErrorType.connectionTimeout),
);
expect(transport.aborted, isTrue);
expect(transport.requests, 1);
} finally {
client.close();
async.flushMicrotasks();
}
});
});
}
test('${connection.dialect.productName} scopes HLS containers to live tune and recovery, not VOD', () async {
final negotiations = <Map<String, dynamic>>[];
final client = JellyfinClient.forTesting(
@@ -830,3 +954,22 @@ void main() {
});
});
}
/// Holds response headers until the transport receives the request's abort.
class _HangingLiveTuneClient extends http.BaseClient {
var requests = 0;
var aborted = false;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
requests++;
final response = Completer<http.StreamedResponse>();
unawaited(
(request as http.Abortable).abortTrigger!.then((_) {
aborted = true;
response.completeError(http.RequestAbortedException(request.url));
}),
);
return response.future;
}
}