import 'dart:convert'; import 'package:dio/dio.dart'; import '../config/plex_config.dart'; import '../models/plex_file_info.dart'; import '../models/plex_filter.dart'; import '../models/plex_hub.dart'; import '../models/plex_library.dart'; import '../models/plex_media_info.dart'; import '../models/plex_media_version.dart'; import '../models/plex_metadata.dart'; import '../models/plex_sort.dart'; import '../models/plex_video_playback_data.dart'; import '../utils/app_logger.dart'; /// Result of testing a connection, including success status and latency class ConnectionTestResult { final bool success; final int latencyMs; ConnectionTestResult({required this.success, required this.latencyMs}); } class PlexClient { PlexConfig config; late final Dio _dio; /// Custom response decoder that handles malformed UTF-8 gracefully static String _lenientUtf8Decoder( List responseBytes, RequestOptions options, ResponseBody responseBody, ) { return utf8.decode(responseBytes, allowMalformed: true); } PlexClient(this.config) { _dio = Dio( BaseOptions( baseUrl: config.baseUrl, headers: config.headers, connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 120), validateStatus: (status) => status != null && status < 500, responseType: ResponseType.json, contentType: 'application/json; charset=utf-8', responseDecoder: _lenientUtf8Decoder, ), ); // Add interceptor for logging (optional, can be disabled in production) _dio.interceptors.add( LogInterceptor( requestBody: false, responseBody: false, error: true, requestHeader: false, responseHeader: false, ), ); } /// Update the token used by this client void updateToken(String newToken) { // Update both the Dio headers and the config to ensure consistency _dio.options.headers['X-Plex-Token'] = newToken; config = config.copyWith(token: newToken); appLogger.d('PlexClient token updated (headers and config)'); } /// Test connection to server Future testConnection() async { try { final response = await _dio.get('/'); return response.statusCode == 200 || response.statusCode == 401; } catch (e) { return false; } } /// Test connection to a specific URL with token and measure latency static Future testConnectionWithLatency( String baseUrl, String token, { Duration timeout = const Duration(seconds: 5), }) async { final stopwatch = Stopwatch()..start(); try { final dio = Dio( BaseOptions( baseUrl: baseUrl, connectTimeout: timeout, receiveTimeout: timeout, validateStatus: (status) => status != null && status < 500, responseType: ResponseType.json, contentType: 'application/json; charset=utf-8', ), ); final response = await dio.get( '/', options: Options(headers: {'X-Plex-Token': token}), ); stopwatch.stop(); final success = response.statusCode == 200 || response.statusCode == 401; return ConnectionTestResult( success: success, latencyMs: stopwatch.elapsedMilliseconds, ); } catch (e) { stopwatch.stop(); return ConnectionTestResult( success: false, latencyMs: stopwatch.elapsedMilliseconds, ); } } /// Test connection multiple times and return average latency static Future testConnectionWithAverageLatency( String baseUrl, String token, { int attempts = 3, Duration timeout = const Duration(seconds: 5), }) async { final results = []; for (int i = 0; i < attempts; i++) { final result = await testConnectionWithLatency( baseUrl, token, timeout: timeout, ); // 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(0, (sum, result) => sum + result.latencyMs) ~/ results.length; return ConnectionTestResult(success: true, latencyMs: avgLatency); } // ============================================================================ // API Response Parsing Helpers // ============================================================================ /// Extract MediaContainer from API response Map? _getMediaContainer(Response response) { if (response.data is Map && response.data.containsKey('MediaContainer')) { return response.data['MediaContainer']; } return null; } /// Extract list of PlexMetadata from response List _extractMetadataList(Response response) { final container = _getMediaContainer(response); if (container != null && container['Metadata'] != null) { return (container['Metadata'] as List) .map((json) => PlexMetadata.fromJson(json)) .toList(); } return []; } /// Extract first metadata JSON from response (returns raw Map or null) Map? _getFirstMetadataJson(Response response) { final container = _getMediaContainer(response); if (container != null && container['Metadata'] != null && (container['Metadata'] as List).isNotEmpty) { return container['Metadata'][0] as Map; } return null; } /// Extract single PlexMetadata from response (returns first item or null) PlexMetadata? _extractSingleMetadata(Response response) { final metadataJson = _getFirstMetadataJson(response); return metadataJson != null ? PlexMetadata.fromJson(metadataJson) : null; } /// Generic helper to extract and map Directory list from response List _extractDirectoryList( Response response, T Function(Map) fromJson, ) { final container = _getMediaContainer(response); if (container != null && container['Directory'] != null) { return (container['Directory'] as List) .map((json) => fromJson(json as Map)) .toList(); } return []; } // ============================================================================ // API Methods // ============================================================================ /// Get server identity Future> getServerIdentity() async { final response = await _dio.get('/identity'); return response.data; } /// Get library sections Future> getLibraries() async { final response = await _dio.get('/library/sections'); return _extractDirectoryList(response, PlexLibrary.fromJson); } /// Get library content by section ID Future> getLibraryContent( String sectionId, { int? start, int? size, Map? filters, CancelToken? cancelToken, }) async { final queryParams = {}; if (start != null) queryParams['X-Plex-Container-Start'] = start; if (size != null) queryParams['X-Plex-Container-Size'] = size; // Add filter parameters if (filters != null) { queryParams.addAll(filters); } final response = await _dio.get( '/library/sections/$sectionId/all', queryParameters: queryParams, cancelToken: cancelToken, ); return _extractMetadataList(response); } /// Get metadata by rating key Future getMetadata(String ratingKey) async { final response = await _dio.get('/library/metadata/$ratingKey'); return _extractSingleMetadata(response); } /// Get metadata by rating key with images (includes clearLogo and OnDeck) Future> getMetadataWithImagesAndOnDeck( String ratingKey, ) async { final response = await _dio.get( '/library/metadata/$ratingKey', queryParameters: {'includeOnDeck': 1}, ); PlexMetadata? metadata; PlexMetadata? onDeckEpisode; final metadataJson = _getFirstMetadataJson(response); // Log the parsed metadata JSON if (metadataJson != null) { metadata = PlexMetadata.fromJsonWithImages(metadataJson); // Check if OnDeck is nested inside Metadata if (metadataJson.containsKey('OnDeck') && metadataJson['OnDeck'] != null) { final onDeckData = metadataJson['OnDeck']; // OnDeck can be either a Map with 'Metadata' key or direct metadata if (onDeckData is Map && onDeckData.containsKey('Metadata')) { final onDeckMetadata = onDeckData['Metadata']; if (onDeckMetadata != null) { onDeckEpisode = PlexMetadata.fromJson(onDeckMetadata); } } } } return {'metadata': metadata, 'onDeckEpisode': onDeckEpisode}; } /// Get metadata by rating key with images (includes clearLogo) Future getMetadataWithImages(String ratingKey) async { final response = await _dio.get('/library/metadata/$ratingKey'); final metadataJson = _getFirstMetadataJson(response); return metadataJson != null ? PlexMetadata.fromJsonWithImages(metadataJson) : null; } /// Search across all libraries using the hub search endpoint /// Only returns movies and shows, filtering out seasons and episodes Future> search(String query, {int limit = 10}) async { final response = await _dio.get( '/hubs/search', queryParameters: { 'query': query, 'limit': limit, 'includeCollections': 1, }, ); final results = []; if (response.data is Map && response.data.containsKey('MediaContainer')) { final container = response.data['MediaContainer']; if (container['Hub'] != null) { // Each hub contains results of a specific type (movies, shows, etc.) for (final hub in container['Hub'] as List) { final hubType = hub['type'] as String?; // Only include movie and show hubs if (hubType != 'movie' && hubType != 'show') { continue; } // Hubs can contain either Metadata (for movies) or Directory (for shows) if (hub['Metadata'] != null) { for (final json in hub['Metadata'] as List) { try { results.add(PlexMetadata.fromJson(json)); } catch (e) { // Skip items that fail to parse appLogger.w('Failed to parse search result', error: e); appLogger.d('Problematic JSON: $json'); } } } if (hub['Directory'] != null) { for (final json in hub['Directory'] as List) { try { results.add(PlexMetadata.fromJson(json)); } catch (e) { // Skip items that fail to parse appLogger.w('Failed to parse search result', error: e); appLogger.d('Problematic JSON: $json'); } } } } } } return results; } /// Get recently added media (filtered to video content only) Future> getRecentlyAdded({int limit = 50}) async { final response = await _dio.get( '/library/recentlyAdded', queryParameters: {'X-Plex-Container-Size': limit, 'includeGuids': 1}, ); final allItems = _extractMetadataList(response); // Filter out music content (artists, albums, tracks) return allItems.where((item) { final type = item.type.toLowerCase(); return type != 'artist' && type != 'album' && type != 'track'; }).toList(); } /// Get on deck items (continue watching, filtered to video content only) Future> getOnDeck() async { final response = await _dio.get('/library/onDeck'); final container = _getMediaContainer(response); if (container != null && container['Metadata'] != null) { final allItems = (container['Metadata'] as List) .map((json) => PlexMetadata.fromJsonWithImages(json)) .toList(); // Filter out music content (artists, albums, tracks) return allItems.where((item) { final type = item.type.toLowerCase(); return type != 'artist' && type != 'album' && type != 'track'; }).toList(); } return []; } /// Get children of a metadata item (e.g., seasons for a show, episodes for a season) Future> getChildren(String ratingKey) async { final response = await _dio.get('/library/metadata/$ratingKey/children'); return _extractMetadataList(response); } /// Get all unwatched episodes for a TV show across all seasons Future> getAllUnwatchedEpisodes( String showRatingKey, ) async { final allEpisodes = []; // Get all seasons for the show final seasons = await getChildren(showRatingKey); // Get episodes from each season for (final season in seasons) { if (season.type == 'season') { final episodes = await getChildren(season.ratingKey); // Filter for unwatched episodes final unwatchedEpisodes = episodes .where((ep) => ep.type == 'episode' && (ep.viewCount ?? 0) == 0) .toList(); allEpisodes.addAll(unwatchedEpisodes); } } return allEpisodes; } /// Get all unwatched episodes in a specific season Future> getUnwatchedEpisodesInSeason( String seasonRatingKey, ) async { final episodes = await getChildren(seasonRatingKey); // Filter for unwatched episodes return episodes .where((ep) => ep.type == 'episode' && (ep.viewCount ?? 0) == 0) .toList(); } /// Get thumbnail URL String getThumbnailUrl(String? thumbPath) { if (thumbPath == null || thumbPath.isEmpty) return ''; // Remove leading slash if present final path = thumbPath.startsWith('/') ? thumbPath.substring(1) : thumbPath; return '${config.baseUrl}/$path?X-Plex-Token=${config.token}'; } /// Get video URL for direct playback /// [mediaIndex] specifies which Media item to use (defaults to 0 - first version) Future getVideoUrl(String ratingKey, {int mediaIndex = 0}) async { final response = await _dio.get('/library/metadata/$ratingKey'); final metadataJson = _getFirstMetadataJson(response); if (metadataJson != null && metadataJson['Media'] != null && (metadataJson['Media'] as List).isNotEmpty) { final mediaList = metadataJson['Media'] as List; // Ensure the requested index is valid if (mediaIndex < 0 || mediaIndex >= mediaList.length) { mediaIndex = 0; } final media = mediaList[mediaIndex]; if (media['Part'] != null && (media['Part'] as List).isNotEmpty) { final part = media['Part'][0]; final partKey = part['key'] as String?; if (partKey != null) { // Return direct play URL return '${config.baseUrl}$partKey?X-Plex-Token=${config.token}'; } } } return null; } /// Get chapters for a media item Future> getChapters(String ratingKey) async { final response = await _dio.get( '/library/metadata/$ratingKey', queryParameters: {'includeChapters': 1}, ); final metadataJson = _getFirstMetadataJson(response); if (metadataJson != null && metadataJson['Chapter'] != null) { final chapterList = metadataJson['Chapter'] as List; return chapterList.map((chapter) { return PlexChapter( id: chapter['id'] as int, index: chapter['index'] as int?, startTimeOffset: chapter['startTimeOffset'] as int?, endTimeOffset: chapter['endTimeOffset'] as int?, title: chapter['tag'] as String?, thumb: chapter['thumb'] as String?, ); }).toList(); } return []; } Future> getMarkers(String ratingKey) async { final response = await _dio.get( '/library/metadata/$ratingKey', queryParameters: {'includeMarkers': 1}, ); final metadataJson = _getFirstMetadataJson(response); if (metadataJson != null && metadataJson['Marker'] != null) { final markerList = metadataJson['Marker'] as List; return markerList.map((marker) { return PlexMarker( id: marker['id'] as int, type: marker['type'] as String, startTimeOffset: marker['startTimeOffset'] as int, endTimeOffset: marker['endTimeOffset'] as int, ); }).toList(); } return []; } /// Get detailed media info including chapters and tracks /// [mediaIndex] specifies which Media item to use (defaults to 0 - first version) Future getMediaInfo( String ratingKey, { int mediaIndex = 0, }) async { final response = await _dio.get('/library/metadata/$ratingKey'); final metadataJson = _getFirstMetadataJson(response); if (metadataJson != null && metadataJson['Media'] != null && (metadataJson['Media'] as List).isNotEmpty) { final mediaList = metadataJson['Media'] as List; // Ensure the requested index is valid if (mediaIndex < 0 || mediaIndex >= mediaList.length) { mediaIndex = 0; } final media = mediaList[mediaIndex]; if (media['Part'] != null && (media['Part'] as List).isNotEmpty) { final part = media['Part'][0]; final partKey = part['key'] as String?; if (partKey != null) { // Parse streams (audio and subtitle tracks) final streams = part['Stream'] as List? ?? []; final audioTracks = []; final subtitleTracks = []; for (var stream in streams) { final streamType = stream['streamType'] as int?; if (streamType == 2) { // Audio track audioTracks.add( PlexAudioTrack( id: stream['id'] as int, index: stream['index'] as int?, codec: stream['codec'] as String?, language: stream['language'] as String?, languageCode: stream['languageCode'] as String?, title: stream['title'] as String?, displayTitle: stream['displayTitle'] as String?, channels: stream['channels'] as int?, selected: stream['selected'] == 1, ), ); } else if (streamType == 3) { // Subtitle track subtitleTracks.add( PlexSubtitleTrack( id: stream['id'] as int, index: stream['index'] as int?, codec: stream['codec'] as String?, language: stream['language'] as String?, languageCode: stream['languageCode'] as String?, title: stream['title'] as String?, displayTitle: stream['displayTitle'] as String?, selected: stream['selected'] == 1, forced: stream['forced'] == 1, key: stream['key'] as String?, ), ); } } // Parse chapters final chapters = []; if (metadataJson['Chapter'] != null) { final chapterList = metadataJson['Chapter'] as List; for (var chapter in chapterList) { chapters.add( PlexChapter( id: chapter['id'] as int, index: chapter['index'] as int?, startTimeOffset: chapter['startTimeOffset'] as int?, endTimeOffset: chapter['endTimeOffset'] as int?, title: chapter['title'] as String?, thumb: chapter['thumb'] as String?, ), ); } } return PlexMediaInfo( videoUrl: '${config.baseUrl}$partKey?X-Plex-Token=${config.token}', audioTracks: audioTracks, subtitleTracks: subtitleTracks, chapters: chapters, ); } } } return null; } /// Get all available media versions for a media item /// Returns a list of PlexMediaVersion objects representing different quality/format options Future> getMediaVersions(String ratingKey) async { final response = await _dio.get('/library/metadata/$ratingKey'); final metadataJson = _getFirstMetadataJson(response); if (metadataJson != null && metadataJson['Media'] != null && (metadataJson['Media'] as List).isNotEmpty) { final mediaList = metadataJson['Media'] as List; return mediaList .map( (media) => PlexMediaVersion.fromJson(media as Map), ) .toList(); } return []; } /// Get consolidated video playback data (URL, media info, and versions) in a single API call /// This method combines the functionality of getVideoUrl(), getMediaInfo(), and getMediaVersions() /// to reduce redundant API calls during video playback initialization. Future getVideoPlaybackData( String ratingKey, { int mediaIndex = 0, }) async { final response = await _dio.get('/library/metadata/$ratingKey'); final metadataJson = _getFirstMetadataJson(response); String? videoUrl; PlexMediaInfo? mediaInfo; List availableVersions = []; if (metadataJson != null && metadataJson['Media'] != null && (metadataJson['Media'] as List).isNotEmpty) { final mediaList = metadataJson['Media'] as List; // Parse available media versions first availableVersions = mediaList .map( (media) => PlexMediaVersion.fromJson(media as Map), ) .toList(); // Ensure the requested index is valid if (mediaIndex < 0 || mediaIndex >= mediaList.length) { mediaIndex = 0; } final media = mediaList[mediaIndex]; if (media['Part'] != null && (media['Part'] as List).isNotEmpty) { final part = media['Part'][0]; final partKey = part['key'] as String?; if (partKey != null) { // Get video URL videoUrl = '${config.baseUrl}$partKey?X-Plex-Token=${config.token}'; // Parse streams (audio and subtitle tracks) for media info final streams = part['Stream'] as List? ?? []; final audioTracks = []; final subtitleTracks = []; for (var stream in streams) { final streamType = stream['streamType'] as int?; if (streamType == 2) { // Audio track audioTracks.add( PlexAudioTrack( id: stream['id'] as int, index: stream['index'] as int?, codec: stream['codec'] as String?, language: stream['language'] as String?, languageCode: stream['languageCode'] as String?, title: stream['title'] as String?, displayTitle: stream['displayTitle'] as String?, channels: stream['channels'] as int?, selected: stream['selected'] == 1, ), ); } else if (streamType == 3) { // Subtitle track subtitleTracks.add( PlexSubtitleTrack( id: stream['id'] as int, index: stream['index'] as int?, codec: stream['codec'] as String?, language: stream['language'] as String?, languageCode: stream['languageCode'] as String?, title: stream['title'] as String?, displayTitle: stream['displayTitle'] as String?, selected: stream['selected'] == 1, forced: stream['forced'] == 1, key: stream['key'] as String?, ), ); } } // Parse chapters final chapters = []; if (metadataJson['Chapter'] != null) { final chapterList = metadataJson['Chapter'] as List; for (var chapter in chapterList) { chapters.add( PlexChapter( id: chapter['id'] as int, index: chapter['index'] as int?, startTimeOffset: chapter['startTimeOffset'] as int?, endTimeOffset: chapter['endTimeOffset'] as int?, title: chapter['title'] as String?, thumb: chapter['thumb'] as String?, ), ); } } // Create media info mediaInfo = PlexMediaInfo( videoUrl: videoUrl, audioTracks: audioTracks, subtitleTracks: subtitleTracks, chapters: chapters, ); } } } return PlexVideoPlaybackData( videoUrl: videoUrl, mediaInfo: mediaInfo, availableVersions: availableVersions, ); } /// Get file information for a media item Future getFileInfo(String ratingKey) async { try { final response = await _dio.get('/library/metadata/$ratingKey'); final metadataJson = _getFirstMetadataJson(response); if (metadataJson != null && metadataJson['Media'] != null && (metadataJson['Media'] as List).isNotEmpty) { final media = metadataJson['Media'][0]; final part = media['Part'] != null && (media['Part'] as List).isNotEmpty ? media['Part'][0] : null; // Extract video stream details final streams = part?['Stream'] as List? ?? []; Map? videoStream; Map? audioStream; for (var stream in streams) { final streamType = stream['streamType'] as int?; if (streamType == 1 && videoStream == null) { videoStream = stream; } else if (streamType == 2 && audioStream == null) { audioStream = stream; } } return PlexFileInfo( // Media level properties container: media['container'] as String?, videoCodec: media['videoCodec'] as String?, videoResolution: media['videoResolution'] as String?, videoFrameRate: media['videoFrameRate'] as String?, videoProfile: media['videoProfile'] as String?, width: media['width'] as int?, height: media['height'] as int?, aspectRatio: (media['aspectRatio'] as num?)?.toDouble(), bitrate: media['bitrate'] as int?, duration: media['duration'] as int?, audioCodec: media['audioCodec'] as String?, audioProfile: media['audioProfile'] as String?, audioChannels: media['audioChannels'] as int?, optimizedForStreaming: media['optimizedForStreaming'] as bool?, has64bitOffsets: media['has64bitOffsets'] as bool?, // Part level properties (file) filePath: part?['file'] as String?, fileSize: part?['size'] as int?, // Video stream details colorSpace: videoStream?['colorSpace'] as String?, colorRange: videoStream?['colorRange'] as String?, colorPrimaries: videoStream?['colorPrimaries'] as String?, colorTrc: videoStream?['colorTrc'] as String?, chromaSubsampling: videoStream?['chromaSubsampling'] as String?, frameRate: (videoStream?['frameRate'] as num?)?.toDouble(), bitDepth: videoStream?['bitDepth'] as int?, // Audio stream details audioChannelLayout: audioStream?['audioChannelLayout'] as String?, ); } return null; } catch (e) { appLogger.e('Failed to get file info: $e'); return null; } } /// Mark media as watched Future markAsWatched(String ratingKey) async { await _dio.get( '/:/scrobble', queryParameters: { 'key': ratingKey, 'identifier': 'com.plexapp.plugins.library', }, ); } /// Mark media as unwatched Future markAsUnwatched(String ratingKey) async { await _dio.get( '/:/unscrobble', queryParameters: { 'key': ratingKey, 'identifier': 'com.plexapp.plugins.library', }, ); } /// Update playback progress Future updateProgress( String ratingKey, { required int time, required String state, // 'playing', 'paused', 'stopped', 'buffering' int? duration, }) async { await _dio.post( '/:/timeline', queryParameters: { 'ratingKey': ratingKey, 'key': '/library/metadata/$ratingKey', 'time': time, 'state': state, if (duration != null) 'duration': duration, }, ); } /// 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 removeFromOnDeck(String ratingKey) async { await _dio.put( '/actions/removeFromContinueWatching', queryParameters: {'ratingKey': ratingKey}, ); } /// Get server preferences Future> getServerPreferences() async { final response = await _dio.get('/:/prefs'); return response.data; } /// Get sessions (currently playing) Future> getSessions() async { final response = await _dio.get('/status/sessions'); final container = _getMediaContainer(response); if (container != null && container['Metadata'] != null) { return container['Metadata'] as List; } return []; } /// Get available filters for a library section Future> getLibraryFilters(String sectionId) async { final response = await _dio.get('/library/sections/$sectionId/filters'); return _extractDirectoryList(response, PlexFilter.fromJson); } /// Get filter values (e.g., list of genres, years, etc.) Future> getFilterValues(String filterKey) async { final response = await _dio.get(filterKey); return _extractDirectoryList(response, PlexFilterValue.fromJson); } /// Get available sort options for a library section Future> getLibrarySorts(String sectionId) async { try { // Use the dedicated sorts endpoint final response = await _dio.get('/library/sections/$sectionId/sorts'); // Parse the Directory array (not Sort array) per the API spec final sorts = _extractDirectoryList(response, PlexSort.fromJson); if (sorts.isNotEmpty) { return sorts; } // Fallback: return common sort options if API doesn't provide them return _getFallbackSorts(sectionId); } catch (e) { appLogger.e('Failed to get library sorts: $e'); // Return fallback sort options on error return _getFallbackSorts(sectionId); } } Future> _getFallbackSorts(String sectionId) async { try { // Get library type to determine which sorts to include final librariesResponse = await _dio.get('/library/sections'); final libraries = _extractDirectoryList( librariesResponse, PlexLibrary.fromJson, ); final library = libraries.firstWhere( (lib) => lib.key == sectionId, orElse: () => libraries.first, ); final fallbackSorts = [ PlexSort(key: 'titleSort', title: 'Title', defaultDirection: 'asc'), PlexSort( key: 'addedAt', descKey: 'addedAt:desc', title: 'Date Added', defaultDirection: 'desc', ), ]; // Add "Latest Episode Air Date" only for TV show libraries if (library.type.toLowerCase() == 'show') { fallbackSorts.add( PlexSort( key: 'episode.originallyAvailableAt', descKey: 'episode.originallyAvailableAt:desc', title: 'Latest Episode Air Date', defaultDirection: 'desc', ), ); } fallbackSorts.addAll([ PlexSort( key: 'originallyAvailableAt', descKey: 'originallyAvailableAt:desc', title: 'Release Date', defaultDirection: 'desc', ), PlexSort( key: 'rating', descKey: 'rating:desc', title: 'Rating', defaultDirection: 'desc', ), ]); return fallbackSorts; } catch (e) { appLogger.e('Failed to get fallback sorts: $e'); // Return minimal fallback options return [ PlexSort(key: 'titleSort', title: 'Title', defaultDirection: 'asc'), PlexSort( key: 'addedAt', descKey: 'addedAt:desc', title: 'Date Added', defaultDirection: 'desc', ), ]; } } /// Find adjacent episode in a given direction /// /// [direction]: +1 for next episode, -1 for previous episode /// /// Handles navigation within current season and across seasons automatically. Future findAdjacentEpisode( PlexMetadata currentEpisode, int direction, ) async { if (currentEpisode.type.toLowerCase() != 'episode') { return null; } final parentKey = currentEpisode.parentRatingKey; final grandparentKey = currentEpisode.grandparentRatingKey; if (parentKey == null || grandparentKey == null) { return null; } try { // Get all episodes in the current season final episodes = await getChildren(parentKey); // Find the current episode index final currentIndex = episodes.indexWhere( (e) => e.ratingKey == currentEpisode.ratingKey, ); if (currentIndex == -1) return null; final targetIndex = currentIndex + direction; // Check if target episode is within current season if (targetIndex >= 0 && targetIndex < episodes.length) { return episodes[targetIndex]; } // Need to move to adjacent season final isAtBoundary = direction > 0 ? currentIndex == episodes.length - 1 : currentIndex == 0; if (isAtBoundary) { // Get all seasons final seasons = await getChildren(grandparentKey); final currentSeasonIndex = seasons.indexWhere( (s) => s.ratingKey == parentKey, ); if (currentSeasonIndex == -1) return null; final targetSeasonIndex = currentSeasonIndex + direction; // Check if target season exists if (targetSeasonIndex >= 0 && targetSeasonIndex < seasons.length) { final targetSeason = seasons[targetSeasonIndex]; final targetSeasonEpisodes = await getChildren( targetSeason.ratingKey, ); if (targetSeasonEpisodes.isNotEmpty) { // Return first episode for next season, last for previous return direction > 0 ? targetSeasonEpisodes.first : targetSeasonEpisodes.last; } } } } catch (e) { // Silently handle errors } return null; } /// Get library hubs (recommendations for a specific library section) /// Returns a list of recommendation hubs like "Trending Movies", "Top in Genre", etc. Future> getLibraryHubs( String sectionId, { int limit = 10, }) async { try { final response = await _dio.get( '/hubs/sections/$sectionId', queryParameters: {'count': limit, 'includeGuids': 1}, ); final container = _getMediaContainer(response); if (container != null && container['Hub'] != null) { final hubs = []; for (final hubJson in container['Hub'] as List) { try { final hub = PlexHub.fromJson(hubJson); // Only include hubs that have items and are movie/show content if (hub.items.isNotEmpty) { // Filter out non-video content types final videoItems = hub.items.where((item) { final type = item.type.toLowerCase(); return type == 'movie' || type == 'show'; }).toList(); if (videoItems.isNotEmpty) { hubs.add( PlexHub( hubKey: hub.hubKey, title: hub.title, type: hub.type, hubIdentifier: hub.hubIdentifier, size: hub.size, more: hub.more, items: videoItems, ), ); } } } catch (e) { appLogger.w('Failed to parse hub', error: e); } } return hubs; } } catch (e) { appLogger.e('Failed to get library hubs: $e'); } return []; } /// Get full content from a hub using its hub key /// Returns the complete list of metadata items in the hub Future> getHubContent(String hubKey) async { try { final response = await _dio.get(hubKey); final allItems = _extractMetadataList(response); // Filter out non-video content types return allItems.where((item) { final type = item.type.toLowerCase(); return type == 'movie' || type == 'show'; }).toList(); } catch (e) { appLogger.e('Failed to get hub content: $e'); return []; } } /// Get playlist content by playlist ID /// Returns the list of metadata items in the playlist Future> getPlaylist(String playlistId) async { try { final response = await _dio.get('/playlists/$playlistId/items'); return _extractMetadataList(response); } catch (e) { appLogger.e('Failed to get playlist: $e'); return []; } } // ============================================================================ // Library Management Methods // ============================================================================ /// Scan/refresh a library section to detect new files Future scanLibrary(String sectionId) async { await _dio.get('/library/sections/$sectionId/refresh'); } /// Refresh metadata for a library section Future refreshLibraryMetadata(String sectionId) async { await _dio.get('/library/sections/$sectionId/refresh?force=1'); } /// Empty trash for a library section Future emptyLibraryTrash(String sectionId) async { await _dio.put('/library/sections/$sectionId/emptyTrash'); } /// Analyze library section Future analyzeLibrary(String sectionId) async { await _dio.get('/library/sections/$sectionId/analyze'); } }