diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 440fde07..903ff608 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -11,6 +11,7 @@ import 'package:spotiflac_android/services/library_queue_store.dart'; import 'package:spotiflac_android/services/library_cleanup.dart'; import 'package:spotiflac_android/services/library_search.dart'; import 'package:spotiflac_android/services/library_database_models.dart'; +import 'package:spotiflac_android/services/library_duplicates.dart'; import 'package:spotiflac_android/services/library_native_adapter.dart'; import 'package:spotiflac_android/services/library_row_mapper.dart'; import 'package:spotiflac_android/services/library_schema.dart'; @@ -1012,66 +1013,12 @@ class LibraryDatabase { .toList(growable: false); } - /// Groups of tracks sharing one ISRC across download history and the - /// local library. Library rows whose path key already appears in history - /// are excluded so a scanned copy of a download doesn't pair with itself. - /// Entries come back best-quality first. + /// Physical copies sharing one ISRC, excluding inactive NAS sources and + /// alternate index/URI references to the same file. Best quality comes first. Future> findIsrcDuplicateGroups() async { final db = await database; - final rows = await db.rawQuery(''' - WITH merged AS ( - SELECT h.id AS id, 'downloaded' AS source, h.track_name, h.artist_name, - h.album_name, h.file_path, UPPER(TRIM(h.isrc)) AS isrc_key, - h.bit_depth, h.sample_rate, h.bitrate, h.format - FROM history_db.history h - WHERE h.isrc IS NOT NULL AND TRIM(h.isrc) != '' - UNION ALL - SELECT l.id AS id, 'local' AS source, l.track_name, l.artist_name, - l.album_name, l.file_path, UPPER(TRIM(l.isrc)) AS isrc_key, - l.bit_depth, l.sample_rate, l.bitrate, l.format - FROM $visibleLibraryView l - WHERE l.isrc IS NOT NULL AND TRIM(l.isrc) != '' - AND NOT EXISTS ( - SELECT 1 - FROM library_path_keys lpk - JOIN history_db.history_path_keys hpk ON hpk.path_key = lpk.path_key - WHERE lpk.item_id = l.id - ) - ) - SELECT * FROM merged - WHERE isrc_key IN ( - SELECT isrc_key FROM merged GROUP BY isrc_key HAVING COUNT(*) > 1 - ) - ORDER BY isrc_key, bit_depth DESC, sample_rate DESC, bitrate DESC - '''); - - final groups = >{}; - for (final row in rows) { - final isrc = row['isrc_key'] as String? ?? ''; - final filePath = row['file_path'] as String? ?? ''; - if (isrc.isEmpty || filePath.isEmpty) continue; - groups - .putIfAbsent(isrc, () => []) - .add( - IsrcDuplicateEntry( - id: row['id'] as String, - source: row['source'] as String, - trackName: row['track_name'] as String? ?? '', - artistName: row['artist_name'] as String? ?? '', - albumName: row['album_name'] as String? ?? '', - filePath: filePath, - bitDepth: (row['bit_depth'] as num?)?.toInt(), - sampleRate: (row['sample_rate'] as num?)?.toInt(), - bitrate: (row['bitrate'] as num?)?.toInt(), - format: row['format'] as String?, - ), - ); - } - return [ - for (final entry in groups.entries) - if (entry.value.length > 1) - IsrcDuplicateGroup(isrc: entry.key, entries: entry.value), - ]; + await _ensureHistoryAttached(db); + return readIsrcDuplicateGroups(db); } Future> getPhysicalFileIds(Iterable filePaths) async => diff --git a/lib/services/library_duplicates.dart b/lib/services/library_duplicates.dart new file mode 100644 index 00000000..2ececfd8 --- /dev/null +++ b/lib/services/library_duplicates.dart @@ -0,0 +1,80 @@ +import 'package:sqflite/sqflite.dart'; +import 'package:spotiflac_android/services/library_database_models.dart'; +import 'package:spotiflac_android/services/library_schema.dart'; +import 'package:spotiflac_android/utils/path_match_keys.dart'; + +/// Review physical copies, not index rows. History remains intact when a NAS +/// source is disabled, but must not suggest deleting its replacement SAF copy. +Future> readIsrcDuplicateGroups( + DatabaseExecutor db, +) async { + final rows = await db.rawQuery(''' + WITH active_history AS ( + SELECT h.* FROM history_db.history h + WHERE NOT ( + h.file_path LIKE 'network://%' + AND EXISTS ( + SELECT 1 FROM library_sources s + WHERE (s.enabled = 0 OR s.available = 0) + AND substr(h.file_path, 1, length(s.path)) = s.path + ) + AND NOT EXISTS ( + SELECT 1 FROM library_sources s + WHERE s.enabled = 1 AND s.available = 1 + AND substr(h.file_path, 1, length(s.path)) = s.path + ) + ) + ), merged AS ( + SELECT h.id, 'downloaded' AS source, h.track_name, h.artist_name, + h.album_name, h.file_path, UPPER(TRIM(h.isrc)) AS isrc_key, + h.bit_depth, h.sample_rate, h.bitrate, h.format + FROM active_history h + WHERE h.isrc IS NOT NULL AND TRIM(h.isrc) != '' + UNION ALL + SELECT l.id, 'local' AS source, l.track_name, l.artist_name, + l.album_name, l.file_path, UPPER(TRIM(l.isrc)) AS isrc_key, + l.bit_depth, l.sample_rate, l.bitrate, l.format + FROM ${LibrarySchema.visibleView} l + WHERE l.isrc IS NOT NULL AND TRIM(l.isrc) != '' + ) + SELECT * FROM merged + WHERE isrc_key IN ( + SELECT isrc_key FROM merged GROUP BY isrc_key HAVING COUNT(*) > 1 + ) + ORDER BY isrc_key, bit_depth DESC, sample_rate DESC, bitrate DESC, source, id + '''); + + final groups = >{}; + final pathsByIsrc = >{}; + for (final row in rows) { + final isrc = row['isrc_key'] as String? ?? ''; + final path = row['file_path'] as String? ?? ''; + if (isrc.isEmpty || path.isEmpty) continue; + final keys = buildPhysicalPathMatchKeys(path); + final seen = pathsByIsrc.putIfAbsent(isrc, () => {}); + final alreadySeen = keys.any(seen.contains); + seen.addAll(keys); + if (alreadySeen) continue; + groups + .putIfAbsent(isrc, () => []) + .add( + IsrcDuplicateEntry( + id: row['id'] as String, + source: row['source'] as String, + trackName: row['track_name'] as String? ?? '', + artistName: row['artist_name'] as String? ?? '', + albumName: row['album_name'] as String? ?? '', + filePath: path, + bitDepth: (row['bit_depth'] as num?)?.toInt(), + sampleRate: (row['sample_rate'] as num?)?.toInt(), + bitrate: (row['bitrate'] as num?)?.toInt(), + format: row['format'] as String?, + ), + ); + } + return [ + for (final entry in groups.entries) + if (entry.value.length > 1) + IsrcDuplicateGroup(isrc: entry.key, entries: entry.value), + ]; +} diff --git a/test/library_duplicates_test.dart b/test/library_duplicates_test.dart new file mode 100644 index 00000000..b17c6302 --- /dev/null +++ b/test/library_duplicates_test.dart @@ -0,0 +1,121 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/services/library_database_models.dart'; +import 'package:spotiflac_android/services/library_duplicates.dart'; +import 'package:spotiflac_android/services/library_schema.dart'; + +import 'support/sqlite_process_database.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + late SqliteProcessDatabase db; + + setUp(() async { + db = await SqliteProcessDatabase.open(); + await LibrarySchema.create(db, LibrarySchema.version); + await db.execute("ATTACH DATABASE ':memory:' AS history_db"); + await db.execute('''CREATE TABLE history_db.history ( + id TEXT PRIMARY KEY, track_name TEXT, artist_name TEXT, album_name TEXT, + file_path TEXT, isrc TEXT, bit_depth INTEGER, sample_rate INTEGER, + bitrate INTEGER, format TEXT + )'''); + }); + tearDown(() => db.close()); + + Future add( + String id, + String path, { + bool history = false, + String? sourceId, + }) => db + .insert(history ? 'history_db.history' : 'library', { + 'id': id, + 'track_name': 'Track', + 'artist_name': 'Artist', + 'album_name': 'Album', + 'file_path': path, + 'isrc': 'SAME-RECORDING', + 'bit_depth': 16, + 'sample_rate': 44100, + if (!history) ...{ + 'scanned_at': '2026-10-10', + 'source_id': sourceId ?? LocalLibraryItem.legacySourceId, + }, + }) + .then((_) {}); + + test( + 'disabled NAS history does not pair with the replacement RSAF library', + () async { + await db.insert('library_sources', { + 'id': 'nas', + 'path': 'network://nas/', + 'display_name': 'SMB', + 'enabled': 0, + 'available': 1, + }); + await add( + 'old-download', + 'network://nas/Album/Track.flac', + history: true, + ); + await add( + 'new-scan', + 'content://rsaf/document/remote%3AAlbum%2FTrack.flac', + ); + await add( + 'disabled-scan', + 'network://nas/Album/Track.flac', + sourceId: 'nas', + ); + expect(await readIsrcDuplicateGroups(db), isEmpty); + // The historical record is retained, not purged while storage is disabled. + expect(await db.query('history_db.history'), hasLength(1)); + await db.update( + 'library_sources', + {'enabled': 1}, + where: 'id = ?', + whereArgs: ['nas'], + ); + expect((await readIsrcDuplicateGroups(db)).single.entries, hasLength(2)); + await db.update( + 'library_sources', + {'available': 0}, + where: 'id = ?', + whereArgs: ['nas'], + ); + expect(await readIsrcDuplicateGroups(db), isEmpty); + }, + ); + + test( + 'rescans and alternate SAF tree URIs do not duplicate one physical file', + () async { + const document = 'remote%3AAlbum%2FTrack.flac'; + await add( + 'download', + 'content://rsaf/tree/root/document/$document', + history: true, + ); + await add('scan', 'content://rsaf/tree/album/document/$document'); + await add( + 'old-history', + 'content://rsaf/document/$document', + history: true, + ); + expect(await readIsrcDuplicateGroups(db), isEmpty); + }, + ); + + test('actual copies and different formats remain selectable', () async { + await add('download', '/music/Track.flac', history: true); + await add('indexed-download', 'file:///music/Track.flac'); + await add('conversion', '/music/Track.opus'); + await add('copy', '/playlist/Track.flac'); + final group = (await readIsrcDuplicateGroups(db)).single; + expect(group.entries.map((entry) => entry.filePath).toSet(), { + '/music/Track.flac', + '/music/Track.opus', + '/playlist/Track.flac', + }); + }); +}