feat(stream): refresh expired debrid links mid-stream (hueco #2/2c)

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.
This commit is contained in:
Deivid Soto 2026-05-31 17:02:59 +02:00
parent 4946982783
commit 7562b62241
5 changed files with 337 additions and 56 deletions

View file

@ -21,6 +21,7 @@ import (
"net/http"
"path"
"strings"
"sync"
"time"
)
@ -47,7 +48,10 @@ var debridHTTPClient = &http.Client{
// Returns an error only when neither a HEAD size nor a fallback is available —
// http.ServeContent needs a real size to range-serve, and serving size 0 would
// hand the browser an empty file.
func NewDebridFileProvider(ctx context.Context, directURL, fileName string, fallbackSize int64) (FileProvider, error) {
// refresh, when non-nil, re-resolves a fresh debrid URL for the same content
// (hueco #2 / 2c) — called when the current link expires mid-stream. nil keeps
// 2a behaviour (an expired link is a hard error, no recovery).
func NewDebridFileProvider(ctx context.Context, directURL, fileName string, fallbackSize int64, refresh func(context.Context) (string, error)) (FileProvider, error) {
if directURL == "" {
return nil, errors.New("debrid provider: empty direct URL")
}
@ -69,23 +73,95 @@ func NewDebridFileProvider(ctx context.Context, directURL, fileName string, fall
name = debridNameFromURL(directURL)
}
return &debridFileProvider{
url: directURL,
name: name,
size: size,
url: directURL,
name: name,
size: size,
refresh: refresh,
}, nil
}
// debridFileProvider serves a file from a debrid HTTPS URL via ranged GETs.
// debridFileProvider serves a file from a debrid HTTPS URL via ranged GETs. The
// URL is mutable: when it expires mid-stream, refreshURL swaps in a fresh one
// (shared across all readers this provider hands out) so the next range request
// uses the live link.
type debridFileProvider struct {
url string
mu sync.Mutex
url string
lastRefreshAt time.Time
inflight *refreshCall // non-nil while a refresh is running; coalesces concurrent callers
refresh func(context.Context) (string, error)
name string
size int64
}
// refreshCall is a single in-flight refresh whose result is shared by every
// reader that piles up behind it (singleflight). done is closed on completion.
type refreshCall struct {
done chan struct{}
url string
err error
}
// currentURL returns the live debrid URL (mutated by refreshURL on expiry).
func (p *debridFileProvider) currentURL() string {
p.mu.Lock()
defer p.mu.Unlock()
return p.url
}
// refreshURL re-resolves a fresh debrid link and stores it. A browser's <video>
// opens several concurrent range connections, so when a link expires N readers
// hit it at once — they must NOT each fire a (multi-second) re-resolution.
// Coalescing is two-layer: (1) a result refreshed in the last few seconds is
// reused without any call; (2) while a refresh is in flight, late callers wait
// on it and share its result (singleflight) rather than starting their own.
func (p *debridFileProvider) refreshURL(ctx context.Context) (string, error) {
if p.refresh == nil {
return "", errors.New("debrid provider: no URL refresher (refresh disabled)")
}
p.mu.Lock()
if time.Since(p.lastRefreshAt) < 5*time.Second && p.url != "" {
u := p.url
p.mu.Unlock()
return u, nil // refreshed very recently — reuse it
}
if call := p.inflight; call != nil {
p.mu.Unlock()
select {
case <-call.done:
return call.url, call.err // shared result from the in-flight refresh
case <-ctx.Done():
return "", ctx.Err()
}
}
call := &refreshCall{done: make(chan struct{})}
p.inflight = call
p.mu.Unlock()
u, err := p.refresh(ctx)
p.mu.Lock()
if err == nil {
p.url = u
p.lastRefreshAt = time.Now()
}
call.url, call.err = u, err
p.inflight = nil
close(call.done)
p.mu.Unlock()
if err != nil {
return "", err
}
log.Printf("[stream] debrid URL refreshed (expired mid-stream)")
return u, nil
}
func (p *debridFileProvider) NewFileReader(ctx context.Context) io.ReadSeekCloser {
return &debridRangeReader{
ctx: ctx,
url: p.url,
prov: p,
size: p.size,
}
}
@ -102,7 +178,7 @@ func (p *debridFileProvider) FileSize() int64 { return p.size }
// the player become a single reopened GET, never a full re-download.
type debridRangeReader struct {
ctx context.Context
url string
prov *debridFileProvider
size int64
pos int64 // logical position (moved by Seek, advanced by Read)
@ -166,43 +242,61 @@ func (r *debridRangeReader) Close() error {
}
// reopen issues a fresh ranged GET from the current logical position. Closes
// any previously held body first.
// any previously held body first. On an expired-link status (401/403/404/410)
// it re-resolves a fresh debrid URL via the provider and retries — bounded, so
// a permanently-dead link surfaces an error instead of looping (hueco #2 / 2c).
func (r *debridRangeReader) reopen() error {
if r.body != nil {
_ = r.body.Close()
r.body = nil
}
req, err := http.NewRequestWithContext(r.ctx, http.MethodGet, r.url, nil)
if err != nil {
return fmt.Errorf("debrid reader: build request: %w", err)
}
// Always send a Range so a seek to 0 still gets a 206 (and so partial
// reopens after a mid-file seek work). An open-ended range runs to EOF.
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", r.pos))
resp, err := debridHTTPClient.Do(req)
if err != nil {
return fmt.Errorf("debrid reader: GET: %w", err)
}
switch resp.StatusCode {
case http.StatusPartialContent:
// Expected.
case http.StatusOK:
// Server ignored Range and is sending the whole file from 0. Only valid
// when we asked from 0; otherwise the bytes wouldn't line up with pos.
if r.pos != 0 {
resp.Body.Close()
return fmt.Errorf("debrid reader: server ignored Range at offset %d (got 200)", r.pos)
// Attempts: 1 initial + 1 after a URL refresh. One fresh link is enough for
// an expiry; if the refreshed link ALSO fails the content is genuinely gone,
// so surface the error rather than burning more multi-second resolutions.
const maxAttempts = 2
for attempt := 0; attempt < maxAttempts; attempt++ {
req, err := http.NewRequestWithContext(r.ctx, http.MethodGet, r.prov.currentURL(), nil)
if err != nil {
return fmt.Errorf("debrid reader: build request: %w", err)
}
// Always send a Range so a seek to 0 still gets a 206 (and so partial
// reopens after a mid-file seek work). An open-ended range runs to EOF.
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", r.pos))
resp, err := debridHTTPClient.Do(req)
if err != nil {
return fmt.Errorf("debrid reader: GET: %w", err)
}
switch resp.StatusCode {
case http.StatusPartialContent:
r.body = resp.Body
r.bodyAt = r.pos
return nil
case http.StatusOK:
// Server ignored Range and is sending the whole file from 0. Only
// valid when we asked from 0; otherwise bytes wouldn't line up.
if r.pos != 0 {
resp.Body.Close()
return fmt.Errorf("debrid reader: server ignored Range at offset %d (got 200)", r.pos)
}
r.body = resp.Body
r.bodyAt = r.pos
return nil
case http.StatusRequestedRangeNotSatisfiable:
resp.Body.Close()
return io.EOF // seeked past end — treat as EOF, not a hard error
case http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound, http.StatusGone:
// Expired/dead debrid link — re-resolve and retry with the fresh URL.
resp.Body.Close()
if _, rerr := r.prov.refreshURL(r.ctx); rerr != nil {
return fmt.Errorf("debrid reader: link expired (%d) and refresh failed: %w", resp.StatusCode, rerr)
}
continue
default:
resp.Body.Close()
return fmt.Errorf("debrid reader: unexpected status %d %s", resp.StatusCode, resp.Status)
}
case http.StatusRequestedRangeNotSatisfiable:
resp.Body.Close()
return io.EOF // seeked past end — treat as EOF, not a hard error
default:
resp.Body.Close()
return fmt.Errorf("debrid reader: unexpected status %d %s", resp.StatusCode, resp.Status)
}
r.body = resp.Body
r.bodyAt = r.pos
return nil
return fmt.Errorf("debrid reader: link still failing after %d refresh attempts", maxAttempts)
}
// debridHeadSize issues a HEAD and returns the Content-Length when present.