- Revamp decypharr arch \n

- Add callback_ur, download_folder to addContent API \n
- Fix few bugs \n
- More declarative UI keywords
- Speed up repairs
- Few other improvements/bug fixes
This commit is contained in:
Mukhtar Akere
2025-06-02 12:57:36 +01:00
parent 1cd09239f9
commit 9c6c44d785
67 changed files with 1726 additions and 1464 deletions

View File

@@ -0,0 +1,446 @@
package alldebrid
import (
"encoding/json"
"fmt"
"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/types"
"net/http"
gourl "net/url"
"path/filepath"
"strconv"
"sync"
"time"
)
type AllDebrid struct {
Name string
Host string `json:"host"`
APIKey string
accounts map[string]types.Account
DownloadUncached bool
client *request.Client
MountPath string
logger zerolog.Logger
checkCached bool
addSamples bool
}
func (ad *AllDebrid) GetProfile() (*types.Profile, error) {
return nil, nil
}
func New(dc config.Debrid) (*AllDebrid, error) {
rl := request.ParseRateLimit(dc.RateLimit)
headers := map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
}
_log := logger.New(dc.Name)
client := request.New(
request.WithHeaders(headers),
request.WithLogger(_log),
request.WithRateLimiter(rl),
request.WithProxy(dc.Proxy),
)
accounts := make(map[string]types.Account)
for idx, key := range dc.DownloadAPIKeys {
id := strconv.Itoa(idx)
accounts[id] = types.Account{
Name: key,
ID: id,
Token: key,
}
}
return &AllDebrid{
Name: "alldebrid",
Host: "http://api.alldebrid.com/v4.1",
APIKey: dc.APIKey,
accounts: accounts,
DownloadUncached: dc.DownloadUncached,
client: client,
MountPath: dc.Folder,
logger: logger.New(dc.Name),
checkCached: dc.CheckCached,
addSamples: dc.AddSamples,
}, nil
}
func (ad *AllDebrid) GetName() string {
return ad.Name
}
func (ad *AllDebrid) GetLogger() zerolog.Logger {
return ad.logger
}
func (ad *AllDebrid) 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
// AllDebrid does not support checking cached infohashes
return result
}
func (ad *AllDebrid) SubmitMagnet(torrent *types.Torrent) (*types.Torrent, error) {
url := fmt.Sprintf("%s/magnet/upload", ad.Host)
query := gourl.Values{}
query.Add("magnets[]", torrent.Magnet.Link)
url += "?" + query.Encode()
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := ad.client.MakeRequest(req)
if err != nil {
return nil, err
}
var data UploadMagnetResponse
err = json.Unmarshal(resp, &data)
if err != nil {
return nil, err
}
magnets := data.Data.Magnets
if len(magnets) == 0 {
return nil, fmt.Errorf("error adding torrent")
}
magnet := magnets[0]
torrentId := strconv.Itoa(magnet.ID)
torrent.Id = torrentId
return torrent, nil
}
func getAlldebridStatus(statusCode int) string {
switch {
case statusCode == 4:
return "downloaded"
case statusCode >= 0 && statusCode <= 3:
return "downloading"
default:
return "error"
}
}
func (ad *AllDebrid) flattenFiles(torrentId string, files []MagnetFile, parentPath string, index *int) map[string]types.File {
result := make(map[string]types.File)
cfg := config.Get()
for _, f := range files {
currentPath := f.Name
if parentPath != "" {
currentPath = filepath.Join(parentPath, f.Name)
}
if f.Elements != nil {
// This is a folder, recurse into it
subFiles := ad.flattenFiles(torrentId, f.Elements, currentPath, index)
for k, v := range subFiles {
if _, ok := result[k]; ok {
// File already exists, use path as key
result[v.Path] = v
} else {
result[k] = v
}
}
} else {
// This is a file
fileName := filepath.Base(f.Name)
// Skip sample files
if !ad.addSamples && utils.IsSampleFile(f.Name) {
continue
}
if !cfg.IsAllowedFile(fileName) {
continue
}
if !cfg.IsSizeAllowed(f.Size) {
continue
}
*index++
file := types.File{
TorrentId: torrentId,
Id: strconv.Itoa(*index),
Name: fileName,
Size: f.Size,
Path: currentPath,
Link: f.Link,
}
result[file.Name] = file
}
}
return result
}
func (ad *AllDebrid) GetTorrent(torrentId string) (*types.Torrent, error) {
url := fmt.Sprintf("%s/magnet/status?id=%s", ad.Host, torrentId)
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := ad.client.MakeRequest(req)
if err != nil {
return nil, err
}
var res TorrentInfoResponse
err = json.Unmarshal(resp, &res)
if err != nil {
ad.logger.Info().Msgf("Error unmarshalling torrent info: %s", err)
return nil, err
}
data := res.Data.Magnets
status := getAlldebridStatus(data.StatusCode)
name := data.Filename
t := &types.Torrent{
Id: strconv.Itoa(data.Id),
Name: name,
Status: status,
Filename: name,
OriginalFilename: name,
Files: make(map[string]types.File),
InfoHash: data.Hash,
Debrid: ad.Name,
MountPath: ad.MountPath,
Added: time.Unix(data.CompletionDate, 0).Format(time.RFC3339),
}
t.Bytes = data.Size
t.Seeders = data.Seeders
if status == "downloaded" {
t.Progress = 100
index := -1
files := ad.flattenFiles(t.Id, data.Files, "", &index)
t.Files = files
} else {
t.Progress = float64(data.Downloaded) / float64(data.Size) * 100
t.Speed = data.DownloadSpeed
}
return t, nil
}
func (ad *AllDebrid) UpdateTorrent(t *types.Torrent) error {
url := fmt.Sprintf("%s/magnet/status?id=%s", ad.Host, t.Id)
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := ad.client.MakeRequest(req)
if err != nil {
return err
}
var res TorrentInfoResponse
err = json.Unmarshal(resp, &res)
if err != nil {
ad.logger.Info().Msgf("Error unmarshalling torrent info: %s", err)
return err
}
data := res.Data.Magnets
status := getAlldebridStatus(data.StatusCode)
name := data.Filename
t.Name = name
t.Status = status
t.Filename = name
t.OriginalFilename = name
t.Folder = name
t.MountPath = ad.MountPath
t.Debrid = ad.Name
t.Bytes = data.Size
t.Seeders = data.Seeders
t.Added = time.Unix(data.CompletionDate, 0).Format(time.RFC3339)
if status == "downloaded" {
t.Progress = 100
index := -1
files := ad.flattenFiles(t.Id, data.Files, "", &index)
t.Files = files
} else {
t.Progress = float64(data.Downloaded) / float64(data.Size) * 100
t.Speed = data.DownloadSpeed
}
return nil
}
func (ad *AllDebrid) CheckStatus(torrent *types.Torrent, isSymlink bool) (*types.Torrent, error) {
for {
err := ad.UpdateTorrent(torrent)
if err != nil || torrent == nil {
return torrent, err
}
status := torrent.Status
if status == "downloaded" {
ad.logger.Info().Msgf("Torrent: %s downloaded", torrent.Name)
if !isSymlink {
err = ad.GenerateDownloadLinks(torrent)
if err != nil {
return torrent, err
}
}
break
} else if utils.Contains(ad.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)
}
}
return torrent, nil
}
func (ad *AllDebrid) DeleteTorrent(torrentId string) error {
url := fmt.Sprintf("%s/magnet/delete?id=%s", ad.Host, torrentId)
req, _ := http.NewRequest(http.MethodGet, url, nil)
if _, err := ad.client.MakeRequest(req); err != nil {
return err
}
ad.logger.Info().Msgf("Torrent %s deleted from AD", torrentId)
return nil
}
func (ad *AllDebrid) GenerateDownloadLinks(t *types.Torrent) error {
filesCh := make(chan types.File, len(t.Files))
errCh := make(chan error, len(t.Files))
var wg sync.WaitGroup
wg.Add(len(t.Files))
for _, file := range t.Files {
go func(file types.File) {
defer wg.Done()
link, err := ad.GetDownloadLink(t, &file)
if err != nil {
errCh <- err
return
}
file.DownloadLink = link
if link != nil {
errCh <- fmt.Errorf("download link is empty")
return
}
filesCh <- file
}(file)
}
go func() {
wg.Wait()
close(filesCh)
close(errCh)
}()
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 (ad *AllDebrid) GetDownloadLink(t *types.Torrent, file *types.File) (*types.DownloadLink, error) {
url := fmt.Sprintf("%s/link/unlock", ad.Host)
query := gourl.Values{}
query.Add("link", file.Link)
url += "?" + query.Encode()
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := ad.client.MakeRequest(req)
if err != nil {
return nil, err
}
var data DownloadLink
if err = json.Unmarshal(resp, &data); err != nil {
return nil, err
}
if data.Error != nil {
return nil, fmt.Errorf("error getting download link: %s", data.Error.Message)
}
link := data.Data.Link
if link == "" {
return nil, fmt.Errorf("download link is empty")
}
return &types.DownloadLink{
Link: file.Link,
DownloadLink: link,
Id: data.Data.Id,
Size: file.Size,
Filename: file.Name,
Generated: time.Now(),
AccountId: "0",
}, nil
}
func (ad *AllDebrid) GetCheckCached() bool {
return ad.checkCached
}
func (ad *AllDebrid) GetTorrents() ([]*types.Torrent, error) {
url := fmt.Sprintf("%s/magnet/status?status=ready", ad.Host)
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := ad.client.MakeRequest(req)
torrents := make([]*types.Torrent, 0)
if err != nil {
return torrents, err
}
var res TorrentsListResponse
err = json.Unmarshal(resp, &res)
if err != nil {
ad.logger.Info().Msgf("Error unmarshalling torrent info: %s", err)
return torrents, err
}
for _, magnet := range res.Data.Magnets {
torrents = append(torrents, &types.Torrent{
Id: strconv.Itoa(magnet.Id),
Name: magnet.Filename,
Bytes: magnet.Size,
Status: getAlldebridStatus(magnet.StatusCode),
Filename: magnet.Filename,
OriginalFilename: magnet.Filename,
Files: make(map[string]types.File),
InfoHash: magnet.Hash,
Debrid: ad.Name,
MountPath: ad.MountPath,
Added: time.Unix(magnet.CompletionDate, 0).Format(time.RFC3339),
})
}
return torrents, nil
}
func (ad *AllDebrid) GetDownloads() (map[string]types.DownloadLink, error) {
return nil, nil
}
func (ad *AllDebrid) GetDownloadingStatus() []string {
return []string{"downloading"}
}
func (ad *AllDebrid) GetDownloadUncached() bool {
return ad.DownloadUncached
}
func (ad *AllDebrid) CheckLink(link string) error {
return nil
}
func (ad *AllDebrid) GetMountPath() string {
return ad.MountPath
}
func (ad *AllDebrid) DisableAccount(accountId string) {
}
func (ad *AllDebrid) ResetActiveDownloadKeys() {
}
func (ad *AllDebrid) DeleteDownloadLink(linkId string) error {
return nil
}

View File

@@ -0,0 +1,83 @@
package alldebrid
type errorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
}
type MagnetFile struct {
Name string `json:"n"`
Size int64 `json:"s"`
Link string `json:"l"`
Elements []MagnetFile `json:"e"`
}
type magnetInfo struct {
Id int `json:"id"`
Filename string `json:"filename"`
Size int64 `json:"size"`
Hash string `json:"hash"`
Status string `json:"status"`
StatusCode int `json:"statusCode"`
UploadDate int64 `json:"uploadDate"`
Downloaded int64 `json:"downloaded"`
Uploaded int64 `json:"uploaded"`
DownloadSpeed int64 `json:"downloadSpeed"`
UploadSpeed int64 `json:"uploadSpeed"`
Seeders int `json:"seeders"`
CompletionDate int64 `json:"completionDate"`
Type string `json:"type"`
Notified bool `json:"notified"`
Version int `json:"version"`
NbLinks int `json:"nbLinks"`
Files []MagnetFile `json:"files"`
}
type TorrentInfoResponse struct {
Status string `json:"status"`
Data struct {
Magnets magnetInfo `json:"magnets"`
} `json:"data"`
Error *errorResponse `json:"error"`
}
type TorrentsListResponse struct {
Status string `json:"status"`
Data struct {
Magnets []magnetInfo `json:"magnets"`
} `json:"data"`
Error *errorResponse `json:"error"`
}
type UploadMagnetResponse struct {
Status string `json:"status"`
Data struct {
Magnets []struct {
Magnet string `json:"magnet"`
Hash string `json:"hash"`
Name string `json:"name"`
FilenameOriginal string `json:"filename_original"`
Size int64 `json:"size"`
Ready bool `json:"ready"`
ID int `json:"id"`
} `json:"magnets"`
}
Error *errorResponse `json:"error"`
}
type DownloadLink struct {
Status string `json:"status"`
Data struct {
Link string `json:"link"`
Host string `json:"host"`
Filename string `json:"filename"`
Streaming []interface{} `json:"streaming"`
Paws bool `json:"paws"`
Filesize int `json:"filesize"`
Id string `json:"id"`
Path []struct {
Name string `json:"n"`
Size int `json:"s"`
} `json:"path"`
} `json:"data"`
Error *errorResponse `json:"error"`
}

