Files
plezy/lib/services/plex_client.dart
T

4519 lines
169 KiB
Dart

import 'dart:async';
import '../utils/isolate_helper.dart';
import '../utils/json_utils.dart';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import 'package:uuid/uuid.dart';
import '../media/download_resolution.dart';
import '../media/library_filter_result.dart';
import '../media/library_first_character.dart';
import '../media/library_query.dart';
import '../media/live_tv_support.dart';
import '../media/lyrics.dart';
import '../media/media_backend.dart';
import '../media/media_hub.dart';
import '../media/media_item.dart';
import '../media/media_kind.dart';
import '../media/media_library.dart';
import '../media/media_playlist.dart';
import '../media/ids.dart';
import '../media/media_server_client.dart';
import '../media/playback_report_metadata.dart';
import '../media/server_capabilities.dart';
import '../utils/external_ids.dart';
import 'bif_thumbnail_service.dart';
import 'download_artwork_helpers.dart';
import 'settings_service.dart';
import 'library_query_translator.dart';
import 'scrub_preview_source.dart';
import '../utils/media_server_http_client.dart';
import '../exceptions/media_server_exceptions.dart';
import '../models/livetv_capture_buffer.dart';
import '../models/livetv_channel.dart';
import '../models/livetv_dvr.dart';
import '../models/livetv_hub_result.dart';
import '../models/livetv_lineup.dart';
import '../models/livetv_program.dart';
import '../models/livetv_server_status.dart';
import '../models/livetv_session.dart';
import '../models/media_grab_operation.dart';
import '../models/media_grabber_device.dart';
import '../models/media_provider_info.dart';
import '../models/media_subscription.dart';
import '../models/plex/plex_activity.dart';
import '../models/plex/plex_config.dart';
import '../models/plex/play_queue_response.dart';
import '../media/media_file_info.dart';
import '../media/media_filter.dart';
import '../media/media_source_info.dart';
import '../models/plex/plex_subtitle_search_result.dart';
import '../models/plex/plex_match_result.dart';
import '../utils/codec_utils.dart';
import '../utils/content_utils.dart';
import '../media/media_sort.dart';
import '../models/audio_quality_preset.dart';
import '../models/plex/plex_video_playback_data.dart';
import '../models/transcode_quality_preset.dart';
import '../utils/device_identity.dart';
import '../utils/lrc_parser.dart';
import '../utils/failover_http_client.dart';
import '../utils/app_logger.dart';
import '../utils/media_server_retry.dart';
import '../utils/media_server_timeouts.dart';
import '../utils/log_redaction_manager.dart';
import '../utils/plex_cache_parser.dart';
import '../utils/plex_library_section_utils.dart';
import '../utils/plex_url_helper.dart';
import '../utils/session_identifier.dart' as session_id;
import '../i18n/strings.g.dart';
import '../mpv/mpv.dart';
import 'api_cache.dart';
import 'plex_api_cache.dart';
import 'plex_constants.dart';
import 'plex_mappers.dart';
import 'plex_playback_mapper.dart';
import 'playback_initialization_types.dart';
part 'plex_client/parts/live_tv.dart';
/// Result of a paginated library content fetch
class _LibraryContentResult {
final List<PlexMetadataDto> 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<PlexHubDto> _processHubResponse(
Map<String, dynamic> decoded,
ServerId serverId,
String? serverName, {
int? librarySectionID,
String? librarySectionTitle,
bool Function(PlexMetadataDto)? filter,
}) {
final container = decoded['MediaContainer'] as Map<String, dynamic>?;
if (container == null || container['Hub'] == null) return [];
final containerSectionID = _librarySectionIdFromJson(container) ?? librarySectionID;
final containerSectionTitle = _librarySectionTitleFromJson(container) ?? librarySectionTitle;
final itemFilter = filter ?? (PlexMetadataDto item) => ContentTypes.videoTypes.contains(item.type?.toLowerCase());
final hubs = <PlexHubDto>[];
for (final hubJson in container['Hub'] as List) {
try {
final hubMap = hubJson as Map<String, dynamic>;
final hubSectionID = _librarySectionIdFromJson(hubMap) ?? containerSectionID;
final hubSectionTitle = _librarySectionTitleFromJson(hubMap) ?? containerSectionTitle;
final hub = _plexHubWithLibrarySection(
PlexHubDto.fromJson(hubMap, serverId: ServerId(serverId), serverName: serverName),
librarySectionID: hubSectionID,
librarySectionTitle: hubSectionTitle,
);
if (hub.items.isEmpty) continue;
final filteredItems = hub.items.where(itemFilter).toList();
if (filteredItems.isNotEmpty) {
hubs.add(
PlexHubDto(
hubKey: hub.hubKey,
title: hub.title,
type: hub.type,
hubIdentifier: hub.hubIdentifier,
size: hub.size,
more: hub.more,
items: filteredItems,
serverId: serverId,
serverName: serverName,
),
);
}
} catch (_) {
// Skip hubs that fail to parse
}
}
return hubs;
}
int? _librarySectionIdFromJson(Map<String, dynamic>? json) => plexLibrarySectionIdFromJson(json);
int? _librarySectionIdFromString(String? sectionId) => plexLibrarySectionIdFromString(sectionId);
String? _librarySectionTitleFromJson(Map<String, dynamic>? json) => plexLibrarySectionTitleFromJson(json);
PlexMetadataDto _plexMetadataWithLibrarySection(
PlexMetadataDto metadata, {
int? librarySectionID,
String? librarySectionTitle,
}) {
final nextSectionID = metadata.librarySectionID ?? librarySectionID;
final nextSectionTitle = metadata.librarySectionTitle ?? librarySectionTitle;
if (nextSectionID == metadata.librarySectionID && nextSectionTitle == metadata.librarySectionTitle) {
return metadata;
}
return metadata.copyWith(librarySectionID: nextSectionID, librarySectionTitle: nextSectionTitle);
}
PlexHubDto _plexHubWithLibrarySection(PlexHubDto hub, {int? librarySectionID, String? librarySectionTitle}) {
if (librarySectionID == null && librarySectionTitle == null) return hub;
return PlexHubDto(
hubKey: hub.hubKey,
title: hub.title,
type: hub.type,
hubIdentifier: hub.hubIdentifier,
size: hub.size,
more: hub.more,
items: hub.items
.map(
(item) => _plexMetadataWithLibrarySection(
item,
librarySectionID: librarySectionID,
librarySectionTitle: librarySectionTitle,
),
)
.toList(),
serverId: hub.serverId,
serverName: hub.serverName,
);
}
// PlexStreamType moved to plex_constants.dart to break a would-be circular
// import once plex_mappers.dart started referencing the same names.
/// Result of testing a connection, including success status and latency
class ConnectionTestResult {
final bool success;
final int latencyMs;
final String? error;
/// `transcoderVideo` from the `/` MediaContainer, captured on successful
/// probes so the connection race doubles as a capability probe. `null`
/// when the probe didn't succeed or the field was absent.
final bool? transcoderVideo;
ConnectionTestResult({required this.success, required this.latencyMs, this.error, this.transcoderVideo});
}
bool? _parsePlexTranscoderVideoCapability(Object? value) {
return switch (value) {
final bool b => b,
final int n when n == 1 => true,
final int n when n == 0 => false,
final String s when s.trim().toLowerCase() == 'true' || s.trim() == '1' => true,
final String s when s.trim().toLowerCase() == 'false' || s.trim() == '0' => false,
_ => null,
};
}
class PlexClient
with MediaServerCacheMixin, _PlexLiveTvClientMethods
implements MediaServerClient, SeasonEpisodePagingClient, GracefullyCloseable {
@override
PlexConfig config;
@override
late final FailoverHttpClient _http;
final Future<void> Function(String newBaseUrl)? _onEndpointChanged;
final VoidCallback? _onAllEndpointsExhausted;
/// Server identifier - all PlexMetadataDto items created by this client are tagged with this
@override
final ServerId serverId;
/// Server name - all PlexMetadataDto items created by this client are tagged with this
@override
final String? serverName;
/// API response cache for offline support
final PlexApiCache _cache = PlexApiCache.instance;
/// Expose the cache through the [MediaServerClient] interface so the shared
/// `fetchWithCacheFallback` / `fetchWithCacheFirst` helpers route through
/// the Plex-specific cache substrate.
@override
ApiCache get cache => _cache;
/// Whether to operate in offline mode (use cache only)
bool _offlineMode = false;
/// Cached result of [serverSupportsVideoTranscoding]. `null` = not yet fetched.
bool? _serverTranscoderCached;
/// In-flight probe for [serverSupportsVideoTranscoding], used to dedupe
/// concurrent callers (e.g. the post-connect warm-up racing the first
/// playback).
Future<bool>? _serverTranscoderPending;
/// Libraries parsed from /media/providers (includes individually shared items)
List<PlexLibraryDto> _providerLibraries = const [];
/// Home hub endpoint advertised by /media/providers (usually /hubs).
String? _providerHomeHubKey;
/// Promoted home hub endpoint advertised by /media/providers (usually /hubs/promoted).
String? _providerPromotedHubKey;
/// Dedicated Continue Watching hub endpoint advertised by /media/providers.
String? _providerContinueWatchingHubKey;
/// EPG providers parsed from /media/providers
@override
List<({String identifier, String gridEndpoint})> _providerEpg = const [];
/// Server-level preferences fetched from /:/prefs
Map<String, dynamic> _serverPrefs = {};
/// Get all fetched server preferences
Map<String, dynamic> get serverPrefs => Map.unmodifiable(_serverPrefs);
/// Get the server's watched threshold percentage (default 90)
int get watchedThresholdPercent {
final value = _serverPrefs['LibraryVideoPlayedThreshold'];
if (value is int) return value;
if (value is String) return int.tryParse(value) ?? 90;
return 90;
}
/// Set offline mode - when true, only cached responses are returned
@override
void setOfflineMode(bool offline) {
_offlineMode = offline;
}
/// Get current offline mode state
@override
bool get isOfflineMode => _offlineMode;
/// Create a fully initialized PlexClient.
/// Fetches /media/providers to discover libraries (including individually shared items) and EPG providers.
static Future<PlexClient> create(
PlexConfig config, {
required ServerId serverId,
String? serverName,
List<String>? prioritizedEndpoints,
Future<void> Function(String newBaseUrl)? onEndpointChanged,
VoidCallback? onAllEndpointsExhausted,
bool? seedTranscoderVideoSupport,
http.Client? httpClient,
}) async {
final client = PlexClient._(
config,
serverId: ServerId(serverId),
serverName: serverName,
prioritizedEndpoints: prioritizedEndpoints,
onEndpointChanged: onEndpointChanged,
onAllEndpointsExhausted: onAllEndpointsExhausted,
httpClient: httpClient,
);
if (seedTranscoderVideoSupport != null) {
client._serverTranscoderCached = seedTranscoderVideoSupport;
}
await client._initMediaProviders();
// If the connection race didn't seed the capability, warm the cache in
// the background so the first playback doesn't pay the probe cost on its
// hot path.
if (seedTranscoderVideoSupport == null) {
unawaited(client.serverSupportsVideoTranscoding());
}
return client;
}
PlexClient._(
this.config, {
required this.serverId,
this.serverName,
List<String>? prioritizedEndpoints,
this._onEndpointChanged,
this._onAllEndpointsExhausted,
http.Client? httpClient,
}) {
LogRedactionManager.registerServer(config.baseUrl, config.token);
_http = FailoverHttpClient(
baseUrl: config.baseUrl,
defaultHeaders: config.headers,
connectTimeout: MediaServerTimeouts.connect,
receiveTimeout: MediaServerTimeouts.receive,
usePlexApiClient: true,
client: httpClient,
logLabel: 'Plex',
prioritizedEndpoints: prioritizedEndpoints ?? const [],
onEndpointSwitch: (newBaseUrl, {required persist}) => _handleEndpointSwitch(newBaseUrl, persist: persist),
onAllEndpointsExhausted: _onAllEndpointsExhausted,
);
}
/// Test-only factory that injects an [http.Client] so URL-builder tests can
/// capture the request URI without spinning up a real Plex server. Mirrors
/// [JellyfinClient.forTesting]. Skips the [_initMediaProviders] step from
/// [create] — tests that need libraries should mock the `/media/providers`
/// response themselves.
@visibleForTesting
static PlexClient forTesting({
required PlexConfig config,
required ServerId serverId,
String? serverName,
required http.Client httpClient,
List<String>? prioritizedEndpoints,
List<({String identifier, String gridEndpoint})> epgProviders = const [],
String? homeHubKey,
String? promotedHubKey,
String? continueWatchingHubKey,
}) {
final client = PlexClient._(
config,
serverId: ServerId(serverId),
serverName: serverName,
httpClient: httpClient,
prioritizedEndpoints: prioritizedEndpoints,
);
client._providerLibraries = const [];
client._providerEpg = epgProviders;
client._providerHomeHubKey = homeHubKey;
client._providerPromotedHubKey = promotedHubKey;
client._providerContinueWatchingHubKey = continueWatchingHubKey;
return client;
}
@override
void close() {
_http.close();
}
@override
Future<void> closeGracefully({Duration drainTimeout = const Duration(seconds: 2)}) {
return _http.closeGracefully(drainTimeout: drainTimeout);
}
/// Execute a GET request with endpoint failover (see [FailoverHttpClient]
/// for the shared semantics) and Plex's status-code policy: non-2xx
/// responses throw so callers don't blindly cast error bodies.
/// Optional hub surfaces disable endpoint failover so a slow row does not
/// move the whole client away from an otherwise working endpoint.
@override
Future<MediaServerResponse> _getWithFailover(
String path, {
Map<String, dynamic>? queryParameters,
Map<String, String>? headers,
Duration? timeout,
AbortController? abort,
bool allowEndpointFailover = true,
}) async {
final response = await _http.get(
path,
queryParameters: queryParameters,
headers: headers,
timeout: timeout,
abort: abort,
allowEndpointFailover: allowEndpointFailover,
);
throwIfHttpError(response);
return response;
}
/// Fetch /media/providers and parse libraries + EPG providers from the response.
/// This discovers individually shared items that don't appear in /library/sections.
Future<void> _initMediaProviders() async {
try {
final response = await _getWithFailover('/media/providers');
final container = _getMediaContainer(response);
if (container == null) {
_providerLibraries = [];
_providerEpg = [];
_providerHomeHubKey = null;
_providerPromotedHubKey = null;
_providerContinueWatchingHubKey = null;
return;
}
final providers = container['MediaProvider'] as List?;
if (providers == null) {
_providerLibraries = [];
_providerEpg = [];
_providerHomeHubKey = null;
_providerPromotedHubKey = null;
_providerContinueWatchingHubKey = null;
return;
}
final libraries = <PlexLibraryDto>[];
final epg = <({String identifier, String gridEndpoint})>[];
String? homeHubKey;
String? promotedHubKey;
String? continueWatchingHubKey;
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'] == 'promoted') {
promotedHubKey ??= feature['key'] as String?;
}
if (feature['type'] == 'continuewatching') {
continueWatchingHubKey ??= feature['key'] as String?;
}
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) {
homeHubKey ??= dir['hubKey'] as String?;
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(
PlexLibraryDto.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;
_providerHomeHubKey = homeHubKey;
_providerPromotedHubKey = promotedHubKey;
_providerContinueWatchingHubKey = continueWatchingHubKey;
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 = [];
_providerHomeHubKey = null;
_providerPromotedHubKey = null;
_providerContinueWatchingHubKey = null;
}
}
/// Update endpoint priority list and optionally hop to the new best endpoint.
Future<void> updateEndpointPreferences(List<String> prioritizedEndpoints, {bool switchToFirst = false}) async {
if (_http.endpoints.isEmpty || prioritizedEndpoints.isEmpty) {
return;
}
final targetBaseUrl = switchToFirst ? prioritizedEndpoints.first : config.baseUrl;
_http.resetEndpoints(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 {
// Memoized after the first call — resolve outside the latency window.
final identity = await DeviceIdentityService.resolve();
final stopwatch = Stopwatch()..start();
MediaServerHttpClient? client;
try {
client = MediaServerHttpClient(baseUrl: baseUrl, connectTimeout: timeout, receiveTimeout: timeout);
final headers = <String, String>{'X-Plex-Token': token};
if (clientIdentifier != null) {
headers['X-Plex-Client-Identifier'] = clientIdentifier;
headers['X-Plex-Product'] = 'Plezy';
headers['X-Plex-Device-Name'] = sanitizeHeaderValue(identity.deviceName) ?? 'Plezy';
}
final response = await client.get('/', headers: headers);
stopwatch.stop();
final success = response.statusCode == 200;
bool? transcoderVideo;
if (success && response.data is Map && response.data['MediaContainer'] is Map) {
transcoderVideo = _parsePlexTranscoderVideoCapability(
(response.data['MediaContainer'] as Map)['transcoderVideo'],
);
}
return ConnectionTestResult(
success: success,
latencyMs: stopwatch.elapsedMilliseconds,
error: success ? null : 'HTTP ${response.statusCode}',
transcoderVideo: transcoderVideo,
);
} on MediaServerHttpException catch (e) {
stopwatch.stop();
final label = switch (e.type) {
MediaServerHttpErrorType.connectionTimeout => 'Connection timeout',
MediaServerHttpErrorType.receiveTimeout => 'Receive timeout',
MediaServerHttpErrorType.connectionError => 'Connection error',
_ => e.type.name,
};
final message = e.message.trim();
var error = message.isEmpty ? label : '$label: $message';
if (e.statusCode != null) {
error += ' (HTTP ${e.statusCode})';
}
return ConnectionTestResult(success: false, latencyMs: stopwatch.elapsedMilliseconds, error: error);
} catch (e) {
stopwatch.stop();
return ConnectionTestResult(success: false, latencyMs: stopwatch.elapsedMilliseconds, error: e.toString());
} finally {
client?.close();
}
}
/// Test connection multiple times and return average latency
static Future<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);
}
@override
Map<String, dynamic>? _getMediaContainer(MediaServerResponse response) {
if (response.data is Map && response.data.containsKey('MediaContainer')) {
return response.data['MediaContainer'];
}
return null;
}
PlexMetadataDto _tagMetadata(PlexMetadataDto metadata) =>
metadata.copyWith(serverId: serverId, serverName: serverName);
PlexMetadataDto _tagMetadataWithLibrary(
PlexMetadataDto metadata, {
int? librarySectionID,
String? librarySectionTitle,
}) {
return _plexMetadataWithLibrarySection(
_tagMetadata(metadata),
librarySectionID: librarySectionID,
librarySectionTitle: librarySectionTitle,
);
}
@override
PlexMetadataDto _createTaggedMetadata(Map<String, dynamic> json) => _tagMetadata(PlexMetadataDto.fromJson(json));
PlexMetadataDto _createTaggedMetadataWithLibrary(
Map<String, dynamic> json, {
int? librarySectionID,
String? librarySectionTitle,
}) {
return _tagMetadataWithLibrary(
PlexMetadataDto.fromJson(json),
librarySectionID: _librarySectionIdFromJson(json) ?? librarySectionID,
librarySectionTitle: _librarySectionTitleFromJson(json) ?? librarySectionTitle,
);
}
List<PlexMetadataDto> _extractMetadataList(MediaServerResponse response) => _extractMetadataListWithLibrary(response);
List<PlexMetadataDto> _extractMetadataListWithLibrary(
MediaServerResponse response, {
int? librarySectionID,
String? librarySectionTitle,
}) {
final container = _getMediaContainer(response);
if (container != null && container['Metadata'] != null) {
final containerSectionID = _librarySectionIdFromJson(container) ?? librarySectionID;
final containerSectionTitle = _librarySectionTitleFromJson(container) ?? librarySectionTitle;
return (container['Metadata'] as List)
.map(
(json) => _createTaggedMetadataWithLibrary(
json as Map<String, dynamic>,
librarySectionID: containerSectionID,
librarySectionTitle: containerSectionTitle,
),
)
.toList();
}
return [];
}
Map<String, dynamic>? _getFirstMetadataJson(MediaServerResponse 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;
}
List<T> _extractDirectoryList<T>(MediaServerResponse 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 [];
}
List<PlexLibraryDto> _extractLibraryList(MediaServerResponse response) {
final container = _getMediaContainer(response);
if (container != null && container['Directory'] != null) {
return (container['Directory'] as List)
.map(
(json) => PlexLibraryDto.fromJson(
json as Map<String, dynamic>,
).copyWith(serverId: serverId, serverName: serverName),
)
.toList();
}
return [];
}
List<PlexPlaylistDto> _extractPlaylistList(MediaServerResponse response) {
final container = _getMediaContainer(response);
if (container != null && container['Metadata'] != null) {
return (container['Metadata'] as List)
.map(
(json) => PlexPlaylistDto.fromJson(
json as Map<String, dynamic>,
).copyWith(serverId: serverId, serverName: serverName),
)
.toList();
}
return [];
}
int? _responseHeaderInt(MediaServerResponse response, String name) {
final lowerName = name.toLowerCase();
for (final entry in response.headers.entries) {
if (entry.key.toLowerCase() == lowerName) return flexibleInt(entry.value);
}
return null;
}
int _responseTotalSize(MediaServerResponse response, {required int itemCount, int? start, int? requestedSize}) {
final headerTotal = _responseHeaderInt(response, 'X-Plex-Container-Total-Size');
if (headerTotal != null) return headerTotal;
final container = _getMediaContainer(response);
final bodyTotal = flexibleInt(container?['totalSize']);
if (bodyTotal != null) return bodyTotal;
final offset = start ?? flexibleInt(container?['offset']) ?? 0;
if (start == null && requestedSize == null) {
return flexibleInt(container?['size']) ?? itemCount;
}
return fallbackPageTotal(offset: offset, itemCount: itemCount, requestedSize: requestedSize);
}
({List<PlexPlaylistDto> items, int totalSize}) _extractPlaylistListResult(
MediaServerResponse response, {
int? start,
int? size,
}) {
final items = _extractPlaylistList(response);
return (
items: items,
totalSize: _responseTotalSize(response, itemCount: items.length, start: start, requestedSize: size),
);
}
Future<Map<String, dynamic>> getServerIdentity() async {
final response = await _getWithFailover('/identity');
return response.data;
}
/// Check if the server connection is healthy (reachable AND authenticated).
///
/// Hits the root `/` MediaContainer (auth-required) rather than `/identity`
/// (an unauthenticated discovery endpoint). With `/identity`, a server with
/// a revoked or expired token would still report healthy, only to 401 on
/// the very next real call. Mirrors Jellyfin's `/Users/Me` choice.
///
/// Distinguishes 401/403 (token revoked / wrong user) as
/// [HealthStatus.authError] from generic transport failures so the
/// manager can route them to a re-auth banner instead of generic
/// "server offline" UI.
@override
Future<HealthStatus> checkHealth() async {
try {
final response = await _getWithFailover('/', timeout: MediaServerTimeouts.plexProbe);
return response.statusCode == 200 ? HealthStatus.online : HealthStatus.offline;
} on MediaServerHttpException catch (e) {
if (e.statusCode == 401 || e.statusCode == 403) return HealthStatus.authError;
return HealthStatus.offline;
} catch (_) {
return HealthStatus.offline;
}
}
@override
Future<bool> isHealthy() async => (await checkHealth()) == HealthStatus.online;
/// Get running background tasks (thumbnail generation, credit detection, etc.)
Future<List<PlexActivity>> getActivities() async {
try {
final response = await _getWithFailover('/activities');
final container = _getMediaContainer(response);
if (container == null) return [];
final activityList = container['Activity'] as List?;
if (activityList == null) return [];
return activityList.map((json) => PlexActivity.fromJson(json as Map<String, dynamic>)).toList();
} catch (e) {
appLogger.e('Failed to get activities', error: e);
return [];
}
}
/// Cancel a running background task by its UUID.
Future<void> cancelActivity(String uuid) async {
final response = await _http.delete('/activities/$uuid');
throwIfHttpError(response);
}
/// 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<PlexLibraryDto>> _getLibraries() async {
if (_providerLibraries.isNotEmpty) return _providerLibraries;
// Fallback for old servers that don't support /media/providers
final response = await _getWithFailover('/library/sections');
return _extractLibraryList(response);
}
/// Get library content by section ID
Future<_LibraryContentResult> _getLibraryContent(
String sectionId, {
int? start,
int? size,
Map<String, String>? filters,
AbortController? abort,
}) async {
final queryParams = _buildPaginationParams(start, size);
if (filters != null) queryParams.addAll(filters);
final endpoint = sectionId == 'shared' ? '/library/shared/all' : '/library/sections/$sectionId/all';
final response = await _getWithFailover(endpoint, queryParameters: queryParams, abort: abort);
return _extractLibraryContentResult(
response,
librarySectionID: _librarySectionIdFromString(sectionId),
start: start,
requestedSize: size,
);
}
Map<String, dynamic> _buildPaginationParams(int? start, int? size) {
final params = <String, dynamic>{};
if (start != null) params['X-Plex-Container-Start'] = start;
if (size != null) params['X-Plex-Container-Size'] = size;
return params;
}
_LibraryContentResult _extractLibraryContentResult(
MediaServerResponse response, {
int? librarySectionID,
String? librarySectionTitle,
int? start,
int? requestedSize,
}) {
final items = _extractMetadataListWithLibrary(
response,
librarySectionID: librarySectionID,
librarySectionTitle: librarySectionTitle,
);
final totalSize = _responseTotalSize(response, itemCount: items.length, start: start, requestedSize: requestedSize);
return _LibraryContentResult(items: items, totalSize: totalSize);
}
Future<_LibraryContentResult> _fetchPaginatedList(
String path, {
int? start,
int? size,
AbortController? abort,
int? librarySectionID,
String? librarySectionTitle,
Map<String, dynamic>? queryParameters,
}) async {
final response = await _getWithFailover(
path,
queryParameters: {...?queryParameters, ..._buildPaginationParams(start, size)},
abort: abort,
);
return _extractLibraryContentResult(
response,
librarySectionID: librarySectionID,
librarySectionTitle: librarySectionTitle,
start: start,
requestedSize: size,
);
}
/// Parse list of PlexMetadataDto from a cached response
List<PlexMetadataDto> _parseMetadataListFromCachedResponse(Map<String, dynamic> cached) {
final container = cached['MediaContainer'] is Map<String, dynamic>
? cached['MediaContainer'] as Map<String, dynamic>
: null;
final containerSectionID = _librarySectionIdFromJson(container);
final containerSectionTitle = _librarySectionTitleFromJson(container);
final metadataList = PlexCacheParser.extractMetadataList(cached);
if (metadataList != null) {
return metadataList
.map(
(json) => _createTaggedMetadataWithLibrary(
json as Map<String, dynamic>,
librarySectionID: containerSectionID,
librarySectionTitle: containerSectionTitle,
),
)
.toList();
}
return [];
}
/// Get the server's machine identifier
@override
Future<String?> getMachineIdentifier() async {
try {
final response = await _getWithFailover('/');
final container = _getMediaContainer(response);
if (container == null) return null;
return container['machineIdentifier'] as String?;
} catch (e) {
appLogger.e('Failed to get machine identifier', error: e);
return null;
}
}
/// Build a proper metadata URI for adding to playlists
/// Returns URI in format: server://{machineId}/com.plexapp.plugins.library/library/metadata/{ratingKey}
Future<String> buildMetadataUri(String ratingKey) async {
// Use cached machine identifier from config if available
final machineId = config.machineIdentifier ?? await getMachineIdentifier();
if (machineId == null) {
throw Exception('Could not get server machine identifier');
}
return 'server://$machineId/com.plexapp.plugins.library/library/metadata/$ratingKey';
}
/// Build a server URI from a folder key for play queue creation.
/// Folder keys are like `/library/sections/1/folder?parent=123`.
Future<String> buildFolderUri(String folderKey) async {
final machineId = config.machineIdentifier ?? await getMachineIdentifier();
if (machineId == null) {
throw Exception('Could not get server machine identifier');
}
return 'server://$machineId/com.plexapp.plugins.library$folderKey';
}
/// Get metadata by rating key with images (includes clearLogo and OnDeck)
/// Uses cache when offline or as fallback on network error
/// Note: OnDeck data is not relevant for offline mode
/// Always fetches with chapters/markers but caches at base endpoint
Future<Map<String, dynamic>> getMetadataWithImagesAndOnDeck(String ratingKey) async {
// Cache key is always the base endpoint (no query params)
final cacheKey = '/library/metadata/$ratingKey';
// Special handling needed for OnDeck - can't use simple fetchWithCacheFallback
// because OnDeck is only available from network response, not cache
return await fetchWithCacheFallback<Map<String, dynamic>>(
cacheKey: cacheKey,
networkCall: () => _http.get(
'/library/metadata/$ratingKey',
queryParameters: {
'includeChapters': 1,
'includeMarkers': 1,
'includeOnDeck': 1,
'checkFiles': 1,
'includeStreams': 1,
},
),
parseCache: (cachedData) {
final metadata = _parseMetadataWithImagesFromCachedResponse(cachedData);
return {'metadata': metadata, 'onDeckEpisode': null};
},
parseResponse: (response) {
PlexMetadataDto? metadata;
PlexMetadataDto? onDeckEpisode;
final container = _getMediaContainer(response);
final containerSectionID = _librarySectionIdFromJson(container);
final containerSectionTitle = _librarySectionTitleFromJson(container);
final metadataJson = _getFirstMetadataJson(response);
if (metadataJson != null) {
metadata = _tagMetadataWithLibrary(
PlexMetadataDto.fromJsonWithImages(metadataJson),
librarySectionID: _librarySectionIdFromJson(metadataJson) ?? containerSectionID,
librarySectionTitle: _librarySectionTitleFromJson(metadataJson) ?? containerSectionTitle,
);
// 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 = _createTaggedMetadataWithLibrary(
onDeckMetadata as Map<String, dynamic>,
librarySectionID: metadata.librarySectionID ?? containerSectionID,
librarySectionTitle: metadata.librarySectionTitle ?? containerSectionTitle,
);
}
}
}
}
return {'metadata': metadata, 'onDeckEpisode': onDeckEpisode};
},
) ??
{'metadata': null, 'onDeckEpisode': 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<PlexMetadataDto?> _getMetadataWithImages(String ratingKey) async {
// Cache key is always the base endpoint (no query params)
final cacheKey = '/library/metadata/$ratingKey';
return fetchWithCacheFallback<PlexMetadataDto>(
cacheKey: cacheKey,
networkCall: () => _http.get(
'/library/metadata/$ratingKey',
queryParameters: {'includeChapters': 1, 'includeMarkers': 1, 'checkFiles': 1, 'includeStreams': 1},
),
parseCache: (cachedData) => _parseMetadataWithImagesFromCachedResponse(cachedData),
parseResponse: (response) {
final container = _getMediaContainer(response);
final metadataJson = _getFirstMetadataJson(response);
return metadataJson != null
? _tagMetadataWithLibrary(
PlexMetadataDto.fromJsonWithImages(metadataJson),
librarySectionID: _librarySectionIdFromJson(metadataJson) ?? _librarySectionIdFromJson(container),
librarySectionTitle:
_librarySectionTitleFromJson(metadataJson) ?? _librarySectionTitleFromJson(container),
)
: null;
},
);
}
/// Parse PlexMetadataDto with images from a cached response
PlexMetadataDto? _parseMetadataWithImagesFromCachedResponse(Map<String, dynamic> cached) {
final container = cached['MediaContainer'] is Map<String, dynamic>
? cached['MediaContainer'] as Map<String, dynamic>
: null;
final firstMetadata = PlexCacheParser.extractFirstMetadata(cached);
if (firstMetadata != null) {
return _tagMetadataWithLibrary(
PlexMetadataDto.fromJsonWithImages(firstMetadata),
librarySectionID: _librarySectionIdFromJson(firstMetadata) ?? _librarySectionIdFromJson(container),
librarySectionTitle: _librarySectionTitleFromJson(firstMetadata) ?? _librarySectionTitleFromJson(container),
);
}
return null;
}
/// 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.
///
/// Contract (matches the rest of the [MediaServerClient] surface):
/// - HTTP 2xx → returns `true`.
/// - HTTP 4xx/5xx → throws [MediaServerHttpException] (via
/// [throwIfHttpError]) so callers can show a real error rather than a
/// silent "success: false".
/// - Network/IO failure → exception bubbles unchanged.
/// - Non-2xx success that the server reports without an error code is
/// vanishingly rare for these endpoints; we still return `false` so
/// callers don't celebrate a non-200 silently.
Future<bool> _wrapBoolApiCall(Future<MediaServerResponse> Function() apiCall, String errorMessage) async {
try {
final response = await apiCall();
throwIfHttpError(response);
return response.statusCode == 200;
} catch (e, st) {
appLogger.e(errorMessage, error: e, stackTrace: st);
rethrow;
}
}
/// Wraps an API call that returns a list, returning empty list on error
@override
Future<List<T>> _wrapListApiCall<T>(
Future<MediaServerResponse> Function() apiCall,
List<T> Function(MediaServerResponse response) parseResponse,
String errorMessage,
) async {
try {
final response = await apiCall();
return parseResponse(response);
} catch (e) {
appLogger.e(errorMessage, error: e);
return [];
}
}
/// Default cap for list-style endpoints when a caller doesn't pass a size.
static const int _defaultListContainerSize = 1000;
/// Page size used when walking all pages of a paginated endpoint.
static const int _fetchAllPageSize = 200;
/// Iterate every page of a paginated endpoint and concatenate the results.
/// Stops as soon as [_LibraryContentResult.totalSize] is reached or a page
/// returns no items. Errors propagate.
Future<List<PlexMetadataDto>> _fetchAllPages(
Future<_LibraryContentResult> Function(int start, int size, AbortController? abort) fetchPage, {
AbortController? abort,
}) async {
final all = <PlexMetadataDto>[];
var start = 0;
while (true) {
final page = await fetchPage(start, _fetchAllPageSize, abort);
all.addAll(page.items);
start += page.items.length;
if (page.items.isEmpty) break;
if (start >= page.totalSize) break;
}
return all;
}
/// Walk every page of [path] and return a single synthesized response whose
/// `MediaContainer.Metadata` concatenates all pages. Lets a caller (and its
/// cache layer) treat a large, server-paginated collection as one complete
/// response while each network request stays small. Raw-response analog of
/// [_fetchAllPages]; errors propagate.
Future<MediaServerResponse> _getAllPagesResponse(
String path, {
Map<String, dynamic>? queryParameters,
AbortController? abort,
}) async {
MediaServerResponse? firstResponse;
Map<String, dynamic>? firstContainer;
final allMetadata = <dynamic>[];
var start = 0;
while (true) {
final response = await _getWithFailover(
path,
queryParameters: {...?queryParameters, ..._buildPaginationParams(start, _fetchAllPageSize)},
abort: abort,
);
final container = _getMediaContainer(response);
final metadata = container?['Metadata'];
final pageItems = metadata is List ? metadata : const [];
firstResponse ??= response;
firstContainer ??= container;
allMetadata.addAll(pageItems);
final total = _responseTotalSize(
response,
itemCount: pageItems.length,
start: start,
requestedSize: _fetchAllPageSize,
);
start += pageItems.length;
if (pageItems.isEmpty || start >= total) break;
}
return MediaServerResponse(
statusCode: firstResponse.statusCode,
data: {
'MediaContainer': {
...?firstContainer,
'Metadata': allMetadata,
'size': allMetadata.length,
'totalSize': allMetadata.length,
},
},
headers: firstResponse.headers,
requestUri: firstResponse.requestUri,
);
}
/// 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;
}
queryParams['allParts'] = 1;
// Use PUT request on /library/parts/{partId}
return _wrapBoolApiCall(
() => _http.put('/library/parts/$partId', queryParameters: queryParams),
'Failed to select streams',
);
}
return true;
}
/// Search for subtitles from external providers (e.g. OpenSubtitles) via the Plex server.
/// [language] is an ISO 639-1 two-letter code (e.g. "en", "es").
Future<List<PlexSubtitleSearchResult>> searchSubtitles(
String ratingKey, {
required String language,
String? title,
int hearingImpaired = 0,
int forced = 0,
}) async {
return _wrapListApiCall<PlexSubtitleSearchResult>(
() => _http.get(
'/library/metadata/$ratingKey/subtitles',
queryParameters: {
'language': language,
if (title != null && title.isNotEmpty) 'title': title,
'hearingImpaired': hearingImpaired,
'forced': forced,
},
),
(response) {
final container = _getMediaContainer(response);
final streams = container?['Stream'] as List? ?? [];
return streams.map((s) => PlexSubtitleSearchResult.fromJson(s as Map<String, dynamic>)).toList();
},
'Failed to search subtitles',
);
}
/// Download a subtitle from an external provider and add it to the media item.
/// The server downloads the file asynchronously; the new stream appears after a short delay.
Future<bool> downloadSubtitle(
String ratingKey, {
required String key,
required String codec,
required String language,
required bool hearingImpaired,
required bool forced,
required String providerTitle,
}) async {
return _wrapBoolApiCall(
() => _http.put(
'/library/metadata/$ratingKey/subtitles',
queryParameters: {
'key': key,
'codec': codec,
'language': language,
'hearingImpaired': hearingImpaired ? 1 : 0,
'forced': forced ? 1 : 0,
'providerTitle': providerTitle,
},
),
'Failed to download subtitle',
);
}
/// Search across all libraries including individually shared items.
/// Uses /library/search (same endpoint as Plex Web) which finds shared content.
/// Only returns movies, shows, and music (artists/albums/tracks), filtering
/// out other types.
Future<List<PlexMetadataDto>> _search(String query, {int limit = 100}) async {
final response = await _getWithFailover(
'/library/search',
queryParameters: {
'query': query,
'limit': limit,
'searchTypes': 'movies,tv,music',
'includeCollections': 1,
'includeExternalMedia': 1,
'X-Plex-Container-Size': limit,
},
);
final results = <PlexMetadataDto>[];
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?;
const allowedTypes = {'movie', 'show', 'artist', 'album', 'track'};
if (!allowedTypes.contains(type)) 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<PlexMetadataDto>> _getRecentlyAdded({int limit = 50}) async {
final response = await _getWithFailover(
'/library/recentlyAdded',
queryParameters: {'X-Plex-Container-Size': limit, 'includeGuids': 1},
);
final allItems = _extractMetadataList(response);
// Filter out music content (artists, albums, tracks)
return allItems.where((item) => !ContentTypes.musicTypes.contains(item.type?.toLowerCase())).toList();
}
/// Get continue watching items via the hubs system.
/// Prefer the provider's dedicated Continue Watching feature key when
/// advertised; fall back to Plex Web's legacy hubs query. Both respect the
/// server's OnDeckWindow preference (unlike /library/onDeck).
Future<List<PlexMetadataDto>> _getContinueWatching({int? count = 20}) async {
final continueWatchingHubKey = _providerContinueWatchingHubKey;
final queryParameters = <String, dynamic>{'count': ?count, 'includeGuids': 1};
if (continueWatchingHubKey == null) {
queryParameters['identifier'] = 'home.continue,home.ondeck';
}
final response = await retryTransientMediaServerCall(
operation: 'Plex continue watching hubs',
attemptTimeouts: MediaServerTimeouts.homeHubAttemptTimeouts,
call: (timeout, abort) => _getWithFailover(
continueWatchingHubKey ?? '/hubs',
queryParameters: queryParameters,
timeout: timeout,
abort: abort,
allowEndpointFailover: false,
),
);
final sid = serverId;
final sname = serverName;
final data = response.data as Map<String, dynamic>;
final hubs = await tryIsolateRun(() => _processHubResponse(data, sid, sname));
// Deduplicate across home.continue and home.ondeck hubs.
// Like plex-web, episodes from the same show (same grandparentRatingKey)
// are deduplicated, preferring the in-progress item (has viewOffset).
final items = hubs.expand((hub) => hub.items).toList();
final result = <PlexMetadataDto>[];
for (final item in items) {
final isEpisode = item.type?.toLowerCase() == 'episode';
final gpKey = item.grandparentRatingKey;
if (isEpisode && gpKey != null) {
final idx = result.indexWhere((e) => e.type?.toLowerCase() == 'episode' && e.grandparentRatingKey == gpKey);
if (idx != -1) {
if (result[idx].viewOffset == null && item.viewOffset != null) {
result[idx] = item;
}
continue;
}
}
result.add(item);
}
return result;
}
/// Get children of a metadata item (e.g., seasons for a show, episodes for a season).
/// Walks every page so large shows (many seasons) aren't truncated by a
/// server-forced container limit; uses cache when offline or as fallback on
/// network error. (Large *episode* lists load lazily via [fetchChildrenPage];
/// this full-fetch is for the seasons list and other complete-list callers.)
Future<List<PlexMetadataDto>> _getChildren(String ratingKey) async {
final endpoint = '/library/metadata/$ratingKey/children';
return await fetchWithCacheFallback<List<PlexMetadataDto>>(
cacheKey: endpoint,
networkCall: () => _getAllPagesResponse(endpoint, queryParameters: {'includeStreams': 1}),
parseCache: (cachedData) => _parseMetadataListFromCachedResponse(cachedData),
parseResponse: (response) => _extractMetadataList(response),
) ??
[];
}
/// Page through direct children of a metadata item (e.g. episodes of a
/// season). This uses `/children`; playable descendant paging uses
/// `/grandchildren` and intentionally has different semantics.
Future<_LibraryContentResult> _getChildrenPage(String ratingKey, {int? start, int? size, AbortController? abort}) =>
_fetchPaginatedList(
'/library/metadata/$ratingKey/children',
start: start,
size: size,
abort: abort,
queryParameters: {'includeStreams': 1},
);
/// Page through playable episodes beneath a show or season. Uses
/// `/grandchildren` rather than `/allLeaves` because the live server returns
/// 0 items for `/allLeaves` on a season.
Future<_LibraryContentResult> _getGrandchildrenPage(
String ratingKey, {
int? start,
int? size,
AbortController? abort,
}) => _fetchPaginatedList(
'/library/metadata/$ratingKey/grandchildren',
start: start,
size: size,
abort: abort,
queryParameters: {'includeStreams': 1},
);
/// Get extras for a metadata item (trailers, behind-the-scenes, etc.)
/// Uses cache when offline or as fallback on network error
Future<List<PlexMetadataDto>> _getExtras(String ratingKey) async {
final endpoint = '/library/metadata/$ratingKey/extras';
return await fetchWithCacheFallback<List<PlexMetadataDto>>(
cacheKey: endpoint,
networkCall: () => _http.get(endpoint),
parseCache: (cachedData) => _parseMetadataListFromCachedResponse(cachedData),
parseResponse: (response) => _extractMetadataList(response),
) ??
[];
}
/// Get thumbnail URL
String getThumbnailUrl(String? thumbPath) {
if (thumbPath == null || thumbPath.isEmpty) return '';
return _http.buildUri(thumbPath).toString().withPlexToken(config.token);
}
/// Download the full BIF (Base Index Frames) file for a given part.
/// Returns the raw bytes, or null on failure.
Future<Uint8List?> downloadBifFile(int partId) async {
try {
final bytes = await _http.getBytes(
'${_http.baseUrl}/library/parts/$partId/indexes/sd',
timeout: const Duration(seconds: 30),
);
if (bytes.isNotEmpty) return bytes;
return null;
} catch (_) {
return null;
}
}
/// Get chapters and markers from cached metadata or fetch if needed
/// Uses same cache key as other metadata methods for consistency
Future<PlaybackExtras> getPlaybackExtras(
String ratingKey, {
String? introPattern,
String? creditsPattern,
bool forceChapterFallback = false,
bool forceRefresh = false,
}) async {
try {
final fetch = forceRefresh ? fetchWithCacheFallback : fetchWithCacheFirst;
final data = await fetch<Map<String, dynamic>>(
cacheKey: '/library/metadata/$ratingKey',
networkCall: () =>
_http.get('/library/metadata/$ratingKey', queryParameters: {'includeChapters': 1, 'includeMarkers': 1}),
parseCache: (cached) => cached as Map<String, dynamic>?,
parseResponse: (response) => response.data as Map<String, dynamic>?,
);
final metadataJson = _getFirstMetadataJsonFromData(data);
return _parsePlaybackExtrasFromMetadataJson(
metadataJson,
introPattern: introPattern,
creditsPattern: creditsPattern,
forceChapterFallback: forceChapterFallback,
);
} 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,
bool forceChapterFallback = false,
}) => plexPlaybackExtrasFromCacheJson(
metadataJson,
introPattern: introPattern,
creditsPattern: creditsPattern,
forceChapterFallback: forceChapterFallback,
);
/// Parse video playback data from raw metadata JSON (no network call).
/// Used by [getVideoPlaybackData] to avoid redundant fetches when the
/// response is already available.
PlexVideoPlaybackData parseVideoPlaybackDataFromJson(
Map<String, dynamic>? metadataJson, {
int mediaIndex = 0,
String? selectedMediaSourceId,
String? preferredVersionSignature,
}) {
return parsePlexVideoPlaybackDataFromJson(
metadataJson,
baseUrl: config.baseUrl,
token: config.token,
mediaIndex: mediaIndex,
selectedMediaSourceId: selectedMediaSourceId,
preferredVersionSignature: preferredVersionSignature,
onVersionFallback: (requested, fallback) {
appLogger.w('Version $requested inaccessible/missing — falling back to version $fallback');
},
);
}
/// 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,
String? selectedMediaSourceId,
String? preferredVersionSignature,
}) async {
Map<String, dynamic>? data;
try {
data = await fetchWithCacheFallback<Map<String, dynamic>>(
cacheKey: '/library/metadata/$ratingKey',
// checkFiles=1 populates Part.accessible/exists so we can skip
// deleted-but-still-indexed versions before play.
networkCall: () => _http.get(
'/library/metadata/$ratingKey',
queryParameters: {'includeMarkers': 1, 'includeChapters': 1, 'checkFiles': 1, 'includeStreams': 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,
selectedMediaSourceId: selectedMediaSourceId,
preferredVersionSignature: preferredVersionSignature,
);
}
/// Get file information for a media item.
///
/// Uses cache for offline mode support and network fallback. Wires the
/// neutral [MediaServerClient.getFileInfo] override below.
@override
Future<MediaFileInfo?> getFileInfo(MediaItem item) => _fetchFileInfo(item.id);
Future<MediaFileInfo?> _fetchFileInfo(String ratingKey) async {
try {
final metadataJson = await _fetchRawMetadataJsonCacheFirst(ratingKey);
return parsePlexFileInfoFromJson(metadataJson);
} catch (e) {
appLogger.e('Failed to get file info: $e');
return null;
}
}
/// Cache-first raw metadata JSON for [ratingKey]. Serves the shared
/// `/library/metadata/{id}` cache row when a detail/playback flow already
/// warmed it (the common case — no extra round-trip); on a miss it fetches
/// with the full playback query params so the row it caches stays complete
/// for the cache-only readers ([fetchPlaybackExtrasFromCacheOnly],
/// [fetchCachedMediaSourceInfo]).
Future<Map<String, dynamic>?> _fetchRawMetadataJsonCacheFirst(String ratingKey) async {
final data = await fetchWithCacheFirst<Map<String, dynamic>>(
cacheKey: '/library/metadata/$ratingKey',
networkCall: () => _http.get(
'/library/metadata/$ratingKey',
queryParameters: {'includeChapters': 1, 'includeMarkers': 1, 'checkFiles': 1, 'includeStreams': 1},
),
parseCache: (cached) => cached as Map<String, dynamic>?,
parseResponse: (response) => response.data as Map<String, dynamic>?,
);
return _getFirstMetadataJsonFromData(data);
}
/// Fetch the raw `Guid` array for a metadata item (`includeGuids=1`).
///
/// Returns the list of `{id: 'imdb://tt...'}` maps as Plex returns them, or
/// an empty list if the item has no external IDs / can't be fetched.
/// Used by the Trakt integration to match Plex items against Trakt's catalog.
Future<List<dynamic>> fetchExternalGuids(String ratingKey) async {
try {
final response = await _getWithFailover('/library/metadata/$ratingKey', queryParameters: {'includeGuids': 1});
final data = response.data;
if (data is! Map) return const [];
final container = data['MediaContainer'] as Map?;
final metadata = container?['Metadata'];
if (metadata is! List || metadata.isEmpty) return const [];
final first = metadata.first;
if (first is! Map) return const [];
final guids = first['Guid'];
if (guids is List) return guids;
return const [];
} catch (e) {
appLogger.d('fetchExternalGuids failed for $ratingKey', error: e);
return const [];
}
}
/// Mark media as watched (transport only — see [MediaServerClient.markWatched]).
Future<void> markAsWatched(String ratingKey) async {
await _getWithFailover(
'/:/scrobble',
queryParameters: {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'},
);
}
/// Mark media as unwatched (transport only — see [MediaServerClient.markUnwatched]).
Future<void> markAsUnwatched(String ratingKey) async {
await _getWithFailover(
'/:/unscrobble',
queryParameters: {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'},
);
}
/// Update playback progress.
///
/// [sessionIdentifier] is the playback's `X-Plex-Session-Identifier` — the
/// same value the (transcode) stream request carries. Sending it on the
/// timeline lets the server correlate this report with the active session,
/// so the dashboard reports the real stream decision (e.g. Transcode) instead
/// of falling back to a generic Direct Play / Original entry.
Future<void> updateProgress(
String ratingKey, {
required int time,
required String state, // 'playing', 'paused', 'stopped', 'buffering'
int? duration,
String? sessionIdentifier,
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
}) async {
final response = await _http.post(
'/:/timeline',
queryParameters: {
'ratingKey': ratingKey,
'key': '/library/metadata/$ratingKey',
'time': time,
'state': state,
'duration': ?duration,
if (report.isOfflineReplay) 'offline': 1,
if (report.recordedAt != null) 'updated': report.recordedAt!.millisecondsSinceEpoch ~/ 1000,
if (report.willContinue != null) 'continuing': report.willContinue! ? 1 : 0,
},
headers: {'X-Plex-Session-Identifier': ?sessionIdentifier},
);
// Surface non-2xx instead of swallowing — progress is the cornerstone
// of resume/Continue Watching, so silent failures hurt the user later.
throwIfHttpError(response);
}
/// Keep a paused transcode session alive. Timeline updates alone have not
/// historically stopped PMS from reaping an idle transcoder, so Plex
/// clients send this alongside every paused timeline (see OpenPHT's
/// SendTranscoderPing). [transcodeSessionId] is the `session` param the
/// transcode was started with. Best-effort: a failed ping must never
/// disturb playback, so errors are logged and swallowed.
Future<void> pingTranscodeSession(String transcodeSessionId) async {
try {
await _http.get('/video/:/transcode/universal/ping', queryParameters: {'session': transcodeSessionId});
} catch (e) {
appLogger.d('Transcode keepalive ping failed', error: e);
}
}
/// 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 {
final response = await _http.put('/actions/removeFromContinueWatching', queryParameters: {'ratingKey': ratingKey});
throwIfHttpError(response);
}
/// 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
@override
Future<bool> deleteMediaItem(MediaItem item) {
return _wrapBoolApiCall(() => _http.delete('/library/metadata/${item.id}'), 'Failed to delete media item');
}
/// Parse a Plex Settings response into a map of id --> value.
Map<String, dynamic> _parseSettingsMap(dynamic response) {
final container = _getMediaContainer(response);
if (container == null) return {};
final settings = container['Setting'];
if (settings == null) return {};
final list = settings is List ? settings : [settings];
return {for (final s in list) s['id'] as String: s['value']};
}
/// Fetch all server-level preferences and store them in [serverPrefs].
///
/// Non-blocking: intended to be called fire-and-forget on connect.
Future<void> fetchServerPrefs() async {
try {
final response = await _getWithFailover('/:/prefs');
_serverPrefs = _parseSettingsMap(response);
// Mirror the watched threshold to settings: offline paths resolve it
// synchronously with no client bound — see
// OfflineWatchSyncService.getWatchedThreshold. Skipped when unchanged
// (this runs on every connect).
final settings = SettingsService.instanceOrNull;
if (settings != null) {
final pref = SettingsService.watchedThresholdPref(ServerId(serverId));
if (settings.read(pref) != watchedThresholdPercent) {
unawaited(settings.write(pref, watchedThresholdPercent));
}
}
} catch (e) {
appLogger.d('Failed to fetch server prefs: $e');
}
}
/// Get preferences for a library section.
///
/// Returns a map of setting id --> value for all settings in the library.
Future<Map<String, dynamic>> getLibrarySectionPrefs(String sectionId) async {
final response = await _getWithFailover('/library/sections/$sectionId/prefs');
return _parseSettingsMap(response);
}
/// Get available filters for a library section
Future<List<MediaFilter>> getLibraryFilters(String sectionId) async {
if (sectionId == 'shared') return [];
final response = await _getWithFailover('/library/sections/$sectionId/filters');
return _extractDirectoryList(response, MediaFilter.fromJson);
}
/// Get first characters (alphabet index) for a library section
Future<List<LibraryFirstCharacter>> getFirstCharacters(
String sectionId, {
int? type,
Map<String, String>? filters,
}) async {
final queryParams = <String, dynamic>{};
if (type != null) queryParams['type'] = type;
if (filters != null) queryParams.addAll(filters);
final response = await _getWithFailover(
'/library/sections/$sectionId/firstCharacter',
queryParameters: queryParams,
);
return _extractDirectoryList(response, (json) {
// The Plex /firstCharacter endpoint returns rows with `key`/`title`/
// `size` (size is a string in the wire payload).
return LibraryFirstCharacter(
key: (json['key'] as String?) ?? '',
title: (json['title'] as String?) ?? '',
size: int.tryParse((json['size'] ?? '').toString()) ?? 0,
);
});
}
/// Get filter values (e.g., list of genres, years, etc.)
Future<List<MediaFilterValue>> getFilterValues(String filterKey) async {
final response = await _getWithFailover(filterKey);
return _extractDirectoryList(response, MediaFilterValue.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.
@override
Future<List<MediaSort>> fetchSortOptions(String sectionId, {String? libraryType}) async {
if (sectionId == 'shared') {
return [
MediaSort(
key: 'titleSort',
descKey: 'titleSort:desc',
title: t.libraries.sortLabels.title,
defaultDirection: 'asc',
),
MediaSort(
key: 'taggingCreatedAt',
descKey: 'taggingCreatedAt:desc',
title: t.libraries.sortLabels.dateShared,
defaultDirection: 'desc',
),
];
}
try {
// Music sections serve per-type sort lists: the bare endpoint returns
// the section default (artist) sorts; `?type=9|10` returns album/track
// sorts. Video libraries keep the bare call — their section type
// already pins the list.
final musicType = switch (libraryType?.toLowerCase()) {
'album' => PlexMetadataType.album,
'track' => PlexMetadataType.track,
_ => null,
};
final response = await _getWithFailover(
'/library/sections/$sectionId/sorts',
queryParameters: musicType == null ? null : {'type': musicType},
);
final sorts = _extractDirectoryList(response, MediaSort.fromJson);
// Fallback: return common sort options if API doesn't provide them
final base = sorts.isNotEmpty ? sorts : _getFallbackSorts(libraryType);
return _withExtraSorts(base, libraryType);
} catch (e) {
appLogger.e('Failed to get library sorts: $e');
// Return fallback sort options on error
return _withExtraSorts(_getFallbackSorts(libraryType), libraryType);
}
}
/// Append sort options that Plex honors via the `sort=` parameter but does not
/// advertise in `/library/sections/{id}/sorts`.
///
/// Date Added (`addedAt`), plays (`viewCount`), and the signed-in user's
/// rating (`userRating`) sort correctly on movie/show libraries, so we
/// surface them client-side (mirroring how the Jellyfin sort list is built).
/// De-duped by key so we never double up if a future Plex version starts
/// advertising them.
List<MediaSort> _withExtraSorts(List<MediaSort> base, String? libraryType) {
final type = libraryType?.toLowerCase();
if (type != 'movie' && type != 'show') return base;
final keys = base.map((s) => s.key).toSet();
final extras = [
_dateAddedSort(),
MediaSort(
key: 'viewCount',
descKey: 'viewCount:desc',
title: t.libraries.sortLabels.playCount,
defaultDirection: 'desc',
),
MediaSort(
key: 'userRating',
descKey: 'userRating:desc',
title: t.libraries.sortLabels.userRating,
defaultDirection: 'desc',
),
].where((s) => !keys.contains(s.key));
return [...base, ...extras];
}
MediaSort _dateAddedSort() {
return MediaSort(
key: 'addedAt',
descKey: 'addedAt:desc',
title: t.libraries.sortLabels.dateAdded,
defaultDirection: 'desc',
);
}
/// Build fallback sort options based on library type.
///
/// If [libraryType] is null, returns generic sorts without the show-specific options.
List<MediaSort> _getFallbackSorts(String? libraryType) {
final fallbackSorts = <MediaSort>[
MediaSort(key: 'titleSort', title: t.libraries.sortLabels.title, defaultDirection: 'asc'),
_dateAddedSort(),
];
// Add "Latest Episode Air Date" only for TV show libraries
if (libraryType?.toLowerCase() == 'show') {
fallbackSorts.add(
MediaSort(
key: 'episode.originallyAvailableAt',
descKey: 'episode.originallyAvailableAt:desc',
title: t.libraries.sortLabels.latestEpisodeAirDate,
defaultDirection: 'desc',
),
);
}
fallbackSorts.addAll([
MediaSort(
key: 'originallyAvailableAt',
descKey: 'originallyAvailableAt:desc',
title: t.libraries.sortLabels.releaseDate,
defaultDirection: 'desc',
),
MediaSort(key: 'rating', descKey: 'rating:desc', title: t.libraries.sortLabels.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<PlexHubDto>> _getLibraryHubs(
String sectionId, {
int limit = defaultHubPreviewLimit,
String? libraryName,
}) async {
try {
final response = await retryTransientMediaServerCall(
operation: 'Plex library hubs',
attemptTimeouts: MediaServerTimeouts.libraryHubAttemptTimeouts,
call: (timeout, abort) => _getWithFailover(
'/hubs/sections/$sectionId',
queryParameters: {'count': limit, 'includeGuids': 1},
timeout: timeout,
abort: abort,
allowEndpointFailover: false,
),
);
final sid = serverId;
final sname = serverName;
final data = response.data as Map<String, dynamic>;
return await tryIsolateRun(
() => _processHubResponse(
data,
sid,
sname,
librarySectionID: _librarySectionIdFromString(sectionId),
librarySectionTitle: libraryName,
// Music-section hubs carry artist/album/track items — the default
// video-only filter would empty them out.
filter: (item) {
final type = item.type?.toLowerCase();
return ContentTypes.videoTypes.contains(type) || ContentTypes.musicTypes.contains(type);
},
),
);
} 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<PlexHubDto>> _getGlobalHubs({int limit = defaultHubPreviewLimit}) async {
try {
final hubKey = _providerPromotedHubKey ?? _providerHomeHubKey ?? '/hubs';
final response = await retryTransientMediaServerCall(
operation: 'Plex global hubs',
attemptTimeouts: MediaServerTimeouts.homeHubAttemptTimeouts,
call: (timeout, abort) => _getWithFailover(
hubKey,
queryParameters: {'count': limit, 'includeGuids': 1},
timeout: timeout,
abort: abort,
allowEndpointFailover: false,
),
);
final sid = serverId;
final sname = serverName;
final data = response.data as Map<String, dynamic>;
return await tryIsolateRun(() => _processHubResponse(data, sid, sname));
} catch (e) {
appLogger.e('Failed to get global hubs: $e');
}
return [];
}
/// Get related hubs for a specific metadata item (collections, similar, "more from" director/actor)
Future<List<PlexHubDto>> _getRelatedHubs(String ratingKey, {int count = 10}) async {
try {
final response = await retryTransientMediaServerCall(
operation: 'Plex related hubs',
attemptTimeouts: MediaServerTimeouts.libraryHubAttemptTimeouts,
call: (timeout, abort) => _getWithFailover(
'/hubs/metadata/$ratingKey/related',
queryParameters: {'count': count},
timeout: timeout,
abort: abort,
allowEndpointFailover: false,
),
);
final sid = serverId;
final sname = serverName;
final data = response.data as Map<String, dynamic>;
return await tryIsolateRun(
() => _processHubResponse(
data,
sid,
sname,
filter: (item) {
final type = item.type?.toLowerCase();
return ContentTypes.videoTypes.contains(type) || type == ContentTypes.collection;
},
),
);
} catch (e) {
appLogger.e('Failed to get related hubs: $e');
}
return [];
}
/// Get full content from a hub using its hub key
/// Returns the complete list of metadata items in the hub
Future<List<PlexMetadataDto>> _getHubContent(String hubKey) async {
try {
final hubSectionID = _librarySectionIdFromString(hubKey);
final items = await _fetchAllPages(
(start, size, abort) =>
_fetchPaginatedList(hubKey, start: start, size: size, abort: abort, librarySectionID: hubSectionID),
);
return items.where(_isVideoMetadata).toList();
} catch (e, st) {
appLogger.e('Failed to get hub content', error: e, stackTrace: st);
return [];
}
}
bool _isVideoMetadata(PlexMetadataDto item) => ContentTypes.videoTypes.contains(item.type?.toLowerCase());
Future<_LibraryContentResult> _getHubContentPage(
String hubKey, {
int? start,
int? size,
AbortController? abort,
}) async {
final filteredOffset = start ?? 0;
final pageSize = size ?? _fetchAllPageSize;
final rawPageSize = pageSize > _fetchAllPageSize ? pageSize : _fetchAllPageSize;
final hubSectionID = _librarySectionIdFromString(hubKey);
final pageItems = <PlexMetadataDto>[];
var rawOffset = 0;
var filteredSeen = 0;
var rawTotal = 0;
var rawFinished = false;
while (pageItems.length < pageSize && !rawFinished) {
final result = await _fetchPaginatedList(
hubKey,
start: rawOffset,
size: rawPageSize,
abort: abort,
librarySectionID: hubSectionID,
);
rawTotal = result.totalSize;
final rawItems = result.items;
rawOffset += rawItems.length;
for (final item in rawItems) {
if (!_isVideoMetadata(item)) continue;
if (filteredSeen >= filteredOffset && pageItems.length < pageSize) {
pageItems.add(item);
}
filteredSeen++;
}
rawFinished = rawItems.isEmpty || rawOffset >= rawTotal;
}
final totalSize = rawFinished ? filteredSeen : filteredOffset + pageItems.length + 1;
return _LibraryContentResult(items: pageItems, totalSize: totalSize);
}
/// Get playlist content by playlist ID, paginated.
Future<_LibraryContentResult> _getPlaylist(String playlistId, {int? start, int? size, AbortController? abort}) =>
_fetchPaginatedList('/playlists/$playlistId/items', start: start, size: size, abort: abort);
/// Get all playlists.
/// Filters by playlistType=video by default.
/// Set smart to true/false to filter smart playlists, or null for all.
Future<List<PlexPlaylistDto>> _getPlaylists({String playlistType = 'video', bool? smart}) async {
try {
final all = <PlexPlaylistDto>[];
var start = 0;
while (true) {
final page = await _getPlaylistsPage(
playlistType: playlistType,
smart: smart,
start: start,
size: _fetchAllPageSize,
);
if (page.items.isEmpty) break;
all.addAll(page.items);
start += page.items.length;
if (start >= page.totalSize) break;
}
return all;
} catch (e, st) {
appLogger.e('Failed to get playlists', error: e, stackTrace: st);
return [];
}
}
Future<({List<PlexPlaylistDto> items, int totalSize})> _getPlaylistsPage({
String playlistType = 'video',
bool? smart,
int? start,
int? size,
AbortController? abort,
}) async {
final pageSize = size ?? _defaultListContainerSize;
final queryParams = <String, dynamic>{
if (playlistType.isNotEmpty) 'playlistType': playlistType,
..._buildPaginationParams(start, pageSize),
};
if (smart != null) {
queryParams['smart'] = smart ? '1' : '0';
}
final response = await _getWithFailover('/playlists', queryParameters: queryParams, abort: abort);
return _extractPlaylistListResult(response, start: start, size: pageSize);
}
/// Get playlist metadata by playlist ID
/// Returns the playlist details (not the items)
Future<PlexPlaylistDto?> _getPlaylistMetadata(String playlistId) async {
try {
final response = await _getWithFailover('/playlists/$playlistId');
final container = _getMediaContainer(response);
if (container == null || container['Metadata'] == null) {
return null;
}
final List<dynamic> metadata = container['Metadata'] as List;
if (metadata.isEmpty) {
return null;
}
return PlexPlaylistDto.fromJson(metadata.first as Map<String, dynamic>);
} catch (e) {
appLogger.e('Failed to get playlist metadata: $e');
return null;
}
}
/// Neutral [MediaServerClient.createPlaylist] override — wraps
/// [createPlaylistFromUri] after building a Plex metadata URI from
/// the supplied items.
@override
Future<MediaPlaylist?> createPlaylist({required String title, required List<MediaItem> items}) async {
if (items.isEmpty) {
return createPlaylistFromUri(title: title);
}
final uri = await buildMetadataUri(items.map((i) => i.id).join(','));
return createPlaylistFromUri(title: title, uri: uri, type: items.first.kind.isMusic ? 'audio' : 'video');
}
/// 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
/// [type] - Plex playlist type ('video' or 'audio' for music items)
///
/// Errors propagate to the caller (matches the [MediaServerClient]
/// contract — throw on HTTP/transport failures, return `null` only when
/// the server replied 2xx but with no usable playlist payload).
Future<MediaPlaylist?> createPlaylistFromUri({
required String title,
String? uri,
int? playQueueId,
String type = 'video',
}) async {
final queryParams = <String, dynamic>{'type': type, 'title': title, 'smart': '0'};
if (uri != null) {
queryParams['uri'] = uri;
}
if (playQueueId != null) {
queryParams['playQueueID'] = playQueueId.toString();
}
final response = await _http.post('/playlists', queryParameters: queryParams);
throwIfHttpError(response);
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;
}
final dto = PlexPlaylistDto.fromJson(
metadata.first as Map<String, dynamic>,
).copyWith(serverId: serverId, serverName: serverName);
return PlexMappers.mediaPlaylist(dto);
}
/// Delete a playlist
@override
Future<bool> deletePlaylist(MediaPlaylist playlist) {
return _wrapBoolApiCall(() => _http.delete('/playlists/${playlist.id}'), 'Failed to delete playlist');
}
/// Neutral [MediaServerClient.addToPlaylist] override — builds a Plex
/// metadata URI from [items] and delegates to [addItemsToPlaylistByUri].
@override
Future<bool> addToPlaylist({required String playlistId, required List<MediaItem> items}) async {
if (items.isEmpty) return true;
final uri = await buildMetadataUri(items.map((i) => i.id).join(','));
return addItemsToPlaylistByUri(playlistId: playlistId, uri: uri);
}
/// Add items to a playlist
/// [playlistId] - The playlist to add items to
/// [uri] - Comma-separated list of item URIs to add
Future<bool> addItemsToPlaylistByUri({required String playlistId, required String uri}) async {
appLogger.d(
'Adding to playlist $playlistId with URI: ${uri.substring(0, uri.length > 100 ? 100 : uri.length)}${uri.length > 100 ? "..." : ""}',
);
final result = await _wrapBoolApiCall(
() => _http.put('/playlists/$playlistId/items', queryParameters: {'uri': uri}),
'Failed to add to playlist',
);
if (result) {
appLogger.d('Add to playlist response status: 200');
}
return result;
}
@override
Future<bool> removeFromPlaylist({required String playlistId, required MediaItem item}) {
if (item is! PlexMediaItem || item.playlistItemId == null) return Future.value(false);
return _wrapBoolApiCall(
() => _http.delete('/playlists/$playlistId/items/${item.playlistItemId}'),
'Failed to remove from playlist',
);
}
/// Plex's `?after=0` sentinel means "move to the top". For any other index
/// the API needs the playlist-item id of the row that should sit immediately
/// before [item] after the move — that's what [afterItem] provides.
@override
Future<bool> movePlaylistItem({
required String playlistId,
required MediaItem item,
required int newIndex,
required MediaItem? afterItem,
}) async {
if (item is! PlexMediaItem || item.playlistItemId == null) return false;
final int after;
if (newIndex == 0) {
after = 0;
} else if (afterItem is PlexMediaItem && afterItem.playlistItemId != null) {
after = afterItem.playlistItemId!;
} else {
return false;
}
appLogger.d('Moving playlist item ${item.playlistItemId} after $after in playlist $playlistId');
return _wrapBoolApiCall(
() => _http.put('/playlists/$playlistId/items/${item.playlistItemId}/move', queryParameters: {'after': after}),
'Failed to move playlist item',
);
}
/// Update metadata fields for a media item
Future<bool> updateMetadata({
required int sectionId,
required String ratingKey,
required int typeNumber,
String? title,
String? titleSort,
String? originalTitle,
String? originallyAvailableAt,
String? contentRating,
String? studio,
String? tagline,
String? summary,
Map<String, ({List<String> current, List<String> original})>? tagChanges,
}) async {
final queryParams = <String, dynamic>{'type': typeNumber, 'id': ratingKey};
void addField(String name, String? value) {
if (value != null) {
queryParams['$name.value'] = value;
queryParams['$name.locked'] = '1';
}
}
addField('title', title);
addField('titleSort', titleSort);
addField('originalTitle', originalTitle);
addField('originallyAvailableAt', originallyAvailableAt);
addField('contentRating', contentRating);
addField('studio', studio);
addField('tagline', tagline);
addField('summary', summary);
if (tagChanges != null) {
for (final entry in tagChanges.entries) {
final field = entry.key;
final current = entry.value.current;
final original = entry.value.original;
for (var i = 0; i < current.length; i++) {
queryParams['$field[$i].tag.tag'] = current[i];
}
final removed = original.where((t) => !current.contains(t)).toList();
if (removed.isNotEmpty) {
queryParams['$field[].tag.tag-'] = removed.map(Uri.encodeComponent).join(',');
}
queryParams['$field.locked'] = '1';
}
}
final result = await _wrapBoolApiCall(
() => _http.put('/library/sections/$sectionId/all', queryParameters: queryParams),
'Failed to update metadata',
);
if (result) {
await _deleteMetadataEditCache(ratingKey);
}
return result;
}
/// Search for match candidates for a media item.
Future<List<PlexMatchResult>> findMatches(
String ratingKey, {
String? title,
String? year,
String? agent,
String? language,
}) async {
final queryParams = <String, dynamic>{'manual': 1};
if (title != null && title.isNotEmpty) queryParams['title'] = title;
if (year != null && year.isNotEmpty) queryParams['year'] = year;
if (agent != null && agent.isNotEmpty) queryParams['agent'] = agent;
if (language != null && language.isNotEmpty) queryParams['language'] = language;
return _wrapListApiCall<PlexMatchResult>(
() => _getWithFailover('/library/metadata/$ratingKey/matches', queryParameters: queryParams),
(response) {
final container = _getMediaContainer(response);
if (container == null || container['SearchResult'] == null) return [];
return (container['SearchResult'] as List)
.map((json) => PlexMatchResult.fromJson(json as Map<String, dynamic>))
.toList();
},
'Failed to search for matches',
);
}
/// Apply a chosen match to a media item.
Future<bool> applyMatch(String ratingKey, {required String guid, String? name, String? year}) async {
final queryParams = <String, dynamic>{'guid': guid};
if (name != null && name.isNotEmpty) queryParams['name'] = name;
if (year != null && year.isNotEmpty) queryParams['year'] = year;
final result = await _wrapBoolApiCall(
() => _http.put('/library/metadata/$ratingKey/match', queryParameters: queryParams),
'Failed to apply match',
);
if (result) {
await _deleteMetadataEditCache(ratingKey);
}
return result;
}
Future<bool> unmatchItem(String ratingKey) async {
final result = await _wrapBoolApiCall(
() => _http.put('/library/metadata/$ratingKey/unmatch'),
'Failed to unmatch item',
);
if (result) {
await _deleteMetadataEditCache(ratingKey);
}
return result;
}
/// Get available artwork (posters or backgrounds) for a media item
Future<List<Map<String, dynamic>>> getAvailableArtwork(String ratingKey, String element) async {
try {
final response = await _getWithFailover('/library/metadata/$ratingKey/$element');
final container = _getMediaContainer(response);
if (container != null && container['Metadata'] != null) {
return (container['Metadata'] as List).cast<Map<String, dynamic>>();
}
return [];
} catch (e) {
appLogger.e('Failed to get available artwork', error: e);
return [];
}
}
/// Set artwork from a URL (can be a Plex internal path or external URL)
Future<bool> setArtworkFromUrl(String ratingKey, String element, String url) async {
final setElement = element.endsWith('s') ? element.substring(0, element.length - 1) : element;
final result = await _wrapBoolApiCall(
() => _http.put('/library/metadata/$ratingKey/$setElement', queryParameters: {'url': url}),
'Failed to set artwork from URL',
);
if (result) {
await _deleteMetadataEditCache(ratingKey);
}
return result;
}
/// Upload artwork from binary data
Future<bool> uploadArtwork(String ratingKey, String element, List<int> bytes) async {
final setElement = element.endsWith('s') ? element.substring(0, element.length - 1) : element;
final result = await _wrapBoolApiCall(
() => _http.put(
'/library/metadata/$ratingKey/$setElement',
body: bytes,
headers: {'Content-Type': 'application/octet-stream', 'Content-Length': '${bytes.length}'},
),
'Failed to upload artwork',
);
if (result) {
await _deleteMetadataEditCache(ratingKey);
}
return result;
}
/// Update per-media advanced preferences
Future<bool> updateMetadataPrefs(String ratingKey, Map<String, String> prefs) async {
final result = await _wrapBoolApiCall(
() => _http.put('/library/metadata/$ratingKey/prefs', queryParameters: prefs),
'Failed to update metadata preferences',
);
if (result) {
await _deleteMetadataEditCache(ratingKey);
}
return result;
}
Future<void> _deleteMetadataEditCache(String ratingKey) async {
try {
await _cache.deleteForItem(serverId, ratingKey);
} catch (e, st) {
appLogger.w('Plex metadata edit cache invalidation failed', error: e, stackTrace: st);
}
}
/// Get one page of collections for a library section.
Future<_LibraryContentResult> _getLibraryCollectionsPage(
String sectionId, {
int? start,
int? size,
AbortController? abort,
}) async {
final queryParameters = _buildPaginationParams(start, size)..['includeGuids'] = 1;
final response = await _getWithFailover(
'/library/sections/$sectionId/collections',
queryParameters: queryParameters,
abort: abort,
);
return _extractLibraryContentResult(
response,
librarySectionID: _librarySectionIdFromString(sectionId),
start: start,
requestedSize: size,
);
}
/// Get all collections for a library section.
Future<List<PlexMetadataDto>> _getLibraryCollections(String sectionId) async {
try {
return _fetchAllPages((start, size, abort) {
return _getLibraryCollectionsPage(sectionId, start: start, size: size, abort: abort);
});
} catch (e, st) {
appLogger.e('Failed to get library collections', error: e, stackTrace: st);
return [];
}
}
/// Get items in a collection, paginated.
Future<_LibraryContentResult> _getCollectionItems(
String collectionId, {
int? start,
int? size,
AbortController? abort,
String? librarySectionID,
String? librarySectionTitle,
}) => _fetchPaginatedList(
'/library/collections/$collectionId/children',
start: start,
size: size,
abort: abort,
librarySectionID: _librarySectionIdFromString(librarySectionID),
librarySectionTitle: librarySectionTitle,
);
/// Get media featuring a specific person (actor/director), paginated.
Future<_LibraryContentResult> _getPersonMedia(String personId, {int? start, int? size, AbortController? abort}) =>
_fetchPaginatedList('/library/people/$personId/media', start: start, size: size, abort: abort);
/// Fetch every media item featuring a given person.
Future<List<PlexMetadataDto>> _fetchAllPersonMediaDto(String personId) =>
_fetchAllPages((start, size, abort) => _getPersonMedia(personId, start: start, size: size, abort: abort));
/// Delete a collection. Reads the section id from [collection.libraryId].
@override
Future<bool> deleteCollection(MediaItem collection) async {
final sectionId = collection.libraryId ?? '';
return deleteCollectionById(sectionId, collection.id);
}
Future<bool> deleteCollectionById(String sectionId, String collectionId) async {
appLogger.d('Deleting collection: sectionId=$sectionId, collectionId=$collectionId');
final result = await _wrapBoolApiCall(
() => _http.delete('/library/collections/$collectionId'),
'Failed to delete collection',
);
if (result) {
appLogger.d('Delete collection response: 200');
}
return result;
}
/// Neutral [MediaServerClient.createCollection] — builds a Plex metadata
/// URI for [items] and maps [itemKind] to Plex's section type id.
@override
Future<String?> createCollection({
required String libraryId,
required String title,
required List<MediaItem> items,
MediaKind? itemKind,
}) async {
final uri = items.isEmpty ? '' : await buildMetadataUri(items.map((i) => i.id).join(','));
final type = switch (itemKind) {
MediaKind.movie => 1,
MediaKind.show => 2,
MediaKind.season => 3,
MediaKind.episode => 4,
_ => null,
};
return createCollectionFromUri(sectionId: libraryId, title: title, uri: uri, type: type);
}
/// 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?> createCollectionFromUri({
required String sectionId,
required String title,
required String uri,
int? type,
}) async {
try {
appLogger.d('Creating collection: sectionId=$sectionId, title=$title, type=$type');
final response = await _http.post(
'/library/collections',
queryParameters: {'type': ?type, 'title': title, 'smart': 0, 'sectionId': sectionId, 'uri': uri},
);
throwIfHttpError(response);
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;
}
}
/// Neutral [MediaServerClient.addToCollection] — builds a Plex metadata URI
/// from [items] and delegates to [addItemsToCollectionByUri].
@override
Future<bool> addToCollection({required String collectionId, required List<MediaItem> items}) async {
if (items.isEmpty) return true;
final uri = await buildMetadataUri(items.map((i) => i.id).join(','));
return addItemsToCollectionByUri(collectionId: collectionId, uri: uri);
}
/// Add items to an existing collection
/// Adds one or more items (specified by URI) to an existing collection
Future<bool> addItemsToCollectionByUri({required String collectionId, required String uri}) async {
appLogger.d('Adding items to collection: collectionId=$collectionId');
final result = await _wrapBoolApiCall(
() => _http.put('/library/collections/$collectionId/items', queryParameters: {'uri': uri}),
'Failed to add items to collection',
);
if (result) {
appLogger.d('Add to collection response: 200');
}
return result;
}
/// Remove an item from a collection
/// Removes a single item from an existing collection
@override
Future<bool> removeFromCollection({required String collectionId, required MediaItem item}) async {
appLogger.d('Removing item from collection: collectionId=$collectionId, itemId=${item.id}');
final result = await _wrapBoolApiCall(
() => _http.delete('/library/collections/$collectionId/items/${item.id}'),
'Failed to remove item from collection',
);
if (result) {
appLogger.d('Remove from collection response: 200');
}
return result;
}
/// Parse a `/playQueues/{id}` response into a [PlayQueueResponse] with
/// MediaItem-typed entries.
PlayQueueResponse _parsePlayQueueResponse(dynamic data, {int? librarySectionID, String? librarySectionTitle}) {
final container = data is Map && data['MediaContainer'] is Map
? data['MediaContainer'] as Map<String, dynamic>
: data as Map<String, dynamic>;
final containerSectionID = _librarySectionIdFromJson(container) ?? librarySectionID;
final containerSectionTitle = _librarySectionTitleFromJson(container) ?? librarySectionTitle;
final metadata = container['Metadata'];
List<MediaItem>? items;
if (metadata is List) {
items = [
for (final e in metadata)
if (e is Map<String, dynamic>)
PlexMappers.mediaItem(
_createTaggedMetadataWithLibrary(
e,
librarySectionID: containerSectionID,
librarySectionTitle: containerSectionTitle,
),
),
];
}
final playQueueID = flexibleInt(container['playQueueID']);
final playQueueVersion = flexibleInt(container['playQueueVersion']);
if (playQueueID == null || playQueueVersion == null) {
throw const FormatException('Plex play queue response is missing its numeric id or version');
}
return PlayQueueResponse(
playQueueID: playQueueID,
playQueueSelectedItemID: flexibleInt(container['playQueueSelectedItemID']),
playQueueSelectedItemOffset: flexibleInt(container['playQueueSelectedItemOffset']),
playQueueSelectedMetadataItemID: container['playQueueSelectedMetadataItemID'] as String?,
playQueueShuffled: flexibleBool(container['playQueueShuffled']),
playQueueSourceURI: container['playQueueSourceURI'] as String?,
playQueueTotalCount: flexibleInt(container['playQueueTotalCount']),
playQueueVersion: playQueueVersion,
size: flexibleInt(container['size']),
items: items,
);
}
/// 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,
String? librarySectionID,
String? librarySectionTitle,
}) async {
try {
final queryParams = <String, dynamic>{
'type': type,
'shuffle': shuffle,
'repeat': repeat,
'continuous': continuous,
};
if (uri != null) {
queryParams['uri'] = uri;
}
if (playlistID != null) {
queryParams['playlistID'] = playlistID;
}
if (key != null) {
queryParams['key'] = key;
}
final response = await _http.post('/playQueues', queryParameters: queryParams);
throwIfHttpError(response);
return _parsePlayQueueResponse(
response.data,
librarySectionID: _librarySectionIdFromString(librarySectionID),
librarySectionTitle: librarySectionTitle,
);
} 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,
String? librarySectionID,
String? librarySectionTitle,
}) async {
try {
final queryParams = <String, dynamic>{
'window': window,
'includeBefore': includeBefore,
'includeAfter': includeAfter,
};
if (center != null) {
queryParams['center'] = center;
}
final response = await _getWithFailover('/playQueues/$playQueueId', queryParameters: queryParams);
return _parsePlayQueueResponse(
response.data,
librarySectionID: _librarySectionIdFromString(librarySectionID),
librarySectionTitle: librarySectionTitle,
);
} catch (e) {
appLogger.e('Failed to get play queue: $e');
return null;
}
}
/// Create a play queue for a TV show (all episodes)
///
/// This is a convenience method that creates a play queue from a show's URI.
/// Perfect for sequential or shuffle playback of an entire series.
///
/// Parameters:
/// - [showRatingKey]: The rating key of the show
/// - [shuffle]: Whether to shuffle the episodes (0 = off, 1 = on)
/// - [startingEpisodeKey]: Optional rating key of episode to start from
///
/// Returns a PlayQueueResponse with all episodes from the show
Future<PlayQueueResponse?> createShowPlayQueue({
required String showRatingKey,
int shuffle = 0,
String? startingEpisodeKey,
String? librarySectionID,
String? librarySectionTitle,
}) async {
try {
// Build the queue from the show's `/allLeaves` (every episode) rather than
// `/children` (its seasons). Plex flattens `/children` season-by-season,
// which clumps the whole Specials folder together; `/allLeaves` makes Plex
// order the queue by the show's aired episode order, so Specials interleave
// between regular episodes the way Plex's own client plays them. `/children`
// otherwise strands interleaved Specials ahead of S01, so sequential
// auto-play walks the season and never reaches them (#1416).
final uri = '${await buildMetadataUri(showRatingKey)}/allLeaves';
return await createPlayQueue(
uri: uri,
type: 'video',
shuffle: shuffle,
key: startingEpisodeKey != null ? '/library/metadata/$startingEpisodeKey' : null,
continuous: startingEpisodeKey != null && shuffle == 0 ? 1 : 0,
librarySectionID: librarySectionID,
librarySectionTitle: librarySectionTitle,
);
} 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<PlexMetadataDto> _extractMetadataAndDirectories(
MediaServerResponse response, {
int? librarySectionID,
String? librarySectionTitle,
}) {
final List<PlexMetadataDto> items = [];
final container = _getMediaContainer(response);
if (container != null) {
final containerSectionID = _librarySectionIdFromJson(container) ?? librarySectionID;
final containerSectionTitle = _librarySectionTitleFromJson(container) ?? librarySectionTitle;
// 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 PlexMetadataDto.fromJson first
items.add(
_createTaggedMetadataWithLibrary(
json as Map<String, dynamic>,
librarySectionID: containerSectionID,
librarySectionTitle: containerSectionTitle,
),
);
} catch (e) {
// If full parsing fails, use minimal safe parsing
appLogger.d('Using minimal parsing for metadata item: $e');
try {
items.add(
PlexMetadataDto(
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'],
librarySectionID: _librarySectionIdFromJson(json) ?? containerSectionID,
librarySectionTitle: _librarySectionTitleFromJson(json) ?? containerSectionTitle,
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 PlexMetadataDto first
items.add(
_createTaggedMetadataWithLibrary(
json as Map<String, dynamic>,
librarySectionID: containerSectionID,
librarySectionTitle: containerSectionTitle,
),
);
} catch (e) {
// If that fails, use minimal folder representation
try {
items.add(
PlexMetadataDto(
ratingKey: json['key'] ?? json['ratingKey'] ?? '',
key: json['key'] ?? '',
type: json['type'] ?? 'folder',
title: json['title'] ?? 'Untitled',
thumb: json['thumb'],
art: json['art'],
librarySectionID: _librarySectionIdFromJson(json) ?? containerSectionID,
librarySectionTitle: _librarySectionTitleFromJson(json) ?? containerSectionTitle,
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<PlexMetadataDto>> _getLibraryFolders(String sectionId) async {
try {
final response = await _getWithFailover(
'/library/sections/$sectionId/folder',
queryParameters: {'includeCollections': 0},
);
return _extractMetadataAndDirectories(response, librarySectionID: _librarySectionIdFromString(sectionId));
} 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<PlexMetadataDto>> _getFolderChildren(
String folderKey, {
String? librarySectionID,
String? librarySectionTitle,
}) async {
try {
final response = await _getWithFailover(folderKey);
return _extractMetadataAndDirectories(
response,
librarySectionID: _librarySectionIdFromString(folderKey) ?? _librarySectionIdFromString(librarySectionID),
librarySectionTitle: librarySectionTitle,
);
} 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
/// Scan/refresh a library section to detect new files
Future<void> scanLibrary(String sectionId) async {
await _getWithFailover('/library/sections/$sectionId/refresh');
}
/// Refresh metadata for a library section
@override
Future<void> refreshLibraryMetadata(String sectionId) async {
await _getWithFailover('/library/sections/$sectionId/refresh?force=1');
}
/// Empty trash for a library section
Future<void> emptyLibraryTrash(String sectionId) async {
final response = await _http.put('/library/sections/$sectionId/emptyTrash');
throwIfHttpError(response);
}
/// Analyze library section
Future<void> analyzeLibrary(String sectionId) async {
await _getWithFailover('/library/sections/$sectionId/analyze');
}
/// Generate 24-char random alphanumeric string. Backend-neutral helper —
/// prefer importing `utils/session_identifier.dart` directly. This thin
/// forwarder stays for callers that already had a `PlexClient.` reference;
/// remove it once they migrate.
static String generateSessionIdentifier() => session_id.generateSessionIdentifier();
/// Coerce String values to num for fields that json_serializable expects as num.
/// Plex tune responses use XML-to-JSON conversion where all values are strings.
static void _coerceNumericFields(Map<String, dynamic> json) {
const numericKeys = [
'duration',
'year',
'addedAt',
'updatedAt',
'lastViewedAt',
'parentIndex',
'index',
'viewOffset',
'viewCount',
'leafCount',
'viewedLeafCount',
'childCount',
'rating',
'audienceRating',
'userRating',
'ratingCount',
'skipCount',
'lastRatedAt',
];
for (final key in numericKeys) {
final val = json[key];
if (val is String) {
json[key] = num.tryParse(val);
}
}
}
/// Checks whether the server has video transcoding enabled.
///
/// Reads `transcoderVideo` from the root MediaContainer. Result is cached
/// for the lifetime of this [PlexClient]. Returns `true` on error (fail-open)
/// — the transcode decision call itself will fail gracefully if transcoding
/// really is unavailable.
Future<bool> serverSupportsVideoTranscoding() {
final cached = _serverTranscoderCached;
if (cached != null) return Future.value(cached);
return _serverTranscoderPending ??= _fetchTranscoderCapability();
}
/// Synchronous view of the probe — returns the cached value, or `true`
/// (assume supported) if the post-connect warm-up hasn't landed yet. The
/// transcode decision call has its own fallback path, so guessing wrong
/// here just routes through that fallback instead of blocking playback.
bool get serverSupportsVideoTranscodingCached => _serverTranscoderCached ?? true;
Future<bool> _fetchTranscoderCapability() async {
try {
// Tight timeout: `/` returns a tiny MediaContainer — any responsive
// server answers in well under a second. Inheriting the default 120 s
// receive timeout would keep a hung server from ever resolving.
final response = await _http.get('/', timeout: const Duration(seconds: 5));
final container = _getMediaContainer(response);
final value = container?['transcoderVideo'];
final supported = _parsePlexTranscoderVideoCapability(value) ?? true;
_serverTranscoderCached = supported;
return supported;
} catch (e) {
appLogger.w('Failed to query server transcoder capability', error: e);
_serverTranscoderCached = true;
return true;
}
}
/// Build a VOD transcode stream URL (decision + start path).
///
/// Mirrors the live tune's _buildLiveStreamPath but for on-demand video with a quality
/// preset, selected audio stream, and Plex Desktop-style HTTP/MKV output.
/// Text subtitles selected on the Plex part are embedded in the MKV stream;
/// real external sidecars are still attached separately by callers.
///
/// [transcodeSessionId] and [sessionIdentifier] should be reused across
/// seeks + quality/version/audio switches within one playback so the
/// server-side transcode session is preserved.
Future<({String? startPath, TranscodeDecisionOutcome outcome})> buildTranscodeStartPath({
required String ratingKey,
required int mediaIndex,
int partIndex = 0,
required TranscodeQualityPreset preset,
required String sessionIdentifier,
required String transcodeSessionId,
int? audioStreamId,
MediaSubtitleTrack? selectedSubtitleTrack,
int? offsetMs,
}) async {
try {
final allParams = _buildTranscodeParams(
ratingKey: ratingKey,
mediaIndex: mediaIndex,
partIndex: partIndex,
preset: preset,
sessionIdentifier: sessionIdentifier,
transcodeSessionId: transcodeSessionId,
audioStreamId: audioStreamId,
selectedSubtitleTrack: selectedSubtitleTrack,
offsetMs: offsetMs,
);
return await _runTranscodeDecision(
startEndpoint: _videoTranscodeStartEndpoint,
allParams: allParams,
isOriginal: preset.isOriginal,
);
} catch (e, st) {
appLogger.e('Failed to build transcode start path', error: e, stackTrace: st);
return (startPath: null, outcome: TranscodeDecisionOutcome.failed);
}
}
/// Build a music transcode stream URL (decision + start path).
///
/// Mirrors [buildTranscodeStartPath] for audio tracks: the same
/// decision → start handshake against `/music/:/transcode/universal`,
/// with a bitrate-capped MP3 target instead of the video HTTP/MKV
/// target. No subtitle/copyts params — those are video-only.
Future<({String? startPath, TranscodeDecisionOutcome outcome})> buildMusicTranscodeStartPath({
required String ratingKey,
required int mediaIndex,
int partIndex = 0,
required AudioQualityPreset preset,
required String sessionIdentifier,
required String transcodeSessionId,
}) async {
try {
final allParams = _buildMusicTranscodeParams(
ratingKey: ratingKey,
mediaIndex: mediaIndex,
partIndex: partIndex,
preset: preset,
sessionIdentifier: sessionIdentifier,
transcodeSessionId: transcodeSessionId,
);
return await _runTranscodeDecision(
startEndpoint: _musicTranscodeStartEndpoint,
allParams: allParams,
isOriginal: preset.isOriginal,
);
} catch (e, st) {
appLogger.e('Failed to build music transcode start path', error: e, stackTrace: st);
return (startPath: null, outcome: TranscodeDecisionOutcome.failed);
}
}
static const String _videoTranscodeStartEndpoint = '/video/:/transcode/universal/start';
static const String _musicTranscodeStartEndpoint = '/music/:/transcode/universal/start.mp3';
/// Shared decision plumbing for the video and music transcode flows: GET
/// the sibling `decision` endpoint with the exact start params, parse the
/// outcome via [_parseTranscodeDecisionOutcome], and hand back the start
/// path (token stripped) on success. [startEndpoint] is the start path the
/// stream will use, including any container extension (`start` /
/// `start.mp3`).
Future<({String? startPath, TranscodeDecisionOutcome outcome})> _runTranscodeDecision({
required String startEndpoint,
required Map<String, String> allParams,
required bool isOriginal,
}) async {
final queryString = allParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&');
final decisionEndpoint = '${startEndpoint.substring(0, startEndpoint.lastIndexOf('/'))}/decision';
final decisionClient = MediaServerHttpClient(
connectTimeout: MediaServerTimeouts.connect,
receiveTimeout: MediaServerTimeouts.receive,
defaultHeaders: const {'Accept-Language': 'en', 'Accept': 'application/json'},
);
try {
final decisionUrl = '${config.baseUrl}$decisionEndpoint?$queryString';
final decisionResponse = await decisionClient.get(decisionUrl);
final decisionBody = decisionResponse.data?.toString() ?? '<empty>';
appLogger.i(
'Transcode decision [${decisionResponse.statusCode}] body: '
'${decisionBody.length > 2000 ? '${decisionBody.substring(0, 2000)}…' : decisionBody}',
);
if (decisionResponse.statusCode != 200) {
appLogger.w('Transcode decision returned ${decisionResponse.statusCode}');
return (startPath: null, outcome: TranscodeDecisionOutcome.failed);
}
final outcome = _parseTranscodeDecisionOutcome(decisionResponse.data, isOriginal: isOriginal);
if (outcome == TranscodeDecisionOutcome.failed) {
return (startPath: null, outcome: outcome);
}
return (startPath: _buildTranscodeStartPathFromParams(allParams, endpoint: startEndpoint), outcome: outcome);
} finally {
decisionClient.close();
}
}
String _buildTranscodeStartPathFromParams(
Map<String, String> params, {
String endpoint = _videoTranscodeStartEndpoint,
}) {
final startParams = Map<String, String>.from(params)..remove('X-Plex-Token');
final startQuery = startParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&');
return '$endpoint?$startQuery';
}
@visibleForTesting
String buildTranscodeStartPathFromParamsForTesting(
Map<String, String> params, {
String endpoint = _videoTranscodeStartEndpoint,
}) {
return _buildTranscodeStartPathFromParams(params, endpoint: endpoint);
}
Map<String, String> _buildTranscodeParams({
required String ratingKey,
required int mediaIndex,
int partIndex = 0,
required TranscodeQualityPreset preset,
required String sessionIdentifier,
required String transcodeSessionId,
int? audioStreamId,
MediaSubtitleTrack? selectedSubtitleTrack,
int? offsetMs,
}) {
final isOriginal = preset.isOriginal;
final selectedEmbeddedSubtitle = _shouldEmbedSubtitleInHttpTranscode(selectedSubtitleTrack)
? selectedSubtitleTrack
: null;
// Only text subtitles get `advancedSubtitles=text`; image subtitles
// (PGS/VOBSUB) are copied into the MKV as-is for the player to render.
final embedSubtitleAsText =
selectedEmbeddedSubtitle != null && _canTranscodeSubtitleAsText(selectedEmbeddedSubtitle);
// Build the client profile from scratch via X-Plex-Client-Profile-Extra.
// We use the `Generic` base platform (see [_transcodePlatformName]) which
// has no pre-installed transcode targets, so we must `add-transcode-target`
// rather than `append-transcode-target-codec` (which only edits existing
// targets — empty on Generic, hence Plex returned decision code 2000
// "neither direct play nor conversion is available").
//
// For non-original presets we also add a bitrate limitation that caps
// the video codec; with `replace=true` it overrides any default limit.
//
// See openapi.md §"Profile Augmentations" for the DSL reference.
final profileExtraClauses = <String>['add-settings(DirectPlayStreamSelection=true)'];
if (!isOriginal && preset.videoBitrateKbps != null) {
profileExtraClauses.add(
'add-limitation(scope=videoCodec&scopeName=*&type=upperBound'
'&name=video.bitrate&value=${preset.videoBitrateKbps}&replace=true)',
);
}
// Match Plex Desktop's stable HTTP/MKV transcode target. Codec-list commas
// are pre-encoded as `%2C` — see the profile-extra encoding note above.
profileExtraClauses.add(
'add-transcode-target(type=videoProfile&context=streaming'
'&protocol=http&container=mkv&videoCodec=h264%2Chevc%2C*'
'&audioCodec=opus%2Cvorbis%2Cflac%2C*&subtitleCodec=ass%2Cpgs%2Cvobsub%2C*)',
);
profileExtraClauses.add(
'add-transcode-target-settings(type=videoProfile&context=streaming'
'&protocol=http&CopyMatroskaAttachments=true)',
);
final clientProfileExtra = profileExtraClauses.join('+');
// HTTP/MKV matches Plex Desktop and lets MPV see embedded subtitle streams.
// HLS `subtitles=segmented` was accepted by Plex but produced manifests
// with only video/audio renditions for MPV.
return <String, String>{
'hasMDE': '1',
'path': '/library/metadata/$ratingKey',
'mediaIndex': mediaIndex.toString(),
'partIndex': partIndex.toString(),
'protocol': 'http',
'fastSeek': '1',
'directPlay': isOriginal ? '1' : '0',
'directStream': isOriginal ? '1' : '0',
'subtitleSize': '100',
'audioBoost': '100',
'location': 'lan',
if (!isOriginal && preset.videoBitrateKbps != null) 'maxVideoBitrate': preset.videoBitrateKbps.toString(),
'addDebugOverlay': '0',
'autoAdjustQuality': '0',
'directStreamAudio': '0',
'mediaBufferSize': '102400',
'session': transcodeSessionId,
// Embed the selected subtitle in the MKV stream: text codecs are
// converted to text, image codecs (PGS/VOBSUB) are copied as-is and
// rendered by the player — never burned into the video. Unselected tracks
// and keyed sidecars stay at `none`.
'subtitles': selectedEmbeddedSubtitle != null ? 'embedded' : 'none',
if (selectedEmbeddedSubtitle != null) 'subtitleStreamID': selectedEmbeddedSubtitle.id.toString(),
if (embedSubtitleAsText) 'advancedSubtitles': 'text',
// Preserve source timestamps for the HTTP/MKV stream so player seeks and
// sidecar subtitles stay aligned with Plex source time.
'copyts': '1',
if (audioStreamId != null) 'audioStreamID': audioStreamId.toString(),
'Accept-Language': 'en',
'X-Plex-Session-Identifier': sessionIdentifier,
'X-Plex-Client-Profile-Extra': clientProfileExtra,
'X-Plex-Chunked': '1',
'X-Plex-Features': 'external-media,indirect-media',
'X-Plex-Model': 'standalone',
'X-Plex-Language': 'en',
'X-Plex-Product': config.product,
'X-Plex-Version': config.version,
'X-Plex-Client-Identifier': config.clientIdentifier,
// Plex's server rejects unknown platform names with HTTP 400 and maps
// known names to codec/bitrate base profiles. Our usual "Flutter"
// platform, plus "MacOSX" / "Linux", are all rejected; swap to a
// Plex-recognized name just for transcode requests. See
// [_transcodePlatformName] for the mapping.
'X-Plex-Platform': _transcodePlatformName(),
if (config.device != null) 'X-Plex-Device': config.device!,
if (config.deviceName != null) 'X-Plex-Device-Name': config.deviceName!,
if (offsetMs != null) 'offset': (offsetMs ~/ 1000).toString(),
if (config.token != null) 'X-Plex-Token': config.token!,
};
}
@visibleForTesting
Map<String, String> buildTranscodeParamsForTesting({
required String ratingKey,
required int mediaIndex,
int partIndex = 0,
required TranscodeQualityPreset preset,
required String sessionIdentifier,
required String transcodeSessionId,
int? audioStreamId,
MediaSubtitleTrack? selectedSubtitleTrack,
int? offsetMs,
}) {
return _buildTranscodeParams(
ratingKey: ratingKey,
mediaIndex: mediaIndex,
partIndex: partIndex,
preset: preset,
sessionIdentifier: sessionIdentifier,
transcodeSessionId: transcodeSessionId,
audioStreamId: audioStreamId,
selectedSubtitleTrack: selectedSubtitleTrack,
offsetMs: offsetMs,
);
}
Map<String, String> _buildMusicTranscodeParams({
required String ratingKey,
required int mediaIndex,
int partIndex = 0,
required AudioQualityPreset preset,
required String sessionIdentifier,
required String transcodeSessionId,
}) {
// The musicProfile transcode target is required: our `Generic` base
// platform ships no targets, and without one the server rejects the
// decision with code 4005. MP3 matches Plex Web's music transcode
// container and plays everywhere.
const clientProfileExtra =
'add-transcode-target(type=musicProfile&context=streaming'
'&protocol=http&container=mp3&audioCodec=mp3)';
return <String, String>{
'hasMDE': '1',
'path': '/library/metadata/$ratingKey',
'mediaIndex': mediaIndex.toString(),
'partIndex': partIndex.toString(),
'protocol': 'http',
'directPlay': '0',
'directStream': '0',
if (preset.bitrateKbps != null) 'musicBitrate': preset.bitrateKbps.toString(),
'session': transcodeSessionId,
'X-Plex-Session-Identifier': sessionIdentifier,
'X-Plex-Client-Profile-Extra': clientProfileExtra,
'X-Plex-Product': config.product,
'X-Plex-Version': config.version,
'X-Plex-Client-Identifier': config.clientIdentifier,
'X-Plex-Platform': _transcodePlatformName(),
if (config.device != null) 'X-Plex-Device': config.device!,
if (config.deviceName != null) 'X-Plex-Device-Name': config.deviceName!,
if (config.token != null) 'X-Plex-Token': config.token!,
};
}
@visibleForTesting
Map<String, String> buildMusicTranscodeParamsForTesting({
required String ratingKey,
required int mediaIndex,
int partIndex = 0,
required AudioQualityPreset preset,
required String sessionIdentifier,
required String transcodeSessionId,
}) {
return _buildMusicTranscodeParams(
ratingKey: ratingKey,
mediaIndex: mediaIndex,
partIndex: partIndex,
preset: preset,
sessionIdentifier: sessionIdentifier,
transcodeSessionId: transcodeSessionId,
);
}
/// Platform name Plex Media Server accepts on the transcode decision
/// endpoint for arbitrary clients. Our default "Flutter" returns HTTP 400,
/// and the known-OS names (`MacOSX`, `Mac`, `Linux`) are also rejected.
/// `Generic` is accepted and comes with no preset transcode targets — we
/// build the profile ourselves via `X-Plex-Client-Profile-Extra` with
/// `add-transcode-target`.
static String _transcodePlatformName() => 'Generic';
/// Strict percent-encoder matching Plex Web's URL encoder — escapes the
/// extra characters `(`, `)`, `*`, `'`, `!` that Dart's [Uri.encodeComponent]
/// leaves literal. Required for `X-Plex-Client-Profile-Extra` whose parens
/// and asterisks must appear as `%28`, `%29`, `%2A` on the wire.
static String _plexEncode(String value) {
return Uri.encodeComponent(value)
.replaceAll('(', '%28')
.replaceAll(')', '%29')
.replaceAll('*', '%2A')
.replaceAll("'", '%27')
.replaceAll('!', '%21');
}
/// Parse decision response for outcome. Any decision code >= 2000 = error
/// (matching Plex Web's error detector).
TranscodeDecisionOutcome _parseTranscodeDecisionOutcome(dynamic data, {required bool isOriginal}) {
try {
Map<String, dynamic>? container;
if (data is Map && data['MediaContainer'] is Map) {
container = Map<String, dynamic>.from(data['MediaContainer'] as Map);
} else if (data is Map<String, dynamic>) {
container = data;
}
if (container == null) return TranscodeDecisionOutcome.failed;
final general = flexibleInt(container['generalDecisionCode']);
final transcode = flexibleInt(container['transcodeDecisionCode']);
final mde = flexibleInt(container['mdeDecisionCode']);
bool isError(int? code) => code != null && code >= 2000;
if (isError(general) || isError(transcode) || isError(mde)) {
appLogger.w('Transcode decision error codes: general=$general transcode=$transcode mde=$mde');
return TranscodeDecisionOutcome.failed;
}
if (isOriginal) return TranscodeDecisionOutcome.transcodeOk;
if (transcode == 1000) return TranscodeDecisionOutcome.directPlayOnly;
if (transcode == 1001) return TranscodeDecisionOutcome.transcodeOk;
if (general == 1001) return TranscodeDecisionOutcome.transcodeOk;
if (general == 1000) return TranscodeDecisionOutcome.directPlayOnly;
return TranscodeDecisionOutcome.transcodeOk;
} catch (e) {
appLogger.w('Failed to parse transcode decision', error: e);
return TranscodeDecisionOutcome.failed;
}
}
/// The persist branch is deliberately outside the changed-guard: the
/// failover client's two-phase protocol applies the switch with
/// `persist: false` first, then re-calls with `persist: true` after the
/// retry succeeds — by which point the URL is already current.
Future<void> _handleEndpointSwitch(String newBaseUrl, {bool persist = true}) async {
if (config.baseUrl != newBaseUrl) {
appLogger.i('Applying Plex endpoint switch', error: newBaseUrl);
_http.baseUrl = newBaseUrl;
config = config.copyWith(baseUrl: newBaseUrl);
LogRedactionManager.registerServerUrl(newBaseUrl);
}
if (persist && _onEndpointChanged != null) {
await _onEndpointChanged(newBaseUrl);
}
}
/// Apply a fresh per-server access token to this client *in place*. Used
/// by [MultiServerManager.refreshTokensForProfile] when switching the
/// active profile so the existing client picks up the new user's
/// identity without a teardown / reconnect.
///
/// Updates both `config.token` and `_http.defaultHeaders` — without the
/// header refresh the next request still sends the previous user's
/// `X-Plex-Token`, so the server returns the *previous* user's view of
/// On Deck / hubs / watch state.
Future<void> applyTokenUpdate(String newToken) async {
if (config.token == newToken) return;
config = config.copyWith(token: newToken);
_http.defaultHeaders = Map.of(config.headers);
LogRedactionManager.registerToken(newToken);
await _initMediaProviders();
}
/// Apply the app locale to future Plex API requests. PMS localizes standard
/// server-provided labels (hubs, generic seasons, etc.) from these headers.
void applyLanguageUpdate(String languageCode) {
if (config.languageCode == languageCode) return;
config = config.copyWith(languageCode: languageCode);
_http.defaultHeaders = Map.of(config.headers);
}
// ────────────────────────────────────────────────────────────────────
// MediaServerClient implementation
//
// These methods wrap the existing Plex-typed methods above and return
// backend-neutral types. They form a thin façade so providers and UI can
// be migrated off `PlexMetadataDto` without changing the underlying transport.
// ────────────────────────────────────────────────────────────────────
@override
MediaBackend get backend => MediaBackend.plex;
@override
ServerCapabilities get capabilities => ServerCapabilities.plex.copyWith(
// Per-server probe: not every Plex install ships with a working
// transcoder (depends on Plex Pass + sufficient hardware). The
// cached value defaults to `true` until [serverSupportsVideoTranscoding]
// resolves — kicked off as a background probe at the end of
// [PlexClient.create] so the first quality-picker tap reflects
// reality on warm clients.
videoTranscoding: serverSupportsVideoTranscodingCached,
);
@override
Future<List<MediaLibrary>> fetchLibraries() async {
final libraries = await _getLibraries();
return libraries.map((l) => PlexMappers.mediaLibrary(l)).toList();
}
@override
Future<LibraryPage<MediaItem>> fetchLibraryContent(String libraryId, LibraryQuery query) async {
final filters = const PlexLibraryQueryTranslator().toQueryParameters(query);
final result = await _getLibraryContent(libraryId, start: query.offset, size: query.limit, filters: filters);
return LibraryPage<MediaItem>(
items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
totalCount: result.totalSize,
offset: query.offset,
);
}
@override
Future<MediaItem?> fetchItem(String id) async {
final metadata = await _getMetadataWithImages(id);
return metadata == null ? null : PlexMappers.mediaItem(metadata);
}
@override
Future<List<MediaItem>> fetchChildren(String parentId) async {
final children = await _getChildren(parentId);
return children.map((m) => PlexMappers.mediaItem(m)).toList();
}
@override
Future<LibraryPage<MediaItem>> fetchChildrenPage(
String parentId, {
int? start,
int? size,
AbortController? abort,
}) async {
final result = await _getChildrenPage(parentId, start: start, size: size, abort: abort);
return LibraryPage<MediaItem>(
items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
totalCount: result.totalSize,
offset: start ?? 0,
);
}
@override
Future<LibraryPage<MediaItem>> fetchSeasonEpisodesPage(
String seriesId,
String seasonId, {
int? start,
int? size,
AbortController? abort,
}) {
return fetchChildrenPage(seasonId, start: start, size: size, abort: abort);
}
@override
Future<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
// Albums parent their tracks directly and `/grandchildren` returns
// nothing for them — branch to `/children`. Artists and shows/seasons
// one-shot via `/grandchildren` (artist → every track, show/season →
// every episode). The kind lookup rides the cached metadata row
// (cache-first — detail screens pre-warm it), so the common path adds
// no extra round-trip.
if (await _fetchItemKind(parentId) == MediaKind.album) {
return fetchChildren(parentId);
}
final leaves = await _fetchAllPages(
(start, size, abort) => _getGrandchildrenPage(parentId, start: start, size: size, abort: abort),
);
return leaves.map((m) => PlexMappers.mediaItem(m)).toList();
}
/// Item kind for [ratingKey] via the cache-first metadata row. Returns
/// [MediaKind.unknown] when the item can't be resolved so callers fall
/// back to their default branch.
Future<MediaKind> _fetchItemKind(String ratingKey) async {
try {
final metadataJson = await _fetchRawMetadataJsonCacheFirst(ratingKey);
return MediaKind.fromString(metadataJson?['type'] as String?);
} catch (e) {
appLogger.w('Failed to resolve item kind for $ratingKey', error: e);
return MediaKind.unknown;
}
}
@override
Future<LibraryPage<MediaItem>> fetchPlayableDescendantsPage(
String parentId, {
int? start,
int? size,
AbortController? abort,
}) async {
final result = await _getGrandchildrenPage(parentId, start: start, size: size, abort: abort);
return LibraryPage<MediaItem>(
items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
totalCount: result.totalSize,
offset: start ?? 0,
);
}
/// Plex maintains episode queues server-side via `/playQueues`, so the
/// client-side window EpisodeNavigationService builds for Jellyfin isn't
/// needed here.
@override
Future<List<MediaItem>?> fetchClientSideEpisodeQueue(String seriesId) async => null;
/// Plex artists are folder-parents of their albums, so both music child
/// listings are plain `/library/metadata/{id}/children` fetches.
@override
Future<List<MediaItem>> fetchArtistAlbums(String artistId) => fetchChildren(artistId);
@override
Future<List<MediaItem>> fetchAlbumTracks(String albumId) => fetchChildren(albumId);
/// Plex "instant mix": a station play queue seeded from [itemId]. The
/// station uri's trailing `?type=10` (track results) is part of the
/// station path and rides inside the encoded uri value. Consumed as a
/// plain track list — music playback is queue-managed client-side.
@override
Future<List<MediaItem>> fetchInstantMix(String itemId, {int limit = 100}) async {
final stationUri = '${await buildMetadataUri(itemId)}/station/${const Uuid().v4()}?type=${PlexMetadataType.track}';
final queue = await createPlayQueue(uri: stationUri, type: 'audio');
final tracks = queue?.items ?? const <MediaItem>[];
return tracks.length > limit ? tracks.sublist(0, limit) : tracks;
}
/// Plex lyrics: sidecar `.lrc`/`.txt` files surface as track Part streams
/// with `streamType 4`; the raw text lives at the stream's `key`
/// (`/library/streams/{id}`). Returns `null` when the track has no lyric
/// stream (or it can't be fetched) — lyrics are decorative, so errors
/// degrade to "none" rather than failing the caller.
@override
Future<Lyrics?> fetchLyrics(MediaItem track) async {
try {
final metadataJson = await _fetchRawMetadataJsonCacheFirst(track.id);
final streamKey = _findLyricStreamKey(metadataJson);
if (streamKey == null) return null;
final response = await _getWithFailover(streamKey);
final raw = response.data;
if (raw is! String) return null;
return parseLrc(raw);
} catch (e) {
appLogger.w('Failed to fetch lyrics for ${track.id}', error: e);
return null;
}
}
/// Find the `/library/streams/{id}` key of [metadataJson]'s lyric stream
/// ([PlexStreamType.lyrics]), preferring `lrc` (synced) over other
/// formats (`txt`).
String? _findLyricStreamKey(Map<String, dynamic>? metadataJson) {
final mediaList = metadataJson?['Media'];
if (mediaList is! List) return null;
String? fallbackKey;
for (final media in mediaList) {
if (media is! Map) continue;
final parts = media['Part'];
if (parts is! List) continue;
for (final part in parts) {
if (part is! Map) continue;
final streams = part['Stream'];
if (streams is! List) continue;
for (final stream in streams) {
if (stream is! Map) continue;
if (flexibleInt(stream['streamType']) != PlexStreamType.lyrics) continue;
final key = stream['key'] as String?;
if (key == null || key.isEmpty) continue;
final format = ((stream['format'] ?? stream['codec']) as String?)?.toLowerCase();
if (format == 'lrc') return key;
fallbackKey ??= key;
}
}
}
return fallbackKey;
}
/// Plex playback resolution. Reuses [getVideoPlaybackData] for metadata,
/// then either runs the transcode-decision flow or returns the direct-play
/// URL. External subtitle tracks are absolutized with the server's auth
/// token; when transcoding, keyed sidecars stay external and selected
/// embedded text subtitles are embedded in the HTTP/MKV stream so subtitles
/// are never burned in.
@override
Future<PlaybackInitializationResult> getPlaybackInitialization(PlaybackInitializationOptions options) async {
try {
final data = await getVideoPlaybackData(
options.metadata.id,
mediaIndex: options.selectedMediaIndex,
selectedMediaSourceId: options.selectedMediaSourceId,
preferredVersionSignature: options.preferredVersionSignature,
);
if (!data.hasValidVideoUrl) {
throw PlaybackException(t.messages.fileInfoNotAvailable);
}
// Tracks consult the music preset — [qualityPreset] is video-shaped
// (resolution/videoQuality) and is ignored for audio.
final isTrack = options.metadata.kind == MediaKind.track;
final audioPreset = options.audioQualityPreset ?? AudioQualityPreset.original;
final wantTranscode = isTrack ? !audioPreset.isOriginal : !options.qualityPreset.isOriginal;
if (wantTranscode && options.sessionIdentifier != null && options.transcodeSessionId != null) {
if (isTrack) {
final result = await buildMusicTranscodeStartPath(
ratingKey: options.metadata.id,
mediaIndex: data.selectedMediaIndex,
partIndex: data.selectedPartIndex,
preset: audioPreset,
sessionIdentifier: options.sessionIdentifier!,
transcodeSessionId: options.transcodeSessionId!,
);
if (result.outcome == TranscodeDecisionOutcome.transcodeOk && result.startPath != null) {
final transcodeUrl = '${config.baseUrl}${result.startPath}'.withPlexToken(config.token);
return PlaybackInitializationResult(
availableVersions: data.availableVersions,
videoUrl: transcodeUrl,
mediaInfo: data.mediaInfo,
isOffline: false,
isTranscoding: true,
playMethod: 'Transcode',
playSessionId: options.sessionIdentifier,
selectedMediaIndex: data.selectedMediaIndex,
);
}
return _transcodeFallbackResult(data, result.outcome, options);
}
final resolvedAudioId = _resolveAudioStreamId(options.selectedAudioStreamId, data.mediaInfo);
final resumeOffsetMs = options.metadata.viewOffsetMs;
final selectedSubtitleTrack = _selectedSubtitleTrack(data.mediaInfo);
final result = await buildTranscodeStartPath(
ratingKey: options.metadata.id,
mediaIndex: data.selectedMediaIndex,
partIndex: data.selectedPartIndex,
preset: options.qualityPreset,
sessionIdentifier: options.sessionIdentifier!,
transcodeSessionId: options.transcodeSessionId!,
audioStreamId: resolvedAudioId,
selectedSubtitleTrack: selectedSubtitleTrack,
offsetMs: resumeOffsetMs != null && resumeOffsetMs > 0 ? resumeOffsetMs : null,
);
if (result.outcome == TranscodeDecisionOutcome.transcodeOk && result.startPath != null) {
final transcodeUrl = '${config.baseUrl}${result.startPath}'.withPlexToken(config.token);
final sidecarSubs = _buildTranscodeSidecarSubtitles(data.mediaInfo);
return PlaybackInitializationResult(
availableVersions: data.availableVersions,
videoUrl: transcodeUrl,
mediaInfo: data.mediaInfo,
externalSubtitles: sidecarSubs,
isOffline: false,
isTranscoding: true,
activeAudioStreamId: resolvedAudioId,
playMethod: 'Transcode',
playSessionId: options.sessionIdentifier,
selectedMediaIndex: data.selectedMediaIndex,
);
}
return _transcodeFallbackResult(data, result.outcome, options);
}
return PlaybackInitializationResult(
availableVersions: data.availableVersions,
videoUrl: data.videoUrl,
mediaInfo: data.mediaInfo,
externalSubtitles: _buildExternalSubtitles(data.mediaInfo),
isOffline: false,
playMethod: 'DirectPlay',
playSessionId: options.sessionIdentifier,
selectedMediaIndex: data.selectedMediaIndex,
);
} catch (e) {
if (e is PlaybackException) rethrow;
throw PlaybackException(t.messages.errorLoading(error: e.toString()));
}
}
/// Direct-play result for a transcode decision that fell back (failed or
/// said direct-play only), surfacing the reason so the UI can notify the
/// user. Shared by the video and music branches of
/// [getPlaybackInitialization].
PlaybackInitializationResult _transcodeFallbackResult(
PlexVideoPlaybackData data,
TranscodeDecisionOutcome outcome,
PlaybackInitializationOptions options,
) {
final fallbackReason = outcome == TranscodeDecisionOutcome.directPlayOnly
? TranscodeFallbackReason.directPlayOnly
: TranscodeFallbackReason.decisionFailed;
appLogger.w('Transcode decision fell back to direct play: ${fallbackReason.name}');
return PlaybackInitializationResult(
availableVersions: data.availableVersions,
videoUrl: data.videoUrl,
mediaInfo: data.mediaInfo,
externalSubtitles: _buildExternalSubtitles(data.mediaInfo),
isOffline: false,
isTranscoding: false,
fallbackReason: fallbackReason,
playMethod: 'DirectPlay',
playSessionId: options.sessionIdentifier,
selectedMediaIndex: data.selectedMediaIndex,
);
}
/// Pick the audio stream ID to send to the transcoder. Preference order:
/// explicit [explicit] → audio track with `selected == true` → first → null.
int? _resolveAudioStreamId(int? explicit, MediaSourceInfo? info) {
if (explicit != null) return explicit;
if (info == null) return null;
final tracks = info.audioTracks;
if (tracks.isEmpty) return null;
for (final track in tracks) {
if (track.selected) return track.id;
}
return tracks.first.id;
}
MediaSubtitleTrack? _selectedSubtitleTrack(MediaSourceInfo? info) {
if (info == null) return null;
for (final track in info.subtitleTracks) {
if (track.selected) return track;
}
return null;
}
/// Build the absolute URL for an external subtitle track on this Plex
/// server. Returns `null` for tracks that aren't external (no `/library/
/// streams/{id}` key) or when the server has no auth token.
///
/// Used by the in-player OpenSubtitles polling flow which needs the URL
/// after the new track shows up in the metadata response.
String? buildExternalSubtitleUrl(MediaSubtitleTrack track) {
if (!track.isExternal || track.key == null || track.key!.isEmpty) return null;
final token = config.token;
if (token == null) return null;
final ext = CodecUtils.getSubtitleExtension(track.codec);
return '${config.baseUrl}${track.key}.$ext?encoding=utf-8&X-Plex-Token=$token';
}
/// Raw sidecar URL for real sidecar subtitle streams. Plex returns 501 for
/// `/library/streams/{id}.{ext}` when the stream is embedded, so a Plex
/// `Stream.key` is required here.
String? _buildSidecarSubtitleUrl(MediaSubtitleTrack track) {
if (track.key == null || track.key!.isEmpty) return null;
final token = config.token;
if (token == null) return null;
final ext = CodecUtils.getSubtitleExtension(track.codec);
return '${config.baseUrl}${track.key}.$ext?encoding=utf-8&X-Plex-Token=$token';
}
bool _canTranscodeSubtitleAsText(MediaSubtitleTrack track) {
return CodecUtils.isTextSubtitleCodec(track.codec);
}
bool _shouldEmbedSubtitleInHttpTranscode(MediaSubtitleTrack? track) {
if (track == null) return false;
if (track.key != null && track.key!.isNotEmpty) return false;
return CodecUtils.isEmbeddableSubtitleCodec(track.codec);
}
SubtitleTrack _subtitleTrackFromMediaTrack(MediaSubtitleTrack track, String url) {
return SubtitleTrack(
id: 'external:$url',
title: track.displayTitle ?? track.title ?? track.language ?? 'Track ${track.id}',
language: track.languageCode,
codec: track.codec,
isDefault: track.selected,
isForced: track.forced,
isExternal: true,
uri: url,
);
}
/// Build subtitle sidecars for Plex transcode playback. Only real keyed
/// sidecars are loaded externally; selected embedded subtitles (text or
/// image) are carried by the main HTTP/MKV stream.
List<SubtitleTrack> _buildTranscodeSidecarSubtitles(MediaSourceInfo? mediaInfo) {
if (mediaInfo == null) return const [];
if (config.token == null) {
appLogger.w('No auth token available for transcode sidecar subtitles');
return const [];
}
final tracks = <SubtitleTrack>[];
for (final sub in mediaInfo.subtitleTracks) {
try {
final url = _buildSidecarSubtitleUrl(sub);
if (url == null) continue;
tracks.add(_subtitleTrackFromMediaTrack(sub, url));
} catch (e) {
appLogger.w('Failed to build sidecar subtitle for stream ${sub.id}', error: e);
}
}
return tracks;
}
@visibleForTesting
List<SubtitleTrack> buildTranscodeSidecarSubtitlesForTesting(MediaSourceInfo? mediaInfo) {
return _buildTranscodeSidecarSubtitles(mediaInfo);
}
/// Build list of external subtitle tracks from media info
List<SubtitleTrack> _buildExternalSubtitles(MediaSourceInfo? mediaInfo) {
final externalSubtitles = <SubtitleTrack>[];
if (mediaInfo == null) {
return externalSubtitles;
}
final externalTracks = mediaInfo.subtitleTracks.where((MediaSubtitleTrack track) => track.isExternal).toList();
if (externalTracks.isNotEmpty) {
appLogger.d('Found ${externalTracks.length} external subtitle track(s)');
}
for (final plexTrack in externalTracks) {
try {
final url = buildExternalSubtitleUrl(plexTrack);
if (url == null) {
appLogger.w('Could not build URL for external subtitle ${plexTrack.id}');
continue;
}
externalSubtitles.add(
SubtitleTrack.uri(
url,
title: plexTrack.displayTitle ?? plexTrack.title ?? plexTrack.language ?? 'Track ${plexTrack.id}',
language: plexTrack.languageCode,
codec: plexTrack.codec,
isDefault: plexTrack.selected,
isForced: plexTrack.forced,
),
);
} catch (e) {
appLogger.w('Failed to add external subtitle track ${plexTrack.id}', error: e);
}
}
return externalSubtitles;
}
/// Plex's filter listing is lazy: categories come from
/// `/library/sections/{id}/filters` and values are fetched per category
/// when the user opens a filter. The result has empty [LibraryFilterResult.cachedValues];
/// the FiltersBottomSheet hits the per-category endpoint on demand.
@override
Future<LibraryFilterResult> fetchLibraryFiltersWithValues(String libraryId) async {
final filters = await getLibraryFilters(libraryId);
return LibraryFilterResult(filters: filters, cachedValues: const {});
}
@override
Future<PlaybackExtras> fetchPlaybackExtras(
String itemId, {
String? introPattern,
String? creditsPattern,
bool forceChapterFallback = false,
bool forceRefresh = false,
}) => getPlaybackExtras(
itemId,
introPattern: introPattern,
creditsPattern: creditsPattern,
forceChapterFallback: forceChapterFallback,
forceRefresh: forceRefresh,
);
@override
Future<PlaybackExtras?> fetchPlaybackExtrasFromCacheOnly(
String itemId, {
String? introPattern,
String? creditsPattern,
bool forceChapterFallback = false,
}) async {
final cached = await cache.get(serverId, '/library/metadata/$itemId');
if (cached == null) return null;
final metadataJson = _getFirstMetadataJsonFromData(cached);
if (metadataJson == null) return null;
return _parsePlaybackExtrasFromMetadataJson(
metadataJson,
introPattern: introPattern,
creditsPattern: creditsPattern,
forceChapterFallback: forceChapterFallback,
);
}
@override
Future<MediaSourceInfo?> fetchCachedMediaSourceInfo(String itemId) async {
final cached = await cache.get(serverId, '/library/metadata/$itemId');
if (cached == null) return null;
final metadataJson = _getFirstMetadataJsonFromData(cached);
if (metadataJson == null) return null;
return plexMediaSourceInfoFromCacheJson(metadataJson);
}
@override
Future<ScrubPreviewSource?> createScrubPreviewSource({
required MediaItem item,
required MediaSourceInfo mediaSource,
}) async {
if (!capabilities.scrubThumbnails) return null;
final partId = mediaSource.partId;
if (partId == null) return null;
final service = BifThumbnailService();
try {
await service.load(this, partId, aspectRatio: mediaSource.videoAspectRatio);
return service;
} catch (e, st) {
appLogger.w('BIF thumbnail load failed for part $partId', error: e, stackTrace: st);
service.dispose();
return null;
}
}
@override
Future<LibraryPage<MediaItem>> fetchLibraryPagedContent(
String libraryId, {
required LibraryQuery query,
MediaKind? libraryKind,
AbortController? abort,
}) async {
// Translate the neutral query back to Plex's flat key=value map. Plex's
// section endpoint takes filters verbatim — `PlexLibraryQueryTranslator`
// emits both typed slots (genre/year/contentRating/tag/alphaPrefix) and
// generic `query.filters` entries, matching what the legacy
// `plexStyleFilters` map carried.
final filters = const PlexLibraryQueryTranslator().toQueryParameters(query);
// Browse tab always asked for collections; preserve as Plex's default
// server behaviour can vary across versions.
filters['includeCollections'] = '1';
final result = await fetchLibraryPage(
libraryId,
start: query.offset,
size: query.limit,
filters: filters,
abort: abort,
);
return LibraryPage<MediaItem>(items: result.items, totalCount: result.totalSize, offset: query.offset);
}
@override
Future<List<LibraryFirstCharacter>> fetchFirstCharacters(String libraryId, {Map<String, String>? filters}) async {
return getFirstCharacters(libraryId, filters: filters);
}
@override
Future<List<MediaItem>> searchItems(String query, {int limit = 100}) async {
final results = await _search(query, limit: limit);
return results.map((m) => PlexMappers.mediaItem(m)).toList();
}
@override
Future<List<MediaItem>> fetchRecentlyAdded({int limit = 50}) async {
final items = await _getRecentlyAdded(limit: limit);
return items.map((m) => PlexMappers.mediaItem(m)).toList();
}
@override
Future<List<MediaItem>> fetchContinueWatching({int? count = 20}) async {
final items = await _getContinueWatching(count: count);
return items.map((m) => PlexMappers.mediaItem(m)).toList();
}
@override
Future<List<MediaHub>> fetchGlobalHubs({int limit = defaultHubPreviewLimit, bool includePlaybackHubs = true}) async {
final hubs = await _getGlobalHubs(limit: limit);
return hubs.map((h) => PlexMappers.mediaHub(h)).toList();
}
@override
Future<List<MediaHub>> fetchLibraryHubs(
String libraryId, {
required String libraryName,
int limit = defaultHubPreviewLimit,
bool includePlaybackHubs = true,
MediaKind? libraryKind,
}) async {
// libraryName is unused: Plex's /hubs/sections/{id} returns hubs already
// titled per-library (e.g. "Recently Added in Movies").
final hubs = await _getLibraryHubs(libraryId, limit: limit, libraryName: libraryName);
return hubs.map((h) => PlexMappers.mediaHub(h)).toList();
}
@override
Future<List<MediaHub>> fetchRelatedHubs(String id, {int count = 10}) async {
final hubs = await _getRelatedHubs(id, count: count);
return hubs.map((h) => PlexMappers.mediaHub(h)).toList();
}
@override
Future<void> markWatched(MediaItem item) => markAsWatched(item.id);
@override
Future<void> markUnwatched(MediaItem item) => markAsUnwatched(item.id);
@override
Future<void> removeFromContinueWatching(MediaItem item) async {
await removeFromOnDeck(item.id);
}
/// Rate a media item (0.0-10.0 scale, where each integer = half a star).
/// Pass `-1` to clear an existing rating. Throws [MediaServerHttpException]
/// on non-2xx — call sites surface a snackbar on the catch arm.
@override
Future<void> rate(MediaItem item, double rating) async {
final response = await _http.put(
'/:/rate',
queryParameters: {'key': item.id, 'identifier': 'com.plexapp.plugins.library', 'rating': rating},
);
throwIfHttpError(response);
}
@override
Future<void> setFavorite(MediaItem item, bool isFavorite) async {
throw UnsupportedError('Plex does not support user favorites.');
}
@override
Future<List<MediaPlaylist>> fetchPlaylists({String playlistType = 'video', bool? smart}) async {
final playlists = await _getPlaylists(playlistType: playlistType, smart: smart);
return playlists.map((p) => PlexMappers.mediaPlaylist(p)).toList();
}
@override
Future<LibraryPage<MediaPlaylist>> fetchPlaylistsPage({
String playlistType = 'video',
bool? smart,
int? start,
int? size,
AbortController? abort,
}) async {
final result = await _getPlaylistsPage(
playlistType: playlistType,
smart: smart,
start: start,
size: size,
abort: abort,
);
return LibraryPage<MediaPlaylist>(
items: result.items.map((p) => PlexMappers.mediaPlaylist(p)).toList(),
totalCount: result.totalSize,
offset: start ?? 0,
);
}
@override
Future<MediaPlaylist?> fetchPlaylistMetadata(String id) async {
final p = await _getPlaylistMetadata(id);
return p == null ? null : PlexMappers.mediaPlaylist(p);
}
@override
Future<List<MediaItem>> fetchPlaylistItems(String id, {int offset = 0, int limit = 100}) async {
final page = await fetchPlaylistPage(id, start: offset, size: limit);
return page.items;
}
@override
Future<LibraryPage<MediaItem>> fetchPlaylistPage(
String playlistId, {
int? start,
int? size,
AbortController? abort,
}) async {
final result = await _getPlaylist(playlistId, start: start, size: size, abort: abort);
return LibraryPage<MediaItem>(
items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
totalCount: result.totalSize,
offset: start ?? 0,
);
}
@override
Future<List<MediaItem>> fetchCollections(String libraryId) async {
final raw = await _getLibraryCollections(libraryId);
return raw.map((m) => PlexMappers.mediaItem(m)).toList();
}
@override
Future<LibraryPage<MediaItem>> fetchCollectionsPage(
String libraryId, {
int? start,
int? size,
AbortController? abort,
}) async {
final result = await _getLibraryCollectionsPage(libraryId, start: start, size: size, abort: abort);
return LibraryPage<MediaItem>(
items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
totalCount: result.totalSize,
offset: start ?? 0,
);
}
@override
Future<LibraryPage<MediaItem>> fetchCollectionPage(
String collectionId, {
int? start,
int? size,
AbortController? abort,
String? libraryId,
String? libraryTitle,
}) async {
final result = await _getCollectionItems(
collectionId,
start: start,
size: size,
abort: abort,
librarySectionID: libraryId,
librarySectionTitle: libraryTitle,
);
return LibraryPage<MediaItem>(
items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
totalCount: result.totalSize,
offset: start ?? 0,
);
}
@override
Future<LibraryPage<MediaItem>> fetchPersonMediaPage(
String personId, {
int? start,
int? size,
AbortController? abort,
}) async {
final result = await _getPersonMedia(personId, start: start, size: size, abort: abort);
return LibraryPage<MediaItem>(
items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
totalCount: result.totalSize,
offset: start ?? 0,
);
}
@override
Future<List<MediaItem>> fetchPersonMedia(String personId) => fetchAllPersonMediaAsMediaItems(personId);
/// Plex-specific: full person-media listing across pages.
Future<List<MediaItem>> fetchAllPersonMediaAsMediaItems(String personId) async {
final raw = await _fetchAllPersonMediaDto(personId);
return raw.map((m) => PlexMappers.mediaItem(m)).toList();
}
/// Plex-specific: hub content as neutral [MediaItem]s.
Future<List<MediaItem>> fetchHubContent(String hubKey) async {
final raw = await _getHubContent(hubKey);
return raw.map((m) => PlexMappers.mediaItem(m)).toList();
}
@override
Future<List<MediaItem>> fetchMoreHubItems(String hubId, {int? limit}) => fetchHubContent(hubId);
@override
Future<LibraryPage<MediaItem>> fetchMoreHubItemsPage(
String hubId, {
int? start,
int? size,
AbortController? abort,
}) async {
final result = await _getHubContentPage(hubId, start: start, size: size, abort: abort);
return LibraryPage<MediaItem>(
items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
totalCount: result.totalSize,
offset: start ?? 0,
);
}
/// Plex folder listings are Directory rows with no usable `type` (mapped to
/// [MediaKind.unknown]) or an explicit `folder` type, identified by their
/// relative `/folder` key. Classify them as [MediaKind.folder] and stamp
/// [MediaItem.backendFolderKey] so the tree and the children fetch stay
/// free of raw-map reads.
MediaItem _classifyFolderRow(MediaItem item) {
final key = item.raw?['key'] as String?;
final isFolder =
item.kind == MediaKind.folder || item.kind == MediaKind.unknown || (key?.contains('/folder') ?? false);
if (!isFolder) return item;
return item.copyWith(kind: MediaKind.folder, backendFolderKey: key);
}
/// Top-level folders in a library. Single response — [onPage] never fires.
@override
Future<List<MediaItem>> fetchLibraryFolders(
String libraryId, {
void Function(List<MediaItem> itemsSoFar)? onPage,
}) async {
final raw = await _getLibraryFolders(libraryId);
return raw.map((m) => _classifyFolderRow(PlexMappers.mediaItem(m))).toList();
}
/// Contents of a folder (files and subfolders), via the folder row's
/// [MediaItem.backendFolderKey]. Single response — [onPage] never fires.
@override
Future<List<MediaItem>> fetchFolderChildren(
MediaItem folder, {
String? libraryId,
String? libraryTitle,
void Function(List<MediaItem> itemsSoFar)? onPage,
}) async {
final folderKey = folder.backendFolderKey;
if (folderKey == null) return const [];
final raw = await _getFolderChildren(
folderKey,
librarySectionID: libraryId ?? folder.libraryId,
librarySectionTitle: libraryTitle ?? folder.libraryTitle,
);
return raw.map((m) => _classifyFolderRow(PlexMappers.mediaItem(m))).toList();
}
/// Plex-specific: extras (trailers, behind-the-scenes) for a media item.
@override
Future<List<MediaItem>> fetchExtras(String ratingKey) async {
final raw = await _getExtras(ratingKey);
return raw.map((m) => PlexMappers.mediaItem(m)).toList();
}
/// Plex-specific: paginated library content with raw Plex filter map,
/// returning neutral [MediaItem]s. The aggregation bridge uses this when it
/// has Plex-specific filter strings (`unwatched=1`, `genre=...`) to forward.
Future<({List<MediaItem> items, int totalSize})> fetchLibraryPage(
String sectionId, {
int? start,
int? size,
Map<String, String>? filters,
AbortController? abort,
}) async {
final result = await _getLibraryContent(sectionId, start: start, size: size, filters: filters, abort: abort);
return (items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(), totalSize: result.totalSize);
}
/// Full item with on-deck episode from a single `/library/metadata/{id}`
/// round-trip. Implements [MediaServerClient.fetchItemWithOnDeck];
/// Jellyfin has no analogous endpoint and returns onDeck=null there.
@override
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id) async {
final result = await getMetadataWithImagesAndOnDeck(id);
final itemDto = result['metadata'] as PlexMetadataDto?;
final onDeckDto = result['onDeckEpisode'] as PlexMetadataDto?;
return (
item: itemDto == null ? null : PlexMappers.mediaItem(itemDto),
onDeckEpisode: onDeckDto == null ? null : PlexMappers.mediaItem(onDeckDto),
);
}
@override
String thumbnailUrl(String? path, {int? width, int? height}) {
if (path == null || path.isEmpty) return '';
// No sizing requested, or already-processed/external URL — passthrough.
if (width == null && height == null) return getThumbnailUrl(path);
if (path.startsWith('http://') || path.startsWith('https://')) {
// External URLs route through [externalImageUrl] for proxying.
// Direct callers without sizing get the raw URL.
return getThumbnailUrl(path);
}
final token = config.token;
if (token == null) return getThumbnailUrl(path);
final encoded = Uri.encodeComponent(path.withPlexToken(token));
final parts = <String>[
if (width != null) 'width=$width',
if (height != null) 'height=$height',
'minSize=1',
'upscale=1',
'url=$encoded',
'X-Plex-Token=$token',
];
return '${config.baseUrl}/photo/:/transcode?${parts.join('&')}';
}
@override
String externalImageUrl(String url, {int? width, int? height}) {
final token = config.token;
if (token == null || (width == null && height == null)) return url;
final encoded = Uri.encodeComponent(url);
final parts = <String>[
if (width != null) 'width=$width',
if (height != null) 'height=$height',
'minSize=1',
'upscale=1',
'url=$encoded',
'X-Plex-Token=$token',
];
return '${config.baseUrl}/photo/:/transcode?${parts.join('&')}';
}
@override
double get watchedThreshold => watchedThresholdPercent / 100.0;
/// Plex's `/:/timeline?state=stopped` doesn't reliably mark watched without
/// an active play session, so the in-player auto-scrobble still issues the
/// explicit `markWatched` (`/:/scrobble`). See [marksWatchedOnPlaybackStopped].
@override
bool get marksWatchedOnPlaybackStopped => false;
@override
Map<String, String> get streamHeaders => Map.unmodifiable(config.headers);
@override
Future<ExternalIds> fetchExternalIds(String itemId) async {
final guids = await fetchExternalGuids(itemId);
return ExternalIds.fromGuids(guids);
}
/// Server-wide title filter + client-side Guid-array verification. Plex's
/// `guid=` field filter matches only the item's primary `plex://` guid
/// (verified against PMS 1.43) — never the external `imdb://`/`tmdb://`
/// ids in the `Guid` array — so this mirrors the Jellyfin
/// title-search-and-verify approach: exact-id verification, false
/// negatives possible on differing titles, false positives never.
///
/// When [year] is known, a ±1 window is applied server-side first
/// (`year=` takes comma-separated values as OR, verified on PMS 1.43) so
/// short/common titles keep the true match inside the 20-item response;
/// the year filter drops items with no year metadata, hence the
/// unfiltered second attempt.
@override
Future<MediaItem?> findByExternalIds(ExternalIds ids, {required MediaKind kind, String? title, int? year}) async {
final plexType = switch (kind) {
MediaKind.movie => 1,
MediaKind.show => 2,
_ => null,
};
if (plexType == null || !ids.hasAny || title == null || title.isEmpty) return null;
Future<MediaItem?> attempt(String? years) async {
final response = await _getWithFailover(
'/library/all',
queryParameters: {
'title': title,
'type': plexType,
'includeGuids': 1,
'X-Plex-Container-Size': 20,
'year': ?years,
},
);
final container = _getMediaContainer(response);
final metadata = container?['Metadata'];
if (metadata is! List) return null;
for (final item in metadata) {
if (item is! Map<String, dynamic>) continue;
final guids = item['Guid'];
if (guids is! List) continue;
if (ids.intersects(ExternalIds.fromGuids(guids))) {
return PlexMappers.mediaItem(_createTaggedMetadataWithLibrary(item));
}
}
return null;
}
if (year != null) {
final match = await attempt('${year - 1},$year,${year + 1}');
if (match != null) return match;
}
return attempt(null);
}
@override
Future<void> reportPlaybackStarted({
required String itemId,
required Duration position,
Duration? duration,
String? playSessionId,
String? playMethod,
String? liveStreamId,
String? mediaSourceId,
int? audioStreamIndex,
int? subtitleStreamIndex,
}) => updateProgress(
itemId,
time: position.inMilliseconds,
state: 'playing',
duration: duration?.inMilliseconds,
sessionIdentifier: playSessionId,
);
@override
Future<void> reportPlaybackProgress({
required String itemId,
required Duration position,
required Duration duration,
bool isPaused = false,
String? playSessionId,
String? playMethod,
String? liveStreamId,
String? mediaSourceId,
int? audioStreamIndex,
int? subtitleStreamIndex,
}) => updateProgress(
itemId,
time: position.inMilliseconds,
state: isPaused ? 'paused' : 'playing',
duration: duration.inMilliseconds,
sessionIdentifier: playSessionId,
);
@override
Future<void> reportPlaybackStopped({
required String itemId,
required Duration position,
Duration? duration,
String? playSessionId,
String? liveStreamId,
String? mediaSourceId,
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
}) => updateProgress(
itemId,
time: position.inMilliseconds,
state: 'stopped',
duration: duration?.inMilliseconds,
sessionIdentifier: playSessionId,
report: report,
);
// ── Downloads ────────────────────────────────────────────────────
@override
Future<String?> resolveExternalPlaybackUrl(MediaItem item, {int mediaIndex = 0, String? mediaSourceId}) async {
final playbackData = await getVideoPlaybackData(item.id, mediaIndex: mediaIndex);
return playbackData.hasValidVideoUrl ? playbackData.videoUrl : null;
}
@override
Future<DownloadResolution> resolveDownload(MediaItem item, {int mediaIndex = 0}) async {
final playbackData = await getVideoPlaybackData(item.id, mediaIndex: mediaIndex);
final subtitles = <DownloadSubtitleSpec>[];
final mediaInfo = playbackData.mediaInfo;
if (mediaInfo != null) {
for (final subtitle in mediaInfo.subtitleTracks) {
if (!subtitle.isExternal || subtitle.key == null) continue;
final url = buildExternalSubtitleUrl(subtitle);
if (url == null) continue;
subtitles.add(
DownloadSubtitleSpec(
id: subtitle.id,
url: url,
codec: subtitle.codec,
language: subtitle.language,
languageCode: subtitle.languageCode,
forced: subtitle.forced,
displayTitle: subtitle.displayTitle,
),
);
}
}
return DownloadResolution(
videoUrl: playbackData.videoUrl,
mediaSourceId: playbackData.mediaInfo?.mediaSourceId,
externalSubtitles: subtitles,
);
}
@override
List<DownloadArtworkSpec> resolveDownloadArtwork(MediaItem item) {
return buildArtworkSpecs(item, getThumbnailUrl);
}
}