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>
262 lines
8.7 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|