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")