Files
minstrel/internal/indexer/indexer.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

157 lines
5.2 KiB
Go

// Package indexer is what every search source answers with, and the Torznab
// client.
//
// A client here holds ONE endpoint and knows nothing about the others. The
// fan-out across sources, the merge and what a source being down means belong
// to internal/acquire, because they are decisions about a search rather than
// about one source.
//
// Prowlarr is not served from here: it has no aggregate Torznab feed (`all`
// 404s like any id it does not have), so asking it everything at once means
// its native API, which internal/prowlarr speaks.
package indexer
import (
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// ErrUnconfigured is a source with no address (or, for Prowlarr, no key): not
// a failure, an instance that does not search there.
var ErrUnconfigured = errors.New("indexer is not configured")
// ErrUnreachable is a search that could not be made: no network, a timeout, a
// malformed answer, a status outside 2xx.
var ErrUnreachable = errors.New("indexer unreachable")
// ErrAuth is a key the source refused. Separate from ErrUnreachable because
// it needs a different sentence and a different action: a refused key is a
// field in Settings, an outage is something to wait out.
var ErrAuth = errors.New("the API key was refused")
// UserAgent names Minstrel in Prowlarr's and each indexer's logs.
const UserAgent = "Minstrel (self-hosted music server; release search)"
// MusicCategories are the Newznab categories a music search asks for: 3000
// Audio, with its MP3 (3010) and Lossless (3040) children alongside, because
// an indexer that files music under a child answers only for the child.
// 3030 (Audiobook) is deliberately absent.
var MusicCategories = []int{3000, 3010, 3040}
// Query is one search: the words, and which categories to ask in. Empty
// Categories asks every category.
type Query struct {
Text string
Categories []int
}
// Result is one search result, as the source described it.
type Result struct {
Title string
// InfoHash is the torrent's identity, lowercased; empty when the source
// gave none.
InfoHash string
// Magnet is a magnet URI when the source published one.
Magnet string
// DownloadURL is a .torrent URL when it did not. It must be fetched
// through the source that offered it (it holds the tracker credentials),
// never handed to the download client.
DownloadURL string
SizeBytes int64
Category int
// Indexer is the tracker BEHIND the source, as the source named it.
Indexer string
// Source is the acquisition_services code that was asked: where a
// .torrent fetch must be routed back to.
Source string
Published time.Time
// Seeders and Peers as reported. A feed that has not sampled a swarm
// reports a nominal 1, so these are not health unless SwarmKnown.
Seeders int
Peers int
// SwarmKnown says Seeders was measured, which only FabledExchange's own
// search can say.
SwarmKnown bool
// FilesCount is how many files the torrent holds, 0 when not said.
FilesCount int
}
// Send sends req through c, stopping at a redirect to a magnet (returned as a
// *MagnetLink), and reads at most 8 MB of the answer. A call that never got
// an answer is ErrUnreachable; the status is the caller's to read.
func Send(c *http.Client, req *http.Request) (*http.Response, []byte, error) {
resp, err := StopAtMagnet(c).Do(req)
if m, ok := AsMagnet(err); ok {
return nil, nil, &MagnetLink{URI: m}
}
if err != nil {
return nil, nil, fmt.Errorf("%w: %v", ErrUnreachable, err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return nil, nil, fmt.Errorf("%w: read body: %v", ErrUnreachable, err)
}
return resp, body, nil
}
// StatusError is what a non-2xx status means for a search source: a 429 is
// RateLimited naming who, anything else ErrUnreachable. Nil for a 2xx.
func StatusError(resp *http.Response, who string) error {
switch {
case resp.StatusCode == http.StatusTooManyRequests:
return &RateLimited{Who: who, RetryAfter: RetryAfter(resp.Header, time.Now())}
case resp.StatusCode < 200 || resp.StatusCode >= 300:
return fmt.Errorf("%w: HTTP %d", ErrUnreachable, resp.StatusCode)
}
return nil
}
// NormalizeHash is a torrent's identity in its one canonical spelling. Every
// comparison of infohashes goes through it, so a hash differing only in case
// is never two releases.
func NormalizeHash(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
// IsInfoHash says whether s is shaped like a v1 (40) or v2 (64) hex infohash.
func IsInfoHash(s string) bool {
s = NormalizeHash(s)
if len(s) != 40 && len(s) != 64 {
return false
}
for _, r := range s {
if (r < '0' || r > '9') && (r < 'a' || r > 'f') {
return false
}
}
return true
}
// MagnetHash pulls the infohash out of a magnet link, or returns "". It does
// not validate it.
func MagnetHash(magnet string) string {
const prefix = "urn:btih:"
at := strings.Index(magnet, prefix)
if at < 0 {
return ""
}
hash := magnet[at+len(prefix):]
if cut := strings.IndexAny(hash, "&/?"); cut >= 0 {
hash = hash[:cut]
}
return strings.ToLower(hash)
}
// FirstLine is an error's first line, for a sentence on the settings page.
func FirstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}