Files
NaviWatcher/internal/musicbrainz/api.go
Vladimir Zagainov e2de91c5d3 fix: address code review findings
- Fix pagination to not trust MusicBrainz count for cutoff (prevents
  silent discography truncation / hidden missing releases)
- Add idx_external_releases_artist_id index for hot per-artist queries
- Dedupe cached_at layout constant in external_releases.go
2026-07-19 21:13:24 +03:00

139 lines
4.8 KiB
Go

package musicbrainz
import (
"context"
"fmt"
"net/url"
"naviwatcher/internal/database"
)
// includedTypes contains release-group primary types that should be included
// when no more specific type classification applies.
var includedTypes = map[string]bool{
"Album": true,
"Single": true,
"EP": true,
}
// GetArtistReleaseGroups fetches all release groups for a given artist from MusicBrainz.
// It queries the artist's release groups via the MusicBrainz Web Service API,
// parses the XML response, and applies status and type filtering.
//
// The method handles pagination automatically by following offset parameters
// until all release groups are fetched.
func (c *MusicBrainzClient) GetArtistReleaseGroups(ctx context.Context, artistMBID string) ([]ReleaseGroup, error) {
var allGroups []ReleaseGroup
offset := 0
limit := 100 // MusicBrainz max limit per request
for {
params := url.Values{}
params.Set("artist", artistMBID)
params.Set("limit", fmt.Sprintf("%d", limit))
params.Set("offset", fmt.Sprintf("%d", offset))
path := "/release-group?" + params.Encode()
body, err := c.doGet(ctx, path)
if err != nil {
return nil, fmt.Errorf("fetch release groups for artist %s: %w", artistMBID, err)
}
parsed, err := ParseReleaseGroups(body)
if err != nil {
return nil, fmt.Errorf("parse release groups for artist %s: %w", artistMBID, err)
}
allGroups = append(allGroups, parsed.ReleaseGroups...)
// Stop when a page is empty (no more results) or when the page
// returned fewer items than the request limit — a reliable end-of-data
// signal. We intentionally do NOT trust parsed.Count for the cutoff:
// MusicBrainz occasionally reports an inaccurate count, which would
// prematurely truncate an artist's discography and hide missing
// releases. The empty-page check also prevents an infinite loop if the
// API keeps returning a non-empty page past the reported count.
if len(parsed.ReleaseGroups) == 0 || len(parsed.ReleaseGroups) < limit {
break
}
// Check context cancellation between pages for responsive shutdown.
if err := ctx.Err(); err != nil {
return nil, fmt.Errorf("fetch release groups for artist %s: %w", artistMBID, err)
}
offset += limit
}
return allGroups, nil
}
// FilterOptions holds per-artist type filtering preferences.
type FilterOptions struct {
IgnoreSingles bool
IgnoreCompilations bool
}
// FilterReleaseGroups applies type filtering to a list of release groups.
// It includes only Album/Single/EP primary types, or release groups whose
// secondary type list contains Single/EP/Compilation (e.g. an "Album" that is
// also a "Compilation"). The IgnoreSingles / IgnoreCompilations toggles drop
// release groups classified as such via either primary or secondary type.
//
// Release groups carry no status in ws/2, so there is no status filtering.
func FilterReleaseGroups(groups []ReleaseGroup, opts FilterOptions) []ReleaseGroup {
var filtered []ReleaseGroup
for _, rg := range groups {
if !IsTypeIncluded(rg.Type) && !hasSliceType(rg.SecondaryTypes, "Single", "EP", "Compilation") {
continue
}
if opts.IgnoreSingles && (rg.Type == "Single" || hasSliceType(rg.SecondaryTypes, "Single")) {
continue
}
if opts.IgnoreCompilations && (rg.Type == "Compilation" || hasSliceType(rg.SecondaryTypes, "Compilation")) {
continue
}
filtered = append(filtered, rg)
}
return filtered
}
// hasSliceType reports whether the slice contains any of the wanted values.
func hasSliceType(types []string, wanted ...string) bool {
for _, s := range types {
for _, w := range wanted {
if s == w {
return true
}
}
}
return false
}
// IsTypeIncluded returns true if the given primary type is in the base
// included set (Album/Single/EP).
func IsTypeIncluded(releaseType string) bool {
return includedTypes[releaseType]
}
// ToExternalRelease converts a ReleaseGroup to an ExternalRelease for database
// persistence. artistID is the canonical artist key from artist_settings (the
// Navidrome artist ID), which is what external_releases.artist_id references and
// what the scanner joins on. The MusicBrainz release-group's own ArtistID (an
// MBID) must NOT be stored here, because artist_settings is keyed by the
// Navidrome ID and the foreign key / join would otherwise never match.
func (rg *ReleaseGroup) ToExternalRelease(artistID string) *database.ExternalRelease {
// The primary type and the secondary types are both persisted so that the
// cache-hit path in SyncArtistDiscography can re-apply the same
// IgnoreSingles / IgnoreCompilations rules (which consider secondary types)
// as the cache-miss path, keeping results stable across cache refreshes.
return &database.ExternalRelease{
RGID: rg.ID,
ArtistID: artistID,
Title: rg.Title,
Type: rg.Type,
ReleaseDate: rg.ReleaseDate,
SecondaryTypes: rg.SecondaryTypes,
}
}