View File

@@ -0,0 +1,478 @@
package debrid_link
import (
"bytes"
"encoding/json"
"fmt"
"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/types"
"strconv"
"time"
"net/http"
"strings"
)
type DebridLink struct {
Name string
Host string `json:"host"`
APIKey string
accounts map[string]types.Account
DownloadUncached bool
client *request.Client
MountPath string
logger zerolog.Logger
checkCached bool
addSamples bool
}
func New(dc config.Debrid) (*DebridLink, error) {
rl := request.ParseRateLimit(dc.RateLimit)
headers := map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
"Content-Type": "application/json",
}
_log := logger.New(dc.Name)
client := request.New(
request.WithHeaders(headers),
request.WithLogger(_log),
request.WithRateLimiter(rl),
request.WithProxy(dc.Proxy),
)
accounts := make(map[string]types.Account)
for idx, key := range dc.DownloadAPIKeys {
id := strconv.Itoa(idx)
accounts[id] = types.Account{
Name: key,
ID: id,
Token: key,
}
}
return &DebridLink{
Name: "debridlink",
Host: "https://debrid-link.com/api/v2",
APIKey: dc.APIKey,
accounts: accounts,
DownloadUncached: dc.DownloadUncached,
client: client,
MountPath: dc.Folder,
logger: logger.New(dc.Name),
checkCached: dc.CheckCached,
addSamples: dc.AddSamples,
}, nil
}
func (dl *DebridLink) GetProfile() (*types.Profile, error) {
return nil, nil
}
func (dl *DebridLink) GetName() string {
return dl.Name
}
func (dl *DebridLink) GetLogger() zerolog.Logger {
return dl.logger
}
func (dl *DebridLink) 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/seedbox/cached/%s", dl.Host, hashStr)
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := dl.client.MakeRequest(req)
if err != nil {
dl.logger.Info().Msgf("Error checking availability: %v", err)
return result
}
var data AvailableResponse
err = json.Unmarshal(resp, &data)
if err != nil {
dl.logger.Info().Msgf("Error marshalling availability: %v", err)
return result
}
if data.Value == nil {
return result
}
value := *data.Value
for _, h := range hashes[i:end] {
_, exists := value[h]
if exists {
result[h] = true
}
}
}
return result
}
func (dl *DebridLink) GetTorrent(torrentId string) (*types.Torrent, error) {
url := fmt.Sprintf("%s/seedbox/%s", dl.Host, torrentId)
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := dl.client.MakeRequest(req)
if err != nil {
return nil, err
}
var res torrentInfo
err = json.Unmarshal(resp, &res)
if err != nil {
return nil, err
}
if !res.Success || res.Value == nil {
return nil, fmt.Errorf("error getting torrent")
}
data := *res.Value
if len(data) == 0 {
return nil, fmt.Errorf("torrent not found")
}
t := data[0]
name := utils.RemoveInvalidChars(t.Name)
torrent := &types.Torrent{
Id: t.ID,
Name: name,
Bytes: t.TotalSize,
Status: "downloaded",
Filename: name,
OriginalFilename: name,
MountPath: dl.MountPath,
Debrid: dl.Name,
Added: time.Unix(t.Created, 0).Format(time.RFC3339),
}
cfg := config.Get()
for _, f := range t.Files {
if !cfg.IsSizeAllowed(f.Size) {
continue
}
file := types.File{
TorrentId: t.ID,
Id: f.ID,
Name: f.Name,
Size: f.Size,
Path: f.Name,
DownloadLink: &types.DownloadLink{
Filename: f.Name,
Link: f.DownloadURL,
DownloadLink: f.DownloadURL,
Generated: time.Now(),
AccountId: "0",
},
Link: f.DownloadURL,
}
torrent.Files[file.Name] = file
}
return torrent, nil
}
func (dl *DebridLink) UpdateTorrent(t *types.Torrent) error {
url := fmt.Sprintf("%s/seedbox/list?ids=%s", dl.Host, t.Id)
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := dl.client.MakeRequest(req)
if err != nil {
return err
}
var res torrentInfo
err = json.Unmarshal(resp, &res)
if err != nil {
return err
}
if !res.Success {
return fmt.Errorf("error getting torrent")
}
if res.Value == nil {
return fmt.Errorf("torrent not found")
}
dt := *res.Value
if len(dt) == 0 {
return fmt.Errorf("torrent not found")
}
data := dt[0]
status := "downloading"
if data.Status == 100 {
status = "downloaded"
}
name := utils.RemoveInvalidChars(data.Name)
t.Id = data.ID
t.Name = name
t.Bytes = data.TotalSize
t.Folder = name
t.Progress = data.DownloadPercent
t.Status = status
t.Speed = data.DownloadSpeed
t.Seeders = data.PeersConnected
t.Filename = name
t.OriginalFilename = name
t.Added = time.Unix(data.Created, 0).Format(time.RFC3339)
cfg := config.Get()
for _, f := range data.Files {
if !cfg.IsSizeAllowed(f.Size) {
continue
}
file := types.File{
TorrentId: t.Id,
Id: f.ID,
Name: f.Name,
Size: f.Size,
Path: f.Name,
DownloadLink: &types.DownloadLink{
Filename: f.Name,
Link: f.DownloadURL,
DownloadLink: f.DownloadURL,
Generated: time.Now(),
AccountId: "0",
},
Link: f.DownloadURL,
}
t.Files[f.Name] = file
}
return nil
}
func (dl *DebridLink) SubmitMagnet(t *types.Torrent) (*types.Torrent, error) {
url := fmt.Sprintf("%s/seedbox/add", dl.Host)
payload := map[string]string{"url": t.Magnet.Link}
jsonPayload, _ := json.Marshal(payload)
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonPayload))
resp, err := dl.client.MakeRequest(req)
if err != nil {
return nil, err
}
var res SubmitTorrentInfo
err = json.Unmarshal(resp, &res)
if err != nil {
return nil, err
}
if !res.Success || res.Value == nil {
return nil, fmt.Errorf("error adding torrent")
}
data := *res.Value
status := "downloading"
name := utils.RemoveInvalidChars(data.Name)
t.Id = data.ID
t.Name = name
t.Bytes = data.TotalSize
t.Folder = name
t.Progress = data.DownloadPercent
t.Status = status
t.Speed = data.DownloadSpeed
t.Seeders = data.PeersConnected
t.Filename = name
t.OriginalFilename = name
t.MountPath = dl.MountPath
t.Debrid = dl.Name
t.Added = time.Unix(data.Created, 0).Format(time.RFC3339)
for _, f := range data.Files {
file := types.File{
TorrentId: t.Id,
Id: f.ID,
Name: f.Name,
Size: f.Size,
Path: f.Name,
Link: f.DownloadURL,
DownloadLink: &types.DownloadLink{
Filename: f.Name,
Link: f.DownloadURL,
DownloadLink: f.DownloadURL,
Generated: time.Now(),
AccountId: "0",
},
Generated: time.Now(),
}
t.Files[f.Name] = file
}
return t, nil
}
func (dl *DebridLink) CheckStatus(torrent *types.Torrent, isSymlink bool) (*types.Torrent, error) {
for {
err := dl.UpdateTorrent(torrent)
if err != nil || torrent == nil {
return torrent, err
}
status := torrent.Status
if status == "downloaded" {
dl.logger.Info().Msgf("Torrent: %s downloaded", torrent.Name)
err = dl.GenerateDownloadLinks(torrent)
if err != nil {
return torrent, err
}
break
} else if utils.Contains(dl.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)
}
}
return torrent, nil
}
func (dl *DebridLink) DeleteTorrent(torrentId string) error {
url := fmt.Sprintf("%s/seedbox/%s/remove", dl.Host, torrentId)
req, _ := http.NewRequest(http.MethodDelete, url, nil)
if _, err := dl.client.MakeRequest(req); err != nil {
return err
}
dl.logger.Info().Msgf("Torrent: %s deleted from DebridLink", torrentId)
return nil
}
func (dl *DebridLink) GenerateDownloadLinks(t *types.Torrent) error {
// Download links are already generated
return nil
}
func (dl *DebridLink) GetDownloads() (map[string]types.DownloadLink, error) {
return nil, nil
}
func (dl *DebridLink) GetDownloadLink(t *types.Torrent, file *types.File) (*types.DownloadLink, error) {
return file.DownloadLink, nil
}
func (dl *DebridLink) GetDownloadingStatus() []string {
return []string{"downloading"}
}
func (dl *DebridLink) GetCheckCached() bool {
return dl.checkCached
}
func (dl *DebridLink) GetDownloadUncached() bool {
return dl.DownloadUncached
}
func (dl *DebridLink) GetTorrents() ([]*types.Torrent, error) {
page := 0
perPage := 100
torrents := make([]*types.Torrent, 0)
for {
t, err := dl.getTorrents(page, perPage)
if err != nil {
break
}
if len(t) == 0 {
break
}
torrents = append(torrents, t...)
page++
}
return torrents, nil
}
func (dl *DebridLink) getTorrents(page, perPage int) ([]*types.Torrent, error) {
url := fmt.Sprintf("%s/seedbox/list?page=%d&perPage=%d", dl.Host, page, perPage)
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := dl.client.MakeRequest(req)
torrents := make([]*types.Torrent, 0)
if err != nil {
return torrents, err
}
var res torrentInfo
err = json.Unmarshal(resp, &res)
if err != nil {
dl.logger.Info().Msgf("Error unmarshalling torrent info: %s", err)
return torrents, err
}
data := *res.Value
if len(data) == 0 {
return torrents, nil
}
for _, t := range data {
if t.Status != 100 {
continue
}
torrent := &types.Torrent{
Id: t.ID,
Name: t.Name,
Bytes: t.TotalSize,
Status: "downloaded",
Filename: t.Name,
OriginalFilename: t.Name,
InfoHash: t.HashString,
Files: make(map[string]types.File),
Debrid: dl.Name,
MountPath: dl.MountPath,
Added: time.Unix(t.Created, 0).Format(time.RFC3339),
}
cfg := config.Get()
for _, f := range t.Files {
if !cfg.IsSizeAllowed(f.Size) {
continue
}
file := types.File{
TorrentId: torrent.Id,
Id: f.ID,
Name: f.Name,
Size: f.Size,
Path: f.Name,
DownloadLink: &types.DownloadLink{
Filename: f.Name,
Link: f.DownloadURL,
DownloadLink: f.DownloadURL,
Generated: time.Now(),
AccountId: "0",
},
Link: f.DownloadURL,
}
torrent.Files[f.Name] = file
}
torrents = append(torrents, torrent)
}
return torrents, nil
}
func (dl *DebridLink) CheckLink(link string) error {
return nil
}
func (dl *DebridLink) GetMountPath() string {
return dl.MountPath
}
func (dl *DebridLink) DisableAccount(accountId string) {
}
func (dl *DebridLink) ResetActiveDownloadKeys() {
}
func (dl *DebridLink) DeleteDownloadLink(linkId string) error {
return nil
}

