fix: every library album is matched to MusicBrainz before it is listed, by its tags, its artist's discography or a search, and checked against its tracks (M502 #5569)
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
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>
This commit is contained in:
+71
-37
@@ -4,19 +4,24 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||
)
|
||||
|
||||
// ReasonLibrary: on the list because the library held it when the list
|
||||
// began (#5567).
|
||||
const ReasonLibrary = "library"
|
||||
|
||||
// libraryNamesPerPass bounds the MusicBrainz lookups one pass makes for
|
||||
// albums whose tags named a release but not its group: about two minutes at
|
||||
// MusicBrainz's one request a second, so the pass still settles its wants.
|
||||
const libraryNamesPerPass = 120
|
||||
// libraryMatchBudget bounds the time one pass spends matching albums to
|
||||
// MusicBrainz, at its one request a second, so the pass still settles its
|
||||
// wants; the rest wait for the next pass.
|
||||
const libraryMatchBudget = 2 * time.Minute
|
||||
|
||||
// libraryMatchBatch is how many albums one query hands the matching.
|
||||
const libraryMatchBatch = 25
|
||||
|
||||
// listLibrary puts the library's albums on the wanted list as held, once
|
||||
// (operator, 2026-10-10: "make a record saying that the import has
|
||||
@@ -26,13 +31,15 @@ const libraryNamesPerPass = 120
|
||||
// - the first step stamps when the listing began, which is its cutoff:
|
||||
// an album the library gains afterwards, dropped in by hand, is never
|
||||
// listed;
|
||||
// - every album whose release group is known goes on the list at once;
|
||||
// - albums that name a release but not its group are looked up on
|
||||
// MusicBrainz, a bounded number a pass, and listed as each is named;
|
||||
// - once none is left to name, the listing is stamped done, with a count
|
||||
// of the albums nothing names to MusicBrainz.
|
||||
// - every album whose tags name its release group and artist goes on the
|
||||
// list at once;
|
||||
// - every other album is matched to MusicBrainz (#5569), within a time
|
||||
// budget a pass, and listed as it is matched;
|
||||
// - once none is left to match, the listing is stamped done, with a count
|
||||
// of the albums no match was found for.
|
||||
//
|
||||
// After that it never runs again, so an album taken off the list stays off.
|
||||
// Each album decided is recorded in library_album_matches, so an album taken
|
||||
// off the list stays off, and once done the listing never runs again.
|
||||
func (s *Service) listLibrary(ctx context.Context, res *PassResult) error {
|
||||
q := dbq.New(s.pool)
|
||||
state, err := q.GetLibraryListing(ctx)
|
||||
@@ -47,6 +54,9 @@ func (s *Service) listLibrary(ctx context.Context, res *PassResult) error {
|
||||
if cutoff, err = q.StartLibraryListing(ctx); err != nil {
|
||||
return fmt.Errorf("wanted: start the library listing: %w", err)
|
||||
}
|
||||
if err := q.RecordListedAlbums(ctx, cutoff); err != nil {
|
||||
return fmt.Errorf("wanted: record the library: %w", err)
|
||||
}
|
||||
n, err := q.ListLibraryAlbums(ctx, dbq.ListLibraryAlbumsParams{Cutoff: cutoff})
|
||||
if err != nil {
|
||||
return fmt.Errorf("wanted: list the library: %w", err)
|
||||
@@ -55,10 +65,11 @@ func (s *Service) listLibrary(ctx context.Context, res *PassResult) error {
|
||||
s.logger.Info("wanted: listing the library's albums as held", "listed", n)
|
||||
}
|
||||
|
||||
deadline := s.now().Add(libraryMatchBudget)
|
||||
for {
|
||||
albums, err := q.ListLibraryAlbumsToName(ctx, dbq.ListLibraryAlbumsToNameParams{Cutoff: cutoff, MaxRows: libraryNamesPerPass})
|
||||
albums, err := q.ListAlbumsToMatch(ctx, dbq.ListAlbumsToMatchParams{Cutoff: cutoff, MaxRows: libraryMatchBatch})
|
||||
if err != nil {
|
||||
return fmt.Errorf("wanted: albums to name: %w", err)
|
||||
return fmt.Errorf("wanted: albums to match: %w", err)
|
||||
}
|
||||
if len(albums) == 0 {
|
||||
break
|
||||
@@ -67,45 +78,68 @@ func (s *Service) listLibrary(ctx context.Context, res *PassResult) error {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
group, err := s.releaseGroup(ctx, deref(a.Mbid))
|
||||
switch {
|
||||
case errors.Is(err, tags.ErrSwitchedOff):
|
||||
// Nothing can be named while MusicBrainz is off: the rest are
|
||||
// counted as unlisted rather than left waiting for ever.
|
||||
return s.finishLibrary(ctx, q, res)
|
||||
case errors.Is(err, tags.ErrNotFound):
|
||||
group = "" // MusicBrainz has none: asked once
|
||||
case err != nil:
|
||||
// Unreachable or refusing for now: the next pass goes on.
|
||||
s.logger.Info("wanted: naming the library's albums paused", "err", err)
|
||||
if s.now().After(deadline) {
|
||||
return nil // the rest next pass
|
||||
}
|
||||
n, err := s.listAlbum(ctx, q, a)
|
||||
if errors.Is(err, errMusicBrainzAway) {
|
||||
s.logger.Info("wanted: matching the library's albums paused", "err", err)
|
||||
return nil
|
||||
}
|
||||
if err := q.SetAlbumReleaseGroup(ctx, dbq.SetAlbumReleaseGroupParams{ID: a.ID, ReleaseGroupMbid: &group}); err != nil {
|
||||
return fmt.Errorf("wanted: name album %s: %w", a.Title, err)
|
||||
}
|
||||
if group == "" {
|
||||
continue
|
||||
}
|
||||
n, err := q.ListLibraryAlbums(ctx, dbq.ListLibraryAlbumsParams{Cutoff: cutoff, AlbumID: a.ID})
|
||||
if err != nil {
|
||||
return fmt.Errorf("wanted: list album %s: %w", a.Title, err)
|
||||
return err
|
||||
}
|
||||
res.Listed += int(n)
|
||||
}
|
||||
if len(albums) == libraryNamesPerPass {
|
||||
return nil // the rest next pass
|
||||
res.Listed += n
|
||||
}
|
||||
}
|
||||
return s.finishLibrary(ctx, q, res)
|
||||
}
|
||||
|
||||
// listAlbum matches one album and lists it, or records why it could not.
|
||||
func (s *Service) listAlbum(ctx context.Context, q *dbq.Queries, a dbq.ListAlbumsToMatchRow) (int, error) {
|
||||
rows, err := q.ListAlbumTracksToMatch(ctx, a.ID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("wanted: read %s's tracks: %w", a.Title, err)
|
||||
}
|
||||
m, err := s.matchAlbum(ctx, a, tracksToMatch(rows))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !m.OK {
|
||||
s.logger.Info("wanted: no MusicBrainz match for a library album", "album", a.Title, "artist", a.ArtistName, "why", m.Detail)
|
||||
return 0, q.RecordAlbumMatch(ctx, dbq.RecordAlbumMatchParams{AlbumID: a.ID, Outcome: "unmatched", Detail: m.Detail})
|
||||
}
|
||||
var listed int64
|
||||
err = pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
|
||||
tq := dbq.New(tx)
|
||||
if err := tq.SetAlbumReleaseGroup(ctx, dbq.SetAlbumReleaseGroupParams{ID: a.ID, ReleaseGroupMbid: &m.Group}); err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := tq.InsertLibraryWant(ctx, dbq.InsertLibraryWantParams{
|
||||
ReleaseGroupMbid: m.Group, ArtistMbid: m.ArtistMBID, ArtistName: m.ArtistName, Title: m.Title,
|
||||
PrimaryType: m.PrimaryType, FirstReleaseDate: m.FirstReleaseDate, AlbumID: a.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
listed = n
|
||||
return tq.RecordAlbumMatch(ctx, dbq.RecordAlbumMatchParams{
|
||||
AlbumID: a.ID, Outcome: "listed", Method: m.Method, ReleaseGroupMbid: m.Group,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("wanted: list album %s: %w", a.Title, err)
|
||||
}
|
||||
return int(listed), nil
|
||||
}
|
||||
|
||||
func (s *Service) finishLibrary(ctx context.Context, q *dbq.Queries, res *PassResult) error {
|
||||
done, err := q.FinishLibraryListing(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wanted: finish the library listing: %w", err)
|
||||
}
|
||||
s.logger.Info("wanted: the library's albums are on the list", "listed_this_pass", res.Listed,
|
||||
"unlisted", done.LibraryUnlisted)
|
||||
"unmatched", done.LibraryUnlisted)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
package wanted
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
|
||||
)
|
||||
|
||||
// Matching a library album to its MusicBrainz release group (#5569).
|
||||
//
|
||||
// Everything in the library came from a service that matched it against
|
||||
// MusicBrainz (operator, 2026-10-10: "they should all exist in music brainz
|
||||
// we need to use their meta data or a search system to link them up"), so
|
||||
// every album should have a group. The ids its tags carry are taken as
|
||||
// they are. Without them, the groups called what the album is called, from
|
||||
// the artist's discography and then from a search, are checked against the
|
||||
// album's own tracks, and the one its tracks agree with is taken.
|
||||
|
||||
// Match methods, as library_album_matches records them.
|
||||
const (
|
||||
matchTags = "tags"
|
||||
matchRelease = "release"
|
||||
matchDiscography = "discography"
|
||||
matchSearch = "search"
|
||||
)
|
||||
|
||||
// matchAgreement is the share of an album's tracks, in tenths, an edition
|
||||
// must agree with: most, not all, since a rip's track may carry a typo or a
|
||||
// hidden track's length.
|
||||
const matchAgreement = 8
|
||||
|
||||
// matchCandidates bounds how many groups one album is checked against: each
|
||||
// costs a MusicBrainz read the first time.
|
||||
const matchCandidates = 8
|
||||
|
||||
// matchDiscographyAge is how old a held discography may be and still be
|
||||
// read for matching: a name does not change, and a new release is not in
|
||||
// the library.
|
||||
const matchDiscographyAge = 90 * 24 * time.Hour
|
||||
|
||||
// errMusicBrainzAway: MusicBrainz did not answer, so the album is left for
|
||||
// the next pass rather than decided on half the evidence.
|
||||
var errMusicBrainzAway = errors.New("wanted: MusicBrainz did not answer")
|
||||
|
||||
// libraryTrack is what one of an album's tracks says about itself.
|
||||
type libraryTrack struct {
|
||||
Disc, Number int
|
||||
Title string
|
||||
LengthMs int
|
||||
Recording string
|
||||
}
|
||||
|
||||
// editionTrack is one track of a MusicBrainz edition.
|
||||
type editionTrack struct {
|
||||
Medium, Position int
|
||||
Title string
|
||||
LengthMs int
|
||||
Recording string
|
||||
}
|
||||
|
||||
// candidate is a release group an album may be.
|
||||
type candidate struct {
|
||||
Group string
|
||||
Title string
|
||||
PrimaryType string
|
||||
FirstReleaseDate string
|
||||
ArtistMBID string
|
||||
ArtistName string
|
||||
Method string
|
||||
}
|
||||
|
||||
// albumMatch is what matching decided: a group, or why there is none.
|
||||
type albumMatch struct {
|
||||
candidate
|
||||
OK bool
|
||||
Detail string
|
||||
}
|
||||
|
||||
func tracksToMatch(rows []dbq.ListAlbumTracksToMatchRow) []libraryTrack {
|
||||
out := make([]libraryTrack, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
t := libraryTrack{Title: r.Title, LengthMs: int(r.DurationMs), Recording: deref(r.Mbid)}
|
||||
if r.DiscNumber != nil {
|
||||
t.Disc = int(*r.DiscNumber)
|
||||
}
|
||||
if r.TrackNumber != nil {
|
||||
t.Number = int(*r.TrackNumber)
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// trackAgreement counts the album's tracks an edition agrees with: the same
|
||||
// recording, or a length that fits and a title that agrees. A track is
|
||||
// paired by its disc and number first, then by title, so an album missing
|
||||
// some tracks still pairs the ones it has.
|
||||
func trackAgreement(tracks []libraryTrack, edition []editionTrack) int {
|
||||
byPos := make(map[[2]int]int, len(edition))
|
||||
for i, e := range edition {
|
||||
byPos[[2]int{max(e.Medium, 1), e.Position}] = i
|
||||
}
|
||||
agree := func(t libraryTrack, e editionTrack) bool {
|
||||
if t.Recording != "" && strings.EqualFold(t.Recording, e.Recording) {
|
||||
return true
|
||||
}
|
||||
return release.LengthFits(t.LengthMs, e.LengthMs) && release.TitleAgrees(t.Title, e.Title)
|
||||
}
|
||||
used := make([]bool, len(edition))
|
||||
n := 0
|
||||
for _, t := range tracks {
|
||||
if t.Number > 0 {
|
||||
if i, ok := byPos[[2]int{max(t.Disc, 1), t.Number}]; ok && !used[i] && agree(t, edition[i]) {
|
||||
used[i] = true
|
||||
n++
|
||||
continue
|
||||
}
|
||||
}
|
||||
for i, e := range edition {
|
||||
if !used[i] && agree(t, e) {
|
||||
used[i] = true
|
||||
n++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// agrees: enough of the album's tracks agree for the edition to be it.
|
||||
func agrees(n, tracks int) bool {
|
||||
return tracks > 0 && n*10 >= tracks*matchAgreement
|
||||
}
|
||||
|
||||
// sameTitle: the same words, not merely one holding the other.
|
||||
func sameTitle(a, b string) bool {
|
||||
return slices.Equal(release.Tokens(a), release.Tokens(b))
|
||||
}
|
||||
|
||||
// matchAlbum finds the release group a library album is. A MusicBrainz
|
||||
// that does not answer is errMusicBrainzAway; anything else MusicBrainz
|
||||
// says is evidence, and an album with none is an albumMatch that is not OK.
|
||||
func (s *Service) matchAlbum(ctx context.Context, a dbq.ListAlbumsToMatchRow, tracks []libraryTrack) (albumMatch, error) {
|
||||
// The ids the tags carry name it.
|
||||
if group := deref(a.ReleaseGroupMbid); group != "" {
|
||||
if c, err := s.groupCandidate(ctx, group, matchTags, a); err != nil || c.Group != "" {
|
||||
return albumMatch{candidate: c, OK: err == nil}, err
|
||||
}
|
||||
}
|
||||
if rel := deref(a.Mbid); rel != "" {
|
||||
group, err := s.releaseGroup(ctx, rel)
|
||||
switch {
|
||||
case errors.Is(err, tags.ErrNotFound): // includes MusicBrainz switched off for tags
|
||||
case err != nil:
|
||||
return albumMatch{}, fmt.Errorf("%w: %v", errMusicBrainzAway, err)
|
||||
default:
|
||||
if c, err := s.groupCandidate(ctx, group, matchRelease, a); err != nil || c.Group != "" {
|
||||
return albumMatch{candidate: c, OK: err == nil}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise its name, checked against its tracks: the artist's own
|
||||
// discography first, then a search.
|
||||
tried := map[string]bool{}
|
||||
var best albumMatch
|
||||
bestN, bestEdition, considered := -1, 0, 0
|
||||
check := func(cands []candidate) error {
|
||||
for _, c := range cands {
|
||||
if tried[c.Group] || len(tried) >= matchCandidates {
|
||||
continue
|
||||
}
|
||||
tried[c.Group] = true
|
||||
n, size, err := s.agreementWith(ctx, c.Group, tracks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
considered++
|
||||
// More tracks agreeing wins; then the same title over a longer
|
||||
// one; then the edition nearest the album's length.
|
||||
st, bt := sameTitle(c.Title, a.Title), sameTitle(best.Title, a.Title)
|
||||
if n > bestN || (n == bestN && ((st && !bt) || (st == bt && abs(size-len(tracks)) < abs(bestEdition-len(tracks))))) {
|
||||
best, bestN, bestEdition = albumMatch{candidate: c}, n, size
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
discography, err := s.discographyCandidates(ctx, a)
|
||||
if err != nil {
|
||||
return albumMatch{}, err
|
||||
}
|
||||
if err := check(discography); err != nil {
|
||||
return albumMatch{}, err
|
||||
}
|
||||
if !agrees(bestN, len(tracks)) {
|
||||
found, err := s.searchCandidates(ctx, a)
|
||||
if err != nil {
|
||||
return albumMatch{}, err
|
||||
}
|
||||
if err := check(found); err != nil {
|
||||
return albumMatch{}, err
|
||||
}
|
||||
}
|
||||
if agrees(bestN, len(tracks)) {
|
||||
best.OK = true
|
||||
return best, nil
|
||||
}
|
||||
if considered == 0 {
|
||||
return albumMatch{Detail: fmt.Sprintf("MusicBrainz has no album called %q by %s.", a.Title, a.ArtistName)}, nil
|
||||
}
|
||||
return albumMatch{Detail: fmt.Sprintf(
|
||||
"MusicBrainz has %d %s called %q by %s, but none has its tracks: the closest, %q, agrees on %d of its %d.",
|
||||
considered, plural(considered, "album", "albums"), a.Title, a.ArtistName, best.Title, max(bestN, 0), len(tracks))}, nil
|
||||
}
|
||||
|
||||
// groupCandidate reads a group the tags name. A group MusicBrainz does not
|
||||
// have is a zero candidate, so the album is matched by name instead.
|
||||
func (s *Service) groupCandidate(ctx context.Context, group, method string, a dbq.ListAlbumsToMatchRow) (candidate, error) {
|
||||
g, err := s.cat.MusicBrainz().ReleaseGroup(ctx, group)
|
||||
switch {
|
||||
case errors.Is(err, musicbrainz.ErrNotFound):
|
||||
return candidate{}, nil
|
||||
case err != nil:
|
||||
return candidate{}, fmt.Errorf("%w: %v", errMusicBrainzAway, err)
|
||||
}
|
||||
artist := creditedArtist(g.ArtistMBIDs, deref(a.ArtistMbid))
|
||||
if artist == "" {
|
||||
return candidate{}, nil
|
||||
}
|
||||
return candidate{Group: g.MBID, Title: g.Title, PrimaryType: g.PrimaryType, FirstReleaseDate: g.FirstReleaseDate,
|
||||
ArtistMBID: artist, ArtistName: g.ArtistCredit, Method: method}, nil
|
||||
}
|
||||
|
||||
// discographyCandidates are the artist's release groups called what the
|
||||
// album is called.
|
||||
func (s *Service) discographyCandidates(ctx context.Context, a dbq.ListAlbumsToMatchRow) ([]candidate, error) {
|
||||
artist := deref(a.ArtistMbid)
|
||||
if !Followable(artist, a.ArtistName) {
|
||||
return nil, nil // no artist, or Various Artists: thousands of groups
|
||||
}
|
||||
d, err := s.cat.DiscographyWithin(ctx, artist, matchDiscographyAge)
|
||||
switch {
|
||||
case errors.Is(err, catalogue.ErrNotFound):
|
||||
return nil, nil
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("%w: %v", errMusicBrainzAway, err)
|
||||
}
|
||||
var out []candidate
|
||||
for _, g := range d.Groups {
|
||||
if release.TitleAgrees(g.Title, a.Title) {
|
||||
out = append(out, candidate{Group: g.Mbid, Title: g.Title, PrimaryType: g.PrimaryType,
|
||||
FirstReleaseDate: g.FirstReleaseDate, ArtistMBID: artist, ArtistName: d.Artist.Name, Method: matchDiscography})
|
||||
}
|
||||
}
|
||||
sortSameTitleFirst(out, a.Title)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// searchCandidates are the groups MusicBrainz's search finds by the album's
|
||||
// artist and title, whose title and artist both agree.
|
||||
func (s *Service) searchCandidates(ctx context.Context, a dbq.ListAlbumsToMatchRow) ([]candidate, error) {
|
||||
found, err := s.cat.MusicBrainz().SearchReleaseGroupsBy(ctx, a.ArtistName, a.Title, 10)
|
||||
switch {
|
||||
case errors.Is(err, musicbrainz.ErrNotFound):
|
||||
return nil, nil
|
||||
case errors.Is(err, musicbrainz.ErrBadResponse):
|
||||
// A query MusicBrainz will not read is not a reason to wait.
|
||||
s.logger.Warn("wanted: MusicBrainz refused a search", "album", a.Title, "artist", a.ArtistName, "err", err)
|
||||
return nil, nil
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("%w: %v", errMusicBrainzAway, err)
|
||||
}
|
||||
var out []candidate
|
||||
for _, g := range found {
|
||||
artist := creditedArtist(g.ArtistMBIDs, deref(a.ArtistMbid))
|
||||
if artist == "" || !release.TitleAgrees(g.Title, a.Title) {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(g.ArtistMBIDs, deref(a.ArtistMbid)) && !release.TitleAgrees(g.ArtistCredit, a.ArtistName) {
|
||||
continue
|
||||
}
|
||||
out = append(out, candidate{Group: g.MBID, Title: g.Title, PrimaryType: g.PrimaryType,
|
||||
FirstReleaseDate: g.FirstReleaseDate, ArtistMBID: artist, ArtistName: g.ArtistCredit, Method: matchSearch})
|
||||
}
|
||||
sortSameTitleFirst(out, a.Title)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// agreementWith is how many of the album's tracks the group's best edition
|
||||
// agrees with, and that edition's length. A group MusicBrainz no longer has
|
||||
// agrees with none.
|
||||
func (s *Service) agreementWith(ctx context.Context, group string, tracks []libraryTrack) (n, size int, err error) {
|
||||
eds, err := s.cat.Releases(ctx, group)
|
||||
switch {
|
||||
case errors.Is(err, catalogue.ErrNotFound):
|
||||
return -1, 0, nil
|
||||
case err != nil:
|
||||
return 0, 0, fmt.Errorf("%w: %v", errMusicBrainzAway, err)
|
||||
}
|
||||
n = -1
|
||||
for _, e := range eds.Editions {
|
||||
edition := make([]editionTrack, 0, len(e.Tracks))
|
||||
for _, t := range e.Tracks {
|
||||
edition = append(edition, editionTrack{Medium: int(t.MediumPosition), Position: int(t.Position),
|
||||
Title: t.Title, LengthMs: int(t.LengthMs), Recording: t.RecordingMbid})
|
||||
}
|
||||
if got := trackAgreement(tracks, edition); got > n || (got == n && abs(len(edition)-len(tracks)) < abs(size-len(tracks))) {
|
||||
n, size = got, len(edition)
|
||||
}
|
||||
}
|
||||
return n, size, nil
|
||||
}
|
||||
|
||||
// creditedArtist is the artist a want is filed under: the library's own
|
||||
// artist when MusicBrainz credits them, else the first credited.
|
||||
func creditedArtist(credited []string, own string) string {
|
||||
if own != "" && (len(credited) == 0 || slices.Contains(credited, own)) {
|
||||
return own
|
||||
}
|
||||
if len(credited) > 0 {
|
||||
return credited[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func sortSameTitleFirst(cs []candidate, title string) {
|
||||
slices.SortStableFunc(cs, func(x, y candidate) int {
|
||||
switch sx, sy := sameTitle(x.Title, title), sameTitle(y.Title, title); {
|
||||
case sx && !sy:
|
||||
return -1
|
||||
case sy && !sx:
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
func abs(n int) int {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func plural(n int, one, many string) string {
|
||||
if n == 1 {
|
||||
return one
|
||||
}
|
||||
return many
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -226,6 +227,13 @@ func newFakeMB(t *testing.T) *fakeMB {
|
||||
{"position": 3, "number": "3", "title": "Bonus", "recording": {"id": "rec-3"}}]}]}]}`)
|
||||
case r.URL.Path == "/release":
|
||||
_, _ = fmt.Fprint(w, `{"release-count": 0, "releases": []}`)
|
||||
case r.URL.Path == "/release-group" && strings.Contains(r.URL.Query().Get("query"), "releasegroup:"):
|
||||
// The library listing's search by artist and title (#5569).
|
||||
found := ""
|
||||
if strings.Contains(r.URL.Query().Get("query"), "Ballad") {
|
||||
found = group(theBallad, "The Ballad of Darren", "Album", "", "2023-07-21")
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, `{"release-groups": [%s]}`, found)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
@@ -455,135 +463,3 @@ func seedUser(t *testing.T, pool *pgxpool.Pool) pgtype.UUID {
|
||||
}
|
||||
return u.ID
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// The library's albums go on the list as held, once (#5567). An album the
|
||||
// tags name by release only is named through MusicBrainz first; one nothing
|
||||
// names is counted, not listed. 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-parklife" {
|
||||
return "rg-parklife", nil
|
||||
}
|
||||
return "", tags.ErrNotFound
|
||||
}
|
||||
blurAlbum := seedHeld(t, q, "Blur", "rel-blur", "rg-blur", 3)
|
||||
parklife := seedHeld(t, q, "Parklife", "rel-parklife", "", 2)
|
||||
_ = seedHeld(t, q, "Bootleg", "rel-bootleg", "", 1)
|
||||
// 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)
|
||||
}
|
||||
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, "rg-parklife": parklife.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 {
|
||||
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)
|
||||
}
|
||||
state, err := q.GetLibraryListing(ctx)
|
||||
if err != nil || !state.LibraryListedAt.Valid || state.LibraryUnlisted != 1 {
|
||||
t.Fatalf("listing = %+v %v: done, with the bootleg unlisted", 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// While MusicBrainz is switched off nothing can be named, so the listing
|
||||
// finishes with what it has rather than waiting for ever.
|
||||
func TestListLibrary_MusicBrainzOffFinishesWithWhatIsNamed_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, string) (string, error) { return "", tags.ErrSwitchedOff }
|
||||
_ = seedHeld(t, q, "Blur", "rel-blur", "rg-blur", 1)
|
||||
_ = seedHeld(t, q, "Parklife", "rel-parklife", "", 1)
|
||||
startListing(t, pool)
|
||||
if err := s.listLibrary(ctx, &PassResult{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, _ := q.GetLibraryListing(ctx)
|
||||
if !state.LibraryListedAt.Valid || state.LibraryUnlisted != 1 {
|
||||
t.Errorf("listing = %+v", state)
|
||||
}
|
||||
var named *string
|
||||
if err := pool.QueryRow(ctx, `SELECT release_group_mbid FROM albums WHERE title = 'Parklife'`).Scan(&named); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if named != nil {
|
||||
t.Errorf("an album was marked unnameable while MusicBrainz was off: %q", *named)
|
||||
}
|
||||
}
|
||||
|
||||
// A MusicBrainz that does not answer pauses the naming; the next pass goes on.
|
||||
func TestListLibrary_UnreachablePausesTheNaming_Integration(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
ctx := context.Background()
|
||||
q := dbq.New(pool)
|
||||
s := newService(t, pool, newFakeMB(t))
|
||||
down := true
|
||||
s.releaseGroup = func(context.Context, string) (string, error) {
|
||||
if down {
|
||||
return "", tags.ErrTransient
|
||||
}
|
||||
return "rg-parklife", nil
|
||||
}
|
||||
_ = seedHeld(t, q, "Parklife", "rel-parklife", "", 1)
|
||||
startListing(t, pool)
|
||||
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 named")
|
||||
}
|
||||
down = false
|
||||
if err := s.listLibrary(ctx, &PassResult{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := q.GetWantByReleaseGroup(ctx, "rg-parklife"); err != nil {
|
||||
t.Errorf("not listed once named: %v", err)
|
||||
}
|
||||
if state, _ := q.GetLibraryListing(ctx); !state.LibraryListedAt.Valid {
|
||||
t.Error("not finished")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user