refactor(livetv): playback session lifecycle behind LiveTvSupport

This commit is contained in:
edde746
2026-06-12 16:06:12 +02:00
parent b2cb874b73
commit 23a423fe85
5 changed files with 511 additions and 33 deletions
+73 -2
View File
@@ -1,3 +1,4 @@
import '../models/livetv_capture_buffer.dart';
import '../models/livetv_channel.dart';
import '../models/livetv_dvr.dart';
import '../models/livetv_lineup.dart';
@@ -16,6 +17,70 @@ class LiveTvActivityResult<T> {
const LiveTvActivityResult({required this.value, this.activityUuid});
}
/// Program info captured when a live session starts. Plex's tune response
/// carries the airing program; Jellyfin streams the channel without a
/// program-scoped session, so its sessions report [none].
class LiveProgramInfo {
/// Program identifier for timeline reporting (Plex program ratingKey).
final String? id;
final int? durationMs;
/// Program start, epoch seconds.
final int? beginsAt;
const LiveProgramInfo({this.id, this.durationMs, this.beginsAt});
static const none = LiveProgramInfo();
}
/// One live-TV playback session, produced by [LiveTvSupport.startPlayback].
///
/// This is the backend-neutral handle the player drives; the
/// `client is PlexClient` branches that used to live in the player's live
/// methods are the per-backend implementations of this interface:
///
/// - **Plex** tunes a DVR transcode session ([captureBuffer] non-null when
/// the server has seekable history) and rebuilds its stream URL for
/// time-shift; heartbeats go to `/:/timeline` and return capture-buffer
/// updates.
/// - **Jellyfin** negotiates one direct stream URL up front; no time-shift,
/// heartbeats go through `/Sessions/Playing*`, and [recover] re-uses the
/// same URL.
///
/// Sessions are immutable handles: every operation that changes the playable
/// stream returns a URL or a fresh session for the caller to adopt, so the
/// player's runtime state has a single adoption point.
abstract class LiveTvPlaybackSession {
LiveProgramInfo get program;
/// Seekable-history snapshot from session start. Heartbeats may return
/// fresher ones ([reportTimeline]); the caller owns tracking the current
/// value.
CaptureBuffer? get captureBuffer;
/// Whether [streamUrlAt] supports a non-null offset.
bool get canTimeShift;
/// Build the playable stream URL. [offsetSeconds] positions the stream
/// that many seconds from the capture-buffer origin — watch-from-start and
/// time-shift seek are the same operation; `null` plays the live edge.
/// Returns `null` on failure, or when an offset is requested but
/// unsupported.
Future<String?> streamUrlAt({int? offsetSeconds});
/// Send a playback heartbeat (`'playing'` / `'paused'` / `'stopped'`).
/// [positionMs] is elapsed playback time; [durationMs] the program
/// duration when known. Returns an updated capture buffer when the backend
/// supplies one, null otherwise.
Future<CaptureBuffer?> reportTimeline({required String state, required int positionMs, required int durationMs});
/// Re-establish playback after stream death. Plex re-tunes (the previous
/// capture session expires while the player exhausts its reconnect
/// attempts) applying the degradation flags; Jellyfin returns itself —
/// the session-less URL is simply re-opened. Returns `null` on failure.
Future<LiveTvPlaybackSession?> recover({required bool directStream, required bool directStreamAudio});
}
enum FavoriteChannelPersistenceMode {
/// A single write replaces the full backend account's favorite list.
sharedFullList,
@@ -68,10 +133,16 @@ abstract class LiveTvSupport {
/// Resolve a playable stream URL for [channelKey].
///
/// Jellyfin returns a negotiated stream URL plus the play session id. Plex
/// returns `null` because its stream URL is only valid after a `tuneChannel`
/// call; the player's Plex branch uses `client + dvrKey` instead.
/// returns `null` because its stream URL is only valid after a tune;
/// playback callers use [startPlayback], which owns that difference.
Future<LiveTvStreamResolution?> resolveStreamUrl(String channelKey, {String? dvrKey});
/// Start a playback session for [channelKey] — the single entry the player
/// uses for initial launch and channel switching. Plex requires [dvrKey]
/// (tune + transcode-session setup); Jellyfin ignores it and negotiates a
/// direct stream URL. Returns `null` when the channel can't be started.
Future<LiveTvPlaybackSession?> startPlayback(String channelKey, {String? dvrKey});
/// Source URI to stamp into [FavoriteChannel] entries. Plex uses
/// `server://{machineId}/{providerId}` so its cloud-synced favorites are
/// keyed per EPG provider. Jellyfin uses `server://{serverId}/jellyfin`
+2
View File
@@ -9,6 +9,7 @@ import '../media/library_filter_result.dart';
import '../media/library_first_character.dart';
import '../media/library_query.dart';
import 'favorite_channels_repository.dart';
import 'live_session_tracker.dart';
import 'file_info_parser.dart';
import 'library_query_translator.dart';
import '../media/media_filter.dart';
@@ -25,6 +26,7 @@ import '../media/media_server_client.dart';
import '../media/playback_report_metadata.dart';
import '../media/server_capabilities.dart';
import '../models/jellyfin/jellyfin_user_profile.dart';
import '../models/livetv_capture_buffer.dart';
import '../models/livetv_channel.dart';
import '../models/livetv_dvr.dart';
import '../models/livetv_lineup.dart';
@@ -211,6 +211,13 @@ class _JellyfinLiveTvSupport implements LiveTvSupport {
return LiveTvStreamResolution(url: url, playSessionId: playSessionId);
}
@override
Future<LiveTvPlaybackSession?> startPlayback(String channelKey, {String? dvrKey}) async {
final resolution = await resolveStreamUrl(channelKey, dvrKey: dvrKey);
if (resolution == null) return null;
return _JellyfinLiveTvPlaybackSession(_client, channelKey, resolution);
}
/// SharedPreferences key for the locally-persisted favorite-channel list.
/// Keyed by the compound connection id (`{machineId}/{userId}`) so two
/// Jellyfin users on the same server don't share favorites.
@@ -448,3 +455,45 @@ class _JellyfinLiveTvSupport implements LiveTvSupport {
@override
Uri buildNotificationEventSourceUri({List<String>? filters}) => _unsupportedSync();
}
/// A Jellyfin live playback session: one negotiated direct-stream URL plus
/// `/Sessions/Playing*` heartbeats via [JellyfinLiveSessionTracker]. No
/// program-scoped session and no time-shift — [recover] re-opens the same
/// session-less URL.
class _JellyfinLiveTvPlaybackSession implements LiveTvPlaybackSession {
final JellyfinClient _client;
final String _channelKey;
final String _url;
final JellyfinLiveSessionTracker _tracker;
_JellyfinLiveTvPlaybackSession(this._client, this._channelKey, LiveTvStreamResolution resolution)
: _url = resolution.url,
_tracker = JellyfinLiveSessionTracker(playSessionId: resolution.playSessionId);
@override
LiveProgramInfo get program => LiveProgramInfo.none;
@override
CaptureBuffer? get captureBuffer => null;
@override
bool get canTimeShift => false;
@override
Future<String?> streamUrlAt({int? offsetSeconds}) async => offsetSeconds == null ? _url : null;
@override
Future<CaptureBuffer?> reportTimeline({required String state, required int positionMs, required int durationMs}) async {
await _tracker.report(
client: _client,
itemId: _channelKey,
state: state,
position: Duration(milliseconds: positionMs),
duration: Duration(milliseconds: durationMs),
);
return null;
}
@override
Future<LiveTvPlaybackSession?> recover({required bool directStream, required bool directStreamAudio}) async => this;
}
+153 -31
View File
@@ -1003,40 +1003,39 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}')
.join('&');
// Decision — separate client so no default X-Plex-* HTTP headers leak through.
// Decision — wrapper around the same transport so no default X-Plex-*
// HTTP headers leak through (everything travels in the query string).
// Not closed: the underlying client is owned by `_http`.
final decisionClient = MediaServerHttpClient(
client: _http.inner,
connectTimeout: MediaServerTimeouts.connect,
receiveTimeout: MediaServerTimeouts.receive,
defaultHeaders: {'Accept-Language': 'en'},
);
try {
final decisionUrl = '${config.baseUrl}/video/:/transcode/universal/decision?$queryString';
final decisionResponse = await decisionClient.get(decisionUrl);
final decisionUrl = '${config.baseUrl}/video/:/transcode/universal/decision?$queryString';
final decisionResponse = await decisionClient.get(decisionUrl);
if (decisionResponse.statusCode != 200) {
appLogger.w('Decision returned ${decisionResponse.statusCode}');
return null;
}
// Log decision response for diagnostics (the web client parses this XML
// to extract generalDecisionCode, mdeDecisionCode, transcodeDecisionCode).
final decisionBody = decisionResponse.data?.toString() ?? '';
if (decisionBody.isNotEmpty) {
appLogger.d(
'Decision response: ${decisionBody.length > 500 ? '${decisionBody.substring(0, 500)}...' : decisionBody}',
);
}
// Token is added by the caller via .withPlexToken()
final startParams = Map<String, String>.from(allParams)..remove('X-Plex-Token');
final startQuery = startParams.entries
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}')
.join('&');
return '/video/:/transcode/universal/start?$startQuery';
} finally {
decisionClient.close();
if (decisionResponse.statusCode != 200) {
appLogger.w('Decision returned ${decisionResponse.statusCode}');
return null;
}
// Log decision response for diagnostics (the web client parses this XML
// to extract generalDecisionCode, mdeDecisionCode, transcodeDecisionCode).
final decisionBody = decisionResponse.data?.toString() ?? '';
if (decisionBody.isNotEmpty) {
appLogger.d(
'Decision response: ${decisionBody.length > 500 ? '${decisionBody.substring(0, 500)}...' : decisionBody}',
);
}
// Token is added by the caller via .withPlexToken()
final startParams = Map<String, String>.from(allParams)..remove('X-Plex-Token');
final startQuery = startParams.entries
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}')
.join('&');
return '/video/:/transcode/universal/start?$startQuery';
} catch (e, st) {
appLogger.e('Failed to build live stream path', error: e, stackTrace: st);
return null;
@@ -1153,10 +1152,10 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin {
}
/// Plex implementation of [LiveTvSupport] — wraps the existing per-DVR
/// methods. The legacy `tuneChannel` / `buildLiveStreamPath` flow remains on
/// [PlexClient] itself because the player consumes those rich session
/// outputs directly; [resolveStreamUrl] returns `null` so callers route
/// through `client + dvrKey`.
/// methods. The `tuneChannel` / `buildLiveStreamPath` protocol flow lives on
/// [PlexClient]; [startPlayback] packages it behind the backend-neutral
/// [LiveTvPlaybackSession], and [resolveStreamUrl] returns `null` because a
/// Plex stream URL is only valid inside a tuned session.
class _PlexLiveTvSupport implements LiveTvSupport {
final PlexClient _client;
_PlexLiveTvSupport(this._client);
@@ -1179,6 +1178,15 @@ class _PlexLiveTvSupport implements LiveTvSupport {
@override
Future<LiveTvStreamResolution?> resolveStreamUrl(String channelKey, {String? dvrKey}) async => null;
@override
Future<LiveTvPlaybackSession?> startPlayback(String channelKey, {String? dvrKey}) {
if (dvrKey == null) {
appLogger.w('Plex live playback requires a dvrKey to tune $channelKey');
return Future.value(null);
}
return _PlexLiveTvPlaybackSession.start(_client, dvrKey: dvrKey, channelKey: channelKey);
}
@override
Future<String> buildFavoriteChannelSource({String? lineup}) => _client.buildFavoriteChannelSource(lineup: lineup);
@@ -1390,3 +1398,117 @@ class _PlexLiveTvSupport implements LiveTvSupport {
Uri buildNotificationEventSourceUri({List<String>? filters}) =>
_client.buildNotificationEventSourceUri(filters: filters);
}
/// A tuned Plex DVR transcode session. Holds the tune outputs
/// (`sessionPath` / `sessionIdentifier`) plus the `transcodeSessionId` that
/// must be reused across time-shift rebuilds so the server reuses its
/// capture buffer.
class _PlexLiveTvPlaybackSession implements LiveTvPlaybackSession {
final PlexClient _client;
final String _dvrKey;
final String _channelKey;
final String _sessionPath;
final String _sessionIdentifier;
final String _transcodeSessionId;
/// Degradation flags are session state (a recovered session keeps its
/// degraded profile for every URL it builds), not per-call options.
final bool _directStream;
final bool _directStreamAudio;
@override
final LiveProgramInfo program;
@override
final CaptureBuffer? captureBuffer;
_PlexLiveTvPlaybackSession._(
this._client,
this._dvrKey,
this._channelKey,
this._sessionPath,
this._sessionIdentifier,
this._transcodeSessionId,
this._directStream,
this._directStreamAudio, {
required this.program,
required this.captureBuffer,
});
/// Tune [channelKey] on [dvrKey]. The stream URL is built lazily via
/// [streamUrlAt] so a watch-from-start decision between tune and first
/// open doesn't cost an extra transcode-decision round-trip.
static Future<_PlexLiveTvPlaybackSession?> start(
PlexClient client, {
required String dvrKey,
required String channelKey,
bool directStream = true,
bool directStreamAudio = true,
}) async {
final tuneResult = await client.tuneChannel(dvrKey, channelKey);
if (tuneResult == null) return null;
return _PlexLiveTvPlaybackSession._(
client,
dvrKey,
channelKey,
tuneResult.sessionPath,
tuneResult.sessionIdentifier,
PlexClient.generateSessionIdentifier(),
directStream,
directStreamAudio,
program: LiveProgramInfo(
id: tuneResult.metadata.ratingKey,
durationMs: tuneResult.metadata.duration,
beginsAt: tuneResult.beginsAt,
),
captureBuffer: tuneResult.captureBuffer,
);
}
@override
bool get canTimeShift => captureBuffer != null;
@override
Future<String?> streamUrlAt({int? offsetSeconds}) async {
final streamPath = await _client.buildLiveStreamPath(
sessionPath: _sessionPath,
sessionIdentifier: _sessionIdentifier,
transcodeSessionId: _transcodeSessionId,
offsetSeconds: offsetSeconds,
directStream: _directStream,
directStreamAudio: _directStreamAudio,
);
return streamPath == null ? null : _client.buildLiveStreamUrl(streamPath);
}
@override
Future<CaptureBuffer?> reportTimeline({required String state, required int positionMs, required int durationMs}) {
// Plex rejects timeline pings where time > duration; grow duration to
// match — otherwise Tunarr-style short synthetic programs 400 mid-stream.
final duration = durationMs >= positionMs ? durationMs : positionMs;
return _client.updateLiveTimeline(
// The program ratingKey from tune metadata, not the channel key.
ratingKey: program.id ?? _channelKey,
sessionPath: _sessionPath,
sessionIdentifier: _sessionIdentifier,
state: state,
time: positionMs,
duration: duration,
playbackTime: positionMs,
);
}
@override
Future<LiveTvPlaybackSession?> recover({required bool directStream, required bool directStreamAudio}) {
// Re-tune for a fresh capture session — the previous one expires while
// the player exhausts its reconnect attempts.
return start(
_client,
dvrKey: _dvrKey,
channelKey: _channelKey,
directStream: directStream,
directStreamAudio: directStreamAudio,
);
}
}
@@ -0,0 +1,234 @@
import 'dart:convert';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/database/app_database.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/models/plex/plex_config.dart';
import 'package:plezy/services/jellyfin_client.dart';
import 'package:plezy/services/plex_api_cache.dart';
import 'package:plezy/services/plex_client.dart';
/// Pins the [LiveTvPlaybackSession] lifecycle on both backends — the
/// per-backend protocol that used to be hand-rolled (3×) inside the player's
/// live methods: tune → lazy stream URL, time-shift offsets reusing the
/// transcode session, the Tunarr duration-grow guard on heartbeats, and
/// recover-with-degradation.
void main() {
late AppDatabase db;
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
PlexApiCache.initialize(db);
});
tearDown(() async {
await db.close();
});
http.Response jsonResponse(Map<String, dynamic> body) =>
http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'});
group('Plex live playback session', () {
Map<String, dynamic> tuneResponse() => {
'MediaContainer': {
'MediaSubscription': [
{
'MediaGrabOperation': [
{
'Metadata': {
'ratingKey': 'prog-1',
'key': '/livetv/sessions/session-abc',
'type': 'clip',
'duration': 1800000,
'Media': [
{'beginsAt': '1700000000'},
],
},
},
],
},
],
'TranscodeSession': [
{'timeStamp': '1700000100', 'minOffsetAvailable': '0', 'maxOffsetAvailable': '120'},
],
},
};
PlexClient makeClient(Future<http.Response> Function(http.Request request) handler) => PlexClient.forTesting(
config: PlexConfig(
baseUrl: 'https://plex.example.com',
token: 'tok',
clientIdentifier: 'client',
product: 'Plezy',
version: '1',
machineIdentifier: 'machine-1',
),
serverId: ServerId('machine-1'),
httpClient: MockClient(handler),
);
test('startPlayback without a dvrKey returns null (tune requires a DVR)', () async {
final client = makeClient((request) async => fail('no request expected'));
addTearDown(client.close);
expect(await client.liveTv.startPlayback('ch-1'), isNull);
});
test('startPlayback tunes and exposes program + capture buffer; URL is built lazily', () async {
final requests = <String>[];
final client = makeClient((request) async {
requests.add(request.url.path);
if (request.url.path.endsWith('/tune')) return jsonResponse(tuneResponse());
return jsonResponse(const {});
});
addTearDown(client.close);
final session = await client.liveTv.startPlayback('ch-1', dvrKey: 'dvr-1');
expect(session, isNotNull);
expect(session!.program.id, 'prog-1');
expect(session.program.durationMs, 1800000);
expect(session.program.beginsAt, 1700000000);
expect(session.captureBuffer, isNotNull);
expect(session.canTimeShift, isTrue);
// Tune only — no transcode decision until the caller asks for a URL
// (a watch-from-start dialog sits between the two).
expect(requests, ['/livetv/dvrs/dvr-1/channels/ch-1/tune']);
});
test('streamUrlAt builds live-edge and offset URLs against one transcode session', () async {
final client = makeClient((request) async {
if (request.url.path.endsWith('/tune')) return jsonResponse(tuneResponse());
if (request.url.path == '/video/:/transcode/universal/decision') return http.Response('ok', 200);
return jsonResponse(const {});
});
addTearDown(client.close);
final session = (await client.liveTv.startPlayback('ch-1', dvrKey: 'dvr-1'))!;
final liveEdge = await session.streamUrlAt();
final shifted = await session.streamUrlAt(offsetSeconds: 90);
expect(liveEdge, isNotNull);
final liveEdgeUri = Uri.parse(liveEdge!);
expect(liveEdgeUri.path, '/video/:/transcode/universal/start');
expect(liveEdgeUri.queryParameters['path'], '/livetv/sessions/session-abc');
expect(liveEdgeUri.queryParameters['X-Plex-Token'], 'tok');
expect(liveEdgeUri.queryParameters.containsKey('offset'), isFalse);
final shiftedUri = Uri.parse(shifted!);
expect(shiftedUri.queryParameters['offset'], '90');
// Same transcode session across rebuilds so the server reuses its
// capture buffer.
expect(shiftedUri.queryParameters['session'], liveEdgeUri.queryParameters['session']);
});
test('reportTimeline targets the tuned program and grows duration to the position', () async {
Map<String, String>? timelineQuery;
final client = makeClient((request) async {
if (request.url.path.endsWith('/tune')) return jsonResponse(tuneResponse());
if (request.url.path == '/:/timeline') {
timelineQuery = request.url.queryParameters;
return jsonResponse({
'MediaContainer': {
'TranscodeSession': [
{'timeStamp': '1700000100', 'minOffsetAvailable': '0', 'maxOffsetAvailable': '300'},
],
},
});
}
return jsonResponse(const {});
});
addTearDown(client.close);
final session = (await client.liveTv.startPlayback('ch-1', dvrKey: 'dvr-1'))!;
// Position past the program duration — Plex 400s when time > duration
// (Tunarr-style short synthetic programs), so duration must grow.
final updated = await session.reportTimeline(state: 'playing', positionMs: 2000000, durationMs: 1800000);
expect(timelineQuery!['ratingKey'], 'prog-1');
expect(timelineQuery!['key'], '/livetv/sessions/session-abc');
expect(timelineQuery!['state'], 'playing');
expect(timelineQuery!['time'], '2000000');
expect(timelineQuery!['duration'], '2000000');
expect(updated, isNotNull);
expect(updated!.seekableDurationSeconds, 300);
});
test('recover re-tunes and the fresh session builds degraded URLs', () async {
var tunes = 0;
final client = makeClient((request) async {
if (request.url.path.endsWith('/tune')) {
tunes++;
return jsonResponse(tuneResponse());
}
if (request.url.path == '/video/:/transcode/universal/decision') return http.Response('ok', 200);
return jsonResponse(const {});
});
addTearDown(client.close);
final session = (await client.liveTv.startPlayback('ch-1', dvrKey: 'dvr-1'))!;
final recovered = await session.recover(directStream: false, directStreamAudio: false);
expect(tunes, 2);
final url = await recovered!.streamUrlAt();
final uri = Uri.parse(url!);
expect(uri.queryParameters['directStream'], '0');
expect(uri.queryParameters['directStreamAudio'], '0');
});
});
group('Jellyfin live playback session', () {
JellyfinConnection conn() => JellyfinConnection(
id: 'srv-1/user-1',
baseUrl: 'https://jf.example.com',
serverName: 'Home',
serverMachineId: 'srv-1',
userId: 'user-1',
userName: 'edde',
accessToken: 'tok-abc',
deviceId: 'dev-xyz',
createdAt: DateTime.fromMillisecondsSinceEpoch(0),
);
test('startPlayback negotiates one direct URL; no time-shift; recover reuses it', () async {
final client = JellyfinClient.forTesting(
connection: conn(),
httpClient: MockClient((request) async {
if (request.url.path.contains('PlaybackInfo')) {
return jsonResponse({
'PlaySessionId': 'play-1',
'MediaSources': [
{'Id': 'source-1', 'Container': 'ts', 'LiveStreamId': 'live-1'},
],
});
}
return jsonResponse(const {});
}),
);
addTearDown(client.close);
final session = await client.liveTv.startPlayback('channel-1');
expect(session, isNotNull);
expect(session!.program.id, isNull);
expect(session.captureBuffer, isNull);
expect(session.canTimeShift, isFalse);
final url = await session.streamUrlAt();
expect(url, isNotNull);
expect(Uri.parse(url!).path, contains('/Videos/channel-1'));
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.
expect(await session.recover(directStream: false, directStreamAudio: false), same(session));
});
});
}