package plan import ( "os" "path/filepath" "strings" "testing" "intake/internal/audio" "intake/internal/scan" "intake/internal/sheet" "intake/internal/tags" ) func track(name string, number, disc int, title string) audio.Track { notes := []tags.Note{ {Field: tags.FieldTitle, Value: title}, {Field: tags.FieldAlbum, Value: "An Album"}, {Field: tags.FieldAlbumArtist, Value: "An Artist"}, {Field: tags.FieldArtist, Value: "An Artist"}, {Field: tags.FieldTrackNumber, Value: itoa(number)}, {Field: tags.FieldDiscNumber, Value: itoa(disc)}, } return audio.Track{Path: "/staging/" + name, Ext: ".flac", Tags: notes} } func itoa(n int) string { if n == 0 { return "0" } var b []byte for n > 0 { b = append([]byte{byte('0' + n%10)}, b...) n /= 10 } return string(b) } func planFor(tracks []audio.Track) Plan { return planForMode(tracks, "always") } func planForMode(tracks []audio.Track, mode string) Plan { return Build(scan.Album{ Dir: "/staging/an-album", Name: "an-album", Status: scan.StatusReady, Tracks: tracks, }, "/archive", "/library", DirKey{}, mode) } func value(t *testing.T, p Plan, i int, field string) string { t.Helper() if i >= len(p.Tracks) { t.Fatalf("no track %d in the plan", i) } v, ok := tags.Get(p.Tracks[i].Tags, field) if !ok { t.Fatalf("track %d has no %s", i, field) } return v } func TestTotalsAreWrittenForASingleDisc(t *testing.T) { p := planFor([]audio.Track{ track("a.flac", 1, 1, "One"), track("b.flac", 2, 1, "Two"), track("c.flac", 3, 1, "Three"), }) for i := 0; i < 3; i++ { for _, f := range []string{tags.FieldTrackTotal, tags.FieldLegacyTrackTotal} { if got := value(t, p, i, f); got != "3" { t.Errorf("track %d %s = %q, want 3", i, f, got) } } for _, f := range []string{tags.FieldDiscTotal, tags.FieldLegacyDiscTotal} { if got := value(t, p, i, f); got != "1" { t.Errorf("track %d %s = %q, want 1", i, f, got) } } } } func TestTrackTotalCountsWithinEachDisc(t *testing.T) { p := planFor([]audio.Track{ track("d1a.flac", 1, 1, "One"), track("d1b.flac", 2, 1, "Two"), track("d2a.flac", 1, 2, "Three"), track("d2b.flac", 2, 2, "Four"), track("d2c.flac", 3, 2, "Five"), }) want := []string{"2", "2", "3", "3", "3"} for i, w := range want { if got := value(t, p, i, tags.FieldTrackTotal); got != w { t.Errorf("track %d TRACKTOTAL = %q, want %q", i, got, w) } if got := value(t, p, i, tags.FieldDiscTotal); got != "2" { t.Errorf("track %d DISCTOTAL = %q, want 2", i, got) } } } func TestTotalsAreComputedNotCopied(t *testing.T) { tracks := []audio.Track{ track("a.flac", 1, 1, "One"), track("b.flac", 2, 1, "Two"), } for i := range tracks { tracks[i].Tags = append(tracks[i].Tags, tags.Note{Field: tags.FieldTrackTotal, Value: "99"}, tags.Note{Field: tags.FieldLegacyTrackTotal, Value: "99"}, tags.Note{Field: tags.FieldDiscTotal, Value: "7"}, tags.Note{Field: tags.FieldLegacyDiscTotal, Value: "7"}, ) } p := planFor(tracks) for i := 0; i < 2; i++ { if got := value(t, p, i, tags.FieldTrackTotal); got != "2" { t.Errorf("track %d TRACKTOTAL = %q, want the computed 2", i, got) } if got := value(t, p, i, tags.FieldDiscTotal); got != "1" { t.Errorf("track %d DISCTOTAL = %q, want the computed 1", i, got) } } } func TestTotalsAreNotReportedAsDropped(t *testing.T) { p := planFor([]audio.Track{track("a.flac", 1, 1, "One")}) dropped := tags.Dropped(p.Tracks[0].Tags) for _, d := range dropped { switch d { case tags.FieldTrackTotal, tags.FieldDiscTotal, tags.FieldLegacyTrackTotal, tags.FieldLegacyDiscTotal: t.Errorf("%s is written by intake but reported as dropped", d) } } } // credit makes a track with a given per-track artist, and optionally no // ALBUMARTIST at all, which is the shape most of these cases care about. // TrackDisc returns a copy of a track placed on another disc. func TrackDisc(t audio.Track, disc int) audio.Track { for i := range t.Tags { if t.Tags[i].Field == tags.FieldDiscNumber { t.Tags[i].Value = itoa(disc) } } return t } func credit(name string, number int, artist string, albumArtist string) audio.Track { notes := []tags.Note{ {Field: tags.FieldTitle, Value: name}, {Field: tags.FieldAlbum, Value: "An Album"}, {Field: tags.FieldArtist, Value: artist}, {Field: tags.FieldTrackNumber, Value: itoa(number)}, {Field: tags.FieldDiscNumber, Value: "1"}, } if albumArtist != "" { notes = append(notes, tags.Note{Field: tags.FieldAlbumArtist, Value: albumArtist}) } return audio.Track{Path: "/staging/" + name + ".flac", Ext: ".flac", Tags: notes} } // An explicit ALBUMARTIST is authoritative. It exists so an album with guest // tracks still has one album artist, so a track crediting someone else must not // turn the album into a compilation. func TestExplicitAlbumArtistSurvivesGuestTracks(t *testing.T) { p := planFor([]audio.Track{ credit("a", 1, "Deadmau5", "Deadmau5"), credit("b", 2, "Deadmau5 + Wolfgang Gartner", "Deadmau5"), credit("c", 3, "Deadmau5 + Imogen Heap", "Deadmau5"), }) if p.VariousArt { t.Error("an album with guest tracks was treated as a compilation") } if p.AlbumArt != "Deadmau5" { t.Errorf("album artist = %q, want Deadmau5", p.AlbumArt) } if p.Blocked() { t.Errorf("plan is blocked: %v", p.Issues) } } // With no ALBUMARTIST, a clear majority per-track artist is the album artist. // Whole credit strings are compared, so "+" needs no special handling. func TestDominantArtistIsUsedWhenAlbumArtistIsMissing(t *testing.T) { var tracks []audio.Track for i := 1; i <= 11; i++ { tracks = append(tracks, credit("solo", i, "Deadmau5", "")) } tracks = append(tracks, credit("guest1", 12, "Deadmau5 + Wolfgang Gartner", ""), credit("guest2", 13, "Deadmau5 + Imogen Heap", ""), ) p := planFor(tracks) if p.VariousArt { t.Error("a solo album with two features was treated as a compilation") } if p.AlbumArt != "Deadmau5" { t.Errorf("album artist = %q, want Deadmau5", p.AlbumArt) } } // A real compilation has no majority, so it still goes to various-artists // rather than refusing to proceed. func TestAllDistinctArtistsStillMeansVarious(t *testing.T) { p := planFor([]audio.Track{ credit("a", 1, "Aphex Twin", ""), credit("b", 2, "Boards of Canada", ""), credit("c", 3, "Autechre", ""), credit("d", 4, "Squarepusher", ""), }) if !p.VariousArt { t.Error("four different artists should be a compilation") } if p.AlbumArt != "Various Artists" { t.Errorf("album artist = %q, want Various Artists", p.AlbumArt) } if p.Blocked() { t.Errorf("a compilation should not be blocked: %v", p.Issues) } } // The tag spelling in the file must not change what it means. This is the bug // that made a deadmau5 album unresolvable: ALBUM ARTIST was stripped as an // unknown tag while the album artist looked like it was never tagged. func TestVariantAlbumArtistSpellingResolves(t *testing.T) { tracks := []audio.Track{ credit("a", 1, "Deadmau5", ""), credit("b", 2, "Deadmau5", ""), } for i := range tracks { tracks[i].Tags = append(tracks[i].Tags, tags.Note{Field: "ALBUM ARTIST", Value: "Deadmau5"}) } p := planFor(tracks) if p.AlbumArt != "Deadmau5" { t.Errorf("album artist = %q, want Deadmau5", p.AlbumArt) } if p.VariousArt { t.Error("variant spelling should not produce a compilation") } if p.Blocked() { t.Errorf("plan is blocked: %v", p.Issues) } } // disc_prefix used to be parsed and then never read, so every album got a 1- // prefix whether or not it had more than one disc. func TestDiscPrefixModes(t *testing.T) { single := []audio.Track{ credit("a", 1, "An Artist", "An Artist"), credit("b", 2, "An Artist", "An Artist"), } multi := []audio.Track{ credit("a", 1, "An Artist", "An Artist"), TrackDisc(credit("b", 1, "An Artist", "An Artist"), 2), } for _, c := range []struct { mode string tracks []audio.Track wantName string }{ {"always", single, "1-01_a.flac"}, {"never", single, "01_a.flac"}, {"auto", single, "01_a.flac"}, {"always", multi, "1-01_a.flac"}, {"never", multi, "01_a.flac"}, {"auto", multi, "1-01_a.flac"}, } { p := planForMode(c.tracks, c.mode) if got := p.Tracks[0].Target; got != c.wantName { t.Errorf("mode %q: first target = %q, want %q", c.mode, got, c.wantName) } } } // Dropping the disc number across a multi-disc album leaves two tracks both // numbered 01, so it is worth saying so rather than doing it quietly. func TestDiscPrefixNeverWarnsOnMultiDisc(t *testing.T) { multi := []audio.Track{ credit("a", 1, "An Artist", "An Artist"), TrackDisc(credit("b", 1, "An Artist", "An Artist"), 2), } p := planForMode(multi, "never") if !hasWarning(p, "disc_prefix") { t.Errorf("expected a warning about dropping the disc prefix, got %v", p.Warnings) } if p := planForMode(multi, "auto"); hasWarning(p, "disc_prefix") { t.Error("auto keeps the prefix on a multi-disc album and should not warn") } } func hasWarning(p Plan, substr string) bool { for _, w := range p.Warnings { if strings.Contains(w, substr) { return true } } return false } // Filesystems cap one path component at 255 bytes, and the archive is assembled // inside a temporary directory named after the target. An over-long album name // therefore failed the whole album with "file name too long" rather than being // shortened, which real rips hit often: scene names, deluxe editions and format // tags all push past it. func TestOverlongFolderNamesAreShortened(t *testing.T) { long := strings.Repeat("Very Long Album Name ", 20) tracks := []audio.Track{{ Path: "/staging/a.flac", Ext: ".flac", Tags: []tags.Note{ {Field: tags.FieldTitle, Value: "A Song"}, {Field: tags.FieldAlbum, Value: long}, {Field: tags.FieldArtist, Value: strings.Repeat("Long Artist ", 30)}, {Field: tags.FieldAlbumArtist, Value: strings.Repeat("Long Artist ", 30)}, {Field: tags.FieldTrackNumber, Value: "1"}, {Field: tags.FieldDiscNumber, Value: "1"}, }, }} p := Build(scan.Album{Dir: "/staging/x", Name: "x", Status: scan.StatusReady, Tracks: tracks}, "/archive", "/library", DirKey{}, "always") if len(p.AlbumDir) > maxNameBytes { t.Errorf("album folder is %d bytes, over the %d cap", len(p.AlbumDir), maxNameBytes) } if len(p.ArtistDir) > maxNameBytes { t.Errorf("artist folder is %d bytes, over the %d cap", len(p.ArtistDir), maxNameBytes) } if !hasWarning(p, "shortened") { t.Errorf("shortening happened without saying so: %v", p.Warnings) } if p.Blocked() { t.Errorf("a long name should be shortened, not refused: %v", p.Issues) } } // The archive holds flac only, and the tools that write it are flac tools. A // lossy source used to be accepted by the plan and then fail deep inside // metaflac with "does not appear to be a FLAC file", after the album had already // been copied into place. func TestNonFLACTracksAreRefusedEarly(t *testing.T) { mp3 := credit("lossy", 2, "An Artist", "An Artist") mp3.Path = "/staging/lossy.mp3" mp3.Ext = ".mp3" p := planFor([]audio.Track{credit("lossless", 1, "An Artist", "An Artist"), mp3}) if !p.Blocked() { t.Fatal("an album containing an mp3 should be refused") } found := false for _, i := range p.Issues { if strings.Contains(i.Text, "flac only") { found = true } } if !found { t.Errorf("the refusal should say what is wrong and what to do about it: %v", p.Issues) } // A flac-only album is unaffected. if planFor([]audio.Track{credit("a", 1, "An Artist", "An Artist")}).Blocked() { t.Error("a flac album should not be refused") } } // A single file plus a cue sheet is how a lot of rips arrive, and the cue // usually describes an album's worth of tracks. intake does not split on cue // sheets, so the user has to be told rather than handed a one-track album. func TestCueSheetBesideASingleFileWarns(t *testing.T) { dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "album.cue"), []byte("FILE \"whole.flac\" WAVE\n"), 0o600); err != nil { t.Fatal(err) } one := []audio.Track{credit("whole", 1, "An Artist", "An Artist")} p := Build(scan.Album{Dir: dir, Name: "x", Status: scan.StatusReady, Tracks: one}, "/archive", "/library", DirKey{}, "always") if !hasWarning(p, "cue sheet") { t.Errorf("a cue sheet beside one file should be reported: %v", p.Warnings) } // An album of separate files with a cue alongside is ordinary, and silent. many := []audio.Track{ credit("a", 1, "An Artist", "An Artist"), credit("b", 2, "An Artist", "An Artist"), } if p := Build(scan.Album{Dir: dir, Name: "x", Status: scan.StatusReady, Tracks: many}, "/archive", "/library", DirKey{}, "always"); hasWarning(p, "cue sheet") { t.Error("a cue beside an already-split album should not warn") } } // Tracks that disagree are treated as not having agreed, but one mistyped tag out // of many should not stop an album. A clear majority wins and says so. func TestDisputedAlbumValueFallsBackToTheMajority(t *testing.T) { tracks := []audio.Track{ credit("a", 1, "An Artist", "An Artist"), credit("b", 2, "An Artist", "An Artist"), credit("c", 3, "An Artist", "An Artist"), } // mistype one, the way a bad rip does for i := range tracks[1].Tags { if tracks[1].Tags[i].Field == tags.FieldAlbum { tracks[1].Tags[i].Value = "Rong Album" } } p := planFor(tracks) if p.AlbumName != "An Album" { t.Errorf("album = %q, want the majority value", p.AlbumName) } if !hasWarning(p, "disagree") { t.Errorf("using the majority should be reported: %v", p.Warnings) } if p.Blocked() { t.Errorf("a single mistyped tag should not stop the album: %v", p.Issues) } // With no majority there is nothing to go on. split := []audio.Track{ credit("a", 1, "An Artist", "An Artist"), credit("b", 2, "An Artist", "An Artist"), } for i := range split[1].Tags { if split[1].Tags[i].Field == tags.FieldAlbum { split[1].Tags[i].Value = "Another Album" } } if p := planFor(split); !p.Blocked() { t.Error("a one-all split has no majority and should be refused") } } // Two tracks that share both a title and a track number produce the same target // name, and the copy truncates, so the second silently replaces the first. The // check that was supposed to catch this groups by title and folds repeats of the // same title together, so it never saw this pair at all. func TestDuplicateNumbersWithTheSameTitleAreBlocked(t *testing.T) { p := planFor([]audio.Track{ track("01 intro.flac", 1, 1, "Intro"), track("01 intro (1).flac", 1, 1, "Intro"), }) if !p.Blocked() { t.Fatalf("both tracks target %q and the plan is not blocked", p.Tracks[0].Target) } if !strings.Contains(firstHard(p), "would be written twice") { t.Errorf("the issue should say the name is written twice: %s", firstHard(p)) } if !strings.Contains(firstHard(p), "01 intro.flac") || !strings.Contains(firstHard(p), "01 intro (1).flac") { t.Errorf("the issue should name both staged files: %s", firstHard(p)) } } // The same pair on different discs is fine with the default disc prefix, because // the targets differ. func TestDuplicateNumbersOnDifferentDiscsAreNotBlocked(t *testing.T) { p := planFor([]audio.Track{ track("01 intro.flac", 1, 1, "Intro"), track("01 intro.flac", 1, 2, "Intro"), }) if p.Blocked() { t.Fatalf("disc 1 and disc 2 targets differ, but the plan is blocked: %s", firstHard(p)) } if p.Tracks[0].Target == p.Tracks[1].Target { t.Fatalf("targets should differ: %q", p.Tracks[0].Target) } } // Without a disc prefix they really do collide, and that has to be caught. func TestDuplicateNumbersWithoutDiscPrefixAreBlocked(t *testing.T) { p := planForMode([]audio.Track{ track("01 intro.flac", 1, 1, "Intro"), track("01 intro.flac", 1, 2, "Intro"), }, "never") if !p.Blocked() { t.Fatalf("disc_prefix=never makes both tracks %q and the plan is not blocked", p.Tracks[0].Target) } } func firstHard(p Plan) string { for _, i := range p.Issues { if i.Hard { return i.Text } } return "" } // The sheet is the last word on what gets written. Everything it carries has to // land in the plan and in the tags, because the tags are the only record of it // once the album is filed. func TestApplySheetSetsEveryField(t *testing.T) { p := planFor([]audio.Track{ track("01 one.flac", 1, 1, "One"), track("02 two.flac", 2, 1, "Two"), }) s := p.Sheet() s.Album = "Corrected Album" s.AlbumArtist = "Corrected Artist" s.ArtistDir = "corrected-artist" s.AlbumDir = "corrected-album" s.Year = "1999" s.Tracks[0].Artist = "Guest Singer" s.Tracks[1].Title = "Two, Reprised" if err := p.ApplySheet(s); err != nil { t.Fatal(err) } if p.AlbumName != "Corrected Album" || p.AlbumArt != "Corrected Artist" { t.Errorf("album = %q by %q", p.AlbumName, p.AlbumArt) } if p.ArtistDir != "corrected-artist" || p.AlbumDir != "corrected-album" { t.Errorf("folders = %q/%q", p.ArtistDir, p.AlbumDir) } if got := value(t, p, 0, tags.FieldAlbum); got != "Corrected Album" { t.Errorf("ALBUM = %q", got) } if got := value(t, p, 0, tags.FieldAlbumArtist); got != "Corrected Artist" { t.Errorf("ALBUMARTIST = %q", got) } if got := value(t, p, 0, tags.FieldArtist); got != "Guest Singer" { t.Errorf("track 1 ARTIST = %q", got) } if got := value(t, p, 1, tags.FieldArtist); got != "Corrected Artist" { t.Errorf("an empty track artist should mean the album artist, got %q", got) } if got := value(t, p, 0, tags.FieldDate); got != "1999" { t.Errorf("DATE = %q", got) } if got := value(t, p, 1, tags.FieldTitle); got != "Two, Reprised" { t.Errorf("TITLE = %q", got) } if p.Tracks[1].Target != "1-02_two-reprised.flac" { t.Errorf("target = %q", p.Tracks[1].Target) } if !strings.Contains(p.AlbumDir, "corrected") { t.Errorf("the destination should follow the folder: %q", p.ArchiveDir) } } // A folder that still holds the name intake derived it from follows a rename of // the album; one that was typed by hand does not. func TestApplySheetFollowsARenamedAlbumButNotATypedFolder(t *testing.T) { p := planFor([]audio.Track{track("01 one.flac", 1, 1, "One")}) s := p.Sheet() s.Album = "A Longer Album Name" if err := p.ApplySheet(s); err != nil { t.Fatal(err) } if p.AlbumDir != "a-longer-album-name" { t.Errorf("the derived folder did not follow the rename: %q", p.AlbumDir) } s = p.Sheet() s.AlbumDir = "deliberate" if err := p.ApplySheet(s); err != nil { t.Fatal(err) } if p.AlbumDir != "deliberate" { t.Errorf("a typed folder was overwritten: %q", p.AlbumDir) } s = p.Sheet() s.Album = "Yet Another Name" if err := p.ApplySheet(s); err != nil { t.Fatal(err) } if p.AlbumDir != "deliberate" { t.Errorf("a typed folder followed a rename anyway: %q", p.AlbumDir) } } // Deleting a block leaves the file out of the album, and the totals are counted // again: a stale TRACKTOTAL is what makes a player show an album as unfinished. func TestApplySheetDropsATrackAndRecounts(t *testing.T) { p := planFor([]audio.Track{ track("01 one.flac", 1, 1, "One"), track("02 two.flac", 2, 1, "Two"), track("03 three.flac", 3, 1, "Three"), }) if got := value(t, p, 0, tags.FieldTrackTotal); got != "3" { t.Fatalf("TRACKTOTAL before the edit = %q", got) } s := p.Sheet() s.Tracks = append(s.Tracks[:1], s.Tracks[2]) if err := p.ApplySheet(s); err != nil { t.Fatal(err) } if len(p.Tracks) != 2 { t.Fatalf("got %d tracks, want 2", len(p.Tracks)) } if filepath.Base(p.Tracks[1].Source) != "03 three.flac" { t.Errorf("the wrong track was dropped: %s", p.Tracks[1].Source) } if got := value(t, p, 0, tags.FieldTrackTotal); got != "2" { t.Errorf("TRACKTOTAL after the drop = %q, want 2", got) } if got := value(t, p, 1, tags.FieldDiscTotal); got != "1" { t.Errorf("DISCTOTAL = %q, want 1", got) } } // A disc that loses all its tracks stops being a disc. func TestApplySheetRecountsDiscs(t *testing.T) { p := planFor([]audio.Track{ track("01 one.flac", 1, 1, "One"), track("01 two.flac", 1, 2, "Two"), }) s := p.Sheet() s.Tracks = s.Tracks[:1] if err := p.ApplySheet(s); err != nil { t.Fatal(err) } if got := value(t, p, 0, tags.FieldDiscTotal); got != "1" { t.Errorf("DISCTOTAL = %q, want 1", got) } if !strings.HasPrefix(p.Tracks[0].Target, "1-01") { t.Errorf("the disc prefix should stay: %q", p.Tracks[0].Target) } } // Renumbering a track renames its file, and the padding of the other tracks does // not change underneath them: deleting track 100 should not rename track 1. func TestApplySheetRenumbersWithoutRenamingEverything(t *testing.T) { p := planFor([]audio.Track{ track("001 one.flac", 1, 1, "One"), track("100 two.flac", 100, 1, "Two"), }) if got := p.Tracks[0].Target; got != "1-001_one.flac" { t.Fatalf("target = %q", got) } s := p.Sheet() s.Tracks = s.Tracks[:1] if err := p.ApplySheet(s); err != nil { t.Fatal(err) } if got := p.Tracks[0].Target; got != "1-001_one.flac" { t.Errorf("the remaining track was renamed to %q", got) } s = p.Sheet() s.Tracks[0].Number = 7 if err := p.ApplySheet(s); err != nil { t.Fatal(err) } if got := value(t, p, 0, tags.FieldTrackNumber); got != "7" { t.Errorf("TRACKNUMBER = %q", got) } if got := p.Tracks[0].Target; got != "1-007_one.flac" { t.Errorf("target = %q", got) } } // A typed file name is used as it is, with the extension filled in when it is // missing, and the TITLE tag follows the name the person chose. func TestApplySheetHonoursATypedFileName(t *testing.T) { p := planFor([]audio.Track{track("01 one.flac", 1, 1, "One")}) s := p.Sheet() s.Tracks[0].File = "1-01_a-different-name" if err := p.ApplySheet(s); err != nil { t.Fatal(err) } if p.Tracks[0].Target != "1-01_a-different-name.flac" { t.Errorf("target = %q", p.Tracks[0].Target) } if got := value(t, p, 0, tags.FieldTitle); got != "a different name" { t.Errorf("TITLE = %q", got) } s = p.Sheet() s.Tracks[0].File = "../../escape.flac" if err := p.ApplySheet(s); err != nil { t.Fatal(err) } if strings.Contains(p.Tracks[0].Target, "/") || strings.Contains(p.Tracks[0].Target, "..") { t.Errorf("a typed name escaped the album: %q", p.Tracks[0].Target) } } // An issue the edit fixed has to go away, or the album stays blocked by a // complaint about metadata that is no longer there. func TestApplySheetClearsIssuesItFixed(t *testing.T) { p := planFor([]audio.Track{{ Path: "/staging/01 one.flac", Ext: ".flac", Tags: []tags.Note{ {Field: tags.FieldAlbum, Value: "An Album"}, {Field: tags.FieldTrackNumber, Value: "1"}, {Field: tags.FieldDiscNumber, Value: "1"}, }, }}) if !p.Blocked() { t.Fatal("an album with no artist and no title should be blocked") } s := p.Sheet() s.AlbumArtist = "Somebody" s.Tracks[0].Title = "One" if err := p.ApplySheet(s); err != nil { t.Fatal(err) } if p.Blocked() { t.Fatalf("the sheet fixed everything, but the plan is still blocked: %+v", p.Issues) } } // Two tracks given the same title and number in the sheet are caught, because // the second would overwrite the first. func TestApplySheetCatchesANewClash(t *testing.T) { p := planFor([]audio.Track{ track("01 one.flac", 1, 1, "One"), track("02 two.flac", 2, 1, "Two"), }) s := p.Sheet() s.Tracks[1].Title = "One" s.Tracks[1].Number = 1 if err := p.ApplySheet(s); err != nil { t.Fatal(err) } if !p.Blocked() { t.Fatal("two tracks now target the same file and the plan is not blocked") } if !strings.Contains(firstHard(p), "written twice") { t.Errorf("the issue should be about the clash: %s", firstHard(p)) } } // An empty year removes the tag rather than leaving the old one behind. func TestApplySheetClearsTheYear(t *testing.T) { p := planFor([]audio.Track{track("01 one.flac", 1, 1, "One")}) if err := p.ApplySheet(func() sheet.Sheet { s := p.Sheet(); s.Year = "1987"; return s }()); err != nil { t.Fatal(err) } if got := value(t, p, 0, tags.FieldDate); got != "1987" { t.Fatalf("DATE = %q", got) } s := p.Sheet() s.Year = "" if err := p.ApplySheet(s); err != nil { t.Fatal(err) } if _, ok := tags.Get(p.Tracks[0].Tags, tags.FieldDate); ok { t.Error("clearing the year left a DATE tag behind") } }