2627 lines
96 KiB
Dart
2627 lines
96 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import '../utils/isolate_helper.dart';
|
|
import 'dart:math';
|
|
import 'package:flutter/foundation.dart';
|
|
|
|
import 'package:dio/dio.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_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_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) {
|
|
final container = decoded['MediaContainer'] as Map<String, dynamic>?;
|
|
if (container == null || container['Hub'] == null) return [];
|
|
|
|
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 videoItems = hub.items
|
|
.where((item) => item.isVideoContent)
|
|
.map((item) => item.copyWith(serverId: serverId, serverName: serverName))
|
|
.toList();
|
|
|
|
if (videoItems.isNotEmpty) {
|
|
hubs.add(
|
|
PlexHub(
|
|
hubKey: hub.hubKey,
|
|
title: hub.title,
|
|
type: hub.type,
|
|
hubIdentifier: hub.hubIdentifier,
|
|
size: hub.size,
|
|
more: hub.more,
|
|
items: videoItems,
|
|
serverId: serverId,
|
|
serverName: serverName,
|
|
),
|
|
);
|
|
}
|
|
} catch (_) {
|
|
// Skip hubs that fail to parse
|
|
}
|
|
}
|
|
return hubs;
|
|
}
|
|
|
|
/// Process on-deck response in an isolate.
|
|
/// Top-level function so it can be passed to [Isolate.run].
|
|
List<PlexMetadata> _processOnDeckResponse(Map<String, dynamic> decoded, String serverId, String? serverName) {
|
|
final container = decoded['MediaContainer'] as Map<String, dynamic>?;
|
|
if (container == null || container['Metadata'] == null) return [];
|
|
|
|
final allItems = (container['Metadata'] as List)
|
|
.map(
|
|
(json) => PlexMetadata.fromJsonWithImages(
|
|
json as Map<String, dynamic>,
|
|
).copyWith(serverId: serverId, serverName: serverName),
|
|
)
|
|
.toList();
|
|
|
|
return allItems.where((item) => !item.isMusicContent).toList();
|
|
}
|
|
|
|
/// 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});
|
|
}
|
|
|
|
// Top-level function required by tryIsolateRun()
|
|
String _decodeUtf8(List<int> bytes) {
|
|
return utf8.decode(bytes, allowMalformed: true);
|
|
}
|
|
|
|
class PlexClient {
|
|
PlexConfig config;
|
|
late final Dio _dio;
|
|
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;
|
|
|
|
/// 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;
|
|
|
|
/// Custom response decoder that handles malformed UTF-8 gracefully.
|
|
/// Large responses are decoded in a background isolate to avoid ANR.
|
|
static FutureOr<String> _lenientUtf8Decoder(
|
|
List<int> responseBytes,
|
|
RequestOptions requestOptions,
|
|
ResponseBody responseBody,
|
|
) {
|
|
if (responseBytes.length > 50 * 1024) {
|
|
return tryIsolateRun(() => _decodeUtf8(responseBytes));
|
|
}
|
|
return utf8.decode(responseBytes, allowMalformed: true);
|
|
}
|
|
|
|
/// 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);
|
|
|
|
_dio = Dio(
|
|
BaseOptions(
|
|
baseUrl: config.baseUrl,
|
|
headers: config.headers,
|
|
connectTimeout: ConnectionTimeouts.connect,
|
|
receiveTimeout: ConnectionTimeouts.receive,
|
|
validateStatus: (status) => status != null && status < 500,
|
|
responseType: ResponseType.json,
|
|
contentType: 'application/json; charset=utf-8',
|
|
responseDecoder: _lenientUtf8Decoder,
|
|
),
|
|
);
|
|
_dio.transformer = BackgroundTransformer();
|
|
|
|
// Add interceptor for logging (optional, can be disabled in production)
|
|
_dio.interceptors.add(
|
|
LogInterceptor(requestBody: false, responseBody: false, error: true, requestHeader: false, responseHeader: false),
|
|
);
|
|
|
|
if (_endpointManager != null) {
|
|
_dio.interceptors.add(
|
|
EndpointFailoverInterceptor(
|
|
dio: _dio,
|
|
endpointManager: _endpointManager,
|
|
onEndpointSwitch: _handleEndpointSwitch,
|
|
onAllEndpointsExhausted: _onAllEndpointsExhausted,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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 _dio.get('/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 the token used by this client
|
|
void updateToken(String newToken) {
|
|
// Update both the Dio headers and the config to ensure consistency
|
|
_dio.options.headers['X-Plex-Token'] = newToken;
|
|
config = config.copyWith(token: newToken);
|
|
LogRedactionManager.registerToken(newToken);
|
|
appLogger.d('PlexClient token updated (headers and config)');
|
|
}
|
|
|
|
/// 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 dio = Dio(
|
|
BaseOptions(
|
|
baseUrl: baseUrl,
|
|
connectTimeout: timeout,
|
|
receiveTimeout: timeout,
|
|
validateStatus: (status) => status != null && status < 500,
|
|
responseType: ResponseType.json,
|
|
contentType: 'application/json; charset=utf-8',
|
|
),
|
|
);
|
|
|
|
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 dio.get('/', options: Options(headers: headers));
|
|
|
|
stopwatch.stop();
|
|
final success = response.statusCode == 200;
|
|
|
|
return ConnectionTestResult(
|
|
success: success,
|
|
latencyMs: stopwatch.elapsedMilliseconds,
|
|
error: success ? null : 'HTTP ${response.statusCode}',
|
|
);
|
|
} catch (e) {
|
|
stopwatch.stop();
|
|
String error;
|
|
if (e is DioException) {
|
|
error = switch (e.type) {
|
|
DioExceptionType.connectionTimeout => 'Connection timeout',
|
|
DioExceptionType.receiveTimeout => 'Receive timeout',
|
|
DioExceptionType.connectionError => 'Connection error',
|
|
_ => e.type.name,
|
|
};
|
|
if (e.response?.statusCode != null) {
|
|
error += ' (HTTP ${e.response!.statusCode})';
|
|
}
|
|
} else {
|
|
error = e.runtimeType.toString();
|
|
}
|
|
return ConnectionTestResult(success: false, latencyMs: stopwatch.elapsedMilliseconds, error: error);
|
|
}
|
|
}
|
|
|
|
/// 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(Response 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(Response 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(Response 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>(Response 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(Response 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(Response 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 _dio.get('/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 _dio.get('/identity');
|
|
return response.statusCode == 200;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// 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 _dio.get('/library/sections');
|
|
return _extractLibraryList(response);
|
|
}
|
|
|
|
/// Get library content by section ID
|
|
Future<LibraryContentResult> getLibraryContent(
|
|
String sectionId, {
|
|
int? start,
|
|
int? size,
|
|
Map<String, String>? filters,
|
|
CancelToken? cancelToken,
|
|
}) 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 _dio.get(
|
|
endpoint,
|
|
queryParameters: queryParams,
|
|
cancelToken: cancelToken,
|
|
);
|
|
|
|
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 _dio.get('/');
|
|
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: () => _dio.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: () =>
|
|
_dio.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<Response> Function() networkCall,
|
|
required T? Function(dynamic cachedData) parseCache,
|
|
required T? Function(Response 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<Response> Function() networkCall,
|
|
required T? Function(dynamic cachedData) parseCache,
|
|
required T? Function(Response 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<Response> 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<Response> Function() apiCall,
|
|
List<T> Function(Response 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: stream['selected'] == 1 || stream['selected'] == true,
|
|
),
|
|
);
|
|
} 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: stream['selected'] == 1 || stream['selected'] == true,
|
|
forced: stream['forced'] == 1,
|
|
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(
|
|
() => _dio.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(
|
|
() => _dio.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 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 _dio.get(
|
|
'/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 _dio.get(
|
|
'/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 on deck items (continue watching, filtered to video content only)
|
|
Future<List<PlexMetadata>> getOnDeck() async {
|
|
final response = await _dio.get('/library/onDeck');
|
|
final sid = serverId;
|
|
final sname = serverName;
|
|
return await tryIsolateRun(() => _processOnDeckResponse(response.data as Map<String, dynamic>, sid, sname));
|
|
}
|
|
|
|
/// 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: () => _dio.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: () => _dio.get(endpoint),
|
|
parseCache: (cachedData) => _parseMetadataListFromCachedResponse(cachedData),
|
|
parseResponse: (response) => _extractMetadataList(response),
|
|
) ??
|
|
[];
|
|
}
|
|
|
|
/// Get all unwatched episodes for a TV show across all seasons
|
|
Future<List<PlexMetadata>> getAllUnwatchedEpisodes(String showRatingKey) async {
|
|
final allEpisodes = <PlexMetadata>[];
|
|
|
|
// Get all seasons for the show
|
|
final seasons = await getChildren(showRatingKey);
|
|
|
|
// Get episodes from each season
|
|
for (final season in seasons) {
|
|
if (season.isSeason) {
|
|
final episodes = await getChildren(season.ratingKey);
|
|
|
|
// Filter for unwatched episodes
|
|
final unwatchedEpisodes = episodes.where((ep) => ep.isEpisode && (ep.viewCount ?? 0) == 0).toList();
|
|
|
|
allEpisodes.addAll(unwatchedEpisodes);
|
|
}
|
|
}
|
|
|
|
return allEpisodes;
|
|
}
|
|
|
|
/// Get all unwatched episodes in a specific season
|
|
Future<List<PlexMetadata>> getUnwatchedEpisodesInSeason(String seasonRatingKey) async {
|
|
final episodes = await getChildren(seasonRatingKey);
|
|
|
|
// Filter for unwatched episodes
|
|
return episodes.where((ep) => ep.isEpisode && (ep.viewCount ?? 0) == 0).toList();
|
|
}
|
|
|
|
/// 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 response = await _dio.get<List<int>>(
|
|
'/library/parts/$partId/indexes/sd',
|
|
options: Options(responseType: ResponseType.bytes, receiveTimeout: const Duration(seconds: 30)),
|
|
);
|
|
if (response.statusCode == 200 && response.data != null) {
|
|
return Uint8List.fromList(response.data!);
|
|
}
|
|
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: () =>
|
|
_dio.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: () =>
|
|
_dio.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: () =>
|
|
_dio.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
|
|
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;
|
|
}
|
|
}
|
|
|
|
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: media['optimizedForStreaming'] as bool?,
|
|
has64bitOffsets: media['has64bitOffsets'] as bool?,
|
|
// 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?,
|
|
colorTrc: videoStream?['colorTrc'] as String?,
|
|
chromaSubsampling: videoStream?['chromaSubsampling'] as String?,
|
|
frameRate: (videoStream?['frameRate'] as num?)?.toDouble(),
|
|
bitDepth: videoStream?['bitDepth'] as int?,
|
|
// Audio stream details
|
|
audioChannelLayout: audioStream?['audioChannelLayout'] as String?,
|
|
);
|
|
}
|
|
|
|
return null;
|
|
} catch (e) {
|
|
appLogger.e('Failed to get file info: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Mark media as watched
|
|
///
|
|
/// If [metadata] is provided, emits a [WatchStateEvent] for UI updates.
|
|
Future<void> markAsWatched(String ratingKey, {PlexMetadata? metadata}) async {
|
|
await _dio.get('/:/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 _dio.get('/:/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 _dio.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.
|
|
Future<void> 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 _dio.get(
|
|
'/:/timeline',
|
|
queryParameters: {
|
|
'ratingKey': ratingKey,
|
|
'key': sessionPath,
|
|
'state': state,
|
|
'hasMDE': '1',
|
|
'time': time,
|
|
'duration': duration,
|
|
'playbackTime': playbackTime,
|
|
'X-Plex-Session-Identifier': sessionIdentifier,
|
|
},
|
|
);
|
|
if (response.statusCode != null && response.statusCode != 200) {
|
|
appLogger.e('Live timeline returned ${response.statusCode}: ${response.data}');
|
|
}
|
|
}
|
|
|
|
/// 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 _dio.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(
|
|
() => _dio.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(() => _dio.delete('/library/metadata/$ratingKey'), 'Failed to delete media item');
|
|
}
|
|
|
|
/// Get server preferences
|
|
Future<Map<String, dynamic>> getServerPreferences() async {
|
|
final response = await _dio.get('/:/prefs');
|
|
return response.data;
|
|
}
|
|
|
|
/// 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 _dio.get('/library/sections/$sectionId/prefs');
|
|
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']};
|
|
}
|
|
|
|
/// Get sessions (currently playing)
|
|
Future<List<dynamic>> getSessions() async {
|
|
final response = await _dio.get('/status/sessions');
|
|
final container = _getMediaContainer(response);
|
|
if (container != null && container['Metadata'] != null) {
|
|
return container['Metadata'] as List;
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/// Get available filters for a library section
|
|
Future<List<PlexFilter>> getLibraryFilters(String sectionId) async {
|
|
if (sectionId == 'shared') return [];
|
|
final response = await _dio.get('/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 _dio.get('/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 _dio.get(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 _dio.get('/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 _dio.get(
|
|
'/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 _dio.get('/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 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>(() => _dio.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>(
|
|
() => _dio.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>(
|
|
() => _dio.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 _dio.get('/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 _dio.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(() => _dio.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(
|
|
() => _dio.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(
|
|
() => _dio.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(
|
|
() => _dio.put(
|
|
'/playlists/$playlistId/items/$playlistItemId/move',
|
|
queryParameters: {'after': afterPlaylistItemId},
|
|
),
|
|
'Failed to move playlist item',
|
|
);
|
|
if (result) {
|
|
appLogger.d('Successfully moved playlist item');
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// Clear all items from a playlist
|
|
Future<bool> clearPlaylist(String playlistId) {
|
|
return _wrapBoolApiCall(() => _dio.delete('/playlists/$playlistId/items'), 'Failed to clear playlist');
|
|
}
|
|
|
|
/// Update playlist metadata (e.g., title, summary)
|
|
/// Uses the same metadata editing mechanism as other items
|
|
Future<bool> updatePlaylist({required String playlistId, String? title, String? summary}) {
|
|
final queryParams = <String, dynamic>{'type': 'playlist', 'id': playlistId};
|
|
|
|
if (title != null) {
|
|
queryParams['title.value'] = title;
|
|
queryParams['title.locked'] = '1';
|
|
}
|
|
if (summary != null) {
|
|
queryParams['summary.value'] = summary;
|
|
queryParams['summary.locked'] = '1';
|
|
}
|
|
|
|
return _wrapBoolApiCall(
|
|
() => _dio.put('/library/metadata/$playlistId', queryParameters: queryParams),
|
|
'Failed to update playlist',
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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,
|
|
}) {
|
|
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);
|
|
|
|
return _wrapBoolApiCall(
|
|
() => _dio.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 _dio.get('/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(
|
|
() => _dio.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(
|
|
() => _dio.put(
|
|
'/library/metadata/$ratingKey/$setElement',
|
|
data: bytes,
|
|
options: Options(headers: {'Content-Length': bytes.length}, contentType: 'application/octet-stream'),
|
|
),
|
|
'Failed to upload artwork',
|
|
);
|
|
}
|
|
|
|
/// Update per-media advanced preferences
|
|
Future<bool> updateMetadataPrefs(String ratingKey, Map<String, String> prefs) {
|
|
return _wrapBoolApiCall(
|
|
() => _dio.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>(
|
|
() => _dio.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>(
|
|
() => _dio.get('/library/collections/$collectionId/children'),
|
|
_extractMetadataList,
|
|
'Failed to get collection items',
|
|
);
|
|
}
|
|
|
|
/// 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(
|
|
() => _dio.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 _dio.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(
|
|
() => _dio.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(
|
|
() => _dio.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 _dio.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 _dio.get('/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;
|
|
}
|
|
}
|
|
|
|
/// Shuffle a play queue
|
|
/// The currently selected item is maintained
|
|
Future<PlayQueueResponse?> shufflePlayQueue(int playQueueId) async {
|
|
try {
|
|
final response = await _dio.put('/playQueues/$playQueueId/shuffle');
|
|
return PlayQueueResponse.fromJson(response.data);
|
|
} catch (e) {
|
|
appLogger.e('Failed to shuffle play queue: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Clear all items from a play queue
|
|
Future<bool> clearPlayQueue(int playQueueId) {
|
|
return _wrapBoolApiCall(() => _dio.delete('/playQueues/$playQueueId/items'), 'Failed to clear play queue');
|
|
}
|
|
|
|
/// 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 {
|
|
// Get machine identifier for building the URI
|
|
final machineId = config.machineIdentifier ?? await getMachineIdentifier();
|
|
if (machineId == null) {
|
|
throw Exception('Could not get server machine identifier');
|
|
}
|
|
|
|
// Build the URI for the show's episodes
|
|
final uri = 'server://$machineId/com.plexapp.plugins.library/library/metadata/$showRatingKey/children';
|
|
|
|
// Create the play queue with optional starting episode
|
|
return await createPlayQueue(
|
|
uri: uri,
|
|
type: 'video',
|
|
shuffle: shuffle,
|
|
key: startingEpisodeKey != null ? '/library/metadata/$startingEpisodeKey' : null,
|
|
);
|
|
} 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(Response 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 _dio.get(
|
|
'/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 _dio.get(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 _dio.get('/library/sections/$sectionId/refresh');
|
|
}
|
|
|
|
/// Refresh metadata for a library section
|
|
Future<void> refreshLibraryMetadata(String sectionId) async {
|
|
await _dio.get('/library/sections/$sectionId/refresh?force=1');
|
|
}
|
|
|
|
/// Empty trash for a library section
|
|
Future<void> emptyLibraryTrash(String sectionId) async {
|
|
await _dio.put('/library/sections/$sectionId/emptyTrash');
|
|
}
|
|
|
|
/// Analyze library section
|
|
Future<void> analyzeLibrary(String sectionId) async {
|
|
await _dio.get('/library/sections/$sectionId/analyze');
|
|
}
|
|
|
|
// ============================================================================
|
|
// Library Statistics Methods
|
|
// ============================================================================
|
|
|
|
/// Get total item count for a library section efficiently.
|
|
/// Uses X-Plex-Container-Size: 1 to get totalSize with minimal data transfer.
|
|
Future<int> getLibraryTotalCount(String sectionId) async {
|
|
try {
|
|
final response = await _dio.get(
|
|
'/library/sections/$sectionId/all',
|
|
queryParameters: {'X-Plex-Container-Start': 0, 'X-Plex-Container-Size': 1},
|
|
);
|
|
final container = _getMediaContainer(response);
|
|
// Try totalSize first, fall back to size if not available
|
|
return container?['totalSize'] as int? ?? container?['size'] as int? ?? 0;
|
|
} catch (e) {
|
|
appLogger.e('Failed to get library total count: $e');
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/// Get total episode count for a TV show library.
|
|
/// Uses the allLeaves endpoint to count all episodes.
|
|
Future<int> getLibraryEpisodeCount(String sectionId) async {
|
|
try {
|
|
final response = await _dio.get(
|
|
'/library/sections/$sectionId/allLeaves',
|
|
queryParameters: {'X-Plex-Container-Start': 0, 'X-Plex-Container-Size': 1},
|
|
);
|
|
final container = _getMediaContainer(response);
|
|
return container?['totalSize'] as int? ?? container?['size'] as int? ?? 0;
|
|
} catch (e) {
|
|
appLogger.e('Failed to get library episode count: $e');
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
/// Get watch history count for a time period.
|
|
/// [since] - Optional DateTime to filter history from this date onwards.
|
|
/// Returns the total count of items watched.
|
|
Future<int> getWatchHistoryCount({DateTime? since}) async {
|
|
try {
|
|
final queryParams = <String, dynamic>{'X-Plex-Container-Start': 0, 'X-Plex-Container-Size': 1};
|
|
if (since != null) {
|
|
final epochSeconds = since.millisecondsSinceEpoch ~/ 1000;
|
|
queryParams['viewedAt>'] = epochSeconds;
|
|
}
|
|
final response = await _dio.get('/status/sessions/history/all', queryParameters: queryParams);
|
|
final container = _getMediaContainer(response);
|
|
return container?['totalSize'] as int? ?? container?['size'] as int? ?? 0;
|
|
} catch (e) {
|
|
appLogger.e('Failed to get watch history count: $e');
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Live TV / DVR Methods
|
|
// ============================================================================
|
|
|
|
/// Get all DVR devices configured on this server
|
|
Future<List<LiveTvDvr>> getDvrs() async {
|
|
return _wrapListApiCall<LiveTvDvr>(() => _dio.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 for a specific lineup
|
|
Future<List<LiveTvChannel>> getEpgChannels({String? lineup}) async {
|
|
final queryParams = <String, dynamic>{};
|
|
if (lineup != null) queryParams['lineup'] = lineup;
|
|
|
|
return _wrapListApiCall<LiveTvChannel>(() => _dio.get('/livetv/epg/channels', queryParameters: queryParams), (
|
|
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();
|
|
}
|
|
// Also check for Metadata key (some endpoints return channels there)
|
|
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 [];
|
|
}, 'Failed to get EPG channels');
|
|
}
|
|
|
|
/// 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.
|
|
List<LiveTvProgram> _parseLiveTvPrograms(List items) {
|
|
final programs = <LiveTvProgram>[];
|
|
for (final item in items) {
|
|
try {
|
|
programs.add(LiveTvProgram.fromJson(item as Map<String, dynamic>));
|
|
} 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>(
|
|
() => _dio.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(Response 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 _dio.get(
|
|
'/${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();
|
|
}
|
|
|
|
/// Tune to a live TV channel and set up the transcode session.
|
|
///
|
|
/// Flow: tune → decision → return /start path (MKV-over-HTTP).
|
|
Future<({PlexMetadata metadata, String streamPath, String sessionIdentifier, String sessionPath})?> tuneChannel(
|
|
String dvrKey,
|
|
String channelIdentifier,
|
|
) async {
|
|
try {
|
|
final sessionIdentifier = _generateSessionIdentifier();
|
|
|
|
final response = await _dio.post(
|
|
'/livetv/dvrs/$dvrKey/channels/$channelIdentifier/tune',
|
|
queryParameters: {'X-Plex-Session-Identifier': sessionIdentifier},
|
|
);
|
|
|
|
if (response.statusCode != null && 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'];
|
|
if (containerStatus != null && containerStatus != 0 && containerStatus != 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
|
|
Map<String, dynamic>? metadataJson;
|
|
final subscriptions = container['MediaSubscription'] as List?;
|
|
if (subscriptions != null && subscriptions.isNotEmpty) {
|
|
final sub = subscriptions.first as Map<String, dynamic>;
|
|
final ops = sub['MediaGrabOperation'] as List?;
|
|
if (ops != null && ops.isNotEmpty) {
|
|
final op = ops.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>;
|
|
}
|
|
}
|
|
}
|
|
metadataJson ??= (container['Metadata'] as List?)?.firstOrNull as Map<String, dynamic>?;
|
|
|
|
if (metadataJson == null) {
|
|
appLogger.w('Tune channel failed: ${container['message'] ?? 'no metadata'} (status: ${container['status']}, keys: ${container.keys.toList()})');
|
|
return null;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// All identity goes in query params; the only HTTP header is Accept-Language
|
|
// (matching the official Plex client behaviour).
|
|
final allParams = <String, String>{
|
|
'hasMDE': '1',
|
|
'path': sessionPath,
|
|
'mediaIndex': '0',
|
|
'partIndex': '0',
|
|
'protocol': 'http',
|
|
'fastSeek': '1',
|
|
'directPlay': '0',
|
|
'directStream': '1',
|
|
'subtitleSize': '100',
|
|
'audioBoost': '100',
|
|
'location': 'lan',
|
|
'addDebugOverlay': '0',
|
|
'autoAdjustQuality': '0',
|
|
'directStreamAudio': '1',
|
|
'advancedSubtitles': 'text',
|
|
'mediaBufferSize': '157286',
|
|
'session': _generateSessionIdentifier(),
|
|
'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 (config.token != null) 'X-Plex-Token': config.token!,
|
|
};
|
|
|
|
// Manual query encoding — Dio encodes spaces as '+' but Plex requires '%20'.
|
|
final queryString = allParams.entries
|
|
.map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}')
|
|
.join('&');
|
|
|
|
// Decision — bare Dio so no default X-Plex-* HTTP headers leak through.
|
|
final decisionDio = Dio(
|
|
BaseOptions(
|
|
headers: {'Accept-Language': 'en'},
|
|
connectTimeout: ConnectionTimeouts.connect,
|
|
receiveTimeout: ConnectionTimeouts.receive,
|
|
),
|
|
);
|
|
final decisionUrl = '${config.baseUrl}/video/:/transcode/universal/decision?$queryString';
|
|
final decisionResponse = await decisionDio.getUri(Uri.parse(decisionUrl));
|
|
|
|
if (decisionResponse.statusCode != 200) {
|
|
appLogger.w('Decision returned ${decisionResponse.statusCode}');
|
|
return null;
|
|
}
|
|
|
|
// 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 (
|
|
metadata: metadata,
|
|
streamPath: '/video/:/transcode/universal/start?$startQuery',
|
|
sessionIdentifier: sessionIdentifier,
|
|
sessionPath: sessionPath,
|
|
);
|
|
} catch (e, st) {
|
|
appLogger.e('Failed to tune channel', error: e, stackTrace: st);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Get active live TV sessions
|
|
Future<List<PlexMetadata>> getLiveTvSessions() {
|
|
return _wrapListApiCall<PlexMetadata>(
|
|
() => _dio.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 _dio.get(
|
|
_favoriteChannelsUrl,
|
|
options: Options(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 _dio.put(
|
|
_favoriteChannelsUrl,
|
|
data: channels.map((c) => c.toJson()).toList(),
|
|
options: Options(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);
|
|
_dio.options.baseUrl = newBaseUrl;
|
|
config = config.copyWith(baseUrl: newBaseUrl);
|
|
LogRedactionManager.registerServerUrl(newBaseUrl);
|
|
|
|
if (_onEndpointChanged != null) {
|
|
await _onEndpointChanged(newBaseUrl);
|
|
}
|
|
}
|
|
}
|