fix: stop Jellyfin live TV sessions

close #1533
This commit is contained in:
edde746
2026-07-11 22:00:05 +02:00
parent 4443b76145
commit 2679745941
21 changed files with 277 additions and 20 deletions
+15 -1
View File
@@ -57,6 +57,9 @@ class LiveProgramInfo {
abstract class LiveTvPlaybackSession { abstract class LiveTvPlaybackSession {
LiveProgramInfo get program; LiveProgramInfo get program;
/// Whether a TV app may retain this server session while backgrounded.
LiveTvBackgroundPolicy get backgroundPolicy;
/// Seekable-history snapshot from session start. Heartbeats may return /// Seekable-history snapshot from session start. Heartbeats may return
/// fresher ones ([reportTimeline]); the caller owns tracking the current /// fresher ones ([reportTimeline]); the caller owns tracking the current
/// value. /// value.
@@ -85,6 +88,15 @@ abstract class LiveTvPlaybackSession {
Future<LiveTvPlaybackSession?> recover({required bool directStream, required bool directStreamAudio}); Future<LiveTvPlaybackSession?> recover({required bool directStream, required bool directStreamAudio});
} }
enum LiveTvBackgroundPolicy {
/// Keep the tuned session alive so its capture buffer can be resumed.
retainSession,
/// Stop the session when a TV app is backgrounded and leave playback when
/// the app resumes.
stopAndExit,
}
enum FavoriteChannelPersistenceMode { enum FavoriteChannelPersistenceMode {
/// A single write replaces the full backend account's favorite list. /// A single write replaces the full backend account's favorite list.
sharedFullList, sharedFullList,
@@ -96,8 +108,10 @@ enum FavoriteChannelPersistenceMode {
class LiveTvStreamResolution { class LiveTvStreamResolution {
final String url; final String url;
final String? playSessionId; final String? playSessionId;
final String? mediaSourceId;
final String? liveStreamId;
const LiveTvStreamResolution({required this.url, this.playSessionId}); const LiveTvStreamResolution({required this.url, this.playSessionId, this.mediaSourceId, this.liveStreamId});
} }
/// Backend-neutral live-TV operations. Implementations are obtained via /// Backend-neutral live-TV operations. Implementations are obtained via
+5 -1
View File
@@ -561,13 +561,15 @@ abstract class MediaServerClient {
/// ///
/// [duration] is the media's total length — passed through to Plex's /// [duration] is the media's total length — passed through to Plex's
/// timeline param so the server can use it. Jellyfin ignores [duration] but /// timeline param so the server can use it. Jellyfin ignores [duration] but
/// uses [mediaSourceId] and stream indexes for active-session state. /// uses [mediaSourceId], [liveStreamId], and stream indexes for active-session
/// state. Jellyfin needs [liveStreamId] to close an auto-opened live source.
Future<void> reportPlaybackStarted({ Future<void> reportPlaybackStarted({
required String itemId, required String itemId,
required Duration position, required Duration position,
Duration? duration, Duration? duration,
String? playSessionId, String? playSessionId,
String? playMethod, String? playMethod,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
int? audioStreamIndex, int? audioStreamIndex,
int? subtitleStreamIndex, int? subtitleStreamIndex,
@@ -583,6 +585,7 @@ abstract class MediaServerClient {
bool isPaused = false, bool isPaused = false,
String? playSessionId, String? playSessionId,
String? playMethod, String? playMethod,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
int? audioStreamIndex, int? audioStreamIndex,
int? subtitleStreamIndex, int? subtitleStreamIndex,
@@ -596,6 +599,7 @@ abstract class MediaServerClient {
required Duration position, required Duration position,
Duration? duration, Duration? duration,
String? playSessionId, String? playSessionId,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
PlaybackReportMetadata report = const PlaybackReportMetadata.live(), PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
}); });
@@ -48,6 +48,10 @@ class LiveTvSessionState {
/// from the background (it is suspended on hide). /// from the background (it is suspended on hide).
bool resumeTimelineOnResume = false; bool resumeTimelineOnResume = false;
/// A non-resumable live session was stopped while the TV app was hidden.
/// The player route is closed instead of attempting to reuse that session.
bool exitOnResume = false;
/// Make [newSession] current and seed the seekable window from its tune /// Make [newSession] current and seed the seekable window from its tune
/// snapshot. Every flow that produces a session (start, retry, channel /// snapshot. Every flow that produces a session (start, retry, channel
/// zap) adopts it here, so a field can't be forgotten in one copy. /// zap) adopts it here, so a field can't be forgotten in one copy.
@@ -88,6 +88,26 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
} }
final isTv = PlatformDetector.isTV(); final isTv = PlatformDetector.isTV();
if (widget.isLive && shouldStopLiveSessionForTvBackground(isTv: isTv, policy: _live.session?.backgroundPolicy)) {
_live.exitOnResume = true;
_live.resumeTimelineOnResume = false;
_stopLiveTimelineUpdates();
// Start the server cleanup before releasing the native stream. tvOS may
// suspend the process shortly after this lifecycle callback returns.
final stoppedReport = _sendStoppedProgressOnce();
try {
await currentPlayer.stop();
} catch (e, stackTrace) {
appLogger.w('Failed to stop live player while backgrounding', error: e, stackTrace: stackTrace);
}
await stoppedReport;
if (!mounted || currentPlayer != player) return;
await _suspendMediaControlsForTvBackground('hidden_live_stopped');
_recordLifecycleState('hidden', action: 'live_stopped_exit_on_resume');
return;
}
final shouldPauseForBackground = PlatformDetector.isHandheld(context) || isTv; final shouldPauseForBackground = PlatformDetector.isHandheld(context) || isTv;
// Pause first so Android MPV does not keep decoding against a transient // Pause first so Android MPV does not keep decoding against a transient
@@ -127,6 +147,13 @@ extension _VideoPlayerLifecycleMethods on VideoPlayerScreenState {
_recordLifecycleState('resumed', action: 'begin'); _recordLifecycleState('resumed', action: 'begin');
_watchTogetherProvider?.setBackgrounded(false); _watchTogetherProvider?.setBackgrounded(false);
if (_live.exitOnResume) {
_live.exitOnResume = false;
_recordLifecycleState('resumed', action: 'exit_stopped_live_session');
await _handleBackButton();
return;
}
if (Platform.isAndroid && _androidAutoPipTransitionInFlight && !PipService().isPipActive.value) { if (Platform.isAndroid && _androidAutoPipTransitionInFlight && !PipService().isPipActive.value) {
_setAndroidAutoPipTransitionInFlight(false, reason: 'resume_without_pip'); _setAndroidAutoPipTransitionInFlight(false, reason: 'resume_without_pip');
} }
@@ -1,9 +1,10 @@
import '../../media/live_tv_support.dart';
/// Whether backgrounding may release the native video pipeline after its /// Whether backgrounding may release the native video pipeline after its
/// grace period. /// grace period.
/// ///
/// Live TV deliberately stays paused with its player open: the tuned session /// Retained live sessions stay paused with their player open because stopping
/// owns the capture buffer, so stopping it would lose both the time-shift /// one would discard its capture buffer and time-shift position.
/// position and the user's paused/playing intent when playback is rebuilt.
bool shouldSuspendPlayerForTvBackground({ bool shouldSuspendPlayerForTvBackground({
required bool isAndroid, required bool isAndroid,
required bool isTv, required bool isTv,
@@ -12,3 +13,8 @@ bool shouldSuspendPlayerForTvBackground({
}) { }) {
return isAndroid && isTv && !isLive && !alreadySuspended; return isAndroid && isTv && !isLive && !alreadySuspended;
} }
/// Whether the current TV live session must be closed before suspension.
bool shouldStopLiveSessionForTvBackground({required bool isTv, required LiveTvBackgroundPolicy? policy}) {
return isTv && policy == LiveTvBackgroundPolicy.stopAndExit;
}
+6 -1
View File
@@ -717,6 +717,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
break; break;
case AppLifecycleState.detached: case AppLifecycleState.detached:
_recordLifecycleState('detached'); _recordLifecycleState('detached');
if (widget.isLive) unawaited(_sendStoppedProgressOnce());
break; break;
} }
} }
@@ -1221,7 +1222,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
unawaited(_sendStoppedProgressOnce()); unawaited(_sendStoppedProgressOnce());
_progressTracker?.stopTracking(); _progressTracker?.stopTracking();
_progressTracker?.dispose(); _progressTracker?.dispose();
_sendLiveTimeline('stopped');
_stopLiveTimelineUpdates(); _stopLiveTimelineUpdates();
_detachPipStateListener(); _detachPipStateListener();
@@ -1449,6 +1449,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
void _onSecondarySubtitleTrackChanged(SubtitleTrack track) => _trackManager?.onSecondarySubtitleTrackChanged(track); void _onSecondarySubtitleTrackChanged(SubtitleTrack track) => _trackManager?.onSecondarySubtitleTrackChanged(track);
Future<void> _sendStoppedProgressOnce({Duration? positionOverride}) { Future<void> _sendStoppedProgressOnce({Duration? positionOverride}) {
if (widget.isLive) {
_stopLiveTimelineUpdates();
return _sendLiveTimeline('stopped');
}
final tracker = _progressTracker; final tracker = _progressTracker;
if (tracker == null) return Future<void>.value(); if (tracker == null) return Future<void>.value();
@@ -189,18 +189,28 @@ class _JellyfinLiveTvSupport implements LiveTvSupport {
String? nonEmptyString(dynamic raw) => raw is String && raw.isNotEmpty ? raw : null; String? nonEmptyString(dynamic raw) => raw is String && raw.isNotEmpty ? raw : null;
var playSessionId = nonEmptyString(info?['PlaySessionId']); var playSessionId = nonEmptyString(info?['PlaySessionId']);
var mediaSourceId = nonEmptyString(source['Id']);
var liveStreamId = nonEmptyString(source['LiveStreamId']);
final rawUrl = nonEmptyString(source['DirectStreamUrl']); final rawUrl = nonEmptyString(source['DirectStreamUrl']);
final url = rawUrl != null final url = rawUrl != null
? _client._withApiKey(rawUrl) ? _client._withApiKey(rawUrl)
: _client.buildDirectStreamUrl( : _client.buildDirectStreamUrl(
channelKey, channelKey,
container: nonEmptyString(source['Container']), container: nonEmptyString(source['Container']),
mediaSourceId: nonEmptyString(source['Id']), mediaSourceId: mediaSourceId,
playSessionId: playSessionId, playSessionId: playSessionId,
liveStreamId: nonEmptyString(source['LiveStreamId']), liveStreamId: liveStreamId,
); );
playSessionId ??= Uri.tryParse(url)?.queryParameters['PlaySessionId']; final query = Uri.tryParse(url)?.queryParameters;
return LiveTvStreamResolution(url: url, playSessionId: playSessionId); playSessionId ??= query?['PlaySessionId'];
mediaSourceId ??= query?['MediaSourceId'];
liveStreamId ??= query?['LiveStreamId'];
return LiveTvStreamResolution(
url: url,
playSessionId: playSessionId,
mediaSourceId: mediaSourceId,
liveStreamId: liveStreamId,
);
} }
@override @override
@@ -460,11 +470,18 @@ class _JellyfinLiveTvPlaybackSession implements LiveTvPlaybackSession {
_JellyfinLiveTvPlaybackSession(this._client, this._channelKey, LiveTvStreamResolution resolution) _JellyfinLiveTvPlaybackSession(this._client, this._channelKey, LiveTvStreamResolution resolution)
: _url = resolution.url, : _url = resolution.url,
_tracker = JellyfinLiveSessionTracker(playSessionId: resolution.playSessionId); _tracker = JellyfinLiveSessionTracker(
playSessionId: resolution.playSessionId,
mediaSourceId: resolution.mediaSourceId,
liveStreamId: resolution.liveStreamId,
);
@override @override
LiveProgramInfo get program => LiveProgramInfo.none; LiveProgramInfo get program => LiveProgramInfo.none;
@override
LiveTvBackgroundPolicy get backgroundPolicy => LiveTvBackgroundPolicy.stopAndExit;
@override @override
CaptureBuffer? get captureBuffer => null; CaptureBuffer? get captureBuffer => null;
@@ -659,6 +659,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
Duration? duration, Duration? duration,
String? playSessionId, String? playSessionId,
String? playMethod, String? playMethod,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
int? audioStreamIndex, int? audioStreamIndex,
int? subtitleStreamIndex, int? subtitleStreamIndex,
@@ -678,6 +679,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
'RepeatMode': 'RepeatNone', 'RepeatMode': 'RepeatNone',
'PlaybackOrder': 'Default', 'PlaybackOrder': 'Default',
'PlaySessionId': ?playSessionId, 'PlaySessionId': ?playSessionId,
'LiveStreamId': ?liveStreamId,
}, },
); );
throwIfHttpError(response); throwIfHttpError(response);
@@ -694,6 +696,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
bool isPaused = false, bool isPaused = false,
String? playSessionId, String? playSessionId,
String? playMethod, String? playMethod,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
int? audioStreamIndex, int? audioStreamIndex,
int? subtitleStreamIndex, int? subtitleStreamIndex,
@@ -713,6 +716,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
'RepeatMode': 'RepeatNone', 'RepeatMode': 'RepeatNone',
'PlaybackOrder': 'Default', 'PlaybackOrder': 'Default',
'PlaySessionId': ?playSessionId, 'PlaySessionId': ?playSessionId,
'LiveStreamId': ?liveStreamId,
}, },
); );
throwIfHttpError(response); throwIfHttpError(response);
@@ -726,6 +730,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
required Duration position, required Duration position,
Duration? duration, Duration? duration,
String? playSessionId, String? playSessionId,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
PlaybackReportMetadata report = const PlaybackReportMetadata.live(), PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
}) async { }) async {
@@ -737,6 +742,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin {
'PositionTicks': msToJellyfinTicks(position.inMilliseconds), 'PositionTicks': msToJellyfinTicks(position.inMilliseconds),
'Failed': false, 'Failed': false,
'PlaySessionId': ?playSessionId, 'PlaySessionId': ?playSessionId,
'LiveStreamId': ?liveStreamId,
}, },
); );
throwIfHttpError(response); throwIfHttpError(response);
+18 -3
View File
@@ -10,9 +10,12 @@ import 'playback_report_session.dart';
/// Plex live path keeps its bespoke capture-buffer flow inline at the call /// Plex live path keeps its bespoke capture-buffer flow inline at the call
/// site; this tracker only covers Jellyfin's `/Sessions/Playing*` flow. /// site; this tracker only covers Jellyfin's `/Sessions/Playing*` flow.
class JellyfinLiveSessionTracker { class JellyfinLiveSessionTracker {
JellyfinLiveSessionTracker({String? playSessionId}) : _playSessionId = playSessionId ?? generateSessionIdentifier(); JellyfinLiveSessionTracker({String? playSessionId, this.mediaSourceId, this.liveStreamId})
: _playSessionId = playSessionId ?? generateSessionIdentifier();
final String _playSessionId; final String _playSessionId;
final String? mediaSourceId;
final String? liveStreamId;
PlaybackReportSession? _session; PlaybackReportSession? _session;
/// Session id reused across all heartbeats for this playback. Exposed /// Session id reused across all heartbeats for this playback. Exposed
@@ -29,8 +32,20 @@ class JellyfinLiveSessionTracker {
required Duration duration, required Duration duration,
}) async { }) async {
try { try {
final session = _session ??= PlaybackReportSession(client: client, itemId: itemId, playSessionId: _playSessionId); final session = _session ??= PlaybackReportSession(
await session.report(PlaybackReportSnapshot(state: state, position: position, duration: duration)); client: client,
itemId: itemId,
playSessionId: _playSessionId,
liveStreamId: liveStreamId,
);
await session.report(
PlaybackReportSnapshot(
state: state,
position: position,
duration: duration,
resolveStreamSelection: () => PlaybackStreamSelection(mediaSourceId: mediaSourceId),
),
);
} catch (e) { } catch (e) {
appLogger.d('Jellyfin live progress report failed', error: e); appLogger.d('Jellyfin live progress report failed', error: e);
} }
+11 -1
View File
@@ -58,12 +58,19 @@ class PlaybackReportSnapshot {
/// fire reports concurrently, but state changes are recorded synchronously /// fire reports concurrently, but state changes are recorded synchronously
/// before any async work such as settings lookup, track mapping, or HTTP calls. /// before any async work such as settings lookup, track mapping, or HTTP calls.
class PlaybackReportSession { class PlaybackReportSession {
PlaybackReportSession({required this.client, required this.itemId, this.playSessionId, this.playMethod}); PlaybackReportSession({
required this.client,
required this.itemId,
this.playSessionId,
this.playMethod,
this.liveStreamId,
});
final MediaServerClient client; final MediaServerClient client;
final String itemId; final String itemId;
final String? playSessionId; final String? playSessionId;
final String? playMethod; final String? playMethod;
final String? liveStreamId;
_PlaybackReportState _state = _PlaybackReportState.idle; _PlaybackReportState _state = _PlaybackReportState.idle;
PlaybackReportSnapshot? _startSnapshot; PlaybackReportSnapshot? _startSnapshot;
@@ -244,6 +251,7 @@ class PlaybackReportSession {
duration: snapshot.duration, duration: snapshot.duration,
playSessionId: playSessionId, playSessionId: playSessionId,
playMethod: playMethod, playMethod: playMethod,
liveStreamId: liveStreamId,
mediaSourceId: selection.mediaSourceId, mediaSourceId: selection.mediaSourceId,
audioStreamIndex: selection.audioStreamIndex, audioStreamIndex: selection.audioStreamIndex,
subtitleStreamIndex: selection.subtitleStreamIndex, subtitleStreamIndex: selection.subtitleStreamIndex,
@@ -260,6 +268,7 @@ class PlaybackReportSession {
isPaused: snapshot.state == 'paused', isPaused: snapshot.state == 'paused',
playSessionId: playSessionId, playSessionId: playSessionId,
playMethod: playMethod, playMethod: playMethod,
liveStreamId: liveStreamId,
mediaSourceId: selection.mediaSourceId, mediaSourceId: selection.mediaSourceId,
audioStreamIndex: selection.audioStreamIndex, audioStreamIndex: selection.audioStreamIndex,
subtitleStreamIndex: selection.subtitleStreamIndex, subtitleStreamIndex: selection.subtitleStreamIndex,
@@ -274,6 +283,7 @@ class PlaybackReportSession {
position: snapshot.position, position: snapshot.position,
duration: snapshot.duration, duration: snapshot.duration,
playSessionId: playSessionId, playSessionId: playSessionId,
liveStreamId: liveStreamId,
mediaSourceId: selection.mediaSourceId, mediaSourceId: selection.mediaSourceId,
report: snapshot.report, report: snapshot.report,
); );
+3
View File
@@ -4472,6 +4472,7 @@ class PlexClient
Duration? duration, Duration? duration,
String? playSessionId, String? playSessionId,
String? playMethod, String? playMethod,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
int? audioStreamIndex, int? audioStreamIndex,
int? subtitleStreamIndex, int? subtitleStreamIndex,
@@ -4491,6 +4492,7 @@ class PlexClient
bool isPaused = false, bool isPaused = false,
String? playSessionId, String? playSessionId,
String? playMethod, String? playMethod,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
int? audioStreamIndex, int? audioStreamIndex,
int? subtitleStreamIndex, int? subtitleStreamIndex,
@@ -4508,6 +4510,7 @@ class PlexClient
required Duration position, required Duration position,
Duration? duration, Duration? duration,
String? playSessionId, String? playSessionId,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
PlaybackReportMetadata report = const PlaybackReportMetadata.live(), PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
}) => updateProgress( }) => updateProgress(
@@ -1429,6 +1429,9 @@ class _PlexLiveTvPlaybackSession implements LiveTvPlaybackSession {
@override @override
final LiveProgramInfo program; final LiveProgramInfo program;
@override
LiveTvBackgroundPolicy get backgroundPolicy => LiveTvBackgroundPolicy.retainSession;
@override @override
final CaptureBuffer? captureBuffer; final CaptureBuffer? captureBuffer;
@@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/live_tv_support.dart';
import 'package:plezy/screens/video_player/tv_background_suspend_policy.dart'; import 'package:plezy/screens/video_player/tv_background_suspend_policy.dart';
void main() { void main() {
@@ -33,4 +34,16 @@ void main() {
isFalse, isFalse,
); );
}); });
test('TV backgrounding stops non-resumable live sessions', () {
expect(shouldStopLiveSessionForTvBackground(isTv: true, policy: LiveTvBackgroundPolicy.stopAndExit), isTrue);
});
test('TV backgrounding retains capture-buffer sessions', () {
expect(shouldStopLiveSessionForTvBackground(isTv: true, policy: LiveTvBackgroundPolicy.retainSession), isFalse);
});
test('non-TV backgrounding does not use the TV live-session policy', () {
expect(shouldStopLiveSessionForTvBackground(isTv: false, policy: LiveTvBackgroundPolicy.stopAndExit), isFalse);
});
} }
@@ -44,6 +44,7 @@ class _RecordingClient implements MediaServerClient {
Duration? duration, Duration? duration,
String? playSessionId, String? playSessionId,
String? playMethod, String? playMethod,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
int? audioStreamIndex, int? audioStreamIndex,
int? subtitleStreamIndex, int? subtitleStreamIndex,
@@ -58,6 +59,7 @@ class _RecordingClient implements MediaServerClient {
required Duration position, required Duration position,
Duration? duration, Duration? duration,
String? playSessionId, String? playSessionId,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
PlaybackReportMetadata report = const PlaybackReportMetadata.live(), PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
}) async { }) async {
@@ -311,6 +311,53 @@ void main() {
expect(body['IsPaused'], isTrue); expect(body['IsPaused'], isTrue);
}); });
test('live playback reports preserve the same server session identity', () async {
final requests = <({String path, Map<String, dynamic> body})>[];
final scoped = JellyfinClient.forTesting(
connection: _conn(),
httpClient: MockClient((request) async {
requests.add((path: request.url.path, body: jsonDecode(request.body) as Map<String, dynamic>));
return http.Response('', 204);
}),
);
addTearDown(scoped.close);
await scoped.reportPlaybackStarted(
itemId: 'channel-1',
position: Duration.zero,
playSessionId: 'play-1',
liveStreamId: 'live-1',
mediaSourceId: 'source-1',
);
await scoped.reportPlaybackProgress(
itemId: 'channel-1',
position: const Duration(seconds: 10),
duration: Duration.zero,
playSessionId: 'play-1',
liveStreamId: 'live-1',
mediaSourceId: 'source-1',
);
await scoped.reportPlaybackStopped(
itemId: 'channel-1',
position: const Duration(seconds: 20),
playSessionId: 'play-1',
liveStreamId: 'live-1',
mediaSourceId: 'source-1',
);
expect(requests.map((request) => request.path), [
'/Sessions/Playing',
'/Sessions/Playing/Progress',
'/Sessions/Playing/Stopped',
]);
for (final request in requests) {
expect(request.body['ItemId'], 'channel-1');
expect(request.body['PlaySessionId'], 'play-1');
expect(request.body['LiveStreamId'], 'live-1');
expect(request.body['MediaSourceId'], 'source-1');
}
});
test('resolveDownload pins direct stream URL and subtitles to selected media source', () async { test('resolveDownload pins direct stream URL and subtitles to selected media source', () async {
final requests = <Uri>[]; final requests = <Uri>[];
String? playbackInfoBody; String? playbackInfoBody;
@@ -1396,6 +1443,8 @@ void main() {
expect(body['EnableTranscoding'], isFalse); expect(body['EnableTranscoding'], isFalse);
expect(resolution, isNotNull); expect(resolution, isNotNull);
expect(resolution!.playSessionId, 'live-session-1'); expect(resolution!.playSessionId, 'live-session-1');
expect(resolution.mediaSourceId, 'source-1');
expect(resolution.liveStreamId, 'open-stream-1');
final uri = Uri.parse(resolution.url); final uri = Uri.parse(resolution.url);
expect(uri.path, '/Videos/channel-1/stream'); expect(uri.path, '/Videos/channel-1/stream');
expect(uri.queryParameters['Static'], 'true'); expect(uri.queryParameters['Static'], 'true');
@@ -1407,6 +1456,38 @@ void main() {
expect(uri.queryParameters['api_key'], 'tok-abc'); expect(uri.queryParameters['api_key'], 'tok-abc');
}); });
test('live TV stream resolution recovers identity from a negotiated direct 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': [
{
'Container': 'ts',
'DirectStreamUrl':
'/Videos/channel-1/stream?MediaSourceId=source-url&LiveStreamId=live-url&PlaySessionId=play-url',
},
],
}),
200,
headers: {'content-type': 'application/json'},
);
}
return http.Response('{}', 404);
}),
);
addTearDown(scoped.close);
final resolution = await scoped.liveTv.resolveStreamUrl('channel-1');
expect(resolution, isNotNull);
expect(resolution!.playSessionId, 'play-url');
expect(resolution.mediaSourceId, 'source-url');
expect(resolution.liveStreamId, 'live-url');
});
test('buildTrickplayTileUrl wires width, sheet index, api_key, and DeviceId', () { test('buildTrickplayTileUrl wires width, sheet index, api_key, and DeviceId', () {
final url = client.buildTrickplayTileUrl('item-99', 320, 4); final url = client.buildTrickplayTileUrl('item-99', 320, 4);
final uri = Uri.parse(url); final uri = Uri.parse(url);
+34 -5
View File
@@ -16,12 +16,13 @@ class _FakeJellyfinClient implements JellyfinClient {
Duration? duration, Duration? duration,
String? playSessionId, String? playSessionId,
String? playMethod, String? playMethod,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
int? audioStreamIndex, int? audioStreamIndex,
int? subtitleStreamIndex, int? subtitleStreamIndex,
}) async { }) async {
await startGate.future; await startGate.future;
calls.add('started:$itemId:$playSessionId'); calls.add('started:$itemId:$playSessionId:$mediaSourceId:$liveStreamId');
} }
@override @override
@@ -32,11 +33,12 @@ class _FakeJellyfinClient implements JellyfinClient {
bool isPaused = false, bool isPaused = false,
String? playSessionId, String? playSessionId,
String? playMethod, String? playMethod,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
int? audioStreamIndex, int? audioStreamIndex,
int? subtitleStreamIndex, int? subtitleStreamIndex,
}) async { }) async {
calls.add('${isPaused ? 'paused' : 'playing'}:$itemId:$playSessionId'); calls.add('${isPaused ? 'paused' : 'playing'}:$itemId:$playSessionId:$mediaSourceId:$liveStreamId');
} }
@override @override
@@ -45,10 +47,11 @@ class _FakeJellyfinClient implements JellyfinClient {
required Duration position, required Duration position,
Duration? duration, Duration? duration,
String? playSessionId, String? playSessionId,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
PlaybackReportMetadata report = const PlaybackReportMetadata.live(), PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
}) async { }) async {
calls.add('stopped:$itemId:$playSessionId'); calls.add('stopped:$itemId:$playSessionId:$mediaSourceId:$liveStreamId');
} }
@override @override
@@ -58,7 +61,11 @@ class _FakeJellyfinClient implements JellyfinClient {
void main() { void main() {
test('coalesces duplicate live starts and orders stop after in-flight start', () async { test('coalesces duplicate live starts and orders stop after in-flight start', () async {
final client = _FakeJellyfinClient(); final client = _FakeJellyfinClient();
final tracker = JellyfinLiveSessionTracker(playSessionId: 'live-session-1'); final tracker = JellyfinLiveSessionTracker(
playSessionId: 'live-session-1',
mediaSourceId: 'source-1',
liveStreamId: 'live-stream-1',
);
final first = tracker.report( final first = tracker.report(
client: client, client: client,
@@ -89,6 +96,28 @@ void main() {
client.startGate.complete(); client.startGate.complete();
await Future.wait([first, second, stopped]); await Future.wait([first, second, stopped]);
expect(client.calls, ['started:channel-1:live-session-1', 'stopped:channel-1:live-session-1']); expect(client.calls, [
'started:channel-1:live-session-1:source-1:live-stream-1',
'stopped:channel-1:live-session-1:source-1:live-stream-1',
]);
});
test('stopping before the first heartbeat still reports the negotiated live identity', () async {
final client = _FakeJellyfinClient();
final tracker = JellyfinLiveSessionTracker(
playSessionId: 'live-session-1',
mediaSourceId: 'source-1',
liveStreamId: 'live-stream-1',
);
await tracker.report(
client: client,
itemId: 'channel-1',
state: 'stopped',
position: Duration.zero,
duration: Duration.zero,
);
expect(client.calls, ['stopped:channel-1:live-session-1:source-1:live-stream-1']);
}); });
} }
@@ -8,6 +8,7 @@ import 'package:plezy/connection/connection.dart';
import 'package:plezy/database/app_database.dart'; import 'package:plezy/database/app_database.dart';
import 'package:plezy/exceptions/media_server_exceptions.dart'; import 'package:plezy/exceptions/media_server_exceptions.dart';
import 'package:plezy/media/ids.dart'; import 'package:plezy/media/ids.dart';
import 'package:plezy/media/live_tv_support.dart';
import 'package:plezy/models/plex/plex_config.dart'; import 'package:plezy/models/plex/plex_config.dart';
import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_api_cache.dart';
@@ -245,6 +246,7 @@ void main() {
expect(session!.program.id, isNull); expect(session!.program.id, isNull);
expect(session.captureBuffer, isNull); expect(session.captureBuffer, isNull);
expect(session.canTimeShift, isFalse); expect(session.canTimeShift, isFalse);
expect(session.backgroundPolicy, LiveTvBackgroundPolicy.stopAndExit);
final url = await session.streamUrlAt(); final url = await session.streamUrlAt();
expect(url, isNotNull); expect(url, isNotNull);
@@ -373,6 +373,7 @@ class FakeMediaServerClient extends Fake implements MediaServerClient {
Duration? duration, Duration? duration,
String? playSessionId, String? playSessionId,
String? playMethod, String? playMethod,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
int? audioStreamIndex, int? audioStreamIndex,
int? subtitleStreamIndex, int? subtitleStreamIndex,
@@ -388,6 +389,7 @@ class FakeMediaServerClient extends Fake implements MediaServerClient {
bool isPaused = false, bool isPaused = false,
String? playSessionId, String? playSessionId,
String? playMethod, String? playMethod,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
int? audioStreamIndex, int? audioStreamIndex,
int? subtitleStreamIndex, int? subtitleStreamIndex,
@@ -401,6 +403,7 @@ class FakeMediaServerClient extends Fake implements MediaServerClient {
required Duration position, required Duration position,
Duration? duration, Duration? duration,
String? playSessionId, String? playSessionId,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
PlaybackReportMetadata report = const PlaybackReportMetadata.live(), PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
}) async { }) async {
@@ -101,6 +101,7 @@ class _RecordingMediaClient implements MediaServerClient {
Duration? duration, Duration? duration,
String? playSessionId, String? playSessionId,
String? playMethod, String? playMethod,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
int? audioStreamIndex, int? audioStreamIndex,
int? subtitleStreamIndex, int? subtitleStreamIndex,
@@ -114,6 +115,7 @@ class _RecordingMediaClient implements MediaServerClient {
required Duration position, required Duration position,
Duration? duration, Duration? duration,
String? playSessionId, String? playSessionId,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
PlaybackReportMetadata report = const PlaybackReportMetadata.live(), PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
}) async { }) async {
@@ -166,6 +166,7 @@ class _FakePlexClient implements PlexClient {
Duration? duration, Duration? duration,
String? playSessionId, String? playSessionId,
String? playMethod, String? playMethod,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
int? audioStreamIndex, int? audioStreamIndex,
int? subtitleStreamIndex, int? subtitleStreamIndex,
@@ -187,6 +188,7 @@ class _FakePlexClient implements PlexClient {
bool isPaused = false, bool isPaused = false,
String? playSessionId, String? playSessionId,
String? playMethod, String? playMethod,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
int? audioStreamIndex, int? audioStreamIndex,
int? subtitleStreamIndex, int? subtitleStreamIndex,
@@ -211,6 +213,7 @@ class _FakePlexClient implements PlexClient {
required Duration position, required Duration position,
Duration? duration, Duration? duration,
String? playSessionId, String? playSessionId,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
PlaybackReportMetadata report = const PlaybackReportMetadata.live(), PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
}) { }) {
@@ -255,6 +258,7 @@ class _DelayedStartClient extends _FakePlexClient {
Duration? duration, Duration? duration,
String? playSessionId, String? playSessionId,
String? playMethod, String? playMethod,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
int? audioStreamIndex, int? audioStreamIndex,
int? subtitleStreamIndex, int? subtitleStreamIndex,
@@ -266,6 +270,7 @@ class _DelayedStartClient extends _FakePlexClient {
duration: duration, duration: duration,
playSessionId: playSessionId, playSessionId: playSessionId,
playMethod: playMethod, playMethod: playMethod,
liveStreamId: liveStreamId,
mediaSourceId: mediaSourceId, mediaSourceId: mediaSourceId,
audioStreamIndex: audioStreamIndex, audioStreamIndex: audioStreamIndex,
subtitleStreamIndex: subtitleStreamIndex, subtitleStreamIndex: subtitleStreamIndex,
@@ -1133,6 +1138,7 @@ class _ScrobblePreciseClient implements PlexClient {
Duration? duration, Duration? duration,
String? playSessionId, String? playSessionId,
String? playMethod, String? playMethod,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
int? audioStreamIndex, int? audioStreamIndex,
int? subtitleStreamIndex, int? subtitleStreamIndex,
@@ -1146,6 +1152,7 @@ class _ScrobblePreciseClient implements PlexClient {
bool isPaused = false, bool isPaused = false,
String? playSessionId, String? playSessionId,
String? playMethod, String? playMethod,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
int? audioStreamIndex, int? audioStreamIndex,
int? subtitleStreamIndex, int? subtitleStreamIndex,
@@ -1157,6 +1164,7 @@ class _ScrobblePreciseClient implements PlexClient {
required Duration position, required Duration position,
Duration? duration, Duration? duration,
String? playSessionId, String? playSessionId,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
PlaybackReportMetadata report = const PlaybackReportMetadata.live(), PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
}) async {} }) async {}
@@ -18,6 +18,7 @@ class _RecordingClient implements MediaServerClient {
Duration? duration, Duration? duration,
String? playSessionId, String? playSessionId,
String? playMethod, String? playMethod,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
int? audioStreamIndex, int? audioStreamIndex,
int? subtitleStreamIndex, int? subtitleStreamIndex,
@@ -35,6 +36,7 @@ class _RecordingClient implements MediaServerClient {
bool isPaused = false, bool isPaused = false,
String? playSessionId, String? playSessionId,
String? playMethod, String? playMethod,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
int? audioStreamIndex, int? audioStreamIndex,
int? subtitleStreamIndex, int? subtitleStreamIndex,
@@ -48,6 +50,7 @@ class _RecordingClient implements MediaServerClient {
required Duration position, required Duration position,
Duration? duration, Duration? duration,
String? playSessionId, String? playSessionId,
String? liveStreamId,
String? mediaSourceId, String? mediaSourceId,
PlaybackReportMetadata report = const PlaybackReportMetadata.live(), PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
}) async { }) async {