347 lines
9.1 KiB
Go
347 lines
9.1 KiB
Go
package autotag
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"strings"
|
|
)
|
|
|
|
// MBReleaseGroupHit is the minimal projection of a MusicBrainz
|
|
// release-group search result that the scorer consumes. Defined
|
|
// here (rather than imported from explore) to keep the autotag
|
|
// package's external surface small and swappable.
|
|
type MBReleaseGroupHit struct {
|
|
MBID string
|
|
Title string
|
|
ArtistCredit string
|
|
FirstDate string
|
|
PrimaryType string
|
|
}
|
|
|
|
// MBRelease is the scorer's projection of a MusicBrainz release
|
|
// (one specific edition with its track list).
|
|
type MBRelease struct {
|
|
MBID string
|
|
Title string
|
|
Date string
|
|
Country string
|
|
Status string
|
|
ArtistCredit string
|
|
Tracks []CandidateTrack
|
|
}
|
|
|
|
// MBClient is the subset of the explore.MusicBrainzClient surface
|
|
// the autotagger depends on. Implementations must be cache-first
|
|
// — repeated calls with the same inputs must not repeat network
|
|
// round-trips.
|
|
type MBClient interface {
|
|
SearchReleaseGroups(
|
|
ctx context.Context,
|
|
query string,
|
|
limit int,
|
|
) ([]MBReleaseGroupHit, int, error)
|
|
BrowseReleases(ctx context.Context, releaseGroupMBID string) ([]MBRelease, error)
|
|
LookupRelease(ctx context.Context, releaseMBID string) (MBRelease, error)
|
|
LookupReleaseGroup(ctx context.Context, releaseGroupMBID string) (MBReleaseGroupHit, error)
|
|
LookupArtist(ctx context.Context, mbid string) (string, error) // returns sort name or name
|
|
}
|
|
|
|
// MBResolver orchestrates MusicBrainz lookups for a tagging group.
|
|
// Strategy: normalize the user-provided album/artist first, then
|
|
// issue a cascade of progressively looser Lucene queries, stopping
|
|
// at the first one that yields enough candidates.
|
|
type MBResolver struct {
|
|
client MBClient
|
|
logger *slog.Logger
|
|
limit int
|
|
}
|
|
|
|
// NewMBResolver wires up the resolver with a default search limit.
|
|
func NewMBResolver(client MBClient, logger *slog.Logger) *MBResolver {
|
|
const defaultLimit = 5
|
|
|
|
return &MBResolver{client: client, logger: logger, limit: defaultLimit}
|
|
}
|
|
|
|
// mbQueryStep describes one cascade level. `label` is surfaced in
|
|
// candidate provenance (so the UI can show "via fuzzy title").
|
|
type mbQueryStep struct {
|
|
label string
|
|
query string
|
|
}
|
|
|
|
// ResolveMB returns MB-sourced candidates for a tagging group.
|
|
// Runs a cascade of Lucene queries, returning at the first step
|
|
// that produces results. Each search hit fans out to
|
|
// BrowseReleases (one per release-group) for track-level data.
|
|
func (r *MBResolver) ResolveMB(
|
|
ctx context.Context,
|
|
albumName, albumArtist string,
|
|
trackCount int,
|
|
knownArtistMBID string,
|
|
) ([]Candidate, error) {
|
|
if albumName == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
nAlbum := Normalize(albumName)
|
|
nArtist := Normalize(albumArtist)
|
|
|
|
if nAlbum == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
steps := buildMBQueryCascade(nAlbum, nArtist, trackCount, knownArtistMBID)
|
|
|
|
for _, step := range steps {
|
|
hits, _, err := r.client.SearchReleaseGroups(ctx, step.query, r.limit)
|
|
if err != nil {
|
|
r.logger.Warn(
|
|
"MB search step failed — trying next",
|
|
"step", step.label, "query", step.query, "err", err,
|
|
)
|
|
|
|
continue
|
|
}
|
|
|
|
if len(hits) == 0 {
|
|
r.logger.Debug(
|
|
"MB search step empty — trying next",
|
|
"step", step.label, "query", step.query,
|
|
)
|
|
|
|
continue
|
|
}
|
|
|
|
r.logger.Info(
|
|
"MB search step succeeded",
|
|
"step", step.label, "hits", len(hits),
|
|
)
|
|
|
|
return r.fanOutBrowse(ctx, hits, step.label), nil
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// fanOutBrowse iterates search hits, fetches each release-group's
|
|
// releases, and returns them as Candidates. Errors on individual
|
|
// browses are logged and skipped.
|
|
func (r *MBResolver) fanOutBrowse(
|
|
ctx context.Context, hits []MBReleaseGroupHit, step string,
|
|
) []Candidate {
|
|
var out []Candidate
|
|
|
|
for _, h := range hits {
|
|
releases, err := r.client.BrowseReleases(ctx, h.MBID)
|
|
if err != nil {
|
|
r.logger.Warn(
|
|
"browse releases failed — skipping release group",
|
|
"release_group_mbid", h.MBID, "err", err,
|
|
)
|
|
|
|
continue
|
|
}
|
|
|
|
for _, rel := range releases {
|
|
out = append(out, mkCandidate(h, rel, step))
|
|
}
|
|
}
|
|
|
|
return out
|
|
}
|
|
|
|
// ResolveOneReleaseMBID fetches a single release by MBID and
|
|
// returns it as a fully-populated Candidate. Used by the paste-
|
|
// URL escape hatch. Falls back to LookupReleaseGroup when the
|
|
// MBID resolves to a release group instead.
|
|
func (r *MBResolver) ResolveOneReleaseMBID(
|
|
ctx context.Context, mbid string,
|
|
) (Candidate, error) {
|
|
rel, err := r.client.LookupRelease(ctx, mbid)
|
|
if err == nil && rel.MBID != "" {
|
|
return Candidate{
|
|
ReleaseMBID: rel.MBID,
|
|
ReleaseGroupMBID: "",
|
|
Title: rel.Title,
|
|
ArtistCredit: rel.ArtistCredit,
|
|
Date: rel.Date,
|
|
Country: rel.Country,
|
|
Status: rel.Status,
|
|
TrackCount: len(rel.Tracks),
|
|
Tracks: rel.Tracks,
|
|
Source: SourceMusicBrainz,
|
|
Provenance: "paste",
|
|
}, nil
|
|
}
|
|
|
|
rgHit, rgErr := r.client.LookupReleaseGroup(ctx, mbid)
|
|
if rgErr != nil {
|
|
return Candidate{}, fmt.Errorf("lookup release or RG: %w / %w", err, rgErr)
|
|
}
|
|
|
|
releases, bErr := r.client.BrowseReleases(ctx, rgHit.MBID)
|
|
if bErr != nil {
|
|
return Candidate{}, fmt.Errorf("browse RG %s: %w", rgHit.MBID, bErr)
|
|
}
|
|
|
|
if len(releases) == 0 {
|
|
return Candidate{}, fmt.Errorf("%w: %s", errEmptyBrowseResult, mbid)
|
|
}
|
|
|
|
return mkCandidate(rgHit, releases[0], "paste"), nil
|
|
}
|
|
|
|
// mkCandidate combines a search hit with one of its releases into
|
|
// a Candidate ready for scoring. Date is the release-specific date
|
|
// (re-issue year for remasters), OriginalDate is the release-group's
|
|
// first-release-date (the album's original year).
|
|
func mkCandidate(h MBReleaseGroupHit, rel MBRelease, step string) Candidate {
|
|
return Candidate{
|
|
ReleaseMBID: rel.MBID,
|
|
ReleaseGroupMBID: h.MBID,
|
|
Title: firstNonEmpty(rel.Title, h.Title),
|
|
ArtistCredit: firstNonEmpty(rel.ArtistCredit, h.ArtistCredit),
|
|
Date: firstNonEmpty(rel.Date, h.FirstDate),
|
|
OriginalDate: h.FirstDate,
|
|
Country: rel.Country,
|
|
Status: rel.Status,
|
|
TrackCount: len(rel.Tracks),
|
|
Tracks: rel.Tracks,
|
|
Source: SourceMusicBrainz,
|
|
Provenance: step,
|
|
}
|
|
}
|
|
|
|
// buildMBQueryCascade returns the Lucene queries to try in order.
|
|
// Cascade:
|
|
//
|
|
// 1. Full: release + arid/artist + tracks:N
|
|
// 2. Drop tracks:N (bonus tracks, live editions, etc.)
|
|
// 3. Drop artist entirely (wrong artist tag is common)
|
|
// 4. Fuzzy title (unquoted; Lucene does token/prefix match)
|
|
//
|
|
// Each step is only added when it would differ from the previous.
|
|
func buildMBQueryCascade(
|
|
normAlbum, normArtist string,
|
|
trackCount int,
|
|
artistMBID string,
|
|
) []mbQueryStep {
|
|
var steps []mbQueryStep
|
|
|
|
release := "release:" + luceneQuote(normAlbum)
|
|
artistClause := ""
|
|
|
|
switch {
|
|
case artistMBID != "":
|
|
artistClause = "arid:" + artistMBID
|
|
case normArtist != "":
|
|
artistClause = "artist:" + luceneQuote(normArtist)
|
|
}
|
|
|
|
tracksClause := ""
|
|
if trackCount > 0 {
|
|
tracksClause = fmt.Sprintf("tracks:%d", trackCount)
|
|
}
|
|
|
|
// Step 1: full query (only include parts we actually have).
|
|
steps = append(steps, mbQueryStep{
|
|
label: "strict",
|
|
query: joinNonEmpty(release, artistClause, tracksClause),
|
|
})
|
|
|
|
// Step 2: drop tracks:N if we had one.
|
|
if tracksClause != "" {
|
|
steps = append(steps, mbQueryStep{
|
|
label: "no-track-count",
|
|
query: joinNonEmpty(release, artistClause),
|
|
})
|
|
}
|
|
|
|
// Step 3: drop the artist clause.
|
|
if artistClause != "" {
|
|
steps = append(steps, mbQueryStep{
|
|
label: "title-only",
|
|
query: release,
|
|
})
|
|
}
|
|
|
|
// Step 4: fuzzy / unquoted title. MB's Lucene tokenizer will
|
|
// do prefix + fuzzy matching on bare tokens.
|
|
fuzzy := "release:" + luceneTokens(normAlbum)
|
|
if fuzzy != release {
|
|
steps = append(steps, mbQueryStep{
|
|
label: "fuzzy-title",
|
|
query: fuzzy,
|
|
})
|
|
}
|
|
|
|
return steps
|
|
}
|
|
|
|
// luceneQuote wraps a phrase in quotes and escapes embedded quotes
|
|
// and backslashes. Used for exact-phrase clauses.
|
|
func luceneQuote(s string) string {
|
|
s = strings.ReplaceAll(s, `\`, `\\`)
|
|
s = strings.ReplaceAll(s, `"`, `\"`)
|
|
|
|
return `"` + s + `"`
|
|
}
|
|
|
|
// luceneTokens returns the phrase as space-separated tokens with
|
|
// Lucene reserved characters escaped. MB's analyzer applies
|
|
// tokenization + fuzzy matching across bare tokens, so this is
|
|
// the right shape for our loosest cascade level.
|
|
func luceneTokens(s string) string {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return s
|
|
}
|
|
|
|
// Escape Lucene-reserved glyphs that aren't stripped by our
|
|
// Normalize() (paren, bracket, colon, etc. Normalize already
|
|
// drops most of these, but belt + braces).
|
|
reserved := `+-&|!(){}[]^"~*?:\/`
|
|
|
|
var b strings.Builder
|
|
|
|
b.Grow(len(s))
|
|
|
|
for _, r := range s {
|
|
if strings.ContainsRune(reserved, r) {
|
|
b.WriteRune('\\')
|
|
}
|
|
|
|
b.WriteRune(r)
|
|
}
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// joinNonEmpty joins non-empty parts with " AND ".
|
|
func joinNonEmpty(parts ...string) string {
|
|
kept := make([]string, 0, len(parts))
|
|
|
|
for _, p := range parts {
|
|
if p != "" {
|
|
kept = append(kept, p)
|
|
}
|
|
}
|
|
|
|
return strings.Join(kept, " AND ")
|
|
}
|
|
|
|
func firstNonEmpty(a, b string) string {
|
|
if a != "" {
|
|
return a
|
|
}
|
|
|
|
return b
|
|
}
|
|
|
|
// errEmptyBrowseResult signals that LookupReleaseGroup succeeded
|
|
// but BrowseReleases returned nothing — unusual, but not fatal.
|
|
var errEmptyBrowseResult = errors.New("autotag: release group has no releases")
|