Files
playlink/internal/meta/meta.go
T
cdnutterandClaude Opus 5.5 428935dc58 Add the step-by-step fixer and library types
Each pair is a Music (Artist/Album/01 Title) or Soundtracks (Album/01 Title)
library. The new Fixer tab takes the master, then the slave, through four
reviewed steps: folder structure, album tags (optional MusicBrainz lookup;
the slave reuses the fixed master's tags), folder names and file names,
then links the two libraries. Every fix is an undoable operation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:10:48 -07:00

274 lines
7.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package meta looks up album information (track lists, titles, dates) on
// MusicBrainz for the fixer's album tags step.
package meta
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/cdnutter/playlink/internal/art"
)
// Client talks to MusicBrainz. Requests are spaced out to respect its limit
// of one request per second.
type Client struct {
HTTP *http.Client
UserAgent string
BaseURL string
Gap time.Duration
mu sync.Mutex
next time.Time
}
func New(version string) *Client {
return &Client{
HTTP: art.SafeHTTPClient(20 * time.Second),
UserAgent: "playlink/" + version + " (self-hosted music library tool)",
BaseURL: "https://musicbrainz.org",
Gap: 1100 * time.Millisecond,
}
}
// Release is one search result.
type Release struct {
ID string `json:"id"`
Title string `json:"title"`
Artist string `json:"artist"`
Date string `json:"date"`
Country string `json:"country"`
Status string `json:"status"`
Type string `json:"type"` // Album, Single, EP, Soundtrack, ...
Disambiguation string `json:"disambiguation,omitempty"`
Formats string `json:"formats"` // "2×CD", "Digital Media"
TrackCount int `json:"track_count"`
Discs int `json:"discs"`
Score int `json:"score"`
}
// Track is one track of a release.
type Track struct {
Disc int `json:"disc"`
Position int `json:"position"`
DiscTracks int `json:"disc_tracks"`
Title string `json:"title"`
Artist string `json:"artist"`
LengthMs int `json:"length_ms"`
}
// ReleaseDetail is a release with its track list.
type ReleaseDetail struct {
Release
Tracks []Track `json:"tracks"`
}
var mbid = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
// ValidID reports whether s is a MusicBrainz id.
func ValidID(s string) bool { return mbid.MatchString(s) }
type artistCredit []struct {
Name string `json:"name"`
JoinPhrase string `json:"joinphrase"`
}
func (a artistCredit) String() string {
var b strings.Builder
for _, c := range a {
b.WriteString(c.Name + c.JoinPhrase)
}
return strings.TrimSpace(b.String())
}
type mbRelease struct {
ID string `json:"id"`
Score int `json:"score"`
Title string `json:"title"`
Status string `json:"status"`
Date string `json:"date"`
Country string `json:"country"`
Disambiguation string `json:"disambiguation"`
TrackCount int `json:"track-count"`
ArtistCredit artistCredit `json:"artist-credit"`
ReleaseGroup struct {
PrimaryType string `json:"primary-type"`
SecondaryTypes []string `json:"secondary-types"`
} `json:"release-group"`
Media []struct {
Position int `json:"position"`
Format string `json:"format"`
TrackCount int `json:"track-count"`
Tracks []struct {
Position int `json:"position"`
Title string `json:"title"`
Length int `json:"length"`
ArtistCredit artistCredit `json:"artist-credit"`
} `json:"tracks"`
} `json:"media"`
}
func (r mbRelease) summary() Release {
out := Release{ID: r.ID, Title: r.Title, Artist: r.ArtistCredit.String(), Date: r.Date, Country: r.Country,
Status: r.Status, Disambiguation: r.Disambiguation, TrackCount: r.TrackCount, Discs: len(r.Media), Score: r.Score}
out.Type = r.ReleaseGroup.PrimaryType
if len(r.ReleaseGroup.SecondaryTypes) > 0 {
out.Type = strings.TrimSpace(out.Type + " " + strings.Join(r.ReleaseGroup.SecondaryTypes, " "))
}
formats := map[string]int{}
var order []string
total := 0
for _, m := range r.Media {
f := m.Format
if f == "" {
f = "Unknown"
}
if formats[f] == 0 {
order = append(order, f)
}
formats[f]++
total += m.TrackCount
}
var parts []string
for _, f := range order {
if formats[f] > 1 {
parts = append(parts, fmt.Sprintf("%d×%s", formats[f], f))
} else {
parts = append(parts, f)
}
}
out.Formats = strings.Join(parts, " + ")
if out.TrackCount == 0 {
out.TrackCount = total
}
return out
}
func quote(s string) string {
// Lucene syntax: escape quotes and backslashes inside a phrase.
s = strings.ReplaceAll(s, `\`, `\\`)
return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"`
}
// SearchReleases finds releases by album title and (optionally) artist.
func (c *Client) SearchReleases(ctx context.Context, artist, album string) ([]Release, error) {
if strings.TrimSpace(album) == "" {
return nil, errors.New("no album title to search for")
}
q := "release:" + quote(album)
if strings.TrimSpace(artist) != "" {
q += " AND artist:" + quote(artist)
}
var d struct {
Releases []mbRelease `json:"releases"`
}
u := c.BaseURL + "/ws/2/release/?" + url.Values{"query": {q}, "fmt": {"json"}, "limit": {"15"}}.Encode()
if err := c.get(ctx, u, &d); err != nil {
return nil, err
}
out := make([]Release, 0, len(d.Releases))
for _, r := range d.Releases {
out = append(out, r.summary())
}
return out, nil
}
// Release fetches a release with its track list.
func (c *Client) Release(ctx context.Context, id string) (*ReleaseDetail, error) {
if !ValidID(id) {
return nil, errors.New("not a MusicBrainz release id")
}
var r mbRelease
u := c.BaseURL + "/ws/2/release/" + id + "?" + url.Values{"inc": {"recordings artist-credits release-groups"}, "fmt": {"json"}}.Encode()
if err := c.get(ctx, u, &r); err != nil {
return nil, err
}
d := &ReleaseDetail{Release: r.summary()}
for _, m := range r.Media {
for _, t := range m.Tracks {
artist := t.ArtistCredit.String()
if artist == "" {
artist = d.Artist
}
d.Tracks = append(d.Tracks, Track{Disc: m.Position, Position: t.Position, DiscTracks: len(m.Tracks),
Title: t.Title, Artist: artist, LengthMs: t.Length})
}
}
return d, nil
}
// get fetches JSON, waiting for its turn and retrying once when MusicBrainz
// says it is busy.
func (c *Client) get(ctx context.Context, u string, v any) error {
for attempt := 0; ; attempt++ {
if err := c.wait(ctx); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", c.UserAgent)
req.Header.Set("Accept", "application/json")
res, err := c.HTTP.Do(req)
if err != nil {
return err
}
body, err := io.ReadAll(io.LimitReader(res.Body, 8<<20))
res.Body.Close()
if err != nil {
return err
}
if res.StatusCode == http.StatusServiceUnavailable && attempt < 2 {
c.backoff(res.Header.Get("Retry-After"))
continue
}
if res.StatusCode != http.StatusOK {
return fmt.Errorf("MusicBrainz: %s", res.Status)
}
return json.Unmarshal(body, v)
}
}
func (c *Client) wait(ctx context.Context) error {
c.mu.Lock()
now := time.Now()
at := c.next
if at.Before(now) {
at = now
}
c.next = at.Add(c.Gap)
c.mu.Unlock()
if d := time.Until(at); d > 0 {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
}
}
return nil
}
func (c *Client) backoff(retryAfter string) {
d := 2 * time.Second
if n, err := strconv.Atoi(retryAfter); err == nil && n > 0 && n < 30 {
d = time.Duration(n) * time.Second
}
c.mu.Lock()
if t := time.Now().Add(d); t.After(c.next) {
c.next = t
}
c.mu.Unlock()
}