release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m28s
release / android (push) Successful in 5m44s
release / Build signed APK (releases and dev) (push) Successful in 5m58s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
The one-time listing took only albums whose tags already named their release group and artist, and left 340 off the list. The rest are now matched: the release their tags name, else the artist's release groups called what the album is called, else a MusicBrainz search by artist and title. A group found by name is taken only when its tracks agree with the album's (by disc and number, then title; length and title, or recording), so a partial copy still matches and a same-titled other album does not. library_album_matches records each album the listing decided, listed or unmatched with why, so an album taken off the list is never re-listed. The albums no match was found for are named on the Held tab. Album rows with no tracks are no longer counted. The length and title checks move from the importer to release, shared by both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
263 lines
10 KiB
Go
263 lines
10 KiB
Go
package wanted
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
|
)
|
|
|
|
// startListing clears the reset's "already listed", as a fresh install has it.
|
|
func startListing(t *testing.T, pool *pgxpool.Pool) {
|
|
t.Helper()
|
|
if _, err := pool.Exec(context.Background(), `
|
|
UPDATE acquisition_settings SET library_listing_started_at = NULL, library_listed_at = NULL, library_unlisted = 0`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// seedAlbum puts an album in the library whose tracks are titled titles,
|
|
// numbered by numbers (from 1 when none are given). An artistMBID or
|
|
// release of "" leaves that id out, as untagged files do.
|
|
func seedAlbum(t *testing.T, q *dbq.Queries, artistMBID, title, rel string, titles []string, numbers ...int32) dbq.Album {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
ap := dbq.UpsertArtistParams{Name: "Blur", SortName: "Blur"}
|
|
if artistMBID != "" {
|
|
ap.Mbid = &artistMBID
|
|
}
|
|
artist, err := q.UpsertArtist(ctx, ap)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
params := dbq.UpsertAlbumParams{Title: title, SortTitle: title, ArtistID: artist.ID}
|
|
if rel != "" {
|
|
params.Mbid = &rel
|
|
}
|
|
album, err := q.UpsertAlbum(ctx, params)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for i, name := range titles {
|
|
num := int32(i + 1)
|
|
if i < len(numbers) {
|
|
num = numbers[i]
|
|
}
|
|
if _, err := q.UpsertTrack(ctx, dbq.UpsertTrackParams{
|
|
Title: name, AlbumID: album.ID, ArtistID: artist.ID, TrackNumber: &num, DurationMs: 180000,
|
|
FilePath: fmt.Sprintf("/music/%s/%02d.flac", strings.ReplaceAll(title, " ", "_"), num), FileSize: 1, FileFormat: "flac",
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
return album
|
|
}
|
|
|
|
func matchOf(t *testing.T, pool *pgxpool.Pool, album pgtype.UUID) (outcome, method, group, detail string) {
|
|
t.Helper()
|
|
if err := pool.QueryRow(context.Background(), `
|
|
SELECT outcome, method, release_group_mbid, detail FROM library_album_matches WHERE album_id = $1`, album,
|
|
).Scan(&outcome, &method, &group, &detail); err != nil {
|
|
t.Fatalf("no match recorded: %v", err)
|
|
}
|
|
return
|
|
}
|
|
|
|
// The library's albums go on the list as held, once (#5567). Tags that name
|
|
// the group list it at once; a release id is named through MusicBrainz;
|
|
// what nothing matches is recorded with why. Taken off the list, an album
|
|
// stays off, and one added to the library afterwards is never listed.
|
|
func TestListLibrary_OnceAndThenTheListIsTheTruth_Integration(t *testing.T) {
|
|
pool := newPool(t)
|
|
ctx := context.Background()
|
|
q := dbq.New(pool)
|
|
s := newService(t, pool, newFakeMB(t))
|
|
s.releaseGroup = func(_ context.Context, release string) (string, error) {
|
|
if release == "rel-ballad" {
|
|
return theBallad, nil
|
|
}
|
|
return "", tags.ErrNotFound
|
|
}
|
|
blurAlbum := seedHeld(t, q, "Blur", "rel-blur", "rg-blur", 3)
|
|
ballad := seedAlbum(t, q, blur, "The Ballad of Darren", "rel-ballad", []string{"The Ballad", "St. Charles Square"})
|
|
bootleg := seedAlbum(t, q, blur, "Bootleg", "rel-bootleg", []string{"Intro"})
|
|
// An album with a track gone: held from before the loss, so
|
|
// re-acquisition still fetches it back.
|
|
lost := seedHeld(t, q, "13", "rel-13", "rg-13", 2)
|
|
if _, err := pool.Exec(ctx, `UPDATE tracks SET missing_since = now() - interval '1 day'
|
|
WHERE album_id = $1 AND track_number = 1`, lost.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// An album row with no tracks is not an album in the library.
|
|
if _, err := q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: "Empty", SortTitle: "Empty", ArtistID: blurAlbum.ArtistID}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
startListing(t, pool)
|
|
|
|
if err := s.listLibrary(ctx, &PassResult{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for group, album := range map[string]pgtype.UUID{"rg-blur": blurAlbum.ID, theBallad: ballad.ID, "rg-13": lost.ID} {
|
|
w, err := q.GetWantByReleaseGroup(ctx, group)
|
|
if err != nil {
|
|
t.Fatalf("%s not listed: %v", group, err)
|
|
}
|
|
if w.Reason != ReasonLibrary || !w.FulfilledAt.Valid || w.AlbumID != album || w.ArtistMbid != blur {
|
|
t.Errorf("%s = %+v: a held library want about the album", group, w)
|
|
}
|
|
}
|
|
if w, _ := q.GetWantByReleaseGroup(ctx, "rg-13"); !w.FulfilledAt.Time.Before(time.Now().Add(-23 * time.Hour)) {
|
|
t.Errorf("an album missing a track is held from %v, not from before the loss", w.FulfilledAt.Time)
|
|
}
|
|
if outcome, method, _, _ := matchOf(t, pool, ballad.ID); outcome != "listed" || method != matchRelease {
|
|
t.Errorf("ballad decided %s by %s", outcome, method)
|
|
}
|
|
if outcome, _, _, detail := matchOf(t, pool, bootleg.ID); outcome != "unmatched" || !strings.Contains(detail, `no album called "Bootleg"`) {
|
|
t.Errorf("bootleg decided %s: %q", outcome, detail)
|
|
}
|
|
state, err := q.GetLibraryListing(ctx)
|
|
if err != nil || !state.LibraryListedAt.Valid || state.LibraryUnlisted != 1 {
|
|
t.Fatalf("listing = %+v %v: done, with only the bootleg unmatched", state, err)
|
|
}
|
|
|
|
// Off the list stays off; an album added later is never listed.
|
|
w, _ := q.GetWantByReleaseGroup(ctx, "rg-blur")
|
|
if _, err := s.RemoveWant(ctx, w.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_ = seedHeld(t, q, "Think Tank", "rel-think", "rg-think", 2)
|
|
if _, err := s.Pass(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, group := range []string{"rg-blur", "rg-think"} {
|
|
if _, err := q.GetWantByReleaseGroup(ctx, group); err == nil {
|
|
t.Errorf("%s is on the list", group)
|
|
}
|
|
}
|
|
}
|
|
|
|
// An album whose tags name nothing is matched by its title among the
|
|
// artist's release groups, and taken only when its tracks agree: a partial
|
|
// copy agrees on the tracks it has. One whose title matches but whose
|
|
// tracks do not is left unmatched, with why.
|
|
func TestListLibrary_MatchesByDiscographyWhenTheTracksAgree_Integration(t *testing.T) {
|
|
pool := newPool(t)
|
|
ctx := context.Background()
|
|
q := dbq.New(pool)
|
|
s := newService(t, pool, newFakeMB(t))
|
|
// MusicBrainz switched off for tags does not stop matching by name.
|
|
s.releaseGroup = func(context.Context, string) (string, error) { return "", tags.ErrSwitchedOff }
|
|
partial := seedAlbum(t, q, blur, "The Ballad of Darren (Deluxe)", "rel-ballad", []string{"St Charles Square"}, 2)
|
|
wrong := seedAlbum(t, q, blur, "Parklife", "", []string{"Girls & Boys", "Tracy Jacks"})
|
|
startListing(t, pool)
|
|
|
|
if err := s.listLibrary(ctx, &PassResult{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if outcome, method, group, _ := matchOf(t, pool, partial.ID); outcome != "listed" || method != matchDiscography || group != theBallad {
|
|
t.Errorf("partial copy decided %s by %s as %s", outcome, method, group)
|
|
}
|
|
var named *string
|
|
if err := pool.QueryRow(ctx, `SELECT release_group_mbid FROM albums WHERE id = $1`, partial.ID).Scan(&named); err != nil || named == nil || *named != theBallad {
|
|
t.Errorf("the album's release group = %v %v", named, err)
|
|
}
|
|
if outcome, _, _, detail := matchOf(t, pool, wrong.ID); outcome != "unmatched" || !strings.Contains(detail, "none has its tracks") {
|
|
t.Errorf("Parklife without Parklife's tracks decided %s: %q", outcome, detail)
|
|
}
|
|
if _, err := q.GetWantByReleaseGroup(ctx, parklife); err == nil {
|
|
t.Error("Parklife listed on its title alone")
|
|
}
|
|
}
|
|
|
|
// An artist the tags give no id is found by MusicBrainz's search on artist
|
|
// and title, and the want is filed under the artist MusicBrainz credits.
|
|
func TestListLibrary_MatchesBySearchWhenTheArtistHasNoID_Integration(t *testing.T) {
|
|
pool := newPool(t)
|
|
ctx := context.Background()
|
|
q := dbq.New(pool)
|
|
s := newService(t, pool, newFakeMB(t))
|
|
album := seedAlbum(t, q, "", "The Ballad of Darren", "", []string{"The Ballad", "St. Charles Square"})
|
|
startListing(t, pool)
|
|
|
|
if err := s.listLibrary(ctx, &PassResult{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if outcome, method, _, _ := matchOf(t, pool, album.ID); outcome != "listed" || method != matchSearch {
|
|
t.Errorf("decided %s by %s", outcome, method)
|
|
}
|
|
w, err := q.GetWantByReleaseGroup(ctx, theBallad)
|
|
if err != nil || w.ArtistMbid != blur || w.Reason != ReasonLibrary || !w.FulfilledAt.Valid {
|
|
t.Errorf("want = %+v %v", w, err)
|
|
}
|
|
}
|
|
|
|
// A MusicBrainz that does not answer pauses the matching; the album is not
|
|
// decided on half the evidence, and the next pass goes on.
|
|
func TestListLibrary_UnreachablePausesTheMatching_Integration(t *testing.T) {
|
|
pool := newPool(t)
|
|
ctx := context.Background()
|
|
q := dbq.New(pool)
|
|
f := newFakeMB(t)
|
|
s := newService(t, pool, f)
|
|
album := seedAlbum(t, q, blur, "The Ballad of Darren", "", []string{"The Ballad", "St. Charles Square"})
|
|
startListing(t, pool)
|
|
|
|
f.down.Store(true)
|
|
if err := s.listLibrary(ctx, &PassResult{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if state, _ := q.GetLibraryListing(ctx); state.LibraryListedAt.Valid {
|
|
t.Fatal("finished while an album was still to be matched")
|
|
}
|
|
var n int
|
|
if err := pool.QueryRow(ctx, `SELECT count(*) FROM library_album_matches WHERE album_id = $1`, album.ID).Scan(&n); err != nil || n != 0 {
|
|
t.Fatalf("decided while MusicBrainz was away: %d %v", n, err)
|
|
}
|
|
f.down.Store(false)
|
|
if err := s.listLibrary(ctx, &PassResult{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := q.GetWantByReleaseGroup(ctx, theBallad); err != nil {
|
|
t.Errorf("not listed once MusicBrainz answered: %v", err)
|
|
}
|
|
if state, _ := q.GetLibraryListing(ctx); !state.LibraryListedAt.Valid {
|
|
t.Error("not finished")
|
|
}
|
|
}
|
|
|
|
func TestTrackAgreement(t *testing.T) {
|
|
edition := []editionTrack{
|
|
{Medium: 1, Position: 1, Title: "Intro", LengthMs: 60000, Recording: "rec-1"},
|
|
{Medium: 1, Position: 2, Title: "Song", LengthMs: 200000},
|
|
{Medium: 2, Position: 1, Title: "Encore", LengthMs: 300000},
|
|
}
|
|
for _, c := range []struct {
|
|
name string
|
|
tracks []libraryTrack
|
|
want int
|
|
}{
|
|
{"all, by position", []libraryTrack{
|
|
{Disc: 1, Number: 1, Title: "Intro", LengthMs: 60000}, {Number: 2, Title: "Song", LengthMs: 201000},
|
|
{Disc: 2, Number: 1, Title: "Encore", LengthMs: 300000}}, 3},
|
|
{"a partial copy", []libraryTrack{{Disc: 2, Number: 1, Title: "Encore", LengthMs: 300500}}, 1},
|
|
{"unnumbered, by title", []libraryTrack{{Title: "Song", LengthMs: 200000}, {Title: "Encore", LengthMs: 299000}}, 2},
|
|
{"the recording, whatever its title", []libraryTrack{{Number: 1, Title: "Untitled", LengthMs: 1, Recording: "REC-1"}}, 1},
|
|
{"the right title at the wrong length", []libraryTrack{{Number: 2, Title: "Song", LengthMs: 400000}}, 0},
|
|
{"a track counted once", []libraryTrack{{Title: "Song", LengthMs: 200000}, {Title: "Song", LengthMs: 200000}}, 1},
|
|
} {
|
|
if got := trackAgreement(c.tracks, edition); got != c.want {
|
|
t.Errorf("%s: %d agree, want %d", c.name, got, c.want)
|
|
}
|
|
}
|
|
if agrees(3, 4) || !agrees(4, 5) || agrees(0, 0) {
|
|
t.Error("agrees: 80% of the album's tracks, and an album has tracks")
|
|
}
|
|
}
|