release / govulncheck (push) Successful in 21s
release / go (push) Failing after 33s
release / web (push) Successful in 56s
release / integration (push) Failing after 4m48s
release / android (push) Successful in 5m6s
release / Build signed APK (releases and dev) (push) Successful in 5m16s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
- internal/release: reads a release name (format, bitrate, bit depth and sample rate, source, year, video, discography) and judges it against the album wanted: the artist and title must be in it; video rips, discographies, other versions (drumless, instrumental, karaoke, remix, live unless the album is live…) and singles or EPs for an album are refused. Quality tiers on Lidarr's ladder; a .torrent's file list is read for format, video files and a track count short of the album. - internal/acquire: fan-out across every search source with a narrowed search kept, a process-wide pacer per source, every candidate judged and kept with its reasons, and the grab: one live grab per want, tie-broken (a person's grab beats the schedule's; a better release replaces a worse one unless it is nearly done). Each download lands in its own folder under the client's downloads folder. - internal/wanted: the search schedule, every ten minutes, never-searched first, then requests, missing files and followed artists, taking the best release when auto-grab is on (the default). - Migration 0082: acquisition_settings (auto-grab, interval, allowed qualities seeded from Lidarr's "Any"), want search columns, want_candidates, grabs, release_blocks. - Web: the Wanted page shows each want's last search, a Search button and every release found with its verdict (Grab, Grab anyway, Never this one, Allow again). Integrations gains "Searching and grabbing". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
166 lines
4.8 KiB
Go
166 lines
4.8 KiB
Go
package acquire
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/indexer"
|
|
)
|
|
|
|
// Searching every source at once (M502 #5521), ported from Fabled Librarian
|
|
// (#4182). 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
|
|
}
|