mirror of
https://github.com/Dvorinka/SpotifyRecAlg.git
synced 2026-06-03 20:13:03 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
package musicbrainz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultBaseURL = "https://musicbrainz.org/ws/2"
|
||||
defaultTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AppName string
|
||||
Contact string
|
||||
Version string
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
Timeout time.Duration
|
||||
MinDelay time.Duration
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
appName string
|
||||
contact string
|
||||
version string
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
minDelay time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
lastCall time.Time
|
||||
lastError string
|
||||
}
|
||||
|
||||
type Recording struct {
|
||||
ID string
|
||||
Title string
|
||||
Artist string
|
||||
ArtistID string
|
||||
ISRC string
|
||||
Genres []string
|
||||
Tags []string
|
||||
}
|
||||
|
||||
func New(cfg Config) *Client {
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
httpClient := cfg.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: timeout}
|
||||
}
|
||||
baseURL := strings.TrimRight(cfg.BaseURL, "/")
|
||||
if baseURL == "" {
|
||||
baseURL = defaultBaseURL
|
||||
}
|
||||
minDelay := cfg.MinDelay
|
||||
if minDelay <= 0 {
|
||||
minDelay = time.Second
|
||||
}
|
||||
version := strings.TrimSpace(cfg.Version)
|
||||
if version == "" {
|
||||
version = "0.1.0"
|
||||
}
|
||||
return &Client{
|
||||
appName: strings.TrimSpace(cfg.AppName),
|
||||
contact: strings.TrimSpace(cfg.Contact),
|
||||
version: version,
|
||||
baseURL: baseURL,
|
||||
httpClient: httpClient,
|
||||
minDelay: minDelay,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Configured() bool {
|
||||
return c.appName != "" && c.contact != ""
|
||||
}
|
||||
|
||||
func (c *Client) LastError() string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.lastError
|
||||
}
|
||||
|
||||
func (c *Client) LookupByISRC(ctx context.Context, isrc string) (Recording, []byte, error) {
|
||||
isrc = strings.ToUpper(strings.TrimSpace(isrc))
|
||||
if isrc == "" {
|
||||
return Recording{}, nil, errors.New("isrc is required")
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("fmt", "json")
|
||||
params.Set("inc", "artist-credits+isrcs+tags")
|
||||
payload, err := c.get(ctx, "/isrc/"+url.PathEscape(isrc), params)
|
||||
if err != nil {
|
||||
return Recording{}, payload, err
|
||||
}
|
||||
recording, err := parseISRCRecording(payload, isrc)
|
||||
return recording, payload, err
|
||||
}
|
||||
|
||||
func (c *Client) SearchRecording(ctx context.Context, title, artist string) (Recording, []byte, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
artist = strings.TrimSpace(artist)
|
||||
if title == "" {
|
||||
return Recording{}, nil, errors.New("title is required")
|
||||
}
|
||||
query := `recording:"` + escapeQuery(title) + `"`
|
||||
if artist != "" {
|
||||
query += ` AND artist:"` + escapeQuery(artist) + `"`
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("fmt", "json")
|
||||
params.Set("query", query)
|
||||
params.Set("limit", "1")
|
||||
payload, err := c.get(ctx, "/recording", params)
|
||||
if err != nil {
|
||||
return Recording{}, payload, err
|
||||
}
|
||||
recording, err := parseSearchRecording(payload)
|
||||
return recording, payload, err
|
||||
}
|
||||
|
||||
func (c *Client) get(ctx context.Context, path string, params url.Values) ([]byte, error) {
|
||||
if !c.Configured() {
|
||||
err := errors.New("musicbrainz app name and contact are required")
|
||||
c.setLastError(err.Error())
|
||||
return nil, err
|
||||
}
|
||||
if err := c.wait(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endpoint := c.baseURL + path
|
||||
if encoded := params.Encode(); encoded != "" {
|
||||
endpoint += "?" + encoded
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", c.userAgent())
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
c.setLastError(err.Error())
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
payload, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if err != nil {
|
||||
c.setLastError(err.Error())
|
||||
return payload, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
err := fmt.Errorf("musicbrainz request failed with status %d", resp.StatusCode)
|
||||
c.setLastError(err.Error())
|
||||
return payload, err
|
||||
}
|
||||
c.setLastError("")
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func (c *Client) wait(ctx context.Context) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
wait := c.minDelay - time.Since(c.lastCall)
|
||||
if wait > 0 {
|
||||
timer := time.NewTimer(wait)
|
||||
c.mu.Unlock()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
c.mu.Lock()
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
c.mu.Lock()
|
||||
}
|
||||
c.lastCall = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) userAgent() string {
|
||||
return fmt.Sprintf("%s/%s (%s)", c.appName, c.version, c.contact)
|
||||
}
|
||||
|
||||
func (c *Client) setLastError(message string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.lastError = message
|
||||
}
|
||||
|
||||
func parseISRCRecording(payload []byte, isrc string) (Recording, error) {
|
||||
var decoded struct {
|
||||
Recordings []recordingJSON `json:"recordings"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
return Recording{}, fmt.Errorf("decode musicbrainz isrc: %w", err)
|
||||
}
|
||||
if len(decoded.Recordings) == 0 {
|
||||
return Recording{}, errors.New("musicbrainz isrc lookup returned no recordings")
|
||||
}
|
||||
return decoded.Recordings[0].toRecording(isrc), nil
|
||||
}
|
||||
|
||||
func parseSearchRecording(payload []byte) (Recording, error) {
|
||||
var decoded struct {
|
||||
Recordings []recordingJSON `json:"recordings"`
|
||||
}
|
||||
if err := json.Unmarshal(payload, &decoded); err != nil {
|
||||
return Recording{}, fmt.Errorf("decode musicbrainz recording search: %w", err)
|
||||
}
|
||||
if len(decoded.Recordings) == 0 {
|
||||
return Recording{}, errors.New("musicbrainz recording search returned no matches")
|
||||
}
|
||||
return decoded.Recordings[0].toRecording(""), nil
|
||||
}
|
||||
|
||||
type recordingJSON struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
ArtistCredit []struct {
|
||||
Artist struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"artist"`
|
||||
} `json:"artist-credit"`
|
||||
ISRCs []string `json:"isrcs"`
|
||||
Tags []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"tags"`
|
||||
Genres []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"genres"`
|
||||
}
|
||||
|
||||
func (r recordingJSON) toRecording(fallbackISRC string) Recording {
|
||||
out := Recording{ID: r.ID, Title: r.Title, ISRC: fallbackISRC}
|
||||
if len(r.ArtistCredit) > 0 {
|
||||
out.Artist = r.ArtistCredit[0].Artist.Name
|
||||
out.ArtistID = r.ArtistCredit[0].Artist.ID
|
||||
}
|
||||
if out.ISRC == "" && len(r.ISRCs) > 0 {
|
||||
out.ISRC = strings.ToUpper(r.ISRCs[0])
|
||||
}
|
||||
for _, genre := range r.Genres {
|
||||
if genre.Name != "" {
|
||||
out.Genres = append(out.Genres, genre.Name)
|
||||
}
|
||||
}
|
||||
for _, tag := range r.Tags {
|
||||
if tag.Name != "" {
|
||||
out.Tags = append(out.Tags, tag.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func escapeQuery(value string) string {
|
||||
return strings.ReplaceAll(value, `"`, `\"`)
|
||||
}
|
||||
Reference in New Issue
Block a user