3991 lines
147 KiB
Dart
3991 lines
147 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 '../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/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/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 '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/plex/plex_video_playback_data.dart';
|
|
import '../models/transcode_quality_preset.dart';
|
|
import '../utils/endpoint_failover_interceptor.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 '../utils/watch_state_notifier.dart';
|
|
import '../i18n/strings.g.dart';
|
|
import '../mpv/mpv.dart';
|
|
import 'api_cache.dart';
|
|
import 'plex_api_cache.dart';
|
|
import 'plex_mappers.dart';
|
|
import 'plex_playback_mapper.dart';
|
|
import 'playback_initialization_types.dart';
|
|
|
|
part 'plex_client/parts/live_tv.dart';
|
|
|
|
/// Result of a paginated library content fetch
|
|
class _LibraryContentResult {
|
|
final List<PlexMetadataDto> items;
|
|
final int totalSize;
|
|
const _LibraryContentResult({required this.items, required this.totalSize});
|
|
}
|
|
|
|
/// Process hub response in an isolate.
|
|
/// Top-level function so it can be passed to [Isolate.run].
|
|
List<PlexHubDto> _processHubResponse(
|
|
Map<String, dynamic> decoded,
|
|
String 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, serverName: serverName),
|
|
librarySectionID: hubSectionID,
|
|
librarySectionTitle: hubSectionTitle,
|
|
);
|
|
if (hub.items.isEmpty) continue;
|
|
|
|
final filteredItems = hub.items.where(itemFilter).toList();
|
|
|
|
if (filteredItems.isNotEmpty) {
|
|
hubs.add(
|
|
PlexHubDto(
|
|
hubKey: hub.hubKey,
|
|
title: hub.title,
|
|
type: hub.type,
|
|
hubIdentifier: hub.hubIdentifier,
|
|
size: hub.size,
|
|
more: hub.more,
|
|
items: filteredItems,
|
|
serverId: serverId,
|
|
serverName: serverName,
|
|
),
|
|
);
|
|
}
|
|
} catch (_) {
|
|
// Skip hubs that fail to parse
|
|
}
|
|
}
|
|
return hubs;
|
|
}
|
|
|
|
int? _librarySectionIdFromJson(Map<String, dynamic>? json) => plexLibrarySectionIdFromJson(json);
|
|
|
|
int? _librarySectionIdFromString(String? sectionId) => plexLibrarySectionIdFromString(sectionId);
|
|
|
|
String? _librarySectionTitleFromJson(Map<String, dynamic>? json) => plexLibrarySectionTitleFromJson(json);
|
|
|
|
PlexMetadataDto _plexMetadataWithLibrarySection(
|
|
PlexMetadataDto metadata, {
|
|
int? librarySectionID,
|
|
String? librarySectionTitle,
|
|
}) {
|
|
final nextSectionID = metadata.librarySectionID ?? librarySectionID;
|
|
final nextSectionTitle = metadata.librarySectionTitle ?? librarySectionTitle;
|
|
if (nextSectionID == metadata.librarySectionID && nextSectionTitle == metadata.librarySectionTitle) {
|
|
return metadata;
|
|
}
|
|
return metadata.copyWith(librarySectionID: nextSectionID, librarySectionTitle: nextSectionTitle);
|
|
}
|
|
|
|
PlexHubDto _plexHubWithLibrarySection(PlexHubDto hub, {int? librarySectionID, String? librarySectionTitle}) {
|
|
if (librarySectionID == null && librarySectionTitle == null) return hub;
|
|
return PlexHubDto(
|
|
hubKey: hub.hubKey,
|
|
title: hub.title,
|
|
type: hub.type,
|
|
hubIdentifier: hub.hubIdentifier,
|
|
size: hub.size,
|
|
more: hub.more,
|
|
items: hub.items
|
|
.map(
|
|
(item) => _plexMetadataWithLibrarySection(
|
|
item,
|
|
librarySectionID: librarySectionID,
|
|
librarySectionTitle: librarySectionTitle,
|
|
),
|
|
)
|
|
.toList(),
|
|
serverId: hub.serverId,
|
|
serverName: hub.serverName,
|
|
);
|
|
}
|
|
|
|
// PlexStreamType moved to plex_constants.dart to break a would-be circular
|
|
// import once plex_mappers.dart started referencing the same names.
|
|
|
|
/// Result of testing a connection, including success status and latency
|
|
class ConnectionTestResult {
|
|
final bool success;
|
|
final int latencyMs;
|
|
final String? error;
|
|
|
|
/// `transcoderVideo` from the `/` MediaContainer, captured on successful
|
|
/// probes so the connection race doubles as a capability probe. `null`
|
|
/// when the probe didn't succeed or the field was absent.
|
|
final bool? transcoderVideo;
|
|
|
|
ConnectionTestResult({required this.success, required this.latencyMs, this.error, this.transcoderVideo});
|
|
}
|
|
|
|
bool? _parsePlexTranscoderVideoCapability(Object? value) {
|
|
return switch (value) {
|
|
final bool b => b,
|
|
final int n when n == 1 => true,
|
|
final int n when n == 0 => false,
|
|
final String s when s.trim().toLowerCase() == 'true' || s.trim() == '1' => true,
|
|
final String s when s.trim().toLowerCase() == 'false' || s.trim() == '0' => false,
|
|
_ => null,
|
|
};
|
|
}
|
|
|
|
class PlexClient
|
|
with MediaServerCacheMixin, _PlexLiveTvClientMethods
|
|
implements MediaServerClient, GracefullyCloseable {
|
|
@override
|
|
PlexConfig config;
|
|
|
|
@override
|
|
late final MediaServerHttpClient _http;
|
|
final EndpointFailoverManager? _endpointManager;
|
|
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 String serverId;
|
|
|
|
/// Server name - all PlexMetadataDto items created by this client are tagged with this
|
|
@override
|
|
final String? serverName;
|
|
|
|
/// API response cache for offline support
|
|
final PlexApiCache _cache = PlexApiCache.instance;
|
|
|
|
/// Expose the cache through the [MediaServerClient] interface so the shared
|
|
/// `fetchWithCacheFallback` / `fetchWithCacheFirst` helpers route through
|
|
/// the Plex-specific cache substrate.
|
|
@override
|
|
ApiCache get cache => _cache;
|
|
|
|
/// Whether to operate in offline mode (use cache only)
|
|
bool _offlineMode = false;
|
|
|
|
/// Cached result of [serverSupportsVideoTranscoding]. `null` = not yet fetched.
|
|
bool? _serverTranscoderCached;
|
|
|
|
/// In-flight probe for [serverSupportsVideoTranscoding], used to dedupe
|
|
/// concurrent callers (e.g. the post-connect warm-up racing the first
|
|
/// playback).
|
|
Future<bool>? _serverTranscoderPending;
|
|
|
|
/// Libraries parsed from /media/providers (includes individually shared items)
|
|
List<PlexLibraryDto> _providerLibraries = const [];
|
|
|
|
/// Home hub endpoint advertised by /media/providers (usually /hubs).
|
|
String? _providerHomeHubKey;
|
|
|
|
/// Promoted home hub endpoint advertised by /media/providers (usually /hubs/promoted).
|
|
String? _providerPromotedHubKey;
|
|
|
|
/// Dedicated Continue Watching hub endpoint advertised by /media/providers.
|
|
String? _providerContinueWatchingHubKey;
|
|
|
|
/// EPG providers parsed from /media/providers
|
|
@override
|
|
List<({String identifier, String gridEndpoint})> _providerEpg = const [];
|
|
|
|
/// Server-level preferences fetched from /:/prefs
|
|
Map<String, dynamic> _serverPrefs = {};
|
|
|
|
/// Get all fetched server preferences
|
|
Map<String, dynamic> get serverPrefs => Map.unmodifiable(_serverPrefs);
|
|
|
|
/// Get the server's watched threshold percentage (default 90)
|
|
int get watchedThresholdPercent {
|
|
final value = _serverPrefs['LibraryVideoPlayedThreshold'];
|
|
if (value is int) return value;
|
|
if (value is String) return int.tryParse(value) ?? 90;
|
|
return 90;
|
|
}
|
|
|
|
/// Set offline mode - when true, only cached responses are returned
|
|
@override
|
|
void setOfflineMode(bool offline) {
|
|
_offlineMode = offline;
|
|
}
|
|
|
|
/// Get current offline mode state
|
|
@override
|
|
bool get isOfflineMode => _offlineMode;
|
|
|
|
/// Create a fully initialized PlexClient.
|
|
/// Fetches /media/providers to discover libraries (including individually shared items) and EPG providers.
|
|
static Future<PlexClient> create(
|
|
PlexConfig config, {
|
|
required String 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,
|
|
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,
|
|
}) : _endpointManager = (prioritizedEndpoints != null && prioritizedEndpoints.isNotEmpty)
|
|
? EndpointFailoverManager(prioritizedEndpoints)
|
|
: null {
|
|
LogRedactionManager.registerServer(config.baseUrl, config.token);
|
|
|
|
_http = MediaServerHttpClient(
|
|
baseUrl: config.baseUrl,
|
|
defaultHeaders: config.headers,
|
|
connectTimeout: MediaServerTimeouts.connect,
|
|
receiveTimeout: MediaServerTimeouts.receive,
|
|
usePlexApiClient: true,
|
|
client: httpClient,
|
|
);
|
|
}
|
|
|
|
/// 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 String 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,
|
|
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);
|
|
}
|
|
|
|
bool _failoverSwitching = false;
|
|
|
|
/// Execute a GET request with endpoint failover retry. On timeout/connection
|
|
/// errors the next endpoint is tried (once). Non-GET methods are not retried.
|
|
/// 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 gen = _endpointManager?.generation;
|
|
try {
|
|
final response = await _http.get(
|
|
path,
|
|
queryParameters: queryParameters,
|
|
headers: headers,
|
|
timeout: timeout,
|
|
abort: abort,
|
|
);
|
|
throwIfHttpError(response);
|
|
return response;
|
|
} on MediaServerHttpException catch (e) {
|
|
if (!allowEndpointFailover ||
|
|
!_shouldAttemptFailover(e) ||
|
|
_failoverSwitching ||
|
|
_endpointManager == null ||
|
|
gen != _endpointManager.generation) {
|
|
rethrow;
|
|
}
|
|
|
|
if (!_endpointManager.hasFallback) {
|
|
_endpointManager.resetToFirst();
|
|
_onAllEndpointsExhausted?.call();
|
|
rethrow;
|
|
}
|
|
|
|
final failedEndpoint = _endpointManager.current;
|
|
final nextBaseUrl = _endpointManager.moveToNext();
|
|
if (nextBaseUrl == null) rethrow;
|
|
|
|
_failoverSwitching = true;
|
|
try {
|
|
appLogger.i(
|
|
'Switching Plex endpoint after GET failure',
|
|
error: {'from': failedEndpoint, 'to': nextBaseUrl, 'path': path},
|
|
);
|
|
await _handleEndpointSwitch(nextBaseUrl, persist: false);
|
|
final response = await _http.get(
|
|
path,
|
|
queryParameters: queryParameters,
|
|
headers: headers,
|
|
timeout: timeout,
|
|
abort: abort,
|
|
);
|
|
throwIfHttpError(response);
|
|
appLogger.i('Endpoint failover retry succeeded', error: {'newEndpoint': nextBaseUrl});
|
|
await _onEndpointChanged?.call(nextBaseUrl);
|
|
return response;
|
|
} finally {
|
|
_failoverSwitching = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
bool _shouldAttemptFailover(MediaServerHttpException e) {
|
|
if (e.isTransient) return true;
|
|
final sc = e.statusCode;
|
|
return sc != null && sc >= 500 && sc <= 599;
|
|
}
|
|
|
|
/// 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 (_endpointManager == null || prioritizedEndpoints.isEmpty) {
|
|
return;
|
|
}
|
|
|
|
final targetBaseUrl = switchToFirst ? prioritizedEndpoints.first : config.baseUrl;
|
|
_endpointManager.reset(prioritizedEndpoints, currentBaseUrl: targetBaseUrl);
|
|
|
|
if (switchToFirst && targetBaseUrl != config.baseUrl) {
|
|
await _handleEndpointSwitch(targetBaseUrl);
|
|
}
|
|
}
|
|
|
|
/// Test connection to a specific URL with token and measure latency
|
|
static Future<ConnectionTestResult> testConnectionWithLatency(
|
|
String baseUrl,
|
|
String token, {
|
|
Duration timeout = const Duration(seconds: 5),
|
|
String? clientIdentifier,
|
|
}) async {
|
|
final stopwatch = Stopwatch()..start();
|
|
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'] = 'Plezy';
|
|
}
|
|
|
|
final response = await client.get('/', headers: headers);
|
|
|
|
stopwatch.stop();
|
|
final success = response.statusCode == 200;
|
|
|
|
bool? transcoderVideo;
|
|
if (success && response.data is Map && response.data['MediaContainer'] is Map) {
|
|
transcoderVideo = _parsePlexTranscoderVideoCapability(
|
|
(response.data['MediaContainer'] as Map)['transcoderVideo'],
|
|
);
|
|
}
|
|
|
|
return ConnectionTestResult(
|
|
success: success,
|
|
latencyMs: stopwatch.elapsedMilliseconds,
|
|
error: success ? null : 'HTTP ${response.statusCode}',
|
|
transcoderVideo: transcoderVideo,
|
|
);
|
|
} on MediaServerHttpException catch (e) {
|
|
stopwatch.stop();
|
|
final label = switch (e.type) {
|
|
MediaServerHttpErrorType.connectionTimeout => 'Connection timeout',
|
|
MediaServerHttpErrorType.receiveTimeout => 'Receive timeout',
|
|
MediaServerHttpErrorType.connectionError => 'Connection error',
|
|
_ => e.type.name,
|
|
};
|
|
final message = e.message.trim();
|
|
var error = message.isEmpty ? label : '$label: $message';
|
|
if (e.statusCode != null) {
|
|
error += ' (HTTP ${e.statusCode})';
|
|
}
|
|
return ConnectionTestResult(success: false, latencyMs: stopwatch.elapsedMilliseconds, error: error);
|
|
} catch (e) {
|
|
stopwatch.stop();
|
|
return ConnectionTestResult(success: false, latencyMs: stopwatch.elapsedMilliseconds, error: e.toString());
|
|
} finally {
|
|
client?.close();
|
|
}
|
|
}
|
|
|
|
/// Test connection multiple times and return average latency
|
|
static Future<ConnectionTestResult> testConnectionWithAverageLatency(
|
|
String baseUrl,
|
|
String token, {
|
|
int attempts = 3,
|
|
Duration timeout = const Duration(seconds: 5),
|
|
String? clientIdentifier,
|
|
}) async {
|
|
final results = <ConnectionTestResult>[];
|
|
|
|
for (int i = 0; i < attempts; i++) {
|
|
final result = await testConnectionWithLatency(
|
|
baseUrl,
|
|
token,
|
|
timeout: timeout,
|
|
clientIdentifier: clientIdentifier,
|
|
);
|
|
|
|
// If any attempt fails, return failed result immediately
|
|
if (!result.success) {
|
|
return ConnectionTestResult(success: false, latencyMs: result.latencyMs);
|
|
}
|
|
|
|
results.add(result);
|
|
}
|
|
|
|
// Calculate average latency from successful attempts
|
|
final avgLatency = results.fold<int>(0, (sum, result) => sum + result.latencyMs) ~/ results.length;
|
|
|
|
return ConnectionTestResult(success: true, latencyMs: avgLatency);
|
|
}
|
|
|
|
@override
|
|
Map<String, dynamic>? _getMediaContainer(MediaServerResponse response) {
|
|
if (response.data is Map && response.data.containsKey('MediaContainer')) {
|
|
return response.data['MediaContainer'];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
PlexMetadataDto _tagMetadata(PlexMetadataDto metadata) =>
|
|
metadata.copyWith(serverId: serverId, serverName: serverName);
|
|
|
|
PlexMetadataDto _tagMetadataWithLibrary(
|
|
PlexMetadataDto metadata, {
|
|
int? librarySectionID,
|
|
String? librarySectionTitle,
|
|
}) {
|
|
return _plexMetadataWithLibrarySection(
|
|
_tagMetadata(metadata),
|
|
librarySectionID: librarySectionID,
|
|
librarySectionTitle: librarySectionTitle,
|
|
);
|
|
}
|
|
|
|
@override
|
|
PlexMetadataDto _createTaggedMetadata(Map<String, dynamic> json) => _tagMetadata(PlexMetadataDto.fromJson(json));
|
|
|
|
PlexMetadataDto _createTaggedMetadataWithLibrary(
|
|
Map<String, dynamic> json, {
|
|
int? librarySectionID,
|
|
String? librarySectionTitle,
|
|
}) {
|
|
return _tagMetadataWithLibrary(
|
|
PlexMetadataDto.fromJson(json),
|
|
librarySectionID: _librarySectionIdFromJson(json) ?? librarySectionID,
|
|
librarySectionTitle: _librarySectionTitleFromJson(json) ?? librarySectionTitle,
|
|
);
|
|
}
|
|
|
|
@override
|
|
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 _fallbackPageTotal({required int offset, required int itemCount, int? requestedSize}) {
|
|
final fullPage = requestedSize != null && requestedSize > 0 && itemCount >= requestedSize;
|
|
return offset + itemCount + (fullPage ? 1 : 0);
|
|
}
|
|
|
|
int _responseTotalSize(MediaServerResponse response, {required int itemCount, int? start, int? requestedSize}) {
|
|
final headerTotal = _responseHeaderInt(response, 'X-Plex-Container-Total-Size');
|
|
if (headerTotal != null) return headerTotal;
|
|
|
|
final container = _getMediaContainer(response);
|
|
final bodyTotal = flexibleInt(container?['totalSize']);
|
|
if (bodyTotal != null) return bodyTotal;
|
|
|
|
final offset = start ?? flexibleInt(container?['offset']) ?? 0;
|
|
if (start == null && requestedSize == null) {
|
|
return flexibleInt(container?['size']) ?? itemCount;
|
|
}
|
|
|
|
return _fallbackPageTotal(offset: offset, itemCount: itemCount, requestedSize: requestedSize);
|
|
}
|
|
|
|
({List<PlexPlaylistDto> items, int totalSize}) _extractPlaylistListResult(
|
|
MediaServerResponse response, {
|
|
int? start,
|
|
int? size,
|
|
}) {
|
|
final items = _extractPlaylistList(response);
|
|
return (
|
|
items: items,
|
|
totalSize: _responseTotalSize(response, itemCount: items.length, start: start, requestedSize: size),
|
|
);
|
|
}
|
|
|
|
Future<Map<String, dynamic>> getServerIdentity() async {
|
|
final response = await _getWithFailover('/identity');
|
|
return response.data;
|
|
}
|
|
|
|
/// Check if the server connection is healthy (reachable AND authenticated).
|
|
///
|
|
/// Hits the root `/` MediaContainer (auth-required) rather than `/identity`
|
|
/// (an unauthenticated discovery endpoint). With `/identity`, a server with
|
|
/// a revoked or expired token would still report healthy, only to 401 on
|
|
/// the very next real call. Mirrors Jellyfin's `/Users/Me` choice.
|
|
///
|
|
/// Distinguishes 401/403 (token revoked / wrong user) as
|
|
/// [HealthStatus.authError] from generic transport failures so the
|
|
/// manager can route them to a re-auth banner instead of generic
|
|
/// "server offline" UI.
|
|
@override
|
|
Future<HealthStatus> checkHealth() async {
|
|
try {
|
|
final response = await _getWithFailover('/');
|
|
return response.statusCode == 200 ? HealthStatus.online : HealthStatus.offline;
|
|
} on MediaServerHttpException catch (e) {
|
|
if (e.statusCode == 401 || e.statusCode == 403) return HealthStatus.authError;
|
|
return HealthStatus.offline;
|
|
} catch (_) {
|
|
return HealthStatus.offline;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<bool> isHealthy() async => (await checkHealth()) == HealthStatus.online;
|
|
|
|
/// Get running background tasks (thumbnail generation, credit detection, etc.)
|
|
Future<List<PlexActivity>> getActivities() async {
|
|
try {
|
|
final response = await _getWithFailover('/activities');
|
|
final container = _getMediaContainer(response);
|
|
if (container == null) return [];
|
|
final activityList = container['Activity'] as List?;
|
|
if (activityList == null) return [];
|
|
return activityList.map((json) => PlexActivity.fromJson(json as Map<String, dynamic>)).toList();
|
|
} catch (e) {
|
|
appLogger.e('Failed to get activities', error: e);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/// Cancel a running background task by its UUID.
|
|
Future<void> cancelActivity(String uuid) async {
|
|
await _http.delete('/activities/$uuid');
|
|
}
|
|
|
|
/// Get library sections
|
|
/// Returns libraries automatically tagged with this client's serverId and serverName.
|
|
/// Prefers /media/providers data (includes individually shared items),
|
|
/// falls back to /library/sections for old servers.
|
|
Future<List<PlexLibraryDto>> _getLibraries() async {
|
|
if (_providerLibraries.isNotEmpty) return _providerLibraries;
|
|
// Fallback for old servers that don't support /media/providers
|
|
final response = await _getWithFailover('/library/sections');
|
|
return _extractLibraryList(response);
|
|
}
|
|
|
|
/// Get library content by section ID
|
|
Future<_LibraryContentResult> _getLibraryContent(
|
|
String sectionId, {
|
|
int? start,
|
|
int? size,
|
|
Map<String, String>? filters,
|
|
AbortController? abort,
|
|
}) async {
|
|
final queryParams = _buildPaginationParams(start, size);
|
|
if (filters != null) queryParams.addAll(filters);
|
|
final endpoint = sectionId == 'shared' ? '/library/shared/all' : '/library/sections/$sectionId/all';
|
|
final response = await _getWithFailover(endpoint, queryParameters: queryParams, abort: abort);
|
|
return _extractLibraryContentResult(
|
|
response,
|
|
librarySectionID: _librarySectionIdFromString(sectionId),
|
|
start: start,
|
|
requestedSize: size,
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> _buildPaginationParams(int? start, int? size) {
|
|
final params = <String, dynamic>{};
|
|
if (start != null) params['X-Plex-Container-Start'] = start;
|
|
if (size != null) params['X-Plex-Container-Size'] = size;
|
|
return params;
|
|
}
|
|
|
|
_LibraryContentResult _extractLibraryContentResult(
|
|
MediaServerResponse response, {
|
|
int? librarySectionID,
|
|
String? librarySectionTitle,
|
|
int? start,
|
|
int? requestedSize,
|
|
}) {
|
|
final items = _extractMetadataListWithLibrary(
|
|
response,
|
|
librarySectionID: librarySectionID,
|
|
librarySectionTitle: librarySectionTitle,
|
|
);
|
|
final totalSize = _responseTotalSize(response, itemCount: items.length, start: start, requestedSize: requestedSize);
|
|
return _LibraryContentResult(items: items, totalSize: totalSize);
|
|
}
|
|
|
|
Future<_LibraryContentResult> _fetchPaginatedList(
|
|
String path, {
|
|
int? start,
|
|
int? size,
|
|
AbortController? abort,
|
|
int? librarySectionID,
|
|
String? librarySectionTitle,
|
|
}) async {
|
|
final response = await _getWithFailover(path, queryParameters: _buildPaginationParams(start, size), abort: abort);
|
|
return _extractLibraryContentResult(
|
|
response,
|
|
librarySectionID: librarySectionID,
|
|
librarySectionTitle: librarySectionTitle,
|
|
start: start,
|
|
requestedSize: size,
|
|
);
|
|
}
|
|
|
|
/// Parse list of PlexMetadataDto from a cached response
|
|
List<PlexMetadataDto> _parseMetadataListFromCachedResponse(Map<String, dynamic> cached) {
|
|
final container = cached['MediaContainer'] is Map<String, dynamic>
|
|
? cached['MediaContainer'] as Map<String, dynamic>
|
|
: null;
|
|
final containerSectionID = _librarySectionIdFromJson(container);
|
|
final containerSectionTitle = _librarySectionTitleFromJson(container);
|
|
final metadataList = PlexCacheParser.extractMetadataList(cached);
|
|
if (metadataList != null) {
|
|
return metadataList
|
|
.map(
|
|
(json) => _createTaggedMetadataWithLibrary(
|
|
json as Map<String, dynamic>,
|
|
librarySectionID: containerSectionID,
|
|
librarySectionTitle: containerSectionTitle,
|
|
),
|
|
)
|
|
.toList();
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/// Get the server's machine identifier
|
|
@override
|
|
Future<String?> getMachineIdentifier() async {
|
|
try {
|
|
final response = await _getWithFailover('/');
|
|
final container = _getMediaContainer(response);
|
|
if (container == null) return null;
|
|
return container['machineIdentifier'] as String?;
|
|
} catch (e) {
|
|
appLogger.e('Failed to get machine identifier', error: e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Build a proper metadata URI for adding to playlists
|
|
/// Returns URI in format: server://{machineId}/com.plexapp.plugins.library/library/metadata/{ratingKey}
|
|
Future<String> buildMetadataUri(String ratingKey) async {
|
|
// Use cached machine identifier from config if available
|
|
final machineId = config.machineIdentifier ?? await getMachineIdentifier();
|
|
if (machineId == null) {
|
|
throw Exception('Could not get server machine identifier');
|
|
}
|
|
return 'server://$machineId/com.plexapp.plugins.library/library/metadata/$ratingKey';
|
|
}
|
|
|
|
/// Build a server URI from a folder key for play queue creation.
|
|
/// Folder keys are like `/library/sections/1/folder?parent=123`.
|
|
Future<String> buildFolderUri(String folderKey) async {
|
|
final machineId = config.machineIdentifier ?? await getMachineIdentifier();
|
|
if (machineId == null) {
|
|
throw Exception('Could not get server machine identifier');
|
|
}
|
|
return 'server://$machineId/com.plexapp.plugins.library$folderKey';
|
|
}
|
|
|
|
/// Get metadata by rating key with images (includes clearLogo and OnDeck)
|
|
/// Uses cache when offline or as fallback on network error
|
|
/// Note: OnDeck data is not relevant for offline mode
|
|
/// Always fetches with chapters/markers but caches at base endpoint
|
|
Future<Map<String, dynamic>> getMetadataWithImagesAndOnDeck(String ratingKey) async {
|
|
// Cache key is always the base endpoint (no query params)
|
|
final cacheKey = '/library/metadata/$ratingKey';
|
|
|
|
// Special handling needed for OnDeck - can't use simple fetchWithCacheFallback
|
|
// because OnDeck is only available from network response, not cache
|
|
return await fetchWithCacheFallback<Map<String, dynamic>>(
|
|
cacheKey: cacheKey,
|
|
networkCall: () => _http.get(
|
|
'/library/metadata/$ratingKey',
|
|
queryParameters: {'includeChapters': 1, 'includeMarkers': 1, 'includeOnDeck': 1},
|
|
),
|
|
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}),
|
|
parseCache: (cachedData) => _parseMetadataWithImagesFromCachedResponse(cachedData),
|
|
parseResponse: (response) {
|
|
final container = _getMediaContainer(response);
|
|
final metadataJson = _getFirstMetadataJson(response);
|
|
return metadataJson != null
|
|
? _tagMetadataWithLibrary(
|
|
PlexMetadataDto.fromJsonWithImages(metadataJson),
|
|
librarySectionID: _librarySectionIdFromJson(metadataJson) ?? _librarySectionIdFromJson(container),
|
|
librarySectionTitle:
|
|
_librarySectionTitleFromJson(metadataJson) ?? _librarySectionTitleFromJson(container),
|
|
)
|
|
: null;
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Parse PlexMetadataDto with images from a cached response
|
|
PlexMetadataDto? _parseMetadataWithImagesFromCachedResponse(Map<String, dynamic> cached) {
|
|
final container = cached['MediaContainer'] is Map<String, dynamic>
|
|
? cached['MediaContainer'] as Map<String, dynamic>
|
|
: null;
|
|
final firstMetadata = PlexCacheParser.extractFirstMetadata(cached);
|
|
if (firstMetadata != null) {
|
|
return _tagMetadataWithLibrary(
|
|
PlexMetadataDto.fromJsonWithImages(firstMetadata),
|
|
librarySectionID: _librarySectionIdFromJson(firstMetadata) ?? _librarySectionIdFromJson(container),
|
|
librarySectionTitle: _librarySectionTitleFromJson(firstMetadata) ?? _librarySectionTitleFromJson(container),
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Get first metadata JSON from response data
|
|
Map<String, dynamic>? _getFirstMetadataJsonFromData(Map<String, dynamic>? data) =>
|
|
PlexCacheParser.extractFirstMetadata(data);
|
|
|
|
/// Wraps an API call that returns a boolean success status.
|
|
///
|
|
/// Contract (matches the rest of the [MediaServerClient] surface):
|
|
/// - HTTP 2xx → returns `true`.
|
|
/// - HTTP 4xx/5xx → throws [MediaServerHttpException] (via
|
|
/// [throwIfHttpError]) so callers can show a real error rather than a
|
|
/// silent "success: false".
|
|
/// - Network/IO failure → exception bubbles unchanged.
|
|
/// - Non-2xx success that the server reports without an error code is
|
|
/// vanishingly rare for these endpoints; we still return `false` so
|
|
/// callers don't celebrate a non-200 silently.
|
|
Future<bool> _wrapBoolApiCall(Future<MediaServerResponse> Function() apiCall, String errorMessage) async {
|
|
try {
|
|
final response = await apiCall();
|
|
throwIfHttpError(response);
|
|
return response.statusCode == 200;
|
|
} catch (e, st) {
|
|
appLogger.e(errorMessage, error: e, stackTrace: st);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// Wraps an API call that returns a list, returning empty list on error
|
|
@override
|
|
Future<List<T>> _wrapListApiCall<T>(
|
|
Future<MediaServerResponse> Function() apiCall,
|
|
List<T> Function(MediaServerResponse response) parseResponse,
|
|
String errorMessage,
|
|
) async {
|
|
try {
|
|
final response = await apiCall();
|
|
return parseResponse(response);
|
|
} catch (e) {
|
|
appLogger.e(errorMessage, error: e);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/// Default cap for list-style endpoints when a caller doesn't pass a size.
|
|
static const int _defaultListContainerSize = 1000;
|
|
|
|
/// Page size used when walking all pages of a paginated endpoint.
|
|
static const int _fetchAllPageSize = 200;
|
|
|
|
/// Iterate every page of a paginated endpoint and concatenate the results.
|
|
/// Stops as soon as [_LibraryContentResult.totalSize] is reached or a page
|
|
/// returns no items. Errors propagate.
|
|
Future<List<PlexMetadataDto>> _fetchAllPages(
|
|
Future<_LibraryContentResult> Function(int start, int size, AbortController? abort) fetchPage, {
|
|
AbortController? abort,
|
|
}) async {
|
|
final all = <PlexMetadataDto>[];
|
|
var start = 0;
|
|
while (true) {
|
|
final page = await fetchPage(start, _fetchAllPageSize, abort);
|
|
all.addAll(page.items);
|
|
start += page.items.length;
|
|
if (page.items.isEmpty) break;
|
|
if (start >= page.totalSize) break;
|
|
}
|
|
return all;
|
|
}
|
|
|
|
/// Set per-media language preferences (audio and subtitle)
|
|
/// For TV shows, use grandparentRatingKey to set preference for the entire series
|
|
/// For movies, use the movie's ratingKey
|
|
Future<bool> setMetadataPreferences(String ratingKey, {String? audioLanguage, String? subtitleLanguage}) async {
|
|
final queryParams = <String, dynamic>{};
|
|
if (audioLanguage != null) {
|
|
queryParams['audioLanguage'] = audioLanguage;
|
|
}
|
|
if (subtitleLanguage != null) {
|
|
queryParams['subtitleLanguage'] = subtitleLanguage;
|
|
}
|
|
|
|
// If no preferences to set, return early
|
|
if (queryParams.isEmpty) {
|
|
return true;
|
|
}
|
|
|
|
return _wrapBoolApiCall(
|
|
() => _http.put('/library/metadata/$ratingKey/prefs', queryParameters: queryParams),
|
|
'Failed to set metadata preferences',
|
|
);
|
|
}
|
|
|
|
/// Select specific audio and subtitle streams for playback
|
|
/// This updates which streams are "selected" in the media metadata
|
|
/// Uses the part ID from media info for accurate stream selection
|
|
Future<bool> selectStreams(int partId, {int? audioStreamID, int? subtitleStreamID, bool allParts = true}) async {
|
|
final queryParams = <String, dynamic>{};
|
|
if (audioStreamID != null) {
|
|
queryParams['audioStreamID'] = audioStreamID;
|
|
}
|
|
if (subtitleStreamID != null) {
|
|
queryParams['subtitleStreamID'] = subtitleStreamID;
|
|
}
|
|
if (allParts) {
|
|
// If no streams to select, return early
|
|
if (queryParams.isEmpty) {
|
|
return true;
|
|
}
|
|
|
|
// Use PUT request on /library/parts/{partId}
|
|
return _wrapBoolApiCall(
|
|
() => _http.put('/library/parts/$partId', queryParameters: queryParams),
|
|
'Failed to select streams',
|
|
);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// Search for subtitles from external providers (e.g. OpenSubtitles) via the Plex server.
|
|
/// [language] is an ISO 639-1 two-letter code (e.g. "en", "es").
|
|
Future<List<PlexSubtitleSearchResult>> searchSubtitles(
|
|
String ratingKey, {
|
|
required String language,
|
|
String? title,
|
|
int hearingImpaired = 0,
|
|
int forced = 0,
|
|
}) async {
|
|
return _wrapListApiCall<PlexSubtitleSearchResult>(
|
|
() => _http.get(
|
|
'/library/metadata/$ratingKey/subtitles',
|
|
queryParameters: {
|
|
'language': language,
|
|
if (title != null && title.isNotEmpty) 'title': title,
|
|
'hearingImpaired': hearingImpaired,
|
|
'forced': forced,
|
|
},
|
|
),
|
|
(response) {
|
|
final container = _getMediaContainer(response);
|
|
final streams = container?['Stream'] as List? ?? [];
|
|
return streams.map((s) => PlexSubtitleSearchResult.fromJson(s as Map<String, dynamic>)).toList();
|
|
},
|
|
'Failed to search subtitles',
|
|
);
|
|
}
|
|
|
|
/// Download a subtitle from an external provider and add it to the media item.
|
|
/// The server downloads the file asynchronously; the new stream appears after a short delay.
|
|
Future<bool> downloadSubtitle(
|
|
String ratingKey, {
|
|
required String key,
|
|
required String codec,
|
|
required String language,
|
|
required bool hearingImpaired,
|
|
required bool forced,
|
|
required String providerTitle,
|
|
}) async {
|
|
return _wrapBoolApiCall(
|
|
() => _http.put(
|
|
'/library/metadata/$ratingKey/subtitles',
|
|
queryParameters: {
|
|
'key': key,
|
|
'codec': codec,
|
|
'language': language,
|
|
'hearingImpaired': hearingImpaired ? 1 : 0,
|
|
'forced': forced ? 1 : 0,
|
|
'providerTitle': providerTitle,
|
|
},
|
|
),
|
|
'Failed to download subtitle',
|
|
);
|
|
}
|
|
|
|
/// Search across all libraries including individually shared items.
|
|
/// Uses /library/search (same endpoint as Plex Web) which finds shared content.
|
|
/// Only returns movies and shows, filtering out other types.
|
|
Future<List<PlexMetadataDto>> _search(String query, {int limit = 100}) async {
|
|
final response = await _getWithFailover(
|
|
'/library/search',
|
|
queryParameters: {
|
|
'query': query,
|
|
'limit': limit,
|
|
'searchTypes': 'movies,tv',
|
|
'includeCollections': 1,
|
|
'includeExternalMedia': 1,
|
|
'X-Plex-Container-Size': limit,
|
|
},
|
|
);
|
|
|
|
final results = <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?;
|
|
if (type != 'movie' && type != 'show') continue;
|
|
|
|
results.add(_createTaggedMetadata(metadata));
|
|
} catch (e) {
|
|
appLogger.w('Failed to parse search result', error: e);
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
/// Get recently added media (filtered to video content only)
|
|
Future<List<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)
|
|
/// Uses cache when offline or as fallback on network error
|
|
Future<List<PlexMetadataDto>> _getChildren(String ratingKey) async {
|
|
final endpoint = '/library/metadata/$ratingKey/children';
|
|
|
|
return await fetchWithCacheFallback<List<PlexMetadataDto>>(
|
|
cacheKey: endpoint,
|
|
networkCall: () => _http.get(endpoint),
|
|
parseCache: (cachedData) => _parseMetadataListFromCachedResponse(cachedData),
|
|
parseResponse: (response) => _extractMetadataList(response),
|
|
) ??
|
|
[];
|
|
}
|
|
|
|
/// 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);
|
|
|
|
/// 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}) {
|
|
return parsePlexVideoPlaybackDataFromJson(
|
|
metadataJson,
|
|
baseUrl: config.baseUrl,
|
|
token: config.token,
|
|
mediaIndex: mediaIndex,
|
|
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}) 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);
|
|
}
|
|
|
|
/// 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 data = await fetchWithCacheFirst<Map<String, dynamic>>(
|
|
cacheKey: '/library/metadata/$ratingKey',
|
|
networkCall: () =>
|
|
_http.get('/library/metadata/$ratingKey', queryParameters: {'includeMarkers': 1, 'includeChapters': 1}),
|
|
parseCache: (cached) => cached as Map<String, dynamic>?,
|
|
parseResponse: (response) => response.data as Map<String, dynamic>?,
|
|
);
|
|
final metadataJson = _getFirstMetadataJsonFromData(data);
|
|
|
|
return parsePlexFileInfoFromJson(metadataJson);
|
|
} catch (e) {
|
|
appLogger.e('Failed to get file info: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Fetch the raw `Guid` array for a metadata item (`includeGuids=1`).
|
|
///
|
|
/// Returns the list of `{id: 'imdb://tt...'}` maps as Plex returns them, or
|
|
/// an empty list if the item has no external IDs / can't be fetched.
|
|
/// Used by the Trakt integration to match Plex items against Trakt's catalog.
|
|
Future<List<dynamic>> fetchExternalGuids(String ratingKey) async {
|
|
try {
|
|
final response = await _getWithFailover('/library/metadata/$ratingKey', queryParameters: {'includeGuids': 1});
|
|
final data = response.data;
|
|
if (data is! Map) return const [];
|
|
final container = data['MediaContainer'] as Map?;
|
|
final metadata = container?['Metadata'];
|
|
if (metadata is! List || metadata.isEmpty) return const [];
|
|
final first = metadata.first;
|
|
if (first is! Map) return const [];
|
|
final guids = first['Guid'];
|
|
if (guids is List) return guids;
|
|
return const [];
|
|
} catch (e) {
|
|
appLogger.d('fetchExternalGuids failed for $ratingKey', error: e);
|
|
return const [];
|
|
}
|
|
}
|
|
|
|
/// Mark media as watched
|
|
///
|
|
/// If [item] is provided, emits a [WatchStateEvent] for UI updates.
|
|
Future<void> markAsWatched(String ratingKey, {MediaItem? item}) async {
|
|
await _getWithFailover(
|
|
'/:/scrobble',
|
|
queryParameters: {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'},
|
|
);
|
|
if (item != null) {
|
|
WatchStateNotifier().notifyWatched(item: item, isNowWatched: true);
|
|
}
|
|
}
|
|
|
|
/// Mark media as unwatched
|
|
///
|
|
/// If [item] is provided, emits a [WatchStateEvent] for UI updates.
|
|
Future<void> markAsUnwatched(String ratingKey, {MediaItem? item}) async {
|
|
await _getWithFailover(
|
|
'/:/unscrobble',
|
|
queryParameters: {'key': ratingKey, 'identifier': 'com.plexapp.plugins.library'},
|
|
);
|
|
if (item != null) {
|
|
WatchStateNotifier().notifyWatched(item: item, isNowWatched: false);
|
|
}
|
|
}
|
|
|
|
/// Update playback progress
|
|
Future<void> updateProgress(
|
|
String ratingKey, {
|
|
required int time,
|
|
required String state, // 'playing', 'paused', 'stopped', 'buffering'
|
|
int? duration,
|
|
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,
|
|
},
|
|
);
|
|
// Surface non-2xx instead of swallowing — progress is the cornerstone
|
|
// of resume/Continue Watching, so silent failures hurt the user later.
|
|
throwIfHttpError(response);
|
|
}
|
|
|
|
/// Remove item from Continue Watching (On Deck) without affecting watch status or progress
|
|
/// This uses the same endpoint Plex Web uses to hide items from Continue Watching
|
|
Future<void> removeFromOnDeck(String ratingKey) async {
|
|
await _http.put('/actions/removeFromContinueWatching', queryParameters: {'ratingKey': ratingKey});
|
|
}
|
|
|
|
/// 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);
|
|
} 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 {
|
|
final response = await _getWithFailover('/library/sections/$sectionId/sorts');
|
|
final sorts = _extractDirectoryList(response, MediaSort.fromJson);
|
|
|
|
if (sorts.isNotEmpty) {
|
|
return sorts;
|
|
}
|
|
|
|
// Fallback: return common sort options if API doesn't provide them
|
|
return _getFallbackSorts(libraryType);
|
|
} catch (e) {
|
|
appLogger.e('Failed to get library sorts: $e');
|
|
// Return fallback sort options on error
|
|
return _getFallbackSorts(libraryType);
|
|
}
|
|
}
|
|
|
|
/// Build fallback sort options based on library type.
|
|
///
|
|
/// If [libraryType] is null, returns generic sorts without the show-specific options.
|
|
List<MediaSort> _getFallbackSorts(String? libraryType) {
|
|
final fallbackSorts = <MediaSort>[
|
|
MediaSort(key: 'titleSort', title: t.libraries.sortLabels.title, defaultDirection: 'asc'),
|
|
MediaSort(
|
|
key: 'addedAt',
|
|
descKey: 'addedAt:desc',
|
|
title: t.libraries.sortLabels.dateAdded,
|
|
defaultDirection: 'desc',
|
|
),
|
|
];
|
|
|
|
// 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 = 10, 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,
|
|
),
|
|
);
|
|
} 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 = 10}) 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 _getWithFailover(
|
|
'/hubs/metadata/$ratingKey/related',
|
|
queryParameters: {'count': count},
|
|
allowEndpointFailover: false,
|
|
);
|
|
final sid = serverId;
|
|
final sname = serverName;
|
|
final data = response.data as Map<String, dynamic>;
|
|
return await tryIsolateRun(
|
|
() => _processHubResponse(
|
|
data,
|
|
sid,
|
|
sname,
|
|
filter: (item) {
|
|
final type = item.type?.toLowerCase();
|
|
return ContentTypes.videoTypes.contains(type) || type == ContentTypes.collection;
|
|
},
|
|
),
|
|
);
|
|
} catch (e) {
|
|
appLogger.e('Failed to get related hubs: $e');
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/// Get full content from a hub using its hub key
|
|
/// Returns the complete list of metadata items in the hub
|
|
Future<List<PlexMetadataDto>> _getHubContent(String hubKey) async {
|
|
try {
|
|
final hubSectionID = _librarySectionIdFromString(hubKey);
|
|
final items = await _fetchAllPages(
|
|
(start, size, abort) =>
|
|
_fetchPaginatedList(hubKey, start: start, size: size, abort: abort, librarySectionID: hubSectionID),
|
|
);
|
|
return items.where(_isVideoMetadata).toList();
|
|
} catch (e, st) {
|
|
appLogger.e('Failed to get hub content', error: e, stackTrace: st);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
bool _isVideoMetadata(PlexMetadataDto item) => ContentTypes.videoTypes.contains(item.type?.toLowerCase());
|
|
|
|
Future<_LibraryContentResult> _getHubContentPage(
|
|
String hubKey, {
|
|
int? start,
|
|
int? size,
|
|
AbortController? abort,
|
|
}) async {
|
|
final filteredOffset = start ?? 0;
|
|
final pageSize = size ?? _fetchAllPageSize;
|
|
final rawPageSize = pageSize > _fetchAllPageSize ? pageSize : _fetchAllPageSize;
|
|
final hubSectionID = _librarySectionIdFromString(hubKey);
|
|
final pageItems = <PlexMetadataDto>[];
|
|
var rawOffset = 0;
|
|
var filteredSeen = 0;
|
|
var rawTotal = 0;
|
|
var rawFinished = false;
|
|
|
|
while (pageItems.length < pageSize && !rawFinished) {
|
|
final result = await _fetchPaginatedList(
|
|
hubKey,
|
|
start: rawOffset,
|
|
size: rawPageSize,
|
|
abort: abort,
|
|
librarySectionID: hubSectionID,
|
|
);
|
|
rawTotal = result.totalSize;
|
|
final rawItems = result.items;
|
|
rawOffset += rawItems.length;
|
|
|
|
for (final item in rawItems) {
|
|
if (!_isVideoMetadata(item)) continue;
|
|
if (filteredSeen >= filteredOffset && pageItems.length < pageSize) {
|
|
pageItems.add(item);
|
|
}
|
|
filteredSeen++;
|
|
}
|
|
|
|
rawFinished = rawItems.isEmpty || rawOffset >= rawTotal;
|
|
}
|
|
|
|
final totalSize = rawFinished ? filteredSeen : filteredOffset + pageItems.length + 1;
|
|
return _LibraryContentResult(items: pageItems, totalSize: totalSize);
|
|
}
|
|
|
|
/// Get playlist content by playlist ID, paginated.
|
|
Future<_LibraryContentResult> _getPlaylist(String playlistId, {int? start, int? size, AbortController? abort}) =>
|
|
_fetchPaginatedList('/playlists/$playlistId/items', start: start, size: size, abort: abort);
|
|
|
|
/// Fetch every page of a playlist's items. For callers that need the full list
|
|
/// (downloads, sync rules, context-menu shuffle).
|
|
Future<List<PlexMetadataDto>> _fetchAllPlaylistItemsDto(String playlistId) =>
|
|
_fetchAllPages((start, size, abort) => _getPlaylist(playlistId, start: start, size: size, abort: abort));
|
|
|
|
/// Get all playlists.
|
|
/// Filters by playlistType=video by default.
|
|
/// Set smart to true/false to filter smart playlists, or null for all.
|
|
Future<List<PlexPlaylistDto>> _getPlaylists({String playlistType = 'video', bool? smart}) async {
|
|
try {
|
|
final all = <PlexPlaylistDto>[];
|
|
var start = 0;
|
|
while (true) {
|
|
final page = await _getPlaylistsPage(
|
|
playlistType: playlistType,
|
|
smart: smart,
|
|
start: start,
|
|
size: _fetchAllPageSize,
|
|
);
|
|
if (page.items.isEmpty) break;
|
|
all.addAll(page.items);
|
|
start += page.items.length;
|
|
if (start >= page.totalSize) break;
|
|
}
|
|
return all;
|
|
} catch (e, st) {
|
|
appLogger.e('Failed to get playlists', error: e, stackTrace: st);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
Future<({List<PlexPlaylistDto> items, int totalSize})> _getPlaylistsPage({
|
|
String playlistType = 'video',
|
|
bool? smart,
|
|
int? start,
|
|
int? size,
|
|
AbortController? abort,
|
|
}) async {
|
|
final pageSize = size ?? _defaultListContainerSize;
|
|
final queryParams = <String, dynamic>{
|
|
if (playlistType.isNotEmpty) 'playlistType': playlistType,
|
|
..._buildPaginationParams(start, pageSize),
|
|
};
|
|
if (smart != null) {
|
|
queryParams['smart'] = smart ? '1' : '0';
|
|
}
|
|
|
|
final response = await _getWithFailover('/playlists', queryParameters: queryParams, abort: abort);
|
|
return _extractPlaylistListResult(response, start: start, size: pageSize);
|
|
}
|
|
|
|
/// Get playlist metadata by playlist ID
|
|
/// Returns the playlist details (not the items)
|
|
Future<PlexPlaylistDto?> _getPlaylistMetadata(String playlistId) async {
|
|
try {
|
|
final response = await _getWithFailover('/playlists/$playlistId');
|
|
final container = _getMediaContainer(response);
|
|
|
|
if (container == null || container['Metadata'] == null) {
|
|
return null;
|
|
}
|
|
|
|
final List<dynamic> metadata = container['Metadata'] as List;
|
|
|
|
if (metadata.isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
return PlexPlaylistDto.fromJson(metadata.first as Map<String, dynamic>);
|
|
} catch (e) {
|
|
appLogger.e('Failed to get playlist metadata: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Neutral [MediaServerClient.createPlaylist] override — wraps
|
|
/// [createPlaylistFromUri] after building a Plex metadata URI from
|
|
/// the supplied items.
|
|
@override
|
|
Future<MediaPlaylist?> createPlaylist({required String title, required List<MediaItem> items}) async {
|
|
if (items.isEmpty) {
|
|
return createPlaylistFromUri(title: title);
|
|
}
|
|
final uri = await buildMetadataUri(items.map((i) => i.id).join(','));
|
|
return createPlaylistFromUri(title: title, uri: uri);
|
|
}
|
|
|
|
/// Create a new playlist
|
|
/// [title] - Name of the playlist
|
|
/// [uri] - Optional comma-separated list of item URIs to add (e.g., "server://uuid/com.plexapp.plugins.library/library/metadata/1234")
|
|
/// [playQueueId] - Optional play queue ID to create playlist from
|
|
///
|
|
/// Errors propagate to the caller (matches the [MediaServerClient]
|
|
/// contract — throw on HTTP/transport failures, return `null` only when
|
|
/// the server replied 2xx but with no usable playlist payload).
|
|
Future<MediaPlaylist?> createPlaylistFromUri({required String title, String? uri, int? playQueueId}) async {
|
|
final queryParams = <String, dynamic>{'type': 'video', 'title': title, 'smart': '0'};
|
|
|
|
if (uri != null) {
|
|
queryParams['uri'] = uri;
|
|
}
|
|
if (playQueueId != null) {
|
|
queryParams['playQueueID'] = playQueueId.toString();
|
|
}
|
|
|
|
final response = await _http.post('/playlists', queryParameters: queryParams);
|
|
throwIfHttpError(response);
|
|
final container = _getMediaContainer(response);
|
|
|
|
if (container == null || container['Metadata'] == null) {
|
|
return null;
|
|
}
|
|
|
|
final List<dynamic> metadata = container['Metadata'] as List;
|
|
|
|
if (metadata.isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
final dto = PlexPlaylistDto.fromJson(
|
|
metadata.first as Map<String, dynamic>,
|
|
).copyWith(serverId: serverId, serverName: serverName);
|
|
return PlexMappers.mediaPlaylist(dto);
|
|
}
|
|
|
|
/// Delete a playlist
|
|
@override
|
|
Future<bool> deletePlaylist(MediaPlaylist playlist) {
|
|
return _wrapBoolApiCall(() => _http.delete('/playlists/${playlist.id}'), 'Failed to delete playlist');
|
|
}
|
|
|
|
/// Neutral [MediaServerClient.addToPlaylist] override — builds a Plex
|
|
/// metadata URI from [items] and delegates to [addItemsToPlaylistByUri].
|
|
@override
|
|
Future<bool> addToPlaylist({required String playlistId, required List<MediaItem> items}) async {
|
|
if (items.isEmpty) return true;
|
|
final uri = await buildMetadataUri(items.map((i) => i.id).join(','));
|
|
return addItemsToPlaylistByUri(playlistId: playlistId, uri: uri);
|
|
}
|
|
|
|
/// Add items to a playlist
|
|
/// [playlistId] - The playlist to add items to
|
|
/// [uri] - Comma-separated list of item URIs to add
|
|
Future<bool> addItemsToPlaylistByUri({required String playlistId, required String uri}) async {
|
|
appLogger.d(
|
|
'Adding to playlist $playlistId with URI: ${uri.substring(0, uri.length > 100 ? 100 : uri.length)}${uri.length > 100 ? "..." : ""}',
|
|
);
|
|
final result = await _wrapBoolApiCall(
|
|
() => _http.put('/playlists/$playlistId/items', queryParameters: {'uri': uri}),
|
|
'Failed to add to playlist',
|
|
);
|
|
if (result) {
|
|
appLogger.d('Add to playlist response status: 200');
|
|
}
|
|
return result;
|
|
}
|
|
|
|
@override
|
|
Future<bool> removeFromPlaylist({required String playlistId, required MediaItem item}) {
|
|
if (item is! PlexMediaItem || item.playlistItemId == null) return Future.value(false);
|
|
return _wrapBoolApiCall(
|
|
() => _http.delete('/playlists/$playlistId/items/${item.playlistItemId}'),
|
|
'Failed to remove from playlist',
|
|
);
|
|
}
|
|
|
|
/// Plex's `?after=0` sentinel means "move to the top". For any other index
|
|
/// the API needs the playlist-item id of the row that should sit immediately
|
|
/// before [item] after the move — that's what [afterItem] provides.
|
|
@override
|
|
Future<bool> movePlaylistItem({
|
|
required String playlistId,
|
|
required MediaItem item,
|
|
required int newIndex,
|
|
required MediaItem? afterItem,
|
|
}) async {
|
|
if (item is! PlexMediaItem || item.playlistItemId == null) return false;
|
|
final int after;
|
|
if (newIndex == 0) {
|
|
after = 0;
|
|
} else if (afterItem is PlexMediaItem && afterItem.playlistItemId != null) {
|
|
after = afterItem.playlistItemId!;
|
|
} else {
|
|
return false;
|
|
}
|
|
appLogger.d('Moving playlist item ${item.playlistItemId} after $after in playlist $playlistId');
|
|
return _wrapBoolApiCall(
|
|
() => _http.put('/playlists/$playlistId/items/${item.playlistItemId}/move', queryParameters: {'after': after}),
|
|
'Failed to move playlist item',
|
|
);
|
|
}
|
|
|
|
/// Update metadata fields for a media item
|
|
Future<bool> updateMetadata({
|
|
required int sectionId,
|
|
required String ratingKey,
|
|
required int typeNumber,
|
|
String? title,
|
|
String? titleSort,
|
|
String? originalTitle,
|
|
String? originallyAvailableAt,
|
|
String? contentRating,
|
|
String? studio,
|
|
String? tagline,
|
|
String? summary,
|
|
Map<String, ({List<String> current, List<String> original})>? tagChanges,
|
|
}) {
|
|
final queryParams = <String, dynamic>{'type': typeNumber, 'id': ratingKey};
|
|
|
|
void addField(String name, String? value) {
|
|
if (value != null) {
|
|
queryParams['$name.value'] = value;
|
|
queryParams['$name.locked'] = '1';
|
|
}
|
|
}
|
|
|
|
addField('title', title);
|
|
addField('titleSort', titleSort);
|
|
addField('originalTitle', originalTitle);
|
|
addField('originallyAvailableAt', originallyAvailableAt);
|
|
addField('contentRating', contentRating);
|
|
addField('studio', studio);
|
|
addField('tagline', tagline);
|
|
addField('summary', summary);
|
|
|
|
if (tagChanges != null) {
|
|
for (final entry in tagChanges.entries) {
|
|
final field = entry.key;
|
|
final current = entry.value.current;
|
|
final original = entry.value.original;
|
|
for (var i = 0; i < current.length; i++) {
|
|
queryParams['$field[$i].tag.tag'] = current[i];
|
|
}
|
|
final removed = original.where((t) => !current.contains(t)).toList();
|
|
if (removed.isNotEmpty) {
|
|
queryParams['$field[].tag.tag-'] = removed.map(Uri.encodeComponent).join(',');
|
|
}
|
|
queryParams['$field.locked'] = '1';
|
|
}
|
|
}
|
|
|
|
return _wrapBoolApiCall(
|
|
() => _http.put('/library/sections/$sectionId/all', queryParameters: queryParams),
|
|
'Failed to update metadata',
|
|
);
|
|
}
|
|
|
|
/// Search for match candidates for a media item.
|
|
Future<List<PlexMatchResult>> findMatches(
|
|
String ratingKey, {
|
|
String? title,
|
|
String? year,
|
|
String? agent,
|
|
String? language,
|
|
}) async {
|
|
final queryParams = <String, dynamic>{'manual': 1};
|
|
if (title != null && title.isNotEmpty) queryParams['title'] = title;
|
|
if (year != null && year.isNotEmpty) queryParams['year'] = year;
|
|
if (agent != null && agent.isNotEmpty) queryParams['agent'] = agent;
|
|
if (language != null && language.isNotEmpty) queryParams['language'] = language;
|
|
|
|
return _wrapListApiCall<PlexMatchResult>(
|
|
() => _getWithFailover('/library/metadata/$ratingKey/matches', queryParameters: queryParams),
|
|
(response) {
|
|
final container = _getMediaContainer(response);
|
|
if (container == null || container['SearchResult'] == null) return [];
|
|
return (container['SearchResult'] as List)
|
|
.map((json) => PlexMatchResult.fromJson(json as Map<String, dynamic>))
|
|
.toList();
|
|
},
|
|
'Failed to search for matches',
|
|
);
|
|
}
|
|
|
|
/// Apply a chosen match to a media item.
|
|
Future<bool> applyMatch(String ratingKey, {required String guid, String? name, String? year}) async {
|
|
final queryParams = <String, dynamic>{'guid': guid};
|
|
if (name != null && name.isNotEmpty) queryParams['name'] = name;
|
|
if (year != null && year.isNotEmpty) queryParams['year'] = year;
|
|
|
|
final result = await _wrapBoolApiCall(
|
|
() => _http.put('/library/metadata/$ratingKey/match', queryParameters: queryParams),
|
|
'Failed to apply match',
|
|
);
|
|
if (result) {
|
|
await _cache.deleteForItem(serverId, ratingKey);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
Future<bool> unmatchItem(String ratingKey) async {
|
|
final result = await _wrapBoolApiCall(
|
|
() => _http.put('/library/metadata/$ratingKey/unmatch'),
|
|
'Failed to unmatch item',
|
|
);
|
|
if (result) {
|
|
await _cache.deleteForItem(serverId, ratingKey);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// Get available artwork (posters or backgrounds) for a media item
|
|
Future<List<Map<String, dynamic>>> getAvailableArtwork(String ratingKey, String element) async {
|
|
try {
|
|
final response = await _getWithFailover('/library/metadata/$ratingKey/$element');
|
|
final container = _getMediaContainer(response);
|
|
if (container != null && container['Metadata'] != null) {
|
|
return (container['Metadata'] as List).cast<Map<String, dynamic>>();
|
|
}
|
|
return [];
|
|
} catch (e) {
|
|
appLogger.e('Failed to get available artwork', error: e);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/// Set artwork from a URL (can be a Plex internal path or external URL)
|
|
Future<bool> setArtworkFromUrl(String ratingKey, String element, String url) {
|
|
final setElement = element.endsWith('s') ? element.substring(0, element.length - 1) : element;
|
|
return _wrapBoolApiCall(
|
|
() => _http.put('/library/metadata/$ratingKey/$setElement', queryParameters: {'url': url}),
|
|
'Failed to set artwork from URL',
|
|
);
|
|
}
|
|
|
|
/// Upload artwork from binary data
|
|
Future<bool> uploadArtwork(String ratingKey, String element, List<int> bytes) {
|
|
final setElement = element.endsWith('s') ? element.substring(0, element.length - 1) : element;
|
|
return _wrapBoolApiCall(
|
|
() => _http.put(
|
|
'/library/metadata/$ratingKey/$setElement',
|
|
body: bytes,
|
|
headers: {'Content-Type': 'application/octet-stream', 'Content-Length': '${bytes.length}'},
|
|
),
|
|
'Failed to upload artwork',
|
|
);
|
|
}
|
|
|
|
/// Update per-media advanced preferences
|
|
Future<bool> updateMetadataPrefs(String ratingKey, Map<String, String> prefs) {
|
|
return _wrapBoolApiCall(
|
|
() => _http.put('/library/metadata/$ratingKey/prefs', queryParameters: prefs),
|
|
'Failed to update metadata preferences',
|
|
);
|
|
}
|
|
|
|
/// Get one page of collections for a library section.
|
|
Future<_LibraryContentResult> _getLibraryCollectionsPage(
|
|
String sectionId, {
|
|
int? start,
|
|
int? size,
|
|
AbortController? abort,
|
|
}) async {
|
|
final queryParameters = _buildPaginationParams(start, size)..['includeGuids'] = 1;
|
|
final response = await _getWithFailover(
|
|
'/library/sections/$sectionId/collections',
|
|
queryParameters: queryParameters,
|
|
abort: abort,
|
|
);
|
|
final result = _extractLibraryContentResult(
|
|
response,
|
|
librarySectionID: _librarySectionIdFromString(sectionId),
|
|
start: start,
|
|
requestedSize: size,
|
|
);
|
|
return result;
|
|
}
|
|
|
|
/// Get all collections for a library section.
|
|
Future<List<PlexMetadataDto>> _getLibraryCollections(String sectionId) async {
|
|
try {
|
|
return _fetchAllPages((start, size, abort) {
|
|
return _getLibraryCollectionsPage(sectionId, start: start, size: size, abort: abort);
|
|
});
|
|
} catch (e, st) {
|
|
appLogger.e('Failed to get library collections', error: e, stackTrace: st);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/// Get items in a collection, paginated.
|
|
Future<_LibraryContentResult> _getCollectionItems(
|
|
String collectionId, {
|
|
int? start,
|
|
int? size,
|
|
AbortController? abort,
|
|
String? librarySectionID,
|
|
String? librarySectionTitle,
|
|
}) => _fetchPaginatedList(
|
|
'/library/collections/$collectionId/children',
|
|
start: start,
|
|
size: size,
|
|
abort: abort,
|
|
librarySectionID: _librarySectionIdFromString(librarySectionID),
|
|
librarySectionTitle: librarySectionTitle,
|
|
);
|
|
|
|
/// Fetch every item in a collection (downloads, sync rules, context-menu shuffle).
|
|
Future<List<PlexMetadataDto>> _fetchAllCollectionItemsDto(
|
|
String collectionId, {
|
|
String? librarySectionID,
|
|
String? librarySectionTitle,
|
|
}) => _fetchAllPages(
|
|
(start, size, abort) => _getCollectionItems(
|
|
collectionId,
|
|
start: start,
|
|
size: size,
|
|
abort: abort,
|
|
librarySectionID: librarySectionID,
|
|
librarySectionTitle: librarySectionTitle,
|
|
),
|
|
);
|
|
|
|
/// Get media featuring a specific person (actor/director), paginated.
|
|
Future<_LibraryContentResult> _getPersonMedia(String personId, {int? start, int? size, AbortController? abort}) =>
|
|
_fetchPaginatedList('/library/people/$personId/media', start: start, size: size, abort: abort);
|
|
|
|
/// Fetch every media item featuring a given person.
|
|
Future<List<PlexMetadataDto>> _fetchAllPersonMediaDto(String personId) =>
|
|
_fetchAllPages((start, size, abort) => _getPersonMedia(personId, start: start, size: size, abort: abort));
|
|
|
|
/// Delete a collection. Reads the section id from [collection.libraryId].
|
|
@override
|
|
Future<bool> deleteCollection(MediaItem collection) async {
|
|
final sectionId = collection.libraryId ?? '';
|
|
return deleteCollectionById(sectionId, collection.id);
|
|
}
|
|
|
|
Future<bool> deleteCollectionById(String sectionId, String collectionId) async {
|
|
appLogger.d('Deleting collection: sectionId=$sectionId, collectionId=$collectionId');
|
|
final result = await _wrapBoolApiCall(
|
|
() => _http.delete('/library/collections/$collectionId'),
|
|
'Failed to delete collection',
|
|
);
|
|
if (result) {
|
|
appLogger.d('Delete collection response: 200');
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// Neutral [MediaServerClient.createCollection] — builds a Plex metadata
|
|
/// URI for [items] and maps [itemKind] to Plex's section type id.
|
|
@override
|
|
Future<String?> createCollection({
|
|
required String libraryId,
|
|
required String title,
|
|
required List<MediaItem> items,
|
|
MediaKind? itemKind,
|
|
}) async {
|
|
final uri = items.isEmpty ? '' : await buildMetadataUri(items.map((i) => i.id).join(','));
|
|
final type = switch (itemKind) {
|
|
MediaKind.movie => 1,
|
|
MediaKind.show => 2,
|
|
MediaKind.season => 3,
|
|
MediaKind.episode => 4,
|
|
_ => null,
|
|
};
|
|
return createCollectionFromUri(sectionId: libraryId, title: title, uri: uri, type: type);
|
|
}
|
|
|
|
/// Create a new collection
|
|
/// Creates a new collection and optionally adds items to it
|
|
/// Returns the created collection ID or null if failed
|
|
Future<String?> createCollectionFromUri({
|
|
required String sectionId,
|
|
required String title,
|
|
required String uri,
|
|
int? type,
|
|
}) async {
|
|
try {
|
|
appLogger.d('Creating collection: sectionId=$sectionId, title=$title, type=$type');
|
|
final response = await _http.post(
|
|
'/library/collections',
|
|
queryParameters: {'type': ?type, 'title': title, 'smart': 0, 'sectionId': sectionId, 'uri': uri},
|
|
);
|
|
appLogger.d('Create collection response: ${response.statusCode}');
|
|
|
|
// Extract the collection ID from the response
|
|
// The response should contain the created collection metadata
|
|
final container = _getMediaContainer(response);
|
|
if (container != null) {
|
|
final metadata = container['Metadata'];
|
|
if (metadata != null && (metadata as List).isNotEmpty) {
|
|
final collectionId = metadata.first['ratingKey']?.toString();
|
|
appLogger.d('Created collection with ID: $collectionId');
|
|
return collectionId;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
} catch (e) {
|
|
appLogger.e('Failed to create collection', error: e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Neutral [MediaServerClient.addToCollection] — builds a Plex metadata URI
|
|
/// from [items] and delegates to [addItemsToCollectionByUri].
|
|
@override
|
|
Future<bool> addToCollection({required String collectionId, required List<MediaItem> items}) async {
|
|
if (items.isEmpty) return true;
|
|
final uri = await buildMetadataUri(items.map((i) => i.id).join(','));
|
|
return addItemsToCollectionByUri(collectionId: collectionId, uri: uri);
|
|
}
|
|
|
|
/// Add items to an existing collection
|
|
/// Adds one or more items (specified by URI) to an existing collection
|
|
Future<bool> addItemsToCollectionByUri({required String collectionId, required String uri}) async {
|
|
appLogger.d('Adding items to collection: collectionId=$collectionId');
|
|
final result = await _wrapBoolApiCall(
|
|
() => _http.put('/library/collections/$collectionId/items', queryParameters: {'uri': uri}),
|
|
'Failed to add items to collection',
|
|
);
|
|
if (result) {
|
|
appLogger.d('Add to collection response: 200');
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// Remove an item from a collection
|
|
/// Removes a single item from an existing collection
|
|
@override
|
|
Future<bool> removeFromCollection({required String collectionId, required MediaItem item}) async {
|
|
appLogger.d('Removing item from collection: collectionId=$collectionId, itemId=${item.id}');
|
|
final result = await _wrapBoolApiCall(
|
|
() => _http.delete('/library/collections/$collectionId/items/${item.id}'),
|
|
'Failed to remove item from collection',
|
|
);
|
|
if (result) {
|
|
appLogger.d('Remove from collection response: 200');
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/// Parse a `/playQueues/{id}` response into a [PlayQueueResponse] with
|
|
/// MediaItem-typed entries.
|
|
PlayQueueResponse _parsePlayQueueResponse(dynamic data, {int? librarySectionID, String? librarySectionTitle}) {
|
|
final container = data is Map && data['MediaContainer'] is Map
|
|
? data['MediaContainer'] as Map<String, dynamic>
|
|
: data as Map<String, dynamic>;
|
|
final containerSectionID = _librarySectionIdFromJson(container) ?? librarySectionID;
|
|
final containerSectionTitle = _librarySectionTitleFromJson(container) ?? librarySectionTitle;
|
|
final metadata = container['Metadata'];
|
|
List<MediaItem>? items;
|
|
if (metadata is List) {
|
|
items = [
|
|
for (final e in metadata)
|
|
if (e is Map<String, dynamic>)
|
|
PlexMappers.mediaItem(
|
|
_createTaggedMetadataWithLibrary(
|
|
e,
|
|
librarySectionID: containerSectionID,
|
|
librarySectionTitle: containerSectionTitle,
|
|
),
|
|
),
|
|
];
|
|
}
|
|
return PlayQueueResponse(
|
|
playQueueID: (container['playQueueID'] as num).toInt(),
|
|
playQueueSelectedItemID: (container['playQueueSelectedItemID'] as num?)?.toInt(),
|
|
playQueueSelectedItemOffset: (container['playQueueSelectedItemOffset'] as num?)?.toInt(),
|
|
playQueueSelectedMetadataItemID: container['playQueueSelectedMetadataItemID'] as String?,
|
|
playQueueShuffled: flexibleBool(container['playQueueShuffled']),
|
|
playQueueSourceURI: container['playQueueSourceURI'] as String?,
|
|
playQueueTotalCount: (container['playQueueTotalCount'] as num?)?.toInt(),
|
|
playQueueVersion: (container['playQueueVersion'] as num).toInt(),
|
|
size: (container['size'] as num?)?.toInt(),
|
|
items: items,
|
|
);
|
|
}
|
|
|
|
/// Create a new play queue
|
|
/// Either uri or playlistID must be specified
|
|
Future<PlayQueueResponse?> createPlayQueue({
|
|
String? uri,
|
|
int? playlistID,
|
|
required String type,
|
|
String? key,
|
|
int shuffle = 0,
|
|
int repeat = 0,
|
|
int continuous = 0,
|
|
String? librarySectionID,
|
|
String? librarySectionTitle,
|
|
}) async {
|
|
try {
|
|
final queryParams = <String, dynamic>{
|
|
'type': type,
|
|
'shuffle': shuffle,
|
|
'repeat': repeat,
|
|
'continuous': continuous,
|
|
};
|
|
|
|
if (uri != null) {
|
|
queryParams['uri'] = uri;
|
|
}
|
|
if (playlistID != null) {
|
|
queryParams['playlistID'] = playlistID;
|
|
}
|
|
if (key != null) {
|
|
queryParams['key'] = key;
|
|
}
|
|
|
|
final response = await _http.post('/playQueues', queryParameters: queryParams);
|
|
|
|
return _parsePlayQueueResponse(
|
|
response.data,
|
|
librarySectionID: _librarySectionIdFromString(librarySectionID),
|
|
librarySectionTitle: librarySectionTitle,
|
|
);
|
|
} catch (e) {
|
|
appLogger.e('Failed to create play queue', error: e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Get a play queue with optional windowing
|
|
/// Can request a window of items around a specific item
|
|
Future<PlayQueueResponse?> getPlayQueue(
|
|
int playQueueId, {
|
|
String? center,
|
|
int window = 50,
|
|
int includeBefore = 1,
|
|
int includeAfter = 1,
|
|
String? librarySectionID,
|
|
String? librarySectionTitle,
|
|
}) async {
|
|
try {
|
|
final queryParams = <String, dynamic>{
|
|
'window': window,
|
|
'includeBefore': includeBefore,
|
|
'includeAfter': includeAfter,
|
|
};
|
|
|
|
if (center != null) {
|
|
queryParams['center'] = center;
|
|
}
|
|
|
|
final response = await _getWithFailover('/playQueues/$playQueueId', queryParameters: queryParams);
|
|
|
|
return _parsePlayQueueResponse(
|
|
response.data,
|
|
librarySectionID: _librarySectionIdFromString(librarySectionID),
|
|
librarySectionTitle: librarySectionTitle,
|
|
);
|
|
} catch (e) {
|
|
appLogger.e('Failed to get play queue: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Create a play queue for a TV show (all episodes)
|
|
///
|
|
/// This is a convenience method that creates a play queue from a show's URI.
|
|
/// Perfect for sequential or shuffle playback of an entire series.
|
|
///
|
|
/// Parameters:
|
|
/// - [showRatingKey]: The rating key of the show
|
|
/// - [shuffle]: Whether to shuffle the episodes (0 = off, 1 = on)
|
|
/// - [startingEpisodeKey]: Optional rating key of episode to start from
|
|
///
|
|
/// Returns a PlayQueueResponse with all episodes from the show
|
|
Future<PlayQueueResponse?> createShowPlayQueue({
|
|
required String showRatingKey,
|
|
int shuffle = 0,
|
|
String? startingEpisodeKey,
|
|
String? librarySectionID,
|
|
String? librarySectionTitle,
|
|
}) async {
|
|
try {
|
|
final machineId = config.machineIdentifier ?? await getMachineIdentifier();
|
|
if (machineId == null) {
|
|
throw Exception('Could not get server machine identifier');
|
|
}
|
|
|
|
final uri = 'server://$machineId/com.plexapp.plugins.library/library/metadata/$showRatingKey/children';
|
|
return await createPlayQueue(
|
|
uri: uri,
|
|
type: 'video',
|
|
shuffle: shuffle,
|
|
key: startingEpisodeKey != null ? '/library/metadata/$startingEpisodeKey' : null,
|
|
continuous: startingEpisodeKey != null && shuffle == 0 ? 1 : 0,
|
|
librarySectionID: librarySectionID,
|
|
librarySectionTitle: librarySectionTitle,
|
|
);
|
|
} catch (e) {
|
|
appLogger.e('Failed to create show play queue', error: e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// Extract both Metadata and Directory entries from response
|
|
/// Folders can come back as either type
|
|
/// Automatically tags all items with this client's serverId and serverName
|
|
List<PlexMetadataDto> _extractMetadataAndDirectories(
|
|
MediaServerResponse response, {
|
|
int? librarySectionID,
|
|
String? librarySectionTitle,
|
|
}) {
|
|
final List<PlexMetadataDto> items = [];
|
|
final container = _getMediaContainer(response);
|
|
|
|
if (container != null) {
|
|
final containerSectionID = _librarySectionIdFromJson(container) ?? librarySectionID;
|
|
final containerSectionTitle = _librarySectionTitleFromJson(container) ?? librarySectionTitle;
|
|
// Extract Metadata entries - try full parsing first
|
|
if (container['Metadata'] != null) {
|
|
for (final json in container['Metadata'] as List) {
|
|
try {
|
|
// Try to parse with full PlexMetadataDto.fromJson first
|
|
items.add(
|
|
_createTaggedMetadataWithLibrary(
|
|
json as Map<String, dynamic>,
|
|
librarySectionID: containerSectionID,
|
|
librarySectionTitle: containerSectionTitle,
|
|
),
|
|
);
|
|
} catch (e) {
|
|
// If full parsing fails, use minimal safe parsing
|
|
appLogger.d('Using minimal parsing for metadata item: $e');
|
|
try {
|
|
items.add(
|
|
PlexMetadataDto(
|
|
ratingKey: json['key'] ?? json['ratingKey'] ?? '',
|
|
key: json['key'] ?? '',
|
|
type: json['type'] ?? 'folder',
|
|
title: json['title'] ?? 'Untitled',
|
|
thumb: json['thumb'],
|
|
art: json['art'],
|
|
year: json['year'],
|
|
librarySectionID: _librarySectionIdFromJson(json) ?? containerSectionID,
|
|
librarySectionTitle: _librarySectionTitleFromJson(json) ?? containerSectionTitle,
|
|
serverId: serverId,
|
|
serverName: serverName,
|
|
),
|
|
);
|
|
} catch (e2) {
|
|
appLogger.e('Failed to parse metadata item: $e2');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract Directory entries (folders)
|
|
if (container['Directory'] != null) {
|
|
for (final json in container['Directory'] as List) {
|
|
try {
|
|
// Try to parse as PlexMetadataDto first
|
|
items.add(
|
|
_createTaggedMetadataWithLibrary(
|
|
json as Map<String, dynamic>,
|
|
librarySectionID: containerSectionID,
|
|
librarySectionTitle: containerSectionTitle,
|
|
),
|
|
);
|
|
} catch (e) {
|
|
// If that fails, use minimal folder representation
|
|
try {
|
|
items.add(
|
|
PlexMetadataDto(
|
|
ratingKey: json['key'] ?? json['ratingKey'] ?? '',
|
|
key: json['key'] ?? '',
|
|
type: json['type'] ?? 'folder',
|
|
title: json['title'] ?? 'Untitled',
|
|
thumb: json['thumb'],
|
|
art: json['art'],
|
|
librarySectionID: _librarySectionIdFromJson(json) ?? containerSectionID,
|
|
librarySectionTitle: _librarySectionTitleFromJson(json) ?? containerSectionTitle,
|
|
serverId: serverId,
|
|
serverName: serverName,
|
|
),
|
|
);
|
|
} catch (e2) {
|
|
appLogger.e('Failed to parse directory item: $e2');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
/// Get root folders for a library section
|
|
/// Returns the top-level folder structure for filesystem-based browsing
|
|
Future<List<PlexMetadataDto>> _getLibraryFolders(String sectionId) async {
|
|
try {
|
|
final response = await _getWithFailover(
|
|
'/library/sections/$sectionId/folder',
|
|
queryParameters: {'includeCollections': 0},
|
|
);
|
|
return _extractMetadataAndDirectories(response, librarySectionID: _librarySectionIdFromString(sectionId));
|
|
} catch (e) {
|
|
appLogger.e('Failed to get library folders: $e');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/// Get children of a specific folder
|
|
/// Returns files and subfolders within the given folder
|
|
Future<List<PlexMetadataDto>> _getFolderChildren(
|
|
String folderKey, {
|
|
String? librarySectionID,
|
|
String? librarySectionTitle,
|
|
}) async {
|
|
try {
|
|
final response = await _getWithFailover(folderKey);
|
|
return _extractMetadataAndDirectories(
|
|
response,
|
|
librarySectionID: _librarySectionIdFromString(folderKey) ?? _librarySectionIdFromString(librarySectionID),
|
|
librarySectionTitle: librarySectionTitle,
|
|
);
|
|
} catch (e) {
|
|
appLogger.e('Failed to get folder children: $e');
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/// Get library-specific playlists
|
|
/// Filters playlists by checking if they contain items from the specified library
|
|
/// This is a client-side filter since the API doesn't support sectionId for playlists
|
|
Future<List<PlexPlaylistDto>> _getLibraryPlaylists({String playlistType = 'video'}) {
|
|
// For now, return all video playlists
|
|
// Future enhancement: filter by checking playlist items' library
|
|
return _getPlaylists(playlistType: playlistType);
|
|
}
|
|
|
|
/// 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 {
|
|
await _http.put('/library/sections/$sectionId/emptyTrash');
|
|
}
|
|
|
|
/// Analyze library section
|
|
Future<void> analyzeLibrary(String sectionId) async {
|
|
await _getWithFailover('/library/sections/$sectionId/analyze');
|
|
}
|
|
|
|
/// Generate 24-char random alphanumeric string. Backend-neutral helper —
|
|
/// prefer importing `utils/session_identifier.dart` directly. This thin
|
|
/// forwarder stays for callers that already had a `PlexClient.` reference;
|
|
/// remove it once they migrate.
|
|
static String generateSessionIdentifier() => session_id.generateSessionIdentifier();
|
|
|
|
/// Coerce String values to num for fields that json_serializable expects as num.
|
|
/// Plex tune responses use XML-to-JSON conversion where all values are strings.
|
|
static void _coerceNumericFields(Map<String, dynamic> json) {
|
|
const numericKeys = [
|
|
'duration',
|
|
'year',
|
|
'addedAt',
|
|
'updatedAt',
|
|
'lastViewedAt',
|
|
'parentIndex',
|
|
'index',
|
|
'viewOffset',
|
|
'viewCount',
|
|
'leafCount',
|
|
'viewedLeafCount',
|
|
'childCount',
|
|
'rating',
|
|
'audienceRating',
|
|
'userRating',
|
|
'ratingCount',
|
|
'skipCount',
|
|
'lastRatedAt',
|
|
];
|
|
for (final key in numericKeys) {
|
|
final val = json[key];
|
|
if (val is String) {
|
|
json[key] = num.tryParse(val);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Checks whether the server has video transcoding enabled.
|
|
///
|
|
/// Reads `transcoderVideo` from the root MediaContainer. Result is cached
|
|
/// for the lifetime of this [PlexClient]. Returns `true` on error (fail-open)
|
|
/// — the transcode decision call itself will fail gracefully if transcoding
|
|
/// really is unavailable.
|
|
Future<bool> serverSupportsVideoTranscoding() {
|
|
final cached = _serverTranscoderCached;
|
|
if (cached != null) return Future.value(cached);
|
|
return _serverTranscoderPending ??= _fetchTranscoderCapability();
|
|
}
|
|
|
|
/// Synchronous view of the probe — returns the cached value, or `true`
|
|
/// (assume supported) if the post-connect warm-up hasn't landed yet. The
|
|
/// transcode decision call has its own fallback path, so guessing wrong
|
|
/// here just routes through that fallback instead of blocking playback.
|
|
bool get serverSupportsVideoTranscodingCached => _serverTranscoderCached ?? true;
|
|
|
|
Future<bool> _fetchTranscoderCapability() async {
|
|
try {
|
|
// Tight timeout: `/` returns a tiny MediaContainer — any responsive
|
|
// server answers in well under a second. Inheriting the default 120 s
|
|
// receive timeout would keep a hung server from ever resolving.
|
|
final response = await _http.get('/', timeout: const Duration(seconds: 5));
|
|
final container = _getMediaContainer(response);
|
|
final value = container?['transcoderVideo'];
|
|
final supported = _parsePlexTranscoderVideoCapability(value) ?? true;
|
|
_serverTranscoderCached = supported;
|
|
return supported;
|
|
} catch (e) {
|
|
appLogger.w('Failed to query server transcoder capability', error: e);
|
|
_serverTranscoderCached = true;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/// Build a VOD transcode stream URL (decision + start path).
|
|
///
|
|
/// Mirrors [buildLiveStreamPath] but for on-demand video with a quality
|
|
/// preset, selected audio stream, and Plex Desktop-style HTTP/MKV output.
|
|
/// Text subtitles selected on the Plex part are embedded in the MKV stream;
|
|
/// real external sidecars are still attached separately by callers.
|
|
///
|
|
/// [transcodeSessionId] and [sessionIdentifier] should be reused across
|
|
/// seeks + quality/version/audio switches within one playback so the
|
|
/// server-side transcode session is preserved.
|
|
Future<({String? startPath, TranscodeDecisionOutcome outcome})> buildTranscodeStartPath({
|
|
required String ratingKey,
|
|
required int mediaIndex,
|
|
int partIndex = 0,
|
|
required TranscodeQualityPreset preset,
|
|
required String sessionIdentifier,
|
|
required String transcodeSessionId,
|
|
int? audioStreamId,
|
|
MediaSubtitleTrack? selectedSubtitleTrack,
|
|
int? offsetMs,
|
|
}) async {
|
|
try {
|
|
final allParams = _buildTranscodeParams(
|
|
ratingKey: ratingKey,
|
|
mediaIndex: mediaIndex,
|
|
partIndex: partIndex,
|
|
preset: preset,
|
|
sessionIdentifier: sessionIdentifier,
|
|
transcodeSessionId: transcodeSessionId,
|
|
audioStreamId: audioStreamId,
|
|
selectedSubtitleTrack: selectedSubtitleTrack,
|
|
offsetMs: offsetMs,
|
|
);
|
|
|
|
final queryString = allParams.entries.map((e) => '${_plexEncode(e.key)}=${_plexEncode(e.value)}').join('&');
|
|
|
|
final decisionClient = MediaServerHttpClient(
|
|
connectTimeout: MediaServerTimeouts.connect,
|
|
receiveTimeout: MediaServerTimeouts.receive,
|
|
defaultHeaders: const {'Accept-Language': 'en', 'Accept': 'application/json'},
|
|
);
|
|
try {
|
|
final decisionUrl = '${config.baseUrl}/video/:/transcode/universal/decision?$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: preset.isOriginal);
|
|
if (outcome == TranscodeDecisionOutcome.failed) {
|
|
return (startPath: null, outcome: outcome);
|
|
}
|
|
|
|
return (startPath: _buildTranscodeStartPathFromParams(allParams), outcome: outcome);
|
|
} finally {
|
|
decisionClient.close();
|
|
}
|
|
} catch (e, st) {
|
|
appLogger.e('Failed to build transcode start path', error: e, stackTrace: st);
|
|
return (startPath: null, outcome: TranscodeDecisionOutcome.failed);
|
|
}
|
|
}
|
|
|
|
String _buildTranscodeStartPathFromParams(Map<String, String> params) {
|
|
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 '/video/:/transcode/universal/start?$startQuery';
|
|
}
|
|
|
|
@visibleForTesting
|
|
String buildTranscodeStartPathFromParamsForTesting(Map<String, String> params) {
|
|
return _buildTranscodeStartPathFromParams(params);
|
|
}
|
|
|
|
Map<String, String> _buildTranscodeParams({
|
|
required String ratingKey,
|
|
required int mediaIndex,
|
|
int partIndex = 0,
|
|
required TranscodeQualityPreset preset,
|
|
required String sessionIdentifier,
|
|
required String transcodeSessionId,
|
|
int? audioStreamId,
|
|
MediaSubtitleTrack? selectedSubtitleTrack,
|
|
int? offsetMs,
|
|
}) {
|
|
final isOriginal = preset.isOriginal;
|
|
final selectedEmbeddedTextSubtitle = _shouldEmbedSubtitleInHttpTranscode(selectedSubtitleTrack)
|
|
? selectedSubtitleTrack
|
|
: null;
|
|
|
|
// Build the client profile from scratch via X-Plex-Client-Profile-Extra.
|
|
// We use the `Generic` base platform (see [_transcodePlatformName]) which
|
|
// has no pre-installed transcode targets, so we must `add-transcode-target`
|
|
// rather than `append-transcode-target-codec` (which only edits existing
|
|
// targets — empty on Generic, hence Plex returned decision code 2000
|
|
// "neither direct play nor conversion is available").
|
|
//
|
|
// For non-original presets we also add a bitrate limitation that caps
|
|
// the video codec; with `replace=true` it overrides any default limit.
|
|
//
|
|
// See openapi.md §"Profile Augmentations" for the DSL reference.
|
|
final profileExtraClauses = <String>['add-settings(DirectPlayStreamSelection=true)'];
|
|
if (!isOriginal && preset.videoBitrateKbps != null) {
|
|
profileExtraClauses.add(
|
|
'add-limitation(scope=videoCodec&scopeName=*&type=upperBound'
|
|
'&name=video.bitrate&value=${preset.videoBitrateKbps}&replace=true)',
|
|
);
|
|
}
|
|
// Match Plex Desktop's stable HTTP/MKV transcode target. Codec-list commas
|
|
// are pre-encoded as `%2C` — see the profile-extra encoding note above.
|
|
profileExtraClauses.add(
|
|
'add-transcode-target(type=videoProfile&context=streaming'
|
|
'&protocol=http&container=mkv&videoCodec=h264%2Chevc%2C*'
|
|
'&audioCodec=opus%2Cvorbis%2Cflac%2C*&subtitleCodec=ass%2Cpgs%2Cvobsub%2C*)',
|
|
);
|
|
profileExtraClauses.add(
|
|
'add-transcode-target-settings(type=videoProfile&context=streaming'
|
|
'&protocol=http&CopyMatroskaAttachments=true)',
|
|
);
|
|
final clientProfileExtra = profileExtraClauses.join('+');
|
|
|
|
// HTTP/MKV matches Plex Desktop and lets MPV see embedded subtitle streams.
|
|
// HLS `subtitles=segmented` was accepted by Plex but produced manifests
|
|
// with only video/audio renditions for MPV.
|
|
return <String, String>{
|
|
'hasMDE': '1',
|
|
'path': '/library/metadata/$ratingKey',
|
|
'mediaIndex': mediaIndex.toString(),
|
|
'partIndex': partIndex.toString(),
|
|
'protocol': 'http',
|
|
'fastSeek': '1',
|
|
'directPlay': isOriginal ? '1' : '0',
|
|
'directStream': isOriginal ? '1' : '0',
|
|
'subtitleSize': '100',
|
|
'audioBoost': '100',
|
|
'location': 'lan',
|
|
if (!isOriginal && preset.videoBitrateKbps != null) 'maxVideoBitrate': preset.videoBitrateKbps.toString(),
|
|
'addDebugOverlay': '0',
|
|
'autoAdjustQuality': '0',
|
|
'directStreamAudio': '0',
|
|
'mediaBufferSize': '102400',
|
|
'session': transcodeSessionId,
|
|
// Embed selected text subtitles in the MKV stream. Bitmap subtitles and
|
|
// unselected tracks stay at `none` so the server cannot burn them into
|
|
// the video.
|
|
'subtitles': selectedEmbeddedTextSubtitle != null ? 'embedded' : 'none',
|
|
if (selectedEmbeddedTextSubtitle != null) 'subtitleStreamID': selectedEmbeddedTextSubtitle.id.toString(),
|
|
if (selectedEmbeddedTextSubtitle != null) 'advancedSubtitles': 'text',
|
|
// Preserve source timestamps for the HTTP/MKV stream so player seeks and
|
|
// sidecar subtitles stay aligned with Plex source time.
|
|
'copyts': '1',
|
|
if (audioStreamId != null) 'audioStreamID': audioStreamId.toString(),
|
|
'Accept-Language': 'en',
|
|
'X-Plex-Session-Identifier': sessionIdentifier,
|
|
'X-Plex-Client-Profile-Extra': clientProfileExtra,
|
|
'X-Plex-Chunked': '1',
|
|
'X-Plex-Features': 'external-media,indirect-media',
|
|
'X-Plex-Model': 'standalone',
|
|
'X-Plex-Language': 'en',
|
|
'X-Plex-Product': config.product,
|
|
'X-Plex-Version': config.version,
|
|
'X-Plex-Client-Identifier': config.clientIdentifier,
|
|
// Plex's server rejects unknown platform names with HTTP 400 and maps
|
|
// known names to codec/bitrate base profiles. Our usual "Flutter"
|
|
// platform, plus "MacOSX" / "Linux", are all rejected; swap to a
|
|
// Plex-recognized name just for transcode requests. See
|
|
// [_transcodePlatformName] for the mapping.
|
|
'X-Plex-Platform': _transcodePlatformName(),
|
|
if (config.device != null) 'X-Plex-Device': config.device!,
|
|
if (offsetMs != null) 'offset': (offsetMs ~/ 1000).toString(),
|
|
if (config.token != null) 'X-Plex-Token': config.token!,
|
|
};
|
|
}
|
|
|
|
@visibleForTesting
|
|
Map<String, String> buildTranscodeParamsForTesting({
|
|
required String ratingKey,
|
|
required int mediaIndex,
|
|
int partIndex = 0,
|
|
required TranscodeQualityPreset preset,
|
|
required String sessionIdentifier,
|
|
required String transcodeSessionId,
|
|
int? audioStreamId,
|
|
MediaSubtitleTrack? selectedSubtitleTrack,
|
|
int? offsetMs,
|
|
}) {
|
|
return _buildTranscodeParams(
|
|
ratingKey: ratingKey,
|
|
mediaIndex: mediaIndex,
|
|
partIndex: partIndex,
|
|
preset: preset,
|
|
sessionIdentifier: sessionIdentifier,
|
|
transcodeSessionId: transcodeSessionId,
|
|
audioStreamId: audioStreamId,
|
|
selectedSubtitleTrack: selectedSubtitleTrack,
|
|
offsetMs: offsetMs,
|
|
);
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
}
|
|
|
|
Future<void> _handleEndpointSwitch(String newBaseUrl, {bool persist = true}) async {
|
|
if (config.baseUrl == newBaseUrl) {
|
|
return;
|
|
}
|
|
|
|
appLogger.i('Applying Plex endpoint switch', error: newBaseUrl);
|
|
_http.baseUrl = newBaseUrl;
|
|
config = config.copyWith(baseUrl: newBaseUrl);
|
|
LogRedactionManager.registerServerUrl(newBaseUrl);
|
|
|
|
if (persist && _onEndpointChanged != null) {
|
|
await _onEndpointChanged(newBaseUrl);
|
|
}
|
|
}
|
|
|
|
/// 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();
|
|
}
|
|
|
|
// ────────────────────────────────────────────────────────────────────
|
|
// 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<List<MediaItem>> fetchPlayableDescendants(String parentId) async {
|
|
final leaves = await _fetchAllPages(
|
|
(start, size, abort) => _getGrandchildrenPage(parentId, start: start, size: size, abort: abort),
|
|
);
|
|
return leaves.map((m) => PlexMappers.mediaItem(m)).toList();
|
|
}
|
|
|
|
@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 playback resolution. Reuses [getVideoPlaybackData] for metadata,
|
|
/// then either runs the transcode-decision flow or returns the direct-play
|
|
/// URL. External subtitle tracks are absolutized with the server's auth
|
|
/// token; when transcoding, keyed sidecars stay external and selected
|
|
/// embedded text subtitles are embedded in the HTTP/MKV stream so subtitles
|
|
/// are never burned in.
|
|
@override
|
|
Future<PlaybackInitializationResult> getPlaybackInitialization(PlaybackInitializationOptions options) async {
|
|
try {
|
|
final data = await getVideoPlaybackData(options.metadata.id, mediaIndex: options.selectedMediaIndex);
|
|
|
|
if (!data.hasValidVideoUrl) {
|
|
throw PlaybackException(t.messages.fileInfoNotAvailable);
|
|
}
|
|
|
|
final wantTranscode = !options.qualityPreset.isOriginal;
|
|
if (wantTranscode && options.sessionIdentifier != null && options.transcodeSessionId != null) {
|
|
final resolvedAudioId = _resolveAudioStreamId(options.selectedAudioStreamId, data.mediaInfo);
|
|
final resumeOffsetMs = options.metadata.viewOffsetMs;
|
|
final selectedSubtitleTrack = _selectedSubtitleTrack(data.mediaInfo);
|
|
final result = await buildTranscodeStartPath(
|
|
ratingKey: options.metadata.id,
|
|
mediaIndex: data.selectedMediaIndex,
|
|
partIndex: data.selectedPartIndex,
|
|
preset: options.qualityPreset,
|
|
sessionIdentifier: options.sessionIdentifier!,
|
|
transcodeSessionId: options.transcodeSessionId!,
|
|
audioStreamId: resolvedAudioId,
|
|
selectedSubtitleTrack: selectedSubtitleTrack,
|
|
offsetMs: resumeOffsetMs != null && resumeOffsetMs > 0 ? resumeOffsetMs : null,
|
|
);
|
|
|
|
if (result.outcome == TranscodeDecisionOutcome.transcodeOk && result.startPath != null) {
|
|
final transcodeUrl = '${config.baseUrl}${result.startPath}'.withPlexToken(config.token);
|
|
final sidecarSubs = _buildTranscodeSidecarSubtitles(data.mediaInfo);
|
|
return PlaybackInitializationResult(
|
|
availableVersions: data.availableVersions,
|
|
videoUrl: transcodeUrl,
|
|
mediaInfo: data.mediaInfo,
|
|
externalSubtitles: sidecarSubs,
|
|
isOffline: false,
|
|
isTranscoding: true,
|
|
activeAudioStreamId: resolvedAudioId,
|
|
playMethod: 'Transcode',
|
|
);
|
|
}
|
|
|
|
// Decision failed or said direct-play only — fall through to direct-play path
|
|
// and surface the fallback reason so the UI can notify the user.
|
|
final fallbackReason = result.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',
|
|
);
|
|
}
|
|
|
|
return PlaybackInitializationResult(
|
|
availableVersions: data.availableVersions,
|
|
videoUrl: data.videoUrl,
|
|
mediaInfo: data.mediaInfo,
|
|
externalSubtitles: _buildExternalSubtitles(data.mediaInfo),
|
|
isOffline: false,
|
|
playMethod: 'DirectPlay',
|
|
);
|
|
} catch (e) {
|
|
if (e is PlaybackException) rethrow;
|
|
throw PlaybackException(t.messages.errorLoading(error: e.toString()));
|
|
}
|
|
}
|
|
|
|
/// Pick the audio stream ID to send to the transcoder. Preference order:
|
|
/// explicit [explicit] → audio track with `selected == true` → first → null.
|
|
int? _resolveAudioStreamId(int? explicit, MediaSourceInfo? info) {
|
|
if (explicit != null) return explicit;
|
|
if (info == null) return null;
|
|
final tracks = info.audioTracks;
|
|
if (tracks.isEmpty) return null;
|
|
for (final track in tracks) {
|
|
if (track.selected) return track.id;
|
|
}
|
|
return tracks.first.id;
|
|
}
|
|
|
|
MediaSubtitleTrack? _selectedSubtitleTrack(MediaSourceInfo? info) {
|
|
if (info == null) return null;
|
|
for (final track in info.subtitleTracks) {
|
|
if (track.selected) return track;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Build the absolute URL for an external subtitle track on this Plex
|
|
/// server. Returns `null` for tracks that aren't external (no `/library/
|
|
/// streams/{id}` key) or when the server has no auth token.
|
|
///
|
|
/// Used by the in-player OpenSubtitles polling flow which needs the URL
|
|
/// after the new track shows up in the metadata response.
|
|
String? buildExternalSubtitleUrl(MediaSubtitleTrack track) {
|
|
if (!track.isExternal || track.key == null || track.key!.isEmpty) return null;
|
|
final token = config.token;
|
|
if (token == null) return null;
|
|
final ext = CodecUtils.getSubtitleExtension(track.codec);
|
|
return '${config.baseUrl}${track.key}.$ext?encoding=utf-8&X-Plex-Token=$token';
|
|
}
|
|
|
|
/// Raw sidecar URL for real sidecar subtitle streams. Plex returns 501 for
|
|
/// `/library/streams/{id}.{ext}` when the stream is embedded, so a Plex
|
|
/// `Stream.key` is required here.
|
|
String? _buildSidecarSubtitleUrl(MediaSubtitleTrack track) {
|
|
if (track.key == null || track.key!.isEmpty) return null;
|
|
final token = config.token;
|
|
if (token == null) return null;
|
|
final ext = CodecUtils.getSubtitleExtension(track.codec);
|
|
return '${config.baseUrl}${track.key}.$ext?encoding=utf-8&X-Plex-Token=$token';
|
|
}
|
|
|
|
bool _canTranscodeSubtitleAsText(MediaSubtitleTrack track) {
|
|
return CodecUtils.isTextSubtitleCodec(track.codec);
|
|
}
|
|
|
|
bool _shouldEmbedSubtitleInHttpTranscode(MediaSubtitleTrack? track) {
|
|
if (track == null) return false;
|
|
if (track.key != null && track.key!.isNotEmpty) return false;
|
|
return _canTranscodeSubtitleAsText(track);
|
|
}
|
|
|
|
SubtitleTrack _subtitleTrackFromMediaTrack(MediaSubtitleTrack track, String url) {
|
|
return SubtitleTrack(
|
|
id: 'external:$url',
|
|
title: track.displayTitle ?? track.title ?? track.language ?? 'Track ${track.id}',
|
|
language: track.languageCode,
|
|
codec: track.codec,
|
|
isDefault: track.selected,
|
|
isForced: track.forced,
|
|
isExternal: true,
|
|
uri: url,
|
|
);
|
|
}
|
|
|
|
/// Build subtitle sidecars for Plex transcode playback. Only real keyed
|
|
/// sidecars are loaded externally; selected embedded text subtitles are
|
|
/// carried by the main HTTP/MKV stream.
|
|
List<SubtitleTrack> _buildTranscodeSidecarSubtitles(MediaSourceInfo? mediaInfo) {
|
|
if (mediaInfo == null) return const [];
|
|
if (config.token == null) {
|
|
appLogger.w('No auth token available for transcode sidecar subtitles');
|
|
return const [];
|
|
}
|
|
|
|
final tracks = <SubtitleTrack>[];
|
|
for (final sub in mediaInfo.subtitleTracks) {
|
|
try {
|
|
final url = _buildSidecarSubtitleUrl(sub);
|
|
if (url == null) continue;
|
|
tracks.add(_subtitleTrackFromMediaTrack(sub, url));
|
|
} catch (e) {
|
|
appLogger.w('Failed to build sidecar subtitle for stream ${sub.id}', error: e);
|
|
}
|
|
}
|
|
return tracks;
|
|
}
|
|
|
|
@visibleForTesting
|
|
List<SubtitleTrack> buildTranscodeSidecarSubtitlesForTesting(MediaSourceInfo? mediaInfo) {
|
|
return _buildTranscodeSidecarSubtitles(mediaInfo);
|
|
}
|
|
|
|
/// Build list of external subtitle tracks from media info
|
|
List<SubtitleTrack> _buildExternalSubtitles(MediaSourceInfo? mediaInfo) {
|
|
final externalSubtitles = <SubtitleTrack>[];
|
|
|
|
if (mediaInfo == null) {
|
|
return externalSubtitles;
|
|
}
|
|
|
|
final externalTracks = mediaInfo.subtitleTracks.where((MediaSubtitleTrack track) => track.isExternal).toList();
|
|
|
|
if (externalTracks.isNotEmpty) {
|
|
appLogger.d('Found ${externalTracks.length} external subtitle track(s)');
|
|
}
|
|
|
|
for (final plexTrack in externalTracks) {
|
|
try {
|
|
final url = buildExternalSubtitleUrl(plexTrack);
|
|
if (url == null) {
|
|
appLogger.w('Could not build URL for external subtitle ${plexTrack.id}');
|
|
continue;
|
|
}
|
|
|
|
externalSubtitles.add(
|
|
SubtitleTrack.uri(
|
|
url,
|
|
title: plexTrack.displayTitle ?? plexTrack.title ?? plexTrack.language ?? 'Track ${plexTrack.id}',
|
|
language: plexTrack.languageCode,
|
|
codec: plexTrack.codec,
|
|
isDefault: plexTrack.selected,
|
|
isForced: plexTrack.forced,
|
|
),
|
|
);
|
|
} catch (e) {
|
|
appLogger.w('Failed to add external subtitle track ${plexTrack.id}', error: e);
|
|
}
|
|
}
|
|
|
|
return externalSubtitles;
|
|
}
|
|
|
|
/// Plex's filter listing is lazy: categories come from
|
|
/// `/library/sections/{id}/filters` and values are fetched per category
|
|
/// when the user opens a filter. The result has empty [LibraryFilterResult.cachedValues];
|
|
/// the FiltersBottomSheet hits the per-category endpoint on demand.
|
|
@override
|
|
Future<LibraryFilterResult> fetchLibraryFiltersWithValues(String libraryId) async {
|
|
final filters = await getLibraryFilters(libraryId);
|
|
return LibraryFilterResult(filters: filters, cachedValues: const {});
|
|
}
|
|
|
|
@override
|
|
Future<PlaybackExtras> fetchPlaybackExtras(
|
|
String itemId, {
|
|
String? introPattern,
|
|
String? creditsPattern,
|
|
bool forceChapterFallback = false,
|
|
bool forceRefresh = false,
|
|
}) => getPlaybackExtras(
|
|
itemId,
|
|
introPattern: introPattern,
|
|
creditsPattern: creditsPattern,
|
|
forceChapterFallback: forceChapterFallback,
|
|
forceRefresh: forceRefresh,
|
|
);
|
|
|
|
@override
|
|
Future<PlaybackExtras?> fetchPlaybackExtrasFromCacheOnly(
|
|
String itemId, {
|
|
String? introPattern,
|
|
String? creditsPattern,
|
|
bool forceChapterFallback = false,
|
|
}) async {
|
|
final cached = await cache.get(serverId, '/library/metadata/$itemId');
|
|
if (cached == null) return null;
|
|
final metadataJson = _getFirstMetadataJsonFromData(cached);
|
|
if (metadataJson == null) return null;
|
|
return _parsePlaybackExtrasFromMetadataJson(
|
|
metadataJson,
|
|
introPattern: introPattern,
|
|
creditsPattern: creditsPattern,
|
|
forceChapterFallback: forceChapterFallback,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<MediaSourceInfo?> fetchCachedMediaSourceInfo(String itemId) async {
|
|
final cached = await cache.get(serverId, '/library/metadata/$itemId');
|
|
if (cached == null) return null;
|
|
final metadataJson = _getFirstMetadataJsonFromData(cached);
|
|
if (metadataJson == null) return null;
|
|
return plexMediaSourceInfoFromCacheJson(metadataJson);
|
|
}
|
|
|
|
@override
|
|
Future<ScrubPreviewSource?> createScrubPreviewSource({
|
|
required MediaItem item,
|
|
required MediaSourceInfo mediaSource,
|
|
}) async {
|
|
if (!capabilities.scrubThumbnails) return null;
|
|
final partId = mediaSource.partId;
|
|
if (partId == null) return null;
|
|
final service = BifThumbnailService();
|
|
try {
|
|
await service.load(this, partId, aspectRatio: mediaSource.videoAspectRatio);
|
|
return service;
|
|
} catch (e, st) {
|
|
appLogger.w('BIF thumbnail load failed for part $partId', error: e, stackTrace: st);
|
|
service.dispose();
|
|
return null;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<LibraryPage<MediaItem>> fetchLibraryPagedContent(
|
|
String libraryId, {
|
|
required LibraryQuery query,
|
|
MediaKind? libraryKind,
|
|
AbortController? abort,
|
|
}) async {
|
|
// Translate the neutral query back to Plex's flat key=value map. Plex's
|
|
// section endpoint takes filters verbatim — `PlexLibraryQueryTranslator`
|
|
// emits both typed slots (genre/year/contentRating/tag/alphaPrefix) and
|
|
// generic `query.filters` entries, matching what the legacy
|
|
// `plexStyleFilters` map carried.
|
|
final filters = const PlexLibraryQueryTranslator().toQueryParameters(query);
|
|
// Browse tab always asked for collections; preserve as Plex's default
|
|
// server behaviour can vary across versions.
|
|
filters['includeCollections'] = '1';
|
|
final result = await fetchLibraryPage(
|
|
libraryId,
|
|
start: query.offset,
|
|
size: query.limit,
|
|
filters: filters,
|
|
abort: abort,
|
|
);
|
|
return LibraryPage<MediaItem>(items: result.items, totalCount: result.totalSize, offset: query.offset);
|
|
}
|
|
|
|
@override
|
|
Future<List<LibraryFirstCharacter>> fetchFirstCharacters(String libraryId, {Map<String, String>? filters}) async {
|
|
return getFirstCharacters(libraryId, filters: filters);
|
|
}
|
|
|
|
@override
|
|
Future<List<MediaItem>> searchItems(String query, {int limit = 100}) async {
|
|
final results = await _search(query, limit: limit);
|
|
return results.map((m) => PlexMappers.mediaItem(m)).toList();
|
|
}
|
|
|
|
@override
|
|
Future<List<MediaItem>> fetchRecentlyAdded({int limit = 50}) async {
|
|
final items = await _getRecentlyAdded(limit: limit);
|
|
return items.map((m) => PlexMappers.mediaItem(m)).toList();
|
|
}
|
|
|
|
@override
|
|
Future<List<MediaItem>> fetchContinueWatching({int? count = 20}) async {
|
|
final items = await _getContinueWatching(count: count);
|
|
return items.map((m) => PlexMappers.mediaItem(m)).toList();
|
|
}
|
|
|
|
@override
|
|
Future<List<MediaHub>> fetchGlobalHubs({int limit = 10, 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 = 10,
|
|
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, item: item);
|
|
|
|
@override
|
|
Future<void> markUnwatched(MediaItem item) => markAsUnwatched(item.id, item: item);
|
|
|
|
@override
|
|
Future<void> removeFromContinueWatching(MediaItem item) async {
|
|
await removeFromOnDeck(item.id);
|
|
WatchStateNotifier().notifyRemovedFromContinueWatching(item: item);
|
|
}
|
|
|
|
/// 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<List<MediaPlaylist>> fetchPlaylists({String playlistType = 'video', bool? smart}) async {
|
|
final playlists = await _getPlaylists(playlistType: playlistType, smart: smart);
|
|
return playlists.map((p) => PlexMappers.mediaPlaylist(p)).toList();
|
|
}
|
|
|
|
@override
|
|
Future<LibraryPage<MediaPlaylist>> fetchPlaylistsPage({
|
|
String playlistType = 'video',
|
|
bool? smart,
|
|
int? start,
|
|
int? size,
|
|
AbortController? abort,
|
|
}) async {
|
|
final result = await _getPlaylistsPage(
|
|
playlistType: playlistType,
|
|
smart: smart,
|
|
start: start,
|
|
size: size,
|
|
abort: abort,
|
|
);
|
|
return LibraryPage<MediaPlaylist>(
|
|
items: result.items.map((p) => PlexMappers.mediaPlaylist(p)).toList(),
|
|
totalCount: result.totalSize,
|
|
offset: start ?? 0,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<MediaPlaylist?> fetchPlaylistMetadata(String id) async {
|
|
final p = await _getPlaylistMetadata(id);
|
|
return p == null ? null : PlexMappers.mediaPlaylist(p);
|
|
}
|
|
|
|
@override
|
|
Future<List<MediaItem>> fetchPlaylistItems(String id, {int offset = 0, int limit = 100}) async {
|
|
final page = await fetchPlaylistPage(id, start: offset, size: limit);
|
|
return page.items;
|
|
}
|
|
|
|
@override
|
|
Future<LibraryPage<MediaItem>> fetchPlaylistPage(
|
|
String playlistId, {
|
|
int? start,
|
|
int? size,
|
|
AbortController? abort,
|
|
}) async {
|
|
final result = await _getPlaylist(playlistId, start: start, size: size, abort: abort);
|
|
return LibraryPage<MediaItem>(
|
|
items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
|
|
totalCount: result.totalSize,
|
|
offset: start ?? 0,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<List<MediaItem>> fetchCollections(String libraryId) async {
|
|
final raw = await _getLibraryCollections(libraryId);
|
|
return raw.map((m) => PlexMappers.mediaItem(m)).toList();
|
|
}
|
|
|
|
@override
|
|
Future<LibraryPage<MediaItem>> fetchCollectionsPage(
|
|
String libraryId, {
|
|
int? start,
|
|
int? size,
|
|
AbortController? abort,
|
|
}) async {
|
|
final result = await _getLibraryCollectionsPage(libraryId, start: start, size: size, abort: abort);
|
|
return LibraryPage<MediaItem>(
|
|
items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
|
|
totalCount: result.totalSize,
|
|
offset: start ?? 0,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<LibraryPage<MediaItem>> fetchCollectionPage(
|
|
String collectionId, {
|
|
int? start,
|
|
int? size,
|
|
AbortController? abort,
|
|
String? libraryId,
|
|
String? libraryTitle,
|
|
}) async {
|
|
final result = await _getCollectionItems(
|
|
collectionId,
|
|
start: start,
|
|
size: size,
|
|
abort: abort,
|
|
librarySectionID: libraryId,
|
|
librarySectionTitle: libraryTitle,
|
|
);
|
|
return LibraryPage<MediaItem>(
|
|
items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
|
|
totalCount: result.totalSize,
|
|
offset: start ?? 0,
|
|
);
|
|
}
|
|
|
|
/// Plex-specific: full collection contents across pages.
|
|
Future<List<MediaItem>> fetchAllCollectionItemsAsMediaItems(
|
|
String collectionId, {
|
|
String? libraryId,
|
|
String? libraryTitle,
|
|
}) async {
|
|
final raw = await _fetchAllCollectionItemsDto(
|
|
collectionId,
|
|
librarySectionID: libraryId,
|
|
librarySectionTitle: libraryTitle,
|
|
);
|
|
return raw.map((m) => PlexMappers.mediaItem(m)).toList();
|
|
}
|
|
|
|
/// Plex-specific: full playlist contents across pages.
|
|
Future<List<MediaItem>> fetchAllPlaylistItemsAsMediaItems(String playlistId) async {
|
|
final raw = await _fetchAllPlaylistItemsDto(playlistId);
|
|
return raw.map((m) => PlexMappers.mediaItem(m)).toList();
|
|
}
|
|
|
|
@override
|
|
Future<LibraryPage<MediaItem>> fetchPersonMediaPage(
|
|
String personId, {
|
|
int? start,
|
|
int? size,
|
|
AbortController? abort,
|
|
}) async {
|
|
final result = await _getPersonMedia(personId, start: start, size: size, abort: abort);
|
|
return LibraryPage<MediaItem>(
|
|
items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
|
|
totalCount: result.totalSize,
|
|
offset: start ?? 0,
|
|
);
|
|
}
|
|
|
|
@override
|
|
Future<List<MediaItem>> fetchPersonMedia(String personId) => fetchAllPersonMediaAsMediaItems(personId);
|
|
|
|
/// Plex-specific: full person-media listing across pages.
|
|
Future<List<MediaItem>> fetchAllPersonMediaAsMediaItems(String personId) async {
|
|
final raw = await _fetchAllPersonMediaDto(personId);
|
|
return raw.map((m) => PlexMappers.mediaItem(m)).toList();
|
|
}
|
|
|
|
/// Plex-specific: hub content as neutral [MediaItem]s.
|
|
Future<List<MediaItem>> fetchHubContent(String hubKey) async {
|
|
final raw = await _getHubContent(hubKey);
|
|
return raw.map((m) => PlexMappers.mediaItem(m)).toList();
|
|
}
|
|
|
|
@override
|
|
Future<List<MediaItem>> fetchMoreHubItems(String hubId, {int? limit}) => fetchHubContent(hubId);
|
|
|
|
@override
|
|
Future<LibraryPage<MediaItem>> fetchMoreHubItemsPage(
|
|
String hubId, {
|
|
int? start,
|
|
int? size,
|
|
AbortController? abort,
|
|
}) async {
|
|
final result = await _getHubContentPage(hubId, start: start, size: size, abort: abort);
|
|
return LibraryPage<MediaItem>(
|
|
items: result.items.map((m) => PlexMappers.mediaItem(m)).toList(),
|
|
totalCount: result.totalSize,
|
|
offset: start ?? 0,
|
|
);
|
|
}
|
|
|
|
/// Plex-specific: top-level folders in a library.
|
|
Future<List<MediaItem>> fetchLibraryFolders(String sectionId) async {
|
|
final raw = await _getLibraryFolders(sectionId);
|
|
return raw.map((m) => PlexMappers.mediaItem(m)).toList();
|
|
}
|
|
|
|
/// Plex-specific: contents of a folder (files and subfolders).
|
|
Future<List<MediaItem>> fetchFolderChildren(String folderKey, {String? libraryId, String? libraryTitle}) async {
|
|
final raw = await _getFolderChildren(folderKey, librarySectionID: libraryId, librarySectionTitle: libraryTitle);
|
|
return raw.map((m) => PlexMappers.mediaItem(m)).toList();
|
|
}
|
|
|
|
/// Plex-specific: extras (trailers, behind-the-scenes) for a media item.
|
|
Future<List<MediaItem>> fetchExtras(String ratingKey) async {
|
|
final raw = await _getExtras(ratingKey);
|
|
return raw.map((m) => PlexMappers.mediaItem(m)).toList();
|
|
}
|
|
|
|
/// Plex-specific: library-scoped playlists.
|
|
Future<List<MediaPlaylist>> fetchLibraryPlaylists({String playlistType = 'video'}) async {
|
|
final raw = await _getLibraryPlaylists(playlistType: playlistType);
|
|
return raw.map((p) => PlexMappers.mediaPlaylist(p)).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;
|
|
|
|
@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);
|
|
}
|
|
|
|
@override
|
|
Future<void> reportPlaybackStarted({
|
|
required String itemId,
|
|
required Duration position,
|
|
Duration? duration,
|
|
String? playSessionId,
|
|
String? playMethod,
|
|
String? mediaSourceId,
|
|
int? audioStreamIndex,
|
|
int? subtitleStreamIndex,
|
|
}) => updateProgress(itemId, time: position.inMilliseconds, state: 'playing', duration: duration?.inMilliseconds);
|
|
|
|
@override
|
|
Future<void> reportPlaybackProgress({
|
|
required String itemId,
|
|
required Duration position,
|
|
required Duration duration,
|
|
bool isPaused = false,
|
|
String? playSessionId,
|
|
String? playMethod,
|
|
String? mediaSourceId,
|
|
int? audioStreamIndex,
|
|
int? subtitleStreamIndex,
|
|
}) => updateProgress(
|
|
itemId,
|
|
time: position.inMilliseconds,
|
|
state: isPaused ? 'paused' : 'playing',
|
|
duration: duration.inMilliseconds,
|
|
);
|
|
|
|
@override
|
|
Future<void> reportPlaybackStopped({
|
|
required String itemId,
|
|
required Duration position,
|
|
Duration? duration,
|
|
String? playSessionId,
|
|
String? mediaSourceId,
|
|
PlaybackReportMetadata report = const PlaybackReportMetadata.live(),
|
|
}) => updateProgress(
|
|
itemId,
|
|
time: position.inMilliseconds,
|
|
state: 'stopped',
|
|
duration: duration?.inMilliseconds,
|
|
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);
|
|
}
|
|
}
|