fix(library): recover transient file availability

This commit is contained in:
zarzet
2026-08-28 01:04:04 +07:00
parent 847fc725b6
commit a744514e10
4 changed files with 313 additions and 101 deletions
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/utils/file_access.dart';
@@ -80,6 +81,207 @@ class CompletionBridgePlayableResult {
}
typedef CompletionBridgePathExists = Future<bool> Function(String path);
typedef LibraryFilePathNormalizer = String Function(String? path);
/// Keeps the per-card Library file check cheap without making a transient
/// storage miss permanent for the lifetime of the tab.
///
/// Android document providers can briefly return `false` immediately after a
/// file is published. Fresh paths therefore stay optimistically playable
/// during a bounded retry window. Confirmed misses are cached, but become
/// eligible for a new probe after [missingRecheckAfter] when their card is
/// rebuilt (for example after returning from the Metadata screen).
class LibraryFileAvailabilityCache {
static const int _defaultMaxEntries = 500;
static const List<Duration> _defaultRetryDelays = [
Duration(milliseconds: 350),
Duration(milliseconds: 700),
Duration(milliseconds: 1400),
Duration(milliseconds: 2200),
];
final CompletionBridgePathExists _pathExists;
final LibraryFilePathNormalizer _normalizePath;
final List<Duration> _retryDelays;
final Duration _missingRecheckAfter;
final int _maxEntries;
final Map<String, bool> _cache = {};
final Map<String, DateTime> _checkedAt = {};
final Map<String, _LibraryFileAvailabilityNotifier> _notifiers = {};
final Map<String, Timer> _retryTimers = {};
final Map<String, int> _generations = {};
final Set<String> _probingPaths = {};
final ValueNotifier<bool> _alwaysMissingNotifier = ValueNotifier(false);
bool _disposed = false;
LibraryFileAvailabilityCache({
CompletionBridgePathExists pathExists = fileExists,
LibraryFilePathNormalizer normalizePath =
DownloadedEmbeddedCoverResolver.cleanFilePath,
List<Duration> retryDelays = _defaultRetryDelays,
Duration missingRecheckAfter = const Duration(seconds: 5),
int maxEntries = _defaultMaxEntries,
}) : assert(maxEntries > 0),
_pathExists = pathExists,
_normalizePath = normalizePath,
_retryDelays = List.unmodifiable(retryDelays),
_missingRecheckAfter = missingRecheckAfter,
_maxEntries = maxEntries;
ValueListenable<bool> listenable(String? filePath) {
final path = _normalizePath(filePath);
if (path.isEmpty || _disposed) return _alwaysMissingNotifier;
final existingNotifier = _notifiers[path];
if (existingNotifier != null) {
final cached = _cache[path];
if (cached != null && existingNotifier.value != cached) {
existingNotifier.value = cached;
}
if (cached == null) {
_beginProbe(path);
} else if (!cached && _confirmedMissIsStale(path)) {
// Keep showing the confirmed state while the background recheck runs;
// it will flip to playable as soon as storage reports the file.
_beginProbe(path, force: true);
}
return existingNotifier;
}
_evictUnusedEntriesIfNeeded();
final notifier = _LibraryFileAvailabilityNotifier(_cache[path] ?? true);
_notifiers[path] = notifier;
_beginProbe(path);
return notifier;
}
/// Starts a fresh optimistic check for a path whose storage state changed,
/// such as the destination of a newly completed download.
void refreshForPath(String? filePath) {
final path = _normalizePath(filePath);
if (path.isEmpty || _disposed) return;
_beginProbe(path, force: true, optimistic: true);
}
/// Publishes a successful check performed by another component. This is
/// used by the completion bridge so its verified final path immediately
/// heals a stale missing icon on the normal Library card.
void markExists(String? filePath) {
final path = _normalizePath(filePath);
if (path.isEmpty || _disposed) return;
_generations[path] = (_generations[path] ?? 0) + 1;
_retryTimers.remove(path)?.cancel();
_probingPaths.remove(path);
_cache[path] = true;
_checkedAt[path] = DateTime.now();
final notifier = _notifiers[path];
if (notifier != null && !notifier.value) {
notifier.value = true;
}
}
bool _confirmedMissIsStale(String path) {
final checkedAt = _checkedAt[path];
return checkedAt == null ||
DateTime.now().difference(checkedAt) >= _missingRecheckAfter;
}
void _beginProbe(String path, {bool force = false, bool optimistic = false}) {
if (_disposed || (!force && _probingPaths.contains(path))) return;
final generation = (_generations[path] ?? 0) + 1;
_generations[path] = generation;
_retryTimers.remove(path)?.cancel();
_probingPaths.add(path);
_cache.remove(path);
_checkedAt.remove(path);
if (optimistic) {
final notifier = _notifiers[path];
if (notifier != null && !notifier.value) notifier.value = true;
}
unawaited(_probe(path, generation: generation, attempt: 0));
}
Future<void> _probe(
String path, {
required int generation,
required int attempt,
}) async {
var exists = false;
try {
exists = await _pathExists(path);
} catch (_) {}
if (_disposed || _generations[path] != generation) return;
if (exists) {
markExists(path);
return;
}
if (attempt < _retryDelays.length) {
_retryTimers[path] = Timer(_retryDelays[attempt], () {
_retryTimers.remove(path);
if (_disposed || _generations[path] != generation) return;
unawaited(_probe(path, generation: generation, attempt: attempt + 1));
});
return;
}
_probingPaths.remove(path);
_cache[path] = false;
_checkedAt[path] = DateTime.now();
final notifier = _notifiers[path];
if (notifier != null && notifier.value) notifier.value = false;
}
void _evictUnusedEntriesIfNeeded() {
while (_notifiers.length >= _maxEntries) {
String? evictionPath;
for (final entry in _notifiers.entries) {
if (!entry.value.hasActiveListeners) {
evictionPath = entry.key;
break;
}
}
if (evictionPath == null) return;
_removeEntry(evictionPath);
}
}
void _removeEntry(String path) {
_retryTimers.remove(path)?.cancel();
_probingPaths.remove(path);
_cache.remove(path);
_checkedAt.remove(path);
_generations.remove(path);
_notifiers.remove(path)?.dispose();
}
void dispose() {
if (_disposed) return;
_disposed = true;
for (final timer in _retryTimers.values) {
timer.cancel();
}
for (final notifier in _notifiers.values) {
notifier.dispose();
}
_retryTimers.clear();
_notifiers.clear();
_probingPaths.clear();
_cache.clear();
_checkedAt.clear();
_generations.clear();
_alwaysMissingNotifier.dispose();
}
}
class _LibraryFileAvailabilityNotifier extends ValueNotifier<bool> {
_LibraryFileAvailabilityNotifier(super.value);
bool get hasActiveListeners => hasListeners;
}
/// Probes both completion-path candidates and keeps a bridge in a neutral
/// checking state while delayed SAF publication becomes visible. A definitive
@@ -94,6 +296,7 @@ class CompletionBridgePlayableProbeCache {
];
final CompletionBridgePathExists _pathExists;
final ValueChanged<String>? _onPlayable;
final List<Duration> _retryDelays;
final int _maxEntries;
final Map<String, _CompletionBridgeProbeEntry> _entries = {};
@@ -103,10 +306,12 @@ class CompletionBridgePlayableProbeCache {
CompletionBridgePlayableProbeCache({
CompletionBridgePathExists pathExists = fileExists,
ValueChanged<String>? onPlayable,
List<Duration> retryDelays = _defaultRetryDelays,
int maxEntries = _defaultMaxEntries,
}) : assert(maxEntries > 0),
_pathExists = pathExists,
_onPlayable = onPlayable,
_retryDelays = List.unmodifiable(retryDelays),
_maxEntries = maxEntries;
@@ -169,6 +374,7 @@ class CompletionBridgePlayableProbeCache {
} catch (_) {}
if (_disposed || generation != entry.generation) return;
if (exists) {
_onPlayable?.call(path);
entry.notifier.value = CompletionBridgePlayableResult.playable(path);
return;
}
+14 -7
View File
@@ -222,10 +222,9 @@ class QueueTab extends ConsumerStatefulWidget {
class _QueueTabState extends ConsumerState<QueueTab> {
static const int _libraryPageSize = 300;
final _FileExistsListenableCache _fileExistsCache =
_FileExistsListenableCache();
final CompletionBridgePlayableProbeCache _completionBridgePlayableProbe =
CompletionBridgePlayableProbeCache();
final LibraryFileAvailabilityCache _fileExistsCache =
LibraryFileAvailabilityCache();
late final CompletionBridgePlayableProbeCache _completionBridgePlayableProbe;
static const double _libraryGridMinExtent = 92;
static const double _libraryGridDefaultExtent = 126;
static const double _libraryGridMaxExtent = 190;
@@ -340,6 +339,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
@override
void initState() {
super.initState();
_completionBridgePlayableProbe = CompletionBridgePlayableProbeCache(
onPlayable: _fileExistsCache.markExists,
);
}
void _initializePageController() {
@@ -1232,6 +1234,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final nowCompleted =
nextItem != null && nextItem.status == DownloadStatus.completed;
if (wasActive && nowCompleted) {
_fileExistsCache.refreshForPath(nextItem.filePath);
_completionBridgePlayableProbe.refreshForPath(nextItem.filePath);
_completionBridge[id] = nextItem;
_completionBridgeAt[id] = DateTime.now();
@@ -1244,10 +1247,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
if (previous == null || previous == next) return;
final historyItems = ref.read(downloadHistoryProvider).items;
for (final bridgeItem in _completionBridge.values) {
_fileExistsCache.refreshForPath(bridgeItem.filePath);
_completionBridgePlayableProbe.refreshForPath(bridgeItem.filePath);
_completionBridgePlayableProbe.refreshForPath(
_historyItemForCompletionBridge(bridgeItem, historyItems)?.filePath,
);
final historyPath = _historyItemForCompletionBridge(
bridgeItem,
historyItems,
)?.filePath;
_fileExistsCache.refreshForPath(historyPath);
_completionBridgePlayableProbe.refreshForPath(historyPath);
}
// The family provider already reruns for the new revision. Retain its
// last successful page while SQLite is loading so metadata backfills
-94
View File
@@ -509,97 +509,3 @@ class _QueueItemIdsSnapshot {
@override
int get hashCode => Object.hashAll(ids);
}
class _FileExistsListenableCache {
static const int _maxCacheSize = 500;
final Map<String, bool> _cache = {};
final Map<String, int> _missCounts = {};
final Map<String, ValueNotifier<bool>> _notifiers = {};
final ValueNotifier<bool> _alwaysMissingNotifier = ValueNotifier(false);
final Set<String> _pendingChecks = {};
ValueListenable<bool> listenable(String? filePath) {
final cleanPath = DownloadedEmbeddedCoverResolver.cleanFilePath(filePath);
if (cleanPath.isEmpty) return _alwaysMissingNotifier;
final existingNotifier = _notifiers[cleanPath];
if (existingNotifier != null) {
final cached = _cache[cleanPath];
if (cached != null && existingNotifier.value != cached) {
existingNotifier.value = cached;
} else if (cached == null) {
_startCheck(cleanPath);
}
return existingNotifier;
}
if (_notifiers.length >= _maxCacheSize) {
final oldestKey = _notifiers.keys.first;
_notifiers.remove(oldestKey)?.dispose();
_cache.remove(oldestKey);
}
final notifier = ValueNotifier<bool>(_cache[cleanPath] ?? true);
_notifiers[cleanPath] = notifier;
_startCheck(cleanPath);
return notifier;
}
void _startCheck(String cleanPath) {
if (_pendingChecks.contains(cleanPath)) {
return;
}
final cached = _cache[cleanPath];
if (cached != null) {
final notifier = _notifiers[cleanPath];
if (notifier != null && notifier.value != cached) {
notifier.value = cached;
}
return;
}
_pendingChecks.add(cleanPath);
Future.microtask(() async {
bool exists;
try {
exists = await fileExists(cleanPath);
} catch (_) {
_pendingChecks.remove(cleanPath);
Timer(const Duration(milliseconds: 700), () => _startCheck(cleanPath));
return;
}
_pendingChecks.remove(cleanPath);
if (exists) {
_missCounts.remove(cleanPath);
_cache[cleanPath] = true;
} else {
final misses = (_missCounts[cleanPath] ?? 0) + 1;
_missCounts[cleanPath] = misses;
if (misses < 2) {
Timer(
const Duration(milliseconds: 700),
() => _startCheck(cleanPath),
);
return;
}
_cache[cleanPath] = false;
}
final notifier = _notifiers[cleanPath];
final value = _cache[cleanPath] ?? true;
if (notifier != null && notifier.value != value) {
notifier.value = value;
}
});
}
void dispose() {
for (final notifier in _notifiers.values) {
notifier.dispose();
}
_notifiers.clear();
_missCounts.clear();
_alwaysMissingNotifier.dispose();
}
}
@@ -134,6 +134,99 @@ void main() {
},
);
test('library file cache hides transient publication misses', () async {
var checks = 0;
final cache = LibraryFileAvailabilityCache(
pathExists: (_) async => ++checks >= 3,
normalizePath: (path) => path ?? '',
retryDelays: const [Duration.zero, Duration.zero],
);
addTearDown(cache.dispose);
final availability = cache.listenable('/music/new.flac');
final publishedValues = <bool>[];
availability.addListener(() => publishedValues.add(availability.value));
expect(availability.value, isTrue);
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(checks, 3);
expect(availability.value, isTrue);
expect(publishedValues, isNot(contains(false)));
});
test('library file cache rechecks a stale confirmed miss', () async {
var exists = false;
final cache = LibraryFileAvailabilityCache(
pathExists: (_) async => exists,
normalizePath: (path) => path ?? '',
retryDelays: const [],
missingRecheckAfter: Duration.zero,
);
addTearDown(cache.dispose);
final availability = cache.listenable('/music/late.flac');
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(availability.value, isFalse);
exists = true;
expect(identical(cache.listenable('/music/late.flac'), availability), true);
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(availability.value, isTrue);
});
test('completion probe heals the normal library file cache', () async {
final availabilityCache = LibraryFileAvailabilityCache(
pathExists: (_) async => false,
normalizePath: (path) => path ?? '',
retryDelays: const [],
);
addTearDown(availabilityCache.dispose);
final availability = availabilityCache.listenable(
'content://downloads/final.flac',
);
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(availability.value, isFalse);
final completionProbe = CompletionBridgePlayableProbeCache(
pathExists: (_) async => true,
onPlayable: availabilityCache.markExists,
retryDelays: const [],
);
addTearDown(completionProbe.dispose);
completionProbe.listenable(
completedItemFilePath: 'content://downloads/final.flac',
);
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(availability.value, isTrue);
});
test(
'download completion explicitly refreshes cached missing paths',
() async {
var exists = false;
final cache = LibraryFileAvailabilityCache(
pathExists: (_) async => exists,
normalizePath: (path) => path ?? '',
retryDelays: const [],
);
addTearDown(cache.dispose);
final availability = cache.listenable('/music/reused.flac');
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(availability.value, isFalse);
exists = true;
cache.refreshForPath('/music/reused.flac');
expect(availability.value, isTrue);
await Future<void>.delayed(const Duration(milliseconds: 10));
expect(availability.value, isTrue);
},
);
test(
'completion probe retries publication before reporting missing',
() async {