fix: reduce main-thread DB I/O blocking in downloads

This commit is contained in:
edde746
2026-02-15 17:55:02 +01:00
parent 04438c8e45
commit 292c4b5d82
3 changed files with 95 additions and 57 deletions
+17 -12
View File
@@ -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<void> _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<String, PlexMetadata> 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}%');
+45 -45
View File
@@ -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<List<DownloadedMediaItem>> getDownloadsByServerId(String serverId) {
return (select(downloadedMedia)..where((t) => t.serverId.equals(serverId))).get();
}
/// Update the background_downloader task ID for a download
Future<void> 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<String, Timer> _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<bool> 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<void> _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<bool> _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<void> _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 = <String>{};
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<bool> _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();
}
+33
View File
@@ -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<Map<String, PlexMetadata>> getAllPinnedMetadata() async {
final rows = await (_db.select(_db.apiCache)
..where((t) => t.pinned.equals(true)))
.get();
final result = <String, PlexMetadata>{};
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<String, dynamic>;
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<void> clearAll() async {
await _db.delete(_db.apiCache).go();