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
389 lines
9.0 KiB
Go
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)
|
|
}
|