diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index f0090200..5b2ccb5b 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/services/library_cleanup.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/notification_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; @@ -1203,23 +1204,10 @@ class LocalLibraryNotifier extends Notifier { offset += pageSize; } - var deletedCount = 0; - await for (final entity in libraryCoverDir.list( - recursive: true, - followLinks: false, - )) { - if (entity is! File || referencedCoverPaths.contains(entity.path)) { - continue; - } - try { - await entity.delete(); - deletedCount++; - } catch (e) { - _log.w( - 'Failed deleting stale library cover cache ${entity.path}: $e', - ); - } - } + final deletedCount = await pruneUnreferencedLibraryCovers( + libraryCoverDir, + referencedCoverPaths, + ); if (deletedCount > 0) { _log.i('Pruned $deletedCount stale library cover cache files'); diff --git a/lib/services/library_cleanup.dart b/lib/services/library_cleanup.dart index bcda0982..1f798d16 100644 --- a/lib/services/library_cleanup.dart +++ b/lib/services/library_cleanup.dart @@ -1,5 +1,39 @@ +import 'dart:io'; import 'package:sqflite/sqflite.dart'; import 'package:spotiflac_android/utils/file_access.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +Future pruneUnreferencedLibraryCovers( + Directory directory, + Set referencedPaths, +) async { + // Native scans may return canonical paths while Dart lists a directory alias. + final retainedPaths = {...referencedPaths}; + for (final path in referencedPaths) { + try { + retainedPaths.add(await File(path).resolveSymbolicLinks()); + } on FileSystemException { + // Missing cache files can remain referenced until the next full scan. + } + } + var deleted = 0; + await for (final entity in directory.list( + recursive: true, + followLinks: false, + )) { + if (entity is! File || retainedPaths.contains(entity.path)) continue; + try { + if (retainedPaths.contains(await entity.resolveSymbolicLinks())) continue; + await entity.delete(); + deleted++; + } catch (error) { + final message = + 'Failed deleting stale library cover cache ${entity.path}: $error'; + AppLogger('LocalLibrary').w(message); + } + } + return deleted; +} /// Pages by stable ID (not OFFSET, since rows are removed during traversal). /// A high-water mark bounds the run if a concurrent scan adds new rows. diff --git a/test/library_cleanup_test.dart b/test/library_cleanup_test.dart index f44f698e..61e39089 100644 --- a/test/library_cleanup_test.dart +++ b/test/library_cleanup_test.dart @@ -102,6 +102,42 @@ void main() { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; tearDown(() => messenger.setMockMethodCallHandler(channel, null)); + test( + 'cover cleanup preserves references through directory aliases', + () async { + final root = await Directory.systemTemp.createTemp( + 'library-cover-cleanup-', + ); + addTearDown(() => root.delete(recursive: true)); + final directory = await Directory('${root.path}/covers').create(); + final alias = await Link('${root.path}/alias').create(directory.path); + final canonical = await File( + '${directory.path}/canonical.jpg', + ).writeAsBytes([1]); + final legacy = await File( + '${directory.path}/legacy.jpg', + ).writeAsBytes([2]); + final orphan = await File( + '${directory.path}/orphan.jpg', + ).writeAsBytes([3]); + final outside = await File('${root.path}/outside.jpg').writeAsBytes([4]); + await Link('${directory.path}/outside.jpg').create(outside.path); + + final deleted = + await pruneUnreferencedLibraryCovers(Directory(alias.path), { + await canonical.resolveSymbolicLinks(), + '${alias.path}/legacy.jpg', + '${alias.path}/already-missing.jpg', + }); + + expect(await canonical.exists(), isTrue); + expect(await legacy.exists(), isTrue); + expect(await orphan.exists(), isFalse); + expect(await outside.readAsBytes(), [4]); + expect(deleted, 1); + }, + ); + test( 'cleanup pages without skipping after deletions and preserves unknown', () async {