package service import ( "context" "encoding/json" "fmt" "net/http" "time" ) type MusicBrainzProvider struct { baseURL string userAgent string httpClient *http.Client lastRequest time.Time } func NewMusicBrainzProvider() *MusicBrainzProvider { return &MusicBrainzProvider{ baseURL: "https://musicbrainz.org/ws/2", userAgent: "UnifiedMediaManager/1.0 (https://github.com/TopherMayor/unified-media-manager)", httpClient: &http.Client{ Timeout: 10 * time.Second, }, } } type mbArtistSearchResponse struct { Count int `json:"count"` Artists []mbArtist `json:"artists"` } type mbReleaseGroupSearchResponse struct { Count int `json:"count"` ReleaseGroups []mbReleaseGroup `json:"release-groups"` } type mbArtist struct { ID string `json:"id"` Name string `json:"name"` SortName string `json:"sort-name"` Disambiguation string `json:"disambiguation"` LifeSpan mbLifeSpan `json:"life-span"` } type mbLifeSpan struct { Begin string `json:"begin"` End string `json:"end"` } type mbReleaseGroup struct { ID string `json:"id"` Title string `json:"title"` FirstReleaseDate string `json:"first-release-date"` Disambiguation string `json:"disambiguation"` ArtistCredit []mbArtistCredit `json:"artist-credit"` PrimaryType string `json:"primary-type"` } type mbArtistCredit struct { Artist mbArtistRef `json:"artist"` } type mbArtistRef struct { ID string `json:"id"` Name string `json:"name"` } type mbArtistDetail struct { ID string `json:"id"` Name string `json:"name"` SortName string `json:"sort-name"` Disambiguation string `json:"disambiguation"` LifeSpan mbLifeSpan `json:"life-span"` } type mbReleaseGroupDetail struct { ID string `json:"id"` Title string `json:"title"` FirstReleaseDate string `json:"first-release-date"` ArtistCredit []mbArtistCredit `json:"artist-credit"` } type coverArtResponse struct { Images []coverArtImage `json:"images"` } type coverArtImage struct { Image string `json:"image"` Front bool `json:"front"` } func (p *MusicBrainzProvider) rateLimit() { elapsed := time.Since(p.lastRequest) if elapsed < time.Second { time.Sleep(time.Second - elapsed) } p.lastRequest = time.Now() } func (p *MusicBrainzProvider) fetch(ctx context.Context, url string, result interface{}) error { p.rateLimit() req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return fmt.Errorf("create request: %w", err) } req.Header.Set("User-Agent", p.userAgent) resp, err := p.httpClient.Do(req) if err != nil { return fmt.Errorf("fetch musicbrainz: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("musicbrainz api returned status %d", resp.StatusCode) } if err := json.NewDecoder(resp.Body).Decode(result); err != nil { return fmt.Errorf("decode musicbrainz response: %w", err) } return nil } func (p *MusicBrainzProvider) Name() string { return "musicbrainz" } func (p *MusicBrainzProvider) Search(ctx context.Context, query string, opts SearchOptions) ([]MetadataSearchResult, error) { if opts.MediaType == "album" { return p.searchReleaseGroups(ctx, query) } return p.searchArtists(ctx, query) } func (p *MusicBrainzProvider) searchArtists(ctx context.Context, query string) ([]MetadataSearchResult, error) { url := fmt.Sprintf("%s/artist?query=%s&fmt=json&limit=20", p.baseURL, query) var resp mbArtistSearchResponse if err := p.fetch(ctx, url, &resp); err != nil { return nil, fmt.Errorf("search musicbrainz artists: %w", err) } var results []MetadataSearchResult for _, a := range resp.Artists { var year *int if len(a.LifeSpan.Begin) >= 4 { y := 0 for _, c := range a.LifeSpan.Begin[:4] { if c >= '0' && c <= '9' { y = y*10 + int(c-'0') } } year = &y } extIDs, _ := json.Marshal(map[string]string{"musicbrainz": a.ID}) results = append(results, MetadataSearchResult{ ProviderID: a.ID, Title: a.Name, Year: year, MediaType: "music", OriginalTitle: a.Disambiguation, ExternalIDs: extIDs, }) } return results, nil } func (p *MusicBrainzProvider) searchReleaseGroups(ctx context.Context, query string) ([]MetadataSearchResult, error) { url := fmt.Sprintf("%s/release-group?query=%s&fmt=json&limit=20", p.baseURL, query) var resp mbReleaseGroupSearchResponse if err := p.fetch(ctx, url, &resp); err != nil { return nil, fmt.Errorf("search musicbrainz release groups: %w", err) } var results []MetadataSearchResult for _, rg := range resp.ReleaseGroups { var year *int if len(rg.FirstReleaseDate) >= 4 { y := 0 for _, c := range rg.FirstReleaseDate[:4] { if c >= '0' && c <= '9' { y = y*10 + int(c-'0') } } year = &y } extIDs, _ := json.Marshal(map[string]string{"musicbrainz": rg.ID}) artistName := "" if len(rg.ArtistCredit) > 0 { artistName = rg.ArtistCredit[0].Artist.Name } results = append(results, MetadataSearchResult{ ProviderID: rg.ID, Title: rg.Title, Year: year, MediaType: "album", OriginalTitle: artistName, ExternalIDs: extIDs, }) } return results, nil } func (p *MusicBrainzProvider) GetDetails(ctx context.Context, id string) (*MetadataDetails, error) { artistURL := fmt.Sprintf("%s/artist/%s?fmt=json&inc=url-rels", p.baseURL, id) var artistDetail mbArtistDetail err := p.fetch(ctx, artistURL, &artistDetail) if err == nil { extIDs, _ := json.Marshal(map[string]string{"musicbrainz": id}) metadata, _ := json.Marshal(map[string]interface{}{ "sort_name": artistDetail.SortName, "disambiguation": artistDetail.Disambiguation, "life_span_begin": artistDetail.LifeSpan.Begin, "life_span_end": artistDetail.LifeSpan.End, }) name := artistDetail.Name return &MetadataDetails{ ProviderID: id, Title: name, ExternalIDs: extIDs, Metadata: metadata, }, nil } rgURL := fmt.Sprintf("%s/release-group/%s?fmt=json&inc=artist-credits", p.baseURL, id) var rgDetail mbReleaseGroupDetail if err := p.fetch(ctx, rgURL, &rgDetail); err != nil { return nil, fmt.Errorf("get musicbrainz details: %w", err) } extIDs, _ := json.Marshal(map[string]string{"musicbrainz": id}) artistName := "" if len(rgDetail.ArtistCredit) > 0 { artistName = rgDetail.ArtistCredit[0].Artist.Name } metadata, _ := json.Marshal(map[string]interface{}{ "first_release_date": rgDetail.FirstReleaseDate, "artist": artistName, }) var year *int if len(rgDetail.FirstReleaseDate) >= 4 { y := 0 for _, c := range rgDetail.FirstReleaseDate[:4] { if c >= '0' && c <= '9' { y = y*10 + int(c-'0') } } year = &y } title := rgDetail.Title return &MetadataDetails{ ProviderID: id, Title: title, Year: year, ExternalIDs: extIDs, Metadata: metadata, }, nil } func (p *MusicBrainzProvider) GetImages(ctx context.Context, id string) ([]ImageResult, error) { coverURL := fmt.Sprintf("https://coverartarchive.org/release-group/%s", id) req, err := http.NewRequestWithContext(ctx, http.MethodGet, coverURL, nil) if err != nil { return nil, nil } req.Header.Set("User-Agent", p.userAgent) p.rateLimit() resp, err := p.httpClient.Do(req) if err != nil { return nil, nil } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, nil } var coverResp coverArtResponse if err := json.NewDecoder(resp.Body).Decode(&coverResp); err != nil { return nil, nil } for _, img := range coverResp.Images { if img.Front { return []ImageResult{{ URL: img.Image, Type: "cover", }}, nil } } if len(coverResp.Images) > 0 { return []ImageResult{{ URL: coverResp.Images[0].Image, Type: "cover", }}, nil } return nil, nil }