github.com/derat/nup@v0.0.0-20230418113745-15592ba7c620/cmd/nup/metadata/musicbrainz.go (about)

     1  // Copyright 2023 Daniel Erat.
     2  // All rights reserved.
     3  
     4  package metadata
     5  
     6  import (
     7  	"context"
     8  	"encoding/json"
     9  	"fmt"
    10  	"io"
    11  	"net/http"
    12  	"time"
    13  
    14  	"github.com/derat/nup/cmd/nup/client/files"
    15  	"github.com/derat/nup/server/db"
    16  	"golang.org/x/time/rate"
    17  )
    18  
    19  const (
    20  	// https://musicbrainz.org/doc/MusicBrainz_API/Rate_Limiting
    21  	maxQPS         = 1
    22  	rateBucketSize = 1
    23  	userAgent      = "nup/0 ( https://github.com/derat/nup )"
    24  
    25  	maxTries   = 3
    26  	retryDelay = 5 * time.Second
    27  )
    28  
    29  // api fetches information using the MusicBrainz API.
    30  // See https://musicbrainz.org/doc/MusicBrainz_API.
    31  type api struct {
    32  	srvURL  string        // base URL of web server, e.g. "https://musicbrainz.org"
    33  	limiter *rate.Limiter // rate-limits network requests
    34  
    35  	lastRelMBID string // last ID passed to getRelease (differs from lastRel.ID if merged)
    36  	lastRel     *release
    37  	lastRelErr  error
    38  }
    39  
    40  func newAPI(srvURL string) *api {
    41  	return &api{
    42  		srvURL:  srvURL,
    43  		limiter: rate.NewLimiter(maxQPS, rateBucketSize),
    44  	}
    45  }
    46  
    47  // httpError is returned by send for non-200 status codes.
    48  type httpError struct {
    49  	code   int
    50  	status string
    51  }
    52  
    53  func (e *httpError) Error() string {
    54  	return fmt.Sprintf("server returned %v (%q)", e.code, e.status)
    55  }
    56  
    57  // fatal returns true if the request that caused the error should not be retried.
    58  func (e *httpError) fatal() bool {
    59  	switch e.code {
    60  	case http.StatusBadRequest: // returned if we e.g. send an empty MBID
    61  		return true
    62  	case http.StatusNotFound: // returned if the entity doesn't exist
    63  		return true
    64  	default:
    65  		return false
    66  	}
    67  }
    68  
    69  // send sends a GET request to the API using the supplied path (e.g. "/ws/2/...?fmt=json")
    70  // and unmarshals the JSON response into dst.
    71  func (api *api) send(ctx context.Context, path string, dst interface{}) error {
    72  	try := func() (io.ReadCloser, error) {
    73  		if err := api.limiter.Wait(ctx); err != nil {
    74  			return nil, err
    75  		}
    76  		req, err := http.NewRequestWithContext(ctx, http.MethodGet, api.srvURL+path, nil)
    77  		if err != nil {
    78  			return nil, err
    79  		}
    80  		req.Header.Set("User-Agent", userAgent)
    81  
    82  		resp, err := http.DefaultClient.Do(req)
    83  		if err != nil {
    84  			return nil, err
    85  		}
    86  		if resp.StatusCode != 200 {
    87  			err = &httpError{resp.StatusCode, resp.Status}
    88  		}
    89  		return resp.Body, err
    90  	}
    91  
    92  	var tries int
    93  	for {
    94  		body, err := try()
    95  		tries++
    96  
    97  		if err == nil {
    98  			defer body.Close()
    99  			return json.NewDecoder(body).Decode(dst)
   100  		}
   101  
   102  		if body != nil {
   103  			body.Close()
   104  		}
   105  		if tries >= maxTries {
   106  			return err
   107  		} else if he, ok := err.(*httpError); ok && he.fatal() {
   108  			return err
   109  		}
   110  		time.Sleep(retryDelay)
   111  	}
   112  }
   113  
   114  // getRelease fetches the release with the supplied MBID.
   115  func (api *api) getRelease(ctx context.Context, mbid string) (*release, error) {
   116  	if mbid == api.lastRelMBID {
   117  		return api.lastRel, api.lastRelErr
   118  	}
   119  	api.lastRelMBID = mbid
   120  	api.lastRel = &release{}
   121  	api.lastRelErr = api.send(ctx, "/ws/2/release/"+mbid+"?inc=artist-credits+recordings+release-groups&fmt=json", api.lastRel)
   122  	return api.lastRel, api.lastRelErr
   123  }
   124  
   125  // getRecording fetches the recording with the supplied MBID.
   126  // This should only be used for standalone recordings that aren't included in releases.
   127  func (api *api) getRecording(ctx context.Context, mbid string) (*recording, error) {
   128  	var rec recording
   129  	err := api.send(ctx, "/ws/2/recording/"+mbid+"?inc=artist-credits&fmt=json", &rec)
   130  	return &rec, err
   131  }
   132  
   133  type release struct {
   134  	Title        string         `json:"title"`
   135  	Artists      []artistCredit `json:"artist-credit"`
   136  	ID           string         `json:"id"`
   137  	Media        []medium       `json:"media"`
   138  	ReleaseGroup releaseGroup   `json:"release-group"`
   139  	Date         date           `json:"date"`
   140  }
   141  
   142  func (rel *release) findTrack(recID string) (*track, *medium) {
   143  	for _, m := range rel.Media {
   144  		for _, t := range m.Tracks {
   145  			if t.Recording.ID == recID {
   146  				return &t, &m
   147  			}
   148  		}
   149  	}
   150  	return nil, nil
   151  }
   152  
   153  func (rel *release) getTrackByIndex(idx int) *track {
   154  	for _, m := range rel.Media {
   155  		if idx < len(m.Tracks) {
   156  			return &m.Tracks[idx]
   157  		}
   158  		idx -= len(m.Tracks)
   159  	}
   160  	return nil
   161  }
   162  
   163  func (rel *release) numTracks() int {
   164  	var n int
   165  	for _, m := range rel.Media {
   166  		n += len(m.Tracks)
   167  	}
   168  	return n
   169  }
   170  
   171  type releaseGroup struct {
   172  	Title            string         `json:"title"`
   173  	Artists          []artistCredit `json:"artist-credit"`
   174  	ID               string         `json:"id"`
   175  	FirstReleaseDate date           `json:"first-release-date"`
   176  }
   177  
   178  type artistCredit struct {
   179  	Name       string `json:"name"`
   180  	JoinPhrase string `json:"joinphrase"`
   181  }
   182  
   183  func joinArtistCredits(acs []artistCredit) string {
   184  	var s string
   185  	for _, ac := range acs {
   186  		s += ac.Name + ac.JoinPhrase
   187  	}
   188  	return s
   189  }
   190  
   191  type medium struct {
   192  	Title    string  `json:"title"`
   193  	Position int     `json:"position"`
   194  	Tracks   []track `json:"tracks"`
   195  }
   196  
   197  type track struct {
   198  	Title     string         `json:"title"`
   199  	Artists   []artistCredit `json:"artist-credit"`
   200  	Position  int            `json:"position"`
   201  	ID        string         `json:"id"`
   202  	Length    int64          `json:"length"` // milliseconds
   203  	Recording recording      `json:"recording"`
   204  }
   205  
   206  type recording struct {
   207  	Title            string         `json:"title"`
   208  	Artists          []artistCredit `json:"artist-credit"`
   209  	ID               string         `json:"id"`
   210  	Length           int64          `json:"length"` // milliseconds
   211  	FirstReleaseDate date           `json:"first-release-date"`
   212  }
   213  
   214  // date unmarshals a date provided as a JSON string like "2020-10-23".
   215  type date time.Time
   216  
   217  func (d *date) UnmarshalJSON(b []byte) error {
   218  	var s string
   219  	if err := json.Unmarshal(b, &s); err != nil {
   220  		return err
   221  	}
   222  	if s == "" {
   223  		*d = date(time.Time{})
   224  		return nil
   225  	}
   226  	for _, format := range []string{"2006-01-02", "2006-01", "2006"} {
   227  		if t, err := time.Parse(format, s); err == nil {
   228  			*d = date(t)
   229  			return nil
   230  		}
   231  	}
   232  	return fmt.Errorf("malformed date %q", s)
   233  }
   234  
   235  func (d date) MarshalJSON() ([]byte, error) {
   236  	t := time.Time(d)
   237  	if t.IsZero() {
   238  		return json.Marshal("")
   239  	} else {
   240  		return json.Marshal(t.Format("2006-01-02"))
   241  	}
   242  }
   243  
   244  func (d date) String() string { return time.Time(d).String() }
   245  
   246  // updateSongFromRelease updates fields in song using data from rel.
   247  // false is returned if the recording isn't included in the release.
   248  func updateSongFromRelease(song *db.Song, rel *release) bool {
   249  	tr, med := rel.findTrack(song.RecordingID)
   250  	if tr == nil {
   251  		return false
   252  	}
   253  
   254  	song.Artist = joinArtistCredits(tr.Artists)
   255  	song.Title = tr.Title
   256  	song.Album = rel.Title
   257  	song.DiscSubtitle = med.Title
   258  	song.AlbumID = rel.ID
   259  	song.Track = tr.Position
   260  	song.Disc = med.Position
   261  	song.Date = time.Time(rel.ReleaseGroup.FirstReleaseDate)
   262  
   263  	// Only set the album artist if it differs from the song artist or if it was previously set.
   264  	// Otherwise we're creating needless churn, since the update command won't send it to the server
   265  	// if it's the same as the song artist.
   266  	if aa := joinArtistCredits(rel.Artists); aa != song.Artist || song.AlbumArtist != "" {
   267  		song.AlbumArtist = aa
   268  	}
   269  
   270  	return true
   271  }
   272  
   273  // updateSongFromRecording updates fields in song using data from rec.
   274  // This should only be used for standalone recordings.
   275  func updateSongFromRecording(song *db.Song, rec *recording) {
   276  	song.Artist = joinArtistCredits(rec.Artists)
   277  	song.Title = rec.Title
   278  	song.Album = files.NonAlbumTracksValue
   279  	song.AlbumID = ""
   280  	song.Date = time.Time(rec.FirstReleaseDate) // always zero?
   281  }