fix(jellyfin): direct stream live tv

close #1010
This commit is contained in:
edde746
2026-05-11 13:50:46 +02:00
parent fcd8198fcb
commit d901f5efc2
5 changed files with 101 additions and 12 deletions
@@ -3,13 +3,26 @@ part of '../../jellyfin_client.dart';
mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin {
JellyfinConnection get connection;
Future<JellyfinPlaybackBundle?> 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<Map<String, dynamic>?> 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);
@@ -172,18 +172,34 @@ class _JellyfinLiveTvSupport implements LiveTvSupport {
@override
Future<LiveTvStreamResolution?> 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<String, dynamic>
? sources.first as Map<String, dynamic>
: 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);
}
@@ -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 = <String, String>{
'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': <String, Object?>{
'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 <Map<String, Object?>>[
+4
View File
@@ -5,6 +5,8 @@ String buildJellyfinDirectStreamUrl({
required String itemId,
String? container,
String? mediaSourceId,
String? playSessionId,
String? liveStreamId,
}) {
final params = <String, String>{
'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)}';
+27 -3
View File
@@ -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<dynamic>).first as Map<String, dynamic>;
expect(directPlayProfile['VideoCodec'], contains('mpeg2video'));
expect(directPlayProfile['AudioCodec'], contains('mp2'));
expect(profile['TranscodingProfiles'], isNotEmpty);
expect(profile['CodecProfiles'], isEmpty);
final subtitleProfiles = profile['SubtitleProfiles'] as List<dynamic>;
@@ -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 = <Uri>[];
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<String, dynamic>;
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');
});