package download import ( "context" "encoding/json" "fmt" "io" "net/http" "strconv" "strings" "time" ) type SABnzbdClient struct { baseURL string apiKey string client *http.Client } func NewSABnzbdClient(baseURL, apiKey string) *SABnzbdClient { return &SABnzbdClient{ baseURL: strings.TrimRight(baseURL, "/"), apiKey: apiKey, client: &http.Client{Timeout: 30 * time.Second}, } } func (s *SABnzbdClient) Add(ctx context.Context, url string, category string) (string, error) { if category == "" { category = "umm" } apiURL := fmt.Sprintf("%s/api?mode=addurl&output=json&apikey=%s&name=%s&nzbname=umm&cat=%s", s.baseURL, s.apiKey, url, category) resp, err := s.doRequest(ctx, apiURL) if err != nil { return "", fmt.Errorf("sabnzbd add: %w", err) } defer resp.Body.Close() var result sabAddResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", fmt.Errorf("sabnzbd add decode: %w", err) } if !result.Status { return "", fmt.Errorf("sabnzbd add failed: %s", result.Error) } if len(result.NzoIDs) == 0 { return "", fmt.Errorf("sabnzbd add: no nzo_id returned") } return result.NzoIDs[0], nil } func (s *SABnzbdClient) GetProgress(ctx context.Context, id string) (*DownloadProgress, error) { apiURL := fmt.Sprintf("%s/api?mode=queue&output=json&apikey=%s", s.baseURL, s.apiKey) resp, err := s.doRequest(ctx, apiURL) if err != nil { return nil, fmt.Errorf("sabnzbd queue: %w", err) } defer resp.Body.Close() var result sabQueueResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("sabnzbd queue decode: %w", err) } for _, slot := range result.Queue.Slots { if slot.NzoID == id { sizeMB, _ := strconv.ParseFloat(slot.SizeTotal, 64) downloadedMB, _ := strconv.ParseFloat(slot.MB, 64) size := int64(sizeMB * 1024 * 1024) progress := float64(0) if sizeMB > 0 { progress = (downloadedMB / sizeMB) * 100 } eta := parseTimeLeft(slot.TimeLeft) return &DownloadProgress{ ID: slot.NzoID, Name: slot.Filename, Status: slot.Status, Progress: progress, Speed: int64(slot.Speed * 1024), ETA: eta, Size: size, }, nil } } return nil, fmt.Errorf("sabnzbd: item %s not found in queue", id) } func (s *SABnzbdClient) GetCompleted(ctx context.Context) ([]CompletedDownload, error) { apiURL := fmt.Sprintf("%s/api?mode=history&output=json&apikey=%s", s.baseURL, s.apiKey) resp, err := s.doRequest(ctx, apiURL) if err != nil { return nil, fmt.Errorf("sabnzbd history: %w", err) } defer resp.Body.Close() var result sabHistoryResponse if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("sabnzbd history decode: %w", err) } var completed []CompletedDownload for _, slot := range result.History.Slots { if strings.EqualFold(slot.Status, "Completed") { size, _ := strconv.ParseInt(slot.Size, 10, 64) completed = append(completed, CompletedDownload{ ID: slot.NzoID, Name: slot.Name, OutputPath: slot.Storage, Size: size, Status: slot.Status, }) } } return completed, nil } func (s *SABnzbdClient) Remove(ctx context.Context, id string) error { apiURL := fmt.Sprintf("%s/api?mode=queue&output=json&apikey=%s&name=delete&value=%s&del_files=1", s.baseURL, s.apiKey, id) resp, err := s.doRequest(ctx, apiURL) if err != nil { return fmt.Errorf("sabnzbd remove: %w", err) } resp.Body.Close() return nil } func (s *SABnzbdClient) Pause(ctx context.Context, id string) error { apiURL := fmt.Sprintf("%s/api?mode=queue&output=json&apikey=%s&name=pause&id=%s", s.baseURL, s.apiKey, id) resp, err := s.doRequest(ctx, apiURL) if err != nil { return fmt.Errorf("sabnzbd pause: %w", err) } resp.Body.Close() return nil } func (s *SABnzbdClient) Resume(ctx context.Context, id string) error { apiURL := fmt.Sprintf("%s/api?mode=queue&output=json&apikey=%s&name=resume&id=%s", s.baseURL, s.apiKey, id) resp, err := s.doRequest(ctx, apiURL) if err != nil { return fmt.Errorf("sabnzbd resume: %w", err) } resp.Body.Close() return nil } func (s *SABnzbdClient) doRequest(ctx context.Context, url string) (*http.Response, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return nil, err } return s.client.Do(req) } func parseTimeLeft(timeLeft string) int { if timeLeft == "" { return 0 } parts := strings.Split(timeLeft, ":") if len(parts) != 3 { return 0 } hours, _ := strconv.Atoi(parts[0]) minutes, _ := strconv.Atoi(parts[1]) seconds, _ := strconv.Atoi(parts[2]) return hours*3600 + minutes*60 + seconds } type sabAddResponse struct { Status bool `json:"status"` NzoIDs []string `json:"nzo_ids"` Error string `json:"error,omitempty"` } type sabQueueResponse struct { Queue struct { Slots []sabQueueSlot `json:"slots"` } `json:"queue"` } type sabQueueSlot struct { NzoID string `json:"nzo_id"` Filename string `json:"filename"` Status string `json:"status"` MB string `json:"mb"` SizeTotal string `json:"sizeleft"` TimeLeft string `json:"timeleft"` Speed float64 `json:"speed"` Percentage string `json:"percentage"` } type sabHistoryResponse struct { History struct { Slots []sabHistorySlot `json:"slots"` } `json:"history"` } type sabHistorySlot struct { NzoID string `json:"nzo_id"` Name string `json:"name"` Status string `json:"status"` Storage string `json:"storage"` Size string `json:"size"` } func init() { _ = io.EOF }