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>
177 lines
5.2 KiB
Go
177 lines
5.2 KiB
Go
// Package prowlarr searches through Prowlarr's native API.
|
|
//
|
|
// Native rather than Torznab because Prowlarr has no aggregate Torznab feed:
|
|
// `/api/v1/indexer/all/newznab` 404s exactly like an indexer id that does not
|
|
// exist.
|
|
// `/api/v1/search` is the one place Prowlarr asks every indexer at once.
|
|
package prowlarr
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
|
|
)
|
|
|
|
// SearchPath is Prowlarr's aggregate search.
|
|
const SearchPath = "/api/v1/search"
|
|
|
|
// DefaultTimeout is longer than a Torznab feed's: Prowlarr waits on every
|
|
// indexer it has before answering.
|
|
const DefaultTimeout = 60 * time.Second
|
|
|
|
// Config is one Prowlarr.
|
|
type Config struct {
|
|
// Code is the acquisition_services row, stamped onto every Result.
|
|
Code string
|
|
BaseURL string
|
|
// APIKey is required: Prowlarr's API refuses every call without one.
|
|
APIKey string
|
|
Timeout time.Duration
|
|
}
|
|
|
|
// Client searches one Prowlarr and fetches .torrent files through it.
|
|
type Client struct {
|
|
Config Config
|
|
// HTTP is injectable for tests. Nil takes http.DefaultClient.
|
|
HTTP *http.Client
|
|
}
|
|
|
|
type searchResult struct {
|
|
Title string `json:"title"`
|
|
InfoHash string `json:"infoHash"`
|
|
GUID string `json:"guid"`
|
|
MagnetURL string `json:"magnetUrl"`
|
|
DownloadURL string `json:"downloadUrl"`
|
|
Size int64 `json:"size"`
|
|
Seeders int `json:"seeders"`
|
|
Leechers int `json:"leechers"`
|
|
Indexer string `json:"indexer"`
|
|
PublishDate string `json:"publishDate"`
|
|
Categories []struct {
|
|
ID int `json:"id"`
|
|
} `json:"categories"`
|
|
}
|
|
|
|
// Search asks every indexer in this Prowlarr. `type=search` is free text;
|
|
// `type=music` takes artist and album separately, and a release name does not
|
|
// reliably say which part is which.
|
|
func (c *Client) Search(ctx context.Context, q indexer.Query) ([]indexer.Result, error) {
|
|
params := url.Values{"query": {q.Text}, "type": {"search"}}
|
|
for _, cat := range q.Categories {
|
|
params.Add("categories", fmt.Sprint(cat))
|
|
}
|
|
endpoint := strings.TrimRight(c.Config.BaseURL, "/") + SearchPath + "?" + params.Encode()
|
|
body, err := c.get(ctx, endpoint)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out, err := parseResults(body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for i := range out {
|
|
out[i].Source = c.Config.Code
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func parseResults(body []byte) ([]indexer.Result, error) {
|
|
var rows []searchResult
|
|
if err := json.Unmarshal(body, &rows); err != nil {
|
|
return nil, fmt.Errorf("%w: parse search results: %v", indexer.ErrUnreachable, err)
|
|
}
|
|
out := make([]indexer.Result, 0, len(rows))
|
|
for _, row := range rows {
|
|
title := strings.TrimSpace(row.Title)
|
|
if title == "" {
|
|
continue
|
|
}
|
|
r := indexer.Result{
|
|
Title: title,
|
|
SizeBytes: row.Size,
|
|
Seeders: row.Seeders,
|
|
Peers: row.Leechers,
|
|
Indexer: strings.TrimSpace(row.Indexer),
|
|
Magnet: firstMagnet(row.MagnetURL, row.GUID),
|
|
DownloadURL: strings.TrimSpace(row.DownloadURL),
|
|
}
|
|
if len(row.Categories) > 0 {
|
|
r.Category = row.Categories[0].ID
|
|
}
|
|
r.InfoHash = indexer.NormalizeHash(row.InfoHash)
|
|
if r.InfoHash == "" {
|
|
r.InfoHash = indexer.MagnetHash(r.Magnet)
|
|
}
|
|
if r.InfoHash == "" && indexer.IsInfoHash(row.GUID) {
|
|
r.InfoHash = indexer.NormalizeHash(row.GUID)
|
|
}
|
|
if t, err := time.Parse(time.RFC3339, strings.TrimSpace(row.PublishDate)); err == nil {
|
|
r.Published = t
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func firstMagnet(candidates ...string) string {
|
|
for _, c := range candidates {
|
|
c = strings.TrimSpace(c)
|
|
if strings.HasPrefix(c, "magnet:") {
|
|
return c
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Download fetches a result's .torrent through Prowlarr, which holds the
|
|
// tracker credentials. A public tracker's link often redirects to a magnet,
|
|
// which comes back as an *indexer.MagnetLink.
|
|
func (c *Client) Download(ctx context.Context, r indexer.Result) ([]byte, error) {
|
|
if r.DownloadURL == "" {
|
|
return nil, fmt.Errorf("%w: %q has no download URL", indexer.ErrUnreachable, r.Title)
|
|
}
|
|
body, err := c.get(ctx, r.DownloadURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return indexer.TorrentBody(r.Title, body)
|
|
}
|
|
|
|
func (c *Client) get(ctx context.Context, endpoint string) ([]byte, error) {
|
|
if c.Config.BaseURL == "" || c.Config.APIKey == "" {
|
|
return nil, indexer.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", indexer.ErrUnreachable, err)
|
|
}
|
|
req.Header.Set("User-Agent", indexer.UserAgent)
|
|
// In a header, not ?apikey=, so the key stays out of URLs, logs and
|
|
// error strings.
|
|
req.Header.Set("X-Api-Key", c.Config.APIKey)
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, body, err := indexer.Send(c.HTTP, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
|
return nil, fmt.Errorf("%w: HTTP %d", indexer.ErrAuth, resp.StatusCode)
|
|
}
|
|
if err := indexer.StatusError(resp, "Prowlarr"); err != nil {
|
|
return nil, err
|
|
}
|
|
return body, nil
|
|
}
|