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>
This commit is contained in:
1 parent
b11d0806c1
commit
428935dc58
19 files changed
+3503
-23
No files matched your search
@@ -0,0 +1,273 @@
|
||||
// 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()
|
||||
}
|
||||
Reference in new issue
Block a user