Files
minstrel/internal/exchange/exchange.go
T
bvandeusenandClaude Opus 5.5 d191a28217 docs: server comments say what the code does, not how it came to be (#6277)
Comment blocks in cmd/minstrel and 30 smaller internal packages, rewritten
to rule 32's standard with each citation read in context: task, milestone
and rule ids go, and so do the stories they were attached to ("used to",
"Before #N", measurements from one install, quotes, pointers to Fabled
Librarian's records and to docs/superpowers files that are not in the
repo). Where a story wrapped a real constraint, the constraint stays as a
present-tense fact.

Comments that had gone stale are corrected:
- taste.Config: the tuned values are overlaid by internal/recsettings;
  it is no longer "a later follow-up".
- StorageConfig: the data dir holds art, caches and the stream secret,
  not only playlist covers.
- the Subsonic album list and getUser comments describe what they do
  today rather than a milestone that would change it.

Checked: the non-comment token stream of every changed file matches
HEAD, and gofmt -l is empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:15:05 -04:00

182 lines
5.7 KiB
Go

// Package exchange talks to FabledExchange's native API for the two jobs that
// are not downloading: searching its DHT index (/api/search) and describing
// one torrent (/api/torrents/{infohash}). Downloading is internal/downloader's.
//
// The native search rather than Exchange's Torznab feed, because it reports
// whether a swarm was measured and how many files a torrent holds, which the
// feed cannot say.
package exchange
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"time"
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
)
// ErrUnconfigured is an Exchange with no address.
var ErrUnconfigured = errors.New("exchange is not configured")
// ErrUnreachable is a call that could not be made or was not understood.
var ErrUnreachable = errors.New("exchange unreachable")
// ErrAuth is an API key Exchange refused, or a secured Exchange asked with
// none.
var ErrAuth = errors.New("exchange refused the API key")
// ErrUnknown is a torrent Exchange has not crawled: a JSON 404, which is an
// answer and not a fault.
var ErrUnknown = errors.New("exchange has not crawled this torrent")
// UserAgent names Minstrel in Exchange's logs.
const UserAgent = "Minstrel (self-hosted music server; release search)"
// DefaultTimeout is what an unset Timeout means. Exchange answers from its
// own database.
const DefaultTimeout = 20 * time.Second
// Config is one Exchange.
type Config struct {
BaseURL string
// APIKey is optional and sent only when set: an empty bearer makes a
// secured Exchange answer 401.
APIKey string
Timeout time.Duration
}
// Client asks one Exchange.
type Client struct {
Config Config
// HTTP is injectable for tests. Nil takes http.DefaultClient.
HTTP *http.Client
// Code is the acquisition_services row, stamped onto search results.
Code string
Logger *slog.Logger
}
func (c *Client) log() *slog.Logger {
if c == nil || c.Logger == nil {
return slog.Default()
}
return c.Logger
}
// Detail is what Exchange knows about one torrent.
type Detail struct {
InfoHash string `json:"infohash"`
Name string `json:"name"`
Size int64 `json:"size"`
FilesCount int `json:"files_count"`
Seeders int `json:"seeders"`
SwarmKnown bool `json:"swarm_known"`
Leechers int `json:"leechers"`
LeechersKnown bool `json:"leechers_known"`
ContentType string `json:"content_type"`
Files []DetailFile `json:"files"`
}
// DetailFile is one file inside a torrent.
type DetailFile struct {
Index int `json:"index"`
Path string `json:"path"`
Size int64 `json:"size"`
}
// Torrent asks Exchange about one v1 infohash.
func (c *Client) Torrent(ctx context.Context, infohash string) (Detail, error) {
if strings.TrimSpace(c.Config.BaseURL) == "" {
return Detail{}, ErrUnconfigured
}
hash := indexer.NormalizeHash(infohash)
if len(hash) != 40 || !indexer.IsInfoHash(hash) {
return Detail{}, fmt.Errorf("%w: %q is not a 40-character infohash", ErrUnreachable, infohash)
}
body, err := c.get(ctx, "/api/torrents/"+hash)
if err != nil {
return Detail{}, err
}
var out Detail
if err := json.Unmarshal(body, &out); err != nil {
return Detail{}, fmt.Errorf("%w: parse torrent detail: %v", ErrUnreachable, err)
}
if out.InfoHash == "" {
return Detail{}, ErrUnknown
}
return out, nil
}
func (c *Client) get(ctx context.Context, path string) ([]byte, error) {
timeout := c.Config.Timeout
if timeout <= 0 {
timeout = DefaultTimeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
target := strings.TrimRight(c.Config.BaseURL, "/") + path
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
}
req.Header.Set("User-Agent", UserAgent)
req.Header.Set("Accept", "application/json")
if key := strings.TrimSpace(c.Config.APIKey); key != "" {
req.Header.Set("Authorization", "Bearer "+key)
}
client := c.HTTP
if client == nil {
client = http.DefaultClient
}
res, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
}
defer func() { _ = res.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("%w: read answer: %v", ErrUnreachable, err)
}
switch {
case res.StatusCode == http.StatusNotFound:
// A 404 that is not JSON came from something else at this address,
// which must not look like a healthy Exchange that lacks a torrent.
if !looksLikeJSON(body) {
return nil, fmt.Errorf("%w: something at this address answered 404, "+
"but it was not FabledExchange", ErrUnreachable)
}
return nil, ErrUnknown
case res.StatusCode == http.StatusUnauthorized || res.StatusCode == http.StatusForbidden:
return nil, fmt.Errorf("%w: %s", ErrAuth,
message(body, "set this FabledExchange's API key in Minstrel's settings"))
case res.StatusCode == http.StatusServiceUnavailable:
return nil, fmt.Errorf("%w: %s", ErrUnreachable, message(body, "no index is configured"))
case res.StatusCode < 200 || res.StatusCode >= 300:
return nil, fmt.Errorf("%w: %s", ErrUnreachable,
message(body, fmt.Sprintf("returned %d", res.StatusCode)))
}
return body, nil
}
// message is Exchange's own {"error": "..."} text, or fallback.
func message(body []byte, fallback string) string {
var env struct {
Error string `json:"error"`
}
if err := json.Unmarshal(body, &env); err == nil && strings.TrimSpace(env.Error) != "" {
return strings.TrimSpace(env.Error)
}
return fallback
}
func looksLikeJSON(body []byte) bool {
t := strings.TrimSpace(string(body))
return strings.HasPrefix(t, "{") || strings.HasPrefix(t, "[")
}