// Package exchange talks to FabledExchange's native API for the two jobs that // are not downloading (M502 #5518): searching its DHT index (/api/search) and // describing one torrent (/api/torrents/{infohash}). Downloading is // internal/downloader's. Ported from Fabled Librarian's internal/exchange. // // 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, "[") }