View File

@@ -0,0 +1,45 @@
package debrid_link
type APIResponse[T any] struct {
Success bool `json:"success"`
Value *T `json:"value"` // Use pointer to allow nil
}
type AvailableResponse APIResponse[map[string]map[string]struct {
Name string `json:"name"`
HashString string `json:"hashString"`
Files []struct {
Name string `json:"name"`
Size int `json:"size"`
} `json:"files"`
}]
type _torrentInfo struct {
ID string `json:"id"`
Name string `json:"name"`
HashString string `json:"hashString"`
UploadRatio float64 `json:"uploadRatio"`
ServerID string `json:"serverId"`
Wait bool `json:"wait"`
PeersConnected int `json:"peersConnected"`
Status int `json:"status"`
TotalSize int64 `json:"totalSize"`
Files []struct {
ID string `json:"id"`
Name string `json:"name"`
DownloadURL string `json:"downloadUrl"`
Size int64 `json:"size"`
DownloadPercent int `json:"downloadPercent"`
} `json:"files"`
Trackers []struct {
Announce string `json:"announce"`
} `json:"trackers"`
Created int64 `json:"created"`
DownloadPercent float64 `json:"downloadPercent"`
DownloadSpeed int64 `json:"downloadSpeed"`
UploadSpeed int64 `json:"uploadSpeed"`
}
type torrentInfo APIResponse[[]_torrentInfo]
type SubmitTorrentInfo APIResponse[_torrentInfo]

