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) }