Files
minstrel/internal/api/suggestions.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

262 lines
8.7 KiB
Go

package api
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
)
// suggestionView is the wire shape returned by GET /api/discover/suggestions.
type suggestionView struct {
MBID string `json:"mbid"`
Name string `json:"name"`
Score float64 `json:"score"`
Attribution []seedContributionView `json:"attribution"`
// MatchedTags are the candidate's tags that overlap the user's taste
// profile, strongest first (#2377) — the "matches: shoegaze, melancholic"
// line. Omitted when empty, which is common: tag coverage for
// out-of-library artists is permanently partial (#2376), and the card
// falls back to the seed attribution it has always shown.
MatchedTags []string `json:"matched_tags,omitempty"`
// ImageURL is Minstrel's catalogue art endpoint for the candidate
// (out-of-library artists have no local art row, M502 #5519). It
// answers 404 when no enabled source has an image, and the client
// falls back to a placeholder.
ImageURL string `json:"image_url,omitempty"`
}
type seedContributionView struct {
ArtistID pgtype.UUID `json:"artist_id"`
Name string `json:"name"`
Contribution float64 `json:"contribution"`
IsLiked bool `json:"is_liked"`
PlayCount int64 `json:"play_count"`
}
// handleListSuggestions implements GET /api/discover/suggestions.
//
// Query params:
// - limit (default 12, capped at 50)
// - half_life_days (default 30)
//
// Returns 200 with a JSON array (possibly empty). Read-only; no admin gate.
func (h *handlers) handleListSuggestions(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
limit := 12
if v := r.URL.Query().Get("limit"); v != "" {
n, err := strconv.Atoi(v)
if err != nil || n < 1 {
writeErr(w, apierror.BadRequest("bad_request", "invalid limit"))
return
}
limit = n
}
halfLife := 30.0
if v := r.URL.Query().Get("half_life_days"); v != "" {
f, err := strconv.ParseFloat(v, 64)
if err != nil || f <= 0 {
writeErr(w, apierror.BadRequest("bad_request", "invalid half_life_days"))
return
}
halfLife = f
}
// Read the tuned weight per request so an admin change takes effect on the
// next refresh, no restart (rule #25).
tagWeight := h.recSettings.Discover().TagOverlapWeight
suggestions, err := recommendation.SuggestArtists(
r.Context(), h.pool, user.ID, halfLife, limit, tagWeight)
if err != nil {
h.logger.Error("api: list suggestions", "err", err)
writeErr(w, apierror.InternalMsg("failed to load suggestions", err))
return
}
out := make([]suggestionView, 0, len(suggestions))
for _, s := range suggestions {
attr := make([]seedContributionView, 0, len(s.Attribution))
for _, a := range s.Attribution {
attr = append(attr, seedContributionView{
ArtistID: a.ArtistID,
Name: a.Name,
Contribution: a.Contribution,
IsLiked: a.IsLiked,
PlayCount: a.PlayCount,
})
}
out = append(out, suggestionView{
MBID: s.MBID, Name: s.Name, Score: s.Score, Attribution: attr,
MatchedTags: s.MatchedTags,
})
}
resolveSuggestionArt(out)
writeJSON(w, http.StatusOK, out)
}
// maxSnoozeDays caps a client-supplied duration. The DEFAULT is not here: it's
// a DB-backed knob on the admin tuning card (rule #25), read per request via
// recSettings.Discover().SnoozeDays. See #2377.
const maxSnoozeDays = 365.0
// snoozeRequest is the POST body. Both fields are optional in the JSON sense
// (an absent body snoozes for the default), but Name is required in practice:
// candidates are out-of-library, so the server has no artists row to resolve a
// display name from and the un-snooze list would have nothing to show. The
// client always has it — it just rendered the card.
type snoozeRequest struct {
Name string `json:"name"`
Days float64 `json:"days"`
}
// snoozeView is one row of GET /api/discover/snoozes.
type snoozeView struct {
MBID string `json:"mbid"`
Name string `json:"name"`
SnoozedUntil pgtype.Timestamptz `json:"snoozed_until"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
// handleSnoozeSuggestion implements
// POST /api/discover/suggestions/{mbid}/snooze.
//
// Parks a candidate for `days` (default 90, capped at 365). Idempotent:
// snoozing an already-snoozed candidate extends it rather than conflicting.
//
// This is NOT negative feedback. It records no verdict on the artist and is
// never read by internal/taste — see 0049_suggestion_snoozes.up.sql for the
// rule #101 reasoning. Returns 204.
func (h *handlers) handleSnoozeSuggestion(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
mbid := strings.TrimSpace(chi.URLParam(r, "mbid"))
if mbid == "" {
writeErr(w, apierror.BadRequest("invalid_id", "missing mbid"))
return
}
// An empty body is a valid "snooze this for the default period", so EOF
// is not an error here — decodeBody would reject it as a malformed body.
var body snoozeRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil && !errors.Is(err, io.EOF) {
writeErr(w, apierror.BadRequest("invalid_body", ""))
return
}
name := strings.TrimSpace(body.Name)
if name == "" {
writeErr(w, apierror.BadRequest("invalid_body", "name is required"))
return
}
days := body.Days
if days <= 0 {
days = h.recSettings.Discover().SnoozeDays
}
if days > maxSnoozeDays {
// Clamp rather than reject: a client asking for longer than we allow
// still means "park this", and failing the write would leave the card
// sitting there as if the tap did nothing.
days = maxSnoozeDays
}
q := dbq.New(h.pool)
if err := q.SnoozeSuggestion(r.Context(), dbq.SnoozeSuggestionParams{
UserID: user.ID,
CandidateMbid: mbid,
CandidateName: name,
Column4: days,
}); err != nil {
h.logger.Error("api: snooze suggestion", "err", err)
writeErr(w, apierror.InternalMsg("failed to snooze suggestion", err))
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleUnsnoozeSuggestion implements
// DELETE /api/discover/suggestions/{mbid}/snooze.
//
// Brings a parked candidate back immediately. 404s an MBID this user never
// snoozed, so the client can tell "undone" from "there was nothing there".
func (h *handlers) handleUnsnoozeSuggestion(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
mbid := strings.TrimSpace(chi.URLParam(r, "mbid"))
if mbid == "" {
writeErr(w, apierror.BadRequest("invalid_id", "missing mbid"))
return
}
q := dbq.New(h.pool)
rows, err := q.UnsnoozeSuggestion(r.Context(), dbq.UnsnoozeSuggestionParams{
UserID: user.ID,
CandidateMbid: mbid,
})
if err != nil {
h.logger.Error("api: unsnooze suggestion", "err", err)
writeErr(w, apierror.InternalMsg("failed to unsnooze suggestion", err))
return
}
if rows == 0 {
writeErr(w, apierror.NotFound("snooze"))
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleListSuggestionSnoozes implements GET /api/discover/snoozes.
//
// The un-snooze surface needs this: a parked candidate is by definition
// absent from the suggestion deck, so without a list there is no way to
// reach the DELETE above. Scoped to the caller (rule #47). Expired rows are
// already filtered by the query — the hourly gc sweep only reclaims space.
func (h *handlers) handleListSuggestionSnoozes(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
rows, err := dbq.New(h.pool).ListActiveSuggestionSnoozes(r.Context(), user.ID)
if err != nil {
h.logger.Error("api: list suggestion snoozes", "err", err)
writeErr(w, apierror.InternalMsg("failed to load snoozes", err))
return
}
out := make([]snoozeView, 0, len(rows))
for _, row := range rows {
out = append(out, snoozeView{
MBID: row.CandidateMbid,
Name: row.CandidateName,
SnoozedUntil: row.SnoozedUntil,
CreatedAt: row.CreatedAt,
})
}
writeJSON(w, http.StatusOK, out)
}
// resolveSuggestionArt points each suggestion's ImageURL at Minstrel's
// catalogue art endpoint (M502 #5519). Nothing is fetched here: the image is
// looked up from the enabled cover-art sources when the browser asks for it,
// and kept, so listing suggestions makes no outbound call. A candidate with
// no MBID keeps an empty ImageURL and the client renders its placeholder.
func resolveSuggestionArt(views []suggestionView) {
for i := range views {
if views[i].MBID != "" {
views[i].ImageURL = artistArtURL(views[i].MBID, views[i].Name)
}
}
}