perf: cache-first metadata fetching for playback
This commit is contained in:
@@ -1952,107 +1952,131 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle audio track changes from the user - save both stream selection and language preference
|
||||
Future<void> _onAudioTrackChanged(AudioTrack track) async {
|
||||
/// Rating key used for series/movie level language preferences.
|
||||
String get _preferenceRatingKey {
|
||||
return widget.metadata.isEpisode
|
||||
? (widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey)
|
||||
: widget.metadata.ratingKey;
|
||||
}
|
||||
|
||||
/// Common guard checks for track change handlers.
|
||||
/// Returns the part ID if all checks pass, or null if the change should be skipped.
|
||||
Future<int?> _guardTrackChange() async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
if (!settings.getRememberTrackSelections()) return null;
|
||||
|
||||
// Only save if remember track selections is enabled
|
||||
if (!settings.getRememberTrackSelections()) {
|
||||
return;
|
||||
}
|
||||
if (_currentMediaInfo == null) {
|
||||
appLogger.w('No media info available, cannot save stream selection');
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
final partId = _currentMediaInfo!.getPartId();
|
||||
if (partId == null) {
|
||||
appLogger.w('No part ID available, cannot save stream selection');
|
||||
return;
|
||||
}
|
||||
return partId;
|
||||
}
|
||||
|
||||
final languageCode = track.language;
|
||||
int? streamID;
|
||||
/// Save language preference and stream selection to the server.
|
||||
Future<void> _saveTrackPreferences({
|
||||
required int partId,
|
||||
required String trackType,
|
||||
String? languageCode,
|
||||
int? streamID,
|
||||
}) async {
|
||||
try {
|
||||
if (!mounted) return;
|
||||
final client = _getClientForMetadata(context);
|
||||
final ratingKey = _preferenceRatingKey;
|
||||
|
||||
// === Matching by attributes ===
|
||||
PlexAudioTrack? matched;
|
||||
final normalizedTrackLang = _iso6391ToPlex6392(track.language);
|
||||
final futures = <Future>[];
|
||||
|
||||
appLogger.d('Normalized media_kit language: ${track.language} -> $normalizedTrackLang');
|
||||
if (languageCode != null && (trackType == 'subtitle' || languageCode.isNotEmpty)) {
|
||||
futures.add(
|
||||
trackType == 'audio'
|
||||
? client.setMetadataPreferences(ratingKey, audioLanguage: languageCode)
|
||||
: client.setMetadataPreferences(ratingKey, subtitleLanguage: languageCode),
|
||||
);
|
||||
}
|
||||
if (streamID != null) {
|
||||
futures.add(
|
||||
trackType == 'audio'
|
||||
? client.selectStreams(partId, audioStreamID: streamID, allParts: true)
|
||||
: client.selectStreams(partId, subtitleStreamID: streamID, allParts: true),
|
||||
);
|
||||
}
|
||||
|
||||
for (final plexTrack in _currentMediaInfo!.audioTracks) {
|
||||
final matchLang = plexTrack.languageCode == normalizedTrackLang;
|
||||
final matchTitle = (track.title == null || track.title!.isEmpty)
|
||||
await Future.wait(futures);
|
||||
appLogger.d('Successfully saved $trackType preferences (language + stream)');
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to save $trackType preferences', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Match an mpv track against Plex tracks by language and title.
|
||||
int? _matchTrackByAttributes<T>({
|
||||
required String? mpvLanguage,
|
||||
required String? mpvTitle,
|
||||
required List<T> plexTracks,
|
||||
required String? Function(T) getLanguageCode,
|
||||
required String? Function(T) getDisplayTitle,
|
||||
required String? Function(T) getTitle,
|
||||
required int Function(T) getId,
|
||||
}) {
|
||||
final normalizedLang = _iso6391ToPlex6392(mpvLanguage);
|
||||
|
||||
for (final plexTrack in plexTracks) {
|
||||
final matchLang = getLanguageCode(plexTrack) == normalizedLang;
|
||||
final matchTitle = (mpvTitle == null || mpvTitle.isEmpty)
|
||||
? true
|
||||
: (plexTrack.displayTitle == track.title || plexTrack.title == track.title);
|
||||
: (getDisplayTitle(plexTrack) == mpvTitle || getTitle(plexTrack) == mpvTitle);
|
||||
|
||||
if (matchLang && matchTitle) {
|
||||
matched = plexTrack;
|
||||
appLogger.d('Matched audio by lang/title: streamID ${matched.id}');
|
||||
break;
|
||||
return getId(plexTrack);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (matched != null) {
|
||||
streamID = matched.id;
|
||||
/// Handle audio track changes from the user - save both stream selection and language preference
|
||||
Future<void> _onAudioTrackChanged(AudioTrack track) async {
|
||||
final partId = await _guardTrackChange();
|
||||
if (partId == null) return;
|
||||
|
||||
int? streamID = _matchTrackByAttributes(
|
||||
mpvLanguage: track.language,
|
||||
mpvTitle: track.title,
|
||||
plexTracks: _currentMediaInfo!.audioTracks,
|
||||
getLanguageCode: (t) => t.languageCode,
|
||||
getDisplayTitle: (t) => t.displayTitle,
|
||||
getTitle: (t) => t.title,
|
||||
getId: (t) => t.id,
|
||||
);
|
||||
|
||||
if (streamID != null) {
|
||||
appLogger.d('Matched audio by lang/title: streamID $streamID');
|
||||
} else {
|
||||
// Use property-based matching from track_selection_service
|
||||
final matchedPlex = findPlexTrackForMpvAudio(track, _currentMediaInfo!.audioTracks);
|
||||
|
||||
if (matchedPlex != null) {
|
||||
streamID = matchedPlex.id;
|
||||
streamID = matchedPlex?.id;
|
||||
if (streamID != null) {
|
||||
appLogger.d('Matched audio by properties: streamID $streamID');
|
||||
} else {
|
||||
appLogger.e('Could not match audio track to any Plex track');
|
||||
}
|
||||
}
|
||||
|
||||
final isEpisode = widget.metadata.isEpisode;
|
||||
final languagePrefRatingKey = isEpisode
|
||||
? (widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey)
|
||||
: widget.metadata.ratingKey;
|
||||
|
||||
try {
|
||||
if (!mounted) return;
|
||||
final client = _getClientForMetadata(context);
|
||||
|
||||
final futures = <Future>[];
|
||||
|
||||
// 1. Language preference (series/movie level)
|
||||
if (languageCode != null && languageCode.isNotEmpty) {
|
||||
futures.add(client.setMetadataPreferences(languagePrefRatingKey, audioLanguage: languageCode));
|
||||
}
|
||||
// 2. Exact stream selection (part level)
|
||||
if (streamID != null) {
|
||||
futures.add(client.selectStreams(partId, audioStreamID: streamID, allParts: true));
|
||||
}
|
||||
|
||||
await Future.wait(futures);
|
||||
appLogger.d('Successfully saved audio preferences (language + stream)');
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to save audio preferences', error: e);
|
||||
}
|
||||
await _saveTrackPreferences(
|
||||
partId: partId,
|
||||
trackType: 'audio',
|
||||
languageCode: track.language,
|
||||
streamID: streamID,
|
||||
);
|
||||
}
|
||||
|
||||
/// Handle subtitle track changes from the user - save both stream selection and language preference
|
||||
Future<void> _onSubtitleTrackChanged(SubtitleTrack track) async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
|
||||
// Only save if remember track selections is enabled
|
||||
if (!settings.getRememberTrackSelections()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_currentMediaInfo == null) {
|
||||
appLogger.w('No media info available, cannot save stream selection');
|
||||
return;
|
||||
}
|
||||
|
||||
final partId = _currentMediaInfo!.getPartId();
|
||||
if (partId == null) {
|
||||
appLogger.w('No part ID available, cannot save stream selection');
|
||||
return;
|
||||
}
|
||||
final partId = await _guardTrackChange();
|
||||
if (partId == null) return;
|
||||
|
||||
String? languageCode;
|
||||
int? streamID;
|
||||
@@ -2064,38 +2088,22 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
} else {
|
||||
languageCode = track.language;
|
||||
|
||||
// === Matching by attributes ===
|
||||
PlexSubtitleTrack? matched;
|
||||
final normalizedTrackLang = _iso6391ToPlex6392(track.language);
|
||||
streamID = _matchTrackByAttributes(
|
||||
mpvLanguage: track.language,
|
||||
mpvTitle: track.title,
|
||||
plexTracks: _currentMediaInfo!.subtitleTracks,
|
||||
getLanguageCode: (t) => t.languageCode,
|
||||
getDisplayTitle: (t) => t.displayTitle,
|
||||
getTitle: (t) => t.title,
|
||||
getId: (t) => t.id,
|
||||
);
|
||||
|
||||
appLogger.d('Normalized media_kit language: ${track.language} -> $normalizedTrackLang');
|
||||
|
||||
for (final plexTrack in _currentMediaInfo!.subtitleTracks) {
|
||||
final matchLang = plexTrack.languageCode == normalizedTrackLang;
|
||||
final matchTitle = (track.title == null || track.title!.isEmpty)
|
||||
? true
|
||||
: (plexTrack.displayTitle == track.title || plexTrack.title == track.title);
|
||||
|
||||
appLogger.d('Comparing with streamID ${plexTrack.id}:');
|
||||
appLogger.d(' matchLang: $matchLang (${plexTrack.languageCode} == $normalizedTrackLang)');
|
||||
appLogger.d(' matchTitle: $matchTitle');
|
||||
|
||||
if (matchLang && matchTitle) {
|
||||
matched = plexTrack;
|
||||
appLogger.d(' ✅ MATCHED!');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matched != null) {
|
||||
streamID = matched.id;
|
||||
if (streamID != null) {
|
||||
appLogger.d('Matched subtitle by lang/title: streamID $streamID');
|
||||
} else {
|
||||
// Use property-based matching from track_selection_service
|
||||
final matchedPlex = findPlexTrackForMpvSubtitle(track, _currentMediaInfo!.subtitleTracks);
|
||||
|
||||
if (matchedPlex != null) {
|
||||
streamID = matchedPlex.id;
|
||||
streamID = matchedPlex?.id;
|
||||
if (streamID != null) {
|
||||
appLogger.d('Matched subtitle by properties: streamID $streamID');
|
||||
} else {
|
||||
appLogger.e('Could not match subtitle track to any Plex track');
|
||||
@@ -2103,36 +2111,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
}
|
||||
}
|
||||
|
||||
// Determine ratingKeys
|
||||
final isEpisode = widget.metadata.isEpisode;
|
||||
final languagePrefRatingKey = isEpisode
|
||||
? (widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey)
|
||||
: widget.metadata.ratingKey;
|
||||
|
||||
appLogger.i(
|
||||
'Saving subtitle preference: language=$languageCode (ratingKey: $languagePrefRatingKey), streamID=$streamID (partId: $partId)',
|
||||
await _saveTrackPreferences(
|
||||
partId: partId,
|
||||
trackType: 'subtitle',
|
||||
languageCode: languageCode,
|
||||
streamID: streamID,
|
||||
);
|
||||
|
||||
try {
|
||||
if (!mounted) return;
|
||||
final client = _getClientForMetadata(context);
|
||||
|
||||
final futures = <Future>[];
|
||||
|
||||
// 1. Save language preference at series/movie level
|
||||
if (languageCode != null) {
|
||||
futures.add(client.setMetadataPreferences(languagePrefRatingKey, subtitleLanguage: languageCode));
|
||||
}
|
||||
// 2. Save exact stream selection using part ID
|
||||
if (streamID != null) {
|
||||
futures.add(client.selectStreams(partId, subtitleStreamID: streamID, allParts: true));
|
||||
}
|
||||
|
||||
await Future.wait(futures);
|
||||
appLogger.d('Successfully saved subtitle preferences (language + stream)');
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to save subtitle preferences', error: e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set flag to skip orientation restoration when replacing with another video
|
||||
|
||||
+54
-145
@@ -557,6 +557,27 @@ class PlexClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch data with cache checked first, network only on cache miss.
|
||||
///
|
||||
/// Use this when fresh data is not critical and prior fetches likely
|
||||
/// already populated the cache (e.g. playback after visiting detail screen).
|
||||
Future<T?> _fetchWithCacheFirst<T>({
|
||||
required String cacheKey,
|
||||
required Future<Response> Function() networkCall,
|
||||
required T? Function(dynamic cachedData) parseCache,
|
||||
required T? Function(Response response) parseResponse,
|
||||
bool cacheResponse = true,
|
||||
}) async {
|
||||
final cached = await _cache.get(serverId, cacheKey);
|
||||
if (cached != null) return parseCache(cached);
|
||||
if (_offlineMode) return null;
|
||||
final response = await networkCall();
|
||||
if (cacheResponse && response.data != null) {
|
||||
await _cache.put(serverId, cacheKey, response.data);
|
||||
}
|
||||
return parseResponse(response);
|
||||
}
|
||||
|
||||
/// Get first metadata JSON from response data
|
||||
Map<String, dynamic>? _getFirstMetadataJsonFromData(Map<String, dynamic>? data) =>
|
||||
PlexCacheParser.extractFirstMetadata(data);
|
||||
@@ -651,6 +672,23 @@ class PlexClient {
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Parse markers from metadata JSON
|
||||
List<PlexMarker> _parseMarkers(Map<String, dynamic>? metadataJson) {
|
||||
if (metadataJson == null || metadataJson['Marker'] == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final markerList = metadataJson['Marker'] as List;
|
||||
return markerList.map((marker) {
|
||||
return PlexMarker(
|
||||
id: marker['id'] as int,
|
||||
type: marker['type'] as String,
|
||||
startTimeOffset: marker['startTimeOffset'] as int,
|
||||
endTimeOffset: marker['endTimeOffset'] as int,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/// Set per-media language preferences (audio and subtitle)
|
||||
/// For TV shows, use grandparentRatingKey to set preference for the entire series
|
||||
/// For movies, use the movie's ratingKey
|
||||
@@ -869,145 +907,31 @@ class PlexClient {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get chapters for a media item
|
||||
Future<List<PlexChapter>> 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<dynamic>;
|
||||
return chapterList.map((chapter) {
|
||||
return PlexChapter(
|
||||
id: chapter['id'] as int,
|
||||
index: chapter['index'] as int?,
|
||||
startTimeOffset: chapter['startTimeOffset'] as int?,
|
||||
endTimeOffset: chapter['endTimeOffset'] as int?,
|
||||
title: chapter['tag'] as String?,
|
||||
thumb: chapter['thumb'] as String?,
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
Future<List<PlexMarker>> 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 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) async {
|
||||
// Use same cache key as other metadata methods
|
||||
final cacheKey = '/library/metadata/$ratingKey';
|
||||
|
||||
// If offline mode, return from cache only
|
||||
if (_offlineMode) {
|
||||
final cached = await _cache.get(serverId, cacheKey);
|
||||
if (cached != null) {
|
||||
return _parsePlaybackExtrasFromCachedResponse(cached);
|
||||
}
|
||||
return PlaybackExtras(chapters: [], markers: []);
|
||||
}
|
||||
|
||||
// Online: check cache first (already has chapters/markers from other fetches)
|
||||
appLogger.d('getPlaybackExtras: serverId=$serverId, cacheKey=$cacheKey');
|
||||
final cached = await _cache.get(serverId, cacheKey);
|
||||
if (cached != null) {
|
||||
final chapters = PlexCacheParser.extractChapters(cached);
|
||||
appLogger.d('getPlaybackExtras: cache hit, ${chapters?.length ?? 0} chapters');
|
||||
return _parsePlaybackExtrasFromCachedResponse(cached);
|
||||
}
|
||||
appLogger.d('getPlaybackExtras: cache miss');
|
||||
|
||||
// Not in cache - fetch and cache
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'/library/metadata/$ratingKey',
|
||||
queryParameters: {'includeChapters': 1, 'includeMarkers': 1},
|
||||
final data = await _fetchWithCacheFirst<Map<String, dynamic>>(
|
||||
cacheKey: '/library/metadata/$ratingKey',
|
||||
networkCall: () =>
|
||||
_dio.get('/library/metadata/$ratingKey', queryParameters: {'includeChapters': 1, 'includeMarkers': 1}),
|
||||
parseCache: (cached) => cached as Map<String, dynamic>?,
|
||||
parseResponse: (response) => response.data as Map<String, dynamic>?,
|
||||
);
|
||||
|
||||
// Cache at base endpoint
|
||||
if (response.data != null) {
|
||||
await _cache.put(serverId, cacheKey, response.data);
|
||||
}
|
||||
|
||||
return _parsePlaybackExtrasFromResponse(response);
|
||||
final metadataJson = _getFirstMetadataJsonFromData(data);
|
||||
return _parsePlaybackExtrasFromMetadataJson(metadataJson);
|
||||
} catch (e) {
|
||||
// Network failed
|
||||
appLogger.w('Network request failed for playback extras', error: e);
|
||||
appLogger.w('Failed to get playback extras', error: e);
|
||||
return PlaybackExtras(chapters: [], markers: []);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse PlaybackExtras from API response
|
||||
PlaybackExtras _parsePlaybackExtrasFromResponse(Response response) {
|
||||
final metadataJson = _getFirstMetadataJson(response);
|
||||
return _parsePlaybackExtrasFromMetadataJson(metadataJson);
|
||||
}
|
||||
|
||||
/// Parse PlaybackExtras from cached response
|
||||
PlaybackExtras _parsePlaybackExtrasFromCachedResponse(Map<String, dynamic> cached) {
|
||||
final metadataJson = PlexCacheParser.extractFirstMetadata(cached);
|
||||
return _parsePlaybackExtrasFromMetadataJson(metadataJson);
|
||||
}
|
||||
|
||||
/// Parse PlaybackExtras from metadata JSON
|
||||
PlaybackExtras _parsePlaybackExtrasFromMetadataJson(Map<String, dynamic>? metadataJson) {
|
||||
final chapters = <PlexChapter>[];
|
||||
final markers = <PlexMarker>[];
|
||||
|
||||
if (metadataJson != null) {
|
||||
// Parse chapters
|
||||
if (metadataJson['Chapter'] != null) {
|
||||
final chapterList = metadataJson['Chapter'] as List<dynamic>;
|
||||
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['tag'] as String?,
|
||||
thumb: chapter['thumb'] as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse markers
|
||||
if (metadataJson['Marker'] != null) {
|
||||
final markerList = metadataJson['Marker'] as List;
|
||||
for (var marker in markerList) {
|
||||
markers.add(
|
||||
PlexMarker(
|
||||
id: marker['id'] as int,
|
||||
type: marker['type'] as String,
|
||||
startTimeOffset: marker['startTimeOffset'] as int,
|
||||
endTimeOffset: marker['endTimeOffset'] as int,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return PlaybackExtras.withChapterFallback(chapters: chapters, markers: markers);
|
||||
return PlaybackExtras.withChapterFallback(
|
||||
chapters: _parseChapters(metadataJson),
|
||||
markers: _parseMarkers(metadataJson),
|
||||
);
|
||||
}
|
||||
|
||||
/// Get consolidated video playback data (URL, media info, versions, and markers) in a single API call.
|
||||
@@ -1016,7 +940,7 @@ class PlexClient {
|
||||
Future<PlexVideoPlaybackData> getVideoPlaybackData(String ratingKey, {int mediaIndex = 0}) async {
|
||||
Map<String, dynamic>? data;
|
||||
try {
|
||||
data = await _fetchWithCacheFallback<Map<String, dynamic>>(
|
||||
data = await _fetchWithCacheFirst<Map<String, dynamic>>(
|
||||
cacheKey: '/library/metadata/$ratingKey',
|
||||
networkCall: () =>
|
||||
_dio.get('/library/metadata/$ratingKey', queryParameters: {'includeMarkers': 1, 'includeChapters': 1}),
|
||||
@@ -1031,24 +955,9 @@ class PlexClient {
|
||||
String? videoUrl;
|
||||
PlexMediaInfo? mediaInfo;
|
||||
List<PlexMediaVersion> availableVersions = [];
|
||||
List<PlexMarker> markers = [];
|
||||
final markers = _parseMarkers(metadataJson);
|
||||
|
||||
if (metadataJson != null) {
|
||||
// Parse markers (for auto-skip functionality)
|
||||
if (metadataJson['Marker'] != null) {
|
||||
final markerList = metadataJson['Marker'] as List;
|
||||
for (var marker in markerList) {
|
||||
markers.add(
|
||||
PlexMarker(
|
||||
id: marker['id'] as int,
|
||||
type: marker['type'] as String,
|
||||
startTimeOffset: marker['startTimeOffset'] as int,
|
||||
endTimeOffset: marker['endTimeOffset'] as int,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (metadataJson['Media'] != null && (metadataJson['Media'] as List).isNotEmpty) {
|
||||
final mediaList = metadataJson['Media'] as List;
|
||||
|
||||
@@ -1099,7 +1008,7 @@ class PlexClient {
|
||||
/// Uses cache for offline mode support and network fallback.
|
||||
Future<PlexFileInfo?> getFileInfo(String ratingKey) async {
|
||||
try {
|
||||
final data = await _fetchWithCacheFallback<Map<String, dynamic>>(
|
||||
final data = await _fetchWithCacheFirst<Map<String, dynamic>>(
|
||||
cacheKey: '/library/metadata/$ratingKey',
|
||||
networkCall: () =>
|
||||
_dio.get('/library/metadata/$ratingKey', queryParameters: {'includeMarkers': 1, 'includeChapters': 1}),
|
||||
|
||||
Reference in New Issue
Block a user