fix(jellyfin): restore Live TV on ts-only tuners and honor the quality preset
Since 2.18.0 no Jellyfin Live TV channel plays: opening one fails with "Could not start the live channel", and switching channel inside the player does nothing at all. Negotiating video transcodes as fMP4 (#2131) left the device profile with no MPEG-TS entry, and Jellyfin discards every non-ts transcoding profile for a live source whose tuner sets UseMostCompatibleTranscodingProfile - hardcoded for every HDHomeRun host, the default for M3U ones - so the negotiation came back with no HLS URL at all. The device profile now sends a ts profile alongside the fMP4 one, listed second, the way jellyfin-web does. Both of its codec lists are strict subsets of the fMP4 entry's and the server ranks profiles with a stable sort, so ts can only win where fMP4 has been filtered out; VOD keeps negotiating fMP4. flac and truehd are absent because TS cannot carry them, av1 because that is the gap fMP4 exists to fill. Live TV also stops being unconditionally transcode-only. It now reads the same saved quality preset the library path does: Original asks for direct play and sends no bitrate ceiling, so a channel the server can hand over untouched no longer costs a GPU encode session per viewer, and a capped preset transcodes at that ceiling. A direct-play session whose stream dies re-negotiates a forced transcode instead of re-opening a URL the server will not serve. Two smaller holes on the same path: a negotiation that bailed out left the live stream AutoOpenLiveStream had already opened running, because no playback session existed to ever stop-report it - a few failed tunes could saturate a provider's connection budget - and a channel switch that failed to start showed no message at all. close #2198
This commit is contained in:
@@ -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<LiveTvPlaybackSession?> 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<LiveTvPlaybackSession?> 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<LiveTvPlaybackSession?> 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1238,8 +1238,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> 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<VideoPlayerScreen> 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<OfflineModeProvider>().isCellularOnly,
|
||||
cellularDefault: settingsService.read(SettingsService.cellularQualityPreset),
|
||||
generalDefault: settingsService.read(SettingsService.defaultQualityPreset),
|
||||
);
|
||||
}
|
||||
|
||||
if (Platform.isWindows) {
|
||||
initPhase = 'syncing display mode';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<void> _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<LiveTvStreamResolution?> _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<LiveTvStreamResolution?> _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 = <String, String>{
|
||||
'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<LiveTvPlaybackSession?> startPlayback(String channelKey, {String? dvrKey}) async {
|
||||
final resolution = await _resolveStreamUrl(channelKey);
|
||||
Future<LiveTvPlaybackSession?> 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<LiveTvPlaybackSession?> recover({required bool directStream, required bool directStreamAudio}) async => this;
|
||||
Future<LiveTvPlaybackSession?> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<LiveTvPlaybackSession?> startPlayback(String channelKey, {String? dvrKey}) {
|
||||
Future<LiveTvPlaybackSession?> 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);
|
||||
|
||||
@@ -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<String, dynamic>;
|
||||
final profile = body['DeviceProfile'] as Map<String, dynamic>;
|
||||
final videoTranscode = (profile['TranscodingProfiles'] as List<dynamic>)
|
||||
final videoProfiles = (profile['TranscodingProfiles'] as List<dynamic>)
|
||||
.map((entry) => entry as Map<String, dynamic>)
|
||||
.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<String, dynamic>;
|
||||
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())!);
|
||||
|
||||
@@ -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 = <http.Request>[];
|
||||
final reports = <http.Request>[];
|
||||
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<String, dynamic>;
|
||||
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<String, dynamic>;
|
||||
expect(report['PlayMethod'], 'DirectPlay');
|
||||
expect(report['LiveStreamId'], 'live-1');
|
||||
});
|
||||
|
||||
test('a capped preset forces a transcode at that ceiling', () async {
|
||||
final negotiations = <http.Request>[];
|
||||
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<String, dynamic>;
|
||||
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 = <http.Request>[];
|
||||
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 = <http.Request>[];
|
||||
final closes = <http.Request>[];
|
||||
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<String, dynamic>;
|
||||
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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user