mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-26 12:52:40 +02:00
fix(library): preserve retained files during duplicate cleanup
This commit is contained in:
@@ -37,15 +37,46 @@ String? _stripAudioExtension(String path) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Set<String> buildPathMatchKeys(String? filePath) {
|
||||
/// Path aliases that refer to the same physical file.
|
||||
///
|
||||
/// Unlike [buildPathMatchKeys], this deliberately keeps the audio extension.
|
||||
/// A converted `.flac` and `.opus` can coexist on disk and must not be treated
|
||||
/// as the same physical file by destructive operations.
|
||||
Set<String> buildPhysicalPathMatchKeys(String? filePath) =>
|
||||
_buildPathMatchKeys(filePath, includeExtensionless: false);
|
||||
|
||||
Set<String> buildPathMatchKeys(String? filePath) =>
|
||||
_buildPathMatchKeys(filePath, includeExtensionless: true);
|
||||
|
||||
bool physicalFilePathsMatch(String? first, String? second) {
|
||||
final firstKeys = buildPhysicalPathMatchKeys(first);
|
||||
if (firstKeys.isEmpty) return false;
|
||||
return buildPhysicalPathMatchKeys(
|
||||
second,
|
||||
).any((key) => firstKeys.contains(key));
|
||||
}
|
||||
|
||||
bool isPhysicalFileRetained(
|
||||
String? candidatePath,
|
||||
Iterable<String?> retainedPaths,
|
||||
) => retainedPaths.any(
|
||||
(retainedPath) => physicalFilePathsMatch(candidatePath, retainedPath),
|
||||
);
|
||||
|
||||
Set<String> _buildPathMatchKeys(
|
||||
String? filePath, {
|
||||
required bool includeExtensionless,
|
||||
}) {
|
||||
final raw = filePath?.trim() ?? '';
|
||||
if (raw.isEmpty) return const {};
|
||||
|
||||
final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7).trim() : raw;
|
||||
if (cleaned.isEmpty) return const {};
|
||||
final cached = _pathMatchKeyCache.remove(cleaned);
|
||||
final cacheKey =
|
||||
'${includeExtensionless ? 'track' : 'physical'}\u0000$cleaned';
|
||||
final cached = _pathMatchKeyCache.remove(cacheKey);
|
||||
if (cached != null) {
|
||||
_pathMatchKeyCache[cleaned] = cached;
|
||||
_pathMatchKeyCache[cacheKey] = cached;
|
||||
return cached;
|
||||
}
|
||||
|
||||
@@ -95,6 +126,10 @@ Set<String> buildPathMatchKeys(String? filePath) {
|
||||
addNormalized(parsed.toFilePath());
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
for (final alias in _androidExternalStorageDocumentPaths(parsed)) {
|
||||
addNormalized(alias);
|
||||
}
|
||||
} else if (trimmed.startsWith('/')) {
|
||||
try {
|
||||
final asFileUri = Uri.file(trimmed).toString();
|
||||
@@ -114,23 +149,55 @@ Set<String> buildPathMatchKeys(String? filePath) {
|
||||
|
||||
addNormalized(cleaned);
|
||||
|
||||
final extensionStrippedKeys = <String>{};
|
||||
for (final key in keys) {
|
||||
final stripped = _stripAudioExtension(key);
|
||||
if (stripped != null && stripped.isNotEmpty) {
|
||||
extensionStrippedKeys.add(stripped);
|
||||
if (includeExtensionless) {
|
||||
final extensionStrippedKeys = <String>{};
|
||||
for (final key in keys) {
|
||||
final stripped = _stripAudioExtension(key);
|
||||
if (stripped != null && stripped.isNotEmpty) {
|
||||
extensionStrippedKeys.add(stripped);
|
||||
}
|
||||
}
|
||||
keys.addAll(extensionStrippedKeys);
|
||||
}
|
||||
keys.addAll(extensionStrippedKeys);
|
||||
|
||||
final result = Set<String>.unmodifiable(keys);
|
||||
_pathMatchKeyCache[cleaned] = result;
|
||||
_pathMatchKeyCache[cacheKey] = result;
|
||||
while (_pathMatchKeyCache.length > _maxPathMatchKeyCacheSize) {
|
||||
_pathMatchKeyCache.remove(_pathMatchKeyCache.keys.first);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Iterable<String> _androidExternalStorageDocumentPaths(Uri uri) {
|
||||
if (uri.scheme.toLowerCase() != 'content' ||
|
||||
uri.host.toLowerCase() != 'com.android.externalstorage.documents') {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final segments = uri.pathSegments;
|
||||
final documentIndex = segments.lastIndexOf('document');
|
||||
final treeIndex = segments.lastIndexOf('tree');
|
||||
final idIndex = documentIndex >= 0 ? documentIndex + 1 : treeIndex + 1;
|
||||
if (idIndex <= 0 || idIndex >= segments.length) return const [];
|
||||
|
||||
var documentId = segments.sublist(idIndex).join('/');
|
||||
try {
|
||||
documentId = Uri.decodeComponent(documentId);
|
||||
} catch (_) {}
|
||||
final separator = documentId.indexOf(':');
|
||||
if (separator < 0 ||
|
||||
documentId.substring(0, separator).toLowerCase() != 'primary') {
|
||||
return const [];
|
||||
}
|
||||
|
||||
final relativePath = documentId
|
||||
.substring(separator + 1)
|
||||
.replaceAll('\\', '/')
|
||||
.replaceFirst(RegExp(r'^/+'), '');
|
||||
final suffix = relativePath.isEmpty ? '' : '/$relativePath';
|
||||
return _androidStoragePathAliases.map((prefix) => '$prefix$suffix');
|
||||
}
|
||||
|
||||
Iterable<String> _androidEquivalentPaths(String path) {
|
||||
final normalized = path.replaceAll('\\', '/');
|
||||
final lower = normalized.toLowerCase();
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:spotiflac_android/providers/local_library_provider.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';
|
||||
import 'package:spotiflac_android/utils/path_match_keys.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
||||
import 'package:spotiflac_android/widgets/settings_group.dart';
|
||||
|
||||
@@ -109,13 +110,27 @@ class _DuplicateReviewSheetState extends ConsumerState<DuplicateReviewSheet> {
|
||||
return confirmed == true && mounted;
|
||||
}
|
||||
|
||||
Future<void> _deleteEntries(List<IsrcDuplicateEntry> entries) async {
|
||||
Future<void> _deleteEntries(
|
||||
List<IsrcDuplicateEntry> entries, {
|
||||
required List<IsrcDuplicateEntry> retainedEntries,
|
||||
}) async {
|
||||
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
||||
var deleted = 0;
|
||||
for (final entry in entries) {
|
||||
final fileDeleted = await deleteFile(
|
||||
DownloadedEmbeddedCoverResolver.cleanFilePath(entry.filePath),
|
||||
final entryPath = DownloadedEmbeddedCoverResolver.cleanFilePath(
|
||||
entry.filePath,
|
||||
);
|
||||
final sharesRetainedFile = isPhysicalFileRetained(
|
||||
entryPath,
|
||||
retainedEntries.map(
|
||||
(retained) =>
|
||||
DownloadedEmbeddedCoverResolver.cleanFilePath(retained.filePath),
|
||||
),
|
||||
);
|
||||
// Two database rows may describe one SAF file through different URI or
|
||||
// raw-path aliases. Remove only the redundant row in that case; deleting
|
||||
// the shared file would also destroy the copy the user chose to retain.
|
||||
final fileDeleted = sharesRetainedFile || await deleteFile(entryPath);
|
||||
if (!fileDeleted) continue;
|
||||
if (entry.source == 'downloaded') {
|
||||
historyNotifier.removeFromHistory(entry.id);
|
||||
@@ -140,14 +155,29 @@ class _DuplicateReviewSheetState extends ConsumerState<DuplicateReviewSheet> {
|
||||
group.entries.first.trackName,
|
||||
),
|
||||
);
|
||||
if (confirmed) await _deleteEntries(toDelete);
|
||||
if (confirmed) {
|
||||
await _deleteEntries(toDelete, retainedEntries: [group.entries.first]);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteSingle(IsrcDuplicateEntry entry) async {
|
||||
Future<void> _deleteSingle(
|
||||
IsrcDuplicateGroup group,
|
||||
IsrcDuplicateEntry entry,
|
||||
) async {
|
||||
final confirmed = await _confirmDelete(
|
||||
context.l10n.duplicatesDeleteCopyMessage(entry.trackName),
|
||||
);
|
||||
if (confirmed) await _deleteEntries([entry]);
|
||||
if (confirmed) {
|
||||
await _deleteEntries(
|
||||
[entry],
|
||||
retainedEntries: group.entries
|
||||
.where(
|
||||
(candidate) =>
|
||||
candidate.source != entry.source || candidate.id != entry.id,
|
||||
)
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -261,7 +291,7 @@ class _DuplicateReviewSheetState extends ConsumerState<DuplicateReviewSheet> {
|
||||
: IconButton(
|
||||
tooltip: context.l10n.dialogDelete,
|
||||
icon: Icon(Icons.delete_outline, color: colorScheme.error),
|
||||
onPressed: () => _deleteSingle(group.entries[i]),
|
||||
onPressed: () => _deleteSingle(group, group.entries[i]),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user