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")
}
}