fix: reduce main-thread DB I/O blocking in downloads
This commit is contained in:
@@ -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}%');
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user