From 23a423fe85622cdab44aee697aa95bb77c062587 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 12 Jun 2026 16:06:12 +0200 Subject: [PATCH] refactor(livetv): playback session lifecycle behind LiveTvSupport --- lib/media/live_tv_support.dart | 75 +++++- lib/services/jellyfin_client.dart | 2 + .../jellyfin_client/parts/live_tv.dart | 49 ++++ lib/services/plex_client/parts/live_tv.dart | 184 +++++++++++--- .../live_tv_playback_session_test.dart | 234 ++++++++++++++++++ 5 files changed, 511 insertions(+), 33 deletions(-) create mode 100644 test/services/live_tv_playback_session_test.dart diff --git a/lib/media/live_tv_support.dart b/lib/media/live_tv_support.dart index 188025d3..6393d065 100644 --- a/lib/media/live_tv_support.dart +++ b/lib/media/live_tv_support.dart @@ -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 { 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 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 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 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 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 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` diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index 6b731853..69364ed9 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -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'; diff --git a/lib/services/jellyfin_client/parts/live_tv.dart b/lib/services/jellyfin_client/parts/live_tv.dart index c8c54e17..01bbb140 100644 --- a/lib/services/jellyfin_client/parts/live_tv.dart +++ b/lib/services/jellyfin_client/parts/live_tv.dart @@ -211,6 +211,13 @@ class _JellyfinLiveTvSupport implements LiveTvSupport { return LiveTvStreamResolution(url: url, playSessionId: playSessionId); } + @override + Future 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? 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 streamUrlAt({int? offsetSeconds}) async => offsetSeconds == null ? _url : null; + + @override + Future 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 recover({required bool directStream, required bool directStreamAudio}) async => this; +} diff --git a/lib/services/plex_client/parts/live_tv.dart b/lib/services/plex_client/parts/live_tv.dart index fd605d4d..509cc437 100644 --- a/lib/services/plex_client/parts/live_tv.dart +++ b/lib/services/plex_client/parts/live_tv.dart @@ -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.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.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 resolveStreamUrl(String channelKey, {String? dvrKey}) async => null; + @override + Future 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 buildFavoriteChannelSource({String? lineup}) => _client.buildFavoriteChannelSource(lineup: lineup); @@ -1390,3 +1398,117 @@ class _PlexLiveTvSupport implements LiveTvSupport { Uri buildNotificationEventSourceUri({List? 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 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 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 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, + ); + } +} diff --git a/test/services/live_tv_playback_session_test.dart b/test/services/live_tv_playback_session_test.dart new file mode 100644 index 00000000..da913434 --- /dev/null +++ b/test/services/live_tv_playback_session_test.dart @@ -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 body) => + http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'}); + + group('Plex live playback session', () { + Map 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 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 = []; + 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? 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)); + }); + }); +}