Rewrites the internal/api comment blocks to rule 32: task, milestone and rule ids gone, the stories behind them restated as the constraint they left, stale references corrected (the stream secret is wired from config, the timezone handler already refreshes the scheduler, the Android app is the native client, the design docs cited are not in the repo). Also rewraps comment lines over 80 columns left by the earlier batches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
165 lines
4.7 KiB
Go
165 lines
4.7 KiB
Go
package acquire
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
|
|
)
|
|
|
|
// Searching every source at once. A source that is down narrows a search rather
|
|
// than failing it: the results come back with the list of who did not answer.
|
|
|
|
// SourceFailure is one source that could not be reached.
|
|
type SourceFailure struct {
|
|
Code string
|
|
Label string
|
|
Err error
|
|
}
|
|
|
|
// PartialError is a search that reached some sources and not others. It is
|
|
// returned alongside the results, not instead of them.
|
|
type PartialError struct {
|
|
Failures []SourceFailure
|
|
// Answered is how many sources did answer. Zero means the search failed.
|
|
Answered int
|
|
}
|
|
|
|
func (e *PartialError) Error() string {
|
|
if e == nil || len(e.Failures) == 0 {
|
|
return "some sources did not answer"
|
|
}
|
|
parts := make([]string, 0, len(e.Failures))
|
|
for _, f := range e.Failures {
|
|
parts = append(parts, fmt.Sprintf("%s: %s", f.Label, indexer.FirstLine(f.Err.Error())))
|
|
}
|
|
return "some sources did not answer — " + strings.Join(parts, "; ")
|
|
}
|
|
|
|
// Unwrap lets errors.Is find indexer.ErrUnreachable through a partial failure.
|
|
func (e *PartialError) Unwrap() []error {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
out := make([]error, 0, len(e.Failures))
|
|
for _, f := range e.Failures {
|
|
out = append(out, f.Err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Fanout searches every source and merges what comes back. It is both the
|
|
// Searcher and the Fetcher, routing a .torrent fetch to the source that
|
|
// offered the result.
|
|
type Fanout struct {
|
|
// Sources in code order: the fold keeps a duplicate's first arrival, so
|
|
// the order decides which source a grab is fetched through.
|
|
Sources []Source
|
|
// Pace spaces each source's searches. Nil paces nothing.
|
|
Pace *Pacer
|
|
}
|
|
|
|
// Search asks every source concurrently. Each client bounds its own call by
|
|
// its configured timeout, so there is no shared deadline here.
|
|
func (f *Fanout) Search(ctx context.Context, query indexer.Query) ([]indexer.Result, error) {
|
|
if f == nil || len(f.Sources) == 0 {
|
|
return nil, indexer.ErrUnconfigured
|
|
}
|
|
found := make([][]indexer.Result, len(f.Sources))
|
|
errs := make([]error, len(f.Sources))
|
|
var wg sync.WaitGroup
|
|
for i, src := range f.Sources {
|
|
if src.Search == nil {
|
|
continue
|
|
}
|
|
f.Pace.spend(src)
|
|
wg.Add(1)
|
|
go func(i int, src Source) {
|
|
defer wg.Done()
|
|
found[i], errs[i] = src.Search.Search(ctx, query)
|
|
}(i, src)
|
|
}
|
|
wg.Wait()
|
|
|
|
out := make([]indexer.Result, 0, 32)
|
|
var failures []SourceFailure
|
|
answered, unconfigured := 0, 0
|
|
for i, src := range f.Sources {
|
|
if src.Search == nil {
|
|
continue
|
|
}
|
|
if errors.Is(errs[i], indexer.ErrUnconfigured) {
|
|
unconfigured++
|
|
continue
|
|
}
|
|
if rl, ok := indexer.AsRateLimited(errs[i]); ok {
|
|
f.Pace.hold(src.Code, rl)
|
|
}
|
|
if errs[i] != nil {
|
|
failures = append(failures, SourceFailure{Code: src.Code, Label: src.Label, Err: errs[i]})
|
|
continue
|
|
}
|
|
answered++
|
|
for j := range found[i] {
|
|
if found[i][j].Source == "" {
|
|
found[i][j].Source = src.Code
|
|
}
|
|
}
|
|
out = append(out, found[i]...)
|
|
}
|
|
switch {
|
|
case answered == 0 && len(failures) > 0:
|
|
return nil, &PartialError{Failures: failures}
|
|
case len(failures) > 0:
|
|
return out, &PartialError{Failures: failures, Answered: answered}
|
|
case answered == 0 && unconfigured > 0:
|
|
return nil, indexer.ErrUnconfigured
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Download fetches a result's .torrent from the source that offered it: a
|
|
// Prowlarr link carries Prowlarr's key and only Prowlarr can redeem it.
|
|
func (f *Fanout) Download(ctx context.Context, r indexer.Result) ([]byte, error) {
|
|
if f == nil {
|
|
return nil, indexer.ErrUnconfigured
|
|
}
|
|
for _, src := range f.Sources {
|
|
if src.Code != r.Source {
|
|
continue
|
|
}
|
|
if src.Fetch == nil {
|
|
return nil, fmt.Errorf("%w: %s cannot fetch torrent files", indexer.ErrUnconfigured, src.Label)
|
|
}
|
|
return src.Fetch.Download(ctx, r)
|
|
}
|
|
if r.Source == "" {
|
|
return nil, fmt.Errorf("%w: this result does not say which source it came from", indexer.ErrUnconfigured)
|
|
}
|
|
return nil, fmt.Errorf("%w: %q is no longer a configured source", indexer.ErrUnconfigured, r.Source)
|
|
}
|
|
|
|
// Narrowing is one source that did not answer, as a page shows it.
|
|
type Narrowing struct {
|
|
Source string `json:"source"`
|
|
Label string `json:"label"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
// narrowings reads a PartialError out of err, and whether any source
|
|
// answered. Nil when err is not a partial failure.
|
|
func narrowings(err error) ([]Narrowing, bool) {
|
|
var partial *PartialError
|
|
if !errors.As(err, &partial) || len(partial.Failures) == 0 {
|
|
return nil, false
|
|
}
|
|
out := make([]Narrowing, 0, len(partial.Failures))
|
|
for _, f := range partial.Failures {
|
|
out = append(out, Narrowing{Source: f.Code, Label: f.Label, Reason: indexer.FirstLine(f.Err.Error())})
|
|
}
|
|
return out, partial.Answered > 0
|
|
}
|