git/intake/main
internal/cli/artist_test.go
245 lines8 KB last change raw · history
package cli

import (
	"bytes"
	"os"
	"path/filepath"
	"strings"
	"testing"

	"intake/internal/artist"
	"intake/internal/config"
	"intake/internal/plan"
	"intake/internal/scan"
	"intake/internal/tags"
)

func artistTestApp(t *testing.T, root string, yes bool) *App {
	t.Helper()
	cfg := config.Defaults()
	cfg.Root = root
	return &App{Cfg: cfg, Yes: yes, Out: &bytes.Buffer{}, Err: &bytes.Buffer{}}
}

func existingArtist(t *testing.T, root, dir string) {
	t.Helper()
	if err := os.MkdirAll(filepath.Join(root, "archive", dir), 0o755); err != nil {
		t.Fatal(err)
	}
}

func aliasFile(t *testing.T, root string) map[string]string {
	t.Helper()
	ix := artist.NewIndex()
	if err := ix.LoadAliases(artist.AliasPath(root)); err != nil {
		t.Fatal(err)
	}
	return ix.Alias
}

// "Blur" and "Blue" are 75% alike, which is exactly the fuzzy threshold. Taking
// the match files Blue's album inside Blur's folder, and nothing downstream ever
// compares a folder name with the ALBUMARTIST tag, so it is silent. An
// unattended run has nobody to ask, so it must create the folder the credit
// names and say what it did.
func TestUnattendedRunDoesNotFileAnArtistIntoASimilarFolder(t *testing.T) {
	for _, tc := range []struct{ existing, credit, want string }{
		{"blur", "Blue", "blue"},
		{"the-cult", "The Cure", "the-cure"},
		{"art-artist", "RTL Artist", "rtl-artist"},
		{"scan-artist", "Slash Artist", "slash-artist"},
		{"loose-artist", "Long Artist", "long-artist"},
	} {
		t.Run(tc.credit, func(t *testing.T) {
			root := t.TempDir()
			existingArtist(t, root, tc.existing)
			a := artistTestApp(t, root, true)

			key, _, err := a.resolveArtist(scan.Album{}, tc.credit, false, "")
			if err != nil {
				t.Fatal(err)
			}
			if key.Dir != tc.want {
				t.Errorf("folder = %q, want %q (existing %q is only similar)", key.Dir, tc.want, tc.existing)
			}
			out := a.Out.(*bytes.Buffer).String()
			if !strings.Contains(out, tc.existing) {
				t.Errorf("the near match %q was not reported:\n%s", tc.existing, out)
			}
			if got := aliasFile(t, root); got[tc.credit] != "" {
				t.Errorf("a guess was written to artists.toml as %q = %q", tc.credit, got[tc.credit])
			}
		})
	}
}

