diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 11139d4f..b5cbbee9 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -89,6 +89,10 @@ class DownloadProvider extends ChangeNotifier { // Load all downloads from database final downloads = await _downloadManager.getAllDownloads(); + + // Bulk-load all pinned metadata in a single query instead of per-item DB calls + final allMetadata = await apiCache.getAllPinnedMetadata(); + for (final item in downloads) { _downloads[item.globalKey] = DownloadProgress( globalKey: item.globalKey, @@ -101,14 +105,15 @@ class DownloadProvider extends ChangeNotifier { // Store Plex thumb path reference (file path computed from hash when needed) _artworkPaths[item.globalKey] = DownloadedArtwork(thumbPath: item.thumbPath); - // Load metadata from API cache (base endpoint - chapters/markers included in data) - final metadata = await apiCache.getMetadata(item.serverId, item.ratingKey); + // Look up metadata from the bulk-loaded map (O(1) instead of DB query per item) + // Falls back to individual query for any unpinned entries (e.g., legacy data) + final metadata = allMetadata[item.globalKey] ?? await apiCache.getMetadata(item.serverId, item.ratingKey); if (metadata != null) { _metadata[item.globalKey] = metadata; - // For episodes, also load parent (show and season) metadata + // For episodes, also load parent (show and season) metadata from the same map if (metadata.isEpisode) { - await _loadParentMetadataFromCache(metadata, apiCache); + _loadParentMetadataFromMap(metadata, allMetadata); } } } @@ -158,20 +163,20 @@ class DownloadProvider extends ChangeNotifier { } } - /// Load parent (show and season) metadata from cache for an episode - Future _loadParentMetadataFromCache(PlexMetadata episode, PlexApiCache apiCache) async { + /// Load parent (show and season) metadata from a pre-loaded map (no DB I/O). + /// Used during bulk initialization to avoid per-item DB queries. + void _loadParentMetadataFromMap(PlexMetadata episode, Map allMetadata) { final serverId = episode.serverId; if (serverId == null) return; - // Load show metadata (base endpoint) + // Load show metadata final showRatingKey = episode.grandparentRatingKey; if (showRatingKey != null) { final showGlobalKey = '$serverId:$showRatingKey'; if (!_metadata.containsKey(showGlobalKey)) { - final showMetadata = await apiCache.getMetadata(serverId, showRatingKey); + final showMetadata = allMetadata[showGlobalKey]; if (showMetadata != null) { _metadata[showGlobalKey] = showMetadata; - // Store artwork reference for offline display if (showMetadata.thumb != null) { _artworkPaths[showGlobalKey] = DownloadedArtwork(thumbPath: showMetadata.thumb); } @@ -179,15 +184,14 @@ class DownloadProvider extends ChangeNotifier { } } - // Load season metadata (base endpoint) + // Load season metadata final seasonRatingKey = episode.parentRatingKey; if (seasonRatingKey != null) { final seasonGlobalKey = '$serverId:$seasonRatingKey'; if (!_metadata.containsKey(seasonGlobalKey)) { - final seasonMetadata = await apiCache.getMetadata(serverId, seasonRatingKey); + final seasonMetadata = allMetadata[seasonGlobalKey]; if (seasonMetadata != null) { _metadata[seasonGlobalKey] = seasonMetadata; - // Store artwork reference for offline display if (seasonMetadata.thumb != null) { _artworkPaths[seasonGlobalKey] = DownloadedArtwork(thumbPath: seasonMetadata.thumb); } @@ -196,6 +200,7 @@ class DownloadProvider extends ChangeNotifier { } } + void _onProgressUpdate(DownloadProgress progress) { appLogger.d('Progress update received: ${progress.globalKey} - ${progress.status} - ${progress.progress}%'); diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index a8066915..bea13129 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -165,6 +165,11 @@ extension DownloadDatabaseOperations on AppDatabase { return (select(downloadedMedia)..where((t) => t.grandparentRatingKey.equals(showKey))).get(); } + /// Get all downloaded items for a specific server + Future> getDownloadsByServerId(String serverId) { + return (select(downloadedMedia)..where((t) => t.serverId.equals(serverId))).get(); + } + /// Update the background_downloader task ID for a download Future updateBgTaskId(String globalKey, String? taskId) async { await (update( @@ -236,6 +241,10 @@ class DownloadManagerService { // Prevents concurrent _processQueue calls bool _isProcessingQueue = false; + // Debounce timers for DB progress writes (keyed by globalKey). + // UI progress streams are still real-time; only the DB write is debounced. + final Map _progressDebounceTimers = {}; + /// Public method to check if downloads should be blocked due to cellular-only setting /// Can be used by DownloadProvider to show user-friendly error static Future shouldBlockDownloadOnCellular() async { @@ -643,8 +652,15 @@ class DownloadManagerService { ), ); - _database.updateDownloadProgress(globalKey, progress, downloadedBytes, totalBytes).catchError((e) { - appLogger.w('Failed to update download progress in DB', error: e); + // Debounce DB writes — only the latest progress value is persisted after + // a 2-second settle period. The stream above provides real-time UI updates; + // the DB write is only for crash-recovery state. + _progressDebounceTimers[globalKey]?.cancel(); + _progressDebounceTimers[globalKey] = Timer(const Duration(seconds: 2), () { + _progressDebounceTimers.remove(globalKey); + _database.updateDownloadProgress(globalKey, progress, downloadedBytes, totalBytes).catchError((e) { + appLogger.w('Failed to update download progress in DB', error: e); + }); }); } @@ -706,6 +722,9 @@ class DownloadManagerService { /// Handle a completed video download — store path, download supplementary content, mark done. Future _onDownloadComplete(String globalKey, Task task) async { try { + // Flush any pending debounced progress write before completing + _progressDebounceTimers.remove(globalKey)?.cancel(); + final ctx = _pendingDownloadContext.remove(globalKey); // ── Phase 1 (critical): resolve and store the video file path ── @@ -1275,37 +1294,11 @@ class DownloadManagerService { } } - /// Check if a chapter thumbnail is used by any other downloaded items - Future _isChapterThumbnailInUse(String serverId, String thumbPath, String excludeRatingKey) async { - try { - // Get all downloaded items - final allItems = await _database.select(_database.downloadedMedia).get(); - - // Check if any other item uses this chapter thumbnail - for (final item in allItems) { - // Skip the item being deleted - if (item.ratingKey == excludeRatingKey) { - continue; - } - - // Get chapter thumb paths for this item - final itemChapterPaths = await _getChapterThumbPaths(serverId, item.ratingKey); - - // Check if this item has the same thumb path - if (itemChapterPaths.contains(thumbPath)) { - return true; // Thumbnail is in use - } - } - - return false; // Thumbnail is not in use - } catch (e) { - appLogger.w('Error checking chapter thumbnail usage: $thumbPath', error: e); - // On error, assume in use to be safe (don't delete) - return true; - } - } - - /// Delete chapter thumbnails for a media item (with reference counting) + /// Delete chapter thumbnails for a media item (with reference counting). + /// + /// Pre-loads all chapter paths for other items on the same server in one pass, + /// then checks membership in a Set — O(items * chapters) instead of + /// O(thumbs * items * chapters) with repeated DB queries. Future _deleteChapterThumbnails(String serverId, String ratingKey) async { try { final thumbPaths = await _getChapterThumbPaths(serverId, ratingKey); @@ -1315,15 +1308,21 @@ class DownloadManagerService { return; } + // Build a set of all chapter thumb paths used by OTHER items on this server + final otherItems = await _database.getDownloadsByServerId(serverId); + final inUseThumbPaths = {}; + for (final item in otherItems) { + if (item.ratingKey == ratingKey) continue; + final itemChapterPaths = await _getChapterThumbPaths(serverId, item.ratingKey); + inUseThumbPaths.addAll(itemChapterPaths); + } + int deletedCount = 0; int preservedCount = 0; for (final thumbPath in thumbPaths) { try { - // Check if this thumbnail is used by other items - final inUse = await _isChapterThumbnailInUse(serverId, thumbPath, ratingKey); - - if (inUse) { + if (inUseThumbPaths.contains(thumbPath)) { appLogger.d('Preserving chapter thumbnail (in use): $thumbPath'); preservedCount++; continue; @@ -1336,7 +1335,6 @@ class DownloadManagerService { } } catch (e) { appLogger.w('Failed to delete chapter thumbnail: $thumbPath', error: e); - // Continue with other chapters even if one fails } } @@ -1345,7 +1343,6 @@ class DownloadManagerService { } } catch (e, stack) { appLogger.w('Error deleting chapter thumbnails for $ratingKey', error: e, stackTrace: stack); - // Don't throw - chapter deletion shouldn't block main deletion } } @@ -1554,13 +1551,12 @@ class DownloadManagerService { Future _isShowArtworkInUse(PlexMetadata metadata, int? _) async { final showKey = metadata.grandparentRatingKey ?? metadata.parentRatingKey ?? metadata.ratingKey; - final allItems = await _database.select(_database.downloadedMedia).get(); + // Use targeted query instead of full table scan + final showEpisodes = await _database.getEpisodesByShow(showKey); - // Check if any items belong to this show besides this one - return allItems.any( - (item) => - (item.grandparentRatingKey == showKey || item.parentRatingKey == showKey) && - item.globalKey != '${metadata.serverId}:${metadata.ratingKey}', + // Check if any episodes belong to this show besides the current item + return showEpisodes.any( + (item) => item.globalKey != '${metadata.serverId}:${metadata.ratingKey}', ); } @@ -1667,6 +1663,10 @@ class DownloadManagerService { } void dispose() { + for (final timer in _progressDebounceTimers.values) { + timer.cancel(); + } + _progressDebounceTimers.clear(); _progressController.close(); _deletionProgressController.close(); } diff --git a/lib/services/plex_api_cache.dart b/lib/services/plex_api_cache.dart index c581b4a6..7a1832c8 100644 --- a/lib/services/plex_api_cache.dart +++ b/lib/services/plex_api_cache.dart @@ -122,6 +122,39 @@ class PlexApiCache { return PlexMetadata.fromJsonWithImages(json).copyWith(serverId: serverId); } + /// Load all pinned metadata in a single query. + /// + /// Returns a map keyed by `serverId:ratingKey` for O(1) lookups. + /// Used by DownloadProvider to batch-load metadata on startup instead of + /// issuing per-item DB queries. + Future> getAllPinnedMetadata() async { + final rows = await (_db.select(_db.apiCache) + ..where((t) => t.pinned.equals(true))) + .get(); + + final result = {}; + for (final row in rows) { + // Extract serverId and ratingKey from cache key like "serverId:/library/metadata/12345" + final colonIdx = row.cacheKey.indexOf(':'); + if (colonIdx < 0) continue; + final serverId = row.cacheKey.substring(0, colonIdx); + final match = RegExp(r'/library/metadata/([^/]+)$').firstMatch(row.cacheKey); + if (match == null) continue; + final ratingKey = match.group(1)!; + + try { + final data = jsonDecode(row.data) as Map; + final json = PlexCacheParser.extractFirstMetadata(data); + if (json == null) continue; + final metadata = PlexMetadata.fromJsonWithImages(json).copyWith(serverId: serverId); + result['$serverId:$ratingKey'] = metadata; + } catch (_) { + // Skip malformed entries + } + } + return result; + } + /// Clear all cached data (useful for debugging/testing) Future clearAll() async { await _db.delete(_db.apiCache).go();