feat(api): add /api/lidarr/search proxy with library/request enrichment
Implements GET /api/lidarr/search?q=&kind=artist|album|track. Validates kind and q, loads lidarr_config per-request, calls the matching Lidarr Lookup* method, enriches each result with in_library (EXISTS by MBID against artists/albums/tracks) and requested (HasNonTerminalRequestForMBID), and returns a normalized JSON array. Adds lidarrCfg field to handlers struct and threads lidarrconfig.Service through Mount and server.Router. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||||
)
|
||||
|
||||
// lidarrSearchResult is the JSON shape returned by GET /api/lidarr/search.
|
||||
// Field names follow the lowercase snake_case convention used by all /api/*
|
||||
// responses. artist_mbid and album_mbid are the parent identifiers surfaced
|
||||
// by LookupAlbum / LookupTrack; they are empty strings on artist results.
|
||||
type lidarrSearchResult 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"`
|
||||
}
|
||||
|
||||
// handleLidarrSearch implements GET /api/lidarr/search?q=&kind=artist|album|track.
|
||||
//
|
||||
// Steps:
|
||||
// 1. Validate kind and q params.
|
||||
// 2. Load lidarr config; 503 when disabled.
|
||||
// 3. Call the matching Lidarr lookup.
|
||||
// 4. For each result, compute in_library (EXISTS by MBID) and requested
|
||||
// (HasNonTerminalRequestForMBID).
|
||||
// 5. Return normalized JSON array.
|
||||
func (h *handlers) handleLidarrSearch(w http.ResponseWriter, r *http.Request) {
|
||||
kind := strings.TrimSpace(r.URL.Query().Get("kind"))
|
||||
switch kind {
|
||||
case "artist", "album", "track":
|
||||
// valid
|
||||
default:
|
||||
writeErr(w, http.StatusBadRequest, "bad_kind", "kind must be artist, album, or track")
|
||||
return
|
||||
}
|
||||
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
if q == "" {
|
||||
writeErr(w, http.StatusBadRequest, "missing_query", "q is required")
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.lidarrCfg.Get(r.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("api: lidarr search: load config", "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "lidarr_lookup_failed", "failed to load lidarr config")
|
||||
return
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
writeErr(w, http.StatusServiceUnavailable, "lidarr_disabled", "Lidarr integration is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
client := lidarr.NewClient(cfg.BaseURL, cfg.APIKey)
|
||||
|
||||
var results []lidarr.LookupResult
|
||||
switch kind {
|
||||
case "artist":
|
||||
results, err = client.LookupArtist(r.Context(), q)
|
||||
case "album":
|
||||
results, err = client.LookupAlbum(r.Context(), q)
|
||||
case "track":
|
||||
results, err = client.LookupTrack(r.Context(), q)
|
||||
}
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, lidarr.ErrUnreachable):
|
||||
writeErr(w, http.StatusServiceUnavailable, "lidarr_unreachable", "Lidarr is unreachable")
|
||||
case errors.Is(err, lidarr.ErrAuthFailed):
|
||||
writeErr(w, http.StatusServiceUnavailable, "lidarr_auth_failed", "Lidarr authentication failed")
|
||||
default:
|
||||
h.logger.Error("api: lidarr search: lookup failed", "kind", kind, "err", err)
|
||||
writeErr(w, http.StatusInternalServerError, "lidarr_lookup_failed", "Lidarr lookup failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
dbQ := dbq.New(h.pool)
|
||||
out := make([]lidarrSearchResult, 0, len(results))
|
||||
for _, res := range results {
|
||||
inLib, libErr := h.lidarrInLibrary(r.Context(), kind, res.MBID)
|
||||
if libErr != nil {
|
||||
h.logger.Error("api: lidarr search: in_library check failed", "err", libErr)
|
||||
writeErr(w, http.StatusInternalServerError, "lidarr_lookup_failed", "library check failed")
|
||||
return
|
||||
}
|
||||
requested, reqErr := dbQ.HasNonTerminalRequestForMBID(r.Context(), res.MBID)
|
||||
if reqErr != nil {
|
||||
h.logger.Error("api: lidarr search: requested check failed", "err", reqErr)
|
||||
writeErr(w, http.StatusInternalServerError, "lidarr_lookup_failed", "request check failed")
|
||||
return
|
||||
}
|
||||
out = append(out, lidarrSearchResult{
|
||||
MBID: res.MBID,
|
||||
Name: res.Name,
|
||||
SecondaryText: res.Secondary,
|
||||
ImageURL: res.ImageURL,
|
||||
ArtistMBID: res.ArtistMBID,
|
||||
AlbumMBID: res.AlbumMBID,
|
||||
InLibrary: inLib,
|
||||
Requested: requested,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// lidarrInLibrary checks whether the given MBID exists in the corresponding
|
||||
// local library table based on kind. Runs a direct EXISTS query since
|
||||
// these per-kind MBID lookups are not in the sqlc-generated query set.
|
||||
func (h *handlers) lidarrInLibrary(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 mbid = $1)`
|
||||
case "track":
|
||||
query = `SELECT EXISTS(SELECT 1 FROM tracks WHERE mbid = $1)`
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
var exists bool
|
||||
if err := h.pool.QueryRow(ctx, query, mbid).Scan(&exists); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user