diff --git a/lib/services/network_library_scanner.dart b/lib/services/network_library_scanner.dart index 7c2411f7..92481b72 100644 --- a/lib/services/network_library_scanner.dart +++ b/lib/services/network_library_scanner.dart @@ -117,16 +117,29 @@ class NetworkLibraryScanner { if (cover != null && cover.isNotEmpty && await File(cover).exists()) { - final target = File('${covers.path}/$id.image'); - final staged = File('${target.path}.tmp'); + // Identical embedded artwork shares an immutable file, including + // across albums/sources. Cleanup retains all DB cover references. + File? target; + File? staged; try { - await File(cover).copy(staged.path); - await staged.rename(target.path); + final digest = await sha256.bind(File(cover).openRead()).first; + target = File('${covers.path}/network_cover_$digest.image'); + staged = File( + '${target.path}.${NetworkStorageService.newId()}.tmp', + ); + if (!await target.exists() || await target.length() == 0) { + await File(cover).copy(staged.path); + await staged.rename(target.path); + } coverPath = target.path; } catch (_) { - if (await target.exists()) coverPath = target.path; + if (target != null && await target.exists()) { + coverPath = target.path; + } } finally { - if (await staged.exists()) await staged.delete(); + if (staged != null && await staged.exists()) { + await staged.delete(); + } } } await checkpoint(); diff --git a/test/network_library_scanner_test.dart b/test/network_library_scanner_test.dart index f93b4c18..354b3af8 100644 --- a/test/network_library_scanner_test.dart +++ b/test/network_library_scanner_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/services/network_library_scanner.dart'; import 'package:spotiflac_android/services/network_storage_service.dart'; +import 'package:spotiflac_android/services/library_cleanup.dart'; import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -49,6 +50,58 @@ void main() { temporaryDirectory: () async => directory, ); + test( + 'identical artwork shares storage until the last reference is gone', + () async { + final shared = await File( + '${directory.path}/shared.jpg', + ).writeAsBytes([1, 2]); + final different = await File( + '${directory.path}/different.jpg', + ).writeAsBytes([3]); + final scan = + await scanner( + _Storage({ + '': [ + const NetworkEntry('one.flac', 'one.flac'), + const NetworkEntry('two.flac', 'two.flac'), + const NetworkEntry('three.flac', 'three.flac'), + ], + }), + (source) async => { + 'title': 'Track', + 'cover_path': source.endsWith('three.flac') + ? different.path + : shared.path, + }, + ).scan( + NetworkStorageService.source('nas', ''), + checkpoint: () async {}, + onProgress: (_, _, _) {}, + ); + final rows = await scan.rows().toList(); + expect(rows[0]['id'], isNot(rows[1]['id'])); + expect(rows[0]['coverPath'], rows[1]['coverPath']); + expect(rows[0]['coverPath'], isNot(rows[2]['coverPath'])); + final covers = Directory('${directory.path}/library_covers'); + expect(await covers.list().length, 2); + // Removing one track retains its sibling's shared cover. + expect( + await pruneUnreferencedLibraryCovers(covers, { + for (final row in rows.skip(1)) row['coverPath'] as String, + }), + 0, + ); + expect( + await pruneUnreferencedLibraryCovers(covers, { + rows[2]['coverPath'] as String, + }), + 1, + ); + expect(await File(rows[2]['coverPath'] as String).readAsBytes(), [3]); + }, + ); + test( 'subfolders keep original metadata, stable identities and persistent artwork', () async {