package torbox import ( "bytes" "cmp" "context" "encoding/json" "fmt" "io" "mime/multipart" "net/http" "seanime/internal/constants" "seanime/internal/debrid/debrid" "seanime/internal/util" "slices" "strconv" "strings" "time" "github.com/rs/zerolog" "github.com/samber/mo" ) type ( TorBox struct { baseUrl string apiKey mo.Option[string] client *http.Client logger *zerolog.Logger } Response struct { Success bool `json:"success"` Detail string `json:"detail"` Data interface{} `json:"data"` } File struct { ID int `json:"id"` MD5 string `json:"md5"` S3Path string `json:"s3_path"` Name string `json:"name"` Size int `json:"size"` MimeType string `json:"mimetype"` ShortName string `json:"short_name"` } Torrent struct { ID int `json:"id"` Hash string `json:"hash"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` Magnet string `json:"magnet"` Size int64 `json:"size"` Active bool `json:"active"` AuthID string `json:"auth_id"` DownloadState string `json:"download_state"` Seeds int `json:"seeds"` Peers int `json:"peers"` Ratio float64 `json:"ratio"` Progress float64 `json:"progress"` DownloadSpeed float64 `json:"download_speed"` UploadSpeed float64 `json:"upload_speed"` Name string `json:"name"` ETA int64 `json:"eta"` Server float64 `json:"server"` TorrentFile bool `json:"torrent_file"` ExpiresAt string `json:"expires_at"` DownloadPresent bool `json:"download_present"` DownloadFinished bool `json:"download_finished"` Files []*File `json:"files"` InactiveCheck int `json:"inactive_check"` Availability float64 `json:"availability"` } TorrentInfo struct { Name string `json:"name"` Hash string `json:"hash"` Size int64 `json:"size"` Files []*TorrentInfoFile `json:"files"` } TorrentInfoFile struct { Name string `json:"name"` // e.g. "Big Buck Bunny/Big Buck Bunny.mp4" Size int64 `json:"size"` } InstantAvailabilityItem struct { Name string `json:"name"` Hash string `json:"hash"` Size int64 `json:"size"` Files []struct { Name string `json:"name"` Size int64 `json:"size"` } `json:"files"` } ) func NewTorBox(logger *zerolog.Logger) debrid.Provider { return &TorBox{ baseUrl: "https://api.torbox.app/v1/api", apiKey: mo.None[string](), client: &http.Client{ Timeout: 30 * time.Second, Transport: &http.Transport{ MaxIdleConns: 10, MaxIdleConnsPerHost: 5, IdleConnTimeout: 90 * time.Second, }, }, logger: logger, } } func (t *TorBox) GetSettings() debrid.Settings { return debrid.Settings{ ID: "torbox", Name: "TorBox", } } func (t *TorBox) doQuery(method, uri string, body io.Reader, contentType string) (*Response, error) { return t.doQueryCtx(context.Background(), method, uri, body, contentType) } func (t *TorBox) doQueryCtx(ctx context.Context, method, uri string, body io.Reader, contentType string) (*Response, error) { apiKey, found := t.apiKey.Get() if !found { return nil, debrid.ErrNotAuthenticated } req, err := http.NewRequestWithContext(ctx, method, uri, body) if err != nil { return nil, err } req.Header.Add("Content-Type", contentType) req.Header.Add("Authorization", "Bearer "+apiKey) req.Header.Add("User-Agent", "Seanime/"+constants.Version) resp, err := t.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode >= 400 { bodyB, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("request failed: code %d, body: %s", resp.StatusCode, string(bodyB)) } bodyB, err := io.ReadAll(resp.Body) if err != nil { t.logger.Error().Err(err).Msg("torbox: Failed to read response body") return nil, err } var ret Response if err := json.Unmarshal(bodyB, &ret); err != nil { trimmedBody := string(bodyB) if len(trimmedBody) > 2000 { trimmedBody = trimmedBody[:2000] + "..." } t.logger.Error().Err(err).Msg("torbox: Failed to decode response, response body: " + trimmedBody) return nil, err } if !ret.Success { return nil, fmt.Errorf("request failed: %s", ret.Detail) } return &ret, nil } ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// func (t *TorBox) Authenticate(apiKey string) error { t.apiKey = mo.Some(apiKey) return nil } func (t *TorBox) GetInstantAvailability(hashes []string) map[string]debrid.TorrentItemInstantAvailability { t.logger.Trace().Strs("hashes", hashes).Msg("torbox: Checking instant availability") availability := make(map[string]debrid.TorrentItemInstantAvailability) if len(hashes) == 0 { return availability } var hashBatches [][]string for i := 0; i < len(hashes); i += 100 { end := i + 100 if end > len(hashes) { end = len(hashes) } hashBatches = append(hashBatches, hashes[i:end]) } for _, batch := range hashBatches { resp, err := t.doQuery("GET", t.baseUrl+fmt.Sprintf("/torrents/checkcached?hash=%s&format=list&list_files=true", strings.Join(batch, ",")), nil, "application/json") if err != nil { return availability } marshaledData, _ := json.Marshal(resp.Data) var items []*InstantAvailabilityItem err = json.Unmarshal(marshaledData, &items) if err != nil { return availability } for _, item := range items { availability[item.Hash] = debrid.TorrentItemInstantAvailability{ CachedFiles: make(map[string]*debrid.CachedFile), } for idx, file := range item.Files { availability[item.Hash].CachedFiles[strconv.Itoa(idx)] = &debrid.CachedFile{ Name: file.Name, Size: file.Size, } } } } return availability } func (t *TorBox) AddTorrent(opts debrid.AddTorrentOptions) (string, error) { // Check if the torrent is already added by checking existing torrents if opts.InfoHash != "" { // First check if it's already in our account using a more efficient approach torrents, err := t.getTorrents() if err == nil { for _, torrent := range torrents { if torrent.Hash == opts.InfoHash { return strconv.Itoa(torrent.ID), nil } } } // Small delay to avoid rate limiting time.Sleep(500 * time.Millisecond) } var body bytes.Buffer writer := multipart.NewWriter(&body) t.logger.Trace().Str("magnetLink", opts.MagnetLink).Msg("torbox: Adding torrent") err := writer.WriteField("magnet", opts.MagnetLink) if err != nil { return "", fmt.Errorf("torbox: Failed to add torrent: %w", err) } err = writer.WriteField("seed", "1") if err != nil { return "", fmt.Errorf("torbox: Failed to add torrent: %w", err) } err = writer.Close() if err != nil { return "", fmt.Errorf("torbox: Failed to add torrent: %w", err) } resp, err := t.doQuery("POST", t.baseUrl+"/torrents/createtorrent", &body, writer.FormDataContentType()) if err != nil { return "", fmt.Errorf("torbox: Failed to add torrent: %w", err) } type data struct { ID int `json:"torrent_id"` Name string `json:"name"` Hash string `json:"hash"` } marshaledData, _ := json.Marshal(resp.Data) var d data err = json.Unmarshal(marshaledData, &d) if err != nil { return "", fmt.Errorf("torbox: Failed to add torrent: %w", err) } t.logger.Debug().Str("torrentId", strconv.Itoa(d.ID)).Str("torrentName", d.Name).Str("torrentHash", d.Hash).Msg("torbox: Torrent added") return strconv.Itoa(d.ID), nil } // GetTorrentStreamUrl blocks until the torrent is downloaded and returns the stream URL for the torrent file by calling GetTorrentDownloadUrl. func (t *TorBox) GetTorrentStreamUrl(ctx context.Context, opts debrid.StreamTorrentOptions, itemCh chan debrid.TorrentItem) (streamUrl string, err error) { t.logger.Trace().Str("torrentId", opts.ID).Str("fileId", opts.FileId).Msg("torbox: Retrieving stream link") doneCh := make(chan struct{}) go func(ctx context.Context) { defer func() { close(doneCh) }() for { select { case <-ctx.Done(): err = ctx.Err() return case <-time.After(4 * time.Second): torrent, _err := t.GetTorrent(opts.ID) if _err != nil { t.logger.Error().Err(_err).Msg("torbox: Failed to get torrent") err = fmt.Errorf("torbox: Failed to get torrent: %w", _err) return } itemCh <- *torrent // Check if the torrent is ready if torrent.IsReady { time.Sleep(1 * time.Second) downloadUrl, err := t.GetTorrentDownloadUrl(debrid.DownloadTorrentOptions{ ID: opts.ID, FileId: opts.FileId, // Filename }) if err != nil { t.logger.Error().Err(err).Msg("torbox: Failed to get download URL") return } streamUrl = downloadUrl return } } } }(ctx) <-doneCh return } func (t *TorBox) GetTorrentDownloadUrl(opts debrid.DownloadTorrentOptions) (downloadUrl string, err error) { t.logger.Trace().Str("torrentId", opts.ID).Msg("torbox: Retrieving download link") apiKey, found := t.apiKey.Get() if !found { return "", fmt.Errorf("torbox: Failed to get download URL: %w", debrid.ErrNotAuthenticated) } url := t.baseUrl + fmt.Sprintf("/torrents/requestdl?token=%s&torrent_id=%s&zip_link=true", apiKey, opts.ID) if opts.FileId != "" { // Get the actual file ID torrent, err := t.getTorrent(opts.ID) if err != nil { return "", fmt.Errorf("torbox: Failed to get download URL: %w", err) } var fId string for _, f := range torrent.Files { if f.ShortName == opts.FileId { fId = strconv.Itoa(f.ID) break } } if fId == "" { return "", fmt.Errorf("torbox: Failed to get download URL, file not found") } url = t.baseUrl + fmt.Sprintf("/torrents/requestdl?token=%s&torrent_id=%s&file_id=%s", apiKey, opts.ID, fId) } resp, err := t.doQuery("GET", url, nil, "application/json") if err != nil { return "", fmt.Errorf("torbox: Failed to get download URL: %w", err) } marshaledData, _ := json.Marshal(resp.Data) var d string err = json.Unmarshal(marshaledData, &d) if err != nil { return "", fmt.Errorf("torbox: Failed to get download URL: %w", err) } t.logger.Debug().Str("downloadUrl", d).Msg("torbox: Download link retrieved") return d, nil } func (t *TorBox) GetTorrent(id string) (ret *debrid.TorrentItem, err error) { torrent, err := t.getTorrent(id) if err != nil { return nil, err } ret = toDebridTorrent(torrent) return ret, nil } func (t *TorBox) getTorrent(id string) (ret *Torrent, err error) { resp, err := t.doQuery("GET", t.baseUrl+fmt.Sprintf("/torrents/mylist?bypass_cache=true&id=%s", id), nil, "application/json") if err != nil { return nil, fmt.Errorf("torbox: Failed to get torrent: %w", err) } marshaledData, _ := json.Marshal(resp.Data) err = json.Unmarshal(marshaledData, &ret) if err != nil { return nil, fmt.Errorf("torbox: Failed to parse torrent: %w", err) } return ret, nil } // GetTorrentInfo uses the info hash to return the torrent's data. // For cached torrents, it uses the /checkcached endpoint for faster response. // For uncached torrents, it falls back to /torrentinfo endpoint. func (t *TorBox) GetTorrentInfo(opts debrid.GetTorrentInfoOptions) (ret *debrid.TorrentInfo, err error) { if opts.InfoHash == "" { return nil, fmt.Errorf("torbox: No info hash provided") } resp, err := t.doQuery("GET", t.baseUrl+fmt.Sprintf("/torrents/checkcached?hash=%s&format=object&list_files=true", opts.InfoHash), nil, "application/json") if err != nil { return nil, fmt.Errorf("torbox: Failed to check cached torrent: %w", err) } // If the torrent is cached if resp.Data != nil { data, ok := resp.Data.(map[string]interface{}) if ok { if torrentData, exists := data[opts.InfoHash]; exists { marshaledData, _ := json.Marshal(torrentData) var torrent TorrentInfo err = json.Unmarshal(marshaledData, &torrent) if err != nil { return nil, fmt.Errorf("torbox: Failed to parse cached torrent: %w", err) } ret = toDebridTorrentInfo(&torrent) return ret, nil } } } // If not cached, fall back resp, err = t.doQuery("GET", t.baseUrl+fmt.Sprintf("/torrents/torrentinfo?hash=%s&timeout=15", opts.InfoHash), nil, "application/json") if err != nil { return nil, fmt.Errorf("torbox: Failed to get torrent info: %w", err) } // DEVNOTE: Handle incorrect TorBox API response data, ok := resp.Data.(map[string]interface{}) if ok { if _, ok := data["data"]; ok { if _, ok := data["data"].(map[string]interface{}); ok { data = data["data"].(map[string]interface{}) } else { return nil, fmt.Errorf("torbox: Failed to parse response") } } } marshaledData, _ := json.Marshal(data) var torrent TorrentInfo err = json.Unmarshal(marshaledData, &torrent) if err != nil { return nil, fmt.Errorf("torbox: Failed to parse torrent: %w", err) } ret = toDebridTorrentInfo(&torrent) return ret, nil } func (t *TorBox) GetTorrents() (ret []*debrid.TorrentItem, err error) { torrents, err := t.getTorrents() if err != nil { return nil, fmt.Errorf("torbox: Failed to get torrents: %w", err) } // Limit the number of torrents to 500 if len(torrents) > 500 { torrents = torrents[:500] } for _, t := range torrents { ret = append(ret, toDebridTorrent(t)) } slices.SortFunc(ret, func(i, j *debrid.TorrentItem) int { return cmp.Compare(j.AddedAt, i.AddedAt) }) return ret, nil } func (t *TorBox) getTorrents() (ret []*Torrent, err error) { resp, err := t.doQuery("GET", t.baseUrl+"/torrents/mylist?bypass_cache=true", nil, "application/json") if err != nil { return nil, fmt.Errorf("torbox: Failed to get torrents: %w", err) } marshaledData, _ := json.Marshal(resp.Data) err = json.Unmarshal(marshaledData, &ret) if err != nil { t.logger.Error().Err(err).Msg("Failed to parse torrents") return nil, fmt.Errorf("torbox: Failed to parse torrents: %w", err) } return ret, nil } func toDebridTorrent(t *Torrent) (ret *debrid.TorrentItem) { addedAt, _ := time.Parse(time.RFC3339Nano, t.CreatedAt) completionPercentage := int(t.Progress * 100) ret = &debrid.TorrentItem{ ID: strconv.Itoa(t.ID), Name: t.Name, Hash: t.Hash, Size: t.Size, FormattedSize: util.Bytes(uint64(t.Size)), CompletionPercentage: completionPercentage, ETA: util.FormatETA(int(t.ETA)), Status: toDebridTorrentStatus(t), AddedAt: addedAt.Format(time.RFC3339), Speed: util.ToHumanReadableSpeed(int(t.DownloadSpeed)), Seeders: t.Seeds, IsReady: t.DownloadPresent, } return } func toDebridTorrentInfo(t *TorrentInfo) (ret *debrid.TorrentInfo) { var files []*debrid.TorrentItemFile for idx, f := range t.Files { nameParts := strings.Split(f.Name, "/") var name string if len(nameParts) == 1 { name = nameParts[0] } else { name = nameParts[len(nameParts)-1] } files = append(files, &debrid.TorrentItemFile{ ID: name, // Set the ID to the og name so GetStreamUrl can use that to get the real file ID Index: idx, Name: name, // e.g. "Big Buck Bunny.mp4" Path: fmt.Sprintf("/%s", f.Name), // e.g. "/Big Buck Bunny/Big Buck Bunny.mp4" Size: f.Size, }) } ret = &debrid.TorrentInfo{ Name: t.Name, Hash: t.Hash, Size: t.Size, Files: files, } return } func toDebridTorrentStatus(t *Torrent) debrid.TorrentItemStatus { if t.DownloadFinished && t.DownloadPresent { switch t.DownloadState { case "uploading": return debrid.TorrentItemStatusSeeding default: return debrid.TorrentItemStatusCompleted } } switch t.DownloadState { case "downloading", "metaDL": return debrid.TorrentItemStatusDownloading case "stalled", "stalled (no seeds)": return debrid.TorrentItemStatusStalled case "completed", "cached": return debrid.TorrentItemStatusCompleted case "uploading": return debrid.TorrentItemStatusSeeding case "paused": return debrid.TorrentItemStatusPaused default: return debrid.TorrentItemStatusOther } } func (t *TorBox) DeleteTorrent(id string) error { type body = struct { ID int `json:"torrent_id"` Operation string `json:"operation"` } b := body{ ID: util.StringToIntMust(id), Operation: "delete", } marshaledData, _ := json.Marshal(b) _, err := t.doQuery("POST", t.baseUrl+fmt.Sprintf("/torrents/controltorrent"), bytes.NewReader(marshaledData), "application/json") if err != nil { return fmt.Errorf("torbox: Failed to delete torrent: %w", err) } return nil }