Files
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

254 lines
7.4 KiB
Go

package indexer
import (
"context"
"encoding/xml"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// DefaultTimeout is what an unset Timeout means. Longer than a metadata
// call: a feed may itself fan out to several trackers before answering.
const DefaultTimeout = 30 * time.Second
// TorznabConfig is one Torznab feed.
type TorznabConfig struct {
// Code is the acquisition_services row this client was built from,
// stamped onto every Result.
Code string
// FeedURL is the feed's whole address up to and including /api, as
// Jackett or a Prowlarr indexer shows it — e.g.
// http://prowlarr:9696/12/api. Query parameters are appended to it.
// One field rather than base + path + indexer id: a pasted URL is more
// reliable than Minstrel being right about a third party's routes.
FeedURL string
// APIKey is optional: Torznab makes it so. A feed that needs one says so
// with Newznab error 100, which is ErrAuth.
APIKey string
Timeout time.Duration
}
// Torznab searches one Torznab feed.
type Torznab struct {
Config TorznabConfig
// HTTP is injectable for tests. Nil takes http.DefaultClient.
HTTP *http.Client
}
// Search asks the feed for q. `t=search` is free text, which every indexer
// supports; `t=music` takes artist and album separately, and a release name
// does not reliably say which of its parts is which.
func (c *Torznab) Search(ctx context.Context, q Query) ([]Result, error) {
params := url.Values{"t": {"search"}, "q": {q.Text}}
for _, cat := range q.Categories {
params.Add("cat", strconv.Itoa(cat))
}
body, err := c.get(ctx, c.endpoint(params))
if err != nil {
return nil, err
}
out, err := parseFeed(body)
if err != nil {
return nil, err
}
for i := range out {
out[i].Source = c.Config.Code
}
return out, nil
}
// Download fetches a result's .torrent bytes through the feed, which holds
// the tracker's credentials. A result with a magnet needs none of this.
func (c *Torznab) Download(ctx context.Context, r Result) ([]byte, error) {
if r.DownloadURL == "" {
return nil, fmt.Errorf("%w: %q has no download URL", ErrUnreachable, r.Title)
}
body, err := c.get(ctx, r.DownloadURL)
if err != nil {
return nil, err
}
return torrentBody(r.Title, body)
}
// torrentBody checks a fetched .torrent. A torrent is a bencoded dictionary
// and starts with 'd'; an expired session gets an HTML login page with HTTP
// 200, which the download client would otherwise reject as an unsupported
// file from a component that did nothing wrong.
func torrentBody(title string, body []byte) ([]byte, error) {
if err := MagnetBody(body); err != nil {
return nil, err
}
if len(body) == 0 || body[0] != 'd' {
return nil, fmt.Errorf("%w: %q returned %d bytes that are not a torrent", ErrUnreachable, title, len(body))
}
return body, nil
}
// TorrentBody is torrentBody for the other search clients' fetches.
func TorrentBody(title string, body []byte) ([]byte, error) { return torrentBody(title, body) }
func (c *Torznab) endpoint(params url.Values) string {
if c.Config.APIKey != "" {
params.Set("apikey", c.Config.APIKey)
}
feed := strings.TrimSpace(c.Config.FeedURL)
sep := "?"
if strings.Contains(feed, "?") {
sep = "&"
}
return feed + sep + params.Encode()
}
func (c *Torznab) get(ctx context.Context, endpoint string) ([]byte, error) {
if strings.TrimSpace(c.Config.FeedURL) == "" {
return nil, ErrUnconfigured
}
timeout := c.Config.Timeout
if timeout <= 0 {
timeout = DefaultTimeout
}
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("%w: build request: %v", ErrUnreachable, err)
}
req.Header.Set("User-Agent", UserAgent)
resp, body, err := Send(c.HTTP, req)
if err != nil {
return nil, err
}
if err := StatusError(resp, "the indexer"); err != nil {
return nil, err
}
// Newznab reports errors as HTTP 200 with an <error> document, so a
// client checking only the status reads a refused key as an empty result
// set: search quietly stops finding things.
if err := errorDocument(body); err != nil {
return nil, err
}
return body, nil
}
// errorDocument reads an <error code=… description=…/> answer. Newznab's 1xx
// codes (100 wrong key, 101 suspended, 102 insufficient privileges) are all
// about who is asking, so they are ErrAuth.
func errorDocument(body []byte) error {
head := body
if len(head) > 512 {
head = head[:512]
}
if !strings.Contains(string(head), "<error") {
return nil
}
var doc struct {
XMLName xml.Name `xml:"error"`
Code string `xml:"code,attr"`
Description string `xml:"description,attr"`
}
if err := xml.Unmarshal(body, &doc); err != nil {
return nil
}
if doc.Code == "" && doc.Description == "" {
return nil
}
if strings.HasPrefix(doc.Code, "1") && len(doc.Code) == 3 {
return fmt.Errorf("%w: indexer error %s: %s", ErrAuth, doc.Code, doc.Description)
}
return fmt.Errorf("%w: indexer error %s: %s", ErrUnreachable, doc.Code, doc.Description)
}
// The Torznab feed: RSS 2.0 with torznab:attr for what RSS has no field for.
type feed struct {
Channel struct {
Items []item `xml:"item"`
} `xml:"channel"`
}
type item struct {
Title string `xml:"title"`
GUID string `xml:"guid"`
Link string `xml:"link"`
PubDate string `xml:"pubDate"`
Size int64 `xml:"size"`
Enclosure struct {
URL string `xml:"url,attr"`
Length int64 `xml:"length,attr"`
} `xml:"enclosure"`
Attrs []struct {
Name string `xml:"name,attr"`
Value string `xml:"value,attr"`
} `xml:"attr"`
}
func (i item) attr(name string) string {
for _, a := range i.Attrs {
if strings.EqualFold(a.Name, name) {
return a.Value
}
}
return ""
}
func parseFeed(body []byte) ([]Result, error) {
var f feed
if err := xml.Unmarshal(body, &f); err != nil {
return nil, fmt.Errorf("%w: parse feed: %v", ErrUnreachable, err)
}
out := make([]Result, 0, len(f.Channel.Items))
for _, it := range f.Channel.Items {
r := Result{
Title: strings.TrimSpace(it.Title),
SizeBytes: it.Size,
Indexer: it.attr("indexer"),
Seeders: atoi(it.attr("seeders")),
Peers: atoi(it.attr("peers")),
Category: atoi(it.attr("category")),
}
if r.Title == "" {
continue
}
if r.SizeBytes == 0 {
r.SizeBytes = it.Enclosure.Length
}
// The link is a magnet or a .torrent URL; Prowlarr puts it in <link>
// and repeats it in <enclosure url>, either populated by version.
for _, candidate := range []string{it.Link, it.Enclosure.URL} {
if strings.HasPrefix(candidate, "magnet:") && r.Magnet == "" {
r.Magnet = candidate
} else if candidate != "" && r.DownloadURL == "" {
r.DownloadURL = candidate
}
}
// The infohash from the explicit attr, then the magnet, then the GUID
// only when it is shaped like a hash: Prowlarr puts a URL there as
// often as a hash.
r.InfoHash = NormalizeHash(it.attr("infohash"))
if r.InfoHash == "" {
r.InfoHash = MagnetHash(r.Magnet)
}
if r.InfoHash == "" && IsInfoHash(it.GUID) {
r.InfoHash = NormalizeHash(it.GUID)
}
if t, err := time.Parse(time.RFC1123Z, strings.TrimSpace(it.PubDate)); err == nil {
r.Published = t
} else if t, err := time.Parse(time.RFC1123, strings.TrimSpace(it.PubDate)); err == nil {
r.Published = t
}
out = append(out, r)
}
return out, nil
}
func atoi(s string) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
return 0
}
return n
}