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:
cdnutterandClaude Opus 5.5 committed 2026-09-30 09:10:48 -07:00
1 parent b11d0806c1
commit 428935dc58
19 files changed
+3503 -23

No files matched your search

+273
View File
@@ -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()
}