Files
minstrel/internal/catalogue/catalogue.go
T
bvandeusenandClaude Opus 5.5 04515a977c
release / govulncheck (push) Successful in 15s
release / web (push) Failing after 1m21s
release / go (push) Successful in 1m35s
release / integration (push) Failing after 4m57s
release / android (push) Successful in 5m11s
release / Build signed APK (releases and dev) (push) Successful in 5m28s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
feat: a wanted list — follow artists on release-type profiles; requests and re-acquisition become wants (M502 #5520)
Minstrel keeps its own list of what it is to get, keyed by MusicBrainz
release group. A want comes from an approved request, a new release by a
followed artist, or an album whose files went missing, and is held once the
library has a full edition of it.

- Migration 0081: release_type_profiles (Standard seeded: Album, EP,
  Single, studio only), follows, follow_seen_groups and wants.
  lidarr_requests becomes requests with plain *_mbid columns; approved
  album and track requests move onto the wanted list, approved artist
  requests become follows, and the library's album artists start out
  followed. missing_reacquisitions and the retry settings go.
- internal/wanted: follows are checked against MusicBrainz discographies
  daily. The first check sets a baseline, and only releases after it are
  wanted unless the follow asked for what already exists. Profiles filter
  by primary and secondary type, so live recordings stay out unless a
  profile ticks Live.
- internal/requests (was lidarrrequests): approving puts the request on
  the wanted list, and an artist request can be approved on a chosen
  profile. A request completes when its want is held.
- The re-acquisition sweeper wants a missing album back instead of asking
  Lidarr; searching for it is the search schedule's (#5521).
- Web: an admin Wanted page (Wanted, Missing files, Held, Following), a
  follow control on artist pages, a release-type profiles card on
  Integrations, "Approve on a profile" on Requests, and want state on
  Missing files. The artist detail API returns the artist's MBID.
- Android: the request body sends artist_mbid, album_mbid and track_mbid.

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

319 lines
11 KiB
Go

// Package catalogue is Minstrel's own record of what artists have released,
// read from MusicBrainz and kept in the database (M502 #5519). The wanted
// list, search and import read it; MusicBrainz is asked only when what is
// held is missing or old.
package catalogue
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
)
const (
// DiscographyMaxAge is how long an artist's list of release groups is
// trusted before it is read again. New releases appear on this cadence.
DiscographyMaxAge = 7 * 24 * time.Hour
// ReleasesMaxAge is how long a release group's editions and track lists
// are trusted. They change rarely once a group is out.
ReleasesMaxAge = 30 * 24 * time.Hour
)
// ErrNotFound: MusicBrainz has no artist or release group under that id.
var ErrNotFound = errors.New("catalogue: not in MusicBrainz")
// Service reads and refreshes the catalogue.
type Service struct {
pool *pgxpool.Pool
mb *musicbrainz.Client
logger *slog.Logger
now func() time.Time
}
// New makes a Service. A nil client talks to the public MusicBrainz.
func New(pool *pgxpool.Pool, mb *musicbrainz.Client, logger *slog.Logger) *Service {
if mb == nil {
mb = &musicbrainz.Client{}
}
if logger == nil {
logger = slog.Default()
}
return &Service{pool: pool, mb: mb, logger: logger, now: time.Now}
}
// MusicBrainz is the client the service asks, for callers that search.
func (s *Service) MusicBrainz() *musicbrainz.Client { return s.mb }
// Discography is an artist and the release groups credited to them.
type Discography struct {
Artist dbq.MbArtist
Groups []dbq.MbReleaseGroup
// Stale: MusicBrainz could not be asked, so this is the last copy read.
Stale bool
}
// Discography returns the artist's release groups, reading them from
// MusicBrainz first when they have never been read or are older than
// DiscographyMaxAge. When MusicBrainz is unreachable, a copy already held is
// returned marked Stale; with nothing held, the error is returned.
func (s *Service) Discography(ctx context.Context, artistMBID string) (Discography, error) {
return s.DiscographyWithin(ctx, artistMBID, DiscographyMaxAge)
}
// DiscographyWithin is Discography with the age past which the held copy is
// read again: a followed artist is checked for new releases more often than
// one the catalogue merely knows.
func (s *Service) DiscographyWithin(ctx context.Context, artistMBID string, maxAge time.Duration) (Discography, error) {
q := dbq.New(s.pool)
artist, err := q.GetMBArtist(ctx, artistMBID)
held := err == nil && artist.DiscographyRefreshedAt.Valid
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return Discography{}, fmt.Errorf("catalogue: read artist: %w", err)
}
stale := false
if !held || s.now().Sub(artist.DiscographyRefreshedAt.Time) > maxAge {
if rerr := s.RefreshArtist(ctx, artistMBID); rerr != nil {
if !held || !errors.Is(rerr, musicbrainz.ErrUnreachable) {
return Discography{}, rerr
}
stale = true
}
if artist, err = q.GetMBArtist(ctx, artistMBID); err != nil {
return Discography{}, fmt.Errorf("catalogue: read artist: %w", err)
}
}
groups, err := q.ListMBArtistReleaseGroups(ctx, artistMBID)
if err != nil {
return Discography{}, fmt.Errorf("catalogue: read discography: %w", err)
}
return Discography{Artist: artist, Groups: groups, Stale: stale}, nil
}
// RefreshArtist reads the artist and every release group credited to them,
// and replaces the held discography in one transaction.
func (s *Service) RefreshArtist(ctx context.Context, artistMBID string) error {
artist, err := s.mb.Artist(ctx, artistMBID)
if err != nil {
return mbErr(err)
}
groups, err := s.mb.ArtistReleaseGroups(ctx, artistMBID)
if err != nil {
return mbErr(err)
}
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
q := dbq.New(tx)
if err := q.UpsertMBArtist(ctx, dbq.UpsertMBArtistParams{
Mbid: artist.MBID, Name: artist.Name, SortName: artist.SortName,
Disambiguation: artist.Disambiguation, ArtistType: artist.Type, Country: artist.Country,
}); err != nil {
return fmt.Errorf("catalogue: save artist: %w", err)
}
if err := q.DeleteMBArtistReleaseGroups(ctx, artist.MBID); err != nil {
return fmt.Errorf("catalogue: clear discography: %w", err)
}
for _, g := range groups {
if err := upsertGroup(ctx, q, g); err != nil {
return err
}
if err := q.AddMBArtistReleaseGroup(ctx, dbq.AddMBArtistReleaseGroupParams{
ArtistMbid: artist.MBID, ReleaseGroupMbid: g.MBID,
}); err != nil {
return fmt.Errorf("catalogue: link release group: %w", err)
}
}
return q.MarkMBArtistDiscographyRefreshed(ctx, artist.MBID)
})
}
func upsertGroup(ctx context.Context, q *dbq.Queries, g musicbrainz.ReleaseGroup) error {
secondary := g.SecondaryTypes
if secondary == nil {
secondary = []string{}
}
if err := q.UpsertMBReleaseGroup(ctx, dbq.UpsertMBReleaseGroupParams{
Mbid: g.MBID, Title: g.Title, PrimaryType: g.PrimaryType, SecondaryTypes: secondary,
FirstReleaseDate: g.FirstReleaseDate, ArtistCredit: g.ArtistCredit,
}); err != nil {
return fmt.Errorf("catalogue: save release group: %w", err)
}
return nil
}
// Edition is one release of a group with its track list.
type Edition struct {
Release dbq.MbRelease
Tracks []dbq.MbReleaseTrack
}
// Editions is a release group and its releases.
type Editions struct {
Group dbq.MbReleaseGroup
Editions []Edition
Stale bool
}
// Releases returns a release group's releases with their track lists, read
// from MusicBrainz first when never read or older than ReleasesMaxAge. Like
// Discography, an unreachable MusicBrainz serves the held copy as Stale.
func (s *Service) Releases(ctx context.Context, groupMBID string) (Editions, error) {
q := dbq.New(s.pool)
group, err := q.GetMBReleaseGroup(ctx, groupMBID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return Editions{}, fmt.Errorf("catalogue: read release group: %w", err)
}
held := err == nil && group.ReleasesRefreshedAt.Valid
stale := false
if !held || s.now().Sub(group.ReleasesRefreshedAt.Time) > ReleasesMaxAge {
if rerr := s.RefreshReleaseGroup(ctx, groupMBID); rerr != nil {
if !held || !errors.Is(rerr, musicbrainz.ErrUnreachable) {
return Editions{}, rerr
}
stale = true
}
if group, err = q.GetMBReleaseGroup(ctx, groupMBID); err != nil {
return Editions{}, fmt.Errorf("catalogue: read release group: %w", err)
}
}
releases, err := q.ListMBReleasesForGroup(ctx, groupMBID)
if err != nil {
return Editions{}, fmt.Errorf("catalogue: read releases: %w", err)
}
tracks, err := q.ListMBReleaseTracksForGroup(ctx, groupMBID)
if err != nil {
return Editions{}, fmt.Errorf("catalogue: read track lists: %w", err)
}
byRelease := make(map[string][]dbq.MbReleaseTrack, len(releases))
for _, t := range tracks {
byRelease[t.ReleaseMbid] = append(byRelease[t.ReleaseMbid], t)
}
out := Editions{Group: group, Stale: stale}
for _, r := range releases {
out.Editions = append(out.Editions, Edition{Release: r, Tracks: byRelease[r.Mbid]})
}
return out, nil
}
// RefreshReleaseGroup reads a group's releases and track lists and replaces
// the held copy in one transaction. A group not yet held is looked up first.
func (s *Service) RefreshReleaseGroup(ctx context.Context, groupMBID string) error {
q := dbq.New(s.pool)
var group *musicbrainz.ReleaseGroup
if _, err := q.GetMBReleaseGroup(ctx, groupMBID); errors.Is(err, pgx.ErrNoRows) {
g, gerr := s.mb.ReleaseGroup(ctx, groupMBID)
if gerr != nil {
return mbErr(gerr)
}
group = &g
} else if err != nil {
return fmt.Errorf("catalogue: read release group: %w", err)
}
releases, err := s.mb.ReleaseGroupReleases(ctx, groupMBID)
if err != nil {
return mbErr(err)
}
return pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error {
tq := dbq.New(tx)
if group != nil {
if err := upsertGroup(ctx, tq, *group); err != nil {
return err
}
}
if err := tq.DeleteMBReleasesForGroup(ctx, groupMBID); err != nil {
return fmt.Errorf("catalogue: clear releases: %w", err)
}
for _, r := range releases {
if err := tq.InsertMBRelease(ctx, dbq.InsertMBReleaseParams{
Mbid: r.MBID, ReleaseGroupMbid: groupMBID, Title: r.Title, Status: r.Status,
ReleaseDate: r.Date, Country: r.Country, Disambiguation: r.Disambiguation,
Formats: r.Formats(), TrackCount: int32(r.TrackCount()),
}); err != nil {
return fmt.Errorf("catalogue: save release: %w", err)
}
for _, m := range r.Media {
for _, t := range m.Tracks {
if err := tq.InsertMBReleaseTrack(ctx, dbq.InsertMBReleaseTrackParams{
ReleaseMbid: r.MBID, MediumPosition: int32(m.Position), Position: int32(t.Position),
MediumFormat: m.Format, Number: t.Number, Title: t.Title,
LengthMs: int32(t.LengthMs), RecordingMbid: t.RecordingMBID,
}); err != nil {
return fmt.Errorf("catalogue: save track: %w", err)
}
}
}
}
return tq.MarkMBReleaseGroupReleasesRefreshed(ctx, groupMBID)
})
}
// RefreshDue re-reads up to limit discographies that are older than
// DiscographyMaxAge, never-read first. It stops at the first unreachable
// MusicBrainz, since the rest would fail the same way.
func (s *Service) RefreshDue(ctx context.Context, limit int) (refreshed int, err error) {
due, err := dbq.New(s.pool).ListMBArtistsDueForRefresh(ctx, dbq.ListMBArtistsDueForRefreshParams{
Before: pgtype.Timestamptz{Time: s.now().Add(-DiscographyMaxAge), Valid: true},
MaxRows: int32(limit),
})
if err != nil {
return 0, fmt.Errorf("catalogue: list due artists: %w", err)
}
for _, mbid := range due {
if err := s.RefreshArtist(ctx, mbid); err != nil {
if errors.Is(err, musicbrainz.ErrUnreachable) || ctx.Err() != nil {
return refreshed, err
}
// One artist MusicBrainz cannot describe (merged away, say)
// must not stop the rest.
s.logger.Warn("catalogue: refresh artist", "mbid", mbid, "err", err)
continue
}
refreshed++
}
return refreshed, nil
}
// RefreshInterval is how often Run looks for discographies due a re-read,
// and RefreshBatch how many it reads per look: 20 artists is about a minute
// of MusicBrainz's budget.
const (
RefreshInterval = time.Hour
RefreshBatch = 20
)
// Run refreshes due discographies every RefreshInterval until ctx ends.
func (s *Service) Run(ctx context.Context) {
ticker := time.NewTicker(RefreshInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
}
n, err := s.RefreshDue(ctx, RefreshBatch)
switch {
case err != nil && ctx.Err() == nil:
s.logger.Warn("catalogue: refresh pass stopped", "refreshed", n, "err", err)
case n > 0:
s.logger.Info("catalogue: refresh pass complete", "refreshed", n)
}
}
}
// mbErr maps MusicBrainz's not-found to the catalogue's; anything else passes
// through so callers can tell an unreachable MusicBrainz apart.
func mbErr(err error) error {
if errors.Is(err, musicbrainz.ErrNotFound) {
return fmt.Errorf("%w: %v", ErrNotFound, err)
}
return err
}