diff --git a/go_backend/cue_duplicate_supplement_test.go b/go_backend/cue_duplicate_supplement_test.go index a49ff8db..9433d2b8 100644 --- a/go_backend/cue_duplicate_supplement_test.go +++ b/go_backend/cue_duplicate_supplement_test.go @@ -159,6 +159,40 @@ func writeTestFlacWithISRC(t *testing.T, path, isrc string) { } } +func TestISRCIndexCoversNonFlacFormats(t *testing.T) { + dir := t.TempDir() + flacPath := filepath.Join(dir, "a.flac") + mp3Path := filepath.Join(dir, "b.mp3") + writeTestFlacWithISRC(t, flacPath, "USAA00000011") + mp3Data := buildID3v23Tag( + id3TextFrame("TIT2", "Song"), + id3TextFrame("TSRC", "usbb00000022"), + ) + if err := os.WriteFile(mp3Path, mp3Data, 0600); err != nil { + t.Fatal(err) + } + // Unsupported/untagged formats must stay invisible to the index. + if err := os.WriteFile(filepath.Join(dir, "c.wav"), []byte("RIFF"), 0600); err != nil { + t.Fatal(err) + } + defer InvalidateISRCCache(dir) + + if got := readFileISRC(mp3Path); got != "usbb00000022" { + t.Fatalf("readFileISRC mp3 = %q", got) + } + if got := readFileISRC(filepath.Join(dir, "c.wav")); got != "" { + t.Fatalf("readFileISRC wav = %q", got) + } + + idx := buildISRCIndex(dir) + if path, ok := idx.lookup("USAA00000011"); !ok || path != flacPath { + t.Fatalf("flac lookup = %q/%v", path, ok) + } + if path, ok := idx.lookup("USBB00000022"); !ok || path != mp3Path { + t.Fatalf("mp3 lookup = %q/%v", path, ok) + } +} + func TestISRCIndexIncrementalRebuild(t *testing.T) { dir := t.TempDir() trackA := filepath.Join(dir, "a.flac") diff --git a/go_backend/duplicate.go b/go_backend/duplicate.go index 476b612c..6cd8abcf 100644 --- a/go_backend/duplicate.go +++ b/go_backend/duplicate.go @@ -108,7 +108,7 @@ func buildISRCIndex(outputDir string) *ISRCIndex { } ext := strings.ToLower(filepath.Ext(path)) - if ext != ".flac" { + if !isrcIndexExts[ext] { return nil } @@ -141,7 +141,7 @@ func buildISRCIndex(outputDir string) *ISRCIndex { go func() { defer wg.Done() for i := range tasks { - isrcs[i] = strings.ToUpper(readFlacISRC(toParse[i].path)) + isrcs[i] = strings.ToUpper(readFileISRC(toParse[i].path)) } }() } @@ -170,6 +170,38 @@ func buildISRCIndex(outputDir string) *ISRCIndex { return idx } +// isrcIndexExts are the formats the download pipeline can produce; each has +// a native tag reader that stops at the metadata blocks. +var isrcIndexExts = map[string]bool{ + ".flac": true, + ".mp3": true, + ".m4a": true, + ".ogg": true, + ".opus": true, +} + +// readFileISRC reads the ISRC tag using the native reader for the format. +// Returns "" for unsupported formats, unreadable files, or missing tags. +func readFileISRC(path string) string { + switch strings.ToLower(filepath.Ext(path)) { + case ".flac": + return readFlacISRC(path) + case ".mp3": + if meta, err := ReadID3Tags(path); err == nil && meta != nil { + return strings.TrimSpace(meta.ISRC) + } + case ".m4a": + if meta, err := ReadM4ATags(path); err == nil && meta != nil { + return strings.TrimSpace(meta.ISRC) + } + case ".ogg", ".opus": + if meta, err := ReadOggVorbisComments(path); err == nil && meta != nil { + return strings.TrimSpace(meta.ISRC) + } + } + return "" +} + // readFlacISRC extracts the ISRC Vorbis comment from a FLAC file by walking // the metadata block headers and reading only the VORBIS_COMMENT payload; // picture and padding blocks are seeked past, never loaded. Returns "" when diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index cbb6a146..40289fc9 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -9,6 +9,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; +import 'package:spotiflac_android/widgets/duplicate_review_sheet.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/widgets/settings_sliver_app_bar.dart'; @@ -405,6 +406,12 @@ class _LibrarySettingsPageState extends ConsumerState { .read(settingsProvider.notifier) .setLocalLibraryShowDuplicates(value), ), + SettingsItem( + icon: Icons.difference_outlined, + title: context.l10n.libraryReviewDuplicates, + subtitle: context.l10n.libraryReviewDuplicatesSubtitle, + onTap: () => DuplicateReviewSheet.show(context), + ), Opacity( opacity: settings.localLibraryEnabled ? 1.0 : 0.5, child: SettingsItem( diff --git a/lib/widgets/duplicate_review_sheet.dart b/lib/widgets/duplicate_review_sheet.dart new file mode 100644 index 00000000..a0ed3907 --- /dev/null +++ b/lib/widgets/duplicate_review_sheet.dart @@ -0,0 +1,301 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as p; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/providers/download_history_provider.dart'; +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/widgets/audio_quality_badges.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; + +/// Bottom sheet listing tracks that exist more than once (same ISRC) across +/// download history and the local library, with keep-best and per-copy +/// delete actions. +class DuplicateReviewSheet extends ConsumerStatefulWidget { + const DuplicateReviewSheet({super.key}); + + static Future show(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (_) => const DuplicateReviewSheet(), + ); + } + + @override + ConsumerState createState() => + _DuplicateReviewSheetState(); +} + +class _DuplicateReviewSheetState extends ConsumerState { + late Future> _groupsFuture; + bool _deletedLocalRows = false; + + @override + void initState() { + super.initState(); + _groupsFuture = LibraryDatabase.instance.findIsrcDuplicateGroups(); + } + + @override + void dispose() { + if (_deletedLocalRows) { + ref.read(localLibraryProvider.notifier).reloadFromStorage(); + } + super.dispose(); + } + + void _reload() { + setState(() { + _groupsFuture = LibraryDatabase.instance.findIsrcDuplicateGroups(); + }); + } + + String _qualityLabel(IsrcDuplicateEntry entry) { + var format = entry.format?.trim().toUpperCase() ?? ''; + if (format.isEmpty) { + format = p + .extension( + DownloadedEmbeddedCoverResolver.cleanFilePath(entry.filePath), + ) + .replaceFirst('.', '') + .toUpperCase(); + } + final bitDepth = entry.bitDepth ?? 0; + final sampleRate = entry.sampleRate ?? 0; + if (bitDepth > 0 && sampleRate > 0) { + final khz = sampleRate / 1000; + final khzText = khz % 1 == 0 ? khz.toInt().toString() : khz.toString(); + return format.isEmpty ? '$bitDepth/$khzText' : '$bitDepth/$khzText $format'; + } + final bitrate = entry.bitrate ?? 0; + if (bitrate > 0) { + return '${(bitrate / 1000).round()}k${format.isEmpty ? '' : ' $format'}'; + } + return format; + } + + Future _confirmDelete(String message) async { + final confirmed = await showDialog( + context: context, + useRootNavigator: true, + builder: (ctx) => AlertDialog( + title: Text(ctx.l10n.dialogDeleteSelectedTitle), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(ctx.l10n.dialogCancel), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + style: FilledButton.styleFrom( + backgroundColor: Theme.of(ctx).colorScheme.error, + ), + child: Text(ctx.l10n.dialogDelete), + ), + ], + ), + ); + return confirmed == true && mounted; + } + + Future _deleteEntries(List entries) async { + final historyNotifier = ref.read(downloadHistoryProvider.notifier); + var deleted = 0; + for (final entry in entries) { + try { + await deleteFile( + DownloadedEmbeddedCoverResolver.cleanFilePath(entry.filePath), + ); + } catch (_) {} + if (entry.source == 'downloaded') { + historyNotifier.removeFromHistory(entry.id); + } else { + await LibraryDatabase.instance.deleteByPath(entry.filePath); + _deletedLocalRows = true; + } + deleted++; + } + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarDeletedTracks(deleted))), + ); + _reload(); + } + + Future _keepBest(IsrcDuplicateGroup group) async { + final toDelete = group.entries.sublist(1); + final confirmed = await _confirmDelete( + context.l10n.duplicatesKeepBestMessage( + toDelete.length, + group.entries.first.trackName, + ), + ); + if (confirmed) await _deleteEntries(toDelete); + } + + Future _deleteSingle(IsrcDuplicateEntry entry) async { + final confirmed = await _confirmDelete( + context.l10n.duplicatesDeleteCopyMessage(entry.trackName), + ); + if (confirmed) await _deleteEntries([entry]); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.85, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + context.l10n.duplicatesTitle, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + ), + Flexible( + child: FutureBuilder>( + future: _groupsFuture, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ); + } + final groups = snapshot.data ?? const []; + if (groups.isEmpty) { + return Padding( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 32), + child: Text( + context.l10n.duplicatesEmpty, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ); + } + return ListView.builder( + shrinkWrap: true, + itemCount: groups.length, + itemBuilder: (context, index) => + _buildGroup(context, colorScheme, groups[index]), + ); + }, + ), + ), + ], + ), + ), + ); + } + + Widget _buildGroup( + BuildContext context, + ColorScheme colorScheme, + IsrcDuplicateGroup group, + ) { + final first = group.entries.first; + return SettingsGroup( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 8, 4), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + first.trackName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 2), + Text( + '${first.artistName} • ${group.isrc}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + TextButton( + onPressed: () => _keepBest(group), + child: Text(context.l10n.duplicatesKeepBest), + ), + ], + ), + ), + for (var i = 0; i < group.entries.length; i++) + ListTile( + dense: true, + title: Row( + children: [ + AudioQualityBadge( + label: _qualityLabel(group.entries[i]), + colorScheme: colorScheme, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + p.basename( + DownloadedEmbeddedCoverResolver.cleanFilePath( + group.entries[i].filePath, + ), + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ), + trailing: i == 0 + ? Icon(Icons.check_circle_outline, color: colorScheme.primary) + : IconButton( + tooltip: context.l10n.dialogDelete, + icon: Icon(Icons.delete_outline, color: colorScheme.error), + onPressed: () => _deleteSingle(group.entries[i]), + ), + ), + ], + ); + } +}