refactor(livetv): playback session lifecycle behind LiveTvSupport
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user