From c84b12eacd2d79d5bf38907e64caf30df216925a Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:12:30 +0200 Subject: [PATCH] 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 --- .../jellyfin_client/parts/playback.dart | 3 + lib/utils/media_server_timeouts.dart | 5 +- .../live_tv_playback_session_test.dart | 143 ++++++++++++++++++ 3 files changed, 149 insertions(+), 2 deletions(-) diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index 422c450a8..b47900c00 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -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, diff --git a/lib/utils/media_server_timeouts.dart b/lib/utils/media_server_timeouts.dart index 3e960d007..f5e54a7c7 100644 --- a/lib/utils/media_server_timeouts.dart +++ b/lib/utils/media_server_timeouts.dart @@ -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); diff --git a/test/services/live_tv_playback_session_test.dart b/test/services/live_tv_playback_session_test.dart index 05ef3ecc8..b80138ad3 100644 --- a/test/services/live_tv_playback_session_test.dart +++ b/test/services/live_tv_playback_session_test.dart @@ -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.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( + (_) => 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().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( + (_) => fail('A stuck request must not succeed'), + onError: (Object error) { + failure = error; + }, + ), + ); + async.elapse(const Duration(seconds: 10)); + expect( + failure, + isA().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 = >[]; 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 send(http.BaseRequest request) { + requests++; + final response = Completer(); + unawaited( + (request as http.Abortable).abortTrigger!.then((_) { + aborted = true; + response.completeError(http.RequestAbortedException(request.url)); + }), + ); + return response.future; + } +}