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