fix(emby): negotiate MPEG-TS HLS for live TV
Emby Live TV can fail to parse fMP4 HLS fragments, and retrying the same negotiated URL cannot change the segment container. Restrict Emby Live TV negotiation to the existing MPEG-TS profile on initial tune and direct-play recovery. Preserve direct play, stream copy, Jellyfin negotiation, and Emby VOD. Cover transport scoping and recovery with protocol regressions. close #2273
This commit is contained in:
@@ -125,6 +125,12 @@ enum MediaBrowserDialect {
|
||||
/// leading codec must be one the dialect is known to emit.
|
||||
bool get rotatesDisabledTranscodeCodecs => this == MediaBrowserDialect.jellyfin;
|
||||
|
||||
/// Restrict Live TV HLS negotiation to MPEG-TS. Emby fMP4 live streams
|
||||
/// fail with missing initialization metadata in affected setups (#2273),
|
||||
/// while the same sources play as TS. VOD keeps fMP4; this is a transport
|
||||
/// compatibility policy, not a restriction on direct play or stream copy.
|
||||
bool get requiresMpegTsForLiveTv => this == MediaBrowserDialect.emby;
|
||||
|
||||
/// `GET /Audio/{id}/Lyrics` (Jellyfin 10.9+). Never call this on Emby: the
|
||||
/// route resolves to audio streaming with `Lyrics` as the container and
|
||||
/// spawns an ffmpeg process that fails with a 500.
|
||||
|
||||
@@ -150,6 +150,7 @@ mixin _JellyfinClientInternals on MediaServerCacheMixin {
|
||||
bool? enableTranscoding,
|
||||
bool? allowVideoStreamCopy,
|
||||
bool? allowAudioStreamCopy,
|
||||
bool isLiveTv,
|
||||
bool audioProfile,
|
||||
bool burnSubtitles,
|
||||
});
|
||||
|
||||
@@ -205,6 +205,7 @@ class _JellyfinLiveTvSupport implements LiveTvSupport {
|
||||
final wantsDirect = quality.isOriginal && !forceTranscode;
|
||||
final info = await _client.getPlaybackInfo(
|
||||
channelKey,
|
||||
isLiveTv: true,
|
||||
// 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,
|
||||
|
||||
@@ -765,6 +765,8 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
||||
/// [audioStreamIndex] / [subtitleStreamIndex] tell the server which streams
|
||||
/// to pick for the transcode profile (Jellyfin's negotiation factors them in
|
||||
/// when picking codec compatibility).
|
||||
/// [isLiveTv] selects the dialect's live HLS transport policy independently
|
||||
/// of [autoOpenLiveStream], which controls server-side source lifecycle.
|
||||
/// [audioProfile] extends the DeviceProfile with music direct-play and
|
||||
/// audio→mp3 transcode entries for track playback; the video profiles (and
|
||||
/// the request body when false) are untouched either way.
|
||||
@@ -783,6 +785,7 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
||||
bool? enableTranscoding,
|
||||
bool? allowVideoStreamCopy,
|
||||
bool? allowAudioStreamCopy,
|
||||
bool isLiveTv = false,
|
||||
bool audioProfile = false,
|
||||
|
||||
/// Drop `External` subtitle delivery from the profile, so the server burns
|
||||
@@ -830,25 +833,27 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
||||
// Every mpv backend already consumes fMP4 HLS — the Plex VOD
|
||||
// target has shipped it since issue #1859.
|
||||
'TranscodingProfiles': <Map<String, Object?>>[
|
||||
{
|
||||
'Type': 'Video',
|
||||
'Container': 'mp4',
|
||||
'Protocol': 'hls',
|
||||
'VideoCodec': _jellyfinTranscodeVideoCodecs(dialect),
|
||||
// Every audio codec Jellyfin can put in an fMP4 segment, so a
|
||||
// transcode forced by the video stream can still copy the audio
|
||||
// instead of re-encoding it; AAC leads because it is the only
|
||||
// entry the server can reliably encode to. Two silent traps:
|
||||
// the server validates this against `^[a-zA-Z0-9\-\._,|]{0,40}$`
|
||||
// when it echoes the list into the transcode URL, so `alac` does
|
||||
// not fit and `*` is not a wildcard; and omitting the key is not
|
||||
// "accept everything" the way it is for a direct-play profile —
|
||||
// the server substitutes the source codec, filters it against
|
||||
// the same fMP4 set, and ships no audio at all for a source it
|
||||
// cannot carry.
|
||||
'AudioCodec': 'aac,mp3,ac3,eac3,flac,opus,dts,truehd',
|
||||
},
|
||||
// MPEG-TS fallback, listed second (#2198): Jellyfin drops every
|
||||
if (!isLiveTv || !dialect.requiresMpegTsForLiveTv)
|
||||
{
|
||||
'Type': 'Video',
|
||||
'Container': 'mp4',
|
||||
'Protocol': 'hls',
|
||||
'VideoCodec': _jellyfinTranscodeVideoCodecs(dialect),
|
||||
// Every audio codec Jellyfin can put in an fMP4 segment, so a
|
||||
// transcode forced by the video stream can still copy the audio
|
||||
// instead of re-encoding it; AAC leads because it is the only
|
||||
// entry the server can reliably encode to. Two silent traps:
|
||||
// the server validates this against `^[a-zA-Z0-9\-\._,|]{0,40}$`
|
||||
// when it echoes the list into the transcode URL, so `alac` does
|
||||
// not fit and `*` is not a wildcard; and omitting the key is not
|
||||
// "accept everything" the way it is for a direct-play profile —
|
||||
// the server substitutes the source codec, filters it against
|
||||
// the same fMP4 set, and ships no audio at all for a source it
|
||||
// cannot carry.
|
||||
'AudioCodec': 'aac,mp3,ac3,eac3,flac,opus,dts,truehd',
|
||||
},
|
||||
// MPEG-TS is the only Emby Live TV target (#2273); otherwise it
|
||||
// stays second as Jellyfin's fallback (#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
|
||||
|
||||
@@ -473,6 +473,70 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
for (final (connection, liveContainers) in [
|
||||
(testJellyfinConnection(), ['mp4', 'ts']),
|
||||
(testEmbyConnection(), ['ts']),
|
||||
]) {
|
||||
test('${connection.dialect.productName} scopes HLS containers to live tune and recovery, not VOD', () async {
|
||||
final negotiations = <Map<String, dynamic>>[];
|
||||
final client = JellyfinClient.forTesting(
|
||||
connection: connection,
|
||||
httpClient: MockClient((request) async {
|
||||
if (!request.url.path.endsWith('/PlaybackInfo')) return http.Response('', 204);
|
||||
negotiations.add(jsonDecode(request.body) as Map<String, dynamic>);
|
||||
return jsonResponse({
|
||||
'PlaySessionId': 'play-${negotiations.length}',
|
||||
'MediaSources': [
|
||||
{
|
||||
'Id': 'source-1',
|
||||
'Container': 'ts',
|
||||
'LiveStreamId': 'live-${negotiations.length}',
|
||||
'SupportsDirectPlay': negotiations.length == 1,
|
||||
'TranscodingUrl': '/Videos/channel-1/live.m3u8',
|
||||
},
|
||||
],
|
||||
});
|
||||
}),
|
||||
);
|
||||
addTearDown(client.close);
|
||||
|
||||
List<dynamic> containers(Map<String, dynamic> body) =>
|
||||
((body['DeviceProfile'] as Map<String, dynamic>)['TranscodingProfiles'] as List)
|
||||
.where((profile) => profile['Type'] == 'Video' && profile['Protocol'] == 'hls')
|
||||
.map((profile) => profile['Container'])
|
||||
.toList();
|
||||
|
||||
// Original must still allow direct play; selecting a live HLS container
|
||||
// must not force the source through the transcoder (#2253).
|
||||
final direct = (await client.liveTv.startPlayback('channel-1'))!;
|
||||
expect(Uri.parse((await direct.streamUrlAt())!).path, '/Videos/channel-1/stream.ts');
|
||||
expect(negotiations.single['EnableDirectPlay'], isTrue);
|
||||
expect(containers(negotiations.single), liveContainers);
|
||||
|
||||
final recovered = (await direct.recover(directStream: false, directStreamAudio: true))!;
|
||||
expect(Uri.parse((await recovered.streamUrlAt())!).path, '/Videos/channel-1/live.m3u8');
|
||||
expect(containers(negotiations[1]), liveContainers);
|
||||
expect(negotiations[1]['EnableDirectPlay'], isFalse);
|
||||
expect(negotiations[1]['AllowVideoStreamCopy'], isTrue);
|
||||
expect(negotiations[1]['AllowAudioStreamCopy'], isTrue);
|
||||
await recovered.reportTimeline(state: 'stopped', positionMs: 0, durationMs: 0);
|
||||
|
||||
// A capped tune reaches HLS immediately rather than through recovery.
|
||||
final capped = (await client.liveTv.startPlayback('channel-1', quality: TranscodeQualityPreset.p720_2mbps))!;
|
||||
expect(containers(negotiations[2]), liveContainers);
|
||||
expect(negotiations[2]['MaxStreamingBitrate'], 2_000_000);
|
||||
expect(negotiations[2]['AllowVideoStreamCopy'], isTrue);
|
||||
expect(negotiations[2]['AllowAudioStreamCopy'], isTrue);
|
||||
await capped.reportTimeline(state: 'stopped', positionMs: 0, durationMs: 0);
|
||||
|
||||
// Returning to VOD on the same client must retain fMP4, even when a
|
||||
// source asks the server to open a live stream during negotiation.
|
||||
await client.getPlaybackInfo('movie-1', autoOpenLiveStream: true);
|
||||
expect(containers(negotiations[3]), ['mp4', 'ts']);
|
||||
await pumpEventQueue();
|
||||
});
|
||||
}
|
||||
|
||||
group('Jellyfin live playback session', () {
|
||||
JellyfinConnection conn() => JellyfinConnection(
|
||||
id: 'srv-1/user-1',
|
||||
|
||||
Reference in New Issue
Block a user