perf: cache-first metadata fetching for playback

This commit is contained in:
edde746
2026-02-23 10:51:25 +01:00
parent edbe2a06be
commit 1eb88e23f5
2 changed files with 164 additions and 271 deletions
+110 -126
View File
@@ -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
View File
@@ -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}),