From f520cc8913b03697f5de8a39de18cfb8a378d8db Mon Sep 17 00:00:00 2001 From: Niklas Kapelle Date: Mon, 11 May 2026 23:02:13 +0200 Subject: [PATCH] added musicbrainz client --- internal/clients/musicbrainz/musicbrainz.go | 228 ++++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 internal/clients/musicbrainz/musicbrainz.go diff --git a/internal/clients/musicbrainz/musicbrainz.go b/internal/clients/musicbrainz/musicbrainz.go new file mode 100644 index 0000000..595dc27 --- /dev/null +++ b/internal/clients/musicbrainz/musicbrainz.go @@ -0,0 +1,228 @@ +package musicbrainz + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +const ( + baseURL = "https://musicbrainz.org/ws/2" + userAgent = "customTool/1.0 (djeeberjr@gmail.com)" +) + +type MusicBrainzClient struct { + httpClient *http.Client + userAgent string +} + +func NewMusicBrainzClient() *MusicBrainzClient { + return &MusicBrainzClient{ + httpClient: &http.Client{Timeout: 15 * time.Second}, + userAgent: userAgent, + } +} + +type Artist struct { + ID string `json:"id"` + Name string `json:"name"` + SortName string `json:"sort-name"` + Type string `json:"type"` + Country string `json:"country"` + Disambiguation string `json:"disambiguation"` + LifeSpan LifeSpan `json:"life-span"` + Tags []Tag `json:"tags"` + Releases []Release `json:"releases"` + ReleaseGroups []ReleaseGroup `json:"release-groups"` +} + +type Release struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + Date string `json:"date"` + Country string `json:"country"` + Disambiguation string `json:"disambiguation"` + ArtistCredit []Credit `json:"artist-credit"` + LabelInfo []LabelInfo `json:"label-info"` + TrackCount int `json:"track-count"` +} + +type ReleaseGroup struct { + ID string `json:"id"` + Title string `json:"title"` + PrimaryType string `json:"primary-type"` + FirstRelease string `json:"first-release-date"` + ArtistCredit []Credit `json:"artist-credit"` +} + +type Recording struct { + ID string `json:"id"` + Title string `json:"title"` + LengthInMS int `json:"length"` + Disambiguation string `json:"disambiguation"` + ArtistCredit []Credit `json:"artist-credit"` + Releases []Release `json:"releases"` + Tags []Tag `json:"tags"` +} + +type Credit struct { + Name string `json:"name"` + JoinPhrase string `json:"joinphrase"` + Artist Artist `json:"artist"` +} + +type LabelInfo struct { + CatalogNumber string `json:"catalog-number"` + Label struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"label"` +} + +type LifeSpan struct { + Begin string `json:"begin"` + End string `json:"end"` + Ended bool `json:"ended"` +} + +type Tag struct { + Name string `json:"name"` + Count int `json:"count"` +} + +type ArtistSearchResult struct { + Created string `json:"created"` + Count int `json:"count"` + Offset int `json:"offset"` + Artists []Artist `json:"artists"` +} + +type ReleaseSearchResult struct { + Created string `json:"created"` + Count int `json:"count"` + Offset int `json:"offset"` + Releases []Release `json:"releases"` +} + +type RecordingSearchResult struct { + Created string `json:"created"` + Count int `json:"count"` + Offset int `json:"offset"` + Recordings []Recording `json:"recordings"` +} + +type SearchOptions struct { + Limit int // max results (default 25, max 100) + Offset int // results offset for pagination +} + +func (o SearchOptions) params() url.Values { + v := url.Values{} + if o.Limit > 0 { + v.Set("limit", fmt.Sprintf("%d", o.Limit)) + } + if o.Offset > 0 { + v.Set("offset", fmt.Sprintf("%d", o.Offset)) + } + return v +} + +func (c *MusicBrainzClient) SearchByISRC(isrc string, opts SearchOptions) (*RecordingSearchResult, error) { + var result RecordingSearchResult + err := c.search("recording", fmt.Sprintf("isrc:%s", isrc), opts, &result) + return &result, err +} + +func (c *MusicBrainzClient) SearchArtists(query string, opts SearchOptions) (*ArtistSearchResult, error) { + var result ArtistSearchResult + err := c.search("artist", query, opts, &result) + return &result, err +} + +func (c *MusicBrainzClient) SearchReleases(query string, opts SearchOptions) (*ReleaseSearchResult, error) { + var result ReleaseSearchResult + err := c.search("release", query, opts, &result) + return &result, err +} + +func (c *MusicBrainzClient) SearchRecordings(query string, opts SearchOptions) (*RecordingSearchResult, error) { + var result RecordingSearchResult + err := c.search("recording", query, opts, &result) + return &result, err +} + +func (c *MusicBrainzClient) GetArtist(mbid string, inc []string) (*Artist, error) { + var artist Artist + err := c.lookup("artist", mbid, inc, &artist) + return &artist, err +} + +func (c *MusicBrainzClient) GetRelease(mbid string, inc []string) (*Release, error) { + var release Release + err := c.lookup("release", mbid, inc, &release) + return &release, err +} + +func (c *MusicBrainzClient) GetRecording(mbid string, inc []string) (*Recording, error) { + var recording Recording + err := c.lookup("recording", mbid, inc, &recording) + return &recording, err +} + +func (c *MusicBrainzClient) search(entity, query string, opts SearchOptions, out any) error { + params := opts.params() + params.Set("query", query) + params.Set("fmt", "json") + + endpoint := fmt.Sprintf("%s/%s?%s", baseURL, entity, params.Encode()) + return c.get(endpoint, out) +} + +func (c *MusicBrainzClient) lookup(entity, mbid string, inc []string, out any) error { + params := url.Values{"fmt": []string{"json"}} + if len(inc) > 0 { + incStr := "" + for i, s := range inc { + if i > 0 { + incStr += "+" + } + incStr += s + } + params.Set("inc", incStr) + } + endpoint := fmt.Sprintf("%s/%s/%s?%s", baseURL, entity, mbid, params.Encode()) + return c.get(endpoint, out) +} + +func (c *MusicBrainzClient) get(endpoint string, out any) error { + req, err := http.NewRequest(http.MethodGet, endpoint, nil) + if err != nil { + return fmt.Errorf("build request: %w", err) + } + req.Header.Set("User-Agent", c.userAgent) + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("status %d: %s", resp.StatusCode, string(body)) + } + + if err := json.Unmarshal(body, out); err != nil { + return fmt.Errorf("decode JSON: %w", err) + } + return nil +}