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