From d901f5efc2775db7e88653a2743d4e30b0bb6f62 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 11 May 2026 13:42:55 +0200 Subject: [PATCH] fix(jellyfin): direct stream live tv close #1010 --- .../parts/images_downloads.dart | 15 +++++++- .../jellyfin_client/parts/live_tv.dart | 26 ++++++++++--- .../jellyfin_client/parts/playback.dart | 38 +++++++++++++++++-- lib/services/jellyfin_playback_urls.dart | 4 ++ test/services/jellyfin_client_urls_test.dart | 30 +++++++++++++-- 5 files changed, 101 insertions(+), 12 deletions(-) diff --git a/lib/services/jellyfin_client/parts/images_downloads.dart b/lib/services/jellyfin_client/parts/images_downloads.dart index fccedd7d..ed659d3b 100644 --- a/lib/services/jellyfin_client/parts/images_downloads.dart +++ b/lib/services/jellyfin_client/parts/images_downloads.dart @@ -3,13 +3,26 @@ part of '../../jellyfin_client.dart'; mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin { JellyfinConnection get connection; Future fetchPlaybackBundle(String itemId, {int sourceIndex = 0}); - String buildDirectStreamUrl(String itemId, {String? container, String? mediaSourceId}); + String buildDirectStreamUrl( + String itemId, { + String? container, + String? mediaSourceId, + String? playSessionId, + String? liveStreamId, + }); Future?> getPlaybackInfo( String itemId, { int maxStreamingBitrate = 100000000, String? mediaSourceId, + String? liveStreamId, int? audioStreamIndex, int? subtitleStreamIndex, + bool? autoOpenLiveStream, + bool? enableDirectPlay, + bool? enableDirectStream, + bool? enableTranscoding, + bool? allowVideoStreamCopy, + bool? allowAudioStreamCopy, }); String _withApiKey(String urlOrPath); diff --git a/lib/services/jellyfin_client/parts/live_tv.dart b/lib/services/jellyfin_client/parts/live_tv.dart index 2cb16027..fcc63384 100644 --- a/lib/services/jellyfin_client/parts/live_tv.dart +++ b/lib/services/jellyfin_client/parts/live_tv.dart @@ -172,18 +172,34 @@ class _JellyfinLiveTvSupport implements LiveTvSupport { @override Future resolveStreamUrl(String channelKey, {String? dvrKey}) async { - final info = await _client.getPlaybackInfo(channelKey); + final info = await _client.getPlaybackInfo( + channelKey, + autoOpenLiveStream: true, + enableDirectPlay: true, + enableDirectStream: true, + enableTranscoding: false, + allowVideoStreamCopy: true, + allowAudioStreamCopy: true, + ); final sources = info?['MediaSources']; final source = sources is List && sources.isNotEmpty && sources.first is Map ? sources.first as Map : null; if (source == null) return null; - final rawUrl = source['TranscodingUrl'] ?? source['DirectStreamUrl']; - final url = rawUrl is String && rawUrl.isNotEmpty + String? nonEmptyString(dynamic raw) => raw is String && raw.isNotEmpty ? raw : null; + + var playSessionId = nonEmptyString(info?['PlaySessionId']); + final rawUrl = nonEmptyString(source['DirectStreamUrl']); + final url = rawUrl != null ? _client._withApiKey(rawUrl) - : _client.buildDirectStreamUrl(channelKey); - var playSessionId = info?['PlaySessionId'] as String?; + : _client.buildDirectStreamUrl( + channelKey, + container: nonEmptyString(source['Container']), + mediaSourceId: nonEmptyString(source['Id']), + playSessionId: playSessionId, + liveStreamId: nonEmptyString(source['LiveStreamId']), + ); playSessionId ??= Uri.tryParse(url)?.queryParameters['PlaySessionId']; return LiveTvStreamResolution(url: url, playSessionId: playSessionId); } diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index 87f20cb6..985bebe7 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -316,7 +316,13 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { /// item only has a single MediaSource, [mediaSourceId] equals [itemId] and /// can be omitted; for items with multiple versions Jellyfin uses the /// param to pick which file to serve. - String buildDirectStreamUrl(String itemId, {String? container, String? mediaSourceId}) { + String buildDirectStreamUrl( + String itemId, { + String? container, + String? mediaSourceId, + String? playSessionId, + String? liveStreamId, + }) { return buildJellyfinDirectStreamUrl( baseUrl: connection.baseUrl, accessToken: connection.accessToken, @@ -324,6 +330,8 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { itemId: itemId, container: container, mediaSourceId: mediaSourceId, + playSessionId: playSessionId, + liveStreamId: liveStreamId, ); } @@ -359,16 +367,30 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { String itemId, { int maxStreamingBitrate = 100000000, String? mediaSourceId, + String? liveStreamId, int? audioStreamIndex, int? subtitleStreamIndex, + bool? autoOpenLiveStream, + bool? enableDirectPlay, + bool? enableDirectStream, + bool? enableTranscoding, + bool? allowVideoStreamCopy, + bool? allowAudioStreamCopy, }) async { try { final query = { 'userId': connection.userId, 'MaxStreamingBitrate': maxStreamingBitrate.toString(), 'MediaSourceId': ?mediaSourceId, + 'LiveStreamId': ?liveStreamId, 'AudioStreamIndex': ?audioStreamIndex?.toString(), 'SubtitleStreamIndex': ?subtitleStreamIndex?.toString(), + 'AutoOpenLiveStream': ?autoOpenLiveStream?.toString(), + 'EnableDirectPlay': ?enableDirectPlay?.toString(), + 'EnableDirectStream': ?enableDirectStream?.toString(), + 'EnableTranscoding': ?enableTranscoding?.toString(), + 'AllowVideoStreamCopy': ?allowVideoStreamCopy?.toString(), + 'AllowAudioStreamCopy': ?allowAudioStreamCopy?.toString(), }; final response = await _http.post( '/Items/${_segment(itemId)}/PlaybackInfo', @@ -376,6 +398,16 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { body: { 'UserId': connection.userId, 'MaxStreamingBitrate': maxStreamingBitrate, + 'MediaSourceId': ?mediaSourceId, + 'LiveStreamId': ?liveStreamId, + 'AudioStreamIndex': ?audioStreamIndex, + 'SubtitleStreamIndex': ?subtitleStreamIndex, + 'AutoOpenLiveStream': ?autoOpenLiveStream, + 'EnableDirectPlay': ?enableDirectPlay, + 'EnableDirectStream': ?enableDirectStream, + 'EnableTranscoding': ?enableTranscoding, + 'AllowVideoStreamCopy': ?allowVideoStreamCopy, + 'AllowAudioStreamCopy': ?allowAudioStreamCopy, 'DeviceProfile': { 'Name': 'Plezy', 'MaxStreamingBitrate': maxStreamingBitrate, @@ -402,8 +434,8 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { { 'Type': 'Video', 'Container': 'mp4,mkv,m4v,webm,mov,ts', - 'VideoCodec': 'hevc,h264,h265,vp8,vp9,av1,mpeg4', - 'AudioCodec': 'aac,mp3,ac3,eac3,flac,opus,vorbis,dts', + 'VideoCodec': 'hevc,h264,h265,vp8,vp9,av1,mpeg4,mpeg2video', + 'AudioCodec': 'aac,mp3,mp2,ac3,eac3,flac,opus,vorbis,dts', }, ], 'SubtitleProfiles': const >[ diff --git a/lib/services/jellyfin_playback_urls.dart b/lib/services/jellyfin_playback_urls.dart index 2bd3f3ae..69216b7e 100644 --- a/lib/services/jellyfin_playback_urls.dart +++ b/lib/services/jellyfin_playback_urls.dart @@ -5,6 +5,8 @@ String buildJellyfinDirectStreamUrl({ required String itemId, String? container, String? mediaSourceId, + String? playSessionId, + String? liveStreamId, }) { final params = { 'Static': 'true', @@ -12,6 +14,8 @@ String buildJellyfinDirectStreamUrl({ 'DeviceId': deviceId, 'Container': ?container, 'MediaSourceId': ?mediaSourceId, + 'PlaySessionId': ?playSessionId, + 'LiveStreamId': ?liveStreamId, }; final encodedItem = Uri.encodeComponent(itemId); return '$baseUrl/Videos/$encodedItem/stream?${_encodeQuery(params)}'; diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 378955d0..6460f330 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -362,6 +362,9 @@ void main() { expect(profile.containsKey('MaxStaticBitrate'), isFalse); expect(profile.containsKey('MusicStreamingTranscodingBitrate'), isFalse); expect(profile['DirectPlayProfiles'], isNotEmpty); + final directPlayProfile = (profile['DirectPlayProfiles'] as List).first as Map; + expect(directPlayProfile['VideoCodec'], contains('mpeg2video')); + expect(directPlayProfile['AudioCodec'], contains('mp2')); expect(profile['TranscodingProfiles'], isNotEmpty); expect(profile['CodecProfiles'], isEmpty); final subtitleProfiles = profile['SubtitleProfiles'] as List; @@ -516,18 +519,25 @@ void main() { expect(uri.queryParameters['api_key'], 'tok-abc'); }); - test('live TV stream resolution negotiates PlaybackInfo and preserves PlaySessionId', () async { + test('live TV stream resolution opens a direct stream instead of HLS transcode', () async { final requests = []; + String? capturedBody; final scoped = JellyfinClient.forTesting( connection: _conn(), httpClient: MockClient((request) async { requests.add(request.url); + capturedBody = request.body; if (request.url.path == '/Items/channel-1/PlaybackInfo') { return http.Response( jsonEncode({ 'PlaySessionId': 'live-session-1', 'MediaSources': [ - {'Id': 'source-1', 'TranscodingUrl': '/Videos/channel-1/master.m3u8?PlaySessionId=live-session-1'}, + { + 'Id': 'source-1', + 'Container': 'ts', + 'LiveStreamId': 'open-stream-1', + 'TranscodingUrl': '/Videos/channel-1/live.m3u8?PlaySessionId=live-session-1', + }, ], }), 200, @@ -542,11 +552,25 @@ void main() { final resolution = await scoped.liveTv.resolveStreamUrl('channel-1'); expect(requests.single.path, '/Items/channel-1/PlaybackInfo'); + expect(requests.single.queryParameters['AutoOpenLiveStream'], 'true'); + expect(requests.single.queryParameters['EnableTranscoding'], 'false'); + expect(requests.single.queryParameters['EnableDirectPlay'], 'true'); + expect(requests.single.queryParameters['EnableDirectStream'], 'true'); + expect(requests.single.queryParameters['AllowVideoStreamCopy'], 'true'); + expect(requests.single.queryParameters['AllowAudioStreamCopy'], 'true'); + final body = jsonDecode(capturedBody!) as Map; + expect(body['AutoOpenLiveStream'], isTrue); + expect(body['EnableTranscoding'], isFalse); expect(resolution, isNotNull); expect(resolution!.playSessionId, 'live-session-1'); final uri = Uri.parse(resolution.url); - expect(uri.path, '/Videos/channel-1/master.m3u8'); + expect(uri.path, '/Videos/channel-1/stream'); + expect(uri.queryParameters['Static'], 'true'); + expect(uri.queryParameters['Container'], 'ts'); + expect(uri.queryParameters['MediaSourceId'], 'source-1'); + expect(uri.queryParameters['LiveStreamId'], 'open-stream-1'); expect(uri.queryParameters['PlaySessionId'], 'live-session-1'); + expect(uri.queryParameters['DeviceId'], 'dev-xyz'); expect(uri.queryParameters['api_key'], 'tok-abc'); });