e68e1b10a6
player: setQueueFromTracks fast-starts a single source at player-index 0 while the full queue is broadcast, so the transient currentIndexStream→0 emission clobbered the correct mediaItem with queue.value[0] (the first track). Mini bar / playlist marker pinned to the wrong track until a later index event (~the "passive ~30s recovery"). Track a logical-index base so the player→queue mapping stays correct during the fill window; also fixes the latent forward-fill auto-advance off-by-base. lidarr #50: approving no longer fails when Lidarr is down. Approve records the decision durably first, then best-effort adds; the reconciler idempotently (re)sends unconfirmed adds every tick until they stick (new additive lidarr_add_confirmed_at; AddArtist/AddAlbum map Lidarr's "already exists" 400 → ErrAlreadyExists). No failed-state or expiry by design — Lidarr keeps trying, operator monitors. lidarr #51: Create() is now idempotent — a non-terminal request for the same MBID returns the existing row instead of inserting a duplicate. Rewrites the obsolete LidarrUnreachable_503 test to assert the durable- approve contract; threads a client factory into NewReconciler. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
468 lines
14 KiB
Go
468 lines
14 KiB
Go
package lidarr
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Client wraps Lidarr's v1 HTTP API. BaseURL is the Lidarr instance
|
|
// (e.g. http://lidarr.lan:8686), APIKey comes from Lidarr's settings.
|
|
type Client struct {
|
|
BaseURL string
|
|
APIKey string
|
|
HTTP *http.Client
|
|
}
|
|
|
|
// NewClient builds a Client with sensible HTTP defaults (30s timeout,
|
|
// matching the pattern in internal/scrobble/listenbrainz). Call sites
|
|
// that need a custom client can construct the Client struct directly.
|
|
func NewClient(baseURL, apiKey string) *Client {
|
|
return &Client{
|
|
BaseURL: baseURL,
|
|
APIKey: apiKey,
|
|
HTTP: &http.Client{Timeout: 30 * time.Second},
|
|
}
|
|
}
|
|
|
|
// get executes an authenticated GET request and maps HTTP status codes to
|
|
// typed errors. The caller is responsible for closing the returned body.
|
|
func (c *Client) get(ctx context.Context, path string, q url.Values) (*http.Response, error) {
|
|
u, err := url.Parse(c.BaseURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
|
|
}
|
|
u.Path = strings.TrimRight(u.Path, "/") + path
|
|
if q != nil {
|
|
u.RawQuery = q.Encode()
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("X-Api-Key", c.APIKey)
|
|
resp, err := c.HTTP.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
|
|
}
|
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
|
_ = resp.Body.Close()
|
|
return nil, ErrAuthFailed
|
|
}
|
|
if resp.StatusCode >= 500 {
|
|
body := readBodySnippet(resp.Body)
|
|
_ = resp.Body.Close()
|
|
return nil, fmt.Errorf("%w: %d: %s", ErrServerError, resp.StatusCode, body)
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
body := readBodySnippet(resp.Body)
|
|
_ = resp.Body.Close()
|
|
return nil, fmt.Errorf("%w: %d: %s", ErrLookupFailed, resp.StatusCode, body)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// readBodySnippet reads up to 512 bytes from an error response body so the
|
|
// wrapped error message can include Lidarr's actual complaint (e.g.
|
|
// `{"errorMessage":"Artist already exists in your library"}`). Without this,
|
|
// callers see only the status-code bucket and can't diagnose without
|
|
// cross-referencing Lidarr's own logs.
|
|
func readBodySnippet(r io.Reader) string {
|
|
buf, _ := io.ReadAll(io.LimitReader(r, 512))
|
|
return strings.TrimSpace(string(buf))
|
|
}
|
|
|
|
// post is the shared POST helper. body must be marshaled JSON. It maps HTTP
|
|
// status codes to typed errors; the caller is responsible for closing the
|
|
// returned body.
|
|
func (c *Client) post(ctx context.Context, path string, body []byte) (*http.Response, error) {
|
|
return c.bodyRequest(ctx, http.MethodPost, path, body)
|
|
}
|
|
|
|
// put is the shared PUT helper. Body must be marshaled JSON. Mirrors post() —
|
|
// same status-code mapping, same caller-closes-body contract.
|
|
func (c *Client) put(ctx context.Context, path string, body []byte) (*http.Response, error) {
|
|
return c.bodyRequest(ctx, http.MethodPut, path, body)
|
|
}
|
|
|
|
// bodyRequest is the shared core for POST and PUT. Lidarr's monitor toggle
|
|
// is PUT-shaped, so factoring it out keeps the post/put helpers from
|
|
// duplicating the status-code mapping.
|
|
func (c *Client) bodyRequest(ctx context.Context, method, path string, body []byte) (*http.Response, error) {
|
|
u, err := url.Parse(c.BaseURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
|
|
}
|
|
u.Path = strings.TrimRight(u.Path, "/") + path
|
|
req, err := http.NewRequestWithContext(ctx, method, u.String(), bytes.NewReader(body))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("X-Api-Key", c.APIKey)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := c.HTTP.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
|
|
}
|
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
|
_ = resp.Body.Close()
|
|
return nil, ErrAuthFailed
|
|
}
|
|
if resp.StatusCode >= 500 {
|
|
body := readBodySnippet(resp.Body)
|
|
_ = resp.Body.Close()
|
|
return nil, fmt.Errorf("%w: %d: %s", ErrServerError, resp.StatusCode, body)
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
body := readBodySnippet(resp.Body)
|
|
_ = resp.Body.Close()
|
|
return nil, fmt.Errorf("%w: %d: %s", ErrLookupFailed, resp.StatusCode, body)
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// LookupArtist hits GET /api/v1/artist/lookup?term=. Returns normalized
|
|
// LookupResults; Secondary is "{first genre} · {albumCount} albums" with
|
|
// missing parts skipped.
|
|
func (c *Client) LookupArtist(ctx context.Context, term string) ([]LookupResult, error) {
|
|
resp, err := c.get(ctx, "/api/v1/artist/lookup", url.Values{"term": {term}})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
|
|
}
|
|
var raw []struct {
|
|
ForeignArtistID string `json:"foreignArtistId"`
|
|
ArtistName string `json:"artistName"`
|
|
Genres []string `json:"genres"`
|
|
AlbumCount int `json:"albumCount"`
|
|
Images []lidarrImage `json:"images"`
|
|
}
|
|
if err := json.Unmarshal(body, &raw); err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
|
|
}
|
|
out := make([]LookupResult, 0, len(raw))
|
|
for _, r := range raw {
|
|
secondary := ""
|
|
if len(r.Genres) > 0 {
|
|
secondary = r.Genres[0]
|
|
}
|
|
if r.AlbumCount > 0 {
|
|
if secondary != "" {
|
|
secondary += " · "
|
|
}
|
|
secondary += strconv.Itoa(r.AlbumCount) + " albums"
|
|
}
|
|
out = append(out, LookupResult{
|
|
MBID: r.ForeignArtistID,
|
|
Name: r.ArtistName,
|
|
Secondary: secondary,
|
|
ImageURL: pickPosterImage(r.Images),
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// LookupAlbum hits GET /api/v1/album/lookup?term=. Returns normalized
|
|
// LookupResults; Secondary is "{artistName} · {year} · {trackCount} tracks".
|
|
func (c *Client) LookupAlbum(ctx context.Context, term string) ([]LookupResult, error) {
|
|
resp, err := c.get(ctx, "/api/v1/album/lookup", url.Values{"term": {term}})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
|
|
}
|
|
var raw []struct {
|
|
ForeignAlbumID string `json:"foreignAlbumId"`
|
|
ForeignArtistID string `json:"foreignArtistId"`
|
|
Title string `json:"title"`
|
|
ArtistName string `json:"artistName"`
|
|
ReleaseDate string `json:"releaseDate"`
|
|
TrackCount int `json:"trackCount"`
|
|
Images []lidarrImage `json:"images"`
|
|
}
|
|
if err := json.Unmarshal(body, &raw); err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
|
|
}
|
|
out := make([]LookupResult, 0, len(raw))
|
|
for _, r := range raw {
|
|
year := ""
|
|
if len(r.ReleaseDate) >= 4 {
|
|
year = r.ReleaseDate[:4]
|
|
}
|
|
secondary := ""
|
|
if r.ArtistName != "" {
|
|
secondary = r.ArtistName
|
|
}
|
|
if year != "" {
|
|
if secondary != "" {
|
|
secondary += " · "
|
|
}
|
|
secondary += year
|
|
}
|
|
if r.TrackCount > 0 {
|
|
if secondary != "" {
|
|
secondary += " · "
|
|
}
|
|
secondary += strconv.Itoa(r.TrackCount) + " tracks"
|
|
}
|
|
out = append(out, LookupResult{
|
|
MBID: r.ForeignAlbumID,
|
|
ArtistMBID: r.ForeignArtistID,
|
|
Name: r.Title,
|
|
Secondary: secondary,
|
|
ImageURL: pickPosterImage(r.Images),
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// LookupTrack hits GET /api/v1/track/lookup?term=. Lidarr's track lookup is
|
|
// per-album under the hood. Secondary is "{albumTitle} · {artistName}".
|
|
func (c *Client) LookupTrack(ctx context.Context, term string) ([]LookupResult, error) {
|
|
resp, err := c.get(ctx, "/api/v1/track/lookup", url.Values{"term": {term}})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
|
|
}
|
|
var raw []struct {
|
|
ForeignTrackID string `json:"foreignTrackId"`
|
|
ForeignAlbumID string `json:"foreignAlbumId"`
|
|
ForeignArtistID string `json:"foreignArtistId"`
|
|
Title string `json:"title"`
|
|
AlbumTitle string `json:"albumTitle"`
|
|
ArtistName string `json:"artistName"`
|
|
}
|
|
if err := json.Unmarshal(body, &raw); err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
|
|
}
|
|
out := make([]LookupResult, 0, len(raw))
|
|
for _, r := range raw {
|
|
secondary := r.AlbumTitle
|
|
if r.ArtistName != "" {
|
|
if secondary != "" {
|
|
secondary += " · "
|
|
}
|
|
secondary += r.ArtistName
|
|
}
|
|
out = append(out, LookupResult{
|
|
MBID: r.ForeignTrackID,
|
|
ArtistMBID: r.ForeignArtistID,
|
|
AlbumMBID: r.ForeignAlbumID,
|
|
Name: r.Title,
|
|
Secondary: secondary,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// AddArtist posts to POST /api/v1/artist. MonitorAll=true sends monitor="all";
|
|
// false sends "future". Returns nil on 2xx; typed error otherwise.
|
|
//
|
|
// All four of artistName, foreignArtistId, qualityProfileId, and
|
|
// metadataProfileId are required by Lidarr. Omitting any one produces a
|
|
// 400 with field-level validation messages (e.g. "'Metadata Profile Id'
|
|
// must be greater than '0'").
|
|
func (c *Client) AddArtist(ctx context.Context, p AddArtistParams) error {
|
|
monitor := "future"
|
|
if p.MonitorAll {
|
|
monitor = "all"
|
|
}
|
|
body, err := json.Marshal(map[string]any{
|
|
"foreignArtistId": p.ForeignArtistID,
|
|
"artistName": p.ArtistName,
|
|
"qualityProfileId": p.QualityProfileID,
|
|
"metadataProfileId": p.MetadataProfileID,
|
|
"rootFolderPath": p.RootFolderPath,
|
|
"monitored": true,
|
|
"monitor": monitor,
|
|
"addOptions": map[string]any{"searchForMissingAlbums": true},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("%w: marshal: %v", ErrInvalidPayload, err)
|
|
}
|
|
resp, err := c.post(ctx, "/api/v1/artist", body)
|
|
if err != nil {
|
|
if isAlreadyExists(err) {
|
|
return ErrAlreadyExists
|
|
}
|
|
return err
|
|
}
|
|
_ = resp.Body.Close()
|
|
return nil
|
|
}
|
|
|
|
// isAlreadyExists detects Lidarr's 400 "already in your library" rejection
|
|
// from an add endpoint. The post() helper buckets it as ErrLookupFailed
|
|
// but embeds Lidarr's errorMessage body snippet; matching the phrase lets
|
|
// the idempotent retry path treat a re-add as success instead of error.
|
|
func isAlreadyExists(err error) bool {
|
|
if !errors.Is(err, ErrLookupFailed) {
|
|
return false
|
|
}
|
|
m := strings.ToLower(err.Error())
|
|
return strings.Contains(m, "already exists") ||
|
|
strings.Contains(m, "already been added") ||
|
|
strings.Contains(m, "already in your library")
|
|
}
|
|
|
|
// AddAlbum posts to POST /api/v1/album. Returns nil on 2xx; typed error
|
|
// otherwise.
|
|
func (c *Client) AddAlbum(ctx context.Context, p AddAlbumParams) error {
|
|
body, err := json.Marshal(map[string]any{
|
|
"foreignAlbumId": p.ForeignAlbumID,
|
|
"foreignArtistId": p.ForeignArtistID,
|
|
"artistName": p.ArtistName,
|
|
"qualityProfileId": p.QualityProfileID,
|
|
"metadataProfileId": p.MetadataProfileID,
|
|
"rootFolderPath": p.RootFolderPath,
|
|
"monitored": true,
|
|
"addOptions": map[string]any{"searchForNewAlbum": true},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("%w: marshal: %v", ErrInvalidPayload, err)
|
|
}
|
|
resp, err := c.post(ctx, "/api/v1/album", body)
|
|
if err != nil {
|
|
if isAlreadyExists(err) {
|
|
return ErrAlreadyExists
|
|
}
|
|
return err
|
|
}
|
|
_ = resp.Body.Close()
|
|
return nil
|
|
}
|
|
|
|
// ListMetadataProfiles hits GET /api/v1/metadataprofile and returns the
|
|
// list of metadata profiles configured in Lidarr. The Approve flow uses
|
|
// the first profile as a default when the operator hasn't pinned one.
|
|
func (c *Client) ListMetadataProfiles(ctx context.Context) ([]MetadataProfile, error) {
|
|
resp, err := c.get(ctx, "/api/v1/metadataprofile", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
|
|
}
|
|
var raw []struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.Unmarshal(body, &raw); err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
|
|
}
|
|
out := make([]MetadataProfile, len(raw))
|
|
for i, r := range raw {
|
|
out[i] = MetadataProfile{ID: r.ID, Name: r.Name}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ListQualityProfiles hits GET /api/v1/qualityprofile and returns the full
|
|
// list of quality profiles configured in Lidarr.
|
|
func (c *Client) ListQualityProfiles(ctx context.Context) ([]QualityProfile, error) {
|
|
resp, err := c.get(ctx, "/api/v1/qualityprofile", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
|
|
}
|
|
var raw []struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
if err := json.Unmarshal(body, &raw); err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
|
|
}
|
|
out := make([]QualityProfile, len(raw))
|
|
for i, r := range raw {
|
|
out[i] = QualityProfile{ID: r.ID, Name: r.Name}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ListRootFolders hits GET /api/v1/rootfolder and returns the list of root
|
|
// folders configured in Lidarr.
|
|
func (c *Client) ListRootFolders(ctx context.Context) ([]RootFolder, error) {
|
|
resp, err := c.get(ctx, "/api/v1/rootfolder", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
|
|
}
|
|
var raw []struct {
|
|
Path string `json:"path"`
|
|
Accessible bool `json:"accessible"`
|
|
FreeSpace int64 `json:"freeSpace"`
|
|
}
|
|
if err := json.Unmarshal(body, &raw); err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
|
|
}
|
|
out := make([]RootFolder, len(raw))
|
|
for i, r := range raw {
|
|
out[i] = RootFolder{Path: r.Path, Accessible: r.Accessible, FreeSpace: r.FreeSpace}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Ping hits GET /api/v1/system/status to verify connectivity and returns
|
|
// the Lidarr version string.
|
|
func (c *Client) Ping(ctx context.Context) (PingResult, error) {
|
|
resp, err := c.get(ctx, "/api/v1/system/status", nil)
|
|
if err != nil {
|
|
return PingResult{}, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return PingResult{}, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
|
|
}
|
|
var raw struct {
|
|
Version string `json:"version"`
|
|
}
|
|
if err := json.Unmarshal(body, &raw); err != nil {
|
|
return PingResult{}, fmt.Errorf("%w: %v", ErrInvalidPayload, err)
|
|
}
|
|
return PingResult{Version: raw.Version}, nil
|
|
}
|
|
|
|
// pickPosterImage returns the remote URL (preferred) or local URL for the
|
|
// first image with coverType=="poster". Returns "" when none match.
|
|
func pickPosterImage(imgs []lidarrImage) string {
|
|
for _, img := range imgs {
|
|
if img.CoverType == "poster" {
|
|
if img.RemoteURL != "" {
|
|
return img.RemoteURL
|
|
}
|
|
return img.URL
|
|
}
|
|
}
|
|
return ""
|
|
}
|