View File

@@ -0,0 +1,943 @@
package realdebrid
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"github.com/sirrobot01/decypharr/pkg/debrid/types"
"io"
"net/http"
gourl "net/url"
"path/filepath"
"sort"
"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/rar"
)
type RealDebrid struct {
Name string
Host string `json:"host"`
APIKey string
currentDownloadKey string
accounts map[string]types.Account
accountsMutex sync.RWMutex
DownloadUncached bool
client *request.Client
downloadClient *request.Client
MountPath string
logger zerolog.Logger
UnpackRar bool
rarSemaphore chan struct{}
checkCached bool
addSamples bool
Profile *types.Profile
}
func New(dc config.Debrid) (*RealDebrid, error) {
rl := request.ParseRateLimit(dc.RateLimit)
headers := map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
}
_log := logger.New(dc.Name)
accounts := make(map[string]types.Account)
currentDownloadKey := dc.DownloadAPIKeys[0]
for idx, key := range dc.DownloadAPIKeys {
id := strconv.Itoa(idx)
accounts[id] = types.Account{
Name: key,
ID: id,
Token: key,
}
}
downloadHeaders := map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", currentDownloadKey),
}
r := &RealDebrid{
Name: "realdebrid",
Host: "https://api.real-debrid.com/rest/1.0",
APIKey: dc.APIKey,
accounts: accounts,
DownloadUncached: dc.DownloadUncached,
UnpackRar: dc.UnpackRar,
client: request.New(
request.WithHeaders(headers),
request.WithRateLimiter(rl),
request.WithLogger(_log),
request.WithMaxRetries(5),
request.WithRetryableStatus(429, 502),
request.WithProxy(dc.Proxy),
),
downloadClient: request.New(
request.WithHeaders(downloadHeaders),
request.WithLogger(_log),
request.WithMaxRetries(10),
request.WithRetryableStatus(429, 447, 502),
request.WithProxy(dc.Proxy),
),
currentDownloadKey: currentDownloadKey,
MountPath: dc.Folder,
logger: logger.New(dc.Name),
rarSemaphore: make(chan struct{}, 2),
checkCached: dc.CheckCached,
addSamples: dc.AddSamples,
}
if _, err := r.GetProfile(); err != nil {
return nil, err
} else {
return r, nil
}
}
func (r *RealDebrid) GetName() string {
return r.Name
}
func (r *RealDebrid) GetLogger() zerolog.Logger {
return r.logger
}
func (r *RealDebrid) getSelectedFiles(t *types.Torrent, data torrentInfo) (map[string]types.File, error) {
files := make(map[string]types.File)
selectedFiles := make([]types.File, 0)
for _, f := range data.Files {
if f.Selected == 1 {
selectedFiles = append(selectedFiles, types.File{
TorrentId: t.Id,
Name: filepath.Base(f.Path),
Path: filepath.Base(f.Path),
Size: f.Bytes,
Id: strconv.Itoa(f.ID),
})
}
}
if len(selectedFiles) == 0 {
return files, nil
}
// Handle RARed torrents (single link, multiple files)
if len(data.Links) == 1 && len(selectedFiles) > 1 {
return r.handleRarArchive(t, data, selectedFiles)
}
// Standard case - map files to links
if len(selectedFiles) > len(data.Links) {
r.logger.Warn().Msgf("More files than links available: %d files, %d links for %s", len(selectedFiles), len(data.Links), t.Name)
}
for i, f := range selectedFiles {
if i < len(data.Links) {
f.Link = data.Links[i]
files[f.Name] = f
} else {
r.logger.Warn().Str("file", f.Name).Msg("No link available for file")
}
}
return files, nil
}
// handleRarArchive processes RAR archives with multiple files
func (r *RealDebrid) handleRarArchive(t *types.Torrent, data torrentInfo, selectedFiles []types.File) (map[string]types.File, error) {
// This will block if 2 RAR operations are already in progress
r.rarSemaphore <- struct{}{}
defer func() {
<-r.rarSemaphore
}()
files := make(map[string]types.File)
if !r.UnpackRar {
r.logger.Debug().Msgf("RAR file detected, but unpacking is disabled: %s", t.Name)
// Create a single file representing the RAR archive
file := types.File{
TorrentId: t.Id,
Id: "0",
Name: t.Name + ".rar",
Size: 0,
IsRar: true,
ByteRange: nil,
Path: t.Name + ".rar",
Link: data.Links[0],
AccountId: selectedFiles[0].AccountId,
Generated: time.Now(),
}
files[file.Name] = file
return files, nil
}
r.logger.Info().Msgf("RAR file detected, unpacking: %s", t.Name)
linkFile := &types.File{TorrentId: t.Id, Link: data.Links[0]}
downloadLinkObj, err := r.GetDownloadLink(t, linkFile)
if err != nil {
return nil, fmt.Errorf("failed to get download link for RAR file: %w", err)
}
dlLink := downloadLinkObj.DownloadLink
reader, err := rar.NewReader(dlLink)
if err != nil {
return nil, fmt.Errorf("failed to create RAR reader: %w", err)
}
rarFiles, err := reader.GetFiles()
if err != nil {
return nil, fmt.Errorf("failed to read RAR files: %w", err)
}
// Create lookup map for faster matching
fileMap := make(map[string]*types.File)
for i := range selectedFiles {
// RD converts special chars to '_' for RAR file paths
// TOOD: there might be more special chars to replace
safeName := strings.NewReplacer("|", "_", "\"", "_", "\\", "_", "?", "_", "*", "_", ":", "_", "<", "_", ">", "_").Replace(selectedFiles[i].Name)
fileMap[safeName] = &selectedFiles[i]
}
for _, rarFile := range rarFiles {
if file, exists := fileMap[rarFile.Name()]; exists {
file.IsRar = true
file.ByteRange = rarFile.ByteRange()
file.Link = data.Links[0]
file.DownloadLink = &types.DownloadLink{
Link: data.Links[0],
DownloadLink: dlLink,
Filename: file.Name,
Size: file.Size,
Generated: time.Now(),
}
files[file.Name] = *file
} else if !rarFile.IsDirectory {
r.logger.Warn().Msgf("RAR file %s not found in torrent files", rarFile.Name())
}
}
return files, nil
}
// getTorrentFiles returns a list of torrent files from the torrent info
// validate is used to determine if the files should be validated
// if validate is false, selected files will be returned
func (r *RealDebrid) getTorrentFiles(t *types.Torrent, data torrentInfo) map[string]types.File {
files := make(map[string]types.File)
cfg := config.Get()
idx := 0
for _, f := range data.Files {
name := filepath.Base(f.Path)
if !r.addSamples && utils.IsSampleFile(f.Path) {
// Skip sample files
continue
}
if !cfg.IsAllowedFile(name) {
continue
}
if !cfg.IsSizeAllowed(f.Bytes) {
continue
}
file := types.File{
TorrentId: t.Id,
Name: name,
Path: name,
Size: f.Bytes,
Id: strconv.Itoa(f.ID),
}
files[name] = file
idx++
}
return files
}
func (r *RealDebrid) 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 += 200 {
end := i + 200
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/torrents/instantAvailability/%s", r.Host, hashStr)
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := r.client.MakeRequest(req)
if err != nil {
r.logger.Info().Msgf("Error checking availability: %v", err)
return result
}
var data AvailabilityResponse
err = json.Unmarshal(resp, &data)
if err != nil {
r.logger.Info().Msgf("Error marshalling availability: %v", err)
return result
}
for _, h := range hashes[i:end] {
hosters, exists := data[strings.ToLower(h)]
if exists && len(hosters.Rd) > 0 {
result[h] = true
}
}
}
return result
}
func (r *RealDebrid) SubmitMagnet(t *types.Torrent) (*types.Torrent, error) {
if t.Magnet.IsTorrent() {
return r.addTorrent(t)
}
return r.addMagnet(t)
}
func (r *RealDebrid) addTorrent(t *types.Torrent) (*types.Torrent, error) {
url := fmt.Sprintf("%s/torrents/addTorrent", r.Host)
var data AddMagnetSchema
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(t.Magnet.File))
if err != nil {
return nil, err
}
req.Header.Add("Content-Type", "application/x-bittorrent")
resp, err := r.client.MakeRequest(req)
if err != nil {
return nil, err
}
if err = json.Unmarshal(resp, &data); err != nil {
return nil, err
}
t.Id = data.Id
t.Debrid = r.Name
t.MountPath = r.MountPath
return t, nil
}
func (r *RealDebrid) addMagnet(t *types.Torrent) (*types.Torrent, error) {
url := fmt.Sprintf("%s/torrents/addMagnet", r.Host)
payload := gourl.Values{
"magnet": {t.Magnet.Link},
}
var data AddMagnetSchema
req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(payload.Encode()))
resp, err := r.client.MakeRequest(req)
if err != nil {
return nil, err
}
if err = json.Unmarshal(resp, &data); err != nil {
return nil, err
}
t.Id = data.Id
t.Debrid = r.Name
t.MountPath = r.MountPath
return t, nil
}
func (r *RealDebrid) GetTorrent(torrentId string) (*types.Torrent, error) {
url := fmt.Sprintf("%s/torrents/info/%s", r.Host, torrentId)
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := r.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("reading response body: %w", err)
}
if resp.StatusCode != http.StatusOK {
if resp.StatusCode == http.StatusNotFound {
return nil, request.TorrentNotFoundError
}
return nil, fmt.Errorf("realdebrid API error: Status: %d || Body: %s", resp.StatusCode, string(bodyBytes))
}
var data torrentInfo
err = json.Unmarshal(bodyBytes, &data)
if err != nil {
return nil, err
}
t := &types.Torrent{
Id: data.ID,
Name: data.Filename,
Bytes: data.Bytes,
Folder: data.OriginalFilename,
Progress: data.Progress,
Speed: data.Speed,
Seeders: data.Seeders,
Added: data.Added,
Status: data.Status,
Filename: data.Filename,
OriginalFilename: data.OriginalFilename,
Links: data.Links,
Debrid: r.Name,
MountPath: r.MountPath,
}
t.Files = r.getTorrentFiles(t, data) // Get selected files
return t, nil
}
func (r *RealDebrid) UpdateTorrent(t *types.Torrent) error {
url := fmt.Sprintf("%s/torrents/info/%s", r.Host, t.Id)
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := r.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
bodyBytes, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("reading response body: %w", err)
}
if resp.StatusCode != http.StatusOK {
if resp.StatusCode == http.StatusNotFound {
return request.TorrentNotFoundError
}
return fmt.Errorf("realdebrid API error: Status: %d || Body: %s", resp.StatusCode, string(bodyBytes))
}
var data torrentInfo
err = json.Unmarshal(bodyBytes, &data)
if err != nil {
return err
}
t.Name = data.Filename
t.Bytes = data.Bytes
t.Folder = data.OriginalFilename
t.Progress = data.Progress
t.Status = data.Status
t.Speed = data.Speed
t.Seeders = data.Seeders
t.Filename = data.Filename
t.OriginalFilename = data.OriginalFilename
t.Links = data.Links
t.MountPath = r.MountPath
t.Debrid = r.Name
t.Added = data.Added
t.Files, _ = r.getSelectedFiles(t, data) // Get selected files
return nil
}
func (r *RealDebrid) CheckStatus(t *types.Torrent, isSymlink bool) (*types.Torrent, error) {
url := fmt.Sprintf("%s/torrents/info/%s", r.Host, t.Id)
req, _ := http.NewRequest(http.MethodGet, url, nil)
for {
resp, err := r.client.MakeRequest(req)
if err != nil {
r.logger.Info().Msgf("ERROR Checking file: %v", err)
return t, err
}
var data torrentInfo
if err = json.Unmarshal(resp, &data); err != nil {
return t, err
}
status := data.Status
t.Name = data.Filename // Important because some magnet changes the name
t.Folder = data.OriginalFilename
t.Filename = data.Filename
t.OriginalFilename = data.OriginalFilename
t.Bytes = data.Bytes
t.Progress = data.Progress
t.Speed = data.Speed
t.Seeders = data.Seeders
t.Links = data.Links
t.Status = status
t.Debrid = r.Name
t.MountPath = r.MountPath
if status == "waiting_files_selection" {
t.Files = r.getTorrentFiles(t, data)
if len(t.Files) == 0 {
return t, fmt.Errorf("no video files found")
}
filesId := make([]string, 0)
for _, f := range t.Files {
filesId = append(filesId, f.Id)
}
p := gourl.Values{
"files": {strings.Join(filesId, ",")},
}
payload := strings.NewReader(p.Encode())
req, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/torrents/selectFiles/%s", r.Host, t.Id), payload)
res, err := r.client.Do(req)
if err != nil {
return t, err
}
if res.StatusCode != http.StatusNoContent {
return t, fmt.Errorf("realdebrid API error: Status: %d", res.StatusCode)
}
} else if status == "downloaded" {
t.Files, err = r.getSelectedFiles(t, data) // Get selected files
if err != nil {
return t, err
}
r.logger.Info().Msgf("Torrent: %s downloaded to RD", t.Name)
if !isSymlink {
err = r.GenerateDownloadLinks(t)
if err != nil {
return t, err
}
}
break
} else if utils.Contains(r.GetDownloadingStatus(), status) {
if !t.DownloadUncached {
return t, fmt.Errorf("torrent: %s not cached", t.Name)
}
return t, nil
} else {
return t, fmt.Errorf("torrent: %s has error: %s", t.Name, status)
}
}
return t, nil
}
func (r *RealDebrid) DeleteTorrent(torrentId string) error {
url := fmt.Sprintf("%s/torrents/delete/%s", r.Host, torrentId)
req, _ := http.NewRequest(http.MethodDelete, url, nil)
if _, err := r.client.MakeRequest(req); err != nil {
return err
}
r.logger.Info().Msgf("Torrent: %s deleted from RD", torrentId)
return nil
}
func (r *RealDebrid) GenerateDownloadLinks(t *types.Torrent) error {
filesCh := make(chan types.File, len(t.Files))
errCh := make(chan error, len(t.Files))
var wg sync.WaitGroup
wg.Add(len(t.Files))
for _, f := range t.Files {
go func(file types.File) {
defer wg.Done()
link, err := r.GetDownloadLink(t, &file)
if err != nil {
errCh <- err
return
}
file.DownloadLink = link
filesCh <- file
}(f)
}
go func() {
wg.Wait()
close(filesCh)
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 (r *RealDebrid) CheckLink(link string) error {
url := fmt.Sprintf("%s/unrestrict/check", r.Host)
payload := gourl.Values{
"link": {link},
}
req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(payload.Encode()))
resp, err := r.client.Do(req)
if err != nil {
return err
}
if resp.StatusCode == http.StatusNotFound {
return request.HosterUnavailableError // File has been removed
}
return nil
}
func (r *RealDebrid) _getDownloadLink(file *types.File) (*types.DownloadLink, error) {
url := fmt.Sprintf("%s/unrestrict/link/", r.Host)
payload := gourl.Values{
"link": {file.Link},
}
req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(payload.Encode()))
resp, err := r.downloadClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Read the response body to get the error message
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var data ErrorResponse
if err = json.Unmarshal(b, &data); err != nil {
return nil, fmt.Errorf("error unmarshalling %d || %s \n %s", resp.StatusCode, err, string(b))
}
switch data.ErrorCode {
case 19:
return nil, request.HosterUnavailableError // File has been removed
case 23:
return nil, request.TrafficExceededError
case 24:
return nil, request.HosterUnavailableError // Link has been nerfed
case 34:
return nil, request.TrafficExceededError // traffic exceeded
case 35:
return nil, request.HosterUnavailableError
case 36:
return nil, request.TrafficExceededError // traffic exceeded
default:
return nil, fmt.Errorf("realdebrid API error: Status: %d || Code: %d", resp.StatusCode, data.ErrorCode)
}
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var data UnrestrictResponse
if err = json.Unmarshal(b, &data); err != nil {
return nil, fmt.Errorf("realdebrid API error: Error unmarshalling response: %w", err)
}
if data.Download == "" {
return nil, fmt.Errorf("realdebrid API error: download link not found")
}
return &types.DownloadLink{
Filename: data.Filename,
Size: data.Filesize,
Link: data.Link,
DownloadLink: data.Download,
Generated: time.Now(),
}, nil
}
func (r *RealDebrid) GetDownloadLink(t *types.Torrent, file *types.File) (*types.DownloadLink, error) {
if r.currentDownloadKey == "" {
// If no download key is set, use the first one
accounts := r.getActiveAccounts()
if len(accounts) < 1 {
// No active download keys. It's likely that the key has reached bandwidth limit
return nil, fmt.Errorf("no active download keys")
}
r.currentDownloadKey = accounts[0].Token
}
r.downloadClient.SetHeader("Authorization", fmt.Sprintf("Bearer %s", r.currentDownloadKey))
downloadLink, err := r._getDownloadLink(file)
retries := 0
if err != nil {
if errors.Is(err, request.TrafficExceededError) {
// Retries generating
retries = 5
} else {
// If the error is not traffic exceeded, return the error
return nil, err
}
}
backOff := 1 * time.Second
for retries > 0 {
downloadLink, err = r._getDownloadLink(file)
if err == nil {
return downloadLink, nil
}
if !errors.Is(err, request.TrafficExceededError) {
return nil, err
}
// Add a delay before retrying
time.Sleep(backOff)
backOff *= 2 // Exponential backoff
}
return downloadLink, nil
}
func (r *RealDebrid) GetCheckCached() bool {
return r.checkCached
}
func (r *RealDebrid) getTorrents(offset int, limit int) (int, []*types.Torrent, error) {
url := fmt.Sprintf("%s/torrents?limit=%d", r.Host, limit)
torrents := make([]*types.Torrent, 0)
if offset > 0 {
url = fmt.Sprintf("%s&offset=%d", url, offset)
}
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := r.client.Do(req)
if err != nil {
return 0, torrents, err
}
if resp.StatusCode == http.StatusNoContent {
return 0, torrents, nil
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return 0, torrents, fmt.Errorf("realdebrid API error: %d", resp.StatusCode)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return 0, torrents, err
}
totalItems, _ := strconv.Atoi(resp.Header.Get("X-Total-Count"))
var data []TorrentsResponse
if err = json.Unmarshal(body, &data); err != nil {
return 0, torrents, err
}
filenames := map[string]struct{}{}
for _, t := range data {
if t.Status != "downloaded" {
continue
}
torrents = append(torrents, &types.Torrent{
Id: t.Id,
Name: t.Filename,
Bytes: t.Bytes,
Progress: t.Progress,
Status: t.Status,
Filename: t.Filename,
OriginalFilename: t.Filename,
Links: t.Links,
Files: make(map[string]types.File),
InfoHash: t.Hash,
Debrid: r.Name,
MountPath: r.MountPath,
Added: t.Added.Format(time.RFC3339),
})
filenames[t.Filename] = struct{}{}
}
return totalItems, torrents, nil
}
func (r *RealDebrid) GetTorrents() ([]*types.Torrent, error) {
limit := 5000
// Get first batch and total count
allTorrents := make([]*types.Torrent, 0)
var fetchError error
offset := 0
for {
// Fetch next batch of torrents
_, torrents, err := r.getTorrents(offset, limit)
if err != nil {
fetchError = err
break
}
totalTorrents := len(torrents)
if totalTorrents == 0 {
break
}
allTorrents = append(allTorrents, torrents...)
offset += totalTorrents
}
if fetchError != nil {
return nil, fetchError
}
return allTorrents, nil
}
func (r *RealDebrid) GetDownloads() (map[string]types.DownloadLink, error) {
links := make(map[string]types.DownloadLink)
offset := 0
limit := 1000
accounts := r.getActiveAccounts()
if len(accounts) < 1 {
// No active download keys. It's likely that the key has reached bandwidth limit
return nil, fmt.Errorf("no active download keys")
}
r.downloadClient.SetHeader("Authorization", fmt.Sprintf("Bearer %s", accounts[0].Token))
for {
dl, err := r._getDownloads(offset, limit)
if err != nil {
break
}
if len(dl) == 0 {
break
}
for _, d := range dl {
if _, exists := links[d.Link]; exists {
// This is ordered by date, so we can skip the rest
continue
}
links[d.Link] = d
}
offset += len(dl)
}
return links, nil
}
func (r *RealDebrid) _getDownloads(offset int, limit int) ([]types.DownloadLink, error) {
url := fmt.Sprintf("%s/downloads?limit=%d", r.Host, limit)
if offset > 0 {
url = fmt.Sprintf("%s&offset=%d", url, offset)
}
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := r.downloadClient.MakeRequest(req)
if err != nil {
return nil, err
}
var data []DownloadsResponse
if err = json.Unmarshal(resp, &data); err != nil {
return nil, err
}
links := make([]types.DownloadLink, 0)
for _, d := range data {
links = append(links, types.DownloadLink{
Filename: d.Filename,
Size: d.Filesize,
Link: d.Link,
DownloadLink: d.Download,
Generated: d.Generated,
Id: d.Id,
})
}
return links, nil
}
func (r *RealDebrid) GetDownloadingStatus() []string {
return []string{"downloading", "magnet_conversion", "queued", "compressing", "uploading"}
}
func (r *RealDebrid) GetDownloadUncached() bool {
return r.DownloadUncached
}
func (r *RealDebrid) GetMountPath() string {
return r.MountPath
}
func (r *RealDebrid) DisableAccount(accountId string) {
r.accountsMutex.Lock()
defer r.accountsMutex.Unlock()
if len(r.accounts) == 1 {
r.logger.Info().Msgf("Cannot disable last account: %s", accountId)
return
}
r.currentDownloadKey = ""
if value, ok := r.accounts[accountId]; ok {
value.Disabled = true
r.accounts[accountId] = value
r.logger.Info().Msgf("Disabled account Index: %s", value.ID)
}
}
func (r *RealDebrid) ResetActiveDownloadKeys() {
r.accountsMutex.Lock()
defer r.accountsMutex.Unlock()
for key, value := range r.accounts {
value.Disabled = false
r.accounts[key] = value
}
}
func (r *RealDebrid) getActiveAccounts() []types.Account {
r.accountsMutex.RLock()
defer r.accountsMutex.RUnlock()
accounts := make([]types.Account, 0)
for _, value := range r.accounts {
if value.Disabled {
continue
}
accounts = append(accounts, value)
}
// Sort accounts by ID
sort.Slice(accounts, func(i, j int) bool {
return accounts[i].ID < accounts[j].ID
})
return accounts
}
func (r *RealDebrid) DeleteDownloadLink(linkId string) error {
url := fmt.Sprintf("%s/downloads/delete/%s", r.Host, linkId)
req, _ := http.NewRequest(http.MethodDelete, url, nil)
if _, err := r.downloadClient.MakeRequest(req); err != nil {
return err
}
return nil
}
func (r *RealDebrid) GetProfile() (*types.Profile, error) {
if r.Profile != nil {
return r.Profile, nil
}
url := fmt.Sprintf("%s/user", r.Host)
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := r.client.MakeRequest(req)
if err != nil {
return nil, err
}
var data profileResponse
if json.Unmarshal(resp, &data) != nil {
return nil, err
}
profile := &types.Profile{
Id: data.Id,
Username: data.Username,
Email: data.Email,
Points: data.Points,
Premium: data.Premium,
Expiration: data.Expiration,
Type: data.Type,
}
return profile, nil
}

