release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m18s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 5m33s
release / android (push) Successful in 5m47s
release / Build signed APK (releases and dev) (push) Successful in 6m4s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
The second run's refusals were mostly albums holding a track twice (a two-track single with three files), which no edition can pair, and release names that differ only in "+" against "/". Tracks are now read once per disc, number and title, and "and", "&" and "+" count for nothing when titles are compared; an edition's own title also counts as the album's name. library_album_matches records which matcher version decided each album. Refusals by an older version are matched again, even after the listing is done, so an improvement reaches them without a migration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
316 lines
13 KiB
Go
316 lines
13 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, false); got != c.want {
|
|
t.Errorf("%s: %d agree, want %d", c.name, got, c.want)
|
|
}
|
|
}
|
|
// Under the album's own name the titles are enough; the reason a track
|
|
// did not agree names both sides.
|
|
edit := []libraryTrack{{Number: 2, Title: "Song (Radio Edit)", LengthMs: 150000}}
|
|
if n, miss := trackAgreement(edit, edition, false); n != 0 || miss != `"Song (Radio Edit)" runs 2:30 here; MusicBrainz's track 2 is "Song", 3:20` {
|
|
t.Errorf("by length: %d, %q", n, miss)
|
|
}
|
|
if n, _ := trackAgreement(edit, edition, true); n != 1 {
|
|
t.Errorf("by title alone: %d agree", n)
|
|
}
|
|
if _, miss := trackAgreement([]libraryTrack{{Title: "Other", LengthMs: 61000}}, edition, true); miss != `no track of its edition is like "Other" (1:01)` {
|
|
t.Errorf("unplaced: %q", miss)
|
|
}
|
|
if !sameTitle("Brain Stew + Jaded", "Brain Stew / Jaded") || sameTitle("+++", "Grind & Hustle") || sameTitle("Blur", "The Best of Blur") {
|
|
t.Error("sameTitle: joining words count for nothing, and a title of none is no title")
|
|
}
|
|
n := int32(1)
|
|
copies := tracksToMatch([]dbq.ListAlbumTracksToMatchRow{
|
|
{Title: "Song", TrackNumber: &n, DurationMs: 1}, {Title: "song", TrackNumber: &n, DurationMs: 2}, {Title: "Other", TrackNumber: &n}})
|
|
if len(copies) != 2 {
|
|
t.Errorf("a second copy of a track is read once: %+v", copies)
|
|
}
|
|
if agrees(3, 4) || !agrees(4, 5) || agrees(0, 0) {
|
|
t.Error("agrees: 80% of the album's tracks, and an album has tracks")
|
|
}
|
|
}
|
|
|
|
// A refusal by an older matcher is matched again once the matching improves,
|
|
// even after the listing is done; a refusal by this one is not.
|
|
func TestListLibrary_RetriesAnOlderMatchersRefusals_Integration(t *testing.T) {
|
|
pool := newPool(t)
|
|
ctx := context.Background()
|
|
q := dbq.New(pool)
|
|
s := newService(t, pool, newFakeMB(t))
|
|
ballad := seedAlbum(t, q, blur, "The Ballad of Darren", "", []string{"The Ballad", "St. Charles Square"})
|
|
bootleg := seedAlbum(t, q, blur, "Bootleg", "", []string{"Intro"})
|
|
for _, a := range []pgtype.UUID{ballad.ID, bootleg.ID} {
|
|
if err := q.RecordAlbumMatch(ctx, dbq.RecordAlbumMatchParams{AlbumID: a, Outcome: "unmatched", Detail: "old", Matcher: 1}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if _, err := pool.Exec(ctx, `UPDATE library_album_matches SET matcher = $1 WHERE album_id = $2`, libraryMatcher, bootleg.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The reset leaves the listing done.
|
|
if err := s.listLibrary(ctx, &PassResult{}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if outcome, method, _, _ := matchOf(t, pool, ballad.ID); outcome != "listed" || method != matchDiscography {
|
|
t.Errorf("the older refusal was decided %s by %s", outcome, method)
|
|
}
|
|
if _, _, _, detail := matchOf(t, pool, bootleg.ID); detail != "old" {
|
|
t.Errorf("this matcher's own refusal was retried: %q", detail)
|
|
}
|
|
if state, _ := q.GetLibraryListing(ctx); state.LibraryUnlisted != 1 {
|
|
t.Errorf("unlisted = %d, want the bootleg alone", state.LibraryUnlisted)
|
|
}
|
|
}
|