Sync from /srv/compose/unified-media-manager
This commit is contained in:
320
internal/service/musicbrainz.go
Normal file
320
internal/service/musicbrainz.go
Normal file
@@ -0,0 +1,320 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user