All checks were successful
CI/CD / Build & Push Docker Image (push) Successful in 1m10s
- Add 'checking' to the list of downloading states in getTorboxStatus() so TorBox torrents in transitional state don't get marked as error - Add debug log before calling onSuccess to trace state update flow Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
673 lines
17 KiB
Go
673 lines
17 KiB
Go
package torbox
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"mime/multipart"
|
|
"net/http"
|
|
gourl "net/url"
|
|
"path"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog"
|
|
"github.com/sirrobot01/decypharr/internal/config"
|
|
"github.com/sirrobot01/decypharr/internal/logger"
|
|
"github.com/sirrobot01/decypharr/internal/request"
|
|
"github.com/sirrobot01/decypharr/internal/utils"
|
|
"github.com/sirrobot01/decypharr/pkg/debrid/account"
|
|
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
|
"github.com/sirrobot01/decypharr/pkg/version"
|
|
"go.uber.org/ratelimit"
|
|
)
|
|
|
|
type Torbox struct {
|
|
name string
|
|
Host string `json:"host"`
|
|
APIKey string
|
|
accountsManager *account.Manager
|
|
autoExpiresLinksAfter time.Duration
|
|
|
|
DownloadUncached bool
|
|
client *request.Client
|
|
|
|
MountPath string
|
|
logger zerolog.Logger
|
|
checkCached bool
|
|
addSamples bool
|
|
}
|
|
|
|
func New(dc config.Debrid, ratelimits map[string]ratelimit.Limiter) (*Torbox, error) {
|
|
|
|
headers := map[string]string{
|
|
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
|
|
"User-Agent": fmt.Sprintf("Decypharr/%s (%s; %s)", version.GetInfo(), runtime.GOOS, runtime.GOARCH),
|
|
}
|
|
_log := logger.New(dc.Name)
|
|
client := request.New(
|
|
request.WithHeaders(headers),
|
|
request.WithRateLimiter(ratelimits["main"]),
|
|
request.WithLogger(_log),
|
|
request.WithProxy(dc.Proxy),
|
|
)
|
|
autoExpiresLinksAfter, err := time.ParseDuration(dc.AutoExpireLinksAfter)
|
|
if autoExpiresLinksAfter == 0 || err != nil {
|
|
autoExpiresLinksAfter = 48 * time.Hour
|
|
}
|
|
|
|
return &Torbox{
|
|
name: "torbox",
|
|
Host: "https://api.torbox.app/v1",
|
|
APIKey: dc.APIKey,
|
|
accountsManager: account.NewManager(dc, ratelimits["download"], _log),
|
|
DownloadUncached: dc.DownloadUncached,
|
|
autoExpiresLinksAfter: autoExpiresLinksAfter,
|
|
client: client,
|
|
MountPath: dc.Folder,
|
|
logger: _log,
|
|
checkCached: dc.CheckCached,
|
|
addSamples: dc.AddSamples,
|
|
}, nil
|
|
}
|
|
|
|
func (tb *Torbox) Name() string {
|
|
return tb.name
|
|
}
|
|
|
|
func (tb *Torbox) Logger() zerolog.Logger {
|
|
return tb.logger
|
|
}
|
|
|
|
func (tb *Torbox) IsAvailable(hashes []string) map[string]bool {
|
|
// Check if the infohashes are available in the local cache
|
|
result := make(map[string]bool)
|
|
|
|
// Divide hashes into groups of 100
|
|
for i := 0; i < len(hashes); i += 100 {
|
|
end := i + 100
|
|
if end > len(hashes) {
|
|
end = len(hashes)
|
|
}
|
|
|
|
// Filter out empty strings
|
|
validHashes := make([]string, 0, end-i)
|
|
for _, hash := range hashes[i:end] {
|
|
if hash != "" {
|
|
validHashes = append(validHashes, hash)
|
|
}
|
|
}
|
|
|
|
// If no valid hashes in this batch, continue to the next batch
|
|
if len(validHashes) == 0 {
|
|
continue
|
|
}
|
|
|
|
hashStr := strings.Join(validHashes, ",")
|
|
url := fmt.Sprintf("%s/api/torrents/checkcached?hash=%s", tb.Host, hashStr)
|
|
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
|
resp, err := tb.client.MakeRequest(req)
|
|
if err != nil {
|
|
tb.logger.Error().Err(err).Msgf("Error checking availability")
|
|
return result
|
|
}
|
|
var res AvailableResponse
|
|
err = json.Unmarshal(resp, &res)
|
|
if err != nil {
|
|
tb.logger.Error().Err(err).Msgf("Error marshalling availability")
|
|
return result
|
|
}
|
|
if res.Data == nil {
|
|
return result
|
|
}
|
|
|
|
for h, c := range *res.Data {
|
|
if c.Size > 0 {
|
|
result[strings.ToUpper(h)] = true
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (tb *Torbox) SubmitMagnet(torrent *types.Torrent) (*types.Torrent, error) {
|
|
url := fmt.Sprintf("%s/api/torrents/createtorrent", tb.Host)
|
|
payload := &bytes.Buffer{}
|
|
writer := multipart.NewWriter(payload)
|
|
_ = writer.WriteField("magnet", torrent.Magnet.Link)
|
|
if !torrent.DownloadUncached {
|
|
_ = writer.WriteField("add_only_if_cached", "true")
|
|
}
|
|
err := writer.Close()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req, _ := http.NewRequest(http.MethodPost, url, payload)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
resp, err := tb.client.MakeRequest(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var data AddMagnetResponse
|
|
err = json.Unmarshal(resp, &data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if data.Data == nil {
|
|
return nil, fmt.Errorf("error adding torrent")
|
|
}
|
|
dt := *data.Data
|
|
torrentId := strconv.Itoa(dt.Id)
|
|
torrent.Id = torrentId
|
|
torrent.MountPath = tb.MountPath
|
|
torrent.Debrid = tb.name
|
|
torrent.Added = time.Now().Format(time.RFC3339)
|
|
|
|
return torrent, nil
|
|
}
|
|
|
|
func (tb *Torbox) getTorboxStatus(status string, finished bool) string {
|
|
// Log raw values for debugging
|
|
tb.logger.Debug().
|
|
Str("download_state", status).
|
|
Bool("download_finished", finished).
|
|
Msg("getTorboxStatus called")
|
|
|
|
// For cached/completed torrents, content is immediately available even if
|
|
// DownloadFinished=false (no download actually happened - it was already cached)
|
|
// Use case-insensitive comparison for safety
|
|
statusLower := strings.ToLower(status)
|
|
if finished || statusLower == "cached" || statusLower == "completed" {
|
|
return "downloaded"
|
|
}
|
|
downloading := []string{"paused", "downloading", "uploading",
|
|
"checkingResumeData", "metaDL", "pausedUP", "queuedUP", "checkingUP",
|
|
"forcedUP", "allocating", "downloading", "metaDL", "pausedDL",
|
|
"queuedDL", "checkingDL", "forcedDL", "checkingResumeData", "moving",
|
|
"checking"}
|
|
|
|
var determinedStatus string
|
|
switch {
|
|
case utils.Contains(downloading, status):
|
|
determinedStatus = "downloading"
|
|
default:
|
|
determinedStatus = "error"
|
|
}
|
|
|
|
return determinedStatus
|
|
}
|
|
|
|
func (tb *Torbox) GetTorrent(torrentId string) (*types.Torrent, error) {
|
|
url := fmt.Sprintf("%s/api/torrents/mylist/?id=%s", tb.Host, torrentId)
|
|
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
|
resp, err := tb.client.MakeRequest(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var res InfoResponse
|
|
err = json.Unmarshal(resp, &res)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
data := res.Data
|
|
if data == nil {
|
|
return nil, fmt.Errorf("error getting torrent")
|
|
}
|
|
t := &types.Torrent{
|
|
Id: strconv.Itoa(data.Id),
|
|
Name: data.Name,
|
|
Bytes: data.Size,
|
|
Folder: data.Name,
|
|
Progress: data.Progress * 100,
|
|
Status: tb.getTorboxStatus(data.DownloadState, data.DownloadFinished),
|
|
Speed: data.DownloadSpeed,
|
|
Seeders: data.Seeds,
|
|
Filename: data.Name,
|
|
OriginalFilename: data.Name,
|
|
MountPath: tb.MountPath,
|
|
Debrid: tb.name,
|
|
Files: make(map[string]types.File),
|
|
Added: data.CreatedAt.Format(time.RFC3339),
|
|
}
|
|
cfg := config.Get()
|
|
|
|
totalFiles := 0
|
|
skippedSamples := 0
|
|
skippedFileType := 0
|
|
skippedSize := 0
|
|
validFiles := 0
|
|
filesWithLinks := 0
|
|
|
|
for _, f := range data.Files {
|
|
totalFiles++
|
|
fileName := filepath.Base(f.Name)
|
|
|
|
if !tb.addSamples && utils.IsSampleFile(f.AbsolutePath) {
|
|
skippedSamples++
|
|
continue
|
|
}
|
|
if !cfg.IsAllowedFile(fileName) {
|
|
skippedFileType++
|
|
continue
|
|
}
|
|
|
|
if !cfg.IsSizeAllowed(f.Size) {
|
|
skippedSize++
|
|
continue
|
|
}
|
|
|
|
validFiles++
|
|
file := types.File{
|
|
TorrentId: t.Id,
|
|
Id: strconv.Itoa(f.Id),
|
|
Name: fileName,
|
|
Size: f.Size,
|
|
Path: f.Name,
|
|
}
|
|
|
|
// For downloaded torrents, set a placeholder link to indicate file is available
|
|
if data.DownloadFinished {
|
|
file.Link = fmt.Sprintf("torbox://%s/%d", t.Id, f.Id)
|
|
filesWithLinks++
|
|
}
|
|
|
|
t.Files[fileName] = file
|
|
}
|
|
|
|
// Log summary only if there are issues or for debugging
|
|
tb.logger.Debug().
|
|
Str("torrent_id", t.Id).
|
|
Str("torrent_name", t.Name).
|
|
Bool("download_finished", data.DownloadFinished).
|
|
Str("status", t.Status).
|
|
Int("total_files", totalFiles).
|
|
Int("valid_files", validFiles).
|
|
Int("final_file_count", len(t.Files)).
|
|
Msg("Torrent file processing completed")
|
|
var cleanPath string
|
|
if len(t.Files) > 0 {
|
|
cleanPath = path.Clean(data.Files[0].Name)
|
|
} else {
|
|
cleanPath = path.Clean(data.Name)
|
|
}
|
|
|
|
t.OriginalFilename = strings.Split(cleanPath, "/")[0]
|
|
t.Debrid = tb.name
|
|
|
|
return t, nil
|
|
}
|
|
|
|
func (tb *Torbox) UpdateTorrent(t *types.Torrent) error {
|
|
url := fmt.Sprintf("%s/api/torrents/mylist/?id=%s", tb.Host, t.Id)
|
|
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
|
resp, err := tb.client.MakeRequest(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var res InfoResponse
|
|
err = json.Unmarshal(resp, &res)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data := res.Data
|
|
name := data.Name
|
|
|
|
t.Name = name
|
|
t.Bytes = data.Size
|
|
t.Folder = name
|
|
t.Progress = data.Progress * 100
|
|
t.Status = tb.getTorboxStatus(data.DownloadState, data.DownloadFinished)
|
|
t.Speed = data.DownloadSpeed
|
|
t.Seeders = data.Seeds
|
|
t.Filename = name
|
|
t.OriginalFilename = name
|
|
t.MountPath = tb.MountPath
|
|
t.Debrid = tb.name
|
|
|
|
// Clear existing files map to rebuild it
|
|
t.Files = make(map[string]types.File)
|
|
|
|
cfg := config.Get()
|
|
validFiles := 0
|
|
filesWithLinks := 0
|
|
|
|
for _, f := range data.Files {
|
|
fileName := filepath.Base(f.Name)
|
|
|
|
if !tb.addSamples && utils.IsSampleFile(f.AbsolutePath) {
|
|
continue
|
|
}
|
|
|
|
if !cfg.IsAllowedFile(fileName) {
|
|
continue
|
|
}
|
|
|
|
if !cfg.IsSizeAllowed(f.Size) {
|
|
continue
|
|
}
|
|
|
|
validFiles++
|
|
file := types.File{
|
|
TorrentId: t.Id,
|
|
Id: strconv.Itoa(f.Id),
|
|
Name: fileName,
|
|
Size: f.Size,
|
|
Path: fileName,
|
|
}
|
|
|
|
// For downloaded torrents, set a placeholder link to indicate file is available
|
|
if data.DownloadFinished {
|
|
file.Link = fmt.Sprintf("torbox://%s/%s", t.Id, strconv.Itoa(f.Id))
|
|
filesWithLinks++
|
|
}
|
|
|
|
t.Files[fileName] = file
|
|
}
|
|
|
|
var cleanPath string
|
|
if len(t.Files) > 0 {
|
|
cleanPath = path.Clean(data.Files[0].Name)
|
|
} else {
|
|
cleanPath = path.Clean(data.Name)
|
|
}
|
|
|
|
t.OriginalFilename = strings.Split(cleanPath, "/")[0]
|
|
t.Debrid = tb.name
|
|
return nil
|
|
}
|
|
|
|
func (tb *Torbox) CheckStatus(torrent *types.Torrent) (*types.Torrent, error) {
|
|
for {
|
|
err := tb.UpdateTorrent(torrent)
|
|
|
|
if err != nil || torrent == nil {
|
|
return torrent, err
|
|
}
|
|
status := torrent.Status
|
|
if status == "downloaded" {
|
|
tb.logger.Info().Msgf("Torrent: %s downloaded", torrent.Name)
|
|
return torrent, nil
|
|
} else if utils.Contains(tb.GetDownloadingStatus(), status) {
|
|
if !torrent.DownloadUncached {
|
|
return torrent, fmt.Errorf("torrent: %s not cached", torrent.Name)
|
|
}
|
|
// Break out of the loop if the torrent is downloading.
|
|
// This is necessary to prevent infinite loop since we moved to sync downloading and async processing
|
|
return torrent, nil
|
|
} else {
|
|
return torrent, fmt.Errorf("torrent: %s has error", torrent.Name)
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
func (tb *Torbox) DeleteTorrent(torrentId string) error {
|
|
url := fmt.Sprintf("%s/api/torrents/controltorrent/%s", tb.Host, torrentId)
|
|
payload := map[string]string{"torrent_id": torrentId, "action": "Delete"}
|
|
jsonPayload, _ := json.Marshal(payload)
|
|
req, _ := http.NewRequest(http.MethodDelete, url, bytes.NewBuffer(jsonPayload))
|
|
if _, err := tb.client.MakeRequest(req); err != nil {
|
|
return err
|
|
}
|
|
tb.logger.Info().Msgf("Torrent %s deleted from Torbox", torrentId)
|
|
return nil
|
|
}
|
|
|
|
func (tb *Torbox) GetFileDownloadLinks(t *types.Torrent) error {
|
|
filesCh := make(chan types.File, len(t.Files))
|
|
linkCh := make(chan types.DownloadLink)
|
|
errCh := make(chan error, len(t.Files))
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(len(t.Files))
|
|
for _, file := range t.Files {
|
|
go func() {
|
|
defer wg.Done()
|
|
link, err := tb.GetDownloadLink(t, &file)
|
|
if err != nil {
|
|
errCh <- err
|
|
return
|
|
}
|
|
if link.DownloadLink != "" {
|
|
linkCh <- link
|
|
file.DownloadLink = link
|
|
}
|
|
filesCh <- file
|
|
}()
|
|
}
|
|
go func() {
|
|
wg.Wait()
|
|
close(filesCh)
|
|
close(linkCh)
|
|
close(errCh)
|
|
}()
|
|
|
|
// Collect results
|
|
files := make(map[string]types.File, len(t.Files))
|
|
for file := range filesCh {
|
|
files[file.Name] = file
|
|
}
|
|
|
|
// Check for errors
|
|
for err := range errCh {
|
|
if err != nil {
|
|
return err // Return the first error encountered
|
|
}
|
|
}
|
|
|
|
t.Files = files
|
|
return nil
|
|
}
|
|
|
|
func (tb *Torbox) GetDownloadLink(t *types.Torrent, file *types.File) (types.DownloadLink, error) {
|
|
url := fmt.Sprintf("%s/api/torrents/requestdl/", tb.Host)
|
|
query := gourl.Values{}
|
|
query.Add("torrent_id", t.Id)
|
|
query.Add("token", tb.APIKey)
|
|
query.Add("file_id", file.Id)
|
|
url += "?" + query.Encode()
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
|
resp, err := tb.client.MakeRequest(req)
|
|
if err != nil {
|
|
tb.logger.Error().
|
|
Err(err).
|
|
Str("torrent_id", t.Id).
|
|
Str("file_id", file.Id).
|
|
Msg("Failed to make request to Torbox API")
|
|
return types.DownloadLink{}, err
|
|
}
|
|
|
|
var data DownloadLinksResponse
|
|
if err = json.Unmarshal(resp, &data); err != nil {
|
|
tb.logger.Error().
|
|
Err(err).
|
|
Str("torrent_id", t.Id).
|
|
Str("file_id", file.Id).
|
|
Msg("Failed to unmarshal Torbox API response")
|
|
return types.DownloadLink{}, err
|
|
}
|
|
|
|
if data.Data == nil {
|
|
tb.logger.Error().
|
|
Str("torrent_id", t.Id).
|
|
Str("file_id", file.Id).
|
|
Bool("success", data.Success).
|
|
Interface("error", data.Error).
|
|
Str("detail", data.Detail).
|
|
Msg("Torbox API returned no data")
|
|
return types.DownloadLink{}, fmt.Errorf("error getting download links")
|
|
}
|
|
|
|
link := *data.Data
|
|
if link == "" {
|
|
tb.logger.Error().
|
|
Str("torrent_id", t.Id).
|
|
Str("file_id", file.Id).
|
|
Msg("Torbox API returned empty download link")
|
|
return types.DownloadLink{}, fmt.Errorf("error getting download links")
|
|
}
|
|
|
|
now := time.Now()
|
|
dl := types.DownloadLink{
|
|
Token: tb.APIKey,
|
|
Link: file.Link,
|
|
DownloadLink: link,
|
|
Id: file.Id,
|
|
Generated: now,
|
|
ExpiresAt: now.Add(tb.autoExpiresLinksAfter),
|
|
}
|
|
|
|
tb.accountsManager.StoreDownloadLink(dl)
|
|
|
|
return dl, nil
|
|
}
|
|
|
|
func (tb *Torbox) GetDownloadingStatus() []string {
|
|
return []string{"downloading"}
|
|
}
|
|
|
|
func (tb *Torbox) GetTorrents() ([]*types.Torrent, error) {
|
|
offset := 0
|
|
allTorrents := make([]*types.Torrent, 0)
|
|
|
|
for {
|
|
torrents, err := tb.getTorrents(offset)
|
|
if err != nil {
|
|
break
|
|
}
|
|
if len(torrents) == 0 {
|
|
break
|
|
}
|
|
allTorrents = append(allTorrents, torrents...)
|
|
offset += len(torrents)
|
|
}
|
|
return allTorrents, nil
|
|
}
|
|
|
|
func (tb *Torbox) getTorrents(offset int) ([]*types.Torrent, error) {
|
|
url := fmt.Sprintf("%s/api/torrents/mylist?offset=%d", tb.Host, offset)
|
|
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
|
resp, err := tb.client.MakeRequest(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var res TorrentsListResponse
|
|
err = json.Unmarshal(resp, &res)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !res.Success || res.Data == nil {
|
|
return nil, fmt.Errorf("torbox API error: %v", res.Error)
|
|
}
|
|
|
|
torrents := make([]*types.Torrent, 0, len(*res.Data))
|
|
cfg := config.Get()
|
|
|
|
for _, data := range *res.Data {
|
|
t := &types.Torrent{
|
|
Id: strconv.Itoa(data.Id),
|
|
Name: data.Name,
|
|
Bytes: data.Size,
|
|
Folder: data.Name,
|
|
Progress: data.Progress * 100,
|
|
Status: tb.getTorboxStatus(data.DownloadState, data.DownloadFinished),
|
|
Speed: data.DownloadSpeed,
|
|
Seeders: data.Seeds,
|
|
Filename: data.Name,
|
|
OriginalFilename: data.Name,
|
|
MountPath: tb.MountPath,
|
|
Debrid: tb.name,
|
|
Files: make(map[string]types.File),
|
|
Added: data.CreatedAt.Format(time.RFC3339),
|
|
InfoHash: data.Hash,
|
|
}
|
|
|
|
// Process files
|
|
for _, f := range data.Files {
|
|
fileName := filepath.Base(f.Name)
|
|
if !tb.addSamples && utils.IsSampleFile(f.AbsolutePath) {
|
|
// Skip sample files
|
|
continue
|
|
}
|
|
if !cfg.IsAllowedFile(fileName) {
|
|
continue
|
|
}
|
|
if !cfg.IsSizeAllowed(f.Size) {
|
|
continue
|
|
}
|
|
file := types.File{
|
|
TorrentId: t.Id,
|
|
Id: strconv.Itoa(f.Id),
|
|
Name: fileName,
|
|
Size: f.Size,
|
|
Path: f.Name,
|
|
}
|
|
|
|
// For downloaded torrents, set a placeholder link to indicate file is available
|
|
if data.DownloadFinished {
|
|
file.Link = fmt.Sprintf("torbox://%s/%d", t.Id, f.Id)
|
|
}
|
|
|
|
t.Files[fileName] = file
|
|
}
|
|
|
|
// Set original filename based on first file or torrent name
|
|
var cleanPath string
|
|
if len(t.Files) > 0 {
|
|
cleanPath = path.Clean(data.Files[0].Name)
|
|
} else {
|
|
cleanPath = path.Clean(data.Name)
|
|
}
|
|
t.OriginalFilename = strings.Split(cleanPath, "/")[0]
|
|
|
|
torrents = append(torrents, t)
|
|
}
|
|
|
|
return torrents, nil
|
|
}
|
|
|
|
func (tb *Torbox) GetDownloadUncached() bool {
|
|
return tb.DownloadUncached
|
|
}
|
|
|
|
func (tb *Torbox) RefreshDownloadLinks() error {
|
|
return nil
|
|
}
|
|
|
|
func (tb *Torbox) CheckLink(link string) error {
|
|
return nil
|
|
}
|
|
|
|
func (tb *Torbox) GetMountPath() string {
|
|
return tb.MountPath
|
|
}
|
|
|
|
func (tb *Torbox) GetAvailableSlots() (int, error) {
|
|
//TODO: Implement the logic to check available slots for Torbox
|
|
return 0, fmt.Errorf("not implemented")
|
|
}
|
|
|
|
func (tb *Torbox) GetProfile() (*types.Profile, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (tb *Torbox) AccountManager() *account.Manager {
|
|
return tb.accountsManager
|
|
}
|
|
|
|
func (tb *Torbox) SyncAccounts() error {
|
|
return nil
|
|
}
|
|
|
|
func (tb *Torbox) DeleteDownloadLink(account *account.Account, downloadLink types.DownloadLink) error {
|
|
account.DeleteDownloadLink(downloadLink.Link)
|
|
return nil
|
|
}
|