release / govulncheck (push) Successful in 34s
release / web (push) Successful in 1m13s
release / go (push) Successful in 2m12s
release / integration (push) Successful in 4m6s
release / Build signed APK (releases and dev) (push) Successful in 8m41s
release / android (push) Successful in 9m47s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 59s
release / Verify release artifacts (tag releases only) (push) Skipped
Admin → Integrations gains "Indexers and download clients": two lists of
connections the operator adds, Fabled Librarian's model. Nothing is seeded
or copied from Lidarr.
- Indexers: Prowlarr (its native /api/v1/search, which asks every indexer
at once; Prowlarr has no aggregate Torznab feed) and any Torznab feed.
- Download clients: qBittorrent (WebUI v2, with re-login on an expired
session) and FabledExchange, one entry with a switch per job:
downloads (/api/downloads), search (/api/search) and release details
(/api/torrents/{infohash}). All three start on; its key is optional.
- Each entry: enable, name, address, a write-only key or password, Test.
A download client's Test lists its torrents and checks the downloads
folder is visible and writable from Minstrel; an Exchange's Test
reports each job switched on.
- Download clients carry a path mapping (Minstrel's /downloads vs the
client's own name for it); search sources carry searches per hour.
Migration 0079 adds acquisition_services with its CHECKs. Ported from
Fabled Librarian: internal/indexer (Torznab, 429 Retry-After, magnet
redirects), internal/prowlarr, internal/exchange, internal/downloader,
and internal/acquire's one builder per kind, which Test already uses
so a passing Test vouches for the path a grab will take.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
183 lines
5.7 KiB
Go
183 lines
5.7 KiB
Go
// 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, "[")
|
|
}
|