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
+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,
);
}
}