import 'dart:async'; import '../utils/isolate_helper.dart'; import '../utils/json_utils.dart'; import 'dart:math'; import 'package:flutter/foundation.dart'; import '../utils/plex_http_client.dart'; import '../utils/plex_http_exception.dart'; import '../models/livetv_capture_buffer.dart'; import '../models/livetv_channel.dart'; import '../models/livetv_dvr.dart'; import '../models/livetv_hub_result.dart'; import '../models/livetv_program.dart'; import '../models/plex_activity.dart'; import '../models/plex_config.dart'; import '../models/play_queue_response.dart'; import '../models/plex_file_info.dart'; import '../models/plex_filter.dart'; import '../models/plex_first_character.dart'; import '../models/plex_hub.dart'; import '../models/plex_library.dart'; import '../models/plex_media_info.dart'; import '../models/plex_subtitle_search_result.dart'; import '../models/plex_media_version.dart'; import '../models/plex_match_result.dart'; import '../models/plex_metadata.dart'; import '../utils/content_utils.dart'; import '../models/plex_playlist.dart'; import '../models/plex_sort.dart'; import '../models/plex_video_playback_data.dart'; import '../models/transcode_quality_preset.dart'; import '../utils/endpoint_failover_interceptor.dart'; import '../utils/app_logger.dart'; import '../utils/connection_constants.dart'; import '../utils/log_redaction_manager.dart'; import '../utils/plex_cache_parser.dart'; import '../utils/plex_url_helper.dart'; import '../utils/watch_state_notifier.dart'; import 'plex_api_cache.dart'; /// Result of a paginated library content fetch class LibraryContentResult { final List items; final int totalSize; const LibraryContentResult({required this.items, required this.totalSize}); } /// Process hub response in an isolate. /// Top-level function so it can be passed to [Isolate.run]. List _processHubResponse( Map decoded, String serverId, String? serverName, { bool Function(PlexMetadata)? filter, }) { final container = decoded['MediaContainer'] as Map?; if (container == null || container['Hub'] == null) return []; final itemFilter = filter ?? (PlexMetadata item) => item.isVideoContent; final hubs = []; for (final hubJson in container['Hub'] as List) { try { final hub = PlexHub.fromJson(hubJson as Map, serverId: serverId, serverName: serverName); if (hub.items.isEmpty) continue; final filteredItems = hub.items.where(itemFilter).toList(); if (filteredItems.isNotEmpty) { hubs.add( PlexHub( hubKey: hub.hubKey, title: hub.title, type: hub.type, hubIdentifier: hub.hubIdentifier, size: hub.size, more: hub.more, items: filteredItems, serverId: serverId, serverName: serverName, ), ); } } catch (_) { // Skip hubs that fail to parse } } return hubs; } /// Constants for Plex stream types class PlexStreamType { static const int video = 1; static const int audio = 2; static const int subtitle = 3; } /// Result of testing a connection, including success status and latency class ConnectionTestResult { final bool success; final int latencyMs; final String? error; /// `transcoderVideo` from the `/` MediaContainer, captured on successful /// probes so the connection race doubles as a capability probe. `null` /// when the probe didn't succeed or the field was absent. final bool? transcoderVideo; ConnectionTestResult({required this.success, required this.latencyMs, this.error, this.transcoderVideo}); } class PlexClient { PlexConfig config; late final PlexHttpClient _http; final EndpointFailoverManager? _endpointManager; final Future Function(String newBaseUrl)? _onEndpointChanged; final VoidCallback? _onAllEndpointsExhausted; /// Server identifier - all PlexMetadata items created by this client are tagged with this final String serverId; /// Server name - all PlexMetadata items created by this client are tagged with this final String? serverName; /// API response cache for offline support final PlexApiCache _cache = PlexApiCache.instance; /// Whether to operate in offline mode (use cache only) bool _offlineMode = false; /// Cached result of [serverSupportsVideoTranscoding]. `null` = not yet fetched. bool? _serverTranscoderCached; /// In-flight probe for [serverSupportsVideoTranscoding], used to dedupe /// concurrent callers (e.g. the post-connect warm-up racing the first /// playback). Future? _serverTranscoderPending; /// Libraries parsed from /media/providers (includes individually shared items) late final List _providerLibraries; /// EPG providers parsed from /media/providers late final List<({String identifier, String gridEndpoint})> _providerEpg; /// Server-level preferences fetched from /:/prefs Map _serverPrefs = {}; /// Get all fetched server preferences Map get serverPrefs => Map.unmodifiable(_serverPrefs); /// Get the server's watched threshold percentage (default 90) int get watchedThresholdPercent { final value = _serverPrefs['LibraryVideoPlayedThreshold']; if (value is int) return value; if (value is String) return int.tryParse(value) ?? 90; return 90; } /// Set offline mode - when true, only cached responses are returned void setOfflineMode(bool offline) { _offlineMode = offline; } /// Get current offline mode state bool get isOfflineMode => _offlineMode; /// Create a fully initialized PlexClient. /// Fetches /media/providers to discover libraries (including individually shared items) and EPG providers. static Future create( PlexConfig config, { required String serverId, String? serverName, List? prioritizedEndpoints, Future Function(String newBaseUrl)? onEndpointChanged, VoidCallback? onAllEndpointsExhausted, bool? seedTranscoderVideoSupport, }) async { final client = PlexClient._( config, serverId: serverId, serverName: serverName, prioritizedEndpoints: prioritizedEndpoints, onEndpointChanged: onEndpointChanged, onAllEndpointsExhausted: onAllEndpointsExhausted, ); if (seedTranscoderVideoSupport != null) { client._serverTranscoderCached = seedTranscoderVideoSupport; } await client._initMediaProviders(); // If the connection race didn't seed the capability, warm the cache in // the background so the first playback doesn't pay the probe cost on its // hot path. if (seedTranscoderVideoSupport == null) { unawaited(client.serverSupportsVideoTranscoding()); } return client; } PlexClient._( this.config, { required this.serverId, this.serverName, List? prioritizedEndpoints, Future Function(String newBaseUrl)? onEndpointChanged, VoidCallback? onAllEndpointsExhausted, }) : _endpointManager = (prioritizedEndpoints != null && prioritizedEndpoints.isNotEmpty) ? EndpointFailoverManager(prioritizedEndpoints) : null, _onEndpointChanged = onEndpointChanged, _onAllEndpointsExhausted = onAllEndpointsExhausted { LogRedactionManager.registerServerUrl(config.baseUrl); LogRedactionManager.registerToken(config.token); _http = PlexHttpClient( baseUrl: config.baseUrl, defaultHeaders: config.headers, connectTimeout: ConnectionTimeouts.connect, receiveTimeout: ConnectionTimeouts.receive, ); } void close() { _http.close(); } bool _failoverSwitching = false; /// Execute a GET request with endpoint failover retry. On timeout/connection /// errors the next endpoint is tried (once). Non-GET methods are not retried. Future _getWithFailover( String path, { Map? queryParameters, Map? headers, Duration? timeout, AbortController? abort, }) async { final gen = _endpointManager?.generation; try { final response = await _http.get( path, queryParameters: queryParameters, headers: headers, timeout: timeout, abort: abort, ); throwIfHttpError(response); return response; } on PlexHttpException catch (e) { if (!_shouldAttemptFailover(e) || _failoverSwitching || _endpointManager == null || gen != _endpointManager.generation) { rethrow; } if (!_endpointManager.hasFallback) { _endpointManager.resetToFirst(); _onAllEndpointsExhausted?.call(); rethrow; } final failedEndpoint = _endpointManager.current; final nextBaseUrl = _endpointManager.moveToNext(); if (nextBaseUrl == null) rethrow; _failoverSwitching = true; try { appLogger.i( 'Switching Plex endpoint after GET failure', error: {'from': failedEndpoint, 'to': nextBaseUrl, 'path': path}, ); await _handleEndpointSwitch(nextBaseUrl, persist: false); final response = await _http.get( path, queryParameters: queryParameters, headers: headers, timeout: timeout, abort: abort, ); throwIfHttpError(response); appLogger.i('Endpoint failover retry succeeded', error: {'newEndpoint': nextBaseUrl}); await _onEndpointChanged?.call(nextBaseUrl); return response; } finally { _failoverSwitching = false; } } } bool _shouldAttemptFailover(PlexHttpException e) { if (e.isTransient) return true; final sc = e.statusCode; return sc != null && sc >= 500 && sc <= 599; } /// 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: ConnectionTimeouts.tune); } on PlexHttpException catch (e) { if (!e.isTransient) rethrow; appLogger.w('Tune channel: transient failure, retrying once', error: e); return await _http.post(path, queryParameters: query, timeout: ConnectionTimeouts.tune); } } /// Fetch /media/providers and parse libraries + EPG providers from the response. /// This discovers individually shared items that don't appear in /library/sections. Future _initMediaProviders() async { try { final response = await _getWithFailover('/media/providers'); final container = _getMediaContainer(response); if (container == null) { _providerLibraries = []; _providerEpg = []; return; } final providers = container['MediaProvider'] as List?; if (providers == null) { _providerLibraries = []; _providerEpg = []; return; } // Parse libraries from the library provider final libraries = []; final epg = <({String identifier, String gridEndpoint})>[]; for (final provider in providers) { if (provider is! Map) continue; final identifier = provider['identifier'] as String?; if (identifier == null) continue; final features = provider['Feature'] as List?; if (features == null) continue; // Library provider — extract directories as libraries if (identifier == 'com.plexapp.plugins.library') { for (final feature in features) { if (feature is! Map) continue; if (feature['type'] != 'content') continue; final directories = feature['Directory'] as List?; if (directories == null) continue; for (final dir in directories) { try { if (dir is! Map) continue; // Skip entries without id (Home hub) and playlists final id = dir['id']?.toString(); if (id == null) continue; if (dir['type'] == 'playlist') continue; final isNumericId = int.tryParse(id) != null; final isSharedLibrary = !isNumericId && dir['key']?.toString().startsWith('/library/shared') == true; // Skip non-numeric IDs unless it's a shared library if (!isNumericId && !isSharedLibrary) continue; // Set key = id so downstream code gets a plain section ID (e.g. "1" or "shared") final json = Map.from(dir); json['key'] = id; libraries.add( PlexLibrary.fromJson( json, ).copyWith(serverId: serverId, serverName: serverName, isShared: isSharedLibrary), ); } catch (e) { appLogger.w('Failed to parse media provider directory entry', error: e); } } } } // EPG provider — extract grid endpoints final protocols = provider['protocols'] as String?; if (protocols != null && protocols.contains('livetv')) { for (final feature in features) { if (feature is! Map) continue; if (feature['type'] == 'grid') { final gridEndpoint = feature['key'] as String?; if (gridEndpoint != null) { epg.add((identifier: identifier, gridEndpoint: gridEndpoint)); appLogger.d('Discovered EPG provider: $identifier (grid: $gridEndpoint)'); } } } } } _providerLibraries = libraries; _providerEpg = epg; appLogger.d('Media providers: ${libraries.length} libraries, ${epg.length} EPG provider(s)'); } catch (e) { appLogger.w('Failed to fetch /media/providers, will fall back to /library/sections', error: e); _providerLibraries = []; _providerEpg = []; } } /// Update endpoint priority list and optionally hop to the new best endpoint. Future updateEndpointPreferences(List prioritizedEndpoints, {bool switchToFirst = false}) async { if (_endpointManager == null || prioritizedEndpoints.isEmpty) { return; } final targetBaseUrl = switchToFirst ? prioritizedEndpoints.first : config.baseUrl; _endpointManager.reset(prioritizedEndpoints, currentBaseUrl: targetBaseUrl); if (switchToFirst && targetBaseUrl != config.baseUrl) { await _handleEndpointSwitch(targetBaseUrl); } } /// Test connection to a specific URL with token and measure latency static Future testConnectionWithLatency( String baseUrl, String token, { Duration timeout = const Duration(seconds: 5), String? clientIdentifier, }) async { final stopwatch = Stopwatch()..start(); PlexHttpClient? client; try { client = PlexHttpClient(baseUrl: baseUrl, connectTimeout: timeout, receiveTimeout: timeout); final headers = {'X-Plex-Token': token}; if (clientIdentifier != null) { headers['X-Plex-Client-Identifier'] = clientIdentifier; headers['X-Plex-Product'] = 'Plezy'; headers['X-Plex-Device-Name'] = 'Plezy'; } final response = await client.get('/', headers: headers); stopwatch.stop(); final success = response.statusCode == 200; bool? transcoderVideo; if (success && response.data is Map && response.data['MediaContainer'] is Map) { transcoderVideo = flexibleBool((response.data['MediaContainer'] as Map)['transcoderVideo']); } return ConnectionTestResult( success: success, latencyMs: stopwatch.elapsedMilliseconds, error: success ? null : 'HTTP ${response.statusCode}', transcoderVideo: transcoderVideo, ); } on PlexHttpException catch (e) { stopwatch.stop(); final label = switch (e.type) { PlexHttpErrorType.connectionTimeout => 'Connection timeout', PlexHttpErrorType.receiveTimeout => 'Receive timeout', PlexHttpErrorType.connectionError => 'Connection error', _ => e.type.name, }; final message = e.message?.trim() ?? ''; var error = message.isEmpty ? label : '$label: $message'; if (e.statusCode != null) { error += ' (HTTP ${e.statusCode})'; } return ConnectionTestResult(success: false, latencyMs: stopwatch.elapsedMilliseconds, error: error); } catch (e) { stopwatch.stop(); return ConnectionTestResult(success: false, latencyMs: stopwatch.elapsedMilliseconds, error: e.toString()); } finally { client?.close(); } } /// Test connection multiple times and return average latency static Future testConnectionWithAverageLatency( String baseUrl, String token, { int attempts = 3, Duration timeout = const Duration(seconds: 5), String? clientIdentifier, }) async { final results = []; for (int i = 0; i < attempts; i++) { final result = await testConnectionWithLatency( baseUrl, token, timeout: timeout, clientIdentifier: clientIdentifier, ); // If any attempt fails, return failed result immediately if (!result.success) { return ConnectionTestResult(success: false, latencyMs: result.latencyMs); } results.add(result); } // Calculate average latency from successful attempts final avgLatency = results.fold(0, (sum, result) => sum + result.latencyMs) ~/ results.length; return ConnectionTestResult(success: true, latencyMs: avgLatency); } // ============================================================================ // API Response Parsing Helpers // ============================================================================ /// Extract MediaContainer from API response Map? _getMediaContainer(PlexResponse response) { if (response.data is Map && response.data.containsKey('MediaContainer')) { return response.data['MediaContainer']; } return null; } /// Tag a PlexMetadata with this client's serverId and serverName PlexMetadata _tagMetadata(PlexMetadata metadata) => metadata.copyWith(serverId: serverId, serverName: serverName); /// Create and tag a PlexMetadata from JSON PlexMetadata _createTaggedMetadata(Map json) => _tagMetadata(PlexMetadata.fromJson(json)); /// Extract list of PlexMetadata from response /// Automatically tags all items with this client's serverId and serverName List _extractMetadataList(PlexResponse response) { final container = _getMediaContainer(response); if (container != null && container['Metadata'] != null) { return (container['Metadata'] as List).map((json) => _createTaggedMetadata(json)).toList(); } return []; } /// Extract first metadata JSON from response (returns raw Map or null) Map? _getFirstMetadataJson(PlexResponse response) { final container = _getMediaContainer(response); if (container != null && container['Metadata'] != null && (container['Metadata'] as List).isNotEmpty) { return container['Metadata'][0] as Map; } return null; } /// Generic helper to extract and map Directory list from response List _extractDirectoryList(PlexResponse response, T Function(Map) fromJson) { final container = _getMediaContainer(response); if (container != null && container['Directory'] != null) { return (container['Directory'] as List).map((json) => fromJson(json as Map)).toList(); } return []; } /// Extract PlexLibrary list from response with auto-tagging List _extractLibraryList(PlexResponse response) { final container = _getMediaContainer(response); if (container != null && container['Directory'] != null) { return (container['Directory'] as List) .map( (json) => PlexLibrary.fromJson(json as Map).copyWith(serverId: serverId, serverName: serverName), ) .toList(); } return []; } /// Extract PlexPlaylist list from response with auto-tagging List _extractPlaylistList(PlexResponse response) { final container = _getMediaContainer(response); if (container != null && container['Metadata'] != null) { return (container['Metadata'] as List) .map( (json) => PlexPlaylist.fromJson( json as Map, ).copyWith(serverId: serverId, serverName: serverName), ) .toList(); } return []; } // ============================================================================ // API Methods // ============================================================================ /// Get server identity Future> getServerIdentity() async { final response = await _getWithFailover('/identity'); return response.data; } /// Check if the server connection is healthy (reachable AND authenticated). /// Returns true only if the server responds with HTTP 200. Future isHealthy() async { try { final response = await _getWithFailover('/identity'); return response.statusCode == 200; } catch (e) { return false; } } /// Get running background tasks (thumbnail generation, credit detection, etc.) Future> getActivities() async { try { final response = await _getWithFailover('/activities'); final container = _getMediaContainer(response); if (container == null) return []; final activityList = container['Activity'] as List?; if (activityList == null) return []; return activityList.map((json) => PlexActivity.fromJson(json as Map)).toList(); } catch (e) { appLogger.e('Failed to get activities', error: e); return []; } } /// Cancel a running background task by its UUID. Future cancelActivity(String uuid) async { await _http.delete('/activities/$uuid'); } /// Get library sections /// Returns libraries automatically tagged with this client's serverId and serverName. /// Prefers /media/providers data (includes individually shared items), /// falls back to /library/sections for old servers. Future> getLibraries() async { if (_providerLibraries.isNotEmpty) return _providerLibraries; // Fallback for old servers that don't support /media/providers final response = await _getWithFailover('/library/sections'); return _extractLibraryList(response); } /// Get library content by section ID Future getLibraryContent( String sectionId, { int? start, int? size, Map? filters, AbortController? abort, }) async { final queryParams = _buildPaginationParams(start, size); if (filters != null) queryParams.addAll(filters); final endpoint = sectionId == 'shared' ? '/library/shared/all' : '/library/sections/$sectionId/all'; final response = await _getWithFailover(endpoint, queryParameters: queryParams, abort: abort); return _extractLibraryContentResult(response); } Map _buildPaginationParams(int? start, int? size) { final params = {}; if (start != null) params['X-Plex-Container-Start'] = start; if (size != null) params['X-Plex-Container-Size'] = size; return params; } LibraryContentResult _extractLibraryContentResult(PlexResponse response) { final items = _extractMetadataList(response); final container = _getMediaContainer(response); final totalSize = container?['totalSize'] as int? ?? container?['size'] as int? ?? items.length; return LibraryContentResult(items: items, totalSize: totalSize); } Future _fetchPaginatedList(String path, {int? start, int? size, AbortController? abort}) async { final response = await _getWithFailover(path, queryParameters: _buildPaginationParams(start, size), abort: abort); return _extractLibraryContentResult(response); } /// Parse list of PlexMetadata from a cached response List _parseMetadataListFromCachedResponse(Map cached) { final metadataList = PlexCacheParser.extractMetadataList(cached); if (metadataList != null) { return metadataList.map((json) => _createTaggedMetadata(json)).toList(); } return []; } /// Get the server's machine identifier Future getMachineIdentifier() async { try { final response = await _getWithFailover('/'); final container = _getMediaContainer(response); if (container == null) return null; return container['machineIdentifier'] as String?; } catch (e) { appLogger.e('Failed to get machine identifier', error: e); return null; } } /// Build a proper metadata URI for adding to playlists /// Returns URI in format: server://{machineId}/com.plexapp.plugins.library/library/metadata/{ratingKey} Future buildMetadataUri(String ratingKey) async { // Use cached machine identifier from config if available final machineId = config.machineIdentifier ?? await getMachineIdentifier(); if (machineId == null) { throw Exception('Could not get server machine identifier'); } return 'server://$machineId/com.plexapp.plugins.library/library/metadata/$ratingKey'; } /// Build a server URI from a folder key for play queue creation. /// Folder keys are like `/library/sections/1/folder?parent=123`. Future buildFolderUri(String folderKey) async { final machineId = config.machineIdentifier ?? await getMachineIdentifier(); if (machineId == null) { throw Exception('Could not get server machine identifier'); } return 'server://$machineId/com.plexapp.plugins.library$folderKey'; } /// Get metadata by rating key with images (includes clearLogo and OnDeck) /// Uses cache when offline or as fallback on network error /// Note: OnDeck data is not relevant for offline mode /// Always fetches with chapters/markers but caches at base endpoint Future> getMetadataWithImagesAndOnDeck(String ratingKey) async { // Cache key is always the base endpoint (no query params) final cacheKey = '/library/metadata/$ratingKey'; // Special handling needed for OnDeck - can't use simple _fetchWithCacheFallback // because OnDeck is only available from network response, not cache return await _fetchWithCacheFallback>( cacheKey: cacheKey, networkCall: () => _http.get( '/library/metadata/$ratingKey', queryParameters: {'includeChapters': 1, 'includeMarkers': 1, 'includeOnDeck': 1}, ), parseCache: (cachedData) { final metadata = _parseMetadataWithImagesFromCachedResponse(cachedData); final firstMetadata = PlexCacheParser.extractFirstMetadata(cachedData); final playbackData = parseVideoPlaybackDataFromJson(firstMetadata); return {'metadata': metadata, 'onDeckEpisode': null, 'playbackData': playbackData}; }, parseResponse: (response) { PlexMetadata? metadata; PlexMetadata? onDeckEpisode; final metadataJson = _getFirstMetadataJson(response); if (metadataJson != null) { metadata = _tagMetadata(PlexMetadata.fromJsonWithImages(metadataJson)); // Check if OnDeck is nested inside Metadata if (metadataJson.containsKey('OnDeck') && metadataJson['OnDeck'] != null) { final onDeckData = metadataJson['OnDeck']; // OnDeck can be either a Map with 'Metadata' key or direct metadata if (onDeckData is Map && onDeckData.containsKey('Metadata')) { final onDeckMetadata = onDeckData['Metadata']; if (onDeckMetadata != null) { onDeckEpisode = _createTaggedMetadata(onDeckMetadata); } } } } // Parse playback data from the same response — zero extra network cost final playbackData = parseVideoPlaybackDataFromJson(metadataJson); return {'metadata': metadata, 'onDeckEpisode': onDeckEpisode, 'playbackData': playbackData}; }, ) ?? {'metadata': null, 'onDeckEpisode': null, 'playbackData': null}; } /// Get metadata by rating key with images (includes clearLogo) /// Uses cache when offline or as fallback on network error /// Always fetches with chapters/markers but caches at base endpoint Future getMetadataWithImages(String ratingKey) async { // Cache key is always the base endpoint (no query params) final cacheKey = '/library/metadata/$ratingKey'; return _fetchWithCacheFallback( cacheKey: cacheKey, networkCall: () => _http.get('/library/metadata/$ratingKey', queryParameters: {'includeChapters': 1, 'includeMarkers': 1}), parseCache: (cachedData) => _parseMetadataWithImagesFromCachedResponse(cachedData), parseResponse: (response) { final metadataJson = _getFirstMetadataJson(response); return metadataJson != null ? _tagMetadata(PlexMetadata.fromJsonWithImages(metadataJson)) : null; }, ); } /// Parse PlexMetadata with images from a cached response PlexMetadata? _parseMetadataWithImagesFromCachedResponse(Map cached) { final firstMetadata = PlexCacheParser.extractFirstMetadata(cached); if (firstMetadata != null) { return _tagMetadata(PlexMetadata.fromJsonWithImages(firstMetadata)); } return null; } /// Generic cache-network-fallback helper for fetching data /// /// This method implements the standard pattern used throughout the client: /// 1. If offline mode is enabled, return cached data only /// 2. Otherwise, try network request first /// 3. If network succeeds and cacheResponse is true, cache the response /// 4. If network fails, fall back to cached data /// 5. If no cached data available, rethrow the network error /// Fetch data with cache fallback for offline mode and network errors. /// /// Use this to get fresh data when cross-device sync is needed. Future _fetchWithCacheFallback({ required String cacheKey, required Future Function() networkCall, required T? Function(dynamic cachedData) parseCache, required T? Function(PlexResponse response) parseResponse, bool cacheResponse = true, }) async { if (_offlineMode) { final cached = await _cache.get(serverId, cacheKey); if (cached != null) return parseCache(cached); return null; } try { final response = await networkCall(); if (cacheResponse) await _cacheResponseData(cacheKey, response.data); return parseResponse(response); } catch (e) { // On forceRefresh, still try cache as last resort on network error appLogger.w('Network request failed for $cacheKey, trying cache', error: e); final cached = await _cache.get(serverId, cacheKey); if (cached != null) return parseCache(cached); rethrow; } } /// Fetch data with cache checked first, network only on cache miss. /// /// Use this when fresh data is not critical and prior fetches likely /// already populated the cache (e.g. playback after visiting detail screen). Future _fetchWithCacheFirst({ required String cacheKey, required Future Function() networkCall, required T? Function(dynamic cachedData) parseCache, required T? Function(PlexResponse response) parseResponse, bool cacheResponse = true, }) async { final cached = await _cache.get(serverId, cacheKey); if (cached != null) return parseCache(cached); if (_offlineMode) return null; final response = await networkCall(); if (cacheResponse) await _cacheResponseData(cacheKey, response.data); return parseResponse(response); } Future _cacheResponseData(String cacheKey, dynamic data) async { if (data is Map) { await _cache.put(serverId, cacheKey, data); } else if (data != null) { appLogger.w('Unexpected response type for $cacheKey: ${data.runtimeType}'); } } /// Get first metadata JSON from response data Map? _getFirstMetadataJsonFromData(Map? data) => PlexCacheParser.extractFirstMetadata(data); /// Wraps an API call that returns a boolean success status Future _wrapBoolApiCall(Future Function() apiCall, String errorMessage) async { try { final response = await apiCall(); return response.statusCode == 200; } catch (e) { appLogger.e(errorMessage, error: e); return false; } } /// Wraps an API call that returns a list, returning empty list on error Future> _wrapListApiCall( Future Function() apiCall, List Function(PlexResponse response) parseResponse, String errorMessage, ) async { try { final response = await apiCall(); return parseResponse(response); } catch (e) { appLogger.e(errorMessage, error: e); return []; } } /// Page size for iterating all items via [_fetchAllPages]. Also the cap /// for endpoints that send `X-Plex-Container-Size` but aren't truly paginated /// (collections listing, playlists listing, search). static const int _defaultListContainerSize = 1000; /// Page size used when walking all pages of a paginated endpoint. static const int _fetchAllPageSize = 200; /// Iterate every page of a paginated endpoint and concatenate the results. /// Stops as soon as [LibraryContentResult.totalSize] is reached or a page /// returns no items. Errors propagate. Future> _fetchAllPages( Future Function(int start, int size, AbortController? abort) fetchPage, { AbortController? abort, }) async { final all = []; var start = 0; while (true) { final page = await fetchPage(start, _fetchAllPageSize, abort); all.addAll(page.items); start += page.items.length; if (page.items.isEmpty) break; if (start >= page.totalSize) break; } return all; } /// Parse audio/subtitle tracks and the video stream's frame rate from a /// raw Part.Stream list in a single pass. ({List audio, List subtitles, double? frameRate}) _parseStreams( List? streams, ) { final audioTracks = []; final subtitleTracks = []; double? frameRate; if (streams == null) return (audio: audioTracks, subtitles: subtitleTracks, frameRate: frameRate); for (final stream in streams) { final streamType = stream['streamType'] as int?; if (streamType == PlexStreamType.video) { frameRate ??= (stream['frameRate'] as num?)?.toDouble(); } else if (streamType == PlexStreamType.audio) { audioTracks.add( PlexAudioTrack( id: stream['id'] as int, index: stream['index'] as int?, codec: stream['codec'] as String?, language: stream['language'] as String?, languageCode: stream['languageCode'] as String?, title: stream['title'] as String?, displayTitle: stream['displayTitle'] as String?, channels: stream['channels'] as int?, selected: flexibleBool(stream['selected']), ), ); } else if (streamType == PlexStreamType.subtitle) { subtitleTracks.add( PlexSubtitleTrack( id: stream['id'] as int, index: stream['index'] as int?, codec: stream['codec'] as String?, language: stream['language'] as String?, languageCode: stream['languageCode'] as String?, title: stream['title'] as String?, displayTitle: stream['displayTitle'] as String?, selected: flexibleBool(stream['selected']), forced: flexibleBool(stream['forced']), key: stream['key'] as String?, ), ); } } return (audio: audioTracks, subtitles: subtitleTracks, frameRate: frameRate); } /// Parse chapters from metadata JSON List _parseChapters(Map? metadataJson) { if (metadataJson == null || metadataJson['Chapter'] == null) { return []; } final chapterList = metadataJson['Chapter'] as List; return chapterList.map((chapter) { return PlexChapter( id: chapter['id'] as int, index: chapter['index'] as int?, startTimeOffset: chapter['startTimeOffset'] as int?, endTimeOffset: chapter['endTimeOffset'] as int?, title: chapter['tag'] as String? ?? chapter['title'] as String?, thumb: chapter['thumb'] as String?, ); }).toList(); } /// Parse markers from metadata JSON List _parseMarkers(Map? metadataJson) { if (metadataJson == null || metadataJson['Marker'] == null) { return []; } final markerList = metadataJson['Marker'] as List; return markerList.map((marker) { return PlexMarker( id: marker['id'] as int, type: marker['type'] as String, startTimeOffset: marker['startTimeOffset'] as int, endTimeOffset: marker['endTimeOffset'] as int, ); }).toList(); } /// Set per-media language preferences (audio and subtitle) /// For TV shows, use grandparentRatingKey to set preference for the entire series /// For movies, use the movie's ratingKey Future setMetadataPreferences(String ratingKey, {String? audioLanguage, String? subtitleLanguage}) async { final queryParams = {}; if (audioLanguage != null) { queryParams['audioLanguage'] = audioLanguage; } if (subtitleLanguage != null) { queryParams['subtitleLanguage'] = subtitleLanguage; } // If no preferences to set, return early if (queryParams.isEmpty) { return true; } return _wrapBoolApiCall( () => _http.put('/library/metadata/$ratingKey/prefs', queryParameters: queryParams), 'Failed to set metadata preferences', ); } /// Select specific audio and subtitle streams for playback /// This updates which streams are "selected" in the media metadata /// Uses the part ID from media info for accurate stream selection Future selectStreams(int partId, {int? audioStreamID, int? subtitleStreamID, bool allParts = true}) async { final queryParams = {}; if (audioStreamID != null) { queryParams['audioStreamID'] = audioStreamID; } if (subtitleStreamID != null) { queryParams['subtitleStreamID'] = subtitleStreamID; } if (allParts) { // If no streams to select, return early if (queryParams.isEmpty) { return true; } // Use PUT request on /library/parts/{partId} return _wrapBoolApiCall( () => _http.put('/library/parts/$partId', queryParameters: queryParams), 'Failed to select streams', ); } // Si allParts est false, retourner true ou false explicitement (selon la logique souhaitée) // Ici, on retourne true par défaut si rien n'est fait return true; } /// Search for subtitles from external providers (e.g. OpenSubtitles) via the Plex server. /// [language] is an ISO 639-1 two-letter code (e.g. "en", "es"). Future> searchSubtitles( String ratingKey, { required String language, String? title, int hearingImpaired = 0, int forced = 0, }) async { return _wrapListApiCall( () => _http.get( '/library/metadata/$ratingKey/subtitles', queryParameters: { 'language': language, if (title != null && title.isNotEmpty) 'title': title, 'hearingImpaired': hearingImpaired, 'forced': forced, }, ), (response) { final container = _getMediaContainer(response); final streams = container?['Stream'] as List? ?? []; return streams.map((s) => PlexSubtitleSearchResult.fromJson(s as Map)).toList(); }, 'Failed to search subtitles', ); } /// Download a subtitle from an external provider and add it to the media item. /// The server downloads the file asynchronously; the new stream appears after a short delay. Future downloadSubtitle( String ratingKey, { required String key, required String codec, required String language, required bool hearingImpaired, required bool forced, required String providerTitle, }) async { return _wrapBoolApiCall( () => _http.put( '/library/metadata/$ratingKey/subtitles', queryParameters: { 'key': key, 'codec': codec, 'language': language, 'hearingImpaired': hearingImpaired ? 1 : 0, 'forced': forced ? 1 : 0, 'providerTitle': providerTitle, }, ), 'Failed to download subtitle', ); } /// Search across all libraries including individually shared items. /// Uses /library/search (same endpoint as Plex Web) which finds shared content. /// Only returns movies and shows, filtering out other types. Future> search(String query, {int limit = 30}) async { final response = await _getWithFailover( '/library/search', queryParameters: { 'query': query, 'limit': limit, 'searchTypes': 'movies,tv', 'includeCollections': 1, 'includeExternalMedia': 1, 'X-Plex-Container-Size': limit, }, ); final results = []; final container = _getMediaContainer(response); if (container == null) return results; final searchResults = container['SearchResult'] as List?; if (searchResults == null) return results; for (final result in searchResults) { try { if (result is! Map) continue; final metadata = result['Metadata']; if (metadata is! Map) continue; final type = metadata['type'] as String?; if (type != 'movie' && type != 'show') continue; results.add(_createTaggedMetadata(metadata)); } catch (e) { appLogger.w('Failed to parse search result', error: e); } } return results; } /// Get recently added media (filtered to video content only) Future> getRecentlyAdded({int limit = 50}) async { final response = await _getWithFailover( '/library/recentlyAdded', queryParameters: {'X-Plex-Container-Size': limit, 'includeGuids': 1}, ); final allItems = _extractMetadataList(response); // Filter out music content (artists, albums, tracks) return allItems.where((item) => !item.isMusicContent).toList(); } /// Get continue watching items via the hubs system. /// Uses /hubs?identifier=home.continue,home.ondeck which respects the /// server's OnDeckWindow preference (unlike /library/onDeck). Future> getContinueWatching({int count = 20}) async { final response = await _getWithFailover( '/hubs', queryParameters: {'identifier': 'home.continue,home.ondeck', 'count': count, 'includeGuids': 1}, ); final sid = serverId; final sname = serverName; final hubs = await tryIsolateRun(() => _processHubResponse(response.data as Map, sid, sname)); // Deduplicate across home.continue and home.ondeck hubs. // Like plex-web, episodes from the same show (same grandparentRatingKey) // are deduplicated, preferring the in-progress item (has viewOffset). final items = hubs.expand((hub) => hub.items).toList(); final result = []; for (final item in items) { final isEpisode = item.type?.toLowerCase() == 'episode'; final gpKey = item.grandparentRatingKey; if (isEpisode && gpKey != null) { final idx = result.indexWhere((e) => e.type?.toLowerCase() == 'episode' && e.grandparentRatingKey == gpKey); if (idx != -1) { if (result[idx].viewOffset == null && item.viewOffset != null) { result[idx] = item; } continue; } } result.add(item); } return result; } /// Get children of a metadata item (e.g., seasons for a show, episodes for a season) /// Uses cache when offline or as fallback on network error Future> getChildren(String ratingKey) async { final endpoint = '/library/metadata/$ratingKey/children'; return await _fetchWithCacheFallback>( cacheKey: endpoint, networkCall: () => _http.get(endpoint), parseCache: (cachedData) => _parseMetadataListFromCachedResponse(cachedData), parseResponse: (response) => _extractMetadataList(response), ) ?? []; } /// Get extras for a metadata item (trailers, behind-the-scenes, etc.) /// Uses cache when offline or as fallback on network error Future> getExtras(String ratingKey) async { final endpoint = '/library/metadata/$ratingKey/extras'; return await _fetchWithCacheFallback>( cacheKey: endpoint, networkCall: () => _http.get(endpoint), parseCache: (cachedData) => _parseMetadataListFromCachedResponse(cachedData), parseResponse: (response) => _extractMetadataList(response), ) ?? []; } /// Get thumbnail URL String getThumbnailUrl(String? thumbPath) { if (thumbPath == null || thumbPath.isEmpty) return ''; return _http.buildUri(thumbPath).toString().withPlexToken(config.token); } /// Download the full BIF (Base Index Frames) file for a given part. /// Returns the raw bytes, or null on failure. Future downloadBifFile(int partId) async { try { final bytes = await _http.getBytes( '${_http.baseUrl}/library/parts/$partId/indexes/sd', timeout: const Duration(seconds: 30), ); if (bytes.isNotEmpty) return bytes; return null; } catch (_) { return null; } } /// Get chapters and markers from cached metadata or fetch if needed /// Uses same cache key as other metadata methods for consistency Future getPlaybackExtras( String ratingKey, { String? introPattern, String? creditsPattern, bool forceRefresh = false, }) async { try { final fetch = forceRefresh ? _fetchWithCacheFallback : _fetchWithCacheFirst; final data = await fetch>( cacheKey: '/library/metadata/$ratingKey', networkCall: () => _http.get('/library/metadata/$ratingKey', queryParameters: {'includeChapters': 1, 'includeMarkers': 1}), parseCache: (cached) => cached as Map?, parseResponse: (response) => response.data as Map?, ); final metadataJson = _getFirstMetadataJsonFromData(data); return _parsePlaybackExtrasFromMetadataJson( metadataJson, introPattern: introPattern, creditsPattern: creditsPattern, ); } catch (e) { appLogger.w('Failed to get playback extras', error: e); return PlaybackExtras(chapters: [], markers: []); } } /// Parse PlaybackExtras from metadata JSON PlaybackExtras _parsePlaybackExtrasFromMetadataJson( Map? metadataJson, { String? introPattern, String? creditsPattern, }) { return PlaybackExtras.withChapterFallback( chapters: _parseChapters(metadataJson), markers: _parseMarkers(metadataJson), introPatternStr: introPattern, creditsPatternStr: creditsPattern, ); } /// Parse video playback data from raw metadata JSON (no network call). /// Used by [getVideoPlaybackData] and [getMetadataWithImagesAndOnDeck] to /// avoid redundant fetches when the response is already available. PlexVideoPlaybackData parseVideoPlaybackDataFromJson(Map? metadataJson, {int mediaIndex = 0}) { String? videoUrl; PlexMediaInfo? mediaInfo; List availableVersions = []; final markers = _parseMarkers(metadataJson); if (metadataJson != null) { if (metadataJson['Media'] != null && (metadataJson['Media'] as List).isNotEmpty) { final mediaList = metadataJson['Media'] as List; // Parse available media versions first availableVersions = mediaList.map((media) => PlexMediaVersion.fromJson(media as Map)).toList(); // Ensure the requested index is valid if (mediaIndex < 0 || mediaIndex >= mediaList.length) { mediaIndex = 0; } if (!availableVersions[mediaIndex].isPlayable) { final fallback = availableVersions.indexWhere((v) => v.isPlayable); if (fallback >= 0) { appLogger.w('Version $mediaIndex inaccessible/missing — falling back to version $fallback'); mediaIndex = fallback; } } final media = mediaList[mediaIndex]; if (media['Part'] != null && (media['Part'] as List).isNotEmpty) { final part = media['Part'][0]; final partKey = part['key'] as String?; if (partKey != null) { // Get video URL videoUrl = '${config.baseUrl}$partKey'.withPlexToken(config.token); // Parse streams using helper final streams = _parseStreams(part['Stream'] as List?); // Parse chapters using helper final chapters = _parseChapters(metadataJson); // Create media info mediaInfo = PlexMediaInfo( videoUrl: videoUrl, audioTracks: streams.audio, subtitleTracks: streams.subtitles, chapters: chapters, partId: part['id'] as int?, frameRate: streams.frameRate, ); } } } } return PlexVideoPlaybackData( videoUrl: videoUrl, mediaInfo: mediaInfo, availableVersions: availableVersions, markers: markers, ); } /// Get consolidated video playback data (URL, media info, versions, and markers) in a single API call. /// This is the primary method for playback initialization. /// Uses cache for offline mode support and network fallback. Future getVideoPlaybackData(String ratingKey, {int mediaIndex = 0}) async { Map? data; try { data = await _fetchWithCacheFallback>( cacheKey: '/library/metadata/$ratingKey', // checkFiles=1 populates Part.accessible/exists so we can skip // deleted-but-still-indexed versions before play. networkCall: () => _http.get( '/library/metadata/$ratingKey', queryParameters: {'includeMarkers': 1, 'includeChapters': 1, 'checkFiles': 1}, ), parseCache: (cached) => cached as Map?, parseResponse: (response) => response.data as Map?, ); } catch (_) { // Gracefully degrade: return empty playback data on total failure } final metadataJson = _getFirstMetadataJsonFromData(data); return parseVideoPlaybackDataFromJson(metadataJson, mediaIndex: mediaIndex); } /// Get file information for a media item /// Uses cache for offline mode support and network fallback. Future getFileInfo(String ratingKey) async { try { final data = await _fetchWithCacheFirst>( cacheKey: '/library/metadata/$ratingKey', networkCall: () => _http.get('/library/metadata/$ratingKey', queryParameters: {'includeMarkers': 1, 'includeChapters': 1}), parseCache: (cached) => cached as Map?, parseResponse: (response) => response.data as Map?, ); final metadataJson = _getFirstMetadataJsonFromData(data); if (metadataJson != null && metadataJson['Media'] != null && (metadataJson['Media'] as List).isNotEmpty) { final media = metadataJson['Media'][0]; final part = media['Part'] != null && (media['Part'] as List).isNotEmpty ? media['Part'][0] : null; // Extract video stream details and all audio/subtitle tracks final streams = part?['Stream'] as List? ?? []; Map? videoStream; Map? audioStream; for (final stream in streams) { final streamType = stream['streamType'] as int?; if (streamType == PlexStreamType.video && videoStream == null) { videoStream = stream; } else if (streamType == PlexStreamType.audio && audioStream == null) { audioStream = stream; } } final parsedTracks = _parseStreams(streams); return PlexFileInfo( // Media level properties container: media['container'] as String?, videoCodec: media['videoCodec'] as String?, videoResolution: media['videoResolution'] as String?, videoFrameRate: media['videoFrameRate'] as String?, videoProfile: media['videoProfile'] as String?, width: media['width'] as int?, height: media['height'] as int?, aspectRatio: (media['aspectRatio'] as num?)?.toDouble(), bitrate: media['bitrate'] as int?, duration: media['duration'] as int?, audioCodec: media['audioCodec'] as String?, audioProfile: media['audioProfile'] as String?, audioChannels: media['audioChannels'] as int?, optimizedForStreaming: flexibleBool(media['optimizedForStreaming']), has64bitOffsets: flexibleBool(media['has64bitOffsets']), // Part level properties (file) filePath: part?['file'] as String?, fileSize: part?['size'] as int?, // Video stream details colorSpace: videoStream?['colorSpace'] as String?, colorRange: videoStream?['colorRange'] as String?, colorPrimaries: videoStream?['colorPrimaries'] as String?, chromaSubsampling: videoStream?['chromaSubsampling'] as String?, frameRate: (videoStream?['frameRate'] as num?)?.toDouble(), bitDepth: videoStream?['bitDepth'] as int?, videoBitrate: videoStream?['bitrate'] as int?, // Audio stream details audioChannelLayout: audioStream?['audioChannelLayout'] as String?, // All audio and subtitle tracks audioTracks: parsedTracks.audio, subtitleTracks: parsedTracks.subtitles, ); } return null; } catch (e) { appLogger.e('Failed to get file info: $e'); return null; } } /// Fetch the raw `Guid` array for a metadata item (`includeGuids=1`). /// /// Returns the list of `{id: 'imdb://tt...'}` maps as Plex returns them, or /// an empty list if the item has no external IDs / can't be fetched. /// Used by the Trakt integration to match Plex items against Trakt's catalog. Future> fetchExternalGuids(String ratingKey) async { try { final response = await _getWithFailover('/library/metadata/$ratingKey', queryParameters: {'includeGuids': 1}); final data = response.data; if (data is! Map) return const []; final container = data['MediaContainer'] as Map?; final metadata = container?['Metadata']; if (metadata is! List || metadata.isEmpty) return const []; final first = metadata.first; if (first is! Map) return const []; final guids = first['Guid']; if (guids is List) return guids; return const []; } catch (e) { appLogger.d('fetchExternalGuids failed for $ratingKey', error: e); return const []; } } /// Mark media as watched /// /// If [metadata] is provided, emits a [WatchStateEvent] for UI updates. Future markAsWatched(String ratingKey, {PlexMetadata? metadata}) async { await _getWithFailover( '/:/scrobble', queryParameters: {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'}, ); if (metadata != null) { WatchStateNotifier().notifyWatched(metadata: metadata, isNowWatched: true); } } /// Mark media as unwatched /// /// If [metadata] is provided, emits a [WatchStateEvent] for UI updates. Future markAsUnwatched(String ratingKey, {PlexMetadata? metadata}) async { await _getWithFailover( '/:/unscrobble', queryParameters: {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'}, ); if (metadata != null) { WatchStateNotifier().notifyWatched(metadata: metadata, isNowWatched: false); } } /// Update playback progress Future updateProgress( String ratingKey, { required int time, required String state, // 'playing', 'paused', 'stopped', 'buffering' int? duration, }) async { await _http.post( '/:/timeline', queryParameters: { 'ratingKey': ratingKey, 'key': '/library/metadata/$ratingKey', 'time': time, 'state': state, 'duration': ?duration, }, ); } /// 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, }, ); 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; } /// Remove item from Continue Watching (On Deck) without affecting watch status or progress /// This uses the same endpoint Plex Web uses to hide items from Continue Watching Future removeFromOnDeck(String ratingKey) async { await _http.put('/actions/removeFromContinueWatching', queryParameters: {'ratingKey': ratingKey}); } /// Rate a media item (0.0-10.0 scale, where each integer = half a star) /// Pass -1 to clear an existing rating Future rateItem(String ratingKey, double rating) { return _wrapBoolApiCall( () => _http.put( '/:/rate', queryParameters: {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library', 'rating': rating}, ), 'Failed to rate item', ); } /// Delete a media item from the library /// This permanently removes the item and its associated files from the server /// Returns true if deletion was successful, false otherwise Future deleteMediaItem(String ratingKey) { return _wrapBoolApiCall(() => _http.delete('/library/metadata/$ratingKey'), 'Failed to delete media item'); } /// Parse a Plex Settings response into a map of id --> value. Map _parseSettingsMap(dynamic response) { final container = _getMediaContainer(response); if (container == null) return {}; final settings = container['Setting']; if (settings == null) return {}; final list = settings is List ? settings : [settings]; return {for (final s in list) s['id'] as String: s['value']}; } /// Fetch all server-level preferences and store them in [serverPrefs]. /// /// Non-blocking: intended to be called fire-and-forget on connect. Future fetchServerPrefs() async { try { final response = await _getWithFailover('/:/prefs'); _serverPrefs = _parseSettingsMap(response); } catch (e) { appLogger.d('Failed to fetch server prefs: $e'); } } /// Get preferences for a library section. /// /// Returns a map of setting id --> value for all settings in the library. Future> getLibrarySectionPrefs(String sectionId) async { final response = await _getWithFailover('/library/sections/$sectionId/prefs'); return _parseSettingsMap(response); } /// Get available filters for a library section Future> getLibraryFilters(String sectionId) async { if (sectionId == 'shared') return []; final response = await _getWithFailover('/library/sections/$sectionId/filters'); return _extractDirectoryList(response, PlexFilter.fromJson); } /// Get first characters (alphabet index) for a library section Future> getFirstCharacters( String sectionId, { int? type, Map? filters, }) async { final queryParams = {}; if (type != null) queryParams['type'] = type; if (filters != null) queryParams.addAll(filters); final response = await _getWithFailover( '/library/sections/$sectionId/firstCharacter', queryParameters: queryParams, ); return _extractDirectoryList(response, PlexFirstCharacter.fromJson); } /// Get filter values (e.g., list of genres, years, etc.) Future> getFilterValues(String filterKey) async { final response = await _getWithFailover(filterKey); return _extractDirectoryList(response, PlexFilterValue.fromJson); } /// Get available sort options for a library section /// /// If [libraryType] is provided (e.g., 'movie', 'show'), it's used for fallback /// sorts without needing to re-fetch the library sections list. Future> getLibrarySorts(String sectionId, {String? libraryType}) async { if (sectionId == 'shared') { return [ PlexSort(key: 'titleSort', descKey: 'titleSort:desc', title: 'Title', defaultDirection: 'asc'), PlexSort( key: 'taggingCreatedAt', descKey: 'taggingCreatedAt:desc', title: 'Date Shared', defaultDirection: 'desc', ), ]; } try { // Use the dedicated sorts endpoint final response = await _getWithFailover('/library/sections/$sectionId/sorts'); // Parse the Directory array (not Sort array) per the API spec final sorts = _extractDirectoryList(response, PlexSort.fromJson); if (sorts.isNotEmpty) { return sorts; } // Fallback: return common sort options if API doesn't provide them return _getFallbackSorts(libraryType); } catch (e) { appLogger.e('Failed to get library sorts: $e'); // Return fallback sort options on error return _getFallbackSorts(libraryType); } } /// Build fallback sort options based on library type. /// /// If [libraryType] is null, returns generic sorts without the show-specific options. List _getFallbackSorts(String? libraryType) { final fallbackSorts = [ PlexSort(key: 'titleSort', title: 'Title', defaultDirection: 'asc'), PlexSort(key: 'addedAt', descKey: 'addedAt:desc', title: 'Date Added', defaultDirection: 'desc'), ]; // Add "Latest Episode Air Date" only for TV show libraries if (libraryType?.toLowerCase() == 'show') { fallbackSorts.add( PlexSort( key: 'episode.originallyAvailableAt', descKey: 'episode.originallyAvailableAt:desc', title: 'Latest Episode Air Date', defaultDirection: 'desc', ), ); } fallbackSorts.addAll([ PlexSort( key: 'originallyAvailableAt', descKey: 'originallyAvailableAt:desc', title: 'Release Date', defaultDirection: 'desc', ), PlexSort(key: 'rating', descKey: 'rating:desc', title: 'Rating', defaultDirection: 'desc'), ]); return fallbackSorts; } /// Get library hubs (recommendations for a specific library section) /// Returns a list of recommendation hubs like "Trending Movies", "Top in Genre", etc. Future> getLibraryHubs(String sectionId, {int limit = 10}) async { try { final response = await _getWithFailover( '/hubs/sections/$sectionId', queryParameters: {'count': limit, 'includeGuids': 1}, ); final sid = serverId; final sname = serverName; return await tryIsolateRun(() => _processHubResponse(response.data as Map, sid, sname)); } catch (e) { appLogger.e('Failed to get library hubs: $e'); } return []; } /// Get global hubs (home page recommendations) /// Returns actual home page hubs like "Recently Added Movies", "Recently Added TV", etc. /// This matches the official Plex client's home page layout. Future> getGlobalHubs({int limit = 10}) async { try { final response = await _getWithFailover('/hubs', queryParameters: {'count': limit, 'includeGuids': 1}); final sid = serverId; final sname = serverName; return await tryIsolateRun(() => _processHubResponse(response.data as Map, sid, sname)); } catch (e) { appLogger.e('Failed to get global hubs: $e'); } return []; } /// Get related hubs for a specific metadata item (collections, similar, "more from" director/actor) Future> getRelatedHubs(String ratingKey, {int count = 10}) async { try { final response = await _getWithFailover('/hubs/metadata/$ratingKey/related', queryParameters: {'count': count}); final sid = serverId; final sname = serverName; return await tryIsolateRun( () => _processHubResponse( response.data as Map, sid, sname, filter: (item) => item.isVideoContent || item.isCollection, ), ); } catch (e) { appLogger.e('Failed to get related hubs: $e'); } return []; } /// Get full content from a hub using its hub key /// Returns the complete list of metadata items in the hub Future> getHubContent(String hubKey) async { return _wrapListApiCall(() => _http.get(hubKey), (response) { final allItems = _extractMetadataList(response); // Filter to only video content (movies, shows, seasons, episodes) return allItems.where((item) { return item.isVideoContent; }).toList(); }, 'Failed to get hub content'); } /// Get playlist content by playlist ID, paginated. Future getPlaylist(String playlistId, {int? start, int? size, AbortController? abort}) => _fetchPaginatedList('/playlists/$playlistId/items', start: start, size: size, abort: abort); /// Fetch every page of a playlist's items. For callers that need the full list /// (downloads, sync rules, context-menu shuffle). Future> fetchAllPlaylistItems(String playlistId) => _fetchAllPages((start, size, abort) => getPlaylist(playlistId, start: start, size: size, abort: abort)); /// Get all playlists /// Filters by playlistType=video by default /// Set smart to true/false to filter smart playlists, or null for all Future> getPlaylists({String playlistType = 'video', bool? smart}) { final queryParams = { 'playlistType': playlistType, 'X-Plex-Container-Size': _defaultListContainerSize, }; if (smart != null) { queryParams['smart'] = smart ? '1' : '0'; } return _wrapListApiCall( () => _http.get('/playlists', queryParameters: queryParams), _extractPlaylistList, 'Failed to get playlists', ); } /// Get playlist metadata by playlist ID /// Returns the playlist details (not the items) Future getPlaylistMetadata(String playlistId) async { try { final response = await _getWithFailover('/playlists/$playlistId'); final container = _getMediaContainer(response); if (container == null || container['Metadata'] == null) { return null; } final List metadata = container['Metadata'] as List; if (metadata.isEmpty) { return null; } return PlexPlaylist.fromJson(metadata.first as Map); } catch (e) { appLogger.e('Failed to get playlist metadata: $e'); return null; } } /// Create a new playlist /// [title] - Name of the playlist /// [uri] - Optional comma-separated list of item URIs to add (e.g., "server://uuid/com.plexapp.plugins.library/library/metadata/1234") /// [playQueueId] - Optional play queue ID to create playlist from Future createPlaylist({required String title, String? uri, int? playQueueId}) async { try { final queryParams = {'type': 'video', 'title': title, 'smart': '0'}; if (uri != null) { queryParams['uri'] = uri; } if (playQueueId != null) { queryParams['playQueueID'] = playQueueId.toString(); } final response = await _http.post('/playlists', queryParameters: queryParams); final container = _getMediaContainer(response); if (container == null || container['Metadata'] == null) { return null; } final List metadata = container['Metadata'] as List; if (metadata.isEmpty) { return null; } return PlexPlaylist.fromJson(metadata.first as Map); } catch (e) { appLogger.e('Failed to create playlist: $e'); return null; } } /// Delete a playlist Future deletePlaylist(String playlistId) { return _wrapBoolApiCall(() => _http.delete('/playlists/$playlistId'), 'Failed to delete playlist'); } /// Add items to a playlist /// [playlistId] - The playlist to add items to /// [uri] - Comma-separated list of item URIs to add Future addToPlaylist({required String playlistId, required String uri}) async { appLogger.d( 'Adding to playlist $playlistId with URI: ${uri.substring(0, uri.length > 100 ? 100 : uri.length)}${uri.length > 100 ? "..." : ""}', ); final result = await _wrapBoolApiCall( () => _http.put('/playlists/$playlistId/items', queryParameters: {'uri': uri}), 'Failed to add to playlist', ); if (result) { appLogger.d('Add to playlist response status: 200'); } return result; } /// Remove an item from a playlist /// [playlistId] - The playlist to remove from /// [playlistItemId] - The playlist item ID to remove (from the item's playlistItemID field) Future removeFromPlaylist({required String playlistId, required String playlistItemId}) { return _wrapBoolApiCall( () => _http.delete('/playlists/$playlistId/items/$playlistItemId'), 'Failed to remove from playlist', ); } /// Move a playlist item to a new position /// Only works with non-smart playlists /// [playlistId] - The playlist rating key /// [playlistItemId] - The playlist item ID to move /// [afterPlaylistItemId] - Move the item after this playlist item ID (0 = move to top) Future movePlaylistItem({ required String playlistId, required int playlistItemId, required int afterPlaylistItemId, }) async { appLogger.d('Moving playlist item $playlistItemId after $afterPlaylistItemId in playlist $playlistId'); final result = await _wrapBoolApiCall( () => _http.put( '/playlists/$playlistId/items/$playlistItemId/move', queryParameters: {'after': afterPlaylistItemId}, ), 'Failed to move playlist item', ); if (result) { appLogger.d('Successfully moved playlist item'); } return result; } // ============================================================================ // Metadata Editing Methods // ============================================================================ /// Update metadata fields for a media item Future updateMetadata({ required int sectionId, required String ratingKey, required int typeNumber, String? title, String? titleSort, String? originalTitle, String? originallyAvailableAt, String? contentRating, String? studio, String? tagline, String? summary, Map current, List original})>? tagChanges, }) { final queryParams = {'type': typeNumber, 'id': ratingKey}; void addField(String name, String? value) { if (value != null) { queryParams['$name.value'] = value; queryParams['$name.locked'] = '1'; } } addField('title', title); addField('titleSort', titleSort); addField('originalTitle', originalTitle); addField('originallyAvailableAt', originallyAvailableAt); addField('contentRating', contentRating); addField('studio', studio); addField('tagline', tagline); addField('summary', summary); if (tagChanges != null) { for (final entry in tagChanges.entries) { final field = entry.key; final current = entry.value.current; final original = entry.value.original; for (var i = 0; i < current.length; i++) { queryParams['$field[$i].tag.tag'] = current[i]; } final removed = original.where((t) => !current.contains(t)).toList(); if (removed.isNotEmpty) { queryParams['$field[].tag.tag-'] = removed.map(Uri.encodeComponent).join(','); } queryParams['$field.locked'] = '1'; } } return _wrapBoolApiCall( () => _http.put('/library/sections/$sectionId/all', queryParameters: queryParams), 'Failed to update metadata', ); } /// Search for match candidates for a media item. Future> findMatches( String ratingKey, { String? title, String? year, String? agent, String? language, }) async { final queryParams = {'manual': 1}; if (title != null && title.isNotEmpty) queryParams['title'] = title; if (year != null && year.isNotEmpty) queryParams['year'] = year; if (agent != null && agent.isNotEmpty) queryParams['agent'] = agent; if (language != null && language.isNotEmpty) queryParams['language'] = language; return _wrapListApiCall( () => _getWithFailover('/library/metadata/$ratingKey/matches', queryParameters: queryParams), (response) { final container = _getMediaContainer(response); if (container == null || container['SearchResult'] == null) return []; return (container['SearchResult'] as List) .map((json) => PlexMatchResult.fromJson(json as Map)) .toList(); }, 'Failed to search for matches', ); } /// Apply a chosen match to a media item. Future applyMatch(String ratingKey, {required String guid, String? name, String? year}) async { final queryParams = {'guid': guid}; if (name != null && name.isNotEmpty) queryParams['name'] = name; if (year != null && year.isNotEmpty) queryParams['year'] = year; final result = await _wrapBoolApiCall( () => _http.put('/library/metadata/$ratingKey/match', queryParameters: queryParams), 'Failed to apply match', ); if (result) { await _cache.deleteForItem(serverId, ratingKey); } return result; } Future unmatchItem(String ratingKey) async { final result = await _wrapBoolApiCall( () => _http.put('/library/metadata/$ratingKey/unmatch'), 'Failed to unmatch item', ); if (result) { await _cache.deleteForItem(serverId, ratingKey); } return result; } /// Get available artwork (posters or backgrounds) for a media item Future>> getAvailableArtwork(String ratingKey, String element) async { try { final response = await _getWithFailover('/library/metadata/$ratingKey/$element'); final container = _getMediaContainer(response); if (container != null && container['Metadata'] != null) { return (container['Metadata'] as List).cast>(); } return []; } catch (e) { appLogger.e('Failed to get available artwork', error: e); return []; } } /// Set artwork from a URL (can be a Plex internal path or external URL) Future setArtworkFromUrl(String ratingKey, String element, String url) { final setElement = element.endsWith('s') ? element.substring(0, element.length - 1) : element; return _wrapBoolApiCall( () => _http.put('/library/metadata/$ratingKey/$setElement', queryParameters: {'url': url}), 'Failed to set artwork from URL', ); } /// Upload artwork from binary data Future uploadArtwork(String ratingKey, String element, List bytes) { final setElement = element.endsWith('s') ? element.substring(0, element.length - 1) : element; return _wrapBoolApiCall( () => _http.put( '/library/metadata/$ratingKey/$setElement', body: bytes, headers: {'Content-Type': 'application/octet-stream', 'Content-Length': '${bytes.length}'}, ), 'Failed to upload artwork', ); } /// Update per-media advanced preferences Future updateMetadataPrefs(String ratingKey, Map prefs) { return _wrapBoolApiCall( () => _http.put('/library/metadata/$ratingKey/prefs', queryParameters: prefs), 'Failed to update metadata preferences', ); } // ============================================================================ // Collection Methods // ============================================================================ /// Get all collections for a library section /// Returns collections as PlexMetadata objects with type="collection" Future> getLibraryCollections(String sectionId) async { return _wrapListApiCall( () => _http.get( '/library/sections/$sectionId/collections', queryParameters: {'includeGuids': 1, 'X-Plex-Container-Size': _defaultListContainerSize}, ), (response) { final allItems = _extractMetadataList(response); // Collections should have type="collection" return allItems.where((item) { return item.isCollection; }).toList(); }, 'Failed to get library collections', ); } /// Get items in a collection, paginated. Future getCollectionItems( String collectionId, { int? start, int? size, AbortController? abort, }) => _fetchPaginatedList('/library/collections/$collectionId/children', start: start, size: size, abort: abort); /// Fetch every item in a collection (downloads, sync rules, context-menu shuffle). Future> fetchAllCollectionItems(String collectionId) => _fetchAllPages((start, size, abort) => getCollectionItems(collectionId, start: start, size: size, abort: abort)); /// Get media featuring a specific person (actor/director), paginated. Future getPersonMedia(String personId, {int? start, int? size, AbortController? abort}) => _fetchPaginatedList('/library/people/$personId/media', start: start, size: size, abort: abort); /// Fetch every media item featuring a given person. Future> fetchAllPersonMedia(String personId) => _fetchAllPages((start, size, abort) => getPersonMedia(personId, start: start, size: size, abort: abort)); /// Delete a collection /// Deletes a library collection from the server Future deleteCollection(String sectionId, String collectionId) async { appLogger.d('Deleting collection: sectionId=$sectionId, collectionId=$collectionId'); final result = await _wrapBoolApiCall( () => _http.delete('/library/collections/$collectionId'), 'Failed to delete collection', ); if (result) { appLogger.d('Delete collection response: 200'); } return result; } /// Create a new collection /// Creates a new collection and optionally adds items to it /// Returns the created collection ID or null if failed Future createCollection({ required String sectionId, required String title, required String uri, int? type, }) async { try { appLogger.d('Creating collection: sectionId=$sectionId, title=$title, type=$type'); final response = await _http.post( '/library/collections', queryParameters: {'type': ?type, 'title': title, 'smart': 0, 'sectionId': sectionId, 'uri': uri}, ); appLogger.d('Create collection response: ${response.statusCode}'); // Extract the collection ID from the response // The response should contain the created collection metadata final container = _getMediaContainer(response); if (container != null) { final metadata = container['Metadata']; if (metadata != null && (metadata as List).isNotEmpty) { final collectionId = metadata.first['ratingKey']?.toString(); appLogger.d('Created collection with ID: $collectionId'); return collectionId; } } return null; } catch (e) { appLogger.e('Failed to create collection', error: e); return null; } } /// Add items to an existing collection /// Adds one or more items (specified by URI) to an existing collection Future addToCollection({required String collectionId, required String uri}) async { appLogger.d('Adding items to collection: collectionId=$collectionId'); final result = await _wrapBoolApiCall( () => _http.put('/library/collections/$collectionId/items', queryParameters: {'uri': uri}), 'Failed to add items to collection', ); if (result) { appLogger.d('Add to collection response: 200'); } return result; } /// Remove an item from a collection /// Removes a single item from an existing collection Future removeFromCollection({required String collectionId, required String itemId}) async { appLogger.d('Removing item from collection: collectionId=$collectionId, itemId=$itemId'); final result = await _wrapBoolApiCall( () => _http.delete('/library/collections/$collectionId/items/$itemId'), 'Failed to remove item from collection', ); if (result) { appLogger.d('Remove from collection response: 200'); } return result; } // ============================================================================ // Play Queue Methods // ============================================================================ /// Create a new play queue /// Either uri or playlistID must be specified Future createPlayQueue({ String? uri, int? playlistID, required String type, String? key, int shuffle = 0, int repeat = 0, int continuous = 0, }) async { try { final queryParams = { 'type': type, 'shuffle': shuffle, 'repeat': repeat, 'continuous': continuous, }; if (uri != null) { queryParams['uri'] = uri; } if (playlistID != null) { queryParams['playlistID'] = playlistID; } if (key != null) { queryParams['key'] = key; } final response = await _http.post('/playQueues', queryParameters: queryParams); return PlayQueueResponse.fromJson(response.data, serverId: serverId, serverName: serverName); } catch (e) { appLogger.e('Failed to create play queue', error: e); return null; } } /// Get a play queue with optional windowing /// Can request a window of items around a specific item Future getPlayQueue( int playQueueId, { String? center, int window = 50, int includeBefore = 1, int includeAfter = 1, }) async { try { final queryParams = { 'window': window, 'includeBefore': includeBefore, 'includeAfter': includeAfter, }; if (center != null) { queryParams['center'] = center; } final response = await _getWithFailover('/playQueues/$playQueueId', queryParameters: queryParams); return PlayQueueResponse.fromJson(response.data, serverId: serverId, serverName: serverName); } catch (e) { appLogger.e('Failed to get play queue: $e'); return null; } } /// Create a play queue for a TV show (all episodes) /// /// This is a convenience method that creates a play queue from a show's URI. /// Perfect for sequential or shuffle playback of an entire series. /// /// Parameters: /// - [showRatingKey]: The rating key of the show /// - [shuffle]: Whether to shuffle the episodes (0 = off, 1 = on) /// - [startingEpisodeKey]: Optional rating key of episode to start from /// /// Returns a PlayQueueResponse with all episodes from the show Future createShowPlayQueue({ required String showRatingKey, int shuffle = 0, String? startingEpisodeKey, }) async { try { final machineId = config.machineIdentifier ?? await getMachineIdentifier(); if (machineId == null) { throw Exception('Could not get server machine identifier'); } final uri = 'server://$machineId/com.plexapp.plugins.library/library/metadata/$showRatingKey/children'; return await createPlayQueue( uri: uri, type: 'video', shuffle: shuffle, key: startingEpisodeKey != null ? '/library/metadata/$startingEpisodeKey' : null, continuous: startingEpisodeKey != null && shuffle == 0 ? 1 : 0, ); } catch (e) { appLogger.e('Failed to create show play queue', error: e); return null; } } /// Extract both Metadata and Directory entries from response /// Folders can come back as either type /// Automatically tags all items with this client's serverId and serverName List _extractMetadataAndDirectories(PlexResponse response) { final List items = []; final container = _getMediaContainer(response); if (container != null) { // Extract Metadata entries - try full parsing first if (container['Metadata'] != null) { for (final json in container['Metadata'] as List) { try { // Try to parse with full PlexMetadata.fromJson first items.add(_createTaggedMetadata(json)); } catch (e) { // If full parsing fails, use minimal safe parsing appLogger.d('Using minimal parsing for metadata item: $e'); try { items.add( PlexMetadata( ratingKey: json['key'] ?? json['ratingKey'] ?? '', key: json['key'] ?? '', type: json['type'] ?? 'folder', title: json['title'] ?? 'Untitled', thumb: json['thumb'], art: json['art'], year: json['year'], serverId: serverId, serverName: serverName, ), ); } catch (e2) { appLogger.e('Failed to parse metadata item: $e2'); } } } } // Extract Directory entries (folders) if (container['Directory'] != null) { for (final json in container['Directory'] as List) { try { // Try to parse as PlexMetadata first items.add(_createTaggedMetadata(json)); } catch (e) { // If that fails, use minimal folder representation try { items.add( PlexMetadata( ratingKey: json['key'] ?? json['ratingKey'] ?? '', key: json['key'] ?? '', type: json['type'] ?? 'folder', title: json['title'] ?? 'Untitled', thumb: json['thumb'], art: json['art'], serverId: serverId, serverName: serverName, ), ); } catch (e2) { appLogger.e('Failed to parse directory item: $e2'); } } } } } return items; } /// Get root folders for a library section /// Returns the top-level folder structure for filesystem-based browsing Future> getLibraryFolders(String sectionId) async { try { final response = await _getWithFailover( '/library/sections/$sectionId/folder', queryParameters: {'includeCollections': 0}, ); return _extractMetadataAndDirectories(response); } catch (e) { appLogger.e('Failed to get library folders: $e'); return []; } } /// Get children of a specific folder /// Returns files and subfolders within the given folder Future> getFolderChildren(String folderKey) async { try { final response = await _getWithFailover(folderKey); return _extractMetadataAndDirectories(response); } catch (e) { appLogger.e('Failed to get folder children: $e'); return []; } } /// Get library-specific playlists /// Filters playlists by checking if they contain items from the specified library /// This is a client-side filter since the API doesn't support sectionId for playlists Future> getLibraryPlaylists({String playlistType = 'video'}) { // For now, return all video playlists // Future enhancement: filter by checking playlist items' library return getPlaylists(playlistType: playlistType); } // ============================================================================ // Library Management Methods // ============================================================================ /// Scan/refresh a library section to detect new files Future scanLibrary(String sectionId) async { await _getWithFailover('/library/sections/$sectionId/refresh'); } /// Refresh metadata for a library section Future refreshLibraryMetadata(String sectionId) async { await _getWithFailover('/library/sections/$sectionId/refresh?force=1'); } /// Empty trash for a library section Future emptyLibraryTrash(String sectionId) async { await _http.put('/library/sections/$sectionId/emptyTrash'); } /// Analyze library section Future analyzeLibrary(String sectionId) async { await _getWithFailover('/library/sections/$sectionId/analyze'); } // ============================================================================ // Live TV / DVR Methods // ============================================================================ /// Get all DVR devices configured on this server Future> getDvrs() async { return _wrapListApiCall(() => _http.get('/livetv/dvrs'), (response) { final container = _getMediaContainer(response); if (container != null && container['Dvr'] != null) { return (container['Dvr'] as List).map((json) => LiveTvDvr.fromJson(json as Map)).toList(); } return []; }, 'Failed to get DVRs'); } /// Check if this server has at least one DVR configured Future hasDvr() async { final dvrs = await getDvrs(); return dvrs.isNotEmpty; } /// Get EPG channels using provider lineup endpoints (matches official Plex web client) Future> getEpgChannels({String? lineup}) async { List parseChannels(PlexResponse 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 _providerEpg) { final isCloudGuide = provider.identifier.startsWith('tv.plex.providers.epg'); final primaryEndpoint = isCloudGuide ? '/lineups/plex/channels' : '/${provider.identifier}/lineups/dvr/channels'; try { final response = await _getWithFailover(primaryEndpoint); final parsed = parseChannels(response); if (parsed.isEmpty && isCloudGuide) { // Fallback: the prefix matched but this server doesn't expose the // cloud endpoint. Retry with the legacy DVR endpoint. final fallback = await _getWithFailover('/${provider.identifier}/lineups/dvr/channels'); allChannels.addAll(parseChannels(fallback)); } else { allChannels.addAll(parsed); } } catch (e) { appLogger.e('Failed to get EPG channels from ${provider.identifier} via $primaryEndpoint', error: e); } } return allChannels; } /// Return EPG providers (already parsed from /media/providers during initialization) Future> _discoverEpgProviders() async { return _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) { 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)); } } else { programs.add(LiveTvProgram.fromJson(map)); } } 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(PlexResponse 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)); } // 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)); } } } 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); 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) { 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); if (entry != null) entries.add(entry); } if (entries.isEmpty) return null; return LiveTvHubResult( title: hubJson['title'] as String? ?? 'Unknown', 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) { try { final metadata = PlexMetadata.fromJson(itemJson).copyWith(serverId: serverId, serverName: serverName); final program = LiveTvProgram.fromJson(itemJson); 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; } } } /// Generate 24-char random alphanumeric string (matching official client format) static String generateSessionIdentifier() { const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; final rand = Random(); return List.generate(24, (_) => chars[rand.nextInt(chars.length)]).join(); } /// Coerce String values to num for fields that json_serializable expects as num. /// Plex tune responses use XML-to-JSON conversion where all values are strings. static void _coerceNumericFields(Map json) { const numericKeys = [ 'duration', 'year', 'addedAt', 'updatedAt', 'lastViewedAt', 'parentIndex', 'index', 'viewOffset', 'viewCount', 'leafCount', 'viewedLeafCount', 'childCount', 'rating', 'audienceRating', 'userRating', 'ratingCount', 'skipCount', 'lastRatedAt', ]; for (final key in numericKeys) { final val = json[key]; if (val is String) { json[key] = num.tryParse(val); } } } /// 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< ({ PlexMetadata metadata, String sessionPath, String sessionIdentifier, CaptureBuffer? captureBuffer, int? beginsAt, })? > tuneChannel(String dvrKey, String channelIdentifier) async { try { final sessionIdentifier = 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'] ?? 'Unknown error'; 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. _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 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': 'http', '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-Chunked': '1', 'X-Plex-Incomplete-Segments': '1', 'X-Plex-Product': config.product, 'X-Plex-Version': config.version, 'X-Plex-Client-Identifier': config.clientIdentifier, 'X-Plex-Platform': config.platform, 'X-Plex-Client-Profile-Name': 'Plex Desktop', 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 — separate client so no default X-Plex-* HTTP headers leak through. final decisionClient = PlexHttpClient( connectTimeout: ConnectionTimeouts.connect, receiveTimeout: ConnectionTimeouts.receive, defaultHeaders: {'Accept-Language': 'en'}, ); 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'; } catch (e, st) { appLogger.e('Failed to build live stream path', error: e, stackTrace: st); return null; } } /// Checks whether the server has video transcoding enabled. /// /// Reads `transcoderVideo` from the root MediaContainer. Result is cached /// for the lifetime of this [PlexClient]. Returns `true` on error (fail-open) /// — the transcode decision call itself will fail gracefully if transcoding /// really is unavailable. Future serverSupportsVideoTranscoding() { final cached = _serverTranscoderCached; if (cached != null) return Future.value(cached); return _serverTranscoderPending ??= _fetchTranscoderCapability(); } /// Synchronous view of the probe — returns the cached value, or `true` /// (assume supported) if the post-connect warm-up hasn't landed yet. The /// transcode decision call has its own fallback path, so guessing wrong /// here just routes through that fallback instead of blocking playback. bool get serverSupportsVideoTranscodingCached => _serverTranscoderCached ?? true; Future _fetchTranscoderCapability() async { try { // Tight timeout: `/` returns a tiny MediaContainer — any responsive // server answers in well under a second. Inheriting the default 120 s // receive timeout would keep a hung server from ever resolving. final response = await _http.get('/', timeout: const Duration(seconds: 5)); final container = _getMediaContainer(response); final value = container?['transcoderVideo']; final supported = flexibleBool(value); _serverTranscoderCached = supported; return supported; } catch (e) { appLogger.w('Failed to query server transcoder capability', error: e); _serverTranscoderCached = true; return true; } } /// Build a VOD transcode stream URL (decision + start path). /// /// Mirrors [buildLiveStreamPath] but for on-demand video with a quality /// preset, selected audio stream, and HLS protocol. Plex returns a single /// audio track and no subtitle tracks in the transcoded stream — callers /// are expected to sidecar additional subtitles separately. /// /// [transcodeSessionId] and [sessionIdentifier] should be reused across /// seeks + quality/version/audio switches within one playback so the /// server-side transcode session is preserved. Future<({String? startPath, TranscodeDecisionOutcome outcome})> buildTranscodeStartPath({ required String ratingKey, required int mediaIndex, required TranscodeQualityPreset preset, required String sessionIdentifier, required String transcodeSessionId, int? audioStreamId, int? offsetMs, }) async { try { final isOriginal = preset.isOriginal; final metadataPath = '/library/metadata/$ratingKey'; // Build the client profile from scratch via X-Plex-Client-Profile-Extra. // We use the `Generic` base platform (see [_transcodePlatformName]) which // has no pre-installed transcode targets, so we must `add-transcode-target` // rather than `append-transcode-target-codec` (which only edits existing // targets — empty on Generic, hence Plex returned decision code 2000 // "neither direct play nor conversion is available"). // // For non-original presets we also add a bitrate limitation that caps // the video codec; with `replace=true` it overrides any default limit. // // See openapi.md §"Profile Augmentations" for the DSL reference. final profileExtraClauses = []; if (!isOriginal && preset.videoBitrateKbps != null) { profileExtraClauses.add( 'add-limitation(scope=videoCodec&scopeName=*&type=upperBound' '&name=video.bitrate&value=${preset.videoBitrateKbps}&replace=true)', ); } // Declare both h264 and hevc as allowed transcode targets. In practice // Plex's decision engine strongly prefers h264 for HLS output, so hevc // only gets chosen in edge cases (e.g. HDR content where the server // wants to preserve dynamic range). The codec-list comma is pre-encoded // as `%2C` — see the profile-extra encoding note above. profileExtraClauses.add( 'add-transcode-target(type=videoProfile&context=streaming' '&protocol=hls&container=mpegts&videoCodec=h264%2Chevc&audioCodec=aac)', ); final clientProfileExtra = profileExtraClauses.join('+'); // HLS protocol: seekable via manifest segments. We started with `dash` // (what Plex Web on Chrome uses) but Plex's server only has DASH // transcode profiles for Chrome/Firefox/Safari/Opera — mobile/desktop // platforms fall through with "No conversion profile found for // protocol dash". HLS profiles exist for every Plex-accepted platform. final allParams = { 'hasMDE': '1', 'path': metadataPath, 'mediaIndex': mediaIndex.toString(), 'partIndex': '0', 'protocol': 'hls', 'fastSeek': '1', 'directPlay': isOriginal ? '1' : '0', 'directStream': isOriginal ? '1' : '0', 'subtitleSize': '100', 'audioBoost': '100', 'location': 'lan', if (!isOriginal && preset.videoBitrateKbps != null) 'maxVideoBitrate': preset.videoBitrateKbps.toString(), 'addDebugOverlay': '0', 'autoAdjustQuality': '0', 'directStreamAudio': '0', 'mediaBufferSize': '102400', 'session': transcodeSessionId, // Subtitles are delivered as client-side sidecars (see // [PlaybackInitializationService._buildTranscodeSidecarSubtitles]). // `subtitles=none` makes the server set the subtitle decision to // `ignore`, so nothing is embedded or burned into the video stream. 'subtitles': 'none', // Preserve source timestamps in the transcoded segments. Without it, // Plex resets segment PTS to 0 — so mpv shows 0:00 and sidecar // subtitles desync even though the server is transcoding from the // `offset` position. With copyts=1 the first segment's PTS equals // the source offset and the player's clock lines up with source time. 'copyts': '1', if (audioStreamId != null) 'audioStreamID': audioStreamId.toString(), 'Accept-Language': 'en', 'X-Plex-Session-Identifier': sessionIdentifier, 'X-Plex-Client-Profile-Extra': clientProfileExtra, 'X-Plex-Incomplete-Segments': '1', 'X-Plex-Features': 'external-media,indirect-media', 'X-Plex-Model': 'standalone', 'X-Plex-Language': 'en', 'X-Plex-Product': config.product, 'X-Plex-Version': config.version, 'X-Plex-Client-Identifier': config.clientIdentifier, // Plex's server rejects unknown platform names with HTTP 400 and maps // known names to codec/bitrate base profiles. Our usual "Flutter" // platform, plus "MacOSX" / "Linux", are all rejected; swap to a // Plex-recognized name just for transcode requests. See // [_transcodePlatformName] for the mapping. 'X-Plex-Platform': _transcodePlatformName(), if (config.device != null) 'X-Plex-Device': config.device!, if (offsetMs != null) 'offset': (offsetMs ~/ 1000).toString(), if (config.token != null) 'X-Plex-Token': config.token!, }; final queryString = allParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&'); final decisionClient = PlexHttpClient( connectTimeout: ConnectionTimeouts.connect, receiveTimeout: ConnectionTimeouts.receive, defaultHeaders: const {'Accept-Language': 'en', 'Accept': 'application/json'}, ); final decisionUrl = '${config.baseUrl}/video/:/transcode/universal/decision?$queryString'; final decisionResponse = await decisionClient.get(decisionUrl); final decisionBody = decisionResponse.data?.toString() ?? ''; appLogger.i( 'Transcode decision [${decisionResponse.statusCode}] body: ' '${decisionBody.length > 2000 ? '${decisionBody.substring(0, 2000)}…' : decisionBody}', ); if (decisionResponse.statusCode != 200) { appLogger.w('Transcode decision returned ${decisionResponse.statusCode}'); return (startPath: null, outcome: TranscodeDecisionOutcome.failed); } final outcome = _parseTranscodeDecisionOutcome(decisionResponse.data, isOriginal: isOriginal); if (outcome == TranscodeDecisionOutcome.failed) { return (startPath: null, outcome: outcome); } final startParams = Map.from(allParams)..remove('X-Plex-Token'); final startQuery = startParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&'); // `.m3u8` tells the server to return an HLS manifest. return (startPath: '/video/:/transcode/universal/start.m3u8?$startQuery', outcome: outcome); } catch (e, st) { appLogger.e('Failed to build transcode start path', error: e, stackTrace: st); return (startPath: null, outcome: TranscodeDecisionOutcome.failed); } } /// Platform name Plex Media Server accepts on the transcode decision /// endpoint for arbitrary clients. Our default "Flutter" returns HTTP 400, /// and the known-OS names (`MacOSX`, `Mac`, `Linux`) are also rejected. /// `Generic` is accepted and comes with no preset transcode targets — we /// build the profile ourselves via `X-Plex-Client-Profile-Extra` with /// `add-transcode-target`. static String _transcodePlatformName() => 'Generic'; /// Strict percent-encoder matching Plex Web's URL encoder — escapes the /// extra characters `(`, `)`, `*`, `'`, `!` that Dart's [Uri.encodeComponent] /// leaves literal. Required for `X-Plex-Client-Profile-Extra` whose parens /// and asterisks must appear as `%28`, `%29`, `%2A` on the wire. static String _plexEncode(String value) { return Uri.encodeComponent(value) .replaceAll('(', '%28') .replaceAll(')', '%29') .replaceAll('*', '%2A') .replaceAll("'", '%27') .replaceAll('!', '%21'); } /// Parse decision response for outcome. Any decision code >= 2000 = error /// (matching Plex Web's error detector). TranscodeDecisionOutcome _parseTranscodeDecisionOutcome(dynamic data, {required bool isOriginal}) { try { Map? container; if (data is Map && data['MediaContainer'] is Map) { container = Map.from(data['MediaContainer'] as Map); } else if (data is Map) { container = data; } if (container == null) return TranscodeDecisionOutcome.failed; final general = flexibleInt(container['generalDecisionCode']); final transcode = flexibleInt(container['transcodeDecisionCode']); final mde = flexibleInt(container['mdeDecisionCode']); bool isError(int? code) => code != null && code >= 2000; if (isError(general) || isError(transcode) || isError(mde)) { appLogger.w('Transcode decision error codes: general=$general transcode=$transcode mde=$mde'); return TranscodeDecisionOutcome.failed; } if (isOriginal) return TranscodeDecisionOutcome.transcodeOk; if (transcode == 1000) return TranscodeDecisionOutcome.directPlayOnly; if (transcode == 1001) return TranscodeDecisionOutcome.transcodeOk; if (general == 1001) return TranscodeDecisionOutcome.transcodeOk; if (general == 1000) return TranscodeDecisionOutcome.directPlayOnly; return TranscodeDecisionOutcome.transcodeOk; } catch (e) { appLogger.w('Failed to parse transcode decision', error: e); return TranscodeDecisionOutcome.failed; } } /// Get active live TV sessions Future> getLiveTvSessions() { return _wrapListApiCall( () => _http.get('/livetv/sessions'), _extractMetadataList, 'Failed to get live TV sessions', ); } static const _favoriteChannelsUrl = 'https://epg.provider.plex.tv/settings/favoriteChannels'; static const _providerVersionHeader = {'X-Plex-Provider-Version': '5.1'}; /// Build the source URI for favorite channels: `server://{machineIdentifier}/{providerIdentifier}` Future buildFavoriteChannelSource() async { final providers = await _discoverEpgProviders(); 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. Future> getFavoriteChannels() async { try { final response = await _http.get(_favoriteChannelsUrl, headers: _providerVersionHeader); final container = _getMediaContainer(response); if (container != null && container['FavoriteChannel'] != null) { return (container['FavoriteChannel'] as List) .map((json) => FavoriteChannel.fromJson(json as Map)) .toList(); } return []; } catch (e) { appLogger.e('Failed to get favorite channels', error: e); return []; } } /// Update favorite channels on the Plex cloud. Future setFavoriteChannels(List channels) async { try { await _http.put( _favoriteChannelsUrl, body: channels.map((c) => c.toJson()).toList(), headers: _providerVersionHeader, ); } catch (e) { appLogger.e('Failed to update favorite channels', error: e); } } Future _handleEndpointSwitch(String newBaseUrl, {bool persist = true}) async { if (config.baseUrl == newBaseUrl) { return; } appLogger.i('Applying Plex endpoint switch', error: newBaseUrl); _http.baseUrl = newBaseUrl; config = config.copyWith(baseUrl: newBaseUrl); LogRedactionManager.registerServerUrl(newBaseUrl); if (persist && _onEndpointChanged != null) { await _onEndpointChanged(newBaseUrl); } } }