Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions pkg/debrid/account/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ func (a *Account) sliceFileLink(fileLink string) string {
func (a *Account) GetDownloadLink(id string, file *types.File, fetcher LinkFetcher) (types.DownloadLink, error) {
slicedLink := a.sliceFileLink(file.Link)
dl, ok := a.links.Load(slicedLink)
if ok && dl.Expired() {
// The cache is keyed by file link, not by lifetime, so an entry can
// outlive the download URL it holds. Serving it only buys a doomed
// request downstream, so drop it and fetch a fresh one.
a.links.Delete(slicedLink)
ok = false
}
if !ok {
var err error
dl, err = fetcher(a, id, file)
Expand Down
133 changes: 133 additions & 0 deletions pkg/debrid/account/account_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package account

import (
"testing"
"time"

"github.com/puzpuzpuz/xsync/v4"
"github.com/sirrobot01/decypharr/pkg/debrid/types"
)

func newTestAccount(debrid string) *Account {
return &Account{
Debrid: debrid,
Token: "test-token",
links: xsync.NewMap[string, types.DownloadLink](),
}
}

func countingFetcher(url string, expiresAt time.Time, calls *int) LinkFetcher {
return func(_ *Account, _ string, file *types.File) (types.DownloadLink, error) {
*calls++
return types.DownloadLink{
Filename: file.Name,
Link: file.Link,
DownloadLink: url,
Debrid: "torbox",
Generated: time.Now(),
ExpiresAt: expiresAt,
}, nil
}
}

func TestGetDownloadLinkServesCachedLinkThatIsStillValid(t *testing.T) {
acc := newTestAccount("torbox")
file := &types.File{Name: "file.mkv", Link: "torbox://1/0"}

acc.storeLink(types.DownloadLink{
Link: file.Link,
DownloadLink: "https://cdn.example.com/cached",
Debrid: "torbox",
ExpiresAt: time.Now().Add(time.Hour),
})

calls := 0
dl, err := acc.GetDownloadLink("1", file, countingFetcher("https://cdn.example.com/fresh", time.Now().Add(time.Hour), &calls))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if calls != 0 {
t.Fatalf("expected the cached link to be served without a fetch, got %d fetches", calls)
}
if dl.DownloadLink != "https://cdn.example.com/cached" {
t.Fatalf("expected the cached link, got %q", dl.DownloadLink)
}
}

func TestGetDownloadLinkEvictsExpiredCachedLink(t *testing.T) {
acc := newTestAccount("torbox")
file := &types.File{Name: "file.mkv", Link: "torbox://1/0"}

acc.storeLink(types.DownloadLink{
Link: file.Link,
DownloadLink: "https://cdn.example.com/expired",
Debrid: "torbox",
Generated: time.Now().Add(-2 * time.Hour),
ExpiresAt: time.Now().Add(-time.Minute),
})

calls := 0
dl, err := acc.GetDownloadLink("1", file, countingFetcher("https://cdn.example.com/fresh", time.Now().Add(time.Hour), &calls))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if calls != 1 {
t.Fatalf("expected exactly one fetch after eviction, got %d", calls)
}
if dl.DownloadLink != "https://cdn.example.com/fresh" {
t.Fatalf("expected the refetched link, got %q", dl.DownloadLink)
}

cached, ok := acc.links.Load(acc.sliceFileLink(file.Link))
if !ok {
t.Fatal("expected the fresh link to be cached")
}
if cached.DownloadLink != "https://cdn.example.com/fresh" {
t.Fatalf("expected the cache to hold the fresh link, got %q", cached.DownloadLink)
}
}

// Providers that don't expose an expiry leave ExpiresAt zero; those entries must
// keep their previous never-evicted behaviour.
func TestGetDownloadLinkKeepsCachedLinkWithoutExpiry(t *testing.T) {
acc := newTestAccount("torbox")
file := &types.File{Name: "file.mkv", Link: "torbox://1/0"}

acc.storeLink(types.DownloadLink{
Link: file.Link,
DownloadLink: "https://cdn.example.com/no-expiry",
Debrid: "torbox",
Generated: time.Now().Add(-72 * time.Hour),
})

calls := 0
dl, err := acc.GetDownloadLink("1", file, countingFetcher("https://cdn.example.com/fresh", time.Time{}, &calls))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if calls != 0 {
t.Fatalf("expected no fetch for a link without an expiry, got %d", calls)
}
if dl.DownloadLink != "https://cdn.example.com/no-expiry" {
t.Fatalf("expected the cached link, got %q", dl.DownloadLink)
}
}

func TestDownloadLinkExpired(t *testing.T) {
cases := map[string]struct {
expiresAt time.Time
want bool
}{
"zero expiry is never expired": {time.Time{}, false},
"future expiry": {time.Now().Add(time.Hour), false},
"past expiry": {time.Now().Add(-time.Hour), true},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
dl := types.DownloadLink{DownloadLink: "https://cdn.example.com/x", ExpiresAt: tc.expiresAt}
if got := dl.Expired(); got != tc.want {
t.Fatalf("Expired() = %v, want %v", got, tc.want)
}
})
}
}
7 changes: 7 additions & 0 deletions pkg/debrid/types/torrent.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,13 @@ func (dl *DownloadLink) Valid() error {
return nil
}

// Expired reports whether the link is past the expiry the provider (or
// auto_expire_links_after) gave it. Providers that don't expose an expiry leave
// ExpiresAt zero; those links are never considered expired.
func (dl *DownloadLink) Expired() bool {
return !dl.ExpiresAt.IsZero() && time.Now().After(dl.ExpiresAt)
}

func (dl *DownloadLink) Empty() bool {
return dl.DownloadLink == ""
}
Expand Down
12 changes: 12 additions & 0 deletions pkg/manager/link/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,18 @@ func (s *Service) fetchAndValidate(ctx context.Context, entry *storage.Entry, fi
return s.handleBadLink(ctx, err, entry, link, attempt)
}

// A link we already know is expired cannot be validated back to life: the
// HEAD below would burn the whole retry ladder before landing in
// invalidateAndRefetch anyway. Refetch first, then validate the fresh link
// once through the normal path.
if link.Expired() && link.Debrid != "" {
fresh, refetchErr := s.invalidateAndRefetch(ctx, entry, link, attempt)
if refetchErr != nil {
return fresh, refetchErr
}
link = fresh
}

// Is link already validated
// Check if we've already validated this link
if validationErr, exists := s.validated.Load(link.DownloadLink); exists {
Expand Down