diff --git a/backend/musicbrainz.go b/backend/musicbrainz.go index 2cca4af..7ba5009 100644 --- a/backend/musicbrainz.go +++ b/backend/musicbrainz.go @@ -6,6 +6,7 @@ import ( "net/http" "net/url" "strings" + "sync" "time" "golang.org/x/text/cases" @@ -14,7 +15,38 @@ import ( var AppVersion = "Unknown" -const musicBrainzAPIBase = "https://musicbrainz.org/ws/2" +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 +) + +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 +) type MusicBrainzRecordingResponse struct { Recordings []struct { @@ -54,66 +86,171 @@ type MusicBrainzRecordingResponse struct { } `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 ( hi@afkarxyz.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 == "" { - return meta, fmt.Errorf("no ISRC provided") + resultErr = fmt.Errorf("no ISRC provided") + return meta, resultErr } + cacheKey := musicBrainzCacheKey(isrc, useSingleGenre) + if cached, ok := musicBrainzCache.Load(cacheKey); ok { + return cached.(Metadata), nil + } + + 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: 10 * time.Second, + Timeout: musicBrainzRequestTimeout, } query := fmt.Sprintf("isrc:%s", isrc) - 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("GET", reqURL, nil) + mbResp, err := queryMusicBrainzRecordings(client, query) if err != nil { - return meta, err - } - - req.Header.Set("User-Agent", fmt.Sprintf("SpotiFLAC/%s ( hi@afkarxyz.qzz.io )", AppVersion)) - - var resp *http.Response - var lastErr error - - for i := 0; i < 3; i++ { - resp, lastErr = client.Do(req) - if lastErr == nil && resp.StatusCode == http.StatusOK { - break - } - - if resp != nil { - resp.Body.Close() - } - - if i < 2 { - time.Sleep(2 * time.Second) - } - } - - if lastErr != nil { - return meta, lastErr - } - - if resp.StatusCode != http.StatusOK { - resp.Body.Close() - return meta, fmt.Errorf("MusicBrainz API returned status: %d", resp.StatusCode) - } - defer resp.Body.Close() - - var mbResp MusicBrainzRecordingResponse - if err := json.NewDecoder(resp.Body).Decode(&mbResp); err != nil { - return meta, err + resultErr = err + return meta, resultErr } if len(mbResp.Recordings) == 0 { - return meta, fmt.Errorf("no recordings found for ISRC: %s", isrc) + resultErr = fmt.Errorf("no recordings found for ISRC: %s", isrc) + return meta, resultErr } recording := mbResp.Recordings[0] @@ -150,5 +287,12 @@ func FetchMusicBrainzMetadata(isrc, title, artist, album string, useSingleGenre } } + if meta.Genre == "" { + resultErr = fmt.Errorf("no genre tags found in MusicBrainz") + return meta, resultErr + } + + musicBrainzCache.Store(cacheKey, meta) + return meta, nil } diff --git a/frontend/src/lib/settings.ts b/frontend/src/lib/settings.ts index 21a4814..291e6ec 100644 --- a/frontend/src/lib/settings.ts +++ b/frontend/src/lib/settings.ts @@ -121,7 +121,7 @@ export const DEFAULT_SETTINGS: Settings = { createM3u8File: false, useFirstArtistOnly: false, useSingleGenre: false, - embedGenre: true, + embedGenre: false, redownloadWithSuffix: false, separator: "semicolon" }; @@ -343,7 +343,7 @@ export async function loadSettings(): Promise { parsed.useSingleGenre = false; } if (!('embedGenre' in parsed)) { - parsed.embedGenre = true; + parsed.embedGenre = false; } if (!('separator' in parsed)) { parsed.separator = "semicolon";