Files
SpotiFLAC-Mobile/lib/services/downloaded_embedded_cover_resolver.dart
T

958 lines
31 KiB
Dart

import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
class _EmbeddedCoverCacheEntry {
final String previewPath;
final int? sourceModTimeMillis;
final bool isPersistent;
const _EmbeddedCoverCacheEntry({
required this.previewPath,
required this.isPersistent,
this.sourceModTimeMillis,
});
}
class _PendingEmbeddedCoverExtraction {
final String cleanPath;
final bool forceRefresh;
final int? knownModTime;
final int generation;
final Completer<String?> completer = Completer<String?>();
final LinkedHashSet<VoidCallback> callbacks = LinkedHashSet<VoidCallback>();
bool isForeground;
bool started = false;
bool cancelled = false;
_PendingEmbeddedCoverExtraction({
required this.cleanPath,
required this.forceRefresh,
required this.knownModTime,
required this.generation,
required this.isForeground,
VoidCallback? onChanged,
}) {
if (onChanged != null) callbacks.add(onChanged);
}
Future<String?> get future => completer.future;
}
/// Shared resolver for embedded cover previews from downloaded/local files.
///
/// The in-memory LRU is backed by a source-versioned persistent cache, while
/// all misses still pass through the bounded, foreground-prioritized scheduler
/// so Library scrolling cannot fan out native I/O.
class DownloadedEmbeddedCoverResolver {
static const int _maxCacheEntries = 180;
static const int _maxFailedExtractEntries = 360;
static const int _maxConcurrentExtractions = 2;
static const int _maxQueuedBackgroundExtractions = 24;
static const String _persistentCacheDirectoryName = 'embedded_cover_previews';
static const String _persistentCacheVersion = 'v1';
static const int _maxPersistentCacheEntries = 384;
static const int _maxPersistentCacheBytes = 160 << 20;
static const int _persistentCacheSweepTargetBytes = 128 << 20;
static final LinkedHashMap<String, _EmbeddedCoverCacheEntry> _cache =
LinkedHashMap<String, _EmbeddedCoverCacheEntry>();
static final Map<String, _PendingEmbeddedCoverExtraction> _pendingExtract =
<String, _PendingEmbeddedCoverExtraction>{};
static final Queue<_PendingEmbeddedCoverExtraction>
_foregroundExtractionQueue = Queue<_PendingEmbeddedCoverExtraction>();
static final Queue<_PendingEmbeddedCoverExtraction>
_backgroundExtractionQueue = Queue<_PendingEmbeddedCoverExtraction>();
static int _activeExtractions = 0;
static bool _drainScheduled = false;
static final Map<String, int> _cacheGeneration = <String, int>{};
static final Set<String> _pendingRefresh = <String>{};
static final Set<String> _pendingPreviewValidation = <String>{};
static final LinkedHashSet<String> _failedExtract = LinkedHashSet<String>();
static Directory? _persistentCacheDirectoryOverride;
static Future<String>? _persistentCacheRootFuture;
static String? _persistentCacheRootPath;
static Future<void>? _persistentMaintenanceFuture;
static int _stagingSequence = 0;
static String cleanFilePath(String? filePath) {
if (filePath == null) return '';
if (filePath.startsWith('EXISTS:')) {
return filePath.substring(7);
}
return filePath;
}
static Future<int?> readFileModTimeMillis(String? filePath) async {
final cleanPath = cleanFilePath(filePath);
if (cleanPath.isEmpty) return null;
if (isContentUri(cleanPath)) {
try {
final modTimes = await PlatformBridge.getSafFileModTimes([cleanPath]);
final modTime = modTimes[cleanPath];
return modTime != null && modTime > 0 ? modTime : null;
} catch (_) {
return null;
}
}
try {
final stat = await File(cleanPath).stat();
if (stat.type == FileSystemEntityType.notFound) return null;
final modTime = stat.modified.millisecondsSinceEpoch;
return modTime > 0 ? modTime : null;
} catch (_) {
return null;
}
}
static String? resolve(String? filePath, {VoidCallback? onChanged}) {
final cleanPath = cleanFilePath(filePath);
if (cleanPath.isEmpty) return null;
if (_pendingRefresh.remove(cleanPath)) {
unawaited(
_ensureCover(
cleanPath,
forceRefresh: true,
onChanged: onChanged,
isForeground: true,
),
);
}
final cached = _cache[cleanPath];
if (cached != null) {
_touch(cleanPath, cached);
_validateCachedPreviewAsync(cleanPath, cached, onChanged: onChanged);
return cached.previewPath;
}
// A downloaded file is the source of truth for its artwork. Start the
// extraction on a cold cache so Library does not remain stuck on the
// metadata provider's online cover until Metadata Screen is opened.
unawaited(_ensureCover(cleanPath, onChanged: onChanged));
return null;
}
/// Returns the cached embedded cover, waiting for one shared extraction when
/// needed. Navigation uses this to start its Hero and blurred backdrop from
/// the same file artwork instead of swapping from the online fallback after
/// the route is already visible.
static Future<String?> resolveOrExtract(
String? filePath, {
VoidCallback? onChanged,
}) async {
final cleanPath = cleanFilePath(filePath);
if (cleanPath.isEmpty) return null;
if (_pendingRefresh.remove(cleanPath)) {
return _ensureCover(
cleanPath,
forceRefresh: true,
onChanged: onChanged,
isForeground: true,
);
}
final cached = _cache[cleanPath];
if (cached != null) {
_touch(cleanPath, cached);
_validateCachedPreviewAsync(cleanPath, cached, onChanged: onChanged);
return cached.previewPath;
}
return _ensureCover(cleanPath, onChanged: onChanged, isForeground: true);
}
/// Whether [previewPath] belongs to this resolver. Persistent previews stay
/// owned even after memory-LRU eviction, so borrowers must never delete a
/// file or recursively delete its shared parent directory.
static bool isManagedPreviewPath(String? previewPath) {
if (previewPath == null || previewPath.isEmpty) return false;
if (_cache.values.any((entry) => entry.previewPath == previewPath)) {
return true;
}
return _isPathInsidePersistentRoot(previewPath);
}
static Future<void> scheduleRefreshForPath(
String? filePath, {
int? beforeModTime,
bool force = false,
VoidCallback? onChanged,
}) async {
final cleanPath = cleanFilePath(filePath);
if (cleanPath.isEmpty) return;
if (!force) {
if (beforeModTime == null) return;
final afterModTime = await readFileModTimeMillis(cleanPath);
if (afterModTime != null && afterModTime == beforeModTime) {
return;
}
}
_pendingRefresh.add(cleanPath);
_failedExtract.remove(cleanPath);
onChanged?.call();
}
static Future<void> invalidate(String? filePath) async {
final cleanPath = cleanFilePath(filePath);
if (cleanPath.isEmpty) return;
_cacheGeneration[cleanPath] = (_cacheGeneration[cleanPath] ?? 0) + 1;
final cached = _cache.remove(cleanPath);
_cancelPendingExtraction(cleanPath);
_cacheGeneration.remove(cleanPath);
_pendingRefresh.remove(cleanPath);
_pendingPreviewValidation.remove(cleanPath);
_failedExtract.remove(cleanPath);
if (cached != null) {
await _cleanupCacheEntry(cached);
}
await _deletePersistentVariantsForSource(cleanPath);
}
/// Clears resolver state together with its App Cache-backed disk entries.
/// Running native work is generation-cancelled and awaited before the disk
/// directory is wiped, so a completed clear leaves no late staging output.
static Future<void> clearPersistentCache() async {
final keys = <String>{..._cache.keys, ..._pendingExtract.keys};
for (final key in keys) {
_cacheGeneration[key] = (_cacheGeneration[key] ?? 0) + 1;
_cancelPendingExtraction(key);
_cacheGeneration.remove(key);
}
while (_activeExtractions > 0) {
await Future<void>.delayed(const Duration(milliseconds: 1));
}
_cacheGeneration.clear();
final entries = _cache.values.toList(growable: false);
_cache.clear();
_pendingRefresh.clear();
_pendingPreviewValidation.clear();
_failedExtract.clear();
for (final entry in entries) {
if (!entry.isPersistent) await _cleanupTempCoverPath(entry.previewPath);
}
final maintenance = _persistentMaintenanceFuture;
if (maintenance != null) await maintenance;
final directory = await _getPersistentCacheDirectory();
await _clearDirectoryContents(directory);
}
@visibleForTesting
static void setPersistentCacheDirectoryForTesting(Directory? directory) {
if (_activeExtractions != 0 || _pendingExtract.isNotEmpty) {
throw StateError('Cannot replace persistent cache while work is active');
}
_persistentCacheDirectoryOverride = directory;
_persistentCacheRootFuture = null;
_persistentCacheRootPath = directory == null
? null
: _normalizedAbsolutePath(directory.path);
}
/// Simulates a process restart without deleting persistent preview files.
@visibleForTesting
static Future<void> resetMemoryStateForTesting({
bool preservePersistentFiles = true,
}) async {
final maintenance = _persistentMaintenanceFuture;
if (maintenance != null) await maintenance;
final keys = <String>{..._cache.keys, ..._pendingExtract.keys};
for (final key in keys) {
_cacheGeneration[key] = (_cacheGeneration[key] ?? 0) + 1;
_cancelPendingExtraction(key);
}
while (_activeExtractions > 0) {
await Future<void>.delayed(const Duration(milliseconds: 1));
}
final entries = _cache.values.toList(growable: false);
_cache.clear();
_pendingExtract.clear();
_foregroundExtractionQueue.clear();
_backgroundExtractionQueue.clear();
_pendingRefresh.clear();
_pendingPreviewValidation.clear();
_failedExtract.clear();
_cacheGeneration.clear();
_drainScheduled = false;
for (final entry in entries) {
if (!entry.isPersistent) await _cleanupTempCoverPath(entry.previewPath);
}
if (!preservePersistentFiles) {
final directory = await _getPersistentCacheDirectory();
await _clearDirectoryContents(directory);
}
}
@visibleForTesting
static Future<void> runPersistentCacheMaintenanceForTesting({
int maxEntries = _maxPersistentCacheEntries,
int maxBytes = _maxPersistentCacheBytes,
int targetBytes = _persistentCacheSweepTargetBytes,
}) async {
final scheduled = _persistentMaintenanceFuture;
if (scheduled != null) await scheduled;
final directory = await _getPersistentCacheDirectory();
await _runPersistentCacheMaintenance(
directory,
maxEntries: maxEntries,
maxBytes: maxBytes,
targetBytes: targetBytes,
);
}
static void _touch(String cleanPath, _EmbeddedCoverCacheEntry entry) {
_cache
..remove(cleanPath)
..[cleanPath] = entry;
}
static void _trimCacheIfNeeded() {
while (_cache.length > _maxCacheEntries) {
final oldestKey = _cache.keys.first;
final removed = _cache.remove(oldestKey);
if (removed != null && !removed.isPersistent) {
_scheduleTempCoverCleanup(removed.previewPath);
}
_cacheGeneration[oldestKey] = (_cacheGeneration[oldestKey] ?? 0) + 1;
_cancelPendingExtraction(oldestKey);
_cacheGeneration.remove(oldestKey);
_pendingRefresh.remove(oldestKey);
_pendingPreviewValidation.remove(oldestKey);
_failedExtract.remove(oldestKey);
}
}
static void _rememberFailedExtract(String cleanPath) {
_failedExtract
..remove(cleanPath)
..add(cleanPath);
while (_failedExtract.length > _maxFailedExtractEntries) {
_failedExtract.remove(_failedExtract.first);
}
}
static void _validateCachedPreviewAsync(
String cleanPath,
_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;
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,
);
}
}
} finally {
_pendingPreviewValidation.remove(cleanPath);
}
});
}
static Future<String?> _ensureCover(
String cleanPath, {
bool forceRefresh = false,
int? knownModTime,
VoidCallback? onChanged,
bool isForeground = false,
}) {
if (cleanPath.isEmpty) return Future<String?>.value();
final inFlight = _pendingExtract[cleanPath];
if (inFlight != null) {
if (onChanged != null) inFlight.callbacks.add(onChanged);
if (isForeground && !inFlight.started && !inFlight.isForeground) {
inFlight.isForeground = true;
if (_backgroundExtractionQueue.remove(inFlight)) {
_foregroundExtractionQueue.addLast(inFlight);
}
_scheduleExtractionDrain();
}
return inFlight.future;
}
final cached = _cache[cleanPath];
if (!forceRefresh && cached != null) {
return Future<String?>.value(cached.previewPath);
}
if (!forceRefresh && _failedExtract.contains(cleanPath)) {
return Future<String?>.value();
}
final job = _PendingEmbeddedCoverExtraction(
cleanPath: cleanPath,
forceRefresh: forceRefresh,
knownModTime: knownModTime,
generation: _cacheGeneration[cleanPath] ?? 0,
isForeground: isForeground,
onChanged: onChanged,
);
_pendingExtract[cleanPath] = job;
if (isForeground) {
_foregroundExtractionQueue.addLast(job);
} else {
_backgroundExtractionQueue.addLast(job);
_trimBackgroundExtractionQueue();
}
_scheduleExtractionDrain();
return job.future;
}
static void _trimBackgroundExtractionQueue() {
while (_backgroundExtractionQueue.length >
_maxQueuedBackgroundExtractions) {
final staleJob = _backgroundExtractionQueue.removeFirst();
staleJob.cancelled = true;
if (identical(_pendingExtract[staleJob.cleanPath], staleJob)) {
_pendingExtract.remove(staleJob.cleanPath);
}
if (!staleJob.completer.isCompleted) {
staleJob.completer.complete(null);
}
}
}
static void _scheduleExtractionDrain() {
if (_drainScheduled) return;
_drainScheduled = true;
scheduleMicrotask(() {
_drainScheduled = false;
_drainExtractionQueue();
});
}
static void _drainExtractionQueue() {
while (_activeExtractions < _maxConcurrentExtractions) {
final _PendingEmbeddedCoverExtraction? job;
if (_foregroundExtractionQueue.isNotEmpty) {
job = _foregroundExtractionQueue.removeFirst();
} else if (_backgroundExtractionQueue.isNotEmpty) {
job = _backgroundExtractionQueue.removeFirst();
} else {
return;
}
if (job.cancelled || !identical(_pendingExtract[job.cleanPath], job)) {
if (!job.completer.isCompleted) job.completer.complete(null);
continue;
}
job.started = true;
_activeExtractions++;
unawaited(_executeExtractionJob(job));
}
}
static Future<void> _executeExtractionJob(
_PendingEmbeddedCoverExtraction job,
) async {
String? result;
try {
result = await _extractCover(job);
} finally {
if (!job.completer.isCompleted) job.completer.complete(result);
if (identical(_pendingExtract[job.cleanPath], job)) {
_pendingExtract.remove(job.cleanPath);
}
_activeExtractions--;
_scheduleExtractionDrain();
}
}
static bool _isCurrentExtraction(_PendingEmbeddedCoverExtraction job) {
return !job.cancelled &&
(_cacheGeneration[job.cleanPath] ?? 0) == job.generation;
}
static Future<String?> _extractCover(
_PendingEmbeddedCoverExtraction job,
) async {
final cleanPath = job.cleanPath;
String? outputPath;
var outputIsPersistent = false;
try {
if (!_isCurrentExtraction(job)) return null;
final candidateModTime =
job.knownModTime ?? await readFileModTimeMillis(cleanPath);
final modTime = candidateModTime != null && candidateModTime > 0
? candidateModTime
: null;
if (!_isCurrentExtraction(job)) return null;
String? persistentPath;
if (modTime != null) {
final directory = await _getPersistentCacheDirectory();
persistentPath = p.join(
directory.path,
_persistentFileName(cleanPath, modTime),
);
if (!job.forceRefresh &&
await _isReusablePersistentPreview(persistentPath, cleanPath)) {
if (!_isCurrentExtraction(job)) return null;
await _touchPersistentPreview(persistentPath);
final next = _EmbeddedCoverCacheEntry(
previewPath: persistentPath,
sourceModTimeMillis: modTime,
isPersistent: true,
);
_touch(cleanPath, next);
_failedExtract.remove(cleanPath);
_trimCacheIfNeeded();
_notifyCoverChanged(job);
_schedulePersistentMaintenance();
return persistentPath;
}
}
if (persistentPath != null) {
final directory = File(persistentPath).parent;
outputPath = p.join(
directory.path,
'.${p.basenameWithoutExtension(persistentPath)}.stage_'
'${DateTime.now().microsecondsSinceEpoch}_${_stagingSequence++}.jpg',
);
} else {
final tempDir = await Directory.systemTemp.createTemp(
'download_cover_preview_',
);
outputPath = p.join(tempDir.path, 'cover_preview.jpg');
}
final result = await PlatformBridge.extractCoverToFile(
cleanPath,
outputPath,
);
if (!_isCurrentExtraction(job)) {
await _cleanupCoverPath(outputPath);
return null;
}
final outputFile = File(outputPath);
final hasCover =
result['error'] == null &&
await outputFile.exists() &&
await outputFile.length() > 0;
if (!hasCover) {
_rememberFailedExtract(cleanPath);
if (job.forceRefresh) {
final previous = _cache.remove(cleanPath);
if (previous != null) await _cleanupCacheEntry(previous);
await _deletePersistentVariantsForSource(cleanPath);
_notifyCoverChanged(job);
}
await _cleanupCoverPath(outputPath);
return null;
}
if (!_isCurrentExtraction(job)) {
await _cleanupCoverPath(outputPath);
return null;
}
var finalPath = outputPath;
if (persistentPath != null) {
finalPath = await _publishPersistentPreview(
stagingPath: outputPath,
targetPath: persistentPath,
cleanPath: cleanPath,
);
outputPath = finalPath;
outputIsPersistent = true;
}
if (!_isCurrentExtraction(job)) {
await _cleanupCoverPath(finalPath);
return null;
}
final previous = _cache[cleanPath];
final next = _EmbeddedCoverCacheEntry(
previewPath: finalPath,
sourceModTimeMillis: modTime,
isPersistent: outputIsPersistent,
);
_touch(cleanPath, next);
_failedExtract.remove(cleanPath);
_trimCacheIfNeeded();
if (previous != null && previous.previewPath != finalPath) {
await _cleanupCacheEntry(previous);
}
if (outputIsPersistent) {
await _deletePersistentVariantsForSource(
cleanPath,
exceptPath: finalPath,
);
_schedulePersistentMaintenance();
}
_notifyCoverChanged(job);
return finalPath;
} catch (_) {
if (_isCurrentExtraction(job)) {
_rememberFailedExtract(cleanPath);
if (job.forceRefresh) {
final previous = _cache.remove(cleanPath);
if (previous != null) await _cleanupCacheEntry(previous);
await _deletePersistentVariantsForSource(cleanPath);
_notifyCoverChanged(job);
}
}
await _cleanupCoverPath(outputPath);
return null;
}
}
static void _notifyCoverChanged(_PendingEmbeddedCoverExtraction job) {
for (final callback in job.callbacks.toList(growable: false)) {
try {
callback();
} catch (_) {}
}
}
static void _cancelPendingExtraction(String cleanPath) {
final job = _pendingExtract.remove(cleanPath);
if (job == null) return;
job.cancelled = true;
if (!job.started) {
_foregroundExtractionQueue.remove(job);
_backgroundExtractionQueue.remove(job);
}
if (!job.completer.isCompleted) job.completer.complete(null);
}
static Future<Directory> _getPersistentCacheDirectory() async {
final override = _persistentCacheDirectoryOverride;
final String rootPath;
if (override != null) {
rootPath = override.path;
} else {
final future = _persistentCacheRootFuture ??= () async {
final appCache = await getApplicationCacheDirectory();
return p.join(appCache.path, _persistentCacheDirectoryName);
}();
rootPath = await future;
}
final directory = Directory(rootPath);
_persistentCacheRootPath = _normalizedAbsolutePath(rootPath);
if (!await directory.exists()) await directory.create(recursive: true);
return directory;
}
static String _persistentFileName(String cleanPath, int modTime) {
return '${_persistentCacheVersion}_${_stablePathHash(cleanPath)}_'
'${modTime.toRadixString(16)}.jpg';
}
/// Two independently mixed 63-bit FNV-1a lanes keep filenames compact.
/// The exact source path is also stored in a sidecar and verified on every
/// disk hit, so even a theoretical hash collision cannot serve wrong art.
static String _stablePathHash(String value) {
const mask = 0x7fffffffffffffff;
const prime = 0x100000001b3;
var first = 0x4bf29ce484222325;
var second = 0x6c62272e07bb0142;
for (final byte in utf8.encode(value)) {
first ^= byte;
first = (first * prime) & mask;
second ^= (byte + 0x9d) & 0xff;
second = ((second * prime) + 0x9e3779b9) & mask;
}
return '${first.toRadixString(16).padLeft(16, '0')}'
'${second.toRadixString(16).padLeft(16, '0')}';
}
static String _persistentSourceSidecarPath(String previewPath) =>
'$previewPath.source';
static Future<bool> _isReusablePersistentPreview(
String previewPath,
String cleanPath,
) async {
final preview = File(previewPath);
final sidecar = File(_persistentSourceSidecarPath(previewPath));
try {
if (!await preview.exists() || await preview.length() <= 0) return false;
if (!await sidecar.exists()) return false;
return await sidecar.readAsString() == cleanPath;
} catch (_) {
return false;
}
}
static Future<String> _publishPersistentPreview({
required String stagingPath,
required String targetPath,
required String cleanPath,
}) async {
final target = File(targetPath);
final sidecar = File(_persistentSourceSidecarPath(targetPath));
final sidecarStaging = File(
'${sidecar.path}.stage_${DateTime.now().microsecondsSinceEpoch}_'
'${_stagingSequence++}',
);
try {
await sidecarStaging.writeAsString(cleanPath, flush: true);
if (await sidecar.exists()) await sidecar.delete();
await sidecarStaging.rename(sidecar.path);
if (await target.exists()) await target.delete();
await File(stagingPath).rename(targetPath);
return targetPath;
} finally {
await _deleteFileIfPresent(sidecarStaging);
}
}
static Future<void> _touchPersistentPreview(String previewPath) async {
try {
await File(previewPath).setLastModified(DateTime.now());
} catch (_) {}
}
static void _schedulePersistentMaintenance() {
if (_persistentMaintenanceFuture != null) return;
late final Future<void> future;
future = Future<void>(() async {
try {
final directory = await _getPersistentCacheDirectory();
await _runPersistentCacheMaintenance(
directory,
maxEntries: _maxPersistentCacheEntries,
maxBytes: _maxPersistentCacheBytes,
targetBytes: _persistentCacheSweepTargetBytes,
);
} finally {
if (identical(_persistentMaintenanceFuture, future)) {
_persistentMaintenanceFuture = null;
}
}
});
_persistentMaintenanceFuture = future;
unawaited(future);
}
static Future<void> _runPersistentCacheMaintenance(
Directory directory, {
required int maxEntries,
required int maxBytes,
required int targetBytes,
}) async {
if (maxEntries < 0 || maxBytes < 0 || targetBytes < 0) {
throw ArgumentError('Persistent cache limits must not be negative');
}
if (!await directory.exists()) return;
final activePaths = _cache.values
.where((entry) => entry.isPersistent)
.map((entry) => _normalizedAbsolutePath(entry.previewPath))
.toSet();
final files = <File>[];
final stats = <String, FileStat>{};
var totalSize = 0;
final now = DateTime.now();
await for (final entity in directory.list(followLinks: false)) {
if (entity is! File) continue;
final name = p.basename(entity.path);
try {
final stat = await entity.stat();
final isAbandonedImageStage =
name.startsWith('.$_persistentCacheVersion') &&
name.contains('.stage_');
final isAbandonedSidecarStage = name.contains('.jpg.source.stage_');
if (isAbandonedImageStage || isAbandonedSidecarStage) {
if (now.difference(stat.modified) > const Duration(hours: 1)) {
await entity.delete();
}
continue;
}
if (name.endsWith('.jpg.source')) {
final previewPath = entity.path.substring(
0,
entity.path.length - '.source'.length,
);
if (!await File(previewPath).exists()) await entity.delete();
continue;
}
if (!name.startsWith('${_persistentCacheVersion}_') ||
!name.endsWith('.jpg')) {
continue;
}
files.add(entity);
stats[entity.path] = stat;
totalSize += stat.size;
} catch (_) {}
}
if (files.length <= maxEntries && totalSize <= maxBytes) return;
files.sort(
(a, b) => stats[a.path]!.modified.compareTo(stats[b.path]!.modified),
);
var remainingEntries = files.length;
final byteGoal = totalSize > maxBytes ? targetBytes : maxBytes;
for (final file in files) {
if (remainingEntries <= maxEntries && totalSize <= byteGoal) break;
if (activePaths.contains(_normalizedAbsolutePath(file.path))) continue;
final stat = stats[file.path]!;
try {
await file.delete();
await _deleteFileIfPresent(
File(_persistentSourceSidecarPath(file.path)),
);
remainingEntries--;
totalSize -= stat.size;
} catch (_) {}
}
}
static Future<void> _deletePersistentVariantsForSource(
String cleanPath, {
String? exceptPath,
}) async {
final directory = await _getPersistentCacheDirectory();
final prefix = '${_persistentCacheVersion}_${_stablePathHash(cleanPath)}_';
final normalizedExcept = exceptPath == null
? null
: _normalizedAbsolutePath(exceptPath);
try {
await for (final entity in directory.list(followLinks: false)) {
if (entity is! File) continue;
final name = p.basename(entity.path);
if (!name.startsWith(prefix) || !name.endsWith('.jpg')) continue;
if (normalizedExcept != null &&
_normalizedAbsolutePath(entity.path) == normalizedExcept) {
continue;
}
final sidecar = File(_persistentSourceSidecarPath(entity.path));
if (await sidecar.exists()) {
try {
if (await sidecar.readAsString() != cleanPath) continue;
} catch (_) {
continue;
}
}
await _deleteFileIfPresent(entity);
await _deleteFileIfPresent(sidecar);
}
} catch (_) {}
}
static bool _isPathInsidePersistentRoot(String candidatePath) {
final root = _persistentCacheRootPath;
if (root == null) return false;
try {
final candidate = _normalizedAbsolutePath(candidatePath);
return p.equals(candidate, root) || p.isWithin(root, candidate);
} catch (_) {
return false;
}
}
static String _normalizedAbsolutePath(String path) =>
p.normalize(p.absolute(path));
static Future<void> _cleanupCacheEntry(_EmbeddedCoverCacheEntry entry) async {
if (entry.isPersistent) {
await _cleanupPersistentCoverPath(entry.previewPath);
} else {
await _cleanupTempCoverPath(entry.previewPath);
}
}
static Future<void> _cleanupCoverPath(String? coverPath) async {
if (coverPath == null || coverPath.isEmpty) return;
if (_isPathInsidePersistentRoot(coverPath)) {
await _cleanupPersistentCoverPath(coverPath);
} else {
await _cleanupTempCoverPath(coverPath);
}
}
static void _scheduleTempCoverCleanup(String? coverPath) {
unawaited(_cleanupTempCoverPath(coverPath));
}
static Future<void> _cleanupPersistentCoverPath(String? coverPath) async {
if (coverPath == null || coverPath.isEmpty) return;
await _deleteFileIfPresent(File(coverPath));
await _deleteFileIfPresent(File(_persistentSourceSidecarPath(coverPath)));
}
static Future<void> _cleanupTempCoverPath(String? coverPath) async {
if (coverPath == null || coverPath.isEmpty) return;
try {
final file = File(coverPath);
await _deleteFileIfPresent(file);
try {
await file.parent.delete(recursive: true);
} catch (_) {}
} catch (_) {}
}
static Future<void> _deleteFileIfPresent(File file) async {
try {
if (await file.exists()) await file.delete();
} catch (_) {}
}
static Future<void> _clearDirectoryContents(Directory directory) async {
if (!await directory.exists()) {
await directory.create(recursive: true);
return;
}
try {
await for (final entity in directory.list(followLinks: false)) {
try {
await entity.delete(recursive: true);
} catch (_) {}
}
} catch (_) {}
if (!await directory.exists()) await directory.create(recursive: true);
}
}