Files
yellowjacket/backend/download/provider_sabnzbd.go
yonluandClaude Sonnet 5 e190fd75b9
Build & publish Arch package / arch-package (push) Successful in 2m12s
Search index maintenance / maintain-index (push) Successful in 2h22m28s
feat: data lifecycle rewrite, download clients, wanted list, and central catalog index
Ships the fresh-start schema cleanup: rebuilt explore catalog index
pipeline (dump import, artifact fetch/build, incremental listen-count
refresh), a new download subsystem (Lidarr/Prowlarr/qBittorrent/SABnzbd/
slskd/yt-dlp providers, staging, reconciliation, wanted list), and the
supporting schema/query/store changes across backend and frontend.

Also includes two smaller follow-ups: bump the central index's
rebuild-after cadence from 90 to 180 days, and remove the Explore
"library only" online/offline toggle entirely (frontend-only, no
backend counterpart) rather than carry unused UI/state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Agd9af5hE7qzti2ackiS
2026-08-06 17:12:01 -04:00

389 lines
9.0 KiB
Go

package download
import (
"context"
"errors"
"fmt"
"log/slog"
"net/url"
"strings"
"time"
)
// SABnzbd is the usenet half of the split-role pair: Prowlarr finds an
// NZB, SABnzbd fetches and unpacks it.
//
// Like slskd, it writes to its own completed-downloads directory rather
// than one we choose, and unlike qBittorrent there is no per-job save
// path we can set reliably across versions. It does, however, report
// the final storage path in its history, so the collect step reads that
// rather than guessing.
// SABnzbd provider errors.
var (
// ErrSabUnreachable means the instance did not answer.
ErrSabUnreachable = errors.New("sabnzbd is unreachable")
// ErrSabAuth means the API key was rejected.
ErrSabAuth = errors.New("sabnzbd rejected the API key")
// ErrSabTransferFailed means the job failed or was removed.
ErrSabTransferFailed = errors.New("sabnzbd job failed")
// ErrSabNoJob means the queued job vanished from both queue and
// history without completing.
ErrSabNoJob = errors.New("sabnzbd job disappeared")
)
// SABnzbd tuning.
const (
// sabHTTPTimeout bounds one API call.
sabHTTPTimeout = 30 * time.Second
// sabPollInterval is how often job state is checked.
sabPollInterval = 5 * time.Second
)
func init() {
Register(
Descriptor{
Kind: KindSABnzbd,
Name: "SABnzbd",
Summary: "Download usenet releases found by an indexer. " +
"Pairs with Prowlarr; does not search on its own.",
RequiresExternal: "SABnzbd",
Caps: Caps{
CanTransport: true,
CanCancel: true,
ReportsSize: true,
Transports: []Protocol{ProtocolUsenet},
},
Fields: []Field{
{
Key: "url",
Label: "SABnzbd URL",
Placeholder: "http://localhost:8080",
Required: true,
Default: "http://localhost:8080",
},
{
Key: "apiKey",
Label: "API key",
Secret: true,
Required: true,
Help: "SABnzbd → Config → General → API Key.",
},
{
Key: "category",
Label: "Category",
Help: "SABnzbd category for these downloads. " +
"Its folder must be readable from this machine.",
Default: "music",
},
},
},
newSABnzbd,
)
}
// sabnzbd is the SABnzbd transport.
type sabnzbd struct {
info ProviderInfo
logger *slog.Logger
client *apiClient
apiKey string
category string
pollInterval time.Duration
}
// newSABnzbd builds the transport from config.
func newSABnzbd(
cfg Config,
secrets SecretLookup,
logger *slog.Logger,
) (Provider, error) {
base := strings.TrimRight(cfg.Setting("url", ""), "/")
if base == "" {
return nil, fmt.Errorf("%w: SABnzbd URL is required", ErrNotConfigured)
}
apiKey := ""
if secrets != nil {
key, err := secrets("apiKey")
if err != nil {
return nil, fmt.Errorf("%w: no API key stored", ErrNotConfigured)
}
apiKey = key
}
return &sabnzbd{
info: ProviderInfo{
ID: cfg.ID,
Kind: KindSABnzbd,
Name: cfg.Name,
Enabled: cfg.Enabled,
Priority: cfg.Priority,
Caps: Caps{
CanTransport: true,
CanCancel: true,
ReportsSize: true,
Transports: []Protocol{ProtocolUsenet},
},
},
logger: logger.With("provider", "sabnzbd"),
// SABnzbd authenticates by query parameter, not header, so the
// shared client carries no auth header here.
client: newAPIClient(
base, "", "", sabHTTPTimeout, ErrSabUnreachable, ErrSabAuth,
),
apiKey: apiKey,
category: cfg.Setting("category", "music"),
pollInterval: sabPollInterval,
}, nil
}
// Info returns the provider's identity.
func (s *sabnzbd) Info() ProviderInfo {
return s.info
}
// Close is a no-op.
func (s *sabnzbd) Close() error {
return nil
}
// sabResponse is SABnzbd's common envelope. It answers a bad API key
// with HTTP 200 and status:false, so the body has to be inspected.
type sabResponse struct {
Status bool `json:"status"`
Error string `json:"error"`
NzoIDs []string `json:"nzo_ids"`
}
// sabQueue is the queue view.
type sabQueue struct {
Queue struct {
Slots []sabQueueSlot `json:"slots"`
} `json:"queue"`
}
// sabQueueSlot is one in-flight job.
type sabQueueSlot struct {
NzoID string `json:"nzo_id"`
Filename string `json:"filename"`
Status string `json:"status"`
Percentage string `json:"percentage"`
MB string `json:"mb"`
MBLeft string `json:"mbleft"`
}
// sabHistory is the history view.
type sabHistory struct {
History struct {
Slots []sabHistorySlot `json:"slots"`
} `json:"history"`
}
// sabHistorySlot is one finished job. Storage is the unpacked path,
// which is the only reliable way to find what SABnzbd produced.
type sabHistorySlot struct {
NzoID string `json:"nzo_id"`
Name string `json:"name"`
Status string `json:"status"`
Storage string `json:"storage"`
FailMsg string `json:"fail_message"`
Bytes int64 `json:"bytes"`
}
// Check verifies the instance answers and the key is accepted.
func (s *sabnzbd) Check(ctx context.Context) error {
var resp struct {
Version string `json:"version"`
}
if err := s.call(ctx, url.Values{"mode": {"version"}}, &resp); err != nil {
return err
}
// version answers without auth, so make one authenticated call too.
var queue sabQueue
return s.call(ctx, url.Values{"mode": {"queue"}}, &queue)
}
// Grab adds the NZB, waits for SABnzbd to finish, and moves the
// unpacked files into dst.
func (s *sabnzbd) Grab(
ctx context.Context,
c Candidate,
dst string,
onProgress ProgressFunc,
) (Result, error) {
link := c.Payload["link"]
if link == "" {
return Result{}, fmt.Errorf(
"%w: candidate has no NZB URL", ErrSabTransferFailed,
)
}
if err := validateHTTPURL(link); err != nil {
return Result{}, err
}
var added sabResponse
if err := s.call(ctx, url.Values{
"mode": {"addurl"},
"name": {link},
"cat": {s.category},
"nzbname": {c.Title},
"priority": {"0"},
}, &added); err != nil {
return Result{}, err
}
if !added.Status || len(added.NzoIDs) == 0 {
return Result{}, fmt.Errorf(
"%w: %s", ErrSabTransferFailed, added.Error,
)
}
nzoID := added.NzoIDs[0]
slot, err := s.await(ctx, nzoID, onProgress)
if err != nil {
return Result{}, err
}
if !strings.EqualFold(slot.Status, "Completed") {
return Result{}, fmt.Errorf(
"%w: %s: %s", ErrSabTransferFailed, slot.Status, slot.FailMsg,
)
}
return collectTree(slot.Storage, dst)
}
// await polls the queue until the job leaves it, then reads history for
// the outcome. SABnzbd moves a job from queue to history when it
// finishes post-processing, so history is where completion is truthful.
func (s *sabnzbd) await(
ctx context.Context,
nzoID string,
onProgress ProgressFunc,
) (sabHistorySlot, error) {
for {
select {
case <-ctx.Done():
return sabHistorySlot{}, fmt.Errorf(
"%w: cancelled", ErrSabTransferFailed,
)
case <-time.After(s.pollInterval):
}
inQueue, slot, err := s.queueSlot(ctx, nzoID)
if err != nil {
s.logger.Debug("sabnzbd queue poll failed", "error", err)
continue
}
if inQueue {
if onProgress != nil {
onProgress(Progress{
Current: parseMB(slot.MB) - parseMB(slot.MBLeft),
Total: parseMB(slot.MB),
Phase: "Downloading from usenet (" + slot.Status + ")",
})
}
continue
}
found, hist, err := s.historySlot(ctx, nzoID)
if err != nil {
s.logger.Debug("sabnzbd history poll failed", "error", err)
continue
}
if found {
return hist, nil
}
// Not in the queue and not in history: the job was removed out
// from under us.
return sabHistorySlot{}, ErrSabNoJob
}
}
// queueSlot looks for a job in the queue.
func (s *sabnzbd) queueSlot(
ctx context.Context,
nzoID string,
) (bool, sabQueueSlot, error) {
var queue sabQueue
if err := s.call(ctx, url.Values{"mode": {"queue"}}, &queue); err != nil {
return false, sabQueueSlot{}, err
}
for _, slot := range queue.Queue.Slots {
if slot.NzoID == nzoID {
return true, slot, nil
}
}
return false, sabQueueSlot{}, nil
}
// historySlot looks for a job in history.
func (s *sabnzbd) historySlot(
ctx context.Context,
nzoID string,
) (bool, sabHistorySlot, error) {
var history sabHistory
if err := s.call(
ctx, url.Values{"mode": {"history"}}, &history,
); err != nil {
return false, sabHistorySlot{}, err
}
for _, slot := range history.History.Slots {
if slot.NzoID == nzoID {
return true, slot, nil
}
}
return false, sabHistorySlot{}, nil
}
// call performs one API request. SABnzbd puts everything on the query
// string of a single endpoint.
func (s *sabnzbd) call(ctx context.Context, params url.Values, out any) error {
params.Set("apikey", s.apiKey)
params.Set("output", "json")
return s.client.get(ctx, "/api?"+params.Encode(), out)
}
// parseMB converts SABnzbd's megabyte strings to bytes. Values are
// decimal strings like "1024.5"; a malformed one yields 0 rather than
// failing a transfer that is otherwise fine.
func parseMB(s string) int64 {
const bytesPerMB = 1024 * 1024
var mb float64
if _, err := fmt.Sscanf(strings.TrimSpace(s), "%f", &mb); err != nil {
return 0
}
return int64(mb * bytesPerMB)
}