diff --git a/lib/media/live_tv_support.dart b/lib/media/live_tv_support.dart index bbadc7609..3bfbb7c16 100644 --- a/lib/media/live_tv_support.dart +++ b/lib/media/live_tv_support.dart @@ -5,6 +5,7 @@ import '../models/livetv_dvr.dart'; import '../models/livetv_program.dart'; import '../models/media_grab_operation.dart'; import '../models/media_subscription.dart'; +import '../models/transcode_quality_preset.dart'; /// Program info captured when a live session starts. Plex's tune response /// carries the airing program; Jellyfin streams the channel without a @@ -85,8 +86,11 @@ abstract class LiveTvPlaybackSession { /// Re-establish playback after stream death. Plex re-tunes (the previous /// capture session expires while the player exhausts its reconnect - /// attempts) applying the degradation flags; Jellyfin returns itself so - /// its negotiated HLS URL is re-opened. Returns `null` on failure. + /// attempts) applying the degradation flags. Jellyfin re-negotiates a + /// forced transcode when a direct-play session is asked to drop + /// [directStream] — releasing the direct session's live stream — and + /// otherwise returns itself so its negotiated HLS URL is re-opened. + /// Returns `null` on failure. Future recover({required bool directStream, required bool directStreamAudio}); } @@ -160,9 +164,17 @@ abstract class LiveTvSupport { /// Start a playback session for [channelKey] — the single entry the player /// uses for initial launch and channel switching. Plex requires [dvrKey] - /// (tune + transcode-session setup); Jellyfin ignores it and negotiates an - /// HLS transcode URL. Returns `null` when the channel can't be started. - Future startPlayback(String channelKey, {String? dvrKey}); + /// (tune + transcode-session setup); Jellyfin ignores it. [quality] is the + /// viewer's preset: on `original` Jellyfin asks the server for direct play + /// with no bitrate ceiling and falls back to an uncapped transcode, while a + /// capped preset forces a transcode at that ceiling. Plex does not consume + /// [quality] yet — its live path still hardcodes a transcode (#2072). + /// Returns `null` when the channel can't be started. + Future startPlayback( + String channelKey, { + String? dvrKey, + TranscodeQualityPreset quality = TranscodeQualityPreset.original, + }); /// Source URI to stamp into [FavoriteChannel] entries. Plex uses /// `server://{machineId}/{providerId}` so its cloud-synced favorites are diff --git a/lib/screens/video_player/parts/live_tv.dart b/lib/screens/video_player/parts/live_tv.dart index c4e685392..bb8d9d056 100644 --- a/lib/screens/video_player/parts/live_tv.dart +++ b/lib/screens/video_player/parts/live_tv.dart @@ -140,7 +140,7 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { appLogger.w('Live TV server ${serverInfo.serverId} is not connected'); return null; } - return client.liveTv.startPlayback(channel.key, dvrKey: serverInfo.dvrKey); + return client.liveTv.startPlayback(channel.key, dvrKey: serverInfo.dvrKey, quality: _selectedQualityPreset); } /// Retry the live stream with degraded direct-stream settings. @@ -410,7 +410,13 @@ extension _VideoPlayerLiveTvMethods on VideoPlayerScreenState { // a failed zap does not tell the server to reclaim the still-playing // tuner/transcode session. session = await _startLiveSession(channel); - if (session == null) return; + if (session == null) { + // Jellyfin's negotiation returns null instead of throwing, so this + // is not covered by the catch below; without feedback a failed zap + // looks like a dead remote (#2198). + if (mounted) showErrorSnackBar(context, t.liveTv.failedToStartChannel); + return; + } if (!isCurrentChannelSwitch()) { _abandonLiveSession(session); return; diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 4cc9c6c83..6a5538a01 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1238,8 +1238,8 @@ class VideoPlayerScreenState extends State with WidgetsBindin // the context (providers), which is still safe to touch here because no // async gaps invalidate it between the guard after the settings await // and the reads below. - // Skipped for live TV (has its own tune path) and offline (its own - // branch in _startPlayback). + // Skipped for live TV (has its own tune path — only the quality preset + // is resolved below) and offline (its own branch in _startPlayback). if (!widget.isLive && !_offlineLibraryMode) { // Backend-neutral lookup so Jellyfin items also flow through here. // Plex-specific transcoder caching is gated on capabilities below; @@ -1287,6 +1287,20 @@ class VideoPlayerScreenState extends State with WidgetsBindin // unhandled async error. The later `await` still receives the error. _playbackDataFuture!.ignore(); } + if (widget.isLive) { + // Live TV skips the resolver but honors the same saved quality + // default: on Original the backend may direct-play the channel, on a + // capped preset it transcodes at that ceiling (#2198). Both live + // backends can transcode, so the capability gate is moot here. + _selectedQualityPreset = + widget.selectedQualityPreset ?? + TranscodeQualityPreset.resolveStartupDefault( + serverSupportsTranscoding: true, + onCellularOnly: context.read().isCellularOnly, + cellularDefault: settingsService.read(SettingsService.cellularQualityPreset), + generalDefault: settingsService.read(SettingsService.defaultQualityPreset), + ); + } if (Platform.isWindows) { initPhase = 'syncing display mode'; diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index b103a04ea..0550d695e 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -44,6 +44,7 @@ import '../models/livetv_program.dart'; import '../models/livetv_dvr.dart'; import '../models/media_grab_operation.dart'; import '../models/media_subscription.dart'; +import '../models/transcode_quality_preset.dart'; import '../media/media_source_info.dart'; import '../media/media_sort.dart'; import '../media/media_version.dart'; diff --git a/lib/services/jellyfin_client/parts/live_tv.dart b/lib/services/jellyfin_client/parts/live_tv.dart index f97871312..83d29c0a3 100644 --- a/lib/services/jellyfin_client/parts/live_tv.dart +++ b/lib/services/jellyfin_client/parts/live_tv.dart @@ -143,6 +143,21 @@ mixin _JellyfinLiveTvMethods on _JellyfinClientInternals { ); } + /// Release a live stream that the PlaybackInfo negotiation opened + /// (`AutoOpenLiveStream`) but no playback session will ever stop-report. + /// Without it the server's consumer count never drops and the tuner slot + /// leaks until an idle timeout (#2198). The server wants `liveStreamId` in + /// the query string (400 when in the body) and answers 204. Best-effort: + /// a failure only defers to the server's own reclaim. + Future _closeLiveStream(String liveStreamId) async { + try { + final response = await _http.post('/LiveStreams/Close', queryParameters: {'liveStreamId': liveStreamId}); + throwIfHttpError(response); + } catch (error, stackTrace) { + appLogger.w('Failed to close a ${dialect.productName} live stream', error: error, stackTrace: stackTrace); + } + } + @override LiveTvSupport get liveTv => _JellyfinLiveTvSupport(this as JellyfinClient); } @@ -167,15 +182,35 @@ class _JellyfinLiveTvSupport implements LiveTvSupport { return _client.fetchLiveTvPrograms(beginsAt: toEpoch(from), endsAt: toEpoch(to)); } - /// Negotiate the HLS transcode URL + session identity for [channelKey]. + /// Negotiate a stream URL + session identity for [channelKey]. /// Jellyfin-only: Plex live URLs are only valid after a tune, so the shared /// entry point is [startPlayback]. - Future _resolveStreamUrl(String channelKey) async { + /// + /// The server yields one of two real outcomes — HTTP direct *stream* is + /// hard-disabled server-side, so `SupportsDirectStream` never comes back + /// without `SupportsDirectPlay`: + /// + /// - **DirectPlay**: no `TranscodingUrl`; the client streams the source + /// through `/Videos/{id}/stream.{container}?Static=true`. Granted only + /// when [quality] is `original` (the server treats an unknown live + /// bitrate as 40 Mbps, so any real `MaxStreamingBitrate` cap would deny + /// it anyway) and the source matches a `DirectPlayProfiles` entry. + /// - **Transcode**: an HLS `TranscodingUrl`, capped by the preset's + /// bitrate when one is set. + Future _resolveStreamUrl( + String channelKey, { + required TranscodeQualityPreset quality, + bool forceTranscode = false, + }) async { + final wantsDirect = quality.isOriginal && !forceTranscode; final info = await _client.getPlaybackInfo( channelKey, + // Original sends no ceiling, mirroring the VOD path: a cap below the + // assumed 40 Mbps live bitrate silently forbids direct play. + maxStreamingBitrate: quality.isOriginal ? null : (quality.videoBitrateKbps ?? 100_000) * 1000, autoOpenLiveStream: true, - enableDirectPlay: false, - enableDirectStream: false, + enableDirectPlay: wantsDirect, + enableDirectStream: wantsDirect, enableTranscoding: true, allowVideoStreamCopy: true, allowAudioStreamCopy: true, @@ -196,14 +231,40 @@ class _JellyfinLiveTvSupport implements LiveTvSupport { var playSessionId = nonEmptyString(info['PlaySessionId']); var mediaSourceId = nonEmptyString(source['Id']); var liveStreamId = nonEmptyString(source['LiveStreamId']); - final rawUrl = nonEmptyString(source['TranscodingUrl']); - if (rawUrl == null) { - appLogger.w('${_client.dialect.productName} Live TV negotiation returned no HLS transcode URL'); - return null; + + final container = nonEmptyString(source['Container']); + if (wantsDirect && source['SupportsDirectPlay'] == true && container != null) { + // The server-proxied direct URL jellyfin-web builds (raw tuner `Path` + // needs client-side reachability probing, so it is deliberately not + // used). No PlaySessionId in the URL — it travels in the heartbeats. + final query = { + 'Static': 'true', + 'MediaSourceId': ?mediaSourceId, + 'LiveStreamId': ?liveStreamId, + 'DeviceId': _client.connection.deviceId, + }; + final directPath = Uri( + path: '/Videos/${_segment(channelKey)}/stream.$container', + queryParameters: query, + ).toString(); + return LiveTvStreamResolution( + url: _client._withApiKey(directPath), + playSessionId: playSessionId, + mediaSourceId: mediaSourceId, + liveStreamId: liveStreamId, + playMethod: 'DirectPlay', + ); } - final rawUri = Uri.tryParse(rawUrl); - if (rawUri == null || !rawUri.path.toLowerCase().endsWith('.m3u8')) { + + final rawUrl = nonEmptyString(source['TranscodingUrl']); + final rawUri = rawUrl == null ? null : Uri.tryParse(rawUrl); + if (rawUrl == null || rawUri == null || !rawUri.path.toLowerCase().endsWith('.m3u8')) { appLogger.w('${_client.dialect.productName} Live TV negotiation returned no HLS transcode URL'); + // AutoOpenLiveStream already opened the tuner; bailing without a + // session means no stop report will ever release it. + if (liveStreamId != null) { + unawaited(_client._closeLiveStream(liveStreamId)); + } return null; } final url = _client._withApiKey(rawUrl); @@ -221,10 +282,14 @@ class _JellyfinLiveTvSupport implements LiveTvSupport { } @override - Future startPlayback(String channelKey, {String? dvrKey}) async { - final resolution = await _resolveStreamUrl(channelKey); + Future startPlayback( + String channelKey, { + String? dvrKey, + TranscodeQualityPreset quality = TranscodeQualityPreset.original, + }) async { + final resolution = await _resolveStreamUrl(channelKey, quality: quality); if (resolution == null) return null; - return _JellyfinLiveTvPlaybackSession(_client, channelKey, resolution); + return _JellyfinLiveTvPlaybackSession(_client, channelKey, quality, resolution); } /// SharedPreferences key for the locally-persisted favorite-channel list. @@ -302,18 +367,22 @@ class _JellyfinLiveTvSupport implements LiveTvSupport { } } -/// A MediaBrowser live playback session: one negotiated HLS transcode URL plus -/// `/Sessions/Playing*` heartbeats via [JellyfinLiveSessionTracker]. No -/// program-scoped session and no time-shift — [recover] re-opens the same -/// negotiated URL. +/// A MediaBrowser live playback session: one negotiated stream URL — direct +/// play or HLS transcode — plus `/Sessions/Playing*` heartbeats via +/// [JellyfinLiveSessionTracker]. No program-scoped session and no time-shift. class _JellyfinLiveTvPlaybackSession implements LiveTvPlaybackSession { final JellyfinClient _client; final String _channelKey; + final TranscodeQualityPreset _quality; final String _url; + final String? _playMethod; + final String? _liveStreamId; final JellyfinLiveSessionTracker _tracker; - _JellyfinLiveTvPlaybackSession(this._client, this._channelKey, LiveTvStreamResolution resolution) + _JellyfinLiveTvPlaybackSession(this._client, this._channelKey, this._quality, LiveTvStreamResolution resolution) : _url = resolution.url, + _playMethod = resolution.playMethod, + _liveStreamId = resolution.liveStreamId, _tracker = JellyfinLiveSessionTracker( playSessionId: resolution.playSessionId, mediaSourceId: resolution.mediaSourceId, @@ -360,6 +429,26 @@ class _JellyfinLiveTvPlaybackSession implements LiveTvPlaybackSession { return null; } + /// A transcode session returns itself so its negotiated HLS URL is + /// re-opened — the server rebuilds the transcode job for the same + /// PlaySessionId. A direct-play session asked to drop [directStream] + /// re-negotiates a forced transcode instead: that negotiation opens its own + /// live stream, and the player adopts the replacement without ever + /// stop-reporting this session, so the old stream is released here. On a + /// failed re-negotiation this session stays current and is stop-reported by + /// the normal teardown, which also closes its stream. [directStreamAudio] + /// has no server-side lever beyond the transcode fallback and is ignored. @override - Future recover({required bool directStream, required bool directStreamAudio}) async => this; + Future recover({required bool directStream, required bool directStreamAudio}) async { + if (_playMethod != 'DirectPlay' || directStream) return this; + final replacement = await _JellyfinLiveTvSupport( + _client, + )._resolveStreamUrl(_channelKey, quality: _quality, forceTranscode: true); + if (replacement == null) return null; + final liveStreamId = _liveStreamId; + if (liveStreamId != null) { + unawaited(_client._closeLiveStream(liveStreamId)); + } + return _JellyfinLiveTvPlaybackSession(_client, _channelKey, _quality, replacement); + } } diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index 599a267da..1e134de14 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -41,6 +41,11 @@ String _jellyfinTranscodeVideoCodecs() => [ 'h264', ].join(','); +/// Transcode output codecs for the MPEG-TS fallback profile. A strict subset +/// of [_jellyfinTranscodeVideoCodecs]: AV1 is absent because a TS segment +/// cannot carry it — that gap is why the fMP4 profile exists (#2131). +String _jellyfinTranscodeVideoCodecsTs() => [if (VideoDecodeCapabilities.supportsHevc) 'hevc', 'h264'].join(','); + mixin _JellyfinPlaybackMethods on _JellyfinClientInternals { // Implemented by _JellyfinBrowseMethods (cross-part call, same pattern as // _JellyfinImageDownloadMethods' redeclarations). @@ -806,6 +811,23 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals { // cannot carry. 'AudioCodec': 'aac,mp3,ac3,eac3,flac,opus,dts,truehd', }, + // MPEG-TS fallback, listed second (#2198): Jellyfin drops every + // non-ts transcoding profile for a live source with + // `UseMostCompatibleTranscodingProfile` — hardcoded true for + // HDHomeRun tuners, default true for M3U tuners — so with fMP4 + // alone Live TV negotiates no HLS URL at all. Both codec lists + // are strict subsets of the fMP4 entry's, and the server ranks + // profiles with a stable sort, so ts can only win when the fMP4 + // entry has been filtered out: VOD keeps negotiating fMP4 + // (jellyfin-web ships the same mp4-then-ts pair). flac and + // truehd are omitted because TS cannot carry them. + { + 'Type': 'Video', + 'Container': 'ts', + 'Protocol': 'hls', + 'VideoCodec': _jellyfinTranscodeVideoCodecsTs(), + 'AudioCodec': 'aac,mp3,ac3,eac3,opus,dts', + }, // Track playback transcode target: stereo mp3 over plain http. // Appended after the video profile so the first-entry-wins // ordering for video output codecs is untouched. diff --git a/lib/services/plex_client/parts/live_tv.dart b/lib/services/plex_client/parts/live_tv.dart index a87e57db8..bef2eec87 100644 --- a/lib/services/plex_client/parts/live_tv.dart +++ b/lib/services/plex_client/parts/live_tv.dart @@ -854,8 +854,14 @@ mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport, return getEpgGrid(beginsAt: toEpoch(from), endsAt: toEpoch(to)); } + /// [quality] is intentionally not consumed yet: the Plex live path still + /// hardcodes `directPlay=0` and sends no bitrate ceiling (#2072). @override - Future startPlayback(String channelKey, {String? dvrKey}) { + Future startPlayback( + String channelKey, { + String? dvrKey, + TranscodeQualityPreset quality = TranscodeQualityPreset.original, + }) { if (dvrKey == null) { appLogger.w('Plex live playback requires a dvrKey to tune $channelKey'); return Future.value(null); diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index a7090490c..891eee7da 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -2390,7 +2390,7 @@ void main() { ); }); - test('getPlaybackInfo negotiates video transcodes as fMP4 HLS', () async { + test('getPlaybackInfo negotiates video transcodes as fMP4 HLS with a ts fallback', () async { String? capturedBody; final scoped = JellyfinClient.forTesting( connection: _conn(), @@ -2405,9 +2405,12 @@ void main() { final body = jsonDecode(capturedBody!) as Map; final profile = body['DeviceProfile'] as Map; - final videoTranscode = (profile['TranscodingProfiles'] as List) + final videoProfiles = (profile['TranscodingProfiles'] as List) .map((entry) => entry as Map) - .first; + .where((entry) => entry['Type'] == 'Video') + .toList(); + expect(videoProfiles, hasLength(2)); + final videoTranscode = videoProfiles.first; expect(videoTranscode['Type'], 'Video'); // fMP4 segments: MPEG-TS cannot carry AV1, so a server with an AV1 // encoder could never pick it (issue #2131). mpv consumes fMP4 HLS on @@ -2429,6 +2432,29 @@ void main() { expect(list.length, lessThanOrEqualTo(40), reason: '$key is too long for the server to accept: $list'); expect(list, matches(RegExp(r'^[a-zA-Z0-9\-\._,|]{0,40}$')), reason: '$key has characters the server rejects'); } + + // MPEG-TS fallback (#2198): live tuners with + // UseMostCompatibleTranscodingProfile — every HDHomeRun host, M3U by + // default — drop all non-ts transcoding profiles, so without this entry + // Live TV negotiates no HLS URL at all. + final tsTranscode = videoProfiles.last; + expect(tsTranscode['Container'], 'ts'); + expect(tsTranscode['Protocol'], 'hls'); + // Both ts codec lists must stay strict subsets of the fMP4 entry's, and + // the ts entry must stay listed second: the server ranks profiles with a + // stable sort, so this pairing guarantees ts can only win when the fMP4 + // entry has been filtered out and VOD keeps negotiating fMP4. + for (final key in ['VideoCodec', 'AudioCodec']) { + final tsCodecs = (tsTranscode[key] as String).split(','); + final mp4Codecs = (videoTranscode[key] as String).split(','); + expect(mp4Codecs, containsAll(tsCodecs), reason: '$key of the ts profile must be a subset of the fMP4 one'); + expect(tsCodecs.length, lessThan(mp4Codecs.length), reason: '$key of the ts profile must be a strict subset'); + final list = tsTranscode[key] as String; + expect(list, matches(RegExp(r'^[a-zA-Z0-9\-\._,|]{0,40}$')), reason: '$key has characters the server rejects'); + } + // av1 cannot ride in a TS segment; flac and truehd cannot either. + expect(tsTranscode['VideoCodec'], 'hevc,h264'); + expect(tsTranscode['AudioCodec'], 'aac,mp3,ac3,eac3,opus,dts'); }); test('the hardware decoder probe narrows both video codec lists', () async { @@ -2699,15 +2725,18 @@ void main() { expect(negotiation.url.path, '/Items/channel-1/PlaybackInfo'); expect(negotiation.url.queryParameters['AutoOpenLiveStream'], 'true'); expect(negotiation.url.queryParameters['EnableTranscoding'], 'true'); - expect(negotiation.url.queryParameters['EnableDirectPlay'], 'false'); - expect(negotiation.url.queryParameters['EnableDirectStream'], 'false'); + // Original quality asks for direct play; this server answers with a + // transcode (no SupportsDirectPlay on the source) and the session + // adopts it. + expect(negotiation.url.queryParameters['EnableDirectPlay'], 'true'); + expect(negotiation.url.queryParameters['EnableDirectStream'], 'true'); expect(negotiation.url.queryParameters['AllowVideoStreamCopy'], 'true'); expect(negotiation.url.queryParameters['AllowAudioStreamCopy'], 'true'); final body = jsonDecode(negotiation.body) as Map; expect(body['AutoOpenLiveStream'], isTrue); expect(body['EnableTranscoding'], isTrue); - expect(body['EnableDirectPlay'], isFalse); - expect(body['EnableDirectStream'], isFalse); + expect(body['EnableDirectPlay'], isTrue); + expect(body['EnableDirectStream'], isTrue); expect(session, isNotNull); final uri = Uri.parse((await session!.streamUrlAt())!); diff --git a/test/services/live_tv_playback_session_test.dart b/test/services/live_tv_playback_session_test.dart index 6684f0ecc..1cd81af7d 100644 --- a/test/services/live_tv_playback_session_test.dart +++ b/test/services/live_tv_playback_session_test.dart @@ -15,6 +15,7 @@ import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_client.dart'; import 'package:plezy/services/playback_initialization_types.dart'; +import 'package:plezy/models/transcode_quality_preset.dart'; import '../test_helpers/backend_client_fixtures.dart'; /// Pins the [LiveTvPlaybackSession] lifecycle on both backends — the @@ -488,5 +489,176 @@ void main() { expect(await client.liveTv.startPlayback('channel-1'), isNull); }); + + test('Original quality direct-plays when the server grants it', () async { + final negotiations = []; + final reports = []; + final client = JellyfinClient.forTesting( + connection: conn(), + httpClient: MockClient((request) async { + if (request.url.path.contains('PlaybackInfo')) { + negotiations.add(request); + return jsonResponse({ + 'PlaySessionId': 'play-1', + 'MediaSources': [ + {'Id': 'source-1', 'Container': 'ts', 'LiveStreamId': 'live-1', 'SupportsDirectPlay': true}, + ], + }); + } + if (request.url.path.contains('Sessions/Playing')) reports.add(request); + return jsonResponse(const {}); + }), + ); + addTearDown(client.close); + + final session = await client.liveTv.startPlayback('channel-1'); + + final body = jsonDecode(negotiations.single.body) as Map; + expect(body['EnableDirectPlay'], isTrue); + expect(body['EnableDirectStream'], isTrue); + // Original sends no ceiling: the server assumes 40 Mbps for an unknown + // live bitrate, so any real cap would silently deny direct play. + expect(body.containsKey('MaxStreamingBitrate'), isFalse); + + // The server-proxied direct URL jellyfin-web uses (not the raw tuner + // Path, which needs reachability probing). + final url = Uri.parse((await session!.streamUrlAt())!); + expect(url.path, '/Videos/channel-1/stream.ts'); + expect(url.queryParameters['Static'], 'true'); + expect(url.queryParameters['MediaSourceId'], 'source-1'); + expect(url.queryParameters['LiveStreamId'], 'live-1'); + expect(url.queryParameters['api_key'], 'tok-abc'); + + // Heartbeats must report DirectPlay so the server accounts the session + // correctly and can reclaim the live stream on stop. + await session.reportTimeline(state: 'playing', positionMs: 1000, durationMs: 0); + final report = jsonDecode(reports.single.body) as Map; + expect(report['PlayMethod'], 'DirectPlay'); + expect(report['LiveStreamId'], 'live-1'); + }); + + test('a capped preset forces a transcode at that ceiling', () async { + final negotiations = []; + final client = JellyfinClient.forTesting( + connection: conn(), + httpClient: MockClient((request) async { + if (request.url.path.contains('PlaybackInfo')) { + negotiations.add(request); + return jsonResponse({ + 'PlaySessionId': 'play-1', + 'MediaSources': [ + { + 'Id': 'source-1', + 'Container': 'ts', + 'LiveStreamId': 'live-1', + 'TranscodingUrl': '/Videos/channel-1/live.m3u8?PlaySessionId=play-1', + }, + ], + }); + } + return jsonResponse(const {}); + }), + ); + addTearDown(client.close); + + final session = await client.liveTv.startPlayback('channel-1', quality: TranscodeQualityPreset.p720_2mbps); + + final body = jsonDecode(negotiations.single.body) as Map; + expect(body['EnableDirectPlay'], isFalse); + expect(body['EnableDirectStream'], isFalse); + expect(body['MaxStreamingBitrate'], 2_000_000); + expect(Uri.parse((await session!.streamUrlAt())!).path, endsWith('.m3u8')); + }); + + test('a negotiation that yields no HLS URL closes the live stream it opened', () async { + final closes = []; + final client = JellyfinClient.forTesting( + connection: conn(), + httpClient: MockClient((request) async { + if (request.url.path.contains('PlaybackInfo')) { + // What a ts-only tuner answers a client with no ts profile: a + // plain /stream URL instead of an HLS playlist (#2198). + return jsonResponse({ + 'PlaySessionId': 'play-1', + 'MediaSources': [ + {'Id': 'source-1', 'LiveStreamId': 'live-1', 'TranscodingUrl': '/Videos/channel-1/stream'}, + ], + }); + } + if (request.url.path.contains('LiveStreams/Close')) { + closes.add(request); + return http.Response('', 204); + } + return jsonResponse(const {}); + }), + ); + addTearDown(client.close); + + expect(await client.liveTv.startPlayback('channel-1'), isNull); + + // The close is fire-and-forget; drain it. Without it the tuner slot + // leaks — no playback session exists to ever stop-report the stream. + await pumpEventQueue(); + expect(closes.single.method, 'POST'); + expect(closes.single.url.queryParameters['liveStreamId'], 'live-1'); + }); + + test('recover degrades a direct-play session to a forced transcode and releases its stream', () async { + final negotiations = []; + final closes = []; + final client = JellyfinClient.forTesting( + connection: conn(), + httpClient: MockClient((request) async { + if (request.url.path.contains('PlaybackInfo')) { + negotiations.add(request); + if (negotiations.length == 1) { + return jsonResponse({ + 'PlaySessionId': 'play-1', + 'MediaSources': [ + {'Id': 'source-1', 'Container': 'ts', 'LiveStreamId': 'live-1', 'SupportsDirectPlay': true}, + ], + }); + } + return jsonResponse({ + 'PlaySessionId': 'play-2', + 'MediaSources': [ + { + 'Id': 'source-1', + 'Container': 'ts', + 'LiveStreamId': 'live-2', + 'TranscodingUrl': '/Videos/channel-1/live.m3u8?PlaySessionId=play-2', + }, + ], + }); + } + if (request.url.path.contains('LiveStreams/Close')) { + closes.add(request); + return http.Response('', 204); + } + return jsonResponse(const {}); + }), + ); + addTearDown(client.close); + + final session = await client.liveTv.startPlayback('channel-1'); + final recovered = await session!.recover(directStream: false, directStreamAudio: true); + + expect(recovered, isNotNull); + expect(recovered, isNot(same(session))); + expect(Uri.parse((await recovered!.streamUrlAt())!).path, endsWith('.m3u8')); + + // The re-negotiation must not ask for direct play again… + final retryBody = jsonDecode(negotiations[1].body) as Map; + expect(retryBody['EnableDirectPlay'], isFalse); + expect(retryBody['EnableDirectStream'], isFalse); + + // …and the replaced direct session's live stream is released: the + // player adopts the replacement without ever stop-reporting the old one. + await pumpEventQueue(); + expect(closes.single.url.queryParameters['liveStreamId'], 'live-1'); + + // A transcode session keeps the documented re-open-the-URL behavior. + expect(await recovered.recover(directStream: false, directStreamAudio: false), same(recovered)); + }); }); }