Files
plezy/lib/services/plex_client.dart
T

3697 lines
139 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';
part 'plex_client/parts/playlists.dart';
part 'plex_client/parts/collections.dart';
part 'plex_client/parts/play_queues.dart';
part 'plex_client/parts/metadata_edit.dart';
const _plexHlsVideoTranscodeTarget =
'add-transcode-target(type=videoProfile&context=streaming'
'&protocol=hls&container=mpegts&videoCodec=h264%2Chevc%2Cmpeg2video'
'&audioCodec=aac%2Cac3%2Ceac3%2Cmp3)';
const _plexHlsSubtitleTranscodeTarget =
'add-transcode-target(type=subtitleProfile&context=streaming'
'&protocol=hls&container=webvtt&subtitleCodec=webvtt)';
String _buildPlexHlsClientProfileExtra({int? maxVideoBitrateKbps}) {
final clauses = <String>['add-settings(DirectPlayStreamSelection=true)'];
if (maxVideoBitrateKbps != null) {
clauses.add(
'add-limitation(scope=videoCodec&scopeName=*&type=upperBound'
'&name=video.bitrate&value=$maxVideoBitrateKbps&replace=true)',
);
}
clauses
..add(_plexHlsVideoTranscodeTarget)
..add(_plexHlsSubtitleTranscodeTarget);
return clauses.join('+');
}
/// 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,
_PlexPlaylistMethods,
_PlexCollectionMethods,
_PlexPlayQueueMethods,
_PlexMetadataEditMethods
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
@override
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));
@override
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);
}
@override
({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 [];
final activities = <PlexActivity>[];
for (final json in activityList) {
if (json is! Map<String, dynamic>) continue;
try {
final activity = PlexActivity.fromJson(json);
if (activity.uuid.isNotEmpty) activities.add(activity);
} catch (e) {
appLogger.d('Skipping malformed Plex activity', error: e);
}
}
return activities;
} 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,
);
}
@override
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;
}
@override
_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);
}
@override
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}
@override
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.
@override
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 [];
}
}
/// 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.
@override
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);
}
/// 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 an HLS VOD transcode stream URL (decision + start path).
///
/// Text subtitles selected on the Plex part are segmented as WebVTT,
/// image subtitles are burned because HLS has no bitmap subtitle rendition,
/// and 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,
}) async {
try {
final allParams = _buildTranscodeParams(
ratingKey: ratingKey,
mediaIndex: mediaIndex,
partIndex: partIndex,
preset: preset,
sessionIdentifier: sessionIdentifier,
transcodeSessionId: transcodeSessionId,
audioStreamId: audioStreamId,
selectedSubtitleTrack: selectedSubtitleTrack,
);
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 HTTP/MP3 target instead of segmented video HLS.
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.m3u8';
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] includes the container
/// extension (`start.m3u8` / `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,
}) {
final isOriginal = preset.isOriginal;
final selectedInternalSubtitle = _selectedInternalSubtitleForHls(selectedSubtitleTrack);
final segmentSubtitle = selectedInternalSubtitle != null && _canTranscodeSubtitleAsText(selectedInternalSubtitle);
final burnSubtitle =
selectedInternalSubtitle != null && CodecUtils.isImageSubtitleCodec(selectedInternalSubtitle.codec);
final clientProfileExtra = _buildPlexHlsClientProfileExtra(
maxVideoBitrateKbps: !isOriginal ? preset.videoBitrateKbps : null,
);
return <String, String>{
'hasMDE': '1',
'path': '/library/metadata/$ratingKey',
'mediaIndex': mediaIndex.toString(),
'partIndex': partIndex.toString(),
'protocol': 'hls',
'fastSeek': '1',
'directPlay': isOriginal ? '1' : '0',
'directStream': isOriginal ? '1' : '0',
'subtitleSize': '100',
'audioBoost': '100',
'location': 'lan',
if (!isOriginal && preset.videoBitrateKbps != null) 'maxVideoBitrate': preset.videoBitrateKbps.toString(),
'addDebugOverlay': '0',
'autoAdjustQuality': '0',
'directStreamAudio': '0',
'mediaBufferSize': '102400',
'session': transcodeSessionId,
'subtitles': segmentSubtitle
? 'segmented'
: burnSubtitle
? 'burn'
: 'none',
if (selectedInternalSubtitle != null) 'subtitleStreamID': selectedInternalSubtitle.id.toString(),
if (segmentSubtitle) 'advancedSubtitles': 'text',
if (audioStreamId != null) 'audioStreamID': audioStreamId.toString(),
'Accept-Language': 'en',
'X-Plex-Session-Identifier': sessionIdentifier,
'X-Plex-Client-Profile-Extra': clientProfileExtra,
'X-Plex-Incomplete-Segments': '1',
'X-Plex-Features': 'external-media,indirect-media',
'X-Plex-Model': 'standalone',
'X-Plex-Language': 'en',
'X-Plex-Product': config.product,
'X-Plex-Version': config.version,
'X-Plex-Client-Identifier': config.clientIdentifier,
'X-Plex-Platform': _transcodePlatformName(),
'X-Plex-Client-Profile-Name': 'Generic',
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> buildTranscodeParamsForTesting({
required String ratingKey,
required int mediaIndex,
int partIndex = 0,
required TranscodeQualityPreset preset,
required String sessionIdentifier,
required String transcodeSessionId,
int? audioStreamId,
MediaSubtitleTrack? selectedSubtitleTrack,
}) {
return _buildTranscodeParams(
ratingKey: ratingKey,
mediaIndex: mediaIndex,
partIndex: partIndex,
preset: preset,
sessionIdentifier: sessionIdentifier,
transcodeSessionId: transcodeSessionId,
audioStreamId: audioStreamId,
selectedSubtitleTrack: selectedSubtitleTrack,
);
}
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. Keyed subtitle tracks remain external sidecars; selected internal
/// text tracks become segmented WebVTT and image tracks are burned into the
/// HLS rendition.
@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 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,
);
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);
}
MediaSubtitleTrack? _selectedInternalSubtitleForHls(MediaSubtitleTrack? track) {
if (track == null) return null;
if (track.key != null && track.key!.isNotEmpty) return null;
return CodecUtils.isTranscodableSubtitleCodec(track.codec) ? track : null;
}
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. Keyed tracks remain
/// external; the selected internal track is delivered by the HLS rendition.
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, {MediaKind? libraryKind}) 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.');
}
/// 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 external-ID 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 modern `Guid` array — so this mirrors the Jellyfin
/// title-search-and-verify approach. Recognized legacy scalar `guid` values
/// are checked only after modern verification fails.
///
/// 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<({Map<String, dynamic>? modern, Map<String, dynamic>? legacy})> 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 (modern: null, legacy: null);
Map<String, dynamic>? legacy;
for (final item in metadata) {
if (item is! Map<String, dynamic>) continue;
final guids = item['Guid'];
if (guids is List && ids.intersects(ExternalIds.fromGuids(guids))) {
return (modern: item, legacy: legacy);
}
if (legacy == null && ids.intersects(ExternalIds.fromLegacyPlexGuid(item['guid']))) {
legacy = item;
}
}
return (modern: null, legacy: legacy);
}
({Map<String, dynamic>? modern, Map<String, dynamic>? legacy})? filtered;
if (year != null) {
filtered = await attempt('${year - 1},$year,${year + 1}');
final modern = filtered.modern;
if (modern != null) {
return PlexMappers.mediaItem(_createTaggedMetadataWithLibrary(modern));
}
}
final unfiltered = await attempt(null);
final match = unfiltered.modern ?? filtered?.legacy ?? unfiltered.legacy;
return match == null ? null : PlexMappers.mediaItem(_createTaggedMetadataWithLibrary(match));
}
@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);
}
}