mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-18 13:41:34 +02:00
fix(library): exclude inactive NAS entries and file aliases from duplicates
This commit is contained in:
1 parent
97c63fb38e
commit
cdceda4585
3 files changed
+206
-58
No files matched your search
@@ -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<List<IsrcDuplicateGroup>> 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 = <String, List<IsrcDuplicateEntry>>{};
|
||||
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<List<String>> getPhysicalFileIds(Iterable<String> filePaths) async =>
|
||||
|
||||
@@ -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<List<IsrcDuplicateGroup>> 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 = <String, List<IsrcDuplicateEntry>>{};
|
||||
final pathsByIsrc = <String, Set<String>>{};
|
||||
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),
|
||||
];
|
||||
}
|
||||
@@ -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<void> 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',
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user