Rewrote account switching, fix some minor bugs here and there

This commit is contained in:
Mukhtar Akere
2025-09-16 21:15:24 +01:00
parent 76f5b85313
commit 30b2db06e7
34 changed files with 945 additions and 866 deletions
+1
View File
@@ -34,6 +34,7 @@ require (
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/puzpuzpuz/xsync/v4 v4.1.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
golang.org/x/sys v0.33.0 // indirect
)
+2
View File
@@ -186,6 +186,8 @@ github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/puzpuzpuz/xsync/v4 v4.1.0 h1:x9eHRl4QhZFIPJ17yl4KKW9xLyVWbb3/Yq4SXpjF71U=
github.com/puzpuzpuz/xsync/v4 v4.1.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
+5 -4
View File
@@ -308,6 +308,10 @@ func (c *Config) IsSizeAllowed(size int64) bool {
return true
}
func (c *Config) SecretKey() string {
return cmp.Or(os.Getenv("DECYPHARR_SECRET_KEY"), "\"wqj(v%lj*!-+kf@4&i95rhh_!5_px5qnuwqbr%cjrvrozz_r*(\"")
}
func (c *Config) GetAuth() *Auth {
if !c.UseAuth {
return nil
@@ -338,10 +342,7 @@ func (c *Config) NeedsSetup() error {
}
func (c *Config) NeedsAuth() bool {
if c.UseAuth {
return c.GetAuth().Username == ""
}
return false
return !c.UseAuth && c.GetAuth().Username == ""
}
func (c *Config) updateDebrid(d Debrid) Debrid {
+71
View 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
View 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
View 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.)
}
+31 -16
View File
@@ -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
}
+30 -35
View File
@@ -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 {
+21 -28
View File
@@ -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 {
+75 -100
View File
@@ -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
}
+21 -28
View File
@@ -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 {
+27 -34
View File
@@ -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
}
+49 -40
View File
@@ -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
}
+1 -1
View File
@@ -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")
}
+7 -7
View File
@@ -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)
}
-282
View File
@@ -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]
}
-29
View File
@@ -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.)
}
+1 -1
View File
@@ -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",
}
+29 -15
View File
@@ -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
}
+118 -43
View File
@@ -2,16 +2,18 @@ package qbit
import (
"context"
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/go-chi/chi/v5"
"github.com/sirrobot01/decypharr/internal/config"
"github.com/sirrobot01/decypharr/pkg/arr"
"github.com/sirrobot01/decypharr/pkg/wire"
"golang.org/x/crypto/bcrypt"
"net/http"
"net/url"
"strings"
)
type contextKey string
@@ -97,14 +99,22 @@ func decodeAuthHeader(header string) (string, string, error) {
bearer := string(bytes)
colonIndex := strings.LastIndex(bearer, ":")
host := bearer[:colonIndex]
token := bearer[colonIndex+1:]
username := bearer[:colonIndex]
password := bearer[colonIndex+1:]
return host, token, nil
if username == "" || password == "" {
return username, password, fmt.Errorf("empty username or password")
}
return strings.TrimSpace(username), strings.TrimSpace(password), nil
}
func (q *QBit) categoryContext(next http.Handler) http.Handler {
// Print full URL for debugging
// Try to get category from URL query first
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Print request method and URL
category := strings.Trim(r.URL.Query().Get("category"), "")
if category == "" {
// Get from form
@@ -127,49 +137,114 @@ func (q *QBit) categoryContext(next http.Handler) http.Handler {
// Only a valid host and token will be added to the context/config. The rest are manual
func (q *QBit) authContext(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cfg := config.Get()
host, token, err := decodeAuthHeader(r.Header.Get("Authorization"))
category := getCategory(r.Context())
arrs := wire.Get().Arr()
// Check if arr exists
a := arrs.Get(category)
if a == nil {
// Arr is not configured, create a new one
downloadUncached := false
a = arr.New(category, "", "", false, false, &downloadUncached, "", "auto")
}
if err == nil {
host = strings.TrimSpace(host)
if host != "" {
a.Host = host
}
token = strings.TrimSpace(token)
if token != "" {
a.Token = token
}
}
if cfg.NeedsAuth() {
if a.Host == "" || a.Token == "" {
http.Error(w, "Unauthorized: Host and token are required for authentication", http.StatusUnauthorized)
return
}
// try to use either Arr validate, or user auth validation
if err := a.Validate(); err != nil {
// If this failed, try to use user auth validation
if !verifyAuth(host, token) {
http.Error(w, "Unauthorized: Invalid host or token", http.StatusUnauthorized)
return
}
}
}
a.Source = "auto"
arrs.AddOrUpdate(a)
username, password, err := getUsernameAndPassword(r)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
category := getCategory(r.Context())
a, err := q.authenticate(category, username, password)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), arrKey, a)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func getUsernameAndPassword(r *http.Request) (string, string, error) {
// Try to get from authorization header
username, password, err := decodeAuthHeader(r.Header.Get("Authorization"))
if err == nil && username != "" {
return username, password, err
}
// Try to get from cookie
sid, err := r.Cookie("sid")
if err != nil {
// try SID
sid, err = r.Cookie("SID")
}
if err == nil {
username, password, err = extractFromSID(sid.Value)
if err != nil {
return "", "", err
}
}
return username, password, nil
}
func (q *QBit) authenticate(category, username, password string) (*arr.Arr, error) {
cfg := config.Get()
arrs := wire.Get().Arr()
// Check if arr exists
a := arrs.Get(category)
if a == nil {
// Arr is not configured, create a new one
downloadUncached := false
a = arr.New(category, "", "", false, false, &downloadUncached, "", "auto")
}
a.Host = username
a.Token = password
if cfg.UseAuth {
if a.Host == "" || a.Token == "" {
return nil, fmt.Errorf("unauthorized: Host and token are required for authentication(you've enabled authentication)")
}
// try to use either Arr validate, or user auth validation
if err := a.Validate(); err != nil {
// If this failed, try to use user auth validation
if !verifyAuth(username, password) {
return nil, fmt.Errorf("unauthorized: invalid credentials")
}
}
}
a.Source = "auto"
arrs.AddOrUpdate(a)
return a, nil
}
func createSID(username, password string) string {
// Create a verification hash
cfg := config.Get()
combined := fmt.Sprintf("%s|%s", username, password)
hash := sha256.Sum256([]byte(combined + cfg.SecretKey()))
hashStr := fmt.Sprintf("%x", hash)[:16] // First 16 chars
// Base64 encode
return base64.URLEncoding.EncodeToString([]byte(fmt.Sprintf("%s|%s", combined, hashStr)))
}
func extractFromSID(sid string) (string, string, error) {
// Decode base64
decoded, err := base64.URLEncoding.DecodeString(sid)
if err != nil {
return "", "", fmt.Errorf("invalid SID format")
}
// Split into parts: username:password:hash
parts := strings.Split(string(decoded), "|")
if len(parts) != 3 {
return "", "", fmt.Errorf("invalid SID structure")
}
username := parts[0]
password := parts[1]
providedHash := parts[2]
// Verify hash
cfg := config.Get()
combined := fmt.Sprintf("%s|%s", username, password)
expectedHash := sha256.Sum256([]byte(combined + cfg.SecretKey()))
expectedHashStr := fmt.Sprintf("%x", expectedHash)[:16]
if providedHash != expectedHashStr {
return "", "", fmt.Errorf("invalid SID signature")
}
return username, password, nil
}
func hashesContext(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_hashes := chi.URLParam(r, "hashes")
+18 -10
View File
@@ -1,25 +1,33 @@
package qbit
import (
"github.com/sirrobot01/decypharr/internal/request"
"github.com/sirrobot01/decypharr/pkg/arr"
"net/http"
"path/filepath"
"strings"
"github.com/sirrobot01/decypharr/internal/config"
"github.com/sirrobot01/decypharr/internal/request"
"github.com/sirrobot01/decypharr/pkg/arr"
)
func (q *QBit) handleLogin(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
_arr := getArrFromContext(ctx)
if _arr == nil {
// Arr not in context, return OK
_, _ = w.Write([]byte("Ok."))
cfg := config.Get()
username := r.FormValue("username")
password := r.FormValue("password")
a, err := q.authenticate(getCategory(ctx), username, password)
if err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
if err := _arr.Validate(); err != nil {
q.logger.Error().Err(err).Msgf("Error validating arr")
http.Error(w, "Invalid arr configuration", http.StatusBadRequest)
return
if cfg.UseAuth {
cookie := &http.Cookie{
Name: "sid",
Value: createSID(a.Host, a.Token),
Path: "/",
SameSite: http.SameSiteNoneMode,
}
http.SetCookie(w, cookie)
}
_, _ = w.Write([]byte("Ok."))
}
+19 -2
View File
@@ -1,33 +1,50 @@
package qbit
import (
"github.com/go-chi/chi/v5"
"net/http"
"github.com/go-chi/chi/v5"
)
func (q *QBit) Routes() http.Handler {
r := chi.NewRouter()
r.Use(q.categoryContext)
r.Group(func(r chi.Router) {
r.Use(q.authContext)
r.Post("/auth/login", q.handleLogin)
r.Route("/torrents", func(r chi.Router) {
r.Use(q.authContext)
r.Use(hashesContext)
r.Get("/info", q.handleTorrentsInfo)
r.Post("/info", q.handleTorrentsInfo)
r.Post("/add", q.handleTorrentsAdd)
r.Post("/delete", q.handleTorrentsDelete)
r.Get("/categories", q.handleCategories)
r.Post("/categories", q.handleCategories)
r.Post("/createCategory", q.handleCreateCategory)
r.Post("/setCategory", q.handleSetCategory)
r.Post("/addTags", q.handleAddTorrentTags)
r.Post("/removeTags", q.handleRemoveTorrentTags)
r.Post("/createTags", q.handleCreateTags)
r.Get("/tags", q.handleGetTags)
r.Get("/pause", q.handleTorrentsPause)
r.Get("/resume", q.handleTorrentsResume)
r.Get("/recheck", q.handleTorrentRecheck)
r.Get("/properties", q.handleTorrentProperties)
r.Get("/files", q.handleTorrentFiles)
// Create POST equivalents for pause, resume, recheck
r.Post("/tags", q.handleGetTags)
r.Post("/pause", q.handleTorrentsPause)
r.Post("/resume", q.handleTorrentsResume)
r.Post("/recheck", q.handleTorrentRecheck)
r.Post("/properties", q.handleTorrentProperties)
r.Post("/files", q.handleTorrentFiles)
})
r.Route("/app", func(r chi.Router) {
+6 -5
View File
@@ -2,11 +2,12 @@ package repair
import (
"fmt"
"github.com/sirrobot01/decypharr/pkg/arr"
"github.com/sirrobot01/decypharr/pkg/debrid/store"
"github.com/sirrobot01/decypharr/pkg/debrid/types"
"os"
"path/filepath"
"github.com/sirrobot01/decypharr/pkg/arr"
"github.com/sirrobot01/decypharr/pkg/debrid/common"
"github.com/sirrobot01/decypharr/pkg/debrid/store"
)
func fileIsSymlinked(file string) bool {
@@ -85,7 +86,7 @@ func collectFiles(media arr.Content) map[string][]arr.ContentFile {
return uniqueParents
}
func (r *Repair) checkTorrentFiles(torrentPath string, files []arr.ContentFile, clients map[string]types.Client, caches map[string]*store.Cache) []arr.ContentFile {
func (r *Repair) checkTorrentFiles(torrentPath string, files []arr.ContentFile, clients map[string]common.Client, caches map[string]*store.Cache) []arr.ContentFile {
brokenFiles := make([]arr.ContentFile, 0)
emptyFiles := make([]arr.ContentFile, 0)
@@ -149,7 +150,7 @@ func (r *Repair) checkTorrentFiles(torrentPath string, files []arr.ContentFile,
return brokenFiles
}
func (r *Repair) findDebridForPath(dir string, clients map[string]types.Client) string {
func (r *Repair) findDebridForPath(dir string, clients map[string]common.Client) string {
// Check cache first
if debridName, exists := r.debridPathCache.Load(dir); exists {
return debridName.(string)
+4 -29
View File
@@ -2,12 +2,13 @@ package server
import (
"fmt"
"net/http"
"runtime"
"github.com/go-chi/chi/v5"
"github.com/sirrobot01/decypharr/internal/request"
debridTypes "github.com/sirrobot01/decypharr/pkg/debrid/types"
"github.com/sirrobot01/decypharr/pkg/wire"
"net/http"
"runtime"
)
func (s *Server) handleIngests(w http.ResponseWriter, r *http.Request) {
@@ -118,33 +119,7 @@ func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
}
debridStat.Library = libraryStat
// Get detailed account information
accounts := client.Accounts().All()
accountDetails := make([]map[string]any, 0)
for _, account := range accounts {
// Mask token - show first 8 characters and last 4 characters
maskedToken := ""
if len(account.Token) > 12 {
maskedToken = account.Token[:8] + "****" + account.Token[len(account.Token)-4:]
} else if len(account.Token) > 8 {
maskedToken = account.Token[:4] + "****" + account.Token[len(account.Token)-2:]
} else {
maskedToken = "****"
}
accountDetail := map[string]any{
"order": account.Order,
"disabled": account.Disabled,
"token_masked": maskedToken,
"username": account.Username,
"traffic_used": account.TrafficUsed,
"links_count": account.LinksCount(),
"debrid": account.Debrid,
}
accountDetails = append(accountDetails, accountDetail)
}
debridStat.Accounts = accountDetails
debridStat.Accounts = client.AccountManager().Stats()
debridStats = append(debridStats, debridStat)
}
stats["debrids"] = debridStats
+5 -4
View File
@@ -3,9 +3,10 @@ package web
import (
"encoding/json"
"fmt"
"github.com/sirrobot01/decypharr/internal/config"
"net/http"
"strings"
"github.com/sirrobot01/decypharr/internal/config"
)
func (wb *Web) setupMiddleware(next http.Handler) http.Handler {
@@ -79,7 +80,7 @@ func (wb *Web) sendJSONError(w http.ResponseWriter, message string, statusCode i
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
json.NewEncoder(w).Encode(map[string]interface{}{
"error": message,
"status": statusCode,
"error": message,
"status": statusCode,
})
}
}
+4
View File
@@ -371,6 +371,9 @@
const statusBadge = account.disabled ?
'<span class="badge badge-error badge-sm">Disabled</span>' :
'<span class="badge badge-success badge-sm">Active</span>';
const inUseBadge = account.in_use ?
'<span class="badge badge-info badge-sm">In Use</span>' :
'';
html += `
<div class="card bg-base-100 compact">
@@ -380,6 +383,7 @@
<div class="flex items-center gap-2">
<h5 class="font-medium text-sm">Account #${account.order + 1}</h5>
${statusBadge}
${inUseBadge}
</div>
<p class="text-xs text-base-content/70 mt-1">${account.username || 'No username'}</p>
</div>
+2 -1
View File
@@ -2,9 +2,10 @@ package web
import (
"encoding/json"
"net/http"
"github.com/sirrobot01/decypharr/internal/config"
"golang.org/x/crypto/bcrypt"
"net/http"
)
func (wb *Web) LoginHandler(w http.ResponseWriter, r *http.Request) {
+5 -5
View File
@@ -1,14 +1,14 @@
package web
import (
"cmp"
"embed"
"html/template"
"github.com/gorilla/sessions"
"github.com/rs/zerolog"
"github.com/sirrobot01/decypharr/internal/config"
"github.com/sirrobot01/decypharr/internal/logger"
"github.com/sirrobot01/decypharr/pkg/wire"
"html/template"
"os"
)
var restartFunc func()
@@ -64,6 +64,7 @@ type Web struct {
}
func New() *Web {
cfg := config.Get()
templates := template.Must(template.ParseFS(
content,
"templates/layout.html",
@@ -75,8 +76,7 @@ func New() *Web {
"templates/login.html",
"templates/register.html",
))
secretKey := cmp.Or(os.Getenv("DECYPHARR_SECRET_KEY"), "\"wqj(v%lj*!-+kf@4&i95rhh_!5_px5qnuwqbr%cjrvrozz_r*(\"")
cookieStore := sessions.NewCookieStore([]byte(secretKey))
cookieStore := sessions.NewCookieStore([]byte(cfg.SecretKey()))
cookieStore.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 7,
+139 -132
View File
@@ -8,7 +8,22 @@ import (
"strings"
"time"
"github.com/sirrobot01/decypharr/internal/utils"
"github.com/sirrobot01/decypharr/pkg/debrid/store"
"github.com/sirrobot01/decypharr/pkg/debrid/types"
)
type retryAction int
const (
noRetry retryAction = iota
retryWithLimit
retryAlways
)
const (
MaxNetworkRetries = 3
MaxLinkRetries = 10
)
type streamError struct {
@@ -29,7 +44,7 @@ type File struct {
name string
torrentName string
link string
downloadLink string
downloadLink types.DownloadLink
size int64
isDir bool
fileId string
@@ -55,26 +70,27 @@ func (f *File) Close() error {
// This is just to satisfy the os.File interface
f.content = nil
f.children = nil
f.downloadLink = ""
f.downloadLink = types.DownloadLink{}
f.readOffset = 0
return nil
}
func (f *File) getDownloadLink() (string, error) {
func (f *File) getDownloadLink() (types.DownloadLink, error) {
// Check if we already have a final URL cached
if f.downloadLink != "" && isValidURL(f.downloadLink) {
if f.downloadLink.Valid() == nil {
return f.downloadLink, nil
}
downloadLink, err := f.cache.GetDownloadLink(f.torrentName, f.name, f.link)
if err != nil {
return "", err
return downloadLink, err
}
if downloadLink != "" && isValidURL(downloadLink) {
f.downloadLink = downloadLink
return downloadLink, nil
err = downloadLink.Valid()
if err != nil {
return types.DownloadLink{}, err
}
return "", os.ErrNotExist
f.downloadLink = downloadLink
return downloadLink, nil
}
func (f *File) getDownloadByteRange() (*[2]int64, error) {
@@ -119,38 +135,29 @@ func (f *File) servePreloadedContent(w http.ResponseWriter, r *http.Request) err
}
func (f *File) StreamResponse(w http.ResponseWriter, r *http.Request) error {
// Handle preloaded content files
if f.content != nil {
return f.servePreloadedContent(w, r)
}
// Try streaming with retry logic
return f.streamWithRetry(w, r, 0)
return f.streamWithRetry(w, r, 0, 0)
}
func (f *File) streamWithRetry(w http.ResponseWriter, r *http.Request, retryCount int) error {
const maxRetries = 3
func (f *File) streamWithRetry(w http.ResponseWriter, r *http.Request, networkRetries, recoverableRetries int) error {
_log := f.cache.Logger()
// Get download link (with caching optimization)
downloadLink, err := f.getDownloadLink()
if err != nil {
return &streamError{Err: err, StatusCode: http.StatusPreconditionFailed}
}
if downloadLink == "" {
return &streamError{Err: fmt.Errorf("empty download link"), StatusCode: http.StatusNotFound}
}
// Create upstream request with streaming optimizations
upstreamReq, err := http.NewRequest("GET", downloadLink, nil)
upstreamReq, err := http.NewRequest("GET", downloadLink.DownloadLink, nil)
if err != nil {
return &streamError{Err: err, StatusCode: http.StatusInternalServerError}
}
setVideoStreamingHeaders(upstreamReq)
// Handle range requests (critical for video seeking)
isRangeRequest := f.handleRangeRequest(upstreamReq, r, w)
if isRangeRequest == -1 {
return &streamError{Err: fmt.Errorf("invalid range"), StatusCode: http.StatusRequestedRangeNotSatisfiable}
@@ -158,31 +165,63 @@ func (f *File) streamWithRetry(w http.ResponseWriter, r *http.Request, retryCoun
resp, err := f.cache.Download(upstreamReq)
if err != nil {
// Network error - retry with limit
if networkRetries < MaxNetworkRetries {
_log.Debug().
Int("network_retries", networkRetries+1).
Err(err).
Msg("Network error, retrying")
return f.streamWithRetry(w, r, networkRetries+1, recoverableRetries)
}
return &streamError{Err: err, StatusCode: http.StatusServiceUnavailable}
}
defer resp.Body.Close()
// Handle upstream errors with retry logic
shouldRetry, retryErr := f.handleUpstream(resp, retryCount, maxRetries)
if shouldRetry && retryCount < maxRetries {
// Retry with new download link
_log.Debug().
Int("retry_count", retryCount+1).
Str("file", f.name).
Msg("Retrying stream request")
return f.streamWithRetry(w, r, retryCount+1)
}
if retryErr != nil {
return retryErr
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
retryType, retryErr := f.handleUpstreamError(downloadLink, resp)
switch retryType {
case retryAlways:
if recoverableRetries >= MaxLinkRetries {
return &streamError{
Err: fmt.Errorf("max link retries exceeded (%d)", MaxLinkRetries),
StatusCode: http.StatusServiceUnavailable,
}
}
_log.Debug().
Int("recoverable_retries", recoverableRetries+1).
Str("file", f.name).
Msg("Recoverable error, retrying")
return f.streamWithRetry(w, r, 0, recoverableRetries+1) // Reset network retries
case retryWithLimit:
if networkRetries < MaxNetworkRetries {
_log.Debug().
Int("network_retries", networkRetries+1).
Str("file", f.name).
Msg("Network error, retrying")
return f.streamWithRetry(w, r, networkRetries+1, recoverableRetries)
}
fallthrough
case noRetry:
if retryErr != nil {
return retryErr
}
return &streamError{
Err: fmt.Errorf("non-retryable error: status %d", resp.StatusCode),
StatusCode: http.StatusBadGateway,
}
}
}
// Determine status code based on range request
// Success - stream the response
statusCode := http.StatusOK
if isRangeRequest == 1 {
statusCode = http.StatusPartialContent
}
// Set headers before streaming
if contentLength := resp.Header.Get("Content-Length"); contentLength != "" {
w.Header().Set("Content-Length", contentLength)
}
@@ -191,10 +230,71 @@ func (f *File) streamWithRetry(w http.ResponseWriter, r *http.Request, retryCoun
w.Header().Set("Content-Range", contentRange)
}
if err := f.streamBuffer(w, resp.Body, statusCode); err != nil {
return err
return f.streamBuffer(w, resp.Body, statusCode)
}
func (f *File) handleUpstreamError(downloadLink types.DownloadLink, resp *http.Response) (retryAction, error) {
_log := f.cache.Logger()
cleanupResp := func(resp *http.Response) {
if resp.Body != nil {
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}
switch resp.StatusCode {
case http.StatusServiceUnavailable:
body, readErr := io.ReadAll(resp.Body)
cleanupResp(resp)
if readErr != nil {
_log.Error().Err(readErr).Msg("Failed to read response body")
return retryWithLimit, nil
}
bodyStr := string(body)
if strings.Contains(bodyStr, "you have exceeded your traffic") {
_log.Debug().
Str("token", utils.Mask(downloadLink.Token)).
Str("file", f.name).
Msg("Bandwidth exceeded for account, invalidating link")
f.cache.MarkDownloadLinkAsInvalid(f.downloadLink, "bandwidth_exceeded")
f.downloadLink = types.DownloadLink{}
return retryAlways, nil
}
return noRetry, &streamError{
Err: fmt.Errorf("service unavailable: %s", bodyStr),
StatusCode: http.StatusServiceUnavailable,
}
case http.StatusNotFound:
cleanupResp(resp)
_log.Debug().
Str("file", f.name).
Msg("Link not found, invalidating and regenerating")
f.cache.MarkDownloadLinkAsInvalid(f.downloadLink, "link_not_found")
f.downloadLink = types.DownloadLink{}
return retryAlways, nil
default:
body, _ := io.ReadAll(resp.Body)
cleanupResp(resp)
_log.Error().
Int("status_code", resp.StatusCode).
Str("file", f.name).
Str("response_body", string(body)).
Msg("Unexpected upstream error")
return retryWithLimit, &streamError{
Err: fmt.Errorf("upstream error %d: %s", resp.StatusCode, string(body)),
StatusCode: http.StatusBadGateway,
}
}
return nil
}
func (f *File) streamBuffer(w http.ResponseWriter, src io.Reader, statusCode int) error {
@@ -245,99 +345,6 @@ func (f *File) streamBuffer(w http.ResponseWriter, src io.Reader, statusCode int
}
}
func (f *File) handleUpstream(resp *http.Response, retryCount, maxRetries int) (shouldRetry bool, err error) {
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusPartialContent {
return false, nil
}
_log := f.cache.Logger()
// Clean up response body properly
cleanupResp := func(resp *http.Response) {
if resp.Body != nil {
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}
}
switch resp.StatusCode {
case http.StatusServiceUnavailable:
// Read the body to check for specific error messages
body, readErr := io.ReadAll(resp.Body)
cleanupResp(resp)
if readErr != nil {
_log.Error().Err(readErr).Msg("Failed to read response body")
return false, &streamError{
Err: fmt.Errorf("failed to read error response: %w", readErr),
StatusCode: http.StatusServiceUnavailable,
}
}
bodyStr := string(body)
if strings.Contains(bodyStr, "you have exceeded your traffic") {
_log.Debug().
Str("file", f.name).
Int("retry_count", retryCount).
Msg("Bandwidth exceeded. Marking link as invalid")
f.cache.MarkDownloadLinkAsInvalid(f.link, f.downloadLink, "bandwidth_exceeded")
// Retry with a different API key if available and we haven't exceeded retries
if retryCount < maxRetries {
return true, nil
}
return false, &streamError{
Err: fmt.Errorf("bandwidth exceeded after %d retries", retryCount),
StatusCode: http.StatusServiceUnavailable,
}
}
return false, &streamError{
Err: fmt.Errorf("service unavailable: %s", bodyStr),
StatusCode: http.StatusServiceUnavailable,
}
case http.StatusNotFound:
cleanupResp(resp)
_log.Debug().
Str("file", f.name).
Int("retry_count", retryCount).
Msg("Link not found (404). Marking link as invalid and regenerating")
f.cache.MarkDownloadLinkAsInvalid(f.link, f.downloadLink, "link_not_found")
// Try to regenerate download link if we haven't exceeded retries
if retryCount < maxRetries {
// Clear cached link to force regeneration
f.downloadLink = ""
return true, nil
}
return false, &streamError{
Err: fmt.Errorf("file not found after %d retries", retryCount),
StatusCode: http.StatusNotFound,
}
default:
body, _ := io.ReadAll(resp.Body)
cleanupResp(resp)
_log.Error().
Int("status_code", resp.StatusCode).
Str("file", f.name).
Str("response_body", string(body)).
Msg("Unexpected upstream error")
return false, &streamError{
Err: fmt.Errorf("upstream error %d: %s", resp.StatusCode, string(body)),
StatusCode: http.StatusBadGateway,
}
}
}
func (f *File) handleRangeRequest(upstreamReq *http.Request, r *http.Request, w http.ResponseWriter) int {
rangeHeader := r.Header.Get("Range")
if rangeHeader == "" {
+6 -5
View File
@@ -4,8 +4,6 @@ import (
"context"
"errors"
"fmt"
"github.com/sirrobot01/decypharr/pkg/debrid/types"
"golang.org/x/net/webdav"
"mime"
"net/http"
"os"
@@ -15,6 +13,9 @@ import (
"strings"
"time"
"github.com/sirrobot01/decypharr/pkg/debrid/types"
"golang.org/x/net/webdav"
"github.com/rs/zerolog"
"github.com/sirrobot01/decypharr/internal/utils"
"github.com/sirrobot01/decypharr/pkg/debrid/store"
@@ -451,15 +452,15 @@ func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
// Handle nginx proxy (X-Accel-Redirect)
if file.content == nil && !file.isRar && h.cache.StreamWithRclone() {
link, err := file.getDownloadLink()
if err != nil || link == "" {
if err != nil || link.Empty() {
http.Error(w, "Could not fetch download link", http.StatusPreconditionFailed)
return
}
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fi.Name()))
w.Header().Set("X-Accel-Redirect", link)
w.Header().Set("X-Accel-Redirect", link.DownloadLink)
w.Header().Set("X-Accel-Buffering", "no")
http.Redirect(w, r, link, http.StatusFound)
http.Redirect(w, r, link.DownloadLink, http.StatusFound)
return
}
+2 -8
View File
@@ -2,21 +2,15 @@ package webdav
import (
"fmt"
"github.com/stanNthe5/stringbuf"
"net/http"
"net/url"
"os"
"path"
"strconv"
"strings"
"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 != ""
}
"github.com/stanNthe5/stringbuf"
)
var pctHex = "0123456789ABCDEF"
+3 -2
View File
@@ -3,7 +3,6 @@ package wire
import (
"crypto/md5"
"fmt"
"github.com/google/uuid"
"net/http"
"os"
"path/filepath"
@@ -13,6 +12,8 @@ import (
"sync"
"time"
"github.com/google/uuid"
"github.com/sirrobot01/decypharr/pkg/debrid/types"
"github.com/cavaliergopher/grab/v3"
@@ -350,7 +351,7 @@ func (s *Store) downloadFiles(torrent *Torrent, debridTorrent *types.Torrent, pa
}
errChan := make(chan error, len(debridTorrent.Files))
for _, file := range debridTorrent.GetFiles() {
if file.DownloadLink == nil {
if file.DownloadLink.Empty() {
s.logger.Info().Msgf("No download link found for %s", file.Name)
continue
}
+1
View File
@@ -25,6 +25,7 @@ func createTorrentFromMagnet(req *ImportRequest) *Torrent {
AutoTmm: false,
Ratio: 1,
RatioLimit: 1,
TotalSize: magnet.Size,
SavePath: filepath.Join(req.DownloadFolder, arrName) + string(os.PathSeparator),
}
return torrent
+1
View File
@@ -292,6 +292,7 @@ func (s *Store) partialTorrentUpdate(t *Torrent, debridTorrent *types.Torrent) *
t.Files = files
t.Debrid = debridTorrent.Debrid
t.Size = totalSize
t.TotalSize = totalSize
t.Completed = sizeCompleted
t.NumSeeds = debridTorrent.Seeders
t.Downloaded = sizeCompleted