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