diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index 079a608c..32a2fc43 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -1673,13 +1673,32 @@ class DownloadManagerService { /// Cache metadata in the API response format for offline access /// This simulates what PlexClient would receive from the server + /// Merges with existing cache to preserve Chapter/Marker/Media arrays Future _cacheMetadataForOffline(String serverId, String ratingKey, PlexMetadata metadata) async { final endpoint = '/library/metadata/$ratingKey'; - // Build a response structure that matches the Plex API format + // Check for existing cache entry to preserve fields not in PlexMetadata + final existing = await _apiCache.get(serverId, endpoint); + final existingMeta = PlexCacheParser.extractFirstMetadata(existing); + + Map merged; + if (existingMeta != null) { + // Start with existing (has Chapter/Marker/Media), overlay new metadata + merged = existingMeta; + final newJson = metadata.toJson(); + // Only update fields that toJson() sets to non-null values + for (final entry in newJson.entries) { + if (entry.value != null) { + merged[entry.key] = entry.value; + } + } + } else { + merged = metadata.toJson(); + } + final cachedResponse = { 'MediaContainer': { - 'Metadata': [metadata.toJson()], + 'Metadata': [merged], }, }; diff --git a/lib/services/offline_watch_sync_service.dart b/lib/services/offline_watch_sync_service.dart index 45546dbd..a8eec148 100644 --- a/lib/services/offline_watch_sync_service.dart +++ b/lib/services/offline_watch_sync_service.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import '../database/app_database.dart'; import '../providers/offline_mode_provider.dart'; import '../utils/app_logger.dart'; +import '../utils/plex_cache_parser.dart'; import 'multi_server_manager.dart'; import 'plex_api_cache.dart'; import 'plex_client.dart'; @@ -421,11 +422,32 @@ class OfflineWatchSyncService extends ChangeNotifier { for (final episode in seasonEpisodes) { if (!downloadedEpisodeKeys.contains(episode.ratingKey)) continue; - await PlexApiCache.instance.put(serverId, '/library/metadata/${episode.ratingKey}', { - 'MediaContainer': { - 'Metadata': [episode.toJson()], - }, - }); + final cacheKey = '/library/metadata/${episode.ratingKey}'; + final existing = await PlexApiCache.instance.get(serverId, cacheKey); + final existingMeta = PlexCacheParser.extractFirstMetadata(existing); + + if (existingMeta != null) { + // Only update watch-state fields — preserves Chapter/Marker/Media/etc. + existingMeta['viewCount'] = episode.viewCount; + existingMeta['viewOffset'] = episode.viewOffset; + existingMeta['lastViewedAt'] = episode.lastViewedAt; + existingMeta['viewedLeafCount'] = episode.viewedLeafCount; + await PlexApiCache.instance.put(serverId, cacheKey, { + 'MediaContainer': {'Metadata': [existingMeta]}, + }); + + // Repair corrupted entries (missing Media/Chapter from previous overwrites) + if (existingMeta['Media'] == null) { + try { + await client.getMetadataWithImages(episode.ratingKey); + } catch (_) {} + } + } else { + // No existing entry — write what we have + await PlexApiCache.instance.put(serverId, cacheKey, { + 'MediaContainer': {'Metadata': [episode.toJson()]}, + }); + } synced++; } @@ -501,13 +523,10 @@ class OfflineWatchSyncService extends ChangeNotifier { await _withOnlineClient(serverId, (client) async { for (final ratingKey in ratingKeys) { try { + // getMetadataWithImages already caches the full API response + // (with chapters/markers) via _fetchWithCacheFallback internally final metadata = await client.getMetadataWithImages(ratingKey); if (metadata != null) { - await PlexApiCache.instance.put(serverId, '/library/metadata/$ratingKey', { - 'MediaContainer': { - 'Metadata': [metadata.toJson()], - }, - }); syncedCount++; } } catch (e) { diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 08beeaed..661a537f 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -954,9 +954,10 @@ class PlexClient { /// Get chapters and markers from cached metadata or fetch if needed /// Uses same cache key as other metadata methods for consistency - Future getPlaybackExtras(String ratingKey, {String? introPattern, String? creditsPattern}) async { + Future getPlaybackExtras(String ratingKey, {String? introPattern, String? creditsPattern, bool forceRefresh = false}) async { try { - final data = await _fetchWithCacheFirst>( + final fetch = forceRefresh ? _fetchWithCacheFallback : _fetchWithCacheFirst; + final data = await fetch>( cacheKey: '/library/metadata/$ratingKey', networkCall: () => _dio.get('/library/metadata/$ratingKey', queryParameters: {'includeChapters': 1, 'includeMarkers': 1}), diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 411250ed..fc3759c2 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -212,6 +212,7 @@ class PlexVideoControls extends StatefulWidget { class _PlexVideoControlsState extends State with WindowListener, WidgetsBindingObserver { bool _showControls = true; bool _forceShowControls = false; + bool _isLoadingExtras = false; List _chapters = []; bool _chaptersLoaded = false; Timer? _hideTimer; @@ -334,6 +335,10 @@ class _PlexVideoControlsState extends State with WindowListen void _onFirstFrameReady() { if (widget.hasFirstFrame?.value == true) { _startHideTimer(); + // Retry with network-first if initial cache-first returned empty + if (_chapters.isEmpty && _markers.isEmpty) { + _loadPlaybackExtras(forceRefresh: true); + } } } @@ -930,19 +935,21 @@ class _PlexVideoControlsState extends State with WindowListen } } - Future _loadPlaybackExtras() async { + Future _loadPlaybackExtras({bool forceRefresh = false}) async { // Live TV metadata uses EPG rating keys, not library items if (widget.isLive) return; + if (_isLoadingExtras) return; + _isLoadingExtras = true; try { - appLogger.d('_loadPlaybackExtras: starting for ${widget.metadata.ratingKey}'); + appLogger.d('_loadPlaybackExtras: starting for ${widget.metadata.ratingKey} (forceRefresh=$forceRefresh)'); final client = _getClientForMetadata(); appLogger.d('_loadPlaybackExtras: got client with serverId=${client.serverId}'); final settings = await SettingsService.getInstance(); final introPattern = settings.getIntroPattern(); final creditsPattern = settings.getCreditsPattern(); - final extras = await client.getPlaybackExtras(widget.metadata.ratingKey, introPattern: introPattern, creditsPattern: creditsPattern); + final extras = await client.getPlaybackExtras(widget.metadata.ratingKey, introPattern: introPattern, creditsPattern: creditsPattern, forceRefresh: forceRefresh); appLogger.d('_loadPlaybackExtras: got ${extras.chapters.length} chapters'); if (mounted) { @@ -975,6 +982,8 @@ class _PlexVideoControlsState extends State with WindowListen } } appLogger.e('_loadPlaybackExtras failed', error: e, stackTrace: stack); + } finally { + _isLoadingExtras = false; } }