part of '../../plex_client.dart'; const _favoriteChannelsUrl = 'https://epg.provider.plex.tv/settings/favoriteChannels'; const _providerVersionHeader = {'X-Plex-Provider-Version': '5.1'}; mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport, LiveTvDvrSupport { PlexConfig get config; MediaServerHttpClient get _http; @override ServerId get serverId; @override String? get serverName; List<({String identifier, String gridEndpoint})> get _providerEpg; Future _getWithFailover( String path, { Map? queryParameters, // ignore: unused_element_parameter Map? headers, // ignore: unused_element_parameter Duration? timeout, // ignore: unused_element_parameter AbortController? abort, bool allowEndpointFailover = true, }); Map? _getMediaContainer(MediaServerResponse response); PlexMetadataDto _createTaggedMetadata(Map json); Future> _wrapListApiCall( Future Function() apiCall, List Function(MediaServerResponse response) parseResponse, String errorMessage, ); /// POST the tune endpoint with one retry on transient HTTP failure. Future _postTuneWithRetry(String path, String sessionIdentifier) async { final query = {'X-Plex-Session-Identifier': sessionIdentifier}; try { return await _http.post(path, queryParameters: query, timeout: MediaServerTimeouts.tune); } on MediaServerHttpException catch (e) { if (!e.isTransient) rethrow; appLogger.w('Tune channel: transient failure, retrying once', error: e); return await _http.post(path, queryParameters: query, timeout: MediaServerTimeouts.tune); } } String? _activityUuid(MediaServerResponse response) => response.headers['x-plex-activity']; List _extractContainerList( MediaServerResponse response, Iterable keys, T Function(Map json) fromJson, ) { final container = _getMediaContainer(response); if (container == null) return const []; for (final key in keys) { final raw = container[key]; final list = flexibleList(raw); if (list == null) continue; return [ for (final item in list) if (item is Map) fromJson(item), ]; } return const []; } T? _extractFirst(MediaServerResponse response, Iterable keys, T Function(Map) fromJson) { final list = _extractContainerList(response, keys, fromJson); return list.firstOrNull; } void _throwIfFailed(MediaServerResponse response) => throwIfHttpError(response); Future _expectOk(Future Function() call) async { final response = await call(); _throwIfFailed(response); } String _withQuery(String path, String query) => query.isEmpty ? path : '$path?$query'; String _subscriptionCreateQuery(MediaSubscriptionCreateRequest request) { final parts = []; final parameters = request.parameters; if (parameters != null && parameters.isNotEmpty) parts.add(parameters); final flat = { 'targetLibrarySectionID': request.targetLibrarySectionID, 'targetSectionLocationID': request.targetSectionLocationID, 'type': request.type, if (request.providers != null) 'providers': request.providers, for (final entry in request.hints.entries) 'hints[${entry.key}]': entry.value, for (final entry in request.prefs.entries) 'prefs[${entry.key}]': entry.value, for (final entry in request.params.entries) 'params[${entry.key}]': entry.value, }; final encoded = MediaServerHttpClient.encodeQueryParameters(flat); if (encoded.isNotEmpty) parts.add(encoded); return parts.join('&'); } Map _prefQuery(String prefix, Map values) => { for (final entry in values.entries) '$prefix[${entry.key}]': entry.value, }; /// Send a live TV timeline heartbeat to keep the transcode session alive. /// /// Returns an updated [CaptureBuffer] if the response contains a /// `TranscodeSession` with seek-range data (used to expand the seekable /// window over time). Future _updateLiveTimeline({ required String ratingKey, required String sessionPath, required String sessionIdentifier, required String state, required int time, required int duration, required int playbackTime, }) async { final response = await _getWithFailover( '/:/timeline', queryParameters: { 'ratingKey': ratingKey, 'key': sessionPath, 'state': state, 'hasMDE': '1', 'time': time, 'duration': duration, 'playbackTime': playbackTime, 'X-Plex-Session-Identifier': sessionIdentifier, }, // A live timeline ping is a transcode-session keepalive, not a general // library fetch. If one ping hits a transient transport/DNS failure, // endpoint failover can close the client held by the active session; the // server then expires the tuner a few minutes later. Let the next // heartbeat retry the current session endpoint instead. allowEndpointFailover: false, ); if (response.statusCode != 200) { appLogger.e('Live timeline returned ${response.statusCode}: ${response.data}'); return null; } // Parse updated capture buffer from TranscodeSession in the response try { final data = response.data; if (data is! Map) return null; final container = data['MediaContainer'] as Map? ?? data; // Try CaptureBuffer wrapper first, then TranscodeSession directly final captureBufferWrapper = container['CaptureBuffer']; if (captureBufferWrapper != null) { final cbMap = captureBufferWrapper is List ? captureBufferWrapper.firstOrNull as Map? : captureBufferWrapper as Map?; if (cbMap != null) { final ts = cbMap['TranscodeSession']; final tsMap = ts is List ? ts.firstOrNull as Map? : ts as Map?; if (tsMap != null) return CaptureBuffer.fromTranscodeSession(tsMap); } } final transcodeSessions = container['TranscodeSession']; if (transcodeSessions is List && transcodeSessions.isNotEmpty) { return CaptureBuffer.fromTranscodeSession(transcodeSessions.first as Map); } else if (transcodeSessions is Map) { return CaptureBuffer.fromTranscodeSession(transcodeSessions); } } catch (e) { // Parsing failure is non-fatal — just no updated seek range } return null; } /// Get all DVR devices configured on this server @override Future> fetchDvrs() async { return _wrapListApiCall(() => _http.get('/livetv/dvrs'), (response) { final container = _getMediaContainer(response); if (container != null && container['Dvr'] != null) { final rootMappings = container['ChannelMapping']; return (container['Dvr'] as List).map((json) { final map = Map.from(json as Map); map['ChannelMapping'] ??= rootMappings; return LiveTvDvr.fromJson(map); }).toList(); } return []; }, 'Failed to get DVRs'); } /// Check if this server has at least one DVR configured Future hasDvr() async { final dvrs = await fetchDvrs(); return dvrs.isNotEmpty; } @override Future> reloadGuide(String dvrId) async { final response = await _http.post('/livetv/dvrs/$dvrId/reloadGuide', timeout: MediaServerTimeouts.receive); _throwIfFailed(response); return LiveTvActivityResult(value: null, activityUuid: _activityUuid(response)); } /// Get EPG channels using provider lineup endpoints (matches official Plex web client) Future> getEpgChannels({String? lineup}) async { List parseChannels(MediaServerResponse response) { final container = _getMediaContainer(response); if (container != null && container['Channel'] is List && (container['Channel'] as List).isNotEmpty) { appLogger.d('EPG channel sample: ${(container['Channel'] as List).first}'); } if (container != null && container['Channel'] != null) { return (container['Channel'] as List) .map( (json) => LiveTvChannel.fromJson( json as Map, ).copyWith(serverId: serverId, serverName: serverName), ) .where((ch) => ch.key.isNotEmpty) .toList(); } if (container != null && container['Metadata'] != null) { return (container['Metadata'] as List) .map( (json) => LiveTvChannel.fromJson( json as Map, ).copyWith(serverId: serverId, serverName: serverName), ) .where((ch) => ch.key.isNotEmpty) .toList(); } appLogger.d('EPG channels: container keys=${container?.keys.toList()}, size=${container?['size']}'); return []; } final allChannels = []; for (final provider in _epgProvidersForLineup(lineup)) { final isCloudGuide = provider.identifier.startsWith('tv.plex.providers.epg'); final legacyEndpoint = '/${provider.identifier}/lineups/dvr/channels'; if (isCloudGuide) { try { final response = await _getWithFailover('/lineups/plex/channels'); final parsed = parseChannels(response); if (parsed.isNotEmpty) { allChannels.addAll(parsed); continue; } } catch (e) { appLogger.d( 'Cloud channel endpoint /lineups/plex/channels unavailable, falling back to $legacyEndpoint', error: e, ); } } try { final response = await _getWithFailover(legacyEndpoint); allChannels.addAll(parseChannels(response)); } catch (e) { appLogger.e('Failed to get EPG channels from ${provider.identifier} via $legacyEndpoint', error: e); } } return allChannels; } /// Return EPG providers (already parsed from /media/providers during initialization) Future> _discoverEpgProviders() async { return _providerEpg; } List<({String identifier, String gridEndpoint})> _epgProvidersForLineup(String? lineup) { if (lineup == null || lineup.isEmpty) return _providerEpg; final matching = _providerEpg.where((p) => p.identifier == lineup || p.gridEndpoint.contains(lineup)).toList(); return matching.isNotEmpty ? matching : _providerEpg; } /// Parse a list of JSON items into [LiveTvProgram] objects, skipping any that fail. /// A single Metadata entry may carry multiple Media entries representing back-to-back /// airings of the same program on the same channel; emit one program per airing. List _parseLiveTvPrograms(List items, {String? providerIdentifier, String? liveDvrKey}) { final programs = []; for (final item in items) { try { final map = item as Map; final mediaList = (map['Media'] as List?)?.whereType>().toList(); if (mediaList != null && mediaList.length > 1) { for (final media in mediaList) { programs.add( LiveTvProgram.fromJson(map, mediaOverride: media).copyWith( serverId: serverId, serverName: serverName, liveDvrKey: liveDvrKey, providerIdentifier: providerIdentifier, ), ); } } else { programs.add( LiveTvProgram.fromJson(map).copyWith( serverId: serverId, serverName: serverName, liveDvrKey: liveDvrKey, providerIdentifier: providerIdentifier, ), ); } } catch (e, st) { appLogger.w('LiveTvProgram parse failed', error: e, stackTrace: st); } } return programs; } /// Get guide/program data for channels (EPG grid data) /// Discovers grid endpoints from /media/providers on first call and queries all providers Future> getEpgGrid({int? beginsAt, int? endsAt}) async { final providers = await _discoverEpgProviders(); if (providers.isEmpty) return []; final queryParams = {}; if (beginsAt != null) queryParams['endsAt>'] = beginsAt; if (endsAt != null) queryParams['beginsAt<'] = endsAt; final allPrograms = []; for (final provider in providers) { try { final programs = await _wrapListApiCall( () => _http.get(provider.gridEndpoint, queryParameters: queryParams), (response) => _parseEpgGridResponse(response, provider.identifier), 'Failed to get EPG grid from ${provider.identifier}', ); appLogger.d('EPG grid from ${provider.identifier}: ${programs.length} programs'); allPrograms.addAll(programs); } catch (e) { appLogger.e('Failed to get EPG grid from provider ${provider.identifier}', error: e); } } return allPrograms; } /// Parse an EPG grid response into a list of [LiveTvProgram] objects. List _parseEpgGridResponse(MediaServerResponse response, String providerIdentifier) { final container = _getMediaContainer(response); if (container != null && container['Metadata'] is List && (container['Metadata'] as List).isNotEmpty) { appLogger.d('EPG grid sample from $providerIdentifier: ${(container['Metadata'] as List).first}'); } final programs = []; if (container != null && container['Metadata'] != null) { programs.addAll(_parseLiveTvPrograms(container['Metadata'] as List, providerIdentifier: providerIdentifier)); } // Some responses nest programs inside Hub entries if (container != null && container['Hub'] != null) { for (final hub in container['Hub'] as List) { if (hub is Map && hub['Metadata'] != null) { programs.addAll(_parseLiveTvPrograms(hub['Metadata'] as List, providerIdentifier: providerIdentifier)); } } } return programs; } /// Get live TV hubs (What's On Now, etc.) from all EPG providers' discover endpoints. /// Returns hubs with both display metadata and EPG timing/channel data per item. Future> getLiveTvHubs({int count = 12}) async { final providers = await _discoverEpgProviders(); if (providers.isEmpty) return []; final allHubs = []; for (final provider in providers) { try { final response = await _getWithFailover( '/${provider.identifier}/hubs/discover', queryParameters: { 'count': count, 'includeStations': 1, 'includeRecentChannels': 1, 'includeMeta': 1, 'includeExternalMetadata': 1, }, ); final container = _getMediaContainer(response); if (container == null || container['Hub'] == null) continue; for (final hubJson in container['Hub'] as List) { final hub = _parseLiveTvHub(hubJson, provider.identifier); if (hub != null) allHubs.add(hub); } } catch (e) { appLogger.e('Failed to get live TV hubs from provider ${provider.identifier}', error: e); } } return allHubs; } /// Parse a single hub JSON object into a [LiveTvHubResult], or null if parsing fails. LiveTvHubResult? _parseLiveTvHub(dynamic hubJson, String providerIdentifier) { try { final metadataList = hubJson['Metadata'] as List?; if (metadataList == null || metadataList.isEmpty) return null; final entries = []; for (final itemJson in metadataList) { if (itemJson is! Map) continue; _extractLiveTvImages(itemJson); final entry = _parseLiveTvHubEntry(itemJson, providerIdentifier); if (entry != null) entries.add(entry); } if (entries.isEmpty) return null; return LiveTvHubResult( title: hubJson['title'] as String? ?? t.liveTv.unknownHub, hubKey: hubJson['key'] as String? ?? '', entries: entries, ); } catch (e) { appLogger.w('Failed to parse live TV hub', error: e); return null; } } /// Parse a single metadata item into a [LiveTvHubEntry], or null if parsing fails. LiveTvHubEntry? _parseLiveTvHubEntry(Map itemJson, String providerIdentifier) { try { final dto = PlexMetadataDto.fromJson(itemJson).copyWith(serverId: serverId, serverName: serverName); final metadata = PlexMappers.mediaItem(dto); final program = LiveTvProgram.fromJson( itemJson, ).copyWith(serverId: serverId, serverName: serverName, providerIdentifier: providerIdentifier); return LiveTvHubEntry(metadata: metadata, program: program); } catch (_) { return null; } } /// Extract poster/art URLs from the Image array in EPG metadata items. /// EPG items often have images only in the Image array (coverPoster, coverArt, etc.) /// rather than in the standard thumb/art fields. void _extractLiveTvImages(Map item) { final images = item['Image'] as List?; if (images == null) return; for (final img in images) { if (img is! Map) continue; final type = img['type'] as String?; final url = img['url'] as String?; if (url == null) continue; switch (type) { case 'coverPoster': // Always prefer coverPoster as thumb for poster display item['thumb'] = url; break; case 'coverArt': item['art'] ??= url; break; case 'background': item['art'] ??= url; break; } } } @override Future> getSubscriptionTemplate(String guid) async { final response = await _getWithFailover('/media/subscriptions/template', queryParameters: {'guid': guid}); return _extractContainerList(response, const ['SubscriptionTemplate'], SubscriptionTemplate.fromJson); } @override Future> fetchRecordingRules({bool includeGrabs = true, bool includeStorage = true}) async { final response = await _getWithFailover( '/media/subscriptions', queryParameters: {'includeGrabs': includeGrabs ? 1 : 0, 'includeStorage': includeStorage ? 1 : 0}, ); return _extractContainerList(response, const ['MediaSubscription'], MediaSubscription.fromJson); } @override Future createRecordingRule(MediaSubscriptionCreateRequest request) async { final response = await _http.post(_withQuery('/media/subscriptions', _subscriptionCreateQuery(request))); _throwIfFailed(response); return _extractFirst(response, const ['MediaSubscription'], MediaSubscription.fromJson); } @override Future updateRecordingRule(String subscriptionId, Map prefs) async { final response = await _http.put( '/media/subscriptions/$subscriptionId', queryParameters: _prefQuery('prefs', prefs), ); _throwIfFailed(response); return _extractFirst(response, const ['MediaSubscription'], MediaSubscription.fromJson); } @override Future deleteRecordingRule(String subscriptionId) => _expectOk(() => _http.delete('/media/subscriptions/$subscriptionId')); @override Future processRecordingRules() => _expectOk(() => _http.post('/media/subscriptions/process')); @override Future> fetchScheduledRecordings() async { final response = await _getWithFailover('/media/subscriptions/scheduled'); return _extractContainerList(response, const ['MediaGrabOperation'], MediaGrabOperation.fromJson); } @override Future cancelGrab(String operationId) { if (operationId.isEmpty) throw ArgumentError.value(operationId, 'operationId', 'must not be empty'); final path = operationId.startsWith('/media/grabbers/operations/') ? operationId : operationId.startsWith('media/grabbers/operations/') ? '/$operationId' : '/media/grabbers/operations/$operationId'; return _expectOk(() => _http.delete(path)); } @override Future> fetchSubscriptionMapping({ required String providerId, required List ratingKeys, bool includeStorage = true, }) async { if (ratingKeys.isEmpty) return const []; final response = await _getWithFailover( '/media/providers/$providerId/media/subscriptions/mapping/${ratingKeys.join(',')}', queryParameters: {'includeStorage': includeStorage ? 1 : 0}, ); return _extractContainerList(response, const ['MediaSubscription'], MediaSubscription.fromJson); } /// Tune to a live TV channel. /// /// POSTs to the tune endpoint and extracts metadata, session info, and /// capture buffer data from the response. Call [_buildLiveStreamPath] after /// to build the actual stream URL (with optional offset for time-shift). Future< ({ PlexMetadataDto metadata, String sessionPath, String sessionIdentifier, CaptureBuffer? captureBuffer, int? beginsAt, })? > _tuneChannel(String dvrKey, String channelIdentifier) async { try { final sessionIdentifier = PlexClient.generateSessionIdentifier(); final response = await _postTuneWithRetry( '/livetv/dvrs/$dvrKey/channels/$channelIdentifier/tune', sessionIdentifier, ); if (response.statusCode >= 400) { appLogger.w('Tune channel returned status ${response.statusCode}'); return null; } final container = _getMediaContainer(response); if (container == null) return null; final containerStatus = container['status']; final statusInt = containerStatus is num ? containerStatus.toInt() : containerStatus is String ? int.tryParse(containerStatus) : null; if (statusInt != null && statusInt != 0 && statusInt != 200) { final msg = container['message'] ?? t.liveTv.unknownError; appLogger.w('Tune channel error: $msg (status: $containerStatus)'); throw Exception(msg); } // Metadata is nested: MediaSubscription[0].MediaGrabOperation[0].Metadata // Both may be a List or single Map depending on the response format. Map? metadataJson; int? beginsAt; final subscriptions = container['MediaSubscription']; final subList = subscriptions is List ? subscriptions : subscriptions is Map ? [subscriptions] : null; if (subList != null && subList.isNotEmpty) { final sub = subList.first as Map; final timeline = sub['Timeline']; // Safely extract the first element if it's a list, or the map itself final op = timeline is List ? (timeline.isNotEmpty ? timeline.first : null) : (timeline is Map ? timeline : null); if (op is Map) { if (op['Metadata'] case [final Map firstMetadata, ...]) { if (firstMetadata['Media'] case [final Map firstMedia, ...]) { final rawBeginsAt = firstMedia['beginsAt']; beginsAt = switch (rawBeginsAt) { final num n => n.toInt(), final String s => int.tryParse(s), _ => null, }; appLogger.d('beginsAt=$beginsAt'); } } } final ops = sub['MediaGrabOperation']; final opList = ops is List ? ops : ops is Map ? [ops] : null; if (opList != null && opList.isNotEmpty) { final op = opList.first as Map; final nested = op['Metadata']; if (nested is Map) { metadataJson = nested; } else if (nested is List && nested.isNotEmpty) { metadataJson = nested.first as Map; } } } if (metadataJson == null) { final fallback = container['Metadata']; if (fallback is List && fallback.isNotEmpty) { metadataJson = fallback.first as Map; } else if (fallback is Map) { metadataJson = fallback; } } if (metadataJson == null) { appLogger.w( 'Tune channel failed: ${container['message'] ?? 'no metadata'} (status: ${container['status']}, keys: ${container.keys.toList()})', ); return null; } // Tune response may return XML-style string values where fromJson expects nums. PlexClient._coerceNumericFields(metadataJson); final metadata = _createTaggedMetadata(metadataJson); final sessionPath = metadataJson['key'] as String?; if (sessionPath == null) { appLogger.w('Tune channel: no session path in metadata key'); return null; } // Extract capture buffer from TranscodeSession. // May be at the container level OR inside the Metadata object. CaptureBuffer? captureBuffer; final tsSource = container['TranscodeSession'] ?? metadataJson['TranscodeSession']; if (tsSource is List && tsSource.isNotEmpty) { captureBuffer = CaptureBuffer.fromTranscodeSession(tsSource.first as Map); } else if (tsSource is Map) { captureBuffer = CaptureBuffer.fromTranscodeSession(tsSource); } // beginsAt may also be on the Media items (not just the GrabOperation) // This value is the start of the requested stream, not the current program. So it will effectively be the current time if (beginsAt == null) { final media = metadataJson['Media']; if (media is List && media.isNotEmpty) { final firstMedia = media.first; if (firstMedia is Map) { final rawBeginsAt = firstMedia['beginsAt']; beginsAt = switch (rawBeginsAt) { final num n => n.toInt(), final String s => int.tryParse(s), _ => null, }; } } } return ( metadata: metadata, sessionPath: sessionPath, sessionIdentifier: sessionIdentifier, captureBuffer: captureBuffer, beginsAt: beginsAt, ); } catch (e, st) { appLogger.e('Failed to tune channel', error: e, stackTrace: st); return null; } } /// Build a live TV HLS stream URL (decision + start path). /// /// [sessionPath] and [sessionIdentifier] come from [_tuneChannel]. /// [transcodeSessionId] should be reused across seeks within the same /// viewing session so the server reuses its capture buffer. /// [offsetSeconds] positions the stream at that many seconds from the /// capture buffer origin (for time-shift / watch-from-start). Future _buildLiveStreamPath({ required String sessionPath, required String sessionIdentifier, required String transcodeSessionId, int? offsetSeconds, bool directStream = true, bool directStreamAudio = true, }) async { try { final allParams = { 'hasMDE': '1', 'path': sessionPath, 'mediaIndex': '0', 'partIndex': '0', 'protocol': _plexVideoHlsProtocol, 'fastSeek': '1', 'directPlay': '0', 'directStream': directStream ? '1' : '0', 'subtitleSize': '100', 'audioBoost': '100', 'location': 'lan', 'addDebugOverlay': '0', 'autoAdjustQuality': '0', 'directStreamAudio': directStreamAudio ? '1' : '0', 'advancedSubtitles': 'text', 'mediaBufferSize': '157286', 'session': transcodeSessionId, 'subtitles': 'auto', 'copyts': '0', 'Accept-Language': 'en', 'X-Plex-Session-Identifier': sessionIdentifier, 'X-Plex-Client-Profile-Extra': _buildPlexHlsClientProfileExtra(), 'X-Plex-Incomplete-Segments': '1', 'X-Plex-Product': config.product, 'X-Plex-Version': config.version, 'X-Plex-Client-Identifier': config.clientIdentifier, 'X-Plex-Platform': 'Generic', 'X-Plex-Client-Profile-Name': 'Generic', if (offsetSeconds != null) 'offset': offsetSeconds.toString(), if (config.token != null) 'X-Plex-Token': config.token!, }; // Manual query encoding — use '%20' for spaces as Plex requires. final queryString = allParams.entries .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') .join('&'); // 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'}, ); final decisionUrl = '${config.baseUrl}$_plexVideoTranscodeBaseEndpoint/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 '$_plexVideoHlsStartEndpoint?$startQuery'; } catch (e, st) { appLogger.e('Failed to build live stream path', error: e, stackTrace: st); return null; } } /// Compose a fully-qualified live stream URL from a relative /// [streamPath] (returned by [_buildLiveStreamPath]) by prefixing the /// configured base URL and appending the Plex token. Centralizes the /// `'${config.baseUrl}$streamPath'.withPlexToken(config.token)` pattern /// so token placement / base-URL handling lives in one place. String _buildLiveStreamUrl(String streamPath) { return '${config.baseUrl}$streamPath'.withPlexToken(config.token); } /// Build the source URI for favorite channels: `server://{machineIdentifier}/{providerIdentifier}` @override Future buildFavoriteChannelSource({String? lineup}) async { final providers = _epgProvidersForLineup(lineup); final providerIdentifier = providers.isNotEmpty ? providers.first.identifier : 'tv.plex.provider.epg'; final machineId = config.machineIdentifier ?? serverId; return 'server://$machineId/$providerIdentifier'; } /// Get favorite channels from the Plex cloud. @override Future> fetchFavoriteChannels() async { final response = await _http.get(_favoriteChannelsUrl, headers: _providerVersionHeader); _throwIfFailed(response); final container = _getMediaContainer(response); if (container == null) { throw const FormatException('Plex favorite-channel response is missing MediaContainer'); } final rows = container['FavoriteChannel']; if (rows == null) return const []; if (rows is! List) { throw const FormatException('Plex FavoriteChannel must be a list'); } return rows.map((json) => FavoriteChannel.fromJson(json as Map)).toList(); } /// Update favorite channels on the Plex cloud. @override Future setFavoriteChannels(List channels) async { try { await _expectOk( () => _http.put( _favoriteChannelsUrl, body: channels.map((c) => c.toJson()).toList(), headers: _providerVersionHeader, ), ); } catch (e, stackTrace) { appLogger.e('Failed to update favorite channels', error: e, stackTrace: stackTrace); rethrow; } } @override LiveTvSupport get liveTv => this as LiveTvSupport; @override LiveTvDvrSupport get dvr => this as LiveTvDvrSupport; @override Future isAvailable() => hasDvr(); @override Future> fetchChannels({String? lineup}) => getEpgChannels(lineup: lineup); @override Future> fetchSchedule({DateTime? from, DateTime? to}) { int? toEpoch(DateTime? dt) => dt == null ? null : dt.millisecondsSinceEpoch ~/ 1000; return getEpgGrid(beginsAt: toEpoch(from), endsAt: toEpoch(to)); } @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(this as PlexClient, dvrKey: dvrKey, channelKey: channelKey); } @override String get favoriteStoreKey => 'plex:${config.clientIdentifier}'; @override FavoriteChannelPersistenceMode get favoritePersistenceMode => FavoriteChannelPersistenceMode.sharedFullList; } /// 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 LiveTvBackgroundPolicy get backgroundPolicy => LiveTvBackgroundPolicy.retainSession; @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, ); } }