@@ -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?>>[
|
||||
|
||||
@@ -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)}';
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user