Files
yellowjacket/backend/download/apiclient.go
T
yonluandClaude Opus 5.5 7a9dd69d30 fix(download): own folder per slskd grab, search timeout in seconds
Checked against slskd 0.26.0's source rather than a live daemon, which
#267 never had.

slskd reads a search's searchTimeout in seconds, counted from the last
response. We sent milliseconds, telling it a search may idle for five
hours, so a search never completed on its own. It now sends seconds,
with slskd's floor of 5.

slskd writes a finished file to <downloads>/<remote leaf folder>/, and
when a name is taken it writes name_<ticks>.ext beside it. collect found
files by name there, so a file left by an earlier failed attempt, or by
the user's own download, was collected in place of this grab's. A user
who changed slskd's destination setting got nothing collected at all.

slskd 0.26 takes a batch download with an explicit destination. Each
grab now enqueues batches into yellowjacket/<uuid>/ (one per disc, since
a batch's files land flat), collects from exactly there, and removes the
folder afterwards, including after a failure. An older daemon answers
the batch route with 400, which is remembered, and the per-user enqueue
is used. There, collect skips files that were already present,
unchanged, before the enqueue, and takes the renamed copy slskd wrote
instead. The comparison is against a snapshot, not a clock, because
slskd may run on another machine. The per-folder lock from #272 is kept
only while batches are not known to work.

The test stub now writes files when they are enqueued, as slskd does,
including the rename, so tests no longer stage files before a grab.
An opt-in TestSlskdLive runs against a real daemon when YJ_SLSKD_URL,
YJ_SLSKD_API_KEY and YJ_SLSKD_DOWNLOADS are set.

Closes #274

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017HJiuc3ZZhxsPXz3ozTirT
2026-09-26 22:04:50 -04:00

188 lines
4.4 KiB
Go

package download
import (
"bytes"
"context"
"encoding/json"
"errors"
"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: %w", c.errUnreachable, &httpStatusError{
code: resp.StatusCode,
body: strings.TrimSpace(string(snippet)),
})
default:
return nil
}
}
// httpStatusError is a non-2xx answer, kept typed so a caller can tell
// a missing endpoint from a daemon that is down.
type httpStatusError struct {
code int
body string
}
func (e *httpStatusError) Error() string {
return fmt.Sprintf("HTTP %d: %s", e.code, e.body)
}
// statusCode returns the HTTP status an error carries, or 0.
func statusCode(err error) int {
var se *httpStatusError
if errors.As(err, &se) {
return se.code
}
return 0
}
// 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
}