Files
unified-media-manager/internal/service/musicbrainz.go
2026-04-24 10:45:19 -07:00

321 lines
7.9 KiB
Go

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
}