release / govulncheck (push) Successful in 15s
release / web (push) Failing after 17s
release / go (push) Successful in 1m13s
release / integration (push) Successful in 4m56s
release / android (push) Successful in 5m9s
release / Build signed APK (releases and dev) (push) Successful in 5m19s
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
Migration 0091 drops lidarr_config and renames the quarantine tables, types, indexes and the history column off the Lidarr name. The Integrations Lidarr card, the Reports "Delete via Lidarr" action, the merge and remove-track unmonitor options, the Lidarr audit actions and error codes go, on the server, web and Android. The README describes acquisition as it is now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
284 lines
8.7 KiB
Go
284 lines
8.7 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, read by the
|
|
// Discover page and request rows. 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.
|
|
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)
|
|
}
|