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
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>
285 lines
8.8 KiB
Go
285 lines
8.8 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/catalogue"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/musicbrainz"
|
|
)
|
|
|
|
// catalogueSearchLimit is how many results one search shows.
|
|
const catalogueSearchLimit = 25
|
|
|
|
// catalogueSearchResult is one row of GET /api/catalogue/search. The shape is
|
|
// the one the Lidarr search answered with, so the Discover page and request
|
|
// rows read it unchanged. artist_mbid and album_mbid are the parents a
|
|
// request needs: empty on an artist, the artist on an album, both on a track.
|
|
// An album is a MusicBrainz release group, as Lidarr's albums were.
|
|
type catalogueSearchResult struct {
|
|
MBID string `json:"mbid"`
|
|
Name string `json:"name"`
|
|
SecondaryText string `json:"secondary_text"`
|
|
ImageURL string `json:"image_url"`
|
|
ArtistMBID string `json:"artist_mbid"`
|
|
AlbumMBID string `json:"album_mbid"`
|
|
InLibrary bool `json:"in_library"`
|
|
Requested bool `json:"requested"`
|
|
}
|
|
|
|
// handleCatalogueSearch implements GET /api/catalogue/search?q=&kind=artist|album|track
|
|
// (M502 #5519), asking MusicBrainz directly. An unreachable MusicBrainz is a
|
|
// 503 the page shows as such (rule 164).
|
|
func (h *handlers) handleCatalogueSearch(w http.ResponseWriter, r *http.Request) {
|
|
kind := strings.TrimSpace(r.URL.Query().Get("kind"))
|
|
switch kind {
|
|
case "artist", "album", "track":
|
|
default:
|
|
writeErr(w, apierror.BadRequest("bad_kind", "kind must be artist, album, or track"))
|
|
return
|
|
}
|
|
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
|
if q == "" {
|
|
writeErr(w, apierror.BadRequest("missing_query", "q is required"))
|
|
return
|
|
}
|
|
|
|
out, err := h.catalogueSearches.get(r.Context(), kind, q, h.searchCatalogue)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, musicbrainz.ErrUnreachable), errors.Is(err, musicbrainz.ErrBadResponse):
|
|
writeErr(w, &apierror.Error{Status: 503, Code: "musicbrainz_unreachable", Message: "MusicBrainz can't be reached", Cause: err})
|
|
case r.Context().Err() != nil:
|
|
return
|
|
default:
|
|
h.logger.Error("api: catalogue search", "kind", kind, "err", err)
|
|
writeErr(w, apierror.InternalMsg("search failed", err))
|
|
}
|
|
return
|
|
}
|
|
|
|
dbQ := dbq.New(h.pool)
|
|
for i := range out {
|
|
inLib, err := h.catalogueInLibrary(r.Context(), kind, out[i].MBID)
|
|
if err != nil {
|
|
writeErr(w, apierror.InternalMsg("library check failed", err))
|
|
return
|
|
}
|
|
requested, err := dbQ.HasNonTerminalRequestForMBID(r.Context(), out[i].MBID)
|
|
if err != nil {
|
|
writeErr(w, apierror.InternalMsg("request check failed", err))
|
|
return
|
|
}
|
|
out[i].InLibrary, out[i].Requested = inLib, requested
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (h *handlers) searchCatalogue(ctx context.Context, kind, q string) ([]catalogueSearchResult, error) {
|
|
mb := h.catalogue.MusicBrainz()
|
|
out := []catalogueSearchResult{}
|
|
switch kind {
|
|
case "artist":
|
|
artists, err := mb.SearchArtists(ctx, q, catalogueSearchLimit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, a := range artists {
|
|
out = append(out, catalogueSearchResult{
|
|
MBID: a.MBID, Name: a.Name, SecondaryText: artistLine(a),
|
|
ImageURL: artistArtURL(a.MBID, a.Name),
|
|
})
|
|
}
|
|
case "album":
|
|
groups, err := mb.SearchReleaseGroups(ctx, q, catalogueSearchLimit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, g := range groups {
|
|
out = append(out, catalogueSearchResult{
|
|
MBID: g.MBID, Name: g.Title, SecondaryText: joinNonEmpty(g.ArtistCredit, g.Year(), groupType(g)),
|
|
ImageURL: groupArtURL(g), ArtistMBID: first(g.ArtistMBIDs),
|
|
})
|
|
}
|
|
case "track":
|
|
recs, err := mb.SearchRecordings(ctx, q, catalogueSearchLimit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, rec := range recs {
|
|
res := catalogueSearchResult{
|
|
MBID: rec.MBID, Name: rec.Title,
|
|
SecondaryText: joinNonEmpty(rec.ReleaseGroup.Title, rec.ArtistCredit),
|
|
ArtistMBID: first(rec.ArtistMBIDs), AlbumMBID: rec.ReleaseGroup.MBID,
|
|
}
|
|
if rec.ReleaseGroup.MBID != "" {
|
|
res.ImageURL = groupArtURL(rec.ReleaseGroup)
|
|
}
|
|
out = append(out, res)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// catalogueSearchTTL is how long a search's MusicBrainz answer is reused.
|
|
// MusicBrainz allows a request a second, and Discover searches as the user
|
|
// types, so the same words are often asked again within moments.
|
|
const catalogueSearchTTL = 10 * time.Minute
|
|
|
|
// catalogueSearchCacheMax bounds the cache; past it, the cache starts over.
|
|
const catalogueSearchCacheMax = 500
|
|
|
|
// searchCache keeps recent MusicBrainz answers by kind and query. A nil
|
|
// cache asks every time. Whether a result is held or requested is not
|
|
// cached: the handler works that out on every request.
|
|
type searchCache struct {
|
|
mu sync.Mutex
|
|
entries map[string]searchEntry
|
|
now func() time.Time
|
|
}
|
|
|
|
type searchEntry struct {
|
|
at time.Time
|
|
results []catalogueSearchResult
|
|
}
|
|
|
|
func newSearchCache() *searchCache {
|
|
return &searchCache{entries: map[string]searchEntry{}, now: time.Now}
|
|
}
|
|
|
|
func (c *searchCache) get(ctx context.Context, kind, q string,
|
|
ask func(context.Context, string, string) ([]catalogueSearchResult, error),
|
|
) ([]catalogueSearchResult, error) {
|
|
if c == nil {
|
|
return ask(ctx, kind, q)
|
|
}
|
|
key := kind + "\x00" + strings.ToLower(q)
|
|
c.mu.Lock()
|
|
e, ok := c.entries[key]
|
|
c.mu.Unlock()
|
|
if ok && c.now().Sub(e.at) < catalogueSearchTTL {
|
|
return slices.Clone(e.results), nil
|
|
}
|
|
results, err := ask(ctx, kind, q)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c.mu.Lock()
|
|
if len(c.entries) >= catalogueSearchCacheMax {
|
|
c.entries = map[string]searchEntry{}
|
|
}
|
|
c.entries[key] = searchEntry{at: c.now(), results: slices.Clone(results)}
|
|
c.mu.Unlock()
|
|
return results, nil
|
|
}
|
|
|
|
// artistLine is what tells two artists of one name apart: MusicBrainz's own
|
|
// disambiguation when it has one, else the kind of artist and the country.
|
|
func artistLine(a musicbrainz.Artist) string {
|
|
if a.Disambiguation != "" {
|
|
return a.Disambiguation
|
|
}
|
|
return joinNonEmpty(a.Type, a.Country)
|
|
}
|
|
|
|
// groupType names an album's kind the way people do: "Album", "EP",
|
|
// "Album · Live", "Single · Remix".
|
|
func groupType(g musicbrainz.ReleaseGroup) string {
|
|
return joinNonEmpty(append([]string{g.PrimaryType}, g.SecondaryTypes...)...)
|
|
}
|
|
|
|
func joinNonEmpty(parts ...string) string {
|
|
kept := parts[:0:0]
|
|
for _, p := range parts {
|
|
if p != "" {
|
|
kept = append(kept, p)
|
|
}
|
|
}
|
|
return strings.Join(kept, " · ")
|
|
}
|
|
|
|
func first(s []string) string {
|
|
if len(s) == 0 {
|
|
return ""
|
|
}
|
|
return s[0]
|
|
}
|
|
|
|
// artistArtURL and groupArtURL point at Minstrel's own art endpoints. The
|
|
// names ride along for the sources that look art up by name.
|
|
func artistArtURL(mbid, name string) string {
|
|
if mbid == "" {
|
|
return ""
|
|
}
|
|
return "/api/catalogue/art/artist/" + mbid + "?" + url.Values{"name": {name}}.Encode()
|
|
}
|
|
|
|
func groupArtURL(g musicbrainz.ReleaseGroup) string {
|
|
return "/api/catalogue/art/release-group/" + g.MBID + "?" +
|
|
url.Values{"artist": {g.ArtistCredit}, "title": {g.Title}}.Encode()
|
|
}
|
|
|
|
// catalogueInLibrary reports whether a result is already held: an artist by
|
|
// its MBID, an album by its release group (or a release id that happens to be
|
|
// the same), a track by its recording.
|
|
func (h *handlers) catalogueInLibrary(ctx context.Context, kind, mbid string) (bool, error) {
|
|
var query string
|
|
switch kind {
|
|
case "artist":
|
|
query = `SELECT EXISTS(SELECT 1 FROM artists WHERE mbid = $1)`
|
|
case "album":
|
|
query = `SELECT EXISTS(SELECT 1 FROM albums WHERE release_group_mbid = $1 OR mbid = $1)`
|
|
case "track":
|
|
query = `SELECT EXISTS(SELECT 1 FROM tracks WHERE mbid = $1)`
|
|
default:
|
|
return false, nil
|
|
}
|
|
var exists bool
|
|
err := h.pool.QueryRow(ctx, query, mbid).Scan(&exists)
|
|
return exists, err
|
|
}
|
|
|
|
// handleCatalogueArt implements GET /api/catalogue/art/{kind}/{mbid} for an
|
|
// artist or a release group Minstrel doesn't hold. The image comes from the
|
|
// operator's enabled cover-art sources and is kept on disk, so the browser
|
|
// never fetches a third-party host. A 404 shows the placeholder.
|
|
func (h *handlers) handleCatalogueArt(w http.ResponseWriter, r *http.Request) {
|
|
kind, mbid := chi.URLParam(r, "kind"), chi.URLParam(r, "mbid")
|
|
if !catalogue.ValidMBID(mbid) {
|
|
writeErr(w, apierror.NotFound("art"))
|
|
return
|
|
}
|
|
var (
|
|
body []byte
|
|
err error
|
|
)
|
|
switch kind {
|
|
case "artist":
|
|
body, err = h.catalogueArt.Artist(r.Context(), mbid, r.URL.Query().Get("name"))
|
|
case "release-group":
|
|
body, err = h.catalogueArt.ReleaseGroup(r.Context(), mbid, r.URL.Query().Get("artist"), r.URL.Query().Get("title"))
|
|
default:
|
|
writeErr(w, apierror.NotFound("art"))
|
|
return
|
|
}
|
|
if err != nil {
|
|
if r.Context().Err() == nil {
|
|
writeErr(w, apierror.NotFound("art"))
|
|
}
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", http.DetectContentType(body))
|
|
w.Header().Set("Cache-Control", "private, max-age=86400")
|
|
_, _ = w.Write(body)
|
|
}
|