// The safe path must not have broken the certain one: a recorded alias, or a
// folder whose name is exactly the credit, is still followed without asking.
func TestCertainArtistMatchesAreStillUsed(t *testing.T) {
	root := t.TempDir()
	existingArtist(t, root, "beatles")

	a := artistTestApp(t, root, true)
	key, _, err := a.resolveArtist(scan.Album{}, "Beatles", false, "")
	if err != nil {
		t.Fatal(err)
	}
	if key.Dir != "beatles" {
		t.Errorf("exact folder match = %q, want beatles", key.Dir)
	}

	if err := os.WriteFile(artist.AliasPath(root),
		[]byte("\"The Beatles\" = \"beatles\"\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	a = artistTestApp(t, root, true)
	key, _, err = a.resolveArtist(scan.Album{}, "The Beatles", false, "")
	if err != nil {
		t.Fatal(err)
	}
	if key.Dir != "beatles" {
		t.Errorf("recorded alias = %q, want beatles", key.Dir)
	}
}

// An alias written by intake has to survive being read back. TOMLKey writes the
// key with %q, so a credit with a quote in it comes back escaped unless the
// reader undoes the escaping, and the entry then matches nothing at all.
func TestAliasRoundTripsThroughQuoting(t *testing.T) {
	root := t.TempDir()
	for _, credit := range []string{
		`"Weird Al" Yankovic`,
		`RTL \u200fArtist`,
		"AC/DC",
		"Straße",
		`back\slash`,
	} {
		t.Run(credit, func(t *testing.T) {
			ix := artist.NewIndex()
			ix.SetAlias(credit, "some-folder")
			if err := ix.SaveAliases(artist.AliasPath(root)); err != nil {
				t.Fatal(err)
			}
			reloaded := artist.NewIndex()
			if err := reloaded.LoadAliases(artist.AliasPath(root)); err != nil {
				t.Fatal(err)
			}
			if got := reloaded.Alias[credit]; got != "some-folder" {
				t.Errorf("alias for %q came back as %q", credit, got)
			}
		})
	}
}

// The album sheet is where a wrong album artist, a missing year or a bad track
// number is fixed. This drives the whole path: build a plan, hand it to $EDITOR,
// read back what the editor saved, and check it landed.
func TestEditingTheAlbumSheetAppliesEverything(t *testing.T) {
	root := t.TempDir()
	script := filepath.Join(root, "editor.sh")
	body := `#!/bin/sh
sed -i \
  -e 's/^albumartist:.*/albumartist:  The Right Artist/' \
  -e 's/^album:.*/album:        The Right Album/' \
  -e 's/^year:.*/year:         1997/' \
  -e 's/^title:        One$/title:        One, Reprised/' \
  "$1"
`
	if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
		t.Fatal(err)
	}

	a, _ := cliApp(t, root)
	a.Cfg.Editor = script
	p := plan.Plan{
		Album:      scan.Album{Dir: filepath.Join(root, "staging", "x")},
		AlbumName:  "An Album",
		AlbumArt:   "An Artist",
		ArtistDir:  "an-artist",
		AlbumDir:   "an-album",
		ArchiveDir: filepath.Join(root, "archive", "an-artist", "an-album"),
		LibraryDir: filepath.Join(root, "library", "an-artist", "an-album"),
		Tracks: []plan.TrackPlan{{
			Source: filepath.Join(root, "staging", "x", "01 one.flac"),
			Title:  "One",
			Disc:   1,
			Number: 1,
			Target: "1-01_one.flac",
			Ext:    ".flac",
			Tags: []tags.Note{
				{Field: tags.FieldTitle, Value: "One"},
				{Field: tags.FieldAlbum, Value: "An Album"},
				{Field: tags.FieldArtist, Value: "An Artist"},
				{Field: tags.FieldAlbumArtist, Value: "An Artist"},
				{Field: tags.FieldTrackNumber, Value: "1"},
			},
		}},
	}

	before := p.Sheet()
	got, err := a.editSheet(&p)
	if err != nil {
		t.Fatal(err)
	}
	if got == nil {
		t.Fatal("the editor returned nothing")
	}
	if p.AlbumArt != "The Right Artist" || p.AlbumName != "The Right Album" {
		t.Errorf("album = %q by %q", p.AlbumName, p.AlbumArt)
	}
	if p.Year != "1997" {
		t.Errorf("year = %q", p.Year)
	}
	if got.Tracks[0].Title != "One, Reprised" {
		t.Errorf("title = %q", got.Tracks[0].Title)
	}
	if p.Tracks[0].Target != "1-01_one-reprised.flac" {
		t.Errorf("target = %q", p.Tracks[0].Target)
	}
	if v, _ := tags.Get(p.Tracks[0].Tags, tags.FieldAlbumArtist); v != "The Right Artist" {
		t.Errorf("the ALBUMARTIST tag was not updated: %q", v)
	}
	if v, _ := tags.Get(p.Tracks[0].Tags, tags.FieldDate); v != "1997" {
		t.Errorf("the DATE tag was not added: %q", v)
	}
	// The artists.toml-worthy question is whether the folder moved.
	changes := got.ChangedFrom(before)
	if !changes.AlbumArtist || changes.ArtistDir {
		t.Errorf("changes = %+v; the folder was not touched by the editor", changes)
	}

	// And an editor that saves the file unchanged changes nothing.
	if _, err := a.editSheet(&p); err != nil {
		t.Fatal(err)
	}
	if p.AlbumArt != "The Right Artist" {
		t.Errorf("a no-op edit changed the album: %q", p.AlbumArt)
	}
}

// An album of mp3s is blocked too, and the sheet cannot turn a lossy file into a
// lossless one, so opening an editor for it would only waste a round trip.
func TestTheSheetOnlyOffersToFixWhatItCanFix(t *testing.T) {
	mp3 := plan.Plan{
		AlbumName: "An Album",
		AlbumArt:  "An Artist",
		Tracks:    []plan.TrackPlan{{Source: "/s/01 one.mp3", Ext: ".mp3", Title: "One"}},
		Issues:    []plan.Issue{{Hard: true, Text: "the archive holds flac only, and this album contains mp3; convert it to flac first, or file it by hand"}},
	}
	if sheetFixable(mp3) {
		t.Error("an mp3 album should not send the user to the editor")
	}

	nameless := plan.Plan{
		Tracks: []plan.TrackPlan{{Source: "/s/01 one.flac", Ext: ".flac", Title: "One"}},
		Issues: []plan.Issue{{Hard: true, Text: "album artist is unknown: no ALBUMARTIST/ARTIST tag and the directory name gives no hint"}},
	}
	if !sheetFixable(nameless) {
		t.Error("an album with no artist is exactly what the sheet is for")
	}

	if sheetFixable(plan.Plan{}) {
		t.Error("a plan with no hard issues should not need the editor")
	}
}