628 lines
16 KiB
Go
628 lines
16 KiB
Go
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
|
|
}
|