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