Rewrote account switching, fix some minor bugs here and there
This commit is contained in:
71
pkg/debrid/account/account.go
Normal file
71
pkg/debrid/account/account.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/puzpuzpuz/xsync/v4"
|
||||
"github.com/sirrobot01/decypharr/internal/request"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
)
|
||||
|
||||
type Account struct {
|
||||
Debrid string `json:"debrid"` // The debrid service name, e.g. "realdebrid"
|
||||
links *xsync.Map[string, types.DownloadLink] // key is the sliced file link
|
||||
Index int `json:"index"` // The index of the account in the config
|
||||
Disabled atomic.Bool `json:"disabled"`
|
||||
Token string `json:"token"`
|
||||
TrafficUsed atomic.Int64 `json:"traffic_used"` // Traffic used in bytes
|
||||
Username string `json:"username"` // Username for the account
|
||||
httpClient *request.Client
|
||||
}
|
||||
|
||||
func (a *Account) Equals(other *Account) bool {
|
||||
if other == nil {
|
||||
return false
|
||||
}
|
||||
return a.Token == other.Token && a.Debrid == other.Debrid
|
||||
}
|
||||
|
||||
func (a *Account) Client() *request.Client {
|
||||
return a.httpClient
|
||||
}
|
||||
|
||||
// slice download link
|
||||
func (a *Account) sliceFileLink(fileLink string) string {
|
||||
if a.Debrid != "realdebrid" {
|
||||
return fileLink
|
||||
}
|
||||
if len(fileLink) < 39 {
|
||||
return fileLink
|
||||
}
|
||||
return fileLink[0:39]
|
||||
}
|
||||
|
||||
func (a *Account) GetDownloadLink(fileLink string) (types.DownloadLink, error) {
|
||||
slicedLink := a.sliceFileLink(fileLink)
|
||||
dl, ok := a.links.Load(slicedLink)
|
||||
if !ok {
|
||||
return types.DownloadLink{}, types.ErrDownloadLinkNotFound
|
||||
}
|
||||
return dl, nil
|
||||
}
|
||||
|
||||
func (a *Account) StoreDownloadLink(dl types.DownloadLink) {
|
||||
slicedLink := a.sliceFileLink(dl.Link)
|
||||
a.links.Store(slicedLink, dl)
|
||||
}
|
||||
func (a *Account) DeleteDownloadLink(fileLink string) {
|
||||
slicedLink := a.sliceFileLink(fileLink)
|
||||
a.links.Delete(slicedLink)
|
||||
}
|
||||
func (a *Account) ClearDownloadLinks() {
|
||||
a.links.Clear()
|
||||
}
|
||||
func (a *Account) DownloadLinksCount() int {
|
||||
return a.links.Size()
|
||||
}
|
||||
func (a *Account) StoreDownloadLinks(dls map[string]*types.DownloadLink) {
|
||||
for _, dl := range dls {
|
||||
a.StoreDownloadLink(*dl)
|
||||
}
|
||||
}
|
||||
211
pkg/debrid/account/manager.go
Normal file
211
pkg/debrid/account/manager.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/puzpuzpuz/xsync/v4"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/internal/request"
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"go.uber.org/ratelimit"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
debrid string
|
||||
current atomic.Pointer[Account]
|
||||
accounts *xsync.Map[string, *Account]
|
||||
}
|
||||
|
||||
func NewManager(debridConf config.Debrid, downloadRL ratelimit.Limiter, logger zerolog.Logger) *Manager {
|
||||
m := &Manager{
|
||||
debrid: debridConf.Name,
|
||||
accounts: xsync.NewMap[string, *Account](),
|
||||
}
|
||||
|
||||
var firstAccount *Account
|
||||
for idx, token := range debridConf.DownloadAPIKeys {
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
headers := map[string]string{
|
||||
"Authorization": fmt.Sprintf("Bearer %s", token),
|
||||
}
|
||||
account := &Account{
|
||||
Debrid: debridConf.Name,
|
||||
Token: token,
|
||||
Index: idx,
|
||||
links: xsync.NewMap[string, types.DownloadLink](),
|
||||
httpClient: request.New(
|
||||
request.WithRateLimiter(downloadRL),
|
||||
request.WithLogger(logger),
|
||||
request.WithHeaders(headers),
|
||||
request.WithMaxRetries(3),
|
||||
request.WithRetryableStatus(429, 447, 502),
|
||||
request.WithProxy(debridConf.Proxy),
|
||||
),
|
||||
}
|
||||
m.accounts.Store(token, account)
|
||||
if firstAccount == nil {
|
||||
firstAccount = account
|
||||
}
|
||||
}
|
||||
m.current.Store(firstAccount)
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Manager) Active() []*Account {
|
||||
activeAccounts := make([]*Account, 0)
|
||||
m.accounts.Range(func(key string, acc *Account) bool {
|
||||
if !acc.Disabled.Load() {
|
||||
activeAccounts = append(activeAccounts, acc)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
slices.SortFunc(activeAccounts, func(i, j *Account) int {
|
||||
return i.Index - j.Index
|
||||
})
|
||||
return activeAccounts
|
||||
}
|
||||
|
||||
func (m *Manager) All() []*Account {
|
||||
allAccounts := make([]*Account, 0)
|
||||
m.accounts.Range(func(key string, acc *Account) bool {
|
||||
allAccounts = append(allAccounts, acc)
|
||||
return true
|
||||
})
|
||||
|
||||
slices.SortFunc(allAccounts, func(i, j *Account) int {
|
||||
return i.Index - j.Index
|
||||
})
|
||||
return allAccounts
|
||||
}
|
||||
|
||||
func (m *Manager) Current() *Account {
|
||||
// Fast path - most common case
|
||||
current := m.current.Load()
|
||||
if current != nil && !current.Disabled.Load() {
|
||||
return current
|
||||
}
|
||||
|
||||
// Slow path - find new current account
|
||||
activeAccounts := m.Active()
|
||||
if len(activeAccounts) == 0 {
|
||||
m.current.Store(nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
newCurrent := activeAccounts[0]
|
||||
m.current.Store(newCurrent)
|
||||
return newCurrent
|
||||
}
|
||||
|
||||
func (m *Manager) setCurrent(account *Account) {
|
||||
m.current.Store(account)
|
||||
}
|
||||
|
||||
func (m *Manager) Disable(account *Account) {
|
||||
if account == nil {
|
||||
return
|
||||
}
|
||||
|
||||
account.Disabled.Store(true)
|
||||
|
||||
// If we're disabling the current account, it will be replaced
|
||||
// on the next Current() call - no need to proactively update
|
||||
current := m.current.Load()
|
||||
if current != nil && current.Token == account.Token {
|
||||
// Optional: immediately find replacement
|
||||
activeAccounts := m.Active()
|
||||
if len(activeAccounts) > 0 {
|
||||
m.current.Store(activeAccounts[0])
|
||||
} else {
|
||||
m.current.Store(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Reset() {
|
||||
m.accounts.Range(func(key string, acc *Account) bool {
|
||||
acc.Disabled.Store(false)
|
||||
return true
|
||||
})
|
||||
|
||||
// Set current to first active account
|
||||
activeAccounts := m.Active()
|
||||
if len(activeAccounts) > 0 {
|
||||
m.current.Store(activeAccounts[0])
|
||||
} else {
|
||||
m.current.Store(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Update(account *Account) {
|
||||
if account != nil {
|
||||
m.accounts.Store(account.Token, account)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) GetAccount(token string) (*Account, error) {
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("token cannot be empty")
|
||||
}
|
||||
acc, ok := m.accounts.Load(token)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("account not found for token")
|
||||
}
|
||||
return acc, nil
|
||||
}
|
||||
|
||||
func (m *Manager) GetDownloadLink(fileLink string) (types.DownloadLink, error) {
|
||||
current := m.Current()
|
||||
if current == nil {
|
||||
return types.DownloadLink{}, fmt.Errorf("no active account for debrid service %s", m.debrid)
|
||||
}
|
||||
return current.GetDownloadLink(fileLink)
|
||||
}
|
||||
|
||||
func (m *Manager) GetAccountFromDownloadLink(downloadLink types.DownloadLink) (*Account, error) {
|
||||
if downloadLink.Link == "" {
|
||||
return nil, fmt.Errorf("cannot get account from empty download link")
|
||||
}
|
||||
if downloadLink.Token == "" {
|
||||
return nil, fmt.Errorf("cannot get account from download link without token")
|
||||
}
|
||||
return m.GetAccount(downloadLink.Token)
|
||||
}
|
||||
|
||||
func (m *Manager) StoreDownloadLink(downloadLink types.DownloadLink) {
|
||||
if downloadLink.Link == "" || downloadLink.Token == "" {
|
||||
return
|
||||
}
|
||||
account, err := m.GetAccount(downloadLink.Token)
|
||||
if err != nil || account == nil {
|
||||
return
|
||||
}
|
||||
account.StoreDownloadLink(downloadLink)
|
||||
}
|
||||
|
||||
func (m *Manager) Stats() []map[string]any {
|
||||
stats := make([]map[string]any, 0)
|
||||
|
||||
for _, acc := range m.All() {
|
||||
maskedToken := utils.Mask(acc.Token)
|
||||
accountDetail := map[string]any{
|
||||
"in_use": acc.Equals(m.Current()),
|
||||
"order": acc.Index,
|
||||
"disabled": acc.Disabled.Load(),
|
||||
"token_masked": maskedToken,
|
||||
"username": acc.Username,
|
||||
"traffic_used": acc.TrafficUsed.Load(),
|
||||
"links_count": acc.DownloadLinksCount(),
|
||||
"debrid": acc.Debrid,
|
||||
}
|
||||
stats = append(stats, accountDetail)
|
||||
}
|
||||
return stats
|
||||
}
|
||||
30
pkg/debrid/common/interface.go
Normal file
30
pkg/debrid/common/interface.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/account"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
)
|
||||
|
||||
type Client interface {
|
||||
SubmitMagnet(tr *types.Torrent) (*types.Torrent, error)
|
||||
CheckStatus(tr *types.Torrent) (*types.Torrent, error)
|
||||
GetFileDownloadLinks(tr *types.Torrent) error
|
||||
GetDownloadLink(tr *types.Torrent, file *types.File) (types.DownloadLink, error)
|
||||
DeleteTorrent(torrentId string) error
|
||||
IsAvailable(infohashes []string) map[string]bool
|
||||
GetDownloadUncached() bool
|
||||
UpdateTorrent(torrent *types.Torrent) error
|
||||
GetTorrent(torrentId string) (*types.Torrent, error)
|
||||
GetTorrents() ([]*types.Torrent, error)
|
||||
Name() string
|
||||
Logger() zerolog.Logger
|
||||
GetDownloadingStatus() []string
|
||||
RefreshDownloadLinks() error
|
||||
CheckLink(link string) error
|
||||
GetMountPath() string
|
||||
AccountManager() *account.Manager // Returns the active download account/token
|
||||
GetProfile() (*types.Profile, error)
|
||||
GetAvailableSlots() (int, error)
|
||||
SyncAccounts() error // Updates each accounts details(like traffic, username, etc.)
|
||||
}
|
||||
@@ -1,13 +1,19 @@
|
||||
package debrid
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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/arr"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/common"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/providers/alldebrid"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/providers/debridlink"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/providers/realdebrid"
|
||||
@@ -15,16 +21,15 @@ import (
|
||||
debridStore "github.com/sirrobot01/decypharr/pkg/debrid/store"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"github.com/sirrobot01/decypharr/pkg/rclone"
|
||||
"sync"
|
||||
"time"
|
||||
"go.uber.org/ratelimit"
|
||||
)
|
||||
|
||||
type Debrid struct {
|
||||
cache *debridStore.Cache // Could be nil if not using WebDAV
|
||||
client types.Client // HTTP client for making requests to the debrid service
|
||||
client common.Client // HTTP client for making requests to the debrid service
|
||||
}
|
||||
|
||||
func (de *Debrid) Client() types.Client {
|
||||
func (de *Debrid) Client() common.Client {
|
||||
return de.client
|
||||
}
|
||||
|
||||
@@ -152,7 +157,7 @@ func (d *Storage) Debrids() map[string]*Debrid {
|
||||
return debridsCopy
|
||||
}
|
||||
|
||||
func (d *Storage) Client(name string) types.Client {
|
||||
func (d *Storage) Client(name string) common.Client {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
if client, exists := d.debrids[name]; exists {
|
||||
@@ -177,10 +182,10 @@ func (d *Storage) Reset() {
|
||||
d.lastUsed = ""
|
||||
}
|
||||
|
||||
func (d *Storage) Clients() map[string]types.Client {
|
||||
func (d *Storage) Clients() map[string]common.Client {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
clientsCopy := make(map[string]types.Client)
|
||||
clientsCopy := make(map[string]common.Client)
|
||||
for name, debrid := range d.debrids {
|
||||
if debrid != nil && debrid.client != nil {
|
||||
clientsCopy[name] = debrid.client
|
||||
@@ -201,10 +206,10 @@ func (d *Storage) Caches() map[string]*debridStore.Cache {
|
||||
return cachesCopy
|
||||
}
|
||||
|
||||
func (d *Storage) FilterClients(filter func(types.Client) bool) map[string]types.Client {
|
||||
func (d *Storage) FilterClients(filter func(common.Client) bool) map[string]common.Client {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
filteredClients := make(map[string]types.Client)
|
||||
filteredClients := make(map[string]common.Client)
|
||||
for name, client := range d.debrids {
|
||||
if client != nil && filter(client.client) {
|
||||
filteredClients[name] = client.client
|
||||
@@ -213,18 +218,28 @@ func (d *Storage) FilterClients(filter func(types.Client) bool) map[string]types
|
||||
return filteredClients
|
||||
}
|
||||
|
||||
func createDebridClient(dc config.Debrid) (types.Client, error) {
|
||||
func createDebridClient(dc config.Debrid) (common.Client, error) {
|
||||
rateLimits := map[string]ratelimit.Limiter{}
|
||||
|
||||
mainRL := request.ParseRateLimit(dc.RateLimit)
|
||||
repairRL := request.ParseRateLimit(cmp.Or(dc.RepairRateLimit, dc.RateLimit))
|
||||
downloadRL := request.ParseRateLimit(cmp.Or(dc.DownloadRateLimit, dc.RateLimit))
|
||||
|
||||
rateLimits["main"] = mainRL
|
||||
rateLimits["repair"] = repairRL
|
||||
rateLimits["download"] = downloadRL
|
||||
|
||||
switch dc.Name {
|
||||
case "realdebrid":
|
||||
return realdebrid.New(dc)
|
||||
return realdebrid.New(dc, rateLimits)
|
||||
case "torbox":
|
||||
return torbox.New(dc)
|
||||
return torbox.New(dc, rateLimits)
|
||||
case "debridlink":
|
||||
return debridlink.New(dc)
|
||||
return debridlink.New(dc, rateLimits)
|
||||
case "alldebrid":
|
||||
return alldebrid.New(dc)
|
||||
return alldebrid.New(dc, rateLimits)
|
||||
default:
|
||||
return realdebrid.New(dc)
|
||||
return realdebrid.New(dc, rateLimits)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,7 +254,7 @@ func Process(ctx context.Context, store *Storage, selectedDebrid string, magnet
|
||||
Files: make(map[string]types.File),
|
||||
}
|
||||
|
||||
clients := store.FilterClients(func(c types.Client) bool {
|
||||
clients := store.FilterClients(func(c common.Client) bool {
|
||||
if selectedDebrid != "" && c.Name() != selectedDebrid {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -3,25 +3,28 @@ 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"
|
||||
|
||||
"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"
|
||||
"go.uber.org/ratelimit"
|
||||
)
|
||||
|
||||
type AllDebrid struct {
|
||||
name string
|
||||
Host string `json:"host"`
|
||||
APIKey string
|
||||
accounts *types.Accounts
|
||||
accountsManager *account.Manager
|
||||
autoExpiresLinksAfter time.Duration
|
||||
DownloadUncached bool
|
||||
client *request.Client
|
||||
@@ -34,8 +37,7 @@ type AllDebrid struct {
|
||||
minimumFreeSlot int
|
||||
}
|
||||
|
||||
func New(dc config.Debrid) (*AllDebrid, error) {
|
||||
rl := request.ParseRateLimit(dc.RateLimit)
|
||||
func New(dc config.Debrid, ratelimits map[string]ratelimit.Limiter) (*AllDebrid, error) {
|
||||
|
||||
headers := map[string]string{
|
||||
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
|
||||
@@ -44,7 +46,7 @@ func New(dc config.Debrid) (*AllDebrid, error) {
|
||||
client := request.New(
|
||||
request.WithHeaders(headers),
|
||||
request.WithLogger(_log),
|
||||
request.WithRateLimiter(rl),
|
||||
request.WithRateLimiter(ratelimits["main"]),
|
||||
request.WithProxy(dc.Proxy),
|
||||
)
|
||||
|
||||
@@ -56,7 +58,7 @@ func New(dc config.Debrid) (*AllDebrid, error) {
|
||||
name: "alldebrid",
|
||||
Host: "http://api.alldebrid.com/v4.1",
|
||||
APIKey: dc.APIKey,
|
||||
accounts: types.NewAccounts(dc),
|
||||
accountsManager: account.NewManager(dc, ratelimits["download"], _log),
|
||||
DownloadUncached: dc.DownloadUncached,
|
||||
autoExpiresLinksAfter: autoExpiresLinksAfter,
|
||||
client: client,
|
||||
@@ -294,7 +296,7 @@ func (ad *AllDebrid) DeleteTorrent(torrentId string) error {
|
||||
|
||||
func (ad *AllDebrid) GetFileDownloadLinks(t *types.Torrent) error {
|
||||
filesCh := make(chan types.File, len(t.Files))
|
||||
linksCh := make(chan *types.DownloadLink, len(t.Files))
|
||||
linksCh := make(chan types.DownloadLink, len(t.Files))
|
||||
errCh := make(chan error, len(t.Files))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
@@ -302,15 +304,11 @@ func (ad *AllDebrid) GetFileDownloadLinks(t *types.Torrent) error {
|
||||
for _, file := range t.Files {
|
||||
go func(file types.File) {
|
||||
defer wg.Done()
|
||||
link, _, err := ad.GetDownloadLink(t, &file)
|
||||
link, err := ad.GetDownloadLink(t, &file)
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
if link == nil {
|
||||
errCh <- fmt.Errorf("download link is empty")
|
||||
return
|
||||
}
|
||||
linksCh <- link
|
||||
file.DownloadLink = link
|
||||
filesCh <- file
|
||||
@@ -328,17 +326,14 @@ func (ad *AllDebrid) GetFileDownloadLinks(t *types.Torrent) error {
|
||||
}
|
||||
|
||||
// Collect download links
|
||||
links := make(map[string]*types.DownloadLink, len(t.Files))
|
||||
links := make(map[string]types.DownloadLink, len(t.Files))
|
||||
|
||||
for link := range linksCh {
|
||||
if link == nil {
|
||||
if link.Empty() {
|
||||
continue
|
||||
}
|
||||
links[link.Link] = link
|
||||
}
|
||||
// Update the files with download links
|
||||
ad.accounts.SetDownloadLinks(nil, links)
|
||||
|
||||
// Check for errors
|
||||
for err := range errCh {
|
||||
if err != nil {
|
||||
@@ -350,7 +345,7 @@ func (ad *AllDebrid) GetFileDownloadLinks(t *types.Torrent) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetDownloadLink(t *types.Torrent, file *types.File) (*types.DownloadLink, *types.Account, error) {
|
||||
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)
|
||||
@@ -358,22 +353,23 @@ func (ad *AllDebrid) GetDownloadLink(t *types.Torrent, file *types.File) (*types
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := ad.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return types.DownloadLink{}, err
|
||||
}
|
||||
var data DownloadLink
|
||||
if err = json.Unmarshal(resp, &data); err != nil {
|
||||
return nil, nil, err
|
||||
return types.DownloadLink{}, err
|
||||
}
|
||||
|
||||
if data.Error != nil {
|
||||
return nil, nil, fmt.Errorf("error getting download link: %s", data.Error.Message)
|
||||
return types.DownloadLink{}, fmt.Errorf("error getting download link: %s", data.Error.Message)
|
||||
}
|
||||
link := data.Data.Link
|
||||
if link == "" {
|
||||
return nil, nil, fmt.Errorf("download link is empty")
|
||||
return types.DownloadLink{}, fmt.Errorf("download link is empty")
|
||||
}
|
||||
now := time.Now()
|
||||
return &types.DownloadLink{
|
||||
dl := types.DownloadLink{
|
||||
Token: ad.APIKey,
|
||||
Link: file.Link,
|
||||
DownloadLink: link,
|
||||
Id: data.Data.Id,
|
||||
@@ -381,7 +377,10 @@ func (ad *AllDebrid) GetDownloadLink(t *types.Torrent, file *types.File) (*types
|
||||
Filename: file.Name,
|
||||
Generated: now,
|
||||
ExpiresAt: now.Add(ad.autoExpiresLinksAfter),
|
||||
}, nil, nil
|
||||
}
|
||||
// Set the download link in the account
|
||||
ad.accountsManager.StoreDownloadLink(dl)
|
||||
return dl, nil
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetTorrents() ([]*types.Torrent, error) {
|
||||
@@ -437,10 +436,6 @@ func (ad *AllDebrid) GetMountPath() string {
|
||||
return ad.MountPath
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) DeleteDownloadLink(linkId string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetAvailableSlots() (int, error) {
|
||||
// This function is a placeholder for AllDebrid
|
||||
//TODO: Implement the logic to check available slots for AllDebrid
|
||||
@@ -495,8 +490,8 @@ func (ad *AllDebrid) GetProfile() (*types.Profile, error) {
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) Accounts() *types.Accounts {
|
||||
return ad.accounts
|
||||
func (ad *AllDebrid) AccountManager() *account.Manager {
|
||||
return ad.accountsManager
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) SyncAccounts() error {
|
||||
|
||||
@@ -4,13 +4,16 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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"
|
||||
"time"
|
||||
"go.uber.org/ratelimit"
|
||||
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -20,7 +23,7 @@ type DebridLink struct {
|
||||
name string
|
||||
Host string `json:"host"`
|
||||
APIKey string
|
||||
accounts *types.Accounts
|
||||
accountsManager *account.Manager
|
||||
DownloadUncached bool
|
||||
client *request.Client
|
||||
|
||||
@@ -34,9 +37,7 @@ type DebridLink struct {
|
||||
Profile *types.Profile `json:"profile,omitempty"`
|
||||
}
|
||||
|
||||
func New(dc config.Debrid) (*DebridLink, error) {
|
||||
rl := request.ParseRateLimit(dc.RateLimit)
|
||||
|
||||
func New(dc config.Debrid, ratelimits map[string]ratelimit.Limiter) (*DebridLink, error) {
|
||||
headers := map[string]string{
|
||||
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
|
||||
"Content-Type": "application/json",
|
||||
@@ -45,7 +46,7 @@ func New(dc config.Debrid) (*DebridLink, error) {
|
||||
client := request.New(
|
||||
request.WithHeaders(headers),
|
||||
request.WithLogger(_log),
|
||||
request.WithRateLimiter(rl),
|
||||
request.WithRateLimiter(ratelimits["main"]),
|
||||
request.WithProxy(dc.Proxy),
|
||||
)
|
||||
|
||||
@@ -57,7 +58,7 @@ func New(dc config.Debrid) (*DebridLink, error) {
|
||||
name: "debridlink",
|
||||
Host: "https://debrid-link.com/api/v2",
|
||||
APIKey: dc.APIKey,
|
||||
accounts: types.NewAccounts(dc),
|
||||
accountsManager: account.NewManager(dc, ratelimits["download"], _log),
|
||||
DownloadUncached: dc.DownloadUncached,
|
||||
autoExpiresLinksAfter: autoExpiresLinksAfter,
|
||||
client: client,
|
||||
@@ -221,7 +222,6 @@ func (dl *DebridLink) UpdateTorrent(t *types.Torrent) error {
|
||||
t.OriginalFilename = name
|
||||
t.Added = time.Unix(data.Created, 0).Format(time.RFC3339)
|
||||
cfg := config.Get()
|
||||
links := make(map[string]*types.DownloadLink)
|
||||
now := time.Now()
|
||||
for _, f := range data.Files {
|
||||
if !cfg.IsSizeAllowed(f.Size) {
|
||||
@@ -235,19 +235,19 @@ func (dl *DebridLink) UpdateTorrent(t *types.Torrent) error {
|
||||
Path: f.Name,
|
||||
Link: f.DownloadURL,
|
||||
}
|
||||
link := &types.DownloadLink{
|
||||
link := types.DownloadLink{
|
||||
Token: dl.APIKey,
|
||||
Filename: f.Name,
|
||||
Link: f.DownloadURL,
|
||||
DownloadLink: f.DownloadURL,
|
||||
Generated: now,
|
||||
ExpiresAt: now.Add(dl.autoExpiresLinksAfter),
|
||||
}
|
||||
links[file.Link] = link
|
||||
file.DownloadLink = link
|
||||
t.Files[f.Name] = file
|
||||
dl.accountsManager.StoreDownloadLink(link)
|
||||
}
|
||||
|
||||
dl.accounts.SetDownloadLinks(nil, links)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -284,8 +284,6 @@ func (dl *DebridLink) SubmitMagnet(t *types.Torrent) (*types.Torrent, error) {
|
||||
t.MountPath = dl.MountPath
|
||||
t.Debrid = dl.name
|
||||
t.Added = time.Unix(data.Created, 0).Format(time.RFC3339)
|
||||
|
||||
links := make(map[string]*types.DownloadLink)
|
||||
now := time.Now()
|
||||
for _, f := range data.Files {
|
||||
file := types.File{
|
||||
@@ -297,18 +295,18 @@ func (dl *DebridLink) SubmitMagnet(t *types.Torrent) (*types.Torrent, error) {
|
||||
Link: f.DownloadURL,
|
||||
Generated: now,
|
||||
}
|
||||
link := &types.DownloadLink{
|
||||
link := types.DownloadLink{
|
||||
Token: dl.APIKey,
|
||||
Filename: f.Name,
|
||||
Link: f.DownloadURL,
|
||||
DownloadLink: f.DownloadURL,
|
||||
Generated: now,
|
||||
ExpiresAt: now.Add(dl.autoExpiresLinksAfter),
|
||||
}
|
||||
links[file.Link] = link
|
||||
file.DownloadLink = link
|
||||
t.Files[f.Name] = file
|
||||
dl.accountsManager.StoreDownloadLink(link)
|
||||
}
|
||||
dl.accounts.SetDownloadLinks(nil, links)
|
||||
|
||||
return t, nil
|
||||
}
|
||||
@@ -356,8 +354,8 @@ func (dl *DebridLink) RefreshDownloadLinks() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dl *DebridLink) GetDownloadLink(t *types.Torrent, file *types.File) (*types.DownloadLink, *types.Account, error) {
|
||||
return dl.accounts.GetDownloadLink(file.Link)
|
||||
func (dl *DebridLink) GetDownloadLink(t *types.Torrent, file *types.File) (types.DownloadLink, error) {
|
||||
return dl.accountsManager.GetDownloadLink(file.Link)
|
||||
}
|
||||
|
||||
func (dl *DebridLink) GetDownloadingStatus() []string {
|
||||
@@ -402,7 +400,6 @@ func (dl *DebridLink) getTorrents(page, perPage int) ([]*types.Torrent, error) {
|
||||
}
|
||||
|
||||
data := *res.Value
|
||||
links := make(map[string]*types.DownloadLink)
|
||||
|
||||
if len(data) == 0 {
|
||||
return torrents, nil
|
||||
@@ -438,20 +435,20 @@ func (dl *DebridLink) getTorrents(page, perPage int) ([]*types.Torrent, error) {
|
||||
Path: f.Name,
|
||||
Link: f.DownloadURL,
|
||||
}
|
||||
link := &types.DownloadLink{
|
||||
link := types.DownloadLink{
|
||||
Token: dl.APIKey,
|
||||
Filename: f.Name,
|
||||
Link: f.DownloadURL,
|
||||
DownloadLink: f.DownloadURL,
|
||||
Generated: now,
|
||||
ExpiresAt: now.Add(dl.autoExpiresLinksAfter),
|
||||
}
|
||||
links[file.Link] = link
|
||||
file.DownloadLink = link
|
||||
torrent.Files[f.Name] = file
|
||||
dl.accountsManager.StoreDownloadLink(link)
|
||||
}
|
||||
torrents = append(torrents, torrent)
|
||||
}
|
||||
dl.accounts.SetDownloadLinks(nil, links)
|
||||
|
||||
return torrents, nil
|
||||
}
|
||||
@@ -464,10 +461,6 @@ func (dl *DebridLink) GetMountPath() string {
|
||||
return dl.MountPath
|
||||
}
|
||||
|
||||
func (dl *DebridLink) DeleteDownloadLink(linkId string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dl *DebridLink) GetAvailableSlots() (int, error) {
|
||||
//TODO: Implement the logic to check available slots for DebridLink
|
||||
return 0, fmt.Errorf("GetAvailableSlots not implemented for DebridLink")
|
||||
@@ -518,8 +511,8 @@ func (dl *DebridLink) GetProfile() (*types.Profile, error) {
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (dl *DebridLink) Accounts() *types.Accounts {
|
||||
return dl.accounts
|
||||
func (dl *DebridLink) AccountManager() *account.Manager {
|
||||
return dl.accountsManager
|
||||
}
|
||||
|
||||
func (dl *DebridLink) SyncAccounts() error {
|
||||
|
||||
@@ -2,11 +2,9 @@ package realdebrid
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"io"
|
||||
"net/http"
|
||||
gourl "net/url"
|
||||
@@ -16,6 +14,10 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/account"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"go.uber.org/ratelimit"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
@@ -28,12 +30,11 @@ type RealDebrid struct {
|
||||
name string
|
||||
Host string `json:"host"`
|
||||
|
||||
APIKey string
|
||||
accounts *types.Accounts
|
||||
APIKey string
|
||||
accountsManager *account.Manager
|
||||
|
||||
DownloadUncached bool
|
||||
client *request.Client
|
||||
downloadClient *request.Client
|
||||
repairClient *request.Client
|
||||
autoExpiresLinksAfter time.Duration
|
||||
|
||||
@@ -49,10 +50,7 @@ type RealDebrid struct {
|
||||
limit int
|
||||
}
|
||||
|
||||
func New(dc config.Debrid) (*RealDebrid, error) {
|
||||
rl := request.ParseRateLimit(dc.RateLimit)
|
||||
repairRl := request.ParseRateLimit(cmp.Or(dc.RepairRateLimit, dc.RateLimit))
|
||||
downloadRl := request.ParseRateLimit(cmp.Or(dc.DownloadRateLimit, dc.RateLimit))
|
||||
func New(dc config.Debrid, ratelimits map[string]ratelimit.Limiter) (*RealDebrid, error) {
|
||||
|
||||
headers := map[string]string{
|
||||
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
|
||||
@@ -68,27 +66,20 @@ func New(dc config.Debrid) (*RealDebrid, error) {
|
||||
name: "realdebrid",
|
||||
Host: "https://api.real-debrid.com/rest/1.0",
|
||||
APIKey: dc.APIKey,
|
||||
accounts: types.NewAccounts(dc),
|
||||
accountsManager: account.NewManager(dc, ratelimits["download"], _log),
|
||||
DownloadUncached: dc.DownloadUncached,
|
||||
autoExpiresLinksAfter: autoExpiresLinksAfter,
|
||||
UnpackRar: dc.UnpackRar,
|
||||
client: request.New(
|
||||
request.WithHeaders(headers),
|
||||
request.WithRateLimiter(rl),
|
||||
request.WithRateLimiter(ratelimits["main"]),
|
||||
request.WithLogger(_log),
|
||||
request.WithMaxRetries(10),
|
||||
request.WithRetryableStatus(429, 502),
|
||||
request.WithProxy(dc.Proxy),
|
||||
),
|
||||
downloadClient: request.New(
|
||||
request.WithRateLimiter(downloadRl),
|
||||
request.WithLogger(_log),
|
||||
request.WithMaxRetries(10),
|
||||
request.WithRetryableStatus(429, 447, 502),
|
||||
request.WithProxy(dc.Proxy),
|
||||
),
|
||||
repairClient: request.New(
|
||||
request.WithRateLimiter(repairRl),
|
||||
request.WithRateLimiter(ratelimits["repair"]),
|
||||
request.WithHeaders(headers),
|
||||
request.WithLogger(_log),
|
||||
request.WithMaxRetries(4),
|
||||
@@ -195,7 +186,7 @@ func (r *RealDebrid) handleRarArchive(t *types.Torrent, data torrentInfo, select
|
||||
|
||||
r.logger.Info().Msgf("RAR file detected, unpacking: %s", t.Name)
|
||||
linkFile := &types.File{TorrentId: t.Id, Link: data.Links[0]}
|
||||
downloadLinkObj, account, err := r.GetDownloadLink(t, linkFile)
|
||||
downloadLinkObj, err := r.GetDownloadLink(t, linkFile)
|
||||
|
||||
if err != nil {
|
||||
r.logger.Debug().Err(err).Msgf("Error getting download link for RAR file: %s. Falling back to single file representation.", t.Name)
|
||||
@@ -244,7 +235,6 @@ func (r *RealDebrid) handleRarArchive(t *types.Torrent, data torrentInfo, select
|
||||
return r.handleRarFallback(t, data)
|
||||
}
|
||||
r.logger.Info().Msgf("Unpacked RAR archive for torrent: %s with %d files", t.Name, len(files))
|
||||
r.accounts.SetDownloadLink(account, downloadLinkObj)
|
||||
return files, nil
|
||||
}
|
||||
|
||||
@@ -361,7 +351,9 @@ func (r *RealDebrid) addTorrent(t *types.Torrent) (*types.Torrent, error) {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("realdebrid API error: Status: %d || Body: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading response body: %w", err)
|
||||
@@ -582,7 +574,7 @@ func (r *RealDebrid) GetFileDownloadLinks(t *types.Torrent) error {
|
||||
var firstErr error
|
||||
|
||||
files := make(map[string]types.File)
|
||||
links := make(map[string]*types.DownloadLink)
|
||||
links := make(map[string]types.DownloadLink)
|
||||
|
||||
_files := t.GetFiles()
|
||||
wg.Add(len(_files))
|
||||
@@ -591,7 +583,7 @@ func (r *RealDebrid) GetFileDownloadLinks(t *types.Torrent) error {
|
||||
go func(file types.File) {
|
||||
defer wg.Done()
|
||||
|
||||
link, account, err := r.GetDownloadLink(t, &file)
|
||||
link, err := r.GetDownloadLink(t, &file)
|
||||
if err != nil {
|
||||
mu.Lock()
|
||||
if firstErr == nil {
|
||||
@@ -600,7 +592,7 @@ func (r *RealDebrid) GetFileDownloadLinks(t *types.Torrent) error {
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
if link == nil {
|
||||
if link.Empty() {
|
||||
mu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("realdebrid API error: download link not found for file %s", file.Name)
|
||||
@@ -610,8 +602,6 @@ func (r *RealDebrid) GetFileDownloadLinks(t *types.Torrent) error {
|
||||
}
|
||||
|
||||
file.DownloadLink = link
|
||||
r.accounts.SetDownloadLink(account, link)
|
||||
|
||||
mu.Lock()
|
||||
files[file.Name] = file
|
||||
links[link.Link] = link
|
||||
@@ -646,8 +636,9 @@ func (r *RealDebrid) CheckLink(link string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) getDownloadLink(account *types.Account, file *types.File) (*types.DownloadLink, error) {
|
||||
func (r *RealDebrid) getDownloadLink(account *account.Account, file *types.File) (types.DownloadLink, error) {
|
||||
url := fmt.Sprintf("%s/unrestrict/link/", r.Host)
|
||||
emptyLink := types.DownloadLink{}
|
||||
_link := file.Link
|
||||
if strings.HasPrefix(file.Link, "https://real-debrid.com/d/") && len(file.Link) > 39 {
|
||||
_link = file.Link[0:39]
|
||||
@@ -656,71 +647,66 @@ func (r *RealDebrid) getDownloadLink(account *types.Account, file *types.File) (
|
||||
"link": {_link},
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(payload.Encode()))
|
||||
r.downloadClient.SetHeader("Authorization", fmt.Sprintf("Bearer %s", account.Token))
|
||||
resp, err := r.downloadClient.Do(req)
|
||||
resp, err := account.Client().Do(req)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return emptyLink, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
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
|
||||
return emptyLink, 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))
|
||||
return emptyLink, fmt.Errorf("error unmarshalling %d || %s \n %s", resp.StatusCode, err, string(b))
|
||||
}
|
||||
switch data.ErrorCode {
|
||||
case 19:
|
||||
return nil, utils.HosterUnavailableError // File has been removed
|
||||
case 23:
|
||||
return nil, utils.TrafficExceededError
|
||||
case 24:
|
||||
return nil, utils.HosterUnavailableError // Link has been nerfed
|
||||
case 34:
|
||||
return nil, utils.TrafficExceededError // traffic exceeded
|
||||
case 35:
|
||||
return nil, utils.HosterUnavailableError
|
||||
case 36:
|
||||
return nil, utils.TrafficExceededError // traffic exceeded
|
||||
case 19, 24, 35:
|
||||
return emptyLink, utils.HosterUnavailableError // File has been removed
|
||||
case 23, 34, 36:
|
||||
return emptyLink, utils.TrafficExceededError
|
||||
default:
|
||||
return nil, fmt.Errorf("realdebrid API error: Status: %d || Code: %d", resp.StatusCode, data.ErrorCode)
|
||||
return emptyLink, 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
|
||||
return emptyLink, err
|
||||
}
|
||||
var data UnrestrictResponse
|
||||
if err = json.Unmarshal(b, &data); err != nil {
|
||||
return nil, fmt.Errorf("realdebrid API error: Error unmarshalling response: %w", err)
|
||||
return emptyLink, 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 emptyLink, fmt.Errorf("realdebrid API error: download link not found")
|
||||
}
|
||||
now := time.Now()
|
||||
return &types.DownloadLink{
|
||||
dl := types.DownloadLink{
|
||||
Token: account.Token,
|
||||
Filename: data.Filename,
|
||||
Size: data.Filesize,
|
||||
Link: data.Link,
|
||||
DownloadLink: data.Download,
|
||||
Generated: now,
|
||||
ExpiresAt: now.Add(r.autoExpiresLinksAfter),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Store the link in the account
|
||||
account.StoreDownloadLink(dl)
|
||||
return dl, nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetDownloadLink(t *types.Torrent, file *types.File) (*types.DownloadLink, *types.Account, error) {
|
||||
|
||||
accounts := r.accounts.Active()
|
||||
for _, account := range accounts {
|
||||
downloadLink, err := r.getDownloadLink(account, file)
|
||||
|
||||
func (r *RealDebrid) GetDownloadLink(t *types.Torrent, file *types.File) (types.DownloadLink, error) {
|
||||
accounts := r.accountsManager.Active()
|
||||
for _, _account := range accounts {
|
||||
downloadLink, err := r.getDownloadLink(_account, file)
|
||||
if err == nil {
|
||||
return downloadLink, account, nil
|
||||
return downloadLink, nil
|
||||
}
|
||||
|
||||
retries := 0
|
||||
@@ -729,16 +715,16 @@ func (r *RealDebrid) GetDownloadLink(t *types.Torrent, file *types.File) (*types
|
||||
retries = 5
|
||||
} else {
|
||||
// If the error is not traffic exceeded, return the error
|
||||
return nil, account, err
|
||||
return downloadLink, err
|
||||
}
|
||||
backOff := 1 * time.Second
|
||||
for retries > 0 {
|
||||
downloadLink, err = r.getDownloadLink(account, file)
|
||||
downloadLink, err = r.getDownloadLink(_account, file)
|
||||
if err == nil {
|
||||
return downloadLink, account, nil
|
||||
return downloadLink, nil
|
||||
}
|
||||
if !errors.Is(err, utils.TrafficExceededError) {
|
||||
return nil, account, err
|
||||
return downloadLink, err
|
||||
}
|
||||
// Add a delay before retrying
|
||||
time.Sleep(backOff)
|
||||
@@ -746,7 +732,7 @@ func (r *RealDebrid) GetDownloadLink(t *types.Torrent, file *types.File) (*types
|
||||
retries--
|
||||
}
|
||||
}
|
||||
return nil, nil, fmt.Errorf("realdebrid API error: used all active accounts")
|
||||
return types.DownloadLink{}, fmt.Errorf("realdebrid API error: used all active accounts")
|
||||
}
|
||||
|
||||
func (r *RealDebrid) getTorrents(offset int, limit int) (int, []*types.Torrent, error) {
|
||||
@@ -844,17 +830,17 @@ func (r *RealDebrid) GetTorrents() ([]*types.Torrent, error) {
|
||||
}
|
||||
|
||||
func (r *RealDebrid) RefreshDownloadLinks() error {
|
||||
accounts := r.accounts.All()
|
||||
accounts := r.accountsManager.All()
|
||||
|
||||
for _, account := range accounts {
|
||||
if account == nil || account.Token == "" {
|
||||
for _, _account := range accounts {
|
||||
if _account == nil || _account.Token == "" {
|
||||
continue
|
||||
}
|
||||
offset := 0
|
||||
limit := 1000
|
||||
links := make(map[string]*types.DownloadLink)
|
||||
for {
|
||||
dl, err := r.getDownloadLinks(account, offset, limit)
|
||||
dl, err := r.getDownloadLinks(_account, offset, limit)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
@@ -872,19 +858,18 @@ func (r *RealDebrid) RefreshDownloadLinks() error {
|
||||
|
||||
offset += len(dl)
|
||||
}
|
||||
r.accounts.SetDownloadLinks(account, links)
|
||||
_account.StoreDownloadLinks(links)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) getDownloadLinks(account *types.Account, offset int, limit int) ([]types.DownloadLink, error) {
|
||||
func (r *RealDebrid) getDownloadLinks(account *account.Account, 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)
|
||||
r.downloadClient.SetHeader("Authorization", fmt.Sprintf("Bearer %s", account.Token))
|
||||
resp, err := r.downloadClient.MakeRequest(req)
|
||||
resp, err := account.Client().MakeRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -895,6 +880,7 @@ func (r *RealDebrid) getDownloadLinks(account *types.Account, offset int, limit
|
||||
links := make([]types.DownloadLink, 0)
|
||||
for _, d := range data {
|
||||
links = append(links, types.DownloadLink{
|
||||
Token: account.Token,
|
||||
Filename: d.Filename,
|
||||
Size: d.Filesize,
|
||||
Link: d.Link,
|
||||
@@ -920,15 +906,6 @@ func (r *RealDebrid) GetMountPath() string {
|
||||
return r.MountPath
|
||||
}
|
||||
|
||||
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
|
||||
@@ -971,35 +948,33 @@ func (r *RealDebrid) GetAvailableSlots() (int, error) {
|
||||
return data.TotalSlots - data.ActiveSlots - r.minimumFreeSlot, nil // Ensure we maintain minimum active pots
|
||||
}
|
||||
|
||||
func (r *RealDebrid) Accounts() *types.Accounts {
|
||||
return r.accounts
|
||||
func (r *RealDebrid) AccountManager() *account.Manager {
|
||||
return r.accountsManager
|
||||
}
|
||||
|
||||
func (r *RealDebrid) SyncAccounts() error {
|
||||
// Sync accounts with the current configuration
|
||||
if len(r.accounts.Active()) == 0 {
|
||||
if len(r.accountsManager.Active()) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, account := range r.accounts.All() {
|
||||
if err := r.syncAccount(account); err != nil {
|
||||
r.logger.Error().Err(err).Msgf("Error syncing account %s", account.Username)
|
||||
for _, _account := range r.accountsManager.All() {
|
||||
if err := r.syncAccount(_account); err != nil {
|
||||
r.logger.Error().Err(err).Msgf("Error syncing account %s", _account.Username)
|
||||
continue // Skip this account and continue with the next
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) syncAccount(account *types.Account) error {
|
||||
func (r *RealDebrid) syncAccount(account *account.Account) error {
|
||||
if account.Token == "" {
|
||||
return fmt.Errorf("account %s has no token", account.Username)
|
||||
}
|
||||
client := http.DefaultClient
|
||||
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/user", r.Host), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating request for account %s: %w", account.Username, err)
|
||||
}
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", account.Token))
|
||||
resp, err := client.Do(req)
|
||||
resp, err := account.Client().Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error checking account %s: %w", account.Username, err)
|
||||
}
|
||||
@@ -1019,8 +994,7 @@ func (r *RealDebrid) syncAccount(account *types.Account) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating request for traffic details for account %s: %w", account.Username, err)
|
||||
}
|
||||
trafficReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", account.Token))
|
||||
trafficResp, err := client.Do(trafficReq)
|
||||
trafficResp, err := account.Client().Do(trafficReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error checking traffic for account %s: %w", account.Username, err)
|
||||
}
|
||||
@@ -1032,13 +1006,14 @@ func (r *RealDebrid) syncAccount(account *types.Account) error {
|
||||
defer trafficResp.Body.Close()
|
||||
var trafficData TrafficResponse
|
||||
if err := json.NewDecoder(trafficResp.Body).Decode(&trafficData); err != nil {
|
||||
return fmt.Errorf("error decoding traffic details for account %s: %w", account.Username, err)
|
||||
// Skip logging traffic error
|
||||
account.TrafficUsed.Store(0)
|
||||
} else {
|
||||
today := time.Now().Format(time.DateOnly)
|
||||
if todayData, exists := trafficData[today]; exists {
|
||||
account.TrafficUsed.Store(todayData.Bytes)
|
||||
}
|
||||
}
|
||||
today := time.Now().Format(time.DateOnly)
|
||||
if todayData, exists := trafficData[today]; exists {
|
||||
account.TrafficUsed = todayData.Bytes
|
||||
}
|
||||
|
||||
r.accounts.Update(account)
|
||||
//r.accountsManager.Update(account)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -20,15 +20,17 @@ import (
|
||||
"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
|
||||
accounts *types.Accounts
|
||||
accountsManager *account.Manager
|
||||
autoExpiresLinksAfter time.Duration
|
||||
|
||||
DownloadUncached bool
|
||||
@@ -40,8 +42,7 @@ type Torbox struct {
|
||||
addSamples bool
|
||||
}
|
||||
|
||||
func New(dc config.Debrid) (*Torbox, error) {
|
||||
rl := request.ParseRateLimit(dc.RateLimit)
|
||||
func New(dc config.Debrid, ratelimits map[string]ratelimit.Limiter) (*Torbox, error) {
|
||||
|
||||
headers := map[string]string{
|
||||
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
|
||||
@@ -50,7 +51,7 @@ func New(dc config.Debrid) (*Torbox, error) {
|
||||
_log := logger.New(dc.Name)
|
||||
client := request.New(
|
||||
request.WithHeaders(headers),
|
||||
request.WithRateLimiter(rl),
|
||||
request.WithRateLimiter(ratelimits["main"]),
|
||||
request.WithLogger(_log),
|
||||
request.WithProxy(dc.Proxy),
|
||||
)
|
||||
@@ -63,7 +64,7 @@ func New(dc config.Debrid) (*Torbox, error) {
|
||||
name: "torbox",
|
||||
Host: "https://api.torbox.app/v1",
|
||||
APIKey: dc.APIKey,
|
||||
accounts: types.NewAccounts(dc),
|
||||
accountsManager: account.NewManager(dc, ratelimits["download"], _log),
|
||||
DownloadUncached: dc.DownloadUncached,
|
||||
autoExpiresLinksAfter: autoExpiresLinksAfter,
|
||||
client: client,
|
||||
@@ -404,7 +405,7 @@ func (tb *Torbox) DeleteTorrent(torrentId string) error {
|
||||
|
||||
func (tb *Torbox) GetFileDownloadLinks(t *types.Torrent) error {
|
||||
filesCh := make(chan types.File, len(t.Files))
|
||||
linkCh := make(chan *types.DownloadLink)
|
||||
linkCh := make(chan types.DownloadLink)
|
||||
errCh := make(chan error, len(t.Files))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
@@ -412,12 +413,12 @@ func (tb *Torbox) GetFileDownloadLinks(t *types.Torrent) error {
|
||||
for _, file := range t.Files {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
link, _, err := tb.GetDownloadLink(t, &file)
|
||||
link, err := tb.GetDownloadLink(t, &file)
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
if link != nil {
|
||||
if link.DownloadLink != "" {
|
||||
linkCh <- link
|
||||
file.DownloadLink = link
|
||||
}
|
||||
@@ -437,13 +438,6 @@ func (tb *Torbox) GetFileDownloadLinks(t *types.Torrent) error {
|
||||
files[file.Name] = file
|
||||
}
|
||||
|
||||
// Collect download links
|
||||
for link := range linkCh {
|
||||
if link != nil {
|
||||
tb.accounts.SetDownloadLink(nil, link)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for errors
|
||||
for err := range errCh {
|
||||
if err != nil {
|
||||
@@ -455,7 +449,7 @@ func (tb *Torbox) GetFileDownloadLinks(t *types.Torrent) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetDownloadLink(t *types.Torrent, file *types.File) (*types.DownloadLink, *types.Account, error) {
|
||||
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)
|
||||
@@ -471,7 +465,7 @@ func (tb *Torbox) GetDownloadLink(t *types.Torrent, file *types.File) (*types.Do
|
||||
Str("torrent_id", t.Id).
|
||||
Str("file_id", file.Id).
|
||||
Msg("Failed to make request to Torbox API")
|
||||
return nil, nil, err
|
||||
return types.DownloadLink{}, err
|
||||
}
|
||||
|
||||
var data DownloadLinksResponse
|
||||
@@ -481,7 +475,7 @@ func (tb *Torbox) GetDownloadLink(t *types.Torrent, file *types.File) (*types.Do
|
||||
Str("torrent_id", t.Id).
|
||||
Str("file_id", file.Id).
|
||||
Msg("Failed to unmarshal Torbox API response")
|
||||
return nil, nil, err
|
||||
return types.DownloadLink{}, err
|
||||
}
|
||||
|
||||
if data.Data == nil {
|
||||
@@ -492,7 +486,7 @@ func (tb *Torbox) GetDownloadLink(t *types.Torrent, file *types.File) (*types.Do
|
||||
Interface("error", data.Error).
|
||||
Str("detail", data.Detail).
|
||||
Msg("Torbox API returned no data")
|
||||
return nil, nil, fmt.Errorf("error getting download links")
|
||||
return types.DownloadLink{}, fmt.Errorf("error getting download links")
|
||||
}
|
||||
|
||||
link := *data.Data
|
||||
@@ -501,11 +495,12 @@ func (tb *Torbox) GetDownloadLink(t *types.Torrent, file *types.File) (*types.Do
|
||||
Str("torrent_id", t.Id).
|
||||
Str("file_id", file.Id).
|
||||
Msg("Torbox API returned empty download link")
|
||||
return nil, nil, fmt.Errorf("error getting download links")
|
||||
return types.DownloadLink{}, fmt.Errorf("error getting download links")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
downloadLink := &types.DownloadLink{
|
||||
dl := types.DownloadLink{
|
||||
Token: tb.APIKey,
|
||||
Link: file.Link,
|
||||
DownloadLink: link,
|
||||
Id: file.Id,
|
||||
@@ -513,7 +508,9 @@ func (tb *Torbox) GetDownloadLink(t *types.Torrent, file *types.File) (*types.Do
|
||||
ExpiresAt: now.Add(tb.autoExpiresLinksAfter),
|
||||
}
|
||||
|
||||
return downloadLink, nil, nil
|
||||
tb.accountsManager.StoreDownloadLink(dl)
|
||||
|
||||
return dl, nil
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetDownloadingStatus() []string {
|
||||
@@ -620,10 +617,6 @@ func (tb *Torbox) GetMountPath() string {
|
||||
return tb.MountPath
|
||||
}
|
||||
|
||||
func (tb *Torbox) DeleteDownloadLink(linkId string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetAvailableSlots() (int, error) {
|
||||
//TODO: Implement the logic to check available slots for Torbox
|
||||
return 0, fmt.Errorf("not implemented")
|
||||
@@ -633,8 +626,8 @@ func (tb *Torbox) GetProfile() (*types.Profile, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (tb *Torbox) Accounts() *types.Accounts {
|
||||
return tb.accounts
|
||||
func (tb *Torbox) AccountManager() *account.Manager {
|
||||
return tb.accountsManager
|
||||
}
|
||||
|
||||
func (tb *Torbox) SyncAccounts() error {
|
||||
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/sirrobot01/decypharr/internal/request"
|
||||
"github.com/sirrobot01/decypharr/pkg/rclone"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
@@ -20,6 +18,10 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/puzpuzpuz/xsync/v4"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/common"
|
||||
"github.com/sirrobot01/decypharr/pkg/rclone"
|
||||
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
|
||||
"encoding/json"
|
||||
@@ -43,20 +45,6 @@ const (
|
||||
WebdavUseHash WebDavFolderNaming = "infohash"
|
||||
)
|
||||
|
||||
var streamingTransport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
MaxIdleConns: 200,
|
||||
MaxIdleConnsPerHost: 100,
|
||||
MaxConnsPerHost: 200,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ResponseHeaderTimeout: 60 * time.Second,
|
||||
ExpectContinueTimeout: 1 * time.Second,
|
||||
DisableKeepAlives: true,
|
||||
ForceAttemptHTTP2: false,
|
||||
TLSNextProto: make(map[string]func(string, *tls.Conn) http.RoundTripper),
|
||||
}
|
||||
|
||||
type CachedTorrent struct {
|
||||
*types.Torrent
|
||||
AddedOn time.Time `json:"added_on"`
|
||||
@@ -89,17 +77,17 @@ type RepairRequest struct {
|
||||
|
||||
type Cache struct {
|
||||
dir string
|
||||
client types.Client
|
||||
client common.Client
|
||||
logger zerolog.Logger
|
||||
|
||||
torrents *torrentCache
|
||||
invalidDownloadLinks sync.Map
|
||||
folderNaming WebDavFolderNaming
|
||||
torrents *torrentCache
|
||||
folderNaming WebDavFolderNaming
|
||||
|
||||
listingDebouncer *utils.Debouncer[bool]
|
||||
// monitors
|
||||
repairRequest sync.Map
|
||||
failedToReinsert sync.Map
|
||||
invalidDownloadLinks *xsync.Map[string, string]
|
||||
repairRequest *xsync.Map[string, *reInsertRequest]
|
||||
failedToReinsert *xsync.Map[string, struct{}]
|
||||
|
||||
// repair
|
||||
repairChan chan RepairRequest
|
||||
@@ -127,7 +115,7 @@ type Cache struct {
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
func NewDebridCache(dc config.Debrid, client types.Client, mounter *rclone.Mount) *Cache {
|
||||
func NewDebridCache(dc config.Debrid, client common.Client, mounter *rclone.Mount) *Cache {
|
||||
cfg := config.Get()
|
||||
cet, err := time.LoadLocation("CET")
|
||||
if err != nil {
|
||||
@@ -170,11 +158,13 @@ func NewDebridCache(dc config.Debrid, client types.Client, mounter *rclone.Mount
|
||||
|
||||
}
|
||||
_log := logger.New(fmt.Sprintf("%s-webdav", client.Name()))
|
||||
if dc.Proxy != "" {
|
||||
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ResponseHeaderTimeout: 30 * time.Second,
|
||||
MaxIdleConns: 10,
|
||||
MaxIdleConnsPerHost: 2,
|
||||
}
|
||||
transport := streamingTransport
|
||||
request.SetProxy(transport, dc.Proxy)
|
||||
httpClient := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 0,
|
||||
@@ -198,8 +188,12 @@ func NewDebridCache(dc config.Debrid, client types.Client, mounter *rclone.Mount
|
||||
customFolders: customFolders,
|
||||
mounter: mounter,
|
||||
|
||||
ready: make(chan struct{}),
|
||||
httpClient: httpClient,
|
||||
ready: make(chan struct{}),
|
||||
httpClient: httpClient,
|
||||
invalidDownloadLinks: xsync.NewMap[string, string](),
|
||||
repairRequest: xsync.NewMap[string, *reInsertRequest](),
|
||||
failedToReinsert: xsync.NewMap[string, struct{}](),
|
||||
repairChan: make(chan RepairRequest, 100), // Initialize the repair channel, max 100 requests buffered
|
||||
}
|
||||
|
||||
c.listingDebouncer = utils.NewDebouncer[bool](100*time.Millisecond, func(refreshRclone bool) {
|
||||
@@ -250,9 +244,9 @@ func (c *Cache) Reset() {
|
||||
c.torrents.reset()
|
||||
|
||||
// 3. Clear any sync.Maps
|
||||
c.invalidDownloadLinks = sync.Map{}
|
||||
c.repairRequest = sync.Map{}
|
||||
c.failedToReinsert = sync.Map{}
|
||||
c.invalidDownloadLinks = xsync.NewMap[string, string]()
|
||||
c.repairRequest = xsync.NewMap[string, *reInsertRequest]()
|
||||
c.failedToReinsert = xsync.NewMap[string, struct{}]()
|
||||
|
||||
// 5. Rebuild the listing debouncer
|
||||
c.listingDebouncer = utils.NewDebouncer[bool](
|
||||
@@ -285,7 +279,6 @@ func (c *Cache) Start(ctx context.Context) error {
|
||||
|
||||
// initial download links
|
||||
go c.refreshDownloadLinks(ctx)
|
||||
c.repairChan = make(chan RepairRequest, 100) // Initialize the repair channel, max 100 requests buffered
|
||||
go c.repairWorker(ctx)
|
||||
|
||||
cfg := config.Get()
|
||||
@@ -777,7 +770,7 @@ func (c *Cache) Add(t *types.Torrent) error {
|
||||
|
||||
}
|
||||
|
||||
func (c *Cache) Client() types.Client {
|
||||
func (c *Cache) Client() common.Client {
|
||||
return c.client
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package store
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
)
|
||||
@@ -30,43 +31,44 @@ func (r *downloadLinkRequest) Wait() (string, error) {
|
||||
return r.result, r.err
|
||||
}
|
||||
|
||||
func (c *Cache) GetDownloadLink(torrentName, filename, fileLink string) (string, error) {
|
||||
func (c *Cache) GetDownloadLink(torrentName, filename, fileLink string) (types.DownloadLink, error) {
|
||||
// Check link cache
|
||||
if dl, err := c.checkDownloadLink(fileLink); dl != "" && err == nil {
|
||||
if dl, err := c.checkDownloadLink(fileLink); err == nil && !dl.Empty() {
|
||||
return dl, nil
|
||||
}
|
||||
|
||||
dl, err := c.fetchDownloadLink(torrentName, filename, fileLink)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return types.DownloadLink{}, err
|
||||
}
|
||||
|
||||
if dl == nil || dl.DownloadLink == "" {
|
||||
if dl.Empty() {
|
||||
err = fmt.Errorf("download link is empty for %s in torrent %s", filename, torrentName)
|
||||
return "", err
|
||||
return types.DownloadLink{}, err
|
||||
}
|
||||
return dl.DownloadLink, err
|
||||
return dl, err
|
||||
}
|
||||
|
||||
func (c *Cache) fetchDownloadLink(torrentName, filename, fileLink string) (*types.DownloadLink, error) {
|
||||
func (c *Cache) fetchDownloadLink(torrentName, filename, fileLink string) (types.DownloadLink, error) {
|
||||
emptyDownloadLink := types.DownloadLink{}
|
||||
ct := c.GetTorrentByName(torrentName)
|
||||
if ct == nil {
|
||||
return nil, fmt.Errorf("torrent not found")
|
||||
return emptyDownloadLink, fmt.Errorf("torrent not found")
|
||||
}
|
||||
file, ok := ct.GetFile(filename)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("file %s not found in torrent %s", filename, torrentName)
|
||||
return emptyDownloadLink, fmt.Errorf("file %s not found in torrent %s", filename, torrentName)
|
||||
}
|
||||
|
||||
if file.Link == "" {
|
||||
// file link is empty, refresh the torrent to get restricted links
|
||||
ct = c.refreshTorrent(file.TorrentId) // Refresh the torrent from the debrid
|
||||
if ct == nil {
|
||||
return nil, fmt.Errorf("failed to refresh torrent")
|
||||
return emptyDownloadLink, fmt.Errorf("failed to refresh torrent")
|
||||
} else {
|
||||
file, ok = ct.GetFile(filename)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("file %s not found in refreshed torrent %s", filename, torrentName)
|
||||
return emptyDownloadLink, fmt.Errorf("file %s not found in refreshed torrent %s", filename, torrentName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,56 +78,53 @@ func (c *Cache) fetchDownloadLink(torrentName, filename, fileLink string) (*type
|
||||
// Try to reinsert the torrent?
|
||||
newCt, err := c.reInsertTorrent(ct)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to reinsert torrent. %w", err)
|
||||
return emptyDownloadLink, fmt.Errorf("failed to reinsert torrent. %w", err)
|
||||
}
|
||||
ct = newCt
|
||||
file, ok = ct.GetFile(filename)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("file %s not found in reinserted torrent %s", filename, torrentName)
|
||||
return emptyDownloadLink, fmt.Errorf("file %s not found in reinserted torrent %s", filename, torrentName)
|
||||
}
|
||||
}
|
||||
|
||||
c.logger.Trace().Msgf("Getting download link for %s(%s)", filename, file.Link)
|
||||
downloadLink, account, err := c.client.GetDownloadLink(ct.Torrent, &file)
|
||||
downloadLink, err := c.client.GetDownloadLink(ct.Torrent, &file)
|
||||
if err != nil {
|
||||
if errors.Is(err, utils.HosterUnavailableError) {
|
||||
c.logger.Trace().
|
||||
Str("account", account.Username).
|
||||
Str("token", utils.Mask(downloadLink.Token)).
|
||||
Str("filename", filename).
|
||||
Str("torrent_id", ct.Id).
|
||||
Msg("Hoster unavailable, attempting to reinsert torrent")
|
||||
|
||||
newCt, err := c.reInsertTorrent(ct)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to reinsert torrent: %w", err)
|
||||
return emptyDownloadLink, fmt.Errorf("failed to reinsert torrent: %w", err)
|
||||
}
|
||||
ct = newCt
|
||||
file, ok = ct.GetFile(filename)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("file %s not found in reinserted torrent %s", filename, torrentName)
|
||||
return emptyDownloadLink, fmt.Errorf("file %s not found in reinserted torrent %s", filename, torrentName)
|
||||
}
|
||||
// Retry getting the download link
|
||||
downloadLink, account, err = c.client.GetDownloadLink(ct.Torrent, &file)
|
||||
downloadLink, err = c.client.GetDownloadLink(ct.Torrent, &file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("retry failed to get download link: %w", err)
|
||||
return emptyDownloadLink, fmt.Errorf("retry failed to get download link: %w", err)
|
||||
}
|
||||
if downloadLink == nil {
|
||||
return nil, fmt.Errorf("download link is empty after retry")
|
||||
if downloadLink.Empty() {
|
||||
return emptyDownloadLink, fmt.Errorf("download link is empty after retry")
|
||||
}
|
||||
return nil, nil
|
||||
return emptyDownloadLink, fmt.Errorf("download link is empty after retry")
|
||||
} else if errors.Is(err, utils.TrafficExceededError) {
|
||||
// This is likely a fair usage limit error
|
||||
return nil, err
|
||||
return emptyDownloadLink, err
|
||||
} else {
|
||||
return nil, fmt.Errorf("failed to get download link: %w", err)
|
||||
return emptyDownloadLink, fmt.Errorf("failed to get download link: %w", err)
|
||||
}
|
||||
}
|
||||
if downloadLink == nil {
|
||||
return nil, fmt.Errorf("download link is empty")
|
||||
if downloadLink.Empty() {
|
||||
return emptyDownloadLink, fmt.Errorf("download link is empty")
|
||||
}
|
||||
|
||||
// Set link to cache
|
||||
go c.client.Accounts().SetDownloadLink(account, downloadLink)
|
||||
return downloadLink, nil
|
||||
}
|
||||
|
||||
@@ -136,28 +135,33 @@ func (c *Cache) GetFileDownloadLinks(t CachedTorrent) {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) checkDownloadLink(link string) (string, error) {
|
||||
|
||||
dl, _, err := c.client.Accounts().GetDownloadLink(link)
|
||||
func (c *Cache) checkDownloadLink(link string) (types.DownloadLink, error) {
|
||||
dl, err := c.client.AccountManager().GetDownloadLink(link)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return dl, err
|
||||
}
|
||||
if !c.downloadLinkIsInvalid(dl.DownloadLink) {
|
||||
return dl.DownloadLink, nil
|
||||
return dl, nil
|
||||
}
|
||||
return "", fmt.Errorf("download link not found for %s", link)
|
||||
return types.DownloadLink{}, fmt.Errorf("download link not found for %s", link)
|
||||
}
|
||||
|
||||
func (c *Cache) MarkDownloadLinkAsInvalid(link, downloadLink, reason string) {
|
||||
c.invalidDownloadLinks.Store(downloadLink, reason)
|
||||
func (c *Cache) MarkDownloadLinkAsInvalid(downloadLink types.DownloadLink, reason string) {
|
||||
c.invalidDownloadLinks.Store(downloadLink.DownloadLink, reason)
|
||||
// Remove the download api key from active
|
||||
if reason == "bandwidth_exceeded" {
|
||||
// Disable the account
|
||||
account, err := c.client.Accounts().GetAccountFromLink(link)
|
||||
accountManager := c.client.AccountManager()
|
||||
account, err := accountManager.GetAccount(downloadLink.Token)
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Str("token", utils.Mask(downloadLink.Token)).Msg("Failed to get account to disable")
|
||||
return
|
||||
}
|
||||
c.client.Accounts().Disable(account)
|
||||
if account == nil {
|
||||
c.logger.Error().Str("token", utils.Mask(downloadLink.Token)).Msg("Account not found to disable")
|
||||
return
|
||||
}
|
||||
accountManager.Disable(account)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,5 +183,10 @@ func (c *Cache) GetDownloadByteRange(torrentName, filename string) (*[2]int64, e
|
||||
}
|
||||
|
||||
func (c *Cache) GetTotalActiveDownloadLinks() int {
|
||||
return c.client.Accounts().GetLinksCount()
|
||||
total := 0
|
||||
allAccounts := c.client.AccountManager().Active()
|
||||
for _, acc := range allAccounts {
|
||||
total += acc.DownloadLinksCount()
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
@@ -249,5 +249,5 @@ func (c *Cache) refreshDownloadLinks(ctx context.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
c.logger.Debug().Msgf("Refreshed download %d links", c.client.Accounts().GetLinksCount())
|
||||
c.logger.Debug().Msgf("Refreshed download links")
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/puzpuzpuz/xsync/v4"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type reInsertRequest struct {
|
||||
@@ -219,8 +221,7 @@ func (c *Cache) reInsertTorrent(ct *CachedTorrent) (*CachedTorrent, error) {
|
||||
if _, ok := c.failedToReinsert.Load(oldID); ok {
|
||||
return ct, fmt.Errorf("can't retry re-insert for %s", torrent.Id)
|
||||
}
|
||||
if reqI, inFlight := c.repairRequest.Load(oldID); inFlight {
|
||||
req := reqI.(*reInsertRequest)
|
||||
if req, inFlight := c.repairRequest.Load(oldID); inFlight {
|
||||
c.logger.Debug().Msgf("Waiting for existing reinsert request to complete for torrent %s", oldID)
|
||||
return req.Wait()
|
||||
}
|
||||
@@ -305,9 +306,8 @@ func (c *Cache) reInsertTorrent(ct *CachedTorrent) (*CachedTorrent, error) {
|
||||
|
||||
func (c *Cache) resetInvalidLinks(ctx context.Context) {
|
||||
c.logger.Debug().Msgf("Resetting accounts")
|
||||
c.invalidDownloadLinks = sync.Map{}
|
||||
c.client.Accounts().Reset() // Reset the active download keys
|
||||
|
||||
c.invalidDownloadLinks = xsync.NewMap[string, string]()
|
||||
c.client.AccountManager().Reset() // Reset the active download keys
|
||||
// Refresh the download links
|
||||
c.refreshDownloadLinks(ctx)
|
||||
}
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
)
|
||||
|
||||
type Accounts struct {
|
||||
current atomic.Value
|
||||
accounts sync.Map // map[string]*Account // key is token
|
||||
}
|
||||
|
||||
func NewAccounts(debridConf config.Debrid) *Accounts {
|
||||
a := &Accounts{
|
||||
accounts: sync.Map{},
|
||||
}
|
||||
var current *Account
|
||||
for idx, token := range debridConf.DownloadAPIKeys {
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
account := newAccount(debridConf.Name, token, idx)
|
||||
a.accounts.Store(token, account)
|
||||
if current == nil {
|
||||
current = account
|
||||
}
|
||||
}
|
||||
a.setCurrent(current)
|
||||
return a
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
Debrid string // e.g., "realdebrid", "torbox", etc.
|
||||
Order int
|
||||
Disabled bool
|
||||
Token string `json:"token"`
|
||||
links map[string]*DownloadLink
|
||||
mu sync.RWMutex
|
||||
TrafficUsed int64 `json:"traffic_used"` // Traffic used in bytes
|
||||
Username string `json:"username"` // Username for the account
|
||||
}
|
||||
|
||||
func (a *Accounts) Active() []*Account {
|
||||
activeAccounts := make([]*Account, 0)
|
||||
a.accounts.Range(func(key, value interface{}) bool {
|
||||
acc, ok := value.(*Account)
|
||||
if ok && !acc.Disabled {
|
||||
activeAccounts = append(activeAccounts, acc)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// Sort active accounts by their Order field
|
||||
slices.SortFunc(activeAccounts, func(i, j *Account) int {
|
||||
return i.Order - j.Order
|
||||
})
|
||||
return activeAccounts
|
||||
}
|
||||
|
||||
func (a *Accounts) All() []*Account {
|
||||
allAccounts := make([]*Account, 0)
|
||||
a.accounts.Range(func(key, value interface{}) bool {
|
||||
acc, ok := value.(*Account)
|
||||
if ok {
|
||||
allAccounts = append(allAccounts, acc)
|
||||
}
|
||||
return true
|
||||
})
|
||||
// Sort all accounts by their Order field
|
||||
slices.SortFunc(allAccounts, func(i, j *Account) int {
|
||||
return i.Order - j.Order
|
||||
})
|
||||
return allAccounts
|
||||
}
|
||||
|
||||
func (a *Accounts) getCurrent() *Account {
|
||||
if acc := a.current.Load(); acc != nil {
|
||||
if current, ok := acc.(*Account); ok {
|
||||
return current
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Accounts) Current() *Account {
|
||||
current := a.getCurrent()
|
||||
if current != nil && !current.Disabled {
|
||||
return current
|
||||
}
|
||||
activeAccounts := a.Active()
|
||||
if len(activeAccounts) == 0 {
|
||||
return current
|
||||
}
|
||||
current = activeAccounts[0]
|
||||
a.setCurrent(current)
|
||||
return current
|
||||
}
|
||||
|
||||
func (a *Accounts) setCurrent(account *Account) {
|
||||
if account == nil {
|
||||
return
|
||||
}
|
||||
a.current.Store(account)
|
||||
}
|
||||
|
||||
func (a *Accounts) Disable(account *Account) {
|
||||
account.Disabled = true
|
||||
a.accounts.Store(account.Token, account)
|
||||
|
||||
current := a.getCurrent()
|
||||
|
||||
if current.Equals(account) {
|
||||
var newCurrent *Account
|
||||
|
||||
a.accounts.Range(func(key, value interface{}) bool {
|
||||
acc, ok := value.(*Account)
|
||||
if ok && !acc.Disabled {
|
||||
newCurrent = acc
|
||||
return false // Break the loop
|
||||
}
|
||||
return true // Continue the loop
|
||||
})
|
||||
a.setCurrent(newCurrent)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Accounts) Reset() {
|
||||
var current *Account
|
||||
a.accounts.Range(func(key, value interface{}) bool {
|
||||
acc, ok := value.(*Account)
|
||||
if ok {
|
||||
acc.resetDownloadLinks()
|
||||
acc.Disabled = false
|
||||
a.accounts.Store(key, acc)
|
||||
if current == nil {
|
||||
current = acc
|
||||
}
|
||||
|
||||
}
|
||||
return true
|
||||
})
|
||||
a.setCurrent(current)
|
||||
}
|
||||
|
||||
func (a *Accounts) GetDownloadLink(fileLink string) (*DownloadLink, *Account, error) {
|
||||
current := a.Current()
|
||||
if current == nil {
|
||||
return nil, nil, NoActiveAccountsError
|
||||
}
|
||||
dl, ok := current.getLink(fileLink)
|
||||
if !ok {
|
||||
return nil, current, NoDownloadLinkError
|
||||
}
|
||||
if err := dl.Valid(); err != nil {
|
||||
return nil, current, err
|
||||
}
|
||||
return dl, current, nil
|
||||
}
|
||||
|
||||
func (a *Accounts) GetAccountFromLink(fileLink string) (*Account, error) {
|
||||
currentAccount := a.Current()
|
||||
if currentAccount == nil {
|
||||
return nil, NoActiveAccountsError
|
||||
}
|
||||
dl, ok := currentAccount.getLink(fileLink)
|
||||
if !ok {
|
||||
return nil, NoDownloadLinkError
|
||||
}
|
||||
if dl.DownloadLink == "" {
|
||||
return currentAccount, EmptyDownloadLinkError
|
||||
}
|
||||
return currentAccount, nil
|
||||
}
|
||||
|
||||
// SetDownloadLink sets the download link for the current account
|
||||
func (a *Accounts) SetDownloadLink(account *Account, dl *DownloadLink) {
|
||||
if dl == nil {
|
||||
return
|
||||
}
|
||||
if account == nil {
|
||||
account = a.getCurrent()
|
||||
}
|
||||
account.setLink(dl.Link, dl)
|
||||
}
|
||||
|
||||
func (a *Accounts) DeleteDownloadLink(fileLink string) {
|
||||
if a.Current() == nil {
|
||||
return
|
||||
}
|
||||
a.Current().deleteLink(fileLink)
|
||||
}
|
||||
|
||||
func (a *Accounts) GetLinksCount() int {
|
||||
if a.Current() == nil {
|
||||
return 0
|
||||
}
|
||||
return a.Current().LinksCount()
|
||||
}
|
||||
|
||||
func (a *Accounts) SetDownloadLinks(account *Account, links map[string]*DownloadLink) {
|
||||
if account == nil {
|
||||
account = a.Current()
|
||||
}
|
||||
account.setLinks(links)
|
||||
a.accounts.Store(account.Token, account)
|
||||
}
|
||||
|
||||
func (a *Accounts) Update(account *Account) {
|
||||
if account == nil {
|
||||
return
|
||||
}
|
||||
a.accounts.Store(account.Token, account)
|
||||
}
|
||||
|
||||
func newAccount(debridName, token string, index int) *Account {
|
||||
return &Account{
|
||||
Debrid: debridName,
|
||||
Token: token,
|
||||
Order: index,
|
||||
links: make(map[string]*DownloadLink),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Account) Equals(other *Account) bool {
|
||||
if other == nil {
|
||||
return false
|
||||
}
|
||||
return a.Token == other.Token && a.Debrid == other.Debrid
|
||||
}
|
||||
|
||||
func (a *Account) getLink(fileLink string) (*DownloadLink, bool) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
dl, ok := a.links[a.sliceFileLink(fileLink)]
|
||||
return dl, ok
|
||||
}
|
||||
func (a *Account) setLink(fileLink string, dl *DownloadLink) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.links[a.sliceFileLink(fileLink)] = dl
|
||||
}
|
||||
func (a *Account) deleteLink(fileLink string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
delete(a.links, a.sliceFileLink(fileLink))
|
||||
}
|
||||
func (a *Account) resetDownloadLinks() {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.links = make(map[string]*DownloadLink)
|
||||
}
|
||||
func (a *Account) LinksCount() int {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return len(a.links)
|
||||
}
|
||||
|
||||
func (a *Account) setLinks(links map[string]*DownloadLink) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
for _, dl := range links {
|
||||
if err := dl.Valid(); err != nil {
|
||||
continue
|
||||
}
|
||||
a.links[a.sliceFileLink(dl.Link)] = dl
|
||||
}
|
||||
}
|
||||
|
||||
// slice download link
|
||||
func (a *Account) sliceFileLink(fileLink string) string {
|
||||
if a.Debrid != "realdebrid" {
|
||||
return fileLink
|
||||
}
|
||||
if len(fileLink) < 39 {
|
||||
return fileLink
|
||||
}
|
||||
return fileLink[0:39]
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type Client interface {
|
||||
SubmitMagnet(tr *Torrent) (*Torrent, error)
|
||||
CheckStatus(tr *Torrent) (*Torrent, error)
|
||||
GetFileDownloadLinks(tr *Torrent) error
|
||||
GetDownloadLink(tr *Torrent, file *File) (*DownloadLink, *Account, error)
|
||||
DeleteTorrent(torrentId string) error
|
||||
IsAvailable(infohashes []string) map[string]bool
|
||||
GetDownloadUncached() bool
|
||||
UpdateTorrent(torrent *Torrent) error
|
||||
GetTorrent(torrentId string) (*Torrent, error)
|
||||
GetTorrents() ([]*Torrent, error)
|
||||
Name() string
|
||||
Logger() zerolog.Logger
|
||||
GetDownloadingStatus() []string
|
||||
RefreshDownloadLinks() error
|
||||
CheckLink(link string) error
|
||||
GetMountPath() string
|
||||
Accounts() *Accounts // Returns the active download account/token
|
||||
DeleteDownloadLink(linkId string) error
|
||||
GetProfile() (*Profile, error)
|
||||
GetAvailableSlots() (int, error)
|
||||
SyncAccounts() error // Updates each accounts details(like traffic, username, etc.)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ var NoActiveAccountsError = &Error{
|
||||
Code: "no_active_accounts",
|
||||
}
|
||||
|
||||
var NoDownloadLinkError = &Error{
|
||||
var ErrDownloadLinkNotFound = &Error{
|
||||
Message: "No download link found",
|
||||
Code: "no_download_link",
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package types
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
@@ -116,18 +117,18 @@ func (t *Torrent) GetFiles() []File {
|
||||
}
|
||||
|
||||
type File struct {
|
||||
TorrentId string `json:"torrent_id"`
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
IsRar bool `json:"is_rar"`
|
||||
ByteRange *[2]int64 `json:"byte_range,omitempty"`
|
||||
Path string `json:"path"`
|
||||
Link string `json:"link"`
|
||||
AccountId string `json:"account_id"`
|
||||
Generated time.Time `json:"generated"`
|
||||
Deleted bool `json:"deleted"`
|
||||
DownloadLink *DownloadLink `json:"-"`
|
||||
TorrentId string `json:"torrent_id"`
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
IsRar bool `json:"is_rar"`
|
||||
ByteRange *[2]int64 `json:"byte_range,omitempty"`
|
||||
Path string `json:"path"`
|
||||
Link string `json:"link"`
|
||||
AccountId string `json:"account_id"`
|
||||
Generated time.Time `json:"generated"`
|
||||
Deleted bool `json:"deleted"`
|
||||
DownloadLink DownloadLink `json:"-"`
|
||||
}
|
||||
|
||||
func (t *Torrent) Cleanup(remove bool) {
|
||||
@@ -170,6 +171,8 @@ type Profile struct {
|
||||
}
|
||||
|
||||
type DownloadLink struct {
|
||||
Debrid string `json:"debrid"`
|
||||
Token string `json:"token"`
|
||||
Filename string `json:"filename"`
|
||||
Link string `json:"link"`
|
||||
DownloadLink string `json:"download_link"`
|
||||
@@ -179,16 +182,27 @@ type DownloadLink struct {
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func isValidURL(str string) bool {
|
||||
u, err := url.Parse(str)
|
||||
// A valid URL should parse without error, and have a non-empty scheme and host.
|
||||
return err == nil && u.Scheme != "" && u.Host != ""
|
||||
}
|
||||
|
||||
func (dl *DownloadLink) Valid() error {
|
||||
if dl.DownloadLink == "" {
|
||||
if dl.Empty() {
|
||||
return EmptyDownloadLinkError
|
||||
}
|
||||
if dl.ExpiresAt.IsZero() || dl.ExpiresAt.Before(time.Now()) {
|
||||
return DownloadLinkExpiredError
|
||||
// Check if the link is actually a valid URL
|
||||
if !isValidURL(dl.DownloadLink) {
|
||||
return ErrDownloadLinkNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dl *DownloadLink) Empty() bool {
|
||||
return dl.DownloadLink == ""
|
||||
}
|
||||
|
||||
func (dl *DownloadLink) String() string {
|
||||
return dl.DownloadLink
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user