diff --git a/lib/media/media_browser_dialect.dart b/lib/media/media_browser_dialect.dart index 3e766900b..0ec428c42 100644 --- a/lib/media/media_browser_dialect.dart +++ b/lib/media/media_browser_dialect.dart @@ -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. diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index e50de577c..56608cea7 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -150,6 +150,7 @@ mixin _JellyfinClientInternals on MediaServerCacheMixin { bool? enableTranscoding, bool? allowVideoStreamCopy, bool? allowAudioStreamCopy, + bool isLiveTv, bool audioProfile, bool burnSubtitles, }); diff --git a/lib/services/jellyfin_client/parts/live_tv.dart b/lib/services/jellyfin_client/parts/live_tv.dart index 741902ead..1c0ca8ebd 100644 --- a/lib/services/jellyfin_client/parts/live_tv.dart +++ b/lib/services/jellyfin_client/parts/live_tv.dart @@ -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, diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index 97dca1e12..422c450a8 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -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': >[ - { - '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 diff --git a/test/services/live_tv_playback_session_test.dart b/test/services/live_tv_playback_session_test.dart index 52f1a6072..05ef3ecc8 100644 --- a/test/services/live_tv_playback_session_test.dart +++ b/test/services/live_tv_playback_session_test.dart @@ -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 = >[]; + 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); + 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 containers(Map body) => + ((body['DeviceProfile'] as Map)['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',