321 lines
7.9 KiB
Go
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
|
|
}
|