release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m17s
release / go (push) Successful in 1m34s
release / integration (push) Failing after 5m34s
release / android (push) Successful in 5m36s
release / Build signed APK (releases and dev) (push) Successful in 5m44s
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
Like Lidarr's Unmapped Files. A local band's album, which MusicBrainz will never have, is left off: never matched again and not counted. A re-tagged album is matched again on the next listing. The Collection's verdict now says when library albums need matching. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
172 lines
5.8 KiB
Go
172 lines
5.8 KiB
Go
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, like Lidarr's "Unmapped Files". 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)
|
|
})
|
|
}
|