package backend import ( "encoding/json" "fmt" "net/http" "net/url" "strings" "sync" "time" "golang.org/x/text/cases" "golang.org/x/text/language" ) var AppVersion = "Unknown" const ( musicBrainzAPIBase = "https://musicbrainz.org/ws/2" musicBrainzRequestTimeout = 10 * time.Second musicBrainzRequestRetries = 3 musicBrainzRequestRetryWait = 3 * time.Second musicBrainzMinRequestInterval = 1100 * time.Millisecond musicBrainzThrottleCooldownOn503 = 5 * time.Second musicBrainzStatusCheckSkipWindow = 5 * time.Minute ) type musicBrainzStatusError struct { StatusCode int } func (e *musicBrainzStatusError) Error() string { return fmt.Sprintf("MusicBrainz API returned status: %d", e.StatusCode) } type musicBrainzInflightCall struct { done chan struct{} result Metadata err error } var ( musicBrainzCache sync.Map musicBrainzInflightMu sync.Mutex musicBrainzInflight = make(map[string]*musicBrainzInflightCall) musicBrainzThrottleMu sync.Mutex musicBrainzNextRequest time.Time musicBrainzBlockedTill time.Time musicBrainzStatusMu sync.RWMutex musicBrainzLastCheckedAt time.Time musicBrainzLastCheckedOnline bool ) func SetMusicBrainzStatusCheckResult(online bool) { musicBrainzStatusMu.Lock() defer musicBrainzStatusMu.Unlock() musicBrainzLastCheckedAt = time.Now() musicBrainzLastCheckedOnline = online } func ShouldSkipMusicBrainzMetadataFetch() bool { musicBrainzStatusMu.RLock() defer musicBrainzStatusMu.RUnlock() if musicBrainzLastCheckedAt.IsZero() { return false } if musicBrainzLastCheckedOnline { return false } return time.Since(musicBrainzLastCheckedAt) <= musicBrainzStatusCheckSkipWindow } type MusicBrainzRecordingResponse struct { Recordings []struct { ID string `json:"id"` Title string `json:"title"` Length int `json:"length"` Releases []struct { ID string `json:"id"` Title string `json:"title"` Status string `json:"status"` ReleaseGroup struct { ID string `json:"id"` Title string `json:"title"` PrimaryType string `json:"primary-type"` } `json:"release-group"` Date string `json:"date"` Country string `json:"country"` Media []struct { Format string `json:"format"` } `json:"media"` LabelInfo []struct { Label struct { Name string `json:"name"` } `json:"label"` } `json:"label-info"` } `json:"releases"` ArtistCredit []struct { Artist struct { ID string `json:"id"` Name string `json:"name"` } `json:"artist"` } `json:"artist-credit"` Tags []struct { Count int `json:"count"` Name string `json:"name"` } `json:"tags"` } `json:"recordings"` } func musicBrainzCacheKey(isrc string, useSingleGenre bool) string { separator := strings.TrimSpace(GetSeparator()) if separator == "" { separator = ";" } return strings.ToUpper(strings.TrimSpace(isrc)) + "|" + fmt.Sprintf("%t", useSingleGenre) + "|" + separator } func waitForMusicBrainzRequestSlot() { musicBrainzThrottleMu.Lock() readyAt := musicBrainzNextRequest if musicBrainzBlockedTill.After(readyAt) { readyAt = musicBrainzBlockedTill } now := time.Now() if readyAt.Before(now) { readyAt = now } musicBrainzNextRequest = readyAt.Add(musicBrainzMinRequestInterval) waitDuration := time.Until(readyAt) musicBrainzThrottleMu.Unlock() if waitDuration > 0 { time.Sleep(waitDuration) } } func noteMusicBrainzThrottle() { musicBrainzThrottleMu.Lock() defer musicBrainzThrottleMu.Unlock() cooldownUntil := time.Now().Add(musicBrainzThrottleCooldownOn503) if cooldownUntil.After(musicBrainzBlockedTill) { musicBrainzBlockedTill = cooldownUntil } if musicBrainzNextRequest.Before(musicBrainzBlockedTill) { musicBrainzNextRequest = musicBrainzBlockedTill } } func shouldRetryMusicBrainzRequest(err error) bool { if err == nil { return false } statusErr, ok := err.(*musicBrainzStatusError) if !ok { return true } return statusErr.StatusCode == http.StatusServiceUnavailable || statusErr.StatusCode >= http.StatusInternalServerError } func queryMusicBrainzRecordings(client *http.Client, query string) (*MusicBrainzRecordingResponse, error) { reqURL := fmt.Sprintf("%s/recording?query=%s&fmt=json&inc=releases+artist-credits+tags+media+release-groups+labels", musicBrainzAPIBase, url.QueryEscape(query)) req, err := http.NewRequest(http.MethodGet, reqURL, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", fmt.Sprintf("SpotiFLAC/%s ( support@spotbye.qzz.io )", AppVersion)) req.Header.Set("Accept", "application/json") var lastErr error for attempt := 0; attempt < musicBrainzRequestRetries; attempt++ { waitForMusicBrainzRequestSlot() resp, err := client.Do(req) if err == nil && resp != nil && resp.StatusCode == http.StatusOK { defer resp.Body.Close() var mbResp MusicBrainzRecordingResponse if decodeErr := json.NewDecoder(resp.Body).Decode(&mbResp); decodeErr != nil { return nil, decodeErr } return &mbResp, nil } if err != nil { lastErr = err } else if resp == nil { lastErr = fmt.Errorf("empty response from MusicBrainz") } else { if resp.StatusCode == http.StatusServiceUnavailable { noteMusicBrainzThrottle() } lastErr = &musicBrainzStatusError{StatusCode: resp.StatusCode} resp.Body.Close() } if attempt < musicBrainzRequestRetries-1 && shouldRetryMusicBrainzRequest(lastErr) { time.Sleep(musicBrainzRequestRetryWait) continue } break } if lastErr == nil { lastErr = fmt.Errorf("empty response from MusicBrainz") } return nil, lastErr } func FetchMusicBrainzMetadata(isrc, title, artist, album string, useSingleGenre bool, embedGenre bool) (Metadata, error) { var meta Metadata var resultErr error if !embedGenre { return meta, nil } if isrc == "" { resultErr = fmt.Errorf("no ISRC provided") return meta, resultErr } cacheKey := musicBrainzCacheKey(isrc, useSingleGenre) if cached, ok := musicBrainzCache.Load(cacheKey); ok { return cached.(Metadata), nil } if ShouldSkipMusicBrainzMetadataFetch() { resultErr = fmt.Errorf("skipping MusicBrainz lookup because the latest status check reported offline") return meta, resultErr } musicBrainzInflightMu.Lock() if call, ok := musicBrainzInflight[cacheKey]; ok { musicBrainzInflightMu.Unlock() <-call.done return call.result, call.err } call := &musicBrainzInflightCall{done: make(chan struct{})} musicBrainzInflight[cacheKey] = call musicBrainzInflightMu.Unlock() defer func() { call.result = meta call.err = resultErr musicBrainzInflightMu.Lock() delete(musicBrainzInflight, cacheKey) close(call.done) musicBrainzInflightMu.Unlock() }() client := &http.Client{ Timeout: musicBrainzRequestTimeout, } query := fmt.Sprintf("isrc:%s", isrc) mbResp, err := queryMusicBrainzRecordings(client, query) if err != nil { resultErr = err return meta, resultErr } if len(mbResp.Recordings) == 0 { resultErr = fmt.Errorf("no recordings found for ISRC: %s", isrc) return meta, resultErr } recording := mbResp.Recordings[0] var genres []string caser := cases.Title(language.English) if useSingleGenre { maxCount := -1 var bestTag string for _, tag := range recording.Tags { if tag.Count > maxCount { maxCount = tag.Count bestTag = tag.Name } } if bestTag != "" { meta.Genre = caser.String(bestTag) } } else { for _, tag := range recording.Tags { genres = append(genres, caser.String(tag.Name)) } if len(genres) > 0 { if len(genres) > 5 { genres = genres[:5] } meta.Genre = strings.Join(genres, GetSeparator()) } } if meta.Genre == "" { resultErr = fmt.Errorf("no genre tags found in MusicBrainz") return meta, resultErr } musicBrainzCache.Store(cacheKey, meta) return meta, nil }