fix(live-tv): stabilize HLS playback
This commit is contained in:
@@ -1464,7 +1464,7 @@ void main() {
|
||||
expect(uri.queryParameters['api_key'], 'tok-abc');
|
||||
});
|
||||
|
||||
test('live TV stream resolution opens a direct stream instead of HLS transcode', () async {
|
||||
test('live TV stream resolution requires an HLS transcode', () async {
|
||||
final requests = <Uri>[];
|
||||
String? capturedBody;
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
@@ -1498,30 +1498,28 @@ void main() {
|
||||
|
||||
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['EnableTranscoding'], 'true');
|
||||
expect(requests.single.queryParameters['EnableDirectPlay'], 'false');
|
||||
expect(requests.single.queryParameters['EnableDirectStream'], 'false');
|
||||
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(body['EnableTranscoding'], isTrue);
|
||||
expect(body['EnableDirectPlay'], isFalse);
|
||||
expect(body['EnableDirectStream'], isFalse);
|
||||
expect(resolution, isNotNull);
|
||||
expect(resolution!.playSessionId, 'live-session-1');
|
||||
expect(resolution.mediaSourceId, 'source-1');
|
||||
expect(resolution.liveStreamId, 'open-stream-1');
|
||||
expect(resolution.playMethod, 'Transcode');
|
||||
final uri = Uri.parse(resolution.url);
|
||||
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.path, '/Videos/channel-1/live.m3u8');
|
||||
expect(uri.queryParameters['PlaySessionId'], 'live-session-1');
|
||||
expect(uri.queryParameters['DeviceId'], 'dev-xyz');
|
||||
expect(uri.queryParameters['api_key'], 'tok-abc');
|
||||
});
|
||||
|
||||
test('live TV stream resolution recovers identity from a negotiated direct URL', () async {
|
||||
test('live TV stream resolution recovers identity from a negotiated HLS URL', () async {
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
connection: _conn(),
|
||||
httpClient: MockClient((request) async {
|
||||
@@ -1531,8 +1529,8 @@ void main() {
|
||||
'MediaSources': [
|
||||
{
|
||||
'Container': 'ts',
|
||||
'DirectStreamUrl':
|
||||
'/Videos/channel-1/stream?MediaSourceId=source-url&LiveStreamId=live-url&PlaySessionId=play-url',
|
||||
'TranscodingUrl':
|
||||
'/Videos/channel-1/live.m3u8?MediaSourceId=source-url&LiveStreamId=live-url&PlaySessionId=play-url',
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -1551,6 +1549,30 @@ void main() {
|
||||
expect(resolution!.playSessionId, 'play-url');
|
||||
expect(resolution.mediaSourceId, 'source-url');
|
||||
expect(resolution.liveStreamId, 'live-url');
|
||||
expect(resolution.playMethod, 'Transcode');
|
||||
});
|
||||
|
||||
test('live TV stream resolution rejects a non-HLS fallback URL', () async {
|
||||
final scoped = JellyfinClient.forTesting(
|
||||
connection: _conn(),
|
||||
httpClient: MockClient((request) async {
|
||||
if (request.url.path == '/Items/channel-1/PlaybackInfo') {
|
||||
return http.Response(
|
||||
jsonEncode({
|
||||
'MediaSources': [
|
||||
{'DirectStreamUrl': '/Videos/channel-1/stream.ts'},
|
||||
],
|
||||
}),
|
||||
200,
|
||||
headers: {'content-type': 'application/json'},
|
||||
);
|
||||
}
|
||||
return http.Response('{}', 404);
|
||||
}),
|
||||
);
|
||||
addTearDown(scoped.close);
|
||||
|
||||
expect(await scoped.liveTv.resolveStreamUrl('channel-1'), isNull);
|
||||
});
|
||||
|
||||
test('buildTrickplayTileUrl wires width, sheet index, api_key, and DeviceId', () {
|
||||
|
||||
@@ -98,6 +98,41 @@ void main() {
|
||||
});
|
||||
});
|
||||
|
||||
test('does not reopen when a skip is clamped to the current boundary', () {
|
||||
fakeAsync((async) {
|
||||
window = (start: 950, end: 1050);
|
||||
final acc = build();
|
||||
|
||||
currentEpoch = 1050;
|
||||
acc.seekBy(15);
|
||||
expect(acc.pendingEpoch, isNull);
|
||||
|
||||
currentEpoch = 950;
|
||||
acc.seekBy(-15);
|
||||
expect(acc.pendingEpoch, isNull);
|
||||
|
||||
async.elapse(const Duration(milliseconds: 300));
|
||||
expect(seeks, isEmpty);
|
||||
expect(changes, 0);
|
||||
acc.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('still seeks away from a capture-buffer boundary', () {
|
||||
fakeAsync((async) {
|
||||
window = (start: 950, end: 1050);
|
||||
currentEpoch = 1050;
|
||||
final acc = build();
|
||||
|
||||
acc.seekBy(-15);
|
||||
expect(acc.pendingEpoch, 1035);
|
||||
|
||||
async.elapse(const Duration(milliseconds: 300));
|
||||
expect(seeks, [1035]);
|
||||
acc.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
test('flushes the newer target when a press lands during the seek', () {
|
||||
fakeAsync((async) {
|
||||
gate = Completer<void>();
|
||||
|
||||
@@ -22,7 +22,7 @@ class _FakeJellyfinClient implements JellyfinClient {
|
||||
int? subtitleStreamIndex,
|
||||
}) async {
|
||||
await startGate.future;
|
||||
calls.add('started:$itemId:$playSessionId:$mediaSourceId:$liveStreamId');
|
||||
calls.add('started:$itemId:$playSessionId:$mediaSourceId:$liveStreamId:$playMethod');
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -65,6 +65,7 @@ void main() {
|
||||
playSessionId: 'live-session-1',
|
||||
mediaSourceId: 'source-1',
|
||||
liveStreamId: 'live-stream-1',
|
||||
playMethod: 'Transcode',
|
||||
);
|
||||
|
||||
final first = tracker.report(
|
||||
@@ -97,7 +98,7 @@ void main() {
|
||||
await Future.wait([first, second, stopped]);
|
||||
|
||||
expect(client.calls, [
|
||||
'started:channel-1:live-session-1:source-1:live-stream-1',
|
||||
'started:channel-1:live-session-1:source-1:live-stream-1:Transcode',
|
||||
'stopped:channel-1:live-session-1:source-1:live-stream-1',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -239,7 +239,7 @@ void main() {
|
||||
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
|
||||
);
|
||||
|
||||
test('startPlayback negotiates one direct URL; no time-shift; recover reuses it', () async {
|
||||
test('startPlayback negotiates one HLS URL; no time-shift; recover reuses it', () async {
|
||||
final client = JellyfinClient.forTesting(
|
||||
connection: conn(),
|
||||
httpClient: MockClient((request) async {
|
||||
@@ -247,7 +247,12 @@ void main() {
|
||||
return jsonResponse({
|
||||
'PlaySessionId': 'play-1',
|
||||
'MediaSources': [
|
||||
{'Id': 'source-1', 'Container': 'ts', 'LiveStreamId': 'live-1'},
|
||||
{
|
||||
'Id': 'source-1',
|
||||
'Container': 'ts',
|
||||
'LiveStreamId': 'live-1',
|
||||
'TranscodingUrl': '/Videos/channel-1/live.m3u8?PlaySessionId=play-1',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -266,13 +271,13 @@ void main() {
|
||||
|
||||
final url = await session.streamUrlAt();
|
||||
expect(url, isNotNull);
|
||||
expect(Uri.parse(url!).path, contains('/Videos/channel-1'));
|
||||
expect(Uri.parse(url!).path, '/Videos/channel-1/live.m3u8');
|
||||
expect(Uri.parse(url).queryParameters['PlaySessionId'], 'play-1');
|
||||
|
||||
// Time-shift unsupported — an offset request must not silently play live.
|
||||
expect(await session.streamUrlAt(offsetSeconds: 60), isNull);
|
||||
|
||||
// Session-less URL: recovery is just re-opening it.
|
||||
// Recovery re-opens the negotiated HLS URL.
|
||||
expect(await session.recover(directStream: false, directStreamAudio: false), same(session));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,7 +169,6 @@ class FakePlayer implements Player {
|
||||
bool play = true,
|
||||
bool isLive = false,
|
||||
List<SubtitleTrack>? externalSubtitles,
|
||||
Duration timelineOffset = Duration.zero,
|
||||
Duration? timelineDuration,
|
||||
}) async {
|
||||
openedUris.add(media.uri);
|
||||
|
||||
Reference in New Issue
Block a user