View File

@@ -0,0 +1,153 @@
package realdebrid
import (
"encoding/json"
"fmt"
"time"
)
type AvailabilityResponse map[string]Hoster
func (r *AvailabilityResponse) UnmarshalJSON(data []byte) error {
// First, try to unmarshal as an object
var objectData map[string]Hoster
err := json.Unmarshal(data, &objectData)
if err == nil {
*r = objectData
return nil
}
// If that fails, try to unmarshal as an array
var arrayData []map[string]Hoster
err = json.Unmarshal(data, &arrayData)
if err != nil {
return fmt.Errorf("failed to unmarshal as both object and array: %v", err)
}
// If it's an array, use the first element
if len(arrayData) > 0 {
*r = arrayData[0]
return nil
}
// If it's an empty array, initialize as an empty map
*r = make(map[string]Hoster)
return nil
}
type Hoster struct {
Rd []map[string]FileVariant `json:"rd"`
}
func (h *Hoster) UnmarshalJSON(data []byte) error {
// Attempt to unmarshal into the expected structure (an object with an "rd" key)
type Alias Hoster
var obj Alias
if err := json.Unmarshal(data, &obj); err == nil {
*h = Hoster(obj)
return nil
}
// If unmarshalling into an object fails, check if it's an empty array
var arr []interface{}
if err := json.Unmarshal(data, &arr); err == nil && len(arr) == 0 {
// It's an empty array; initialize with no entries
*h = Hoster{Rd: nil}
return nil
}
// If both attempts fail, return an error
return fmt.Errorf("hoster: cannot unmarshal JSON data: %s", string(data))
}
type FileVariant struct {
Filename string `json:"filename"`
Filesize int `json:"filesize"`
}
type AddMagnetSchema struct {
Id string `json:"id"`
Uri string `json:"uri"`
}
type torrentInfo struct {
ID string `json:"id"`
Filename string `json:"filename"`
OriginalFilename string `json:"original_filename"`
Hash string `json:"hash"`
Bytes int64 `json:"bytes"`
OriginalBytes int64 `json:"original_bytes"`
Host string `json:"host"`
Split int `json:"split"`
Progress float64 `json:"progress"`
Status string `json:"status"`
Added string `json:"added"`
Files []struct {
ID int `json:"id"`
Path string `json:"path"`
Bytes int64 `json:"bytes"`
Selected int `json:"selected"`
} `json:"files"`
Links []string `json:"links"`
Ended string `json:"ended,omitempty"`
Speed int64 `json:"speed,omitempty"`
Seeders int `json:"seeders,omitempty"`
}
type UnrestrictResponse struct {
Id string `json:"id"`
Filename string `json:"filename"`
MimeType string `json:"mimeType"`
Filesize int64 `json:"filesize"`
Link string `json:"link"`
Host string `json:"host"`
Chunks int `json:"chunks"`
Crc int `json:"crc"`
Download string `json:"download"`
Streamable int `json:"streamable"`
}
type TorrentsResponse struct {
Id string `json:"id"`
Filename string `json:"filename"`
Hash string `json:"hash"`
Bytes int64 `json:"bytes"`
Host string `json:"host"`
Split int64 `json:"split"`
Progress float64 `json:"progress"`
Status string `json:"status"`
Added time.Time `json:"added"`
Links []string `json:"links"`
Ended time.Time `json:"ended"`
}
type DownloadsResponse struct {
Id string `json:"id"`
Filename string `json:"filename"`
MimeType string `json:"mimeType"`
Filesize int64 `json:"filesize"`
Link string `json:"link"`
Host string `json:"host"`
HostIcon string `json:"host_icon"`
Chunks int64 `json:"chunks"`
Download string `json:"download"`
Streamable int `json:"streamable"`
Generated time.Time `json:"generated"`
}
type ErrorResponse struct {
Error string `json:"error"`
ErrorCode int `json:"error_code"`
}
type profileResponse struct {
Id int64 `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Points int64 `json:"points"`
Locale string `json:"locale"`
Avatar string `json:"avatar"`
Type string `json:"type"`
Premium int `json:"premium"`
Expiration time.Time `json:"expiration"`
}

View File

@@ -0,0 +1,472 @@
package torbox
import (
"bytes"
"encoding/json"
"fmt"
"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/types"
"github.com/sirrobot01/decypharr/pkg/version"
"mime/multipart"
"net/http"
gourl "net/url"
"path"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
type Torbox struct {
Name string
Host string `json:"host"`
APIKey string
accounts map[string]types.Account
DownloadUncached bool
client *request.Client
MountPath string
logger zerolog.Logger
checkCached bool
addSamples bool
}
func (tb *Torbox) GetProfile() (*types.Profile, error) {
return nil, nil
}
func New(dc config.Debrid) (*Torbox, error) {
rl := request.ParseRateLimit(dc.RateLimit)
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(rl),
request.WithLogger(_log),
request.WithProxy(dc.Proxy),
)
accounts := make(map[string]types.Account)
for idx, key := range dc.DownloadAPIKeys {
id := strconv.Itoa(idx)
accounts[id] = types.Account{
Name: key,
ID: id,
Token: key,
}
}
return &Torbox{
Name: "torbox",
Host: "https://api.torbox.app/v1",
APIKey: dc.APIKey,
accounts: accounts,
DownloadUncached: dc.DownloadUncached,
client: client,
MountPath: dc.Folder,
logger: _log,
checkCached: dc.CheckCached,
addSamples: dc.AddSamples,
}, nil
}
func (tb *Torbox) GetName() string {
return tb.Name
}
func (tb *Torbox) GetLogger() 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.Info().Msgf("Error checking availability: %v", err)
return result
}
var res AvailableResponse
err = json.Unmarshal(resp, &res)
if err != nil {
tb.logger.Info().Msgf("Error marshalling availability: %v", err)
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)
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
return torrent, nil
}
func getTorboxStatus(status string, finished bool) string {
if finished {
return "downloaded"
}
downloading := []string{"completed", "cached", "paused", "downloading", "uploading",
"checkingResumeData", "metaDL", "pausedUP", "queuedUP", "checkingUP",
"forcedUP", "allocating", "downloading", "metaDL", "pausedDL",
"queuedDL", "checkingDL", "forcedDL", "checkingResumeData", "moving"}
switch {
case utils.Contains(downloading, status):
return "downloading"
default:
return "error"
}
}
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: 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()
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,
}
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 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 = 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
cfg := config.Get()
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: fileName,
}
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, isSymlink bool) (*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)
if !isSymlink {
err = tb.GenerateDownloadLinks(torrent)
if err != nil {
return torrent, err
}
}
break
} 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)
}
}
return torrent, nil
}
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) GenerateDownloadLinks(t *types.Torrent) error {
filesCh := make(chan types.File, len(t.Files))
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
}
file.DownloadLink = link
filesCh <- file
}()
}
go func() {
wg.Wait()
close(filesCh)
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 {
return nil, err
}
var data DownloadLinksResponse
if err = json.Unmarshal(resp, &data); err != nil {
return nil, err
}
if data.Data == nil {
return nil, fmt.Errorf("error getting download links")
}
link := *data.Data
if link == "" {
return nil, fmt.Errorf("error getting download links")
}
return &types.DownloadLink{
Link: file.Link,
DownloadLink: link,
Id: file.Id,
AccountId: "0",
Generated: time.Now(),
}, nil
}
func (tb *Torbox) GetDownloadingStatus() []string {
return []string{"downloading"}
}
func (tb *Torbox) GetCheckCached() bool {
return tb.checkCached
}
func (tb *Torbox) GetTorrents() ([]*types.Torrent, error) {
return nil, nil
}
func (tb *Torbox) GetDownloadUncached() bool {
return tb.DownloadUncached
}
func (tb *Torbox) GetDownloads() (map[string]types.DownloadLink, error) {
return nil, nil
}
func (tb *Torbox) CheckLink(link string) error {
return nil
}
func (tb *Torbox) GetMountPath() string {
return tb.MountPath
}
func (tb *Torbox) DisableAccount(accountId string) {
}
func (tb *Torbox) ResetActiveDownloadKeys() {
}
func (tb *Torbox) DeleteDownloadLink(linkId string) error {
return nil
}

View File

@@ -0,0 +1,75 @@
package torbox
import "time"
type APIResponse[T any] struct {
Success bool `json:"success"`
Error any `json:"error"`
Detail string `json:"detail"`
Data *T `json:"data"` // Use pointer to allow nil
}
type AvailableResponse APIResponse[map[string]struct {
Name string `json:"name"`
Size int `json:"size"`
Hash string `json:"hash"`
}]
type AddMagnetResponse APIResponse[struct {
Id int `json:"torrent_id"`
Hash string `json:"hash"`
}]
type torboxInfo struct {
Id int `json:"id"`
AuthId string `json:"auth_id"`
Server int `json:"server"`
Hash string `json:"hash"`
Name string `json:"name"`
Magnet interface{} `json:"magnet"`
Size int64 `json:"size"`
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DownloadState string `json:"download_state"`
Seeds int `json:"seeds"`
Peers int `json:"peers"`
Ratio float64 `json:"ratio"`
Progress float64 `json:"progress"`
DownloadSpeed int64 `json:"download_speed"`
UploadSpeed int `json:"upload_speed"`
Eta int `json:"eta"`
TorrentFile bool `json:"torrent_file"`
ExpiresAt interface{} `json:"expires_at"`
DownloadPresent bool `json:"download_present"`
Files []struct {
Id int `json:"id"`
Md5 interface{} `json:"md5"`
Hash string `json:"hash"`
Name string `json:"name"`
Size int64 `json:"size"`
Zipped bool `json:"zipped"`
S3Path string `json:"s3_path"`
Infected bool `json:"infected"`
Mimetype string `json:"mimetype"`
ShortName string `json:"short_name"`
AbsolutePath string `json:"absolute_path"`
} `json:"files"`
DownloadPath string `json:"download_path"`
InactiveCheck int `json:"inactive_check"`
Availability int `json:"availability"`
DownloadFinished bool `json:"download_finished"`
Tracker interface{} `json:"tracker"`
TotalUploaded int `json:"total_uploaded"`
TotalDownloaded int `json:"total_downloaded"`
Cached bool `json:"cached"`
Owner string `json:"owner"`
SeedTorrent bool `json:"seed_torrent"`
AllowZipped bool `json:"allow_zipped"`
LongTermSeeding bool `json:"long_term_seeding"`
TrackerMessage interface{} `json:"tracker_message"`
}
type InfoResponse APIResponse[torboxInfo]
type DownloadLinksResponse APIResponse[string]