Debrid direct links are time-limited; a long playback can outlive the link the session was created with. When a debrid source dies mid-stream the daemon now re-resolves a fresh link for the same content and resumes — no torrent fallback, no playback restart. - debridFileProvider holds the URL behind a mutex; on an expired-link status (401/403/404/410) the ranged reader re-resolves via a refresh callback and retries (bounded: 1 initial + 1 post-refresh attempt). A browser opens several range connections, so the refresh is coalesced singleflight-style — N readers hitting the dead link share ONE re-resolution, not N. - HLS-from-URL: the auto-restart supervisor re-resolves the link before relaunching ffmpeg (else it just retries the dead URL and burns the retry budget). The mutable URL lives in s.liveURL under s.mu — restartFromSegment reads it from the HTTP handler goroutine too (seek-restart), so cfg stays immutable and the write races nothing. - agentClient.RefreshStreamURL → POST /api/internal/agent/stream-url. Cross-source torrent<->debrid swap (the rare "debrid genuinely gone" case) is intentionally deferred. Reader refresh + coalescing covered by unit tests (incl. -race); the web endpoint re-resolves against a real AllDebrid account.
368 lines
12 KiB
Go
368 lines
12 KiB
Go
package engine
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// rangeServer serves a fixed byte slice with full HTTP Range support via
|
|
// http.ServeContent (the same machinery a real debrid CDN exposes). Records
|
|
// the number of GETs so a test can assert that a seek triggers exactly one
|
|
// reopen rather than a full re-download.
|
|
func rangeServer(data []byte) (*httptest.Server, *int) {
|
|
gets := 0
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodGet {
|
|
gets++
|
|
}
|
|
http.ServeContent(w, r, "movie.mp4", time.Time{}, bytes.NewReader(data))
|
|
}))
|
|
return srv, &gets
|
|
}
|
|
|
|
func makeData(n int) []byte {
|
|
b := make([]byte, n)
|
|
for i := range b {
|
|
b[i] = byte(i % 251) // non-trivial, deterministic pattern
|
|
}
|
|
return b
|
|
}
|
|
|
|
func TestDebridProviderHeadSize(t *testing.T) {
|
|
data := makeData(4096)
|
|
srv, _ := rangeServer(data)
|
|
defer srv.Close()
|
|
|
|
// HEAD reports the real size; fallback is ignored when HEAD succeeds.
|
|
p, err := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 999, nil)
|
|
if err != nil {
|
|
t.Fatalf("NewDebridFileProvider: %v", err)
|
|
}
|
|
if got := p.FileSize(); got != int64(len(data)) {
|
|
t.Fatalf("FileSize from HEAD = %d, want %d", got, len(data))
|
|
}
|
|
if p.FileName() != "movie.mp4" {
|
|
t.Fatalf("FileName = %q, want movie.mp4", p.FileName())
|
|
}
|
|
}
|
|
|
|
func TestDebridProviderNameFromURLWhenNoExtension(t *testing.T) {
|
|
// The web may pass a torrent title with no extension (its file-name
|
|
// fallback). The provider must derive the name from the URL so the served
|
|
// Content-Type is video/mp4, not application/octet-stream.
|
|
data := makeData(1024)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.ServeContent(w, r, "x", time.Time{}, bytes.NewReader(data))
|
|
}))
|
|
defer srv.Close()
|
|
p, err := NewDebridFileProvider(context.Background(), srv.URL+"/Movie.2026.1080p.mp4?token=abc", "Project Hail Mary (2026) 1080p web", 0, nil)
|
|
if err != nil {
|
|
t.Fatalf("provider: %v", err)
|
|
}
|
|
if got := p.FileName(); got != "Movie.2026.1080p.mp4" {
|
|
t.Fatalf("FileName = %q, want Movie.2026.1080p.mp4 (derived from URL)", got)
|
|
}
|
|
// A passed name WITH an extension is kept as-is.
|
|
p2, _ := NewDebridFileProvider(context.Background(), srv.URL+"/whatever.mp4", "Nice Title.mp4", 0, nil)
|
|
if got := p2.FileName(); got != "Nice Title.mp4" {
|
|
t.Fatalf("FileName = %q, want Nice Title.mp4 (kept)", got)
|
|
}
|
|
}
|
|
|
|
func TestDebridProviderFallbackSizeWhenNoHead(t *testing.T) {
|
|
// Server that refuses HEAD (405) but serves GET — provider must fall back
|
|
// to the size the web reported.
|
|
data := makeData(2048)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodHead {
|
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
http.ServeContent(w, r, "movie.mp4", time.Time{}, bytes.NewReader(data))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
p, err := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", int64(len(data)), nil)
|
|
if err != nil {
|
|
t.Fatalf("NewDebridFileProvider: %v", err)
|
|
}
|
|
if got := p.FileSize(); got != int64(len(data)) {
|
|
t.Fatalf("FileSize fallback = %d, want %d", got, len(data))
|
|
}
|
|
}
|
|
|
|
func TestDebridProviderNoSizeFails(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusMethodNotAllowed) // no HEAD, no usable GET
|
|
}))
|
|
defer srv.Close()
|
|
// No HEAD size and fallback 0 → must error (ServeContent can't range-serve
|
|
// size 0 without handing the browser an empty file).
|
|
if _, err := NewDebridFileProvider(context.Background(), srv.URL, "", 0, nil); err == nil {
|
|
t.Fatal("expected error when size is unknown, got nil")
|
|
}
|
|
if _, err := NewDebridFileProvider(context.Background(), "", "movie.mp4", 100, nil); err == nil {
|
|
t.Fatal("expected error for empty URL, got nil")
|
|
}
|
|
}
|
|
|
|
func TestDebridReaderSequentialRead(t *testing.T) {
|
|
data := makeData(100_000)
|
|
srv, gets := rangeServer(data)
|
|
defer srv.Close()
|
|
|
|
p, err := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 0, nil)
|
|
if err != nil {
|
|
t.Fatalf("provider: %v", err)
|
|
}
|
|
rd := p.NewFileReader(context.Background())
|
|
defer rd.Close()
|
|
|
|
got, err := io.ReadAll(rd)
|
|
if err != nil {
|
|
t.Fatalf("ReadAll: %v", err)
|
|
}
|
|
if !bytes.Equal(got, data) {
|
|
t.Fatalf("sequential read mismatch: got %d bytes, want %d", len(got), len(data))
|
|
}
|
|
// A pure sequential read holds a single body to EOF → exactly one GET.
|
|
if *gets != 1 {
|
|
t.Fatalf("sequential read issued %d GETs, want 1", *gets)
|
|
}
|
|
}
|
|
|
|
func TestDebridReaderSeekEndReportsSize(t *testing.T) {
|
|
data := makeData(5000)
|
|
srv, _ := rangeServer(data)
|
|
defer srv.Close()
|
|
p, _ := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 0, nil)
|
|
rd := p.NewFileReader(context.Background())
|
|
defer rd.Close()
|
|
|
|
// http.ServeContent calls Seek(0, SeekEnd) to learn the size — must be
|
|
// network-free and return the total.
|
|
size, err := rd.Seek(0, io.SeekEnd)
|
|
if err != nil {
|
|
t.Fatalf("Seek end: %v", err)
|
|
}
|
|
if size != int64(len(data)) {
|
|
t.Fatalf("SeekEnd = %d, want %d", size, len(data))
|
|
}
|
|
}
|
|
|
|
func TestDebridReaderSeekThenRead(t *testing.T) {
|
|
data := makeData(50_000)
|
|
srv, gets := rangeServer(data)
|
|
defer srv.Close()
|
|
p, _ := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 0, nil)
|
|
rd := p.NewFileReader(context.Background())
|
|
defer rd.Close()
|
|
|
|
const off = 12_345
|
|
if _, err := rd.Seek(off, io.SeekStart); err != nil {
|
|
t.Fatalf("seek: %v", err)
|
|
}
|
|
got, err := io.ReadAll(rd)
|
|
if err != nil {
|
|
t.Fatalf("ReadAll after seek: %v", err)
|
|
}
|
|
if !bytes.Equal(got, data[off:]) {
|
|
t.Fatalf("tail mismatch: got %d bytes, want %d", len(got), len(data)-off)
|
|
}
|
|
// Seek is network-free; the read after it is the only GET.
|
|
if *gets != 1 {
|
|
t.Fatalf("seek+read issued %d GETs, want 1", *gets)
|
|
}
|
|
}
|
|
|
|
func TestDebridReaderServeContentRoundTrip(t *testing.T) {
|
|
// Drive the reader exactly like StreamServer does: hand it to
|
|
// http.ServeContent and issue a ranged request. Verifies the reader is a
|
|
// correct io.ReadSeeker for the production serving path.
|
|
data := makeData(80_000)
|
|
srv, _ := rangeServer(data)
|
|
defer srv.Close()
|
|
p, _ := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 0, nil)
|
|
|
|
front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
rd := p.NewFileReader(r.Context())
|
|
defer rd.Close()
|
|
http.ServeContent(w, r, p.FileName(), time.Time{}, rd)
|
|
}))
|
|
defer front.Close()
|
|
|
|
req, _ := http.NewRequest(http.MethodGet, front.URL, nil)
|
|
req.Header.Set("Range", "bytes=10000-19999")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatalf("ranged GET: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusPartialContent {
|
|
t.Fatalf("status = %d, want 206", resp.StatusCode)
|
|
}
|
|
body, _ := io.ReadAll(resp.Body)
|
|
want := data[10000:20000]
|
|
if !bytes.Equal(body, want) {
|
|
t.Fatalf("ranged body mismatch: got %d bytes", len(body))
|
|
}
|
|
}
|
|
|
|
func TestDebridReaderSeekPastEnd(t *testing.T) {
|
|
data := makeData(1000)
|
|
srv, _ := rangeServer(data)
|
|
defer srv.Close()
|
|
p, _ := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 0, nil)
|
|
rd := p.NewFileReader(context.Background())
|
|
defer rd.Close()
|
|
|
|
// Seeking at/past size then reading yields EOF, no error, no bytes.
|
|
if _, err := rd.Seek(int64(len(data)), io.SeekStart); err != nil {
|
|
t.Fatalf("seek: %v", err)
|
|
}
|
|
n, err := rd.Read(make([]byte, 16))
|
|
if n != 0 || err != io.EOF {
|
|
t.Fatalf("read past end = (%d, %v), want (0, EOF)", n, err)
|
|
}
|
|
}
|
|
|
|
// hueco #2 / 2c — an expired link (401) is recovered by re-resolving a fresh
|
|
// URL via the refresh callback and retrying, transparent to the reader.
|
|
func TestDebridReaderRefreshOnExpiry(t *testing.T) {
|
|
data := makeData(20_000)
|
|
live, _ := rangeServer(data)
|
|
defer live.Close()
|
|
expired := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized) // link expired
|
|
}))
|
|
defer expired.Close()
|
|
|
|
refreshed := 0
|
|
refresh := func(_ context.Context) (string, error) {
|
|
refreshed++
|
|
return live.URL, nil
|
|
}
|
|
// HEAD on the expired URL 401s → falls back to the provided size.
|
|
p, err := NewDebridFileProvider(context.Background(), expired.URL, "movie.mp4", int64(len(data)), refresh)
|
|
if err != nil {
|
|
t.Fatalf("provider: %v", err)
|
|
}
|
|
rd := p.NewFileReader(context.Background())
|
|
defer rd.Close()
|
|
|
|
got, err := io.ReadAll(rd)
|
|
if err != nil {
|
|
t.Fatalf("ReadAll after expiry+refresh: %v", err)
|
|
}
|
|
if !bytes.Equal(got, data) {
|
|
t.Fatalf("post-refresh read mismatch: got %d bytes, want %d", len(got), len(data))
|
|
}
|
|
if refreshed == 0 {
|
|
t.Fatal("expected the reader to refresh the expired URL")
|
|
}
|
|
}
|
|
|
|
// Coalescing: when N readers hit an expired link at once, only ONE refresh
|
|
// runs (singleflight) and they all share its result — not N re-resolutions
|
|
// hammering the web (hueco #2 / 2c).
|
|
func TestDebridProviderRefreshCoalesces(t *testing.T) {
|
|
data := makeData(8000)
|
|
live, _ := rangeServer(data)
|
|
defer live.Close()
|
|
|
|
var refreshCalls int64
|
|
refresh := func(_ context.Context) (string, error) {
|
|
atomic.AddInt64(&refreshCalls, 1)
|
|
time.Sleep(80 * time.Millisecond) // simulate a slow re-resolution
|
|
return live.URL, nil
|
|
}
|
|
p := &debridFileProvider{url: "https://expired.invalid/x.mp4", refresh: refresh}
|
|
|
|
const N = 8
|
|
var wg sync.WaitGroup
|
|
errs := make([]error, N)
|
|
for i := 0; i < N; i++ {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
_, errs[i] = p.refreshURL(context.Background())
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
for i, err := range errs {
|
|
if err != nil {
|
|
t.Fatalf("reader %d refresh failed: %v", i, err)
|
|
}
|
|
}
|
|
if got := atomic.LoadInt64(&refreshCalls); got != 1 {
|
|
t.Fatalf("expected 1 coalesced refresh for %d concurrent readers, got %d", N, got)
|
|
}
|
|
if p.currentURL() != live.URL {
|
|
t.Fatalf("provider URL = %q, want the refreshed live URL", p.currentURL())
|
|
}
|
|
}
|
|
|
|
func TestDebridReaderRefreshFailsSurfacesError(t *testing.T) {
|
|
expired := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
}))
|
|
defer expired.Close()
|
|
refresh := func(_ context.Context) (string, error) {
|
|
return "", errors.New("debrid gone")
|
|
}
|
|
p, _ := NewDebridFileProvider(context.Background(), expired.URL, "movie.mp4", 1000, refresh)
|
|
rd := p.NewFileReader(context.Background())
|
|
defer rd.Close()
|
|
if _, err := rd.Read(make([]byte, 16)); err == nil {
|
|
t.Fatal("expected an error when refresh fails, got nil")
|
|
}
|
|
}
|
|
|
|
func TestDebridReaderNoRefresherExpiryIsHardError(t *testing.T) {
|
|
// refresh == nil (2a behaviour): an expired link is a hard error, no retry.
|
|
expired := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusGone)
|
|
}))
|
|
defer expired.Close()
|
|
p, _ := NewDebridFileProvider(context.Background(), expired.URL, "movie.mp4", 1000, nil)
|
|
rd := p.NewFileReader(context.Background())
|
|
defer rd.Close()
|
|
if _, err := rd.Read(make([]byte, 16)); err == nil {
|
|
t.Fatal("expected a hard error with no refresher, got nil")
|
|
}
|
|
}
|
|
|
|
func TestDebridReaderRejectsServerIgnoringRange(t *testing.T) {
|
|
// A server that always returns 200 (ignores Range) is only safe at pos 0.
|
|
// A reopen at a non-zero offset (after a seek) must error rather than serve
|
|
// misaligned bytes.
|
|
data := makeData(4000)
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Length", "4000")
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write(data)
|
|
}))
|
|
defer srv.Close()
|
|
p, err := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", int64(len(data)), nil)
|
|
if err != nil {
|
|
t.Fatalf("provider: %v", err)
|
|
}
|
|
rd := p.NewFileReader(context.Background())
|
|
defer rd.Close()
|
|
|
|
if _, err := rd.Seek(1000, io.SeekStart); err != nil {
|
|
t.Fatalf("seek: %v", err)
|
|
}
|
|
if _, err := rd.Read(make([]byte, 16)); err == nil {
|
|
t.Fatal("expected error when server ignores Range at non-zero offset, got nil")
|
|
}
|
|
}
|