diff --git a/lib/services/downloaded_embedded_cover_resolver.dart b/lib/services/downloaded_embedded_cover_resolver.dart index a70b2dc2..3bea4388 100644 --- a/lib/services/downloaded_embedded_cover_resolver.dart +++ b/lib/services/downloaded_embedded_cover_resolver.dart @@ -13,14 +13,27 @@ class _EmbeddedCoverCacheEntry { final String previewPath; final int? sourceModTimeMillis; final bool isPersistent; + DateTime? lastValidatedAt; - const _EmbeddedCoverCacheEntry({ + _EmbeddedCoverCacheEntry({ required this.previewPath, required this.isPersistent, this.sourceModTimeMillis, }); } +class _PendingPreviewValidation { + final _EmbeddedCoverCacheEntry entry; + final Set callbacks = {}; + _PendingPreviewValidation(this.entry); + + void notify() { + for (final callback in callbacks) { + callback(); + } + } +} + class _PendingEmbeddedCoverExtraction { final String cleanPath; final bool forceRefresh; @@ -74,7 +87,12 @@ class DownloadedEmbeddedCoverResolver { static bool _drainScheduled = false; static final Map _cacheGeneration = {}; static final Set _pendingRefresh = {}; - static final Set _pendingPreviewValidation = {}; + static final _pendingPreviewValidation = {}; + static Future? _previewValidationFuture; + static const _previewValidationInterval = Duration(seconds: 5); + static const _previewValidationBatchSize = 8; + static const _maxPendingPreviewValidations = 64; + static DateTime Function() _validationClock = DateTime.now; static final LinkedHashSet _failedExtract = LinkedHashSet(); static Directory? _persistentCacheDirectoryOverride; @@ -243,6 +261,7 @@ class DownloadedEmbeddedCoverResolver { _cache.clear(); _pendingRefresh.clear(); _pendingPreviewValidation.clear(); + await _previewValidationFuture; _failedExtract.clear(); for (final entry in entries) { if (!entry.isPersistent) await _cleanupTempCoverPath(entry.previewPath); @@ -290,6 +309,8 @@ class DownloadedEmbeddedCoverResolver { _backgroundExtractionQueue.clear(); _pendingRefresh.clear(); _pendingPreviewValidation.clear(); + await _previewValidationFuture; + _validationClock = DateTime.now; _failedExtract.clear(); _cacheGeneration.clear(); _drainScheduled = false; @@ -356,38 +377,108 @@ class DownloadedEmbeddedCoverResolver { _EmbeddedCoverCacheEntry entry, { VoidCallback? onChanged, }) { - if (_pendingPreviewValidation.contains(cleanPath)) return; - _pendingPreviewValidation.add(cleanPath); - Future.microtask(() async { - try { - final exists = await fileExists(entry.previewPath); - final latest = _cache[cleanPath]; - if (!identical(latest, entry)) return; + final existing = _pendingPreviewValidation[cleanPath]; + if (existing != null && identical(existing.entry, entry)) { + if (onChanged != null) existing.callbacks.add(onChanged); + return; + } + final lastValidated = entry.lastValidatedAt; + if (lastValidated != null && + _validationClock().difference(lastValidated) < + _previewValidationInterval) { + return; + } + if (_pendingPreviewValidation.length >= _maxPendingPreviewValidations) { + return; // A later visible cache hit can retry; extraction is unaffected. + } + final request = _PendingPreviewValidation(entry); + if (onChanged != null) request.callbacks.add(onChanged); + _pendingPreviewValidation[cleanPath] = request; + _previewValidationFuture ??= Future.microtask(_drainPreviewValidations); + } - if (!exists) { - _cache.remove(cleanPath); - _failedExtract.remove(cleanPath); - await _cleanupCacheEntry(entry); - onChanged?.call(); - return; - } - - final cachedModTime = entry.sourceModTimeMillis; - if (cachedModTime != null) { - final currentModTime = await readFileModTimeMillis(cleanPath); - if (currentModTime != null && currentModTime != cachedModTime) { - await _ensureCover( - cleanPath, - forceRefresh: true, - knownModTime: currentModTime, - onChanged: onChanged, - ); + static Future _drainPreviewValidations() async { + try { + while (_pendingPreviewValidation.isNotEmpty) { + final batch = _pendingPreviewValidation.entries + .take(_previewValidationBatchSize) + .toList(growable: false); + final safPaths = batch + .where( + (item) => + isContentUri(item.key) && + item.value.entry.sourceModTimeMillis != null, + ) + .map((item) => item.key) + .toList(growable: false); + Map safModTimes = const {}; + if (safPaths.isNotEmpty) { + try { + safModTimes = await PlatformBridge.getSafFileModTimes(safPaths); + } catch (_) { + // An unavailable provider is not evidence of changed artwork. } } - } finally { - _pendingPreviewValidation.remove(cleanPath); + await Future.wait( + batch.map((item) async { + final path = item.key; + final request = item.value; + final entry = request.entry; + bool isCurrent() => + identical(_cache[path], entry) && + identical(_pendingPreviewValidation[path], request); + try { + if (!isCurrent()) return; + final exists = await fileExists(entry.previewPath); + if (!isCurrent()) return; + if (!exists) { + _cache.remove(path); + _failedExtract.remove(path); + await _cleanupCacheEntry(entry); + request.notify(); + return; + } + final cachedModTime = entry.sourceModTimeMillis; + final currentModTime = cachedModTime == null + ? null + : isContentUri(path) + ? safModTimes[path] + : await readFileModTimeMillis(path); + if (!isCurrent()) return; + entry.lastValidatedAt = _validationClock(); + if (currentModTime != null && + currentModTime > 0 && + currentModTime != cachedModTime) { + await _ensureCover( + path, + forceRefresh: true, + knownModTime: currentModTime, + onChanged: request.notify, + ); + } + } catch (_) { + // Keep the cached preview on inconclusive I/O failures. + } finally { + if (identical(_pendingPreviewValidation[path], request)) { + _pendingPreviewValidation.remove(path); + } + } + }), + ); } - }); + } finally { + _previewValidationFuture = null; + } + } + + @visibleForTesting + static void setValidationClockForTesting(DateTime Function() clock) { + _validationClock = clock; + } + + @visibleForTesting + static Future waitForPreviewValidationForTesting() async { + await _previewValidationFuture; } static Future _ensureCover( diff --git a/test/downloaded_embedded_cover_resolver_test.dart b/test/downloaded_embedded_cover_resolver_test.dart index 853952d0..54e2c90f 100644 --- a/test/downloaded_embedded_cover_resolver_test.dart +++ b/test/downloaded_embedded_cover_resolver_test.dart @@ -220,6 +220,161 @@ void main() { }, ); + test( + 'cached SAF previews batch validation and throttle repeated hits', + () async { + var now = DateTime.utc(2026); + DownloadedEmbeddedCoverResolver.setValidationClockForTesting(() => now); + final paths = List.generate( + 18, + (index) => 'content://covers/document/$index', + ); + final batches = >[]; + var extractions = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(backendChannel, (call) async { + final args = call.arguments as Map; + if (call.method == 'getSafFileModTimes') { + final uris = (jsonDecode(args['uris'] as String) as List) + .cast(); + batches.add(uris); + return jsonEncode({for (final path in uris) path: 1234}); + } + expect(call.method, 'extractCoverToFile'); + extractions++; + await File(args['output_path'] as String).writeAsBytes([1, 2, 3]); + return jsonEncode({'success': true}); + }); + for (final path in paths) { + expect( + await DownloadedEmbeddedCoverResolver.resolveOrExtract(path), + isNotNull, + ); + } + batches.clear(); + for (final path in paths) { + DownloadedEmbeddedCoverResolver.resolve(path); + DownloadedEmbeddedCoverResolver.resolve(path); + } + await DownloadedEmbeddedCoverResolver.waitForPreviewValidationForTesting(); + expect(batches.map((batch) => batch.length), [8, 8, 2]); + expect(batches.expand((batch) => batch).toSet(), paths.toSet()); + for (final path in paths) { + DownloadedEmbeddedCoverResolver.resolve(path); + } + await DownloadedEmbeddedCoverResolver.waitForPreviewValidationForTesting(); + expect(batches.length, 3); + now = now.add(const Duration(seconds: 5)); + for (final path in paths) { + DownloadedEmbeddedCoverResolver.resolve(path); + } + await DownloadedEmbeddedCoverResolver.waitForPreviewValidationForTesting(); + expect(batches.length, 6); + expect(extractions, paths.length); + // Explicit edits bypass the interval, even immediately after validation. + await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( + paths.first, + force: true, + ); + await DownloadedEmbeddedCoverResolver.resolveOrExtract(paths.first); + expect(extractions, paths.length + 1); + }, + ); + + test( + 'validation shares callbacks and detects external changes after interval', + () async { + var now = DateTime.utc(2026); + DownloadedEmbeddedCoverResolver.setValidationClockForTesting(() => now); + const path = 'content://covers/document/external'; + var modTime = 1234; + var extractions = 0; + var modTimeCalls = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(backendChannel, (call) async { + if (call.method == 'getSafFileModTimes') { + modTimeCalls++; + return jsonEncode({path: modTime}); + } + expect(call.method, 'extractCoverToFile'); + extractions++; + await File( + (call.arguments as Map)['output_path'] as String, + ).writeAsBytes([1, 2, 3]); + return jsonEncode({'success': true}); + }); + await DownloadedEmbeddedCoverResolver.resolveOrExtract(path); + DownloadedEmbeddedCoverResolver.resolve(path); + await DownloadedEmbeddedCoverResolver.waitForPreviewValidationForTesting(); + modTime = 5678; + DownloadedEmbeddedCoverResolver.resolve(path); + await DownloadedEmbeddedCoverResolver.waitForPreviewValidationForTesting(); + expect(extractions, 1); + expect(modTimeCalls, 2); + now = now.add(const Duration(seconds: 5)); + var firstNotified = 0; + var secondNotified = 0; + DownloadedEmbeddedCoverResolver.resolve( + path, + onChanged: () => firstNotified++, + ); + DownloadedEmbeddedCoverResolver.resolve( + path, + onChanged: () => secondNotified++, + ); + await DownloadedEmbeddedCoverResolver.waitForPreviewValidationForTesting(); + expect(extractions, 2); + expect(firstNotified, 1); + expect(secondNotified, 1); + }, + ); + + test( + 'invalidation during a pending validation cannot restore stale artwork', + () async { + const path = 'content://covers/document/invalidation'; + final entered = Completer(); + final release = Completer(); + addTearDown(() { + if (!release.isCompleted) release.complete(); + }); + var modTimeCalls = 0; + var extractions = 0; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(backendChannel, (call) async { + if (call.method == 'getSafFileModTimes') { + if (++modTimeCalls == 2) { + entered.complete(); + await release.future; + return jsonEncode({path: 5678}); + } + return jsonEncode({path: 1234}); + } + expect(call.method, 'extractCoverToFile'); + extractions++; + await File( + (call.arguments as Map)['output_path'] as String, + ).writeAsBytes([1, 2, 3]); + return jsonEncode({'success': true}); + }); + final preview = await DownloadedEmbeddedCoverResolver.resolveOrExtract( + path, + ); + var notified = false; + DownloadedEmbeddedCoverResolver.resolve( + path, + onChanged: () => notified = true, + ); + await entered.future.timeout(const Duration(seconds: 2)); + await DownloadedEmbeddedCoverResolver.invalidate(path); + release.complete(); + await DownloadedEmbeddedCoverResolver.waitForPreviewValidationForTesting(); + expect(extractions, 1); + expect(notified, isFalse); + expect(await File(preview!).exists(), isFalse); + }, + ); + test('foreground extraction is promoted ahead of background jobs', () async { final paths = await Future.wait([ for (var index = 0; index < 4; index++)