Files
minstrel/internal/musicbrainz/entities.go
T
bvandeusenandClaude Opus 5.5 77389f88bc
release / govulncheck (push) Successful in 22s
release / web (push) Failing after 24s
release / go (push) Successful in 1m57s
release / integration (push) Successful in 4m1s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / android (push) Canceled after 4m38s
release / Build signed APK (releases and dev) (push) Canceled after 4m37s
feat: a release catalogue from MusicBrainz; request search asks it directly (M502 #5519)
Minstrel keeps its own record of what artists have released, read from
MusicBrainz, so the wanted list, search and import no longer need Lidarr.

- internal/musicbrainz: searches (artists, release groups, recordings) and
  browses (an artist's release groups; a group's releases with track lists).
  One process-wide pacer at 1.1s, shared with tag enrichment, because
  MusicBrainz's limit is per address. A 503 throttle is retried.
- Migration 0080: mb_artists, mb_release_groups, mb_artist_release_groups
  (a collaboration belongs to each credited artist), mb_releases,
  mb_release_tracks.
- internal/catalogue: Discography and Releases serve the held copy and
  re-read it when older than 7 or 30 days. An unreachable MusicBrainz
  serves the stale copy, marked. An hourly pass re-reads due artists.
- Art for what isn't held comes through /api/catalogue/art from the
  enabled cover-art sources (Cover Art Archive by release group, then the
  name-based sources) and is kept on disk, so the browser never fetches a
  third-party host. A miss is a 404 and the card shows its icon.
- Request search moves from /api/lidarr/search to /api/catalogue/search,
  with the same response shape. Albums are release groups, and "in
  library" matches albums.release_group_mbid. A 10-minute cache absorbs
  search-as-you-type. Discover suggestions point at the art endpoint
  instead of looking up Lidarr.
- Android calls the new path and loads Discover art through ServerImage.
  Installed builds older than this one lose Discover search until they
  update (no compatibility path, rule 22).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:08:22 -04:00

384 lines
11 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 musicbrainz
import (
"context"
"net/url"
"strconv"
"strings"
)
// Artist is a MusicBrainz artist.
type Artist struct {
MBID string
Name string
SortName string
Disambiguation string
Type string // Person, Group, Orchestra, …; may be empty
Country string
}
// ReleaseGroup is what a person calls an album: one work, released any
// number of times. Lidarr's albums were release groups too.
type ReleaseGroup struct {
MBID string
Title string
PrimaryType string // Album, Single, EP, Broadcast, Other; may be empty
SecondaryTypes []string // Compilation, Live, Remix, Soundtrack, …
// FirstReleaseDate is MusicBrainz's partial date: "", "1997",
// "1997-05" or "1997-05-21".
FirstReleaseDate string
ArtistCredit string // the credit as printed, joinphrases included
ArtistMBIDs []string
}
// Year is the first release's year, or "".
func (g ReleaseGroup) Year() string {
if len(g.FirstReleaseDate) >= 4 {
return g.FirstReleaseDate[:4]
}
return ""
}
// Release is one issue of a release group: a pressing, an edition, a
// digital release, each with its own track list.
type Release struct {
MBID string
Title string
Status string // Official, Promotion, Bootleg, Pseudo-Release; may be empty
Date string // partial, like ReleaseGroup.FirstReleaseDate
Country string
Disambiguation string
Media []Medium
}
// TrackCount totals the tracks across every medium.
func (r Release) TrackCount() int {
n := 0
for _, m := range r.Media {
n += len(m.Tracks)
}
return n
}
// Formats summarises the media, e.g. "CD", "2×CD" or "CD + DVD".
func (r Release) Formats() string {
var parts []string
counts := map[string]int{}
for _, m := range r.Media {
f := m.Format
if f == "" {
f = "Unknown"
}
if counts[f] == 0 {
parts = append(parts, f)
}
counts[f]++
}
for i, f := range parts {
if counts[f] > 1 {
parts[i] = strconv.Itoa(counts[f]) + "×" + f
}
}
return strings.Join(parts, " + ")
}
// Medium is one disc (or side, or file set) of a release.
type Medium struct {
Position int
Format string
Tracks []Track
}
// Track is a position on a medium, pointing at the recording heard there.
type Track struct {
Position int
Number string // as printed: "1", "A1", …
Title string
LengthMs int // 0 when unknown
RecordingMBID string
}
// Recording is a piece of audio, as a search returns it.
type Recording struct {
MBID string
Title string
LengthMs int
ArtistCredit string
ArtistMBIDs []string
// ReleaseGroup is the group the recording is best known from: the first
// official album it appears on, else the first release listed.
ReleaseGroup ReleaseGroup
}
// --- wire shapes ---
type wireCredit struct {
Name string `json:"name"`
JoinPhrase string `json:"joinphrase"`
Artist struct {
ID string `json:"id"`
Name string `json:"name"`
} `json:"artist"`
}
func credit(cs []wireCredit) (string, []string) {
var b strings.Builder
ids := make([]string, 0, len(cs))
for _, c := range cs {
name := c.Name
if name == "" {
name = c.Artist.Name
}
b.WriteString(name)
b.WriteString(c.JoinPhrase)
if c.Artist.ID != "" {
ids = append(ids, c.Artist.ID)
}
}
return b.String(), ids
}
type wireArtist struct {
ID string `json:"id"`
Name string `json:"name"`
SortName string `json:"sort-name"`
Disambiguation string `json:"disambiguation"`
Type string `json:"type"`
Country string `json:"country"`
}
func (w wireArtist) artist() Artist {
return Artist{MBID: w.ID, Name: w.Name, SortName: w.SortName, Disambiguation: w.Disambiguation, Type: w.Type, Country: w.Country}
}
type wireReleaseGroup struct {
ID string `json:"id"`
Title string `json:"title"`
PrimaryType string `json:"primary-type"`
SecondaryTypes []string `json:"secondary-types"`
FirstReleaseDate string `json:"first-release-date"`
ArtistCredit []wireCredit `json:"artist-credit"`
}
func (w wireReleaseGroup) group() ReleaseGroup {
name, ids := credit(w.ArtistCredit)
return ReleaseGroup{
MBID: w.ID, Title: w.Title, PrimaryType: w.PrimaryType,
SecondaryTypes: append([]string{}, w.SecondaryTypes...), FirstReleaseDate: w.FirstReleaseDate,
ArtistCredit: name, ArtistMBIDs: ids,
}
}
type wireTrack struct {
Position int `json:"position"`
Number string `json:"number"`
Title string `json:"title"`
Length *int `json:"length"`
Recording struct {
ID string `json:"id"`
Length *int `json:"length"`
} `json:"recording"`
}
type wireRelease struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Date string `json:"date"`
Country string `json:"country"`
Disambiguation string `json:"disambiguation"`
Media []struct {
Position int `json:"position"`
Format string `json:"format"`
Tracks []wireTrack `json:"tracks"`
} `json:"media"`
ReleaseGroup *wireReleaseGroup `json:"release-group"`
}
func (w wireRelease) release() Release {
r := Release{MBID: w.ID, Title: w.Title, Status: w.Status, Date: w.Date, Country: w.Country, Disambiguation: w.Disambiguation}
for _, m := range w.Media {
med := Medium{Position: m.Position, Format: m.Format}
for _, t := range m.Tracks {
length := 0
switch {
case t.Length != nil:
length = *t.Length
case t.Recording.Length != nil:
length = *t.Recording.Length
}
med.Tracks = append(med.Tracks, Track{
Position: t.Position, Number: t.Number, Title: t.Title,
LengthMs: length, RecordingMBID: t.Recording.ID,
})
}
r.Media = append(r.Media, med)
}
return r
}
// --- searches ---
// SearchArtists finds artists by name or alias.
func (c *Client) SearchArtists(ctx context.Context, text string, limit int) ([]Artist, error) {
var resp struct {
Artists []wireArtist `json:"artists"`
}
q := url.Values{"query": {fieldQuery(text, "artist", "alias")}, "limit": {searchLimit(limit)}}
if err := c.get(ctx, "/artist", q, &resp); err != nil {
return nil, err
}
out := make([]Artist, 0, len(resp.Artists))
for _, a := range resp.Artists {
out = append(out, a.artist())
}
return out, nil
}
// SearchReleaseGroups finds albums by title or by artist, so "radiohead ok
// computer" and "ok computer" both find OK Computer.
func (c *Client) SearchReleaseGroups(ctx context.Context, text string, limit int) ([]ReleaseGroup, error) {
var resp struct {
ReleaseGroups []wireReleaseGroup `json:"release-groups"`
}
q := url.Values{"query": {fieldQuery(text, "releasegroup", "artist")}, "limit": {searchLimit(limit)}}
if err := c.get(ctx, "/release-group", q, &resp); err != nil {
return nil, err
}
out := make([]ReleaseGroup, 0, len(resp.ReleaseGroups))
for _, g := range resp.ReleaseGroups {
out = append(out, g.group())
}
return out, nil
}
// SearchRecordings finds songs by title or by artist.
func (c *Client) SearchRecordings(ctx context.Context, text string, limit int) ([]Recording, error) {
var resp struct {
Recordings []struct {
ID string `json:"id"`
Title string `json:"title"`
Length *int `json:"length"`
ArtistCredit []wireCredit `json:"artist-credit"`
Releases []wireRelease `json:"releases"`
} `json:"recordings"`
}
q := url.Values{"query": {fieldQuery(text, "recording", "artist")}, "limit": {searchLimit(limit)}}
if err := c.get(ctx, "/recording", q, &resp); err != nil {
return nil, err
}
out := make([]Recording, 0, len(resp.Recordings))
for _, r := range resp.Recordings {
name, ids := credit(r.ArtistCredit)
rec := Recording{MBID: r.ID, Title: r.Title, ArtistCredit: name, ArtistMBIDs: ids}
if r.Length != nil {
rec.LengthMs = *r.Length
}
rec.ReleaseGroup = bestKnownFrom(r.Releases)
out = append(out, rec)
}
return out, nil
}
// bestKnownFrom picks the release group a recording should be filed under
// in a search result: the first official album, else the first release.
func bestKnownFrom(releases []wireRelease) ReleaseGroup {
var first *wireReleaseGroup
for _, r := range releases {
if r.ReleaseGroup == nil || r.ReleaseGroup.ID == "" {
continue
}
if first == nil {
first = r.ReleaseGroup
}
if r.Status == "Official" && r.ReleaseGroup.PrimaryType == "Album" && len(r.ReleaseGroup.SecondaryTypes) == 0 {
return r.ReleaseGroup.group()
}
}
if first == nil {
return ReleaseGroup{}
}
return first.group()
}
// --- lookups and browses ---
// Artist looks one artist up by MBID.
func (c *Client) Artist(ctx context.Context, mbid string) (Artist, error) {
var w wireArtist
if err := c.get(ctx, "/artist/"+url.PathEscape(mbid), nil, &w); err != nil {
return Artist{}, err
}
if w.ID == "" {
return Artist{}, ErrBadResponse
}
return w.artist(), nil
}
// ArtistReleaseGroups lists every release group the artist is credited on,
// paging through MusicBrainz's browse.
func (c *Client) ArtistReleaseGroups(ctx context.Context, artistMBID string) ([]ReleaseGroup, error) {
var out []ReleaseGroup
for page := 0; page < maxPages; page++ {
var resp struct {
Count int `json:"release-group-count"`
ReleaseGroups []wireReleaseGroup `json:"release-groups"`
}
q := url.Values{
"artist": {artistMBID}, "inc": {"artist-credits"},
"limit": {strconv.Itoa(pageSize)}, "offset": {strconv.Itoa(len(out))},
}
if err := c.get(ctx, "/release-group", q, &resp); err != nil {
return nil, err
}
for _, g := range resp.ReleaseGroups {
out = append(out, g.group())
}
if len(resp.ReleaseGroups) == 0 || len(out) >= resp.Count {
break
}
}
return out, nil
}
// ReleaseGroupReleases lists every release of a release group with its
// media and track lists.
func (c *Client) ReleaseGroupReleases(ctx context.Context, groupMBID string) ([]Release, error) {
var out []Release
for page := 0; page < maxPages; page++ {
var resp struct {
Count int `json:"release-count"`
Releases []wireRelease `json:"releases"`
}
q := url.Values{
"release-group": {groupMBID}, "inc": {"media recordings"},
"limit": {strconv.Itoa(pageSize)}, "offset": {strconv.Itoa(len(out))},
}
if err := c.get(ctx, "/release", q, &resp); err != nil {
return nil, err
}
for _, r := range resp.Releases {
out = append(out, r.release())
}
// Advance by what came back: MusicBrainz may return fewer than the
// page size when track lists are large.
if len(resp.Releases) == 0 || len(out) >= resp.Count {
break
}
}
return out, nil
}
// ReleaseGroup looks one release group up by MBID, with its artist credit.
func (c *Client) ReleaseGroup(ctx context.Context, mbid string) (ReleaseGroup, error) {
var w wireReleaseGroup
q := url.Values{"inc": {"artist-credits"}}
if err := c.get(ctx, "/release-group/"+url.PathEscape(mbid), q, &w); err != nil {
return ReleaseGroup{}, err
}
if w.ID == "" {
return ReleaseGroup{}, ErrBadResponse
}
return w.group(), nil
}