Files
plezy/lib/services/plex_client.dart
T
2026-04-19 11:31:27 +02:00

2907 lines
106 KiB
Dart

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_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 '../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<PlexMetadata> 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<PlexHub> _processHubResponse(
Map<String, dynamic> decoded,
String serverId,
String? serverName, {
bool Function(PlexMetadata)? filter,
}) {
final container = decoded['MediaContainer'] as Map<String, dynamic>?;
if (container == null || container['Hub'] == null) return [];
final itemFilter = filter ?? (PlexMetadata item) => item.isVideoContent;
final hubs = <PlexHub>[];
for (final hubJson in container['Hub'] as List) {
try {
final hub = PlexHub.fromJson(hubJson as Map<String, dynamic>);
if (hub.items.isEmpty) continue;
final filteredItems = hub.items
.where(itemFilter)
.map((item) => item.copyWith(serverId: serverId, serverName: serverName))
.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;
ConnectionTestResult({required this.success, required this.latencyMs, this.error});
}
class PlexClient {
PlexConfig config;
late final PlexHttpClient _http;
final EndpointFailoverManager? _endpointManager;
final Future<void> 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;
/// Libraries parsed from /media/providers (includes individually shared items)
late final List<PlexLibrary> _providerLibraries;
/// EPG providers parsed from /media/providers
late final List<({String identifier, String gridEndpoint})> _providerEpg;
/// Server-level preferences fetched from /:/prefs
Map<String, dynamic> _serverPrefs = {};
/// Get all fetched server preferences
Map<String, dynamic> 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<PlexClient> create(
PlexConfig config, {
required String serverId,
String? serverName,
List<String>? prioritizedEndpoints,
Future<void> Function(String newBaseUrl)? onEndpointChanged,
VoidCallback? onAllEndpointsExhausted,
}) async {
final client = PlexClient._(
config,
serverId: serverId,
serverName: serverName,
prioritizedEndpoints: prioritizedEndpoints,
onEndpointChanged: onEndpointChanged,
onAllEndpointsExhausted: onAllEndpointsExhausted,
);
await client._initMediaProviders();
return client;
}
PlexClient._(
this.config, {
required this.serverId,
this.serverName,
List<String>? prioritizedEndpoints,
Future<void> 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<PlexResponse> _getWithFailover(
String path, {
Map<String, dynamic>? queryParameters,
Map<String, String>? headers,
Duration? timeout,
AbortController? abort,
}) async {
final gen = _endpointManager?.generation;
try {
return await _http.get(path, queryParameters: queryParameters, headers: headers, timeout: timeout, abort: abort);
} 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);
final response = await _http.get(
path,
queryParameters: queryParameters,
headers: headers,
timeout: timeout,
abort: abort,
);
appLogger.i('Endpoint failover retry succeeded', error: {'newEndpoint': nextBaseUrl});
return response;
} finally {
_failoverSwitching = false;
}
}
}
bool _shouldAttemptFailover(PlexHttpException e) {
return e.type == PlexHttpErrorType.connectionTimeout ||
e.type == PlexHttpErrorType.receiveTimeout ||
e.type == PlexHttpErrorType.connectionError;
}
/// Fetch /media/providers and parse libraries + EPG providers from the response.
/// This discovers individually shared items that don't appear in /library/sections.
Future<void> _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 = <PlexLibrary>[];
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<String, dynamic>) 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<String, dynamic>.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<void> updateEndpointPreferences(List<String> 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<ConnectionTestResult> testConnectionWithLatency(
String baseUrl,
String token, {
Duration timeout = const Duration(seconds: 5),
String? clientIdentifier,
}) async {
final stopwatch = Stopwatch()..start();
try {
final client = PlexHttpClient(baseUrl: baseUrl, connectTimeout: timeout, receiveTimeout: timeout);
final headers = <String, String>{'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;
return ConnectionTestResult(
success: success,
latencyMs: stopwatch.elapsedMilliseconds,
error: success ? null : 'HTTP ${response.statusCode}',
);
} on PlexHttpException catch (e) {
stopwatch.stop();
String error = switch (e.type) {
PlexHttpErrorType.connectionTimeout => 'Connection timeout',
PlexHttpErrorType.receiveTimeout => 'Receive timeout',
PlexHttpErrorType.connectionError => 'Connection error',
_ => e.type.name,
};
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.runtimeType.toString(),
);
}
}
/// Test connection multiple times and return average latency
static Future<ConnectionTestResult> testConnectionWithAverageLatency(
String baseUrl,
String token, {
int attempts = 3,
Duration timeout = const Duration(seconds: 5),
String? clientIdentifier,
}) async {
final results = <ConnectionTestResult>[];
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<int>(0, (sum, result) => sum + result.latencyMs) ~/ results.length;
return ConnectionTestResult(success: true, latencyMs: avgLatency);
}
// ============================================================================
// API Response Parsing Helpers
// ============================================================================
/// Extract MediaContainer from API response
Map<String, dynamic>? _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<String, dynamic> json) => _tagMetadata(PlexMetadata.fromJson(json));
/// Extract list of PlexMetadata from response
/// Automatically tags all items with this client's serverId and serverName
List<PlexMetadata> _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<String, dynamic>? _getFirstMetadataJson(PlexResponse response) {
final container = _getMediaContainer(response);
if (container != null && container['Metadata'] != null && (container['Metadata'] as List).isNotEmpty) {
return container['Metadata'][0] as Map<String, dynamic>;
}
return null;
}
/// Generic helper to extract and map Directory list from response
List<T> _extractDirectoryList<T>(PlexResponse response, T Function(Map<String, dynamic>) fromJson) {
final container = _getMediaContainer(response);
if (container != null && container['Directory'] != null) {
return (container['Directory'] as List).map((json) => fromJson(json as Map<String, dynamic>)).toList();
}
return [];
}
/// Extract PlexLibrary list from response with auto-tagging
List<PlexLibrary> _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<String, dynamic>).copyWith(serverId: serverId, serverName: serverName),
)
.toList();
}
return [];
}
/// Extract PlexPlaylist list from response with auto-tagging
List<PlexPlaylist> _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<String, dynamic>,
).copyWith(serverId: serverId, serverName: serverName),
)
.toList();
}
return [];
}
// ============================================================================
// API Methods
// ============================================================================
/// Get server identity
Future<Map<String, dynamic>> 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<bool> 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<List<PlexActivity>> 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<String, dynamic>)).toList();
} catch (e) {
appLogger.e('Failed to get activities', error: e);
return [];
}
}
/// Cancel a running background task by its UUID.
Future<void> 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<List<PlexLibrary>> 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<LibraryContentResult> getLibraryContent(
String sectionId, {
int? start,
int? size,
Map<String, String>? filters,
AbortController? abort,
}) async {
final queryParams = <String, dynamic>{};
if (start != null) queryParams['X-Plex-Container-Start'] = start;
if (size != null) queryParams['X-Plex-Container-Size'] = size;
// Add filter parameters
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);
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);
}
/// Parse list of PlexMetadata from a cached response
List<PlexMetadata> _parseMetadataListFromCachedResponse(Map<String, dynamic> cached) {
final metadataList = PlexCacheParser.extractMetadataList(cached);
if (metadataList != null) {
return metadataList.map((json) => _createTaggedMetadata(json)).toList();
}
return [];
}
/// Get the server's machine identifier
Future<String?> 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<String> 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<String> 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<Map<String, dynamic>> 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<Map<String, dynamic>>(
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<PlexMetadata?> getMetadataWithImages(String ratingKey) async {
// Cache key is always the base endpoint (no query params)
final cacheKey = '/library/metadata/$ratingKey';
return _fetchWithCacheFallback<PlexMetadata>(
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<String, dynamic> 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<T?> _fetchWithCacheFallback<T>({
required String cacheKey,
required Future<PlexResponse> 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<T?> _fetchWithCacheFirst<T>({
required String cacheKey,
required Future<PlexResponse> 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<void> _cacheResponseData(String cacheKey, dynamic data) async {
if (data is Map<String, dynamic>) {
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<String, dynamic>? _getFirstMetadataJsonFromData(Map<String, dynamic>? data) =>
PlexCacheParser.extractFirstMetadata(data);
/// Wraps an API call that returns a boolean success status
Future<bool> _wrapBoolApiCall(Future<PlexResponse> 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<List<T>> _wrapListApiCall<T>(
Future<PlexResponse> Function() apiCall,
List<T> Function(PlexResponse response) parseResponse,
String errorMessage,
) async {
try {
final response = await apiCall();
return parseResponse(response);
} catch (e) {
appLogger.e(errorMessage, error: e);
return [];
}
}
/// Parse audio and subtitle tracks from a stream list
({List<PlexAudioTrack> audio, List<PlexSubtitleTrack> subtitles}) _parseStreams(List<dynamic>? streams) {
final audioTracks = <PlexAudioTrack>[];
final subtitleTracks = <PlexSubtitleTrack>[];
if (streams == null) return (audio: audioTracks, subtitles: subtitleTracks);
for (var stream in streams) {
final streamType = stream['streamType'] as int?;
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);
}
/// Parse chapters from metadata JSON
List<PlexChapter> _parseChapters(Map<String, dynamic>? metadataJson) {
if (metadataJson == null || metadataJson['Chapter'] == null) {
return [];
}
final chapterList = metadataJson['Chapter'] as List<dynamic>;
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<PlexMarker> _parseMarkers(Map<String, dynamic>? 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<bool> setMetadataPreferences(String ratingKey, {String? audioLanguage, String? subtitleLanguage}) async {
final queryParams = <String, dynamic>{};
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<bool> selectStreams(int partId, {int? audioStreamID, int? subtitleStreamID, bool allParts = true}) async {
final queryParams = <String, dynamic>{};
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<List<PlexSubtitleSearchResult>> searchSubtitles(
String ratingKey, {
required String language,
String? title,
int hearingImpaired = 0,
int forced = 0,
}) async {
return _wrapListApiCall<PlexSubtitleSearchResult>(
() => _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<String, dynamic>)).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<bool> 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<List<PlexMetadata>> 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,
},
);
final results = <PlexMetadata>[];
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<String, dynamic>) 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<List<PlexMetadata>> 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<List<PlexMetadata>> 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<String, dynamic>, 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 = <PlexMetadata>[];
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<List<PlexMetadata>> getChildren(String ratingKey) async {
final endpoint = '/library/metadata/$ratingKey/children';
return await _fetchWithCacheFallback<List<PlexMetadata>>(
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<List<PlexMetadata>> getExtras(String ratingKey) async {
final endpoint = '/library/metadata/$ratingKey/extras';
return await _fetchWithCacheFallback<List<PlexMetadata>>(
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 '';
// Remove leading slash if present
final path = thumbPath.startsWith('/') ? thumbPath.substring(1) : thumbPath;
return '${config.baseUrl}/$path'.withPlexToken(config.token);
}
/// Download the full BIF (Base Index Frames) file for a given part.
/// Returns the raw bytes, or null on failure.
Future<Uint8List?> 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<PlaybackExtras> getPlaybackExtras(
String ratingKey, {
String? introPattern,
String? creditsPattern,
bool forceRefresh = false,
}) async {
try {
final fetch = forceRefresh ? _fetchWithCacheFallback : _fetchWithCacheFirst;
final data = await fetch<Map<String, dynamic>>(
cacheKey: '/library/metadata/$ratingKey',
networkCall: () =>
_http.get('/library/metadata/$ratingKey', queryParameters: {'includeChapters': 1, 'includeMarkers': 1}),
parseCache: (cached) => cached as Map<String, dynamic>?,
parseResponse: (response) => response.data as Map<String, dynamic>?,
);
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<String, dynamic>? 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<String, dynamic>? metadataJson, {int mediaIndex = 0}) {
String? videoUrl;
PlexMediaInfo? mediaInfo;
List<PlexMediaVersion> 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<String, dynamic>)).toList();
// Ensure the requested index is valid
if (mediaIndex < 0 || mediaIndex >= mediaList.length) {
mediaIndex = 0;
}
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<dynamic>?);
// 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?,
);
}
}
}
}
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<PlexVideoPlaybackData> getVideoPlaybackData(String ratingKey, {int mediaIndex = 0}) async {
Map<String, dynamic>? data;
try {
data = await _fetchWithCacheFallback<Map<String, dynamic>>(
cacheKey: '/library/metadata/$ratingKey',
networkCall: () =>
_http.get('/library/metadata/$ratingKey', queryParameters: {'includeMarkers': 1, 'includeChapters': 1}),
parseCache: (cached) => cached as Map<String, dynamic>?,
parseResponse: (response) => response.data as Map<String, dynamic>?,
);
} 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<PlexFileInfo?> getFileInfo(String ratingKey) async {
try {
final data = await _fetchWithCacheFirst<Map<String, dynamic>>(
cacheKey: '/library/metadata/$ratingKey',
networkCall: () =>
_http.get('/library/metadata/$ratingKey', queryParameters: {'includeMarkers': 1, 'includeChapters': 1}),
parseCache: (cached) => cached as Map<String, dynamic>?,
parseResponse: (response) => response.data as Map<String, dynamic>?,
);
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<dynamic>? ?? [];
Map<String, dynamic>? videoStream;
Map<String, dynamic>? audioStream;
for (var 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<List<dynamic>> 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<void> 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<void> 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<void> 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<CaptureBuffer?> 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<String, dynamic>) return null;
final container = data['MediaContainer'] as Map<String, dynamic>? ?? data;
// Try CaptureBuffer wrapper first, then TranscodeSession directly
final captureBufferWrapper = container['CaptureBuffer'];
if (captureBufferWrapper != null) {
final cbMap = captureBufferWrapper is List
? captureBufferWrapper.firstOrNull as Map<String, dynamic>?
: captureBufferWrapper as Map<String, dynamic>?;
if (cbMap != null) {
final ts = cbMap['TranscodeSession'];
final tsMap = ts is List ? ts.firstOrNull as Map<String, dynamic>? : ts as Map<String, dynamic>?;
if (tsMap != null) return CaptureBuffer.fromTranscodeSession(tsMap);
}
}
final transcodeSessions = container['TranscodeSession'];
if (transcodeSessions is List && transcodeSessions.isNotEmpty) {
return CaptureBuffer.fromTranscodeSession(transcodeSessions.first as Map<String, dynamic>);
} else if (transcodeSessions is Map<String, dynamic>) {
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<void> 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<bool> 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<bool> 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<String, dynamic> _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<void> 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<Map<String, dynamic>> getLibrarySectionPrefs(String sectionId) async {
final response = await _getWithFailover('/library/sections/$sectionId/prefs');
return _parseSettingsMap(response);
}
/// Get available filters for a library section
Future<List<PlexFilter>> 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<List<PlexFirstCharacter>> getFirstCharacters(
String sectionId, {
int? type,
Map<String, String>? filters,
}) async {
final queryParams = <String, dynamic>{};
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<List<PlexFilterValue>> 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<List<PlexSort>> 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<PlexSort> _getFallbackSorts(String? libraryType) {
final fallbackSorts = <PlexSort>[
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<List<PlexHub>> 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<String, dynamic>, 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<List<PlexHub>> 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<String, dynamic>, 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<List<PlexHub>> 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<String, dynamic>,
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<List<PlexMetadata>> getHubContent(String hubKey) async {
return _wrapListApiCall<PlexMetadata>(() => _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
/// Returns the list of metadata items in the playlist
Future<List<PlexMetadata>> getPlaylist(String playlistId) {
return _wrapListApiCall<PlexMetadata>(
() => _http.get('/playlists/$playlistId/items'),
_extractMetadataList,
'Failed to get playlist',
);
}
/// Get all playlists
/// Filters by playlistType=video by default
/// Set smart to true/false to filter smart playlists, or null for all
Future<List<PlexPlaylist>> getPlaylists({String playlistType = 'video', bool? smart}) {
final queryParams = <String, dynamic>{'playlistType': playlistType};
if (smart != null) {
queryParams['smart'] = smart ? '1' : '0';
}
return _wrapListApiCall<PlexPlaylist>(
() => _http.get('/playlists', queryParameters: queryParams),
_extractPlaylistList,
'Failed to get playlists',
);
}
/// Get playlist metadata by playlist ID
/// Returns the playlist details (not the items)
Future<PlexPlaylist?> 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<dynamic> metadata = container['Metadata'] as List;
if (metadata.isEmpty) {
return null;
}
return PlexPlaylist.fromJson(metadata.first as Map<String, dynamic>);
} 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<PlexPlaylist?> createPlaylist({required String title, String? uri, int? playQueueId}) async {
try {
final queryParams = <String, dynamic>{'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<dynamic> metadata = container['Metadata'] as List;
if (metadata.isEmpty) {
return null;
}
return PlexPlaylist.fromJson(metadata.first as Map<String, dynamic>);
} catch (e) {
appLogger.e('Failed to create playlist: $e');
return null;
}
}
/// Delete a playlist
Future<bool> 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<bool> 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<bool> 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<bool> 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<bool> 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<String, ({List<String> current, List<String> original})>? tagChanges,
}) {
final queryParams = <String, dynamic>{'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',
);
}
/// Get available artwork (posters or backgrounds) for a media item
Future<List<Map<String, dynamic>>> 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<Map<String, dynamic>>();
}
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<bool> 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<bool> uploadArtwork(String ratingKey, String element, List<int> 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<bool> updateMetadataPrefs(String ratingKey, Map<String, String> 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<List<PlexMetadata>> getLibraryCollections(String sectionId) async {
return _wrapListApiCall<PlexMetadata>(
() => _http.get('/library/sections/$sectionId/collections', queryParameters: {'includeGuids': 1}),
(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
/// Returns the list of metadata items in the collection
Future<List<PlexMetadata>> getCollectionItems(String collectionId) {
return _wrapListApiCall<PlexMetadata>(
() => _http.get('/library/collections/$collectionId/children'),
_extractMetadataList,
'Failed to get collection items',
);
}
/// Get media featuring a specific person (actor/director)
Future<List<PlexMetadata>> getPersonMedia(String personId) {
return _wrapListApiCall<PlexMetadata>(
() => _http.get('/library/people/$personId/media'),
_extractMetadataList,
'Failed to get person media',
);
}
/// Delete a collection
/// Deletes a library collection from the server
Future<bool> 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<String?> 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<bool> 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<bool> 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<PlayQueueResponse?> createPlayQueue({
String? uri,
int? playlistID,
required String type,
String? key,
int shuffle = 0,
int repeat = 0,
int continuous = 0,
}) async {
try {
final queryParams = <String, dynamic>{
'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<PlayQueueResponse?> getPlayQueue(
int playQueueId, {
String? center,
int window = 50,
int includeBefore = 1,
int includeAfter = 1,
}) async {
try {
final queryParams = <String, dynamic>{
'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<PlayQueueResponse?> 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<PlexMetadata> _extractMetadataAndDirectories(PlexResponse response) {
final List<PlexMetadata> 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<List<PlexMetadata>> 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<List<PlexMetadata>> 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<List<PlexPlaylist>> 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<void> scanLibrary(String sectionId) async {
await _getWithFailover('/library/sections/$sectionId/refresh');
}
/// Refresh metadata for a library section
Future<void> refreshLibraryMetadata(String sectionId) async {
await _getWithFailover('/library/sections/$sectionId/refresh?force=1');
}
/// Empty trash for a library section
Future<void> emptyLibraryTrash(String sectionId) async {
await _http.put('/library/sections/$sectionId/emptyTrash');
}
/// Analyze library section
Future<void> analyzeLibrary(String sectionId) async {
await _getWithFailover('/library/sections/$sectionId/analyze');
}
// ============================================================================
// Live TV / DVR Methods
// ============================================================================
/// Get all DVR devices configured on this server
Future<List<LiveTvDvr>> getDvrs() async {
return _wrapListApiCall<LiveTvDvr>(() => _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<String, dynamic>)).toList();
}
return [];
}, 'Failed to get DVRs');
}
/// Check if this server has at least one DVR configured
Future<bool> hasDvr() async {
final dvrs = await getDvrs();
return dvrs.isNotEmpty;
}
/// Get EPG channels using provider lineup endpoints (matches official Plex web client)
Future<List<LiveTvChannel>> getEpgChannels({String? lineup}) async {
List<LiveTvChannel> 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<String, dynamic>,
).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<String, dynamic>,
).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 = <LiveTvChannel>[];
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<List<({String identifier, String gridEndpoint})>> _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<LiveTvProgram> _parseLiveTvPrograms(List items) {
final programs = <LiveTvProgram>[];
for (final item in items) {
try {
final map = item as Map<String, dynamic>;
final mediaList = (map['Media'] as List?)?.whereType<Map<String, dynamic>>().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 (_) {}
}
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<List<LiveTvProgram>> getEpgGrid({int? beginsAt, int? endsAt}) async {
final providers = await _discoverEpgProviders();
if (providers.isEmpty) return [];
final queryParams = <String, dynamic>{};
if (beginsAt != null) queryParams['endsAt>'] = beginsAt;
if (endsAt != null) queryParams['beginsAt<'] = endsAt;
final allPrograms = <LiveTvProgram>[];
for (final provider in providers) {
try {
final programs = await _wrapListApiCall<LiveTvProgram>(
() => _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<LiveTvProgram> _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 = <LiveTvProgram>[];
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<List<LiveTvHubResult>> getLiveTvHubs({int count = 12}) async {
final providers = await _discoverEpgProviders();
if (providers.isEmpty) return [];
final allHubs = <LiveTvHubResult>[];
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 = <LiveTvHubEntry>[];
for (final itemJson in metadataList) {
if (itemJson is! Map<String, dynamic>) 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<String, dynamic> 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<String, dynamic> 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 _http.post(
'/livetv/dvrs/$dvrKey/channels/$channelIdentifier/tune',
queryParameters: {'X-Plex-Session-Identifier': 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<String, dynamic>? 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<String, dynamic>;
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 [Map firstMetadata, ...]) {
if (firstMetadata['Media'] case [Map firstMedia, ...]) {
final rawBeginsAt = firstMedia['beginsAt'];
beginsAt = switch (rawBeginsAt) {
num n => n.toInt(),
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<String, dynamic>;
final nested = op['Metadata'];
if (nested is Map<String, dynamic>) {
metadataJson = nested;
} else if (nested is List && nested.isNotEmpty) {
metadataJson = nested.first as Map<String, dynamic>;
}
}
}
if (metadataJson == null) {
final fallback = container['Metadata'];
if (fallback is List && fallback.isNotEmpty) {
metadataJson = fallback.first as Map<String, dynamic>;
} else if (fallback is Map<String, dynamic>) {
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<String, dynamic>);
} else if (tsSource is Map<String, dynamic>) {
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<String, dynamic>) {
final rawBeginsAt = firstMedia['beginsAt'];
beginsAt = switch (rawBeginsAt) {
num n => n.toInt(),
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<String?> buildLiveStreamPath({
required String sessionPath,
required String sessionIdentifier,
required String transcodeSessionId,
int? offsetSeconds,
bool directStream = true,
bool directStreamAudio = true,
}) async {
try {
final allParams = <String, String>{
'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<String, String>.from(allParams)..remove('X-Plex-Token');
final startQuery = startParams.entries
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}')
.join('&');
return '/video/:/transcode/universal/start?$startQuery';
} catch (e, st) {
appLogger.e('Failed to build live stream path', error: e, stackTrace: st);
return null;
}
}
/// Get active live TV sessions
Future<List<PlexMetadata>> getLiveTvSessions() {
return _wrapListApiCall<PlexMetadata>(
() => _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<String> 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<List<FavoriteChannel>> 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<String, dynamic>))
.toList();
}
return [];
} catch (e) {
appLogger.e('Failed to get favorite channels', error: e);
return [];
}
}
/// Update favorite channels on the Plex cloud.
Future<void> setFavoriteChannels(List<FavoriteChannel> 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<void> _handleEndpointSwitch(String newBaseUrl) 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 (_onEndpointChanged != null) {
await _onEndpointChanged(newBaseUrl);
}
}
}