package wanted import ( "context" "errors" "fmt" "regexp" "strings" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz" "git.fabledsword.com/bvandeusen/minstrel/internal/tags" ) // Matching a library album by hand (#5579): the Collection's Unmatched // section. A person names the MusicBrainz // album an album is, by a link or a search, and it goes on the list as held; // or leaves it off for good, as a local band's album MusicBrainz will never // have. const matchManual = "manual" // Manual-match errors, which the API answers as 400, 404 or 409. var ( ErrMatchRef = errors.New("wanted: not a MusicBrainz album link or id") ErrMatchNoGroup = errors.New("wanted: MusicBrainz has no such album") ErrAlbumNotUnmatched = errors.New("wanted: no such unmatched album") ) // ManualMatch is what matching an album by hand did: the group it is now, // and how many of its tracks agree with the group's closest edition, so the // person can see whether the link was the right one. type ManualMatch struct { Group string Title string Artist string Agree int Tracks int } var mbidPattern = regexp.MustCompile(`(?i)(?:(release-group|release)/)?([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})`) // ParseMusicBrainzRef reads a MusicBrainz link (a release group's or a // release's page) or a bare id. Kind is "release-group", "release", or "" // for a bare id, which is tried as a group first. func ParseMusicBrainzRef(ref string) (kind, id string, ok bool) { m := mbidPattern.FindStringSubmatch(strings.TrimSpace(ref)) if m == nil { return "", "", false } return strings.ToLower(m[1]), strings.ToLower(m[2]), true } // MatchAlbumTo puts an unmatched (or left-off) library album on the list as // held, as the MusicBrainz album ref names. The person's word is taken: the // tracks are compared only to say how well they agree. func (s *Service) MatchAlbumTo(ctx context.Context, albumID pgtype.UUID, ref string) (ManualMatch, error) { kind, id, ok := ParseMusicBrainzRef(ref) if !ok { return ManualMatch{}, ErrMatchRef } q := dbq.New(s.pool) a, err := q.GetAlbumToMatch(ctx, albumID) if errors.Is(err, pgx.ErrNoRows) { return ManualMatch{}, ErrAlbumNotUnmatched } if err != nil { return ManualMatch{}, fmt.Errorf("wanted: read album: %w", err) } group, err := s.groupOf(ctx, kind, id) if err != nil { return ManualMatch{}, err } g, err := s.cat.MusicBrainz().ReleaseGroup(ctx, group) if errors.Is(err, musicbrainz.ErrNotFound) { return ManualMatch{}, ErrMatchNoGroup } if err != nil { return ManualMatch{}, err } artist := creditedArtist(g.ArtistMBIDs, deref(a.ArtistMbid)) if artist == "" { return ManualMatch{}, fmt.Errorf("%w: it credits no artist", ErrMatchNoGroup) } rows, err := q.ListAlbumTracksToMatch(ctx, a.ID) if err != nil { return ManualMatch{}, fmt.Errorf("wanted: read %s's tracks: %w", a.Title, err) } tracks := tracksToMatch(rows) n, _, _, err := s.agreementWith(ctx, group, tracks, func(string) bool { return true }) if err != nil { return ManualMatch{}, err } 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: &group}); err != nil { return err } if _, err := tq.InsertLibraryWant(ctx, dbq.InsertLibraryWantParams{ ReleaseGroupMbid: group, ArtistMbid: artist, ArtistName: g.ArtistCredit, Title: g.Title, PrimaryType: g.PrimaryType, FirstReleaseDate: g.FirstReleaseDate, AlbumID: a.ID, }); err != nil { return err } if err := tq.RecordAlbumMatch(ctx, dbq.RecordAlbumMatchParams{ AlbumID: a.ID, Outcome: "listed", Method: matchManual, ReleaseGroupMbid: group, Matcher: libraryMatcher, }); err != nil { return err } return tq.RecountLibraryUnlisted(ctx) }) if err != nil { return ManualMatch{}, fmt.Errorf("wanted: match %s by hand: %w", a.Title, err) } s.logger.Info("wanted: a library album matched by hand", "album", a.Title, "group", group, "agree", max(n, 0), "tracks", len(tracks)) return ManualMatch{Group: group, Title: g.Title, Artist: g.ArtistCredit, Agree: max(n, 0), Tracks: len(tracks)}, nil } // groupOf names the release group a MusicBrainz id is, or belongs to. func (s *Service) groupOf(ctx context.Context, kind, id string) (string, error) { if kind != "release" { _, err := s.cat.MusicBrainz().ReleaseGroup(ctx, id) if err == nil { return id, nil } if !errors.Is(err, musicbrainz.ErrNotFound) { return "", err } if kind == "release-group" { return "", ErrMatchNoGroup } } group, err := s.releaseGroup(ctx, id) switch { case errors.Is(err, tags.ErrNotFound): return "", ErrMatchNoGroup case err != nil: return "", err case group == "": return "", ErrMatchNoGroup } return group, nil } // LeaveAlbumOff leaves an unmatched library album off the list for good: // matching never tries it again, and it is no longer counted as unmatched. func (s *Service) LeaveAlbumOff(ctx context.Context, albumID pgtype.UUID) error { return s.setMatchOutcome(ctx, albumID, "unmatched", "dismissed") } // PutAlbumBack undoes LeaveAlbumOff: the album is unmatched again. func (s *Service) PutAlbumBack(ctx context.Context, albumID pgtype.UUID) error { return s.setMatchOutcome(ctx, albumID, "dismissed", "unmatched") } func (s *Service) setMatchOutcome(ctx context.Context, albumID pgtype.UUID, was, outcome string) error { return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { tq := dbq.New(tx) n, err := tq.SetAlbumMatchOutcome(ctx, dbq.SetAlbumMatchOutcomeParams{AlbumID: albumID, Was: was, Outcome: outcome}) if err != nil { return err } if n == 0 { return ErrAlbumNotUnmatched } return tq.RecountLibraryUnlisted(ctx) }) }