feat: data lifecycle rewrite, download clients, wanted list, and central catalog index
Ships the fresh-start schema cleanup: rebuilt explore catalog index pipeline (dump import, artifact fetch/build, incremental listen-count refresh), a new download subsystem (Lidarr/Prowlarr/qBittorrent/SABnzbd/ slskd/yt-dlp providers, staging, reconciliation, wanted list), and the supporting schema/query/store changes across backend and frontend. Also includes two smaller follow-ups: bump the central index's rebuild-after cadence from 90 to 180 days, and remove the Explore "library only" online/offline toggle entirely (frontend-only, no backend counterpart) rather than carry unused UI/state. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Every remote provider here talks to a self-hosted service over the
|
||||
// same shape of API: a base URL, one header carrying an API key, JSON
|
||||
// in and out, and a handful of status codes that mean the same thing
|
||||
// everywhere. This is that, once.
|
||||
//
|
||||
// Each adapter supplies its own sentinel errors so callers can still
|
||||
// distinguish "slskd is down" from "Prowlarr is down" with errors.Is.
|
||||
|
||||
// apiClient is a small JSON-over-HTTP client for a self-hosted service.
|
||||
type apiClient struct {
|
||||
http *http.Client
|
||||
|
||||
baseURL string
|
||||
authKey string
|
||||
authValue string
|
||||
|
||||
// errUnreachable and errAuth are the adapter's sentinels, returned
|
||||
// for transport failures and rejected credentials respectively.
|
||||
errUnreachable error
|
||||
errAuth error
|
||||
}
|
||||
|
||||
// newAPIClient builds a client for one service.
|
||||
func newAPIClient(
|
||||
baseURL, authHeader, authValue string,
|
||||
timeout time.Duration,
|
||||
errUnreachable, errAuth error,
|
||||
) *apiClient {
|
||||
return &apiClient{
|
||||
http: &http.Client{Timeout: timeout},
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
authKey: authHeader,
|
||||
authValue: authValue,
|
||||
errUnreachable: errUnreachable,
|
||||
errAuth: errAuth,
|
||||
}
|
||||
}
|
||||
|
||||
// get performs a GET, decoding the response into out when non-nil.
|
||||
func (c *apiClient) get(ctx context.Context, endpoint string, out any) error {
|
||||
return c.do(ctx, http.MethodGet, endpoint, nil, out)
|
||||
}
|
||||
|
||||
// post performs a POST with a JSON body.
|
||||
func (c *apiClient) post(
|
||||
ctx context.Context,
|
||||
endpoint string,
|
||||
body, out any,
|
||||
) error {
|
||||
return c.do(ctx, http.MethodPost, endpoint, body, out)
|
||||
}
|
||||
|
||||
// put performs a PUT with a JSON body.
|
||||
func (c *apiClient) put(
|
||||
ctx context.Context,
|
||||
endpoint string,
|
||||
body, out any,
|
||||
) error {
|
||||
return c.do(ctx, http.MethodPut, endpoint, body, out)
|
||||
}
|
||||
|
||||
// delete performs a DELETE.
|
||||
func (c *apiClient) delete(ctx context.Context, endpoint string) error {
|
||||
return c.do(ctx, http.MethodDelete, endpoint, nil, nil)
|
||||
}
|
||||
|
||||
// do performs one request.
|
||||
func (c *apiClient) do(
|
||||
ctx context.Context,
|
||||
method, endpoint string,
|
||||
body, out any,
|
||||
) error {
|
||||
var reader io.Reader
|
||||
|
||||
if body != nil {
|
||||
encoded, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode request body: %w", err)
|
||||
}
|
||||
|
||||
reader = bytes.NewReader(encoded)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx, method, c.baseURL+endpoint, reader,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build request: %w", err)
|
||||
}
|
||||
|
||||
if c.authKey != "" {
|
||||
req.Header.Set(c.authKey, c.authValue)
|
||||
}
|
||||
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", c.errUnreachable, err)
|
||||
}
|
||||
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if err := c.checkStatus(resp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
|
||||
return fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkStatus maps HTTP status onto the adapter's sentinel errors,
|
||||
// including a snippet of the body — self-hosted services put the useful
|
||||
// part of a failure there, not in the status line.
|
||||
func (c *apiClient) checkStatus(resp *http.Response) error {
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusUnauthorized,
|
||||
resp.StatusCode == http.StatusForbidden:
|
||||
return c.errAuth
|
||||
case resp.StatusCode >= 400:
|
||||
snippet, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||
|
||||
return fmt.Errorf(
|
||||
"%w: HTTP %d: %s",
|
||||
c.errUnreachable,
|
||||
resp.StatusCode,
|
||||
strings.TrimSpace(string(snippet)),
|
||||
)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// decodeJSON decodes a JSON string into out. Providers whose auth or
|
||||
// response handling does not fit apiClient still parse bodies the same
|
||||
// way, so the helper lives here rather than being repeated.
|
||||
func decodeJSON(body string, out any) error {
|
||||
if err := json.Unmarshal([]byte(body), out); err != nil {
|
||||
return fmt.Errorf("decode json: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user