feat(library): ISRC duplicate detection beyond FLAC with review sheet

The download-time ISRC index now also parses mp3, m4a, ogg, and opus
tags through the existing native readers instead of skipping everything
but .flac. Library settings gain a Review duplicates sheet that groups
history and local-library rows sharing an ISRC via SQL over the
attached databases (no filesystem walk, SAF-safe), shows quality
badges, and offers keep-best and per-copy delete with confirmation.
This commit is contained in:
zarzet
2026-07-26 18:49:58 +07:00
parent 50c8f0e454
commit a21d5bcb33
4 changed files with 376 additions and 2 deletions
@@ -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")
+34 -2
View File
@@ -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
@@ -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<LibrarySettingsPage> {
.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(
+301
View File
@@ -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<void> show(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (_) => const DuplicateReviewSheet(),
);
}
@override
ConsumerState<DuplicateReviewSheet> createState() =>
_DuplicateReviewSheetState();
}
class _DuplicateReviewSheetState extends ConsumerState<DuplicateReviewSheet> {
late Future<List<IsrcDuplicateGroup>> _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<bool> _confirmDelete(String message) async {
final confirmed = await showDialog<bool>(
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<void> _deleteEntries(List<IsrcDuplicateEntry> 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<void> _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<void> _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<List<IsrcDuplicateGroup>>(
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]),
),
),
],
);
}
}