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 }