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:
parent
4946982783
commit
7562b62241
5 changed files with 337 additions and 56 deletions
|
|
@ -130,6 +130,27 @@ func (c *Client) MarkSessionReady(ctx context.Context, sessionID string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RefreshStreamURL re-resolves a fresh debrid direct URL for a live streaming
|
||||||
|
// session (hueco #2 / 2c). Called by the daemon when a debrid source expires
|
||||||
|
// mid-stream (the link is time-limited; the content is still cached). Returns
|
||||||
|
// the new URL on success; an error (incl. 409/410) means refresh isn't
|
||||||
|
// possible and the caller should stop trying.
|
||||||
|
func (c *Client) RefreshStreamURL(ctx context.Context, sessionID string) (string, error) {
|
||||||
|
req := struct {
|
||||||
|
SessionID string `json:"sessionId"`
|
||||||
|
}{SessionID: sessionID}
|
||||||
|
var resp struct {
|
||||||
|
DirectURL string `json:"directUrl"`
|
||||||
|
}
|
||||||
|
if err := c.doPost(ctx, "/api/internal/agent/stream-url", req, &resp); err != nil {
|
||||||
|
return "", fmt.Errorf("refresh stream url: %w", err)
|
||||||
|
}
|
||||||
|
if resp.DirectURL == "" {
|
||||||
|
return "", fmt.Errorf("refresh stream url: empty url in response")
|
||||||
|
}
|
||||||
|
return resp.DirectURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ReportStatus reports download progress. Returns server-side flags the CLI must act on.
|
// ReportStatus reports download progress. Returns server-side flags the CLI must act on.
|
||||||
func (c *Client) ReportStatus(ctx context.Context, update StatusUpdate) (*StatusResponse, error) {
|
func (c *Client) ReportStatus(ctx context.Context, update StatusUpdate) (*StatusResponse, error) {
|
||||||
var resp StatusResponse
|
var resp StatusResponse
|
||||||
|
|
|
||||||
|
|
@ -598,10 +598,16 @@ func runDaemonStart() error {
|
||||||
// no-op (matches the HLS branch's handoff rationale).
|
// no-op (matches the HLS branch's handoff rationale).
|
||||||
if sess.DirectURL != "" && sess.PlayMethod != "hls" {
|
if sess.DirectURL != "" && sess.PlayMethod != "hls" {
|
||||||
playerSessionRegistry.add(sess.SessionID, func() { streamSrv.ClearFile() })
|
playerSessionRegistry.add(sess.SessionID, func() { streamSrv.ClearFile() })
|
||||||
|
// refresh re-resolves a fresh debrid link when this one expires
|
||||||
|
// mid-stream (hueco #2 / 2c). Bound to the daemon ctx so a shutdown
|
||||||
|
// cancels an in-flight refresh.
|
||||||
|
refresh := func(rctx context.Context) (string, error) {
|
||||||
|
return agentClient.RefreshStreamURL(rctx, sess.SessionID)
|
||||||
|
}
|
||||||
go func() {
|
go func() {
|
||||||
bctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
bctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
provider, perr := engine.NewDebridFileProvider(bctx, sess.DirectURL, sess.FileName, sess.FileSize)
|
provider, perr := engine.NewDebridFileProvider(bctx, sess.DirectURL, sess.FileName, sess.FileSize, refresh)
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
playerSessionRegistry.remove(sess.SessionID)
|
playerSessionRegistry.remove(sess.SessionID)
|
||||||
log.Printf("[stream %s] debrid provider failed: %v", agent.ShortID(sess.SessionID), perr)
|
log.Printf("[stream %s] debrid provider failed: %v", agent.ShortID(sess.SessionID), perr)
|
||||||
|
|
@ -640,6 +646,11 @@ func runDaemonStart() error {
|
||||||
AudioIndex: sess.AudioIndex,
|
AudioIndex: sess.AudioIndex,
|
||||||
Transcode: tcRuntime,
|
Transcode: tcRuntime,
|
||||||
Cache: hlsCache,
|
Cache: hlsCache,
|
||||||
|
// 2c: refresh the debrid link if it expires mid-transcode; the
|
||||||
|
// auto-restart supervisor calls this before relaunching ffmpeg.
|
||||||
|
RefreshURL: func(rctx context.Context) (string, error) {
|
||||||
|
return agentClient.RefreshStreamURL(rctx, sess.SessionID)
|
||||||
|
},
|
||||||
}, hlsCtx, hlsCancel)
|
}, hlsCtx, hlsCancel)
|
||||||
log.Printf("[hls %s] debrid HLS-from-URL: %s", agent.ShortID(sess.SessionID), sess.FileName)
|
log.Printf("[hls %s] debrid HLS-from-URL: %s", agent.ShortID(sess.SessionID), sess.FileName)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,11 @@ type HLSSessionConfig struct {
|
||||||
// files). Set to a stable id (the torrent info_hash) for SourceURL sessions
|
// files). Set to a stable id (the torrent info_hash) for SourceURL sessions
|
||||||
// so re-plays cache-hit even though the debrid URL changes each resolution.
|
// so re-plays cache-hit even though the debrid URL changes each resolution.
|
||||||
CacheID string
|
CacheID string
|
||||||
|
// RefreshURL, when set (debrid URL sessions only), re-resolves a fresh
|
||||||
|
// SourceURL when the current link expires mid-transcode (hueco #2 / 2c).
|
||||||
|
// The auto-restart supervisor calls it before relaunching ffmpeg so the
|
||||||
|
// restart uses a live link instead of retrying the dead one. nil = no refresh.
|
||||||
|
RefreshURL func(context.Context) (string, error)
|
||||||
FileName string
|
FileName string
|
||||||
Quality string // "2160p"|"1080p"|"720p"|"480p"|"original"|""
|
Quality string // "2160p"|"1080p"|"720p"|"480p"|"original"|""
|
||||||
AudioIndex int // 0-based ffmpeg audio stream selection (-map 0:a:N). -1 = default.
|
AudioIndex int // 0-based ffmpeg audio stream selection (-map 0:a:N). -1 = default.
|
||||||
|
|
@ -204,6 +209,13 @@ type HLSSession struct {
|
||||||
ffmpegSegStart int // index of the first segment the current ffmpeg writes
|
ffmpegSegStart int // index of the first segment the current ffmpeg writes
|
||||||
restartCount int // bounded auto-restart counter (resets on Close)
|
restartCount int // bounded auto-restart counter (resets on Close)
|
||||||
lastRestartAt time.Time
|
lastRestartAt time.Time
|
||||||
|
// liveURL is the mutable debrid source URL (hueco #2 / 2c). Initialised to
|
||||||
|
// cfg.SourceURL; refreshed in place by waitFFmpeg when the link expires.
|
||||||
|
// Guarded by mu because restartFromSegment reads it from BOTH the supervisor
|
||||||
|
// goroutine (auto-restart) AND the HTTP handler goroutine (seek-restart),
|
||||||
|
// while waitFFmpeg writes it. Empty for local-file sessions. cfg itself is
|
||||||
|
// treated as immutable after construction so copying it stays race-free.
|
||||||
|
liveURL string
|
||||||
|
|
||||||
// readyCh + readyMax track how many segments ffmpeg has finished writing.
|
// readyCh + readyMax track how many segments ffmpeg has finished writing.
|
||||||
// readyMax is a COUNT (not an index): readyMax=N means seg-0 … seg-(N-1)
|
// readyMax is a COUNT (not an index): readyMax=N means seg-0 … seg-(N-1)
|
||||||
|
|
@ -444,6 +456,7 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
|
||||||
cacheKey: cacheKey,
|
cacheKey: cacheKey,
|
||||||
fromCache: fromCache,
|
fromCache: fromCache,
|
||||||
writerLockHeld: writerLockHeld,
|
writerLockHeld: writerLockHeld,
|
||||||
|
liveURL: cfg.SourceURL, // mutable copy; cfg stays immutable
|
||||||
}
|
}
|
||||||
s.manifestVideo = renderVideoPlaylist(probe.DurationSec, segCount)
|
s.manifestVideo = renderVideoPlaylist(probe.DurationSec, segCount)
|
||||||
s.manifestRoot = renderMasterPlaylist(probe, cfg.Quality)
|
s.manifestRoot = renderMasterPlaylist(probe, cfg.Quality)
|
||||||
|
|
@ -483,9 +496,14 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
|
||||||
|
|
||||||
if len(probe.SubtitleTracks) > 0 {
|
if len(probe.SubtitleTracks) > 0 {
|
||||||
s.subsDone = make(chan struct{})
|
s.subsDone = make(chan struct{})
|
||||||
|
// Capture the source ref now (by value): subs are extracted once at
|
||||||
|
// startup, and a later URL refresh (2c) mutates s.cfg.SourceURL from the
|
||||||
|
// waitFFmpeg goroutine — passing the URL in keeps extractSubtitles from
|
||||||
|
// racing that write.
|
||||||
|
subSrc := cfg.sourceRef()
|
||||||
go func() {
|
go func() {
|
||||||
defer close(s.subsDone)
|
defer close(s.subsDone)
|
||||||
s.extractSubtitles(ffCtx)
|
s.extractSubtitles(ffCtx, subSrc)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -750,6 +768,26 @@ func (s *HLSSession) waitFFmpeg() {
|
||||||
s.lastRestartAt = time.Now()
|
s.lastRestartAt = time.Now()
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// Debrid URL session (hueco #2 / 2c): the likeliest cause of an ffmpeg
|
||||||
|
// network exit is the debrid link expiring. Re-resolve a fresh one before
|
||||||
|
// restarting, else the restart just retries the dead URL and burns the
|
||||||
|
// retry budget. The network call runs lock-free; the result is stored in
|
||||||
|
// s.liveURL under s.mu because restartFromSegment reads it from the HTTP
|
||||||
|
// handler goroutine too (seek-restart), not just this supervisor goroutine.
|
||||||
|
if s.cfg.SourceURL != "" && s.cfg.RefreshURL != nil {
|
||||||
|
rctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||||
|
newURL, rerr := s.cfg.RefreshURL(rctx)
|
||||||
|
cancel()
|
||||||
|
if rerr != nil {
|
||||||
|
log.Printf("[hls %s] URL refresh before restart failed: %v", shortHLSID(s.cfg.SessionID), rerr)
|
||||||
|
} else {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.liveURL = newURL
|
||||||
|
s.mu.Unlock()
|
||||||
|
log.Printf("[hls %s] debrid URL refreshed before restart", shortHLSID(s.cfg.SessionID))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Restart from the last segment we know is safely on disk. If readyMax
|
// Restart from the last segment we know is safely on disk. If readyMax
|
||||||
// is 0 (never produced anything), retry from segment 0 — covers initial
|
// is 0 (never produced anything), retry from segment 0 — covers initial
|
||||||
// startup failures on transient errors.
|
// startup failures on transient errors.
|
||||||
|
|
@ -1005,8 +1043,15 @@ func (s *HLSSession) restartFromSegment(targetIdx int) error {
|
||||||
// Build args for the new ffmpeg with -ss offset. Segments are non-uniform
|
// Build args for the new ffmpeg with -ss offset. Segments are non-uniform
|
||||||
// (seg-0 is hlsInitSegmentDuration s, the rest are hlsSegmentDuration s),
|
// (seg-0 is hlsInitSegmentDuration s, the rest are hlsSegmentDuration s),
|
||||||
// so use segmentStartSec for the seek time instead of multiplying.
|
// so use segmentStartSec for the seek time instead of multiplying.
|
||||||
|
// Use a local cfg copy carrying the live (possibly-refreshed) debrid URL,
|
||||||
|
// read under s.mu — this runs from the HTTP handler goroutine too, so it
|
||||||
|
// can't read s.liveURL unsynchronised while waitFFmpeg writes it (2c).
|
||||||
startSec := segmentStartSec(targetIdx)
|
startSec := segmentStartSec(targetIdx)
|
||||||
args := buildHLSFFmpegArgsAt(s.cfg, s.probe, s.tmpDir, targetIdx, startSec)
|
cfg := s.cfg
|
||||||
|
s.mu.Lock()
|
||||||
|
cfg.SourceURL = s.liveURL // "" for local-file sessions — no-op, sourceRef falls back to SourcePath
|
||||||
|
s.mu.Unlock()
|
||||||
|
args := buildHLSFFmpegArgsAt(cfg, s.probe, s.tmpDir, targetIdx, startSec)
|
||||||
|
|
||||||
ffCtx, cancel := context.WithCancel(context.Background())
|
ffCtx, cancel := context.WithCancel(context.Background())
|
||||||
cmd := exec.CommandContext(ffCtx, s.cfg.Transcode.FFmpegPath, args...)
|
cmd := exec.CommandContext(ffCtx, s.cfg.Transcode.FFmpegPath, args...)
|
||||||
|
|
@ -1352,7 +1397,7 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin
|
||||||
// extractSubtitles spawns short-lived ffmpeg jobs to convert each text-based
|
// extractSubtitles spawns short-lived ffmpeg jobs to convert each text-based
|
||||||
// subtitle track to WebVTT in parallel. Bitmap subs (PGS, DVB) are skipped —
|
// subtitle track to WebVTT in parallel. Bitmap subs (PGS, DVB) are skipped —
|
||||||
// they would require burn-in into the video encode, which is out of scope.
|
// they would require burn-in into the video encode, which is out of scope.
|
||||||
func (s *HLSSession) extractSubtitles(ctx context.Context) {
|
func (s *HLSSession) extractSubtitles(ctx context.Context, src string) {
|
||||||
subsDir := filepath.Join(s.tmpDir, "subs")
|
subsDir := filepath.Join(s.tmpDir, "subs")
|
||||||
for i, sub := range s.probe.SubtitleTracks {
|
for i, sub := range s.probe.SubtitleTracks {
|
||||||
if !sub.IsTextSubtitle() {
|
if !sub.IsTextSubtitle() {
|
||||||
|
|
@ -1361,7 +1406,7 @@ func (s *HLSSession) extractSubtitles(ctx context.Context) {
|
||||||
out := filepath.Join(subsDir, fmt.Sprintf("sub-%d.vtt", i))
|
out := filepath.Join(subsDir, fmt.Sprintf("sub-%d.vtt", i))
|
||||||
args := []string{
|
args := []string{
|
||||||
"-y", "-hide_banner", "-loglevel", "warning",
|
"-y", "-hide_banner", "-loglevel", "warning",
|
||||||
"-i", s.cfg.sourceRef(),
|
"-i", src,
|
||||||
"-map", fmt.Sprintf("0:s:%d?", i),
|
"-map", fmt.Sprintf("0:s:%d?", i),
|
||||||
"-c:s", "webvtt",
|
"-c:s", "webvtt",
|
||||||
out,
|
out,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -47,7 +48,10 @@ var debridHTTPClient = &http.Client{
|
||||||
// Returns an error only when neither a HEAD size nor a fallback is available —
|
// 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
|
// http.ServeContent needs a real size to range-serve, and serving size 0 would
|
||||||
// hand the browser an empty file.
|
// 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 == "" {
|
if directURL == "" {
|
||||||
return nil, errors.New("debrid provider: empty direct URL")
|
return nil, errors.New("debrid provider: empty direct URL")
|
||||||
}
|
}
|
||||||
|
|
@ -72,20 +76,92 @@ func NewDebridFileProvider(ctx context.Context, directURL, fileName string, fall
|
||||||
url: directURL,
|
url: directURL,
|
||||||
name: name,
|
name: name,
|
||||||
size: size,
|
size: size,
|
||||||
|
refresh: refresh,
|
||||||
}, nil
|
}, 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 {
|
type debridFileProvider struct {
|
||||||
|
mu sync.Mutex
|
||||||
url string
|
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
|
name string
|
||||||
size int64
|
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 {
|
func (p *debridFileProvider) NewFileReader(ctx context.Context) io.ReadSeekCloser {
|
||||||
return &debridRangeReader{
|
return &debridRangeReader{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
url: p.url,
|
prov: p,
|
||||||
size: p.size,
|
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.
|
// the player become a single reopened GET, never a full re-download.
|
||||||
type debridRangeReader struct {
|
type debridRangeReader struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
url string
|
prov *debridFileProvider
|
||||||
size int64
|
size int64
|
||||||
|
|
||||||
pos int64 // logical position (moved by Seek, advanced by Read)
|
pos int64 // logical position (moved by Seek, advanced by Read)
|
||||||
|
|
@ -166,13 +242,20 @@ func (r *debridRangeReader) Close() error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// reopen issues a fresh ranged GET from the current logical position. Closes
|
// 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 {
|
func (r *debridRangeReader) reopen() error {
|
||||||
if r.body != nil {
|
if r.body != nil {
|
||||||
_ = r.body.Close()
|
_ = r.body.Close()
|
||||||
r.body = nil
|
r.body = nil
|
||||||
}
|
}
|
||||||
req, err := http.NewRequestWithContext(r.ctx, http.MethodGet, r.url, nil)
|
// 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 {
|
if err != nil {
|
||||||
return fmt.Errorf("debrid reader: build request: %w", err)
|
return fmt.Errorf("debrid reader: build request: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -185,24 +268,35 @@ func (r *debridRangeReader) reopen() error {
|
||||||
}
|
}
|
||||||
switch resp.StatusCode {
|
switch resp.StatusCode {
|
||||||
case http.StatusPartialContent:
|
case http.StatusPartialContent:
|
||||||
// Expected.
|
r.body = resp.Body
|
||||||
|
r.bodyAt = r.pos
|
||||||
|
return nil
|
||||||
case http.StatusOK:
|
case http.StatusOK:
|
||||||
// Server ignored Range and is sending the whole file from 0. Only valid
|
// Server ignored Range and is sending the whole file from 0. Only
|
||||||
// when we asked from 0; otherwise the bytes wouldn't line up with pos.
|
// valid when we asked from 0; otherwise bytes wouldn't line up.
|
||||||
if r.pos != 0 {
|
if r.pos != 0 {
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
return fmt.Errorf("debrid reader: server ignored Range at offset %d (got 200)", r.pos)
|
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:
|
case http.StatusRequestedRangeNotSatisfiable:
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
return io.EOF // seeked past end — treat as EOF, not a hard error
|
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:
|
default:
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
return fmt.Errorf("debrid reader: unexpected status %d %s", resp.StatusCode, resp.Status)
|
return fmt.Errorf("debrid reader: unexpected status %d %s", resp.StatusCode, resp.Status)
|
||||||
}
|
}
|
||||||
r.body = resp.Body
|
}
|
||||||
r.bodyAt = r.pos
|
return fmt.Errorf("debrid reader: link still failing after %d refresh attempts", maxAttempts)
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// debridHeadSize issues a HEAD and returns the Content-Length when present.
|
// debridHeadSize issues a HEAD and returns the Content-Length when present.
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,12 @@ package engine
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
@ -39,7 +42,7 @@ func TestDebridProviderHeadSize(t *testing.T) {
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
// HEAD reports the real size; fallback is ignored when HEAD succeeds.
|
// HEAD reports the real size; fallback is ignored when HEAD succeeds.
|
||||||
p, err := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 999)
|
p, err := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 999, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewDebridFileProvider: %v", err)
|
t.Fatalf("NewDebridFileProvider: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -60,7 +63,7 @@ func TestDebridProviderNameFromURLWhenNoExtension(t *testing.T) {
|
||||||
http.ServeContent(w, r, "x", time.Time{}, bytes.NewReader(data))
|
http.ServeContent(w, r, "x", time.Time{}, bytes.NewReader(data))
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
p, err := NewDebridFileProvider(context.Background(), srv.URL+"/Movie.2026.1080p.mp4?token=abc", "Project Hail Mary (2026) 1080p web", 0)
|
p, err := NewDebridFileProvider(context.Background(), srv.URL+"/Movie.2026.1080p.mp4?token=abc", "Project Hail Mary (2026) 1080p web", 0, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("provider: %v", err)
|
t.Fatalf("provider: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -68,7 +71,7 @@ func TestDebridProviderNameFromURLWhenNoExtension(t *testing.T) {
|
||||||
t.Fatalf("FileName = %q, want Movie.2026.1080p.mp4 (derived from URL)", got)
|
t.Fatalf("FileName = %q, want Movie.2026.1080p.mp4 (derived from URL)", got)
|
||||||
}
|
}
|
||||||
// A passed name WITH an extension is kept as-is.
|
// A passed name WITH an extension is kept as-is.
|
||||||
p2, _ := NewDebridFileProvider(context.Background(), srv.URL+"/whatever.mp4", "Nice Title.mp4", 0)
|
p2, _ := NewDebridFileProvider(context.Background(), srv.URL+"/whatever.mp4", "Nice Title.mp4", 0, nil)
|
||||||
if got := p2.FileName(); got != "Nice Title.mp4" {
|
if got := p2.FileName(); got != "Nice Title.mp4" {
|
||||||
t.Fatalf("FileName = %q, want Nice Title.mp4 (kept)", got)
|
t.Fatalf("FileName = %q, want Nice Title.mp4 (kept)", got)
|
||||||
}
|
}
|
||||||
|
|
@ -87,7 +90,7 @@ func TestDebridProviderFallbackSizeWhenNoHead(t *testing.T) {
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
p, err := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", int64(len(data)))
|
p, err := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", int64(len(data)), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("NewDebridFileProvider: %v", err)
|
t.Fatalf("NewDebridFileProvider: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -103,10 +106,10 @@ func TestDebridProviderNoSizeFails(t *testing.T) {
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
// No HEAD size and fallback 0 → must error (ServeContent can't range-serve
|
// No HEAD size and fallback 0 → must error (ServeContent can't range-serve
|
||||||
// size 0 without handing the browser an empty file).
|
// size 0 without handing the browser an empty file).
|
||||||
if _, err := NewDebridFileProvider(context.Background(), srv.URL, "", 0); err == nil {
|
if _, err := NewDebridFileProvider(context.Background(), srv.URL, "", 0, nil); err == nil {
|
||||||
t.Fatal("expected error when size is unknown, got nil")
|
t.Fatal("expected error when size is unknown, got nil")
|
||||||
}
|
}
|
||||||
if _, err := NewDebridFileProvider(context.Background(), "", "movie.mp4", 100); err == nil {
|
if _, err := NewDebridFileProvider(context.Background(), "", "movie.mp4", 100, nil); err == nil {
|
||||||
t.Fatal("expected error for empty URL, got nil")
|
t.Fatal("expected error for empty URL, got nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -116,7 +119,7 @@ func TestDebridReaderSequentialRead(t *testing.T) {
|
||||||
srv, gets := rangeServer(data)
|
srv, gets := rangeServer(data)
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
p, err := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 0)
|
p, err := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 0, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("provider: %v", err)
|
t.Fatalf("provider: %v", err)
|
||||||
}
|
}
|
||||||
|
|
@ -140,7 +143,7 @@ func TestDebridReaderSeekEndReportsSize(t *testing.T) {
|
||||||
data := makeData(5000)
|
data := makeData(5000)
|
||||||
srv, _ := rangeServer(data)
|
srv, _ := rangeServer(data)
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
p, _ := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 0)
|
p, _ := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 0, nil)
|
||||||
rd := p.NewFileReader(context.Background())
|
rd := p.NewFileReader(context.Background())
|
||||||
defer rd.Close()
|
defer rd.Close()
|
||||||
|
|
||||||
|
|
@ -159,7 +162,7 @@ func TestDebridReaderSeekThenRead(t *testing.T) {
|
||||||
data := makeData(50_000)
|
data := makeData(50_000)
|
||||||
srv, gets := rangeServer(data)
|
srv, gets := rangeServer(data)
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
p, _ := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 0)
|
p, _ := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 0, nil)
|
||||||
rd := p.NewFileReader(context.Background())
|
rd := p.NewFileReader(context.Background())
|
||||||
defer rd.Close()
|
defer rd.Close()
|
||||||
|
|
||||||
|
|
@ -187,7 +190,7 @@ func TestDebridReaderServeContentRoundTrip(t *testing.T) {
|
||||||
data := makeData(80_000)
|
data := makeData(80_000)
|
||||||
srv, _ := rangeServer(data)
|
srv, _ := rangeServer(data)
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
p, _ := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 0)
|
p, _ := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 0, nil)
|
||||||
|
|
||||||
front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
rd := p.NewFileReader(r.Context())
|
rd := p.NewFileReader(r.Context())
|
||||||
|
|
@ -217,7 +220,7 @@ func TestDebridReaderSeekPastEnd(t *testing.T) {
|
||||||
data := makeData(1000)
|
data := makeData(1000)
|
||||||
srv, _ := rangeServer(data)
|
srv, _ := rangeServer(data)
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
p, _ := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 0)
|
p, _ := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 0, nil)
|
||||||
rd := p.NewFileReader(context.Background())
|
rd := p.NewFileReader(context.Background())
|
||||||
defer rd.Close()
|
defer rd.Close()
|
||||||
|
|
||||||
|
|
@ -231,6 +234,113 @@ func TestDebridReaderSeekPastEnd(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
func TestDebridReaderRejectsServerIgnoringRange(t *testing.T) {
|
||||||
// A server that always returns 200 (ignores Range) is only safe at pos 0.
|
// 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
|
// A reopen at a non-zero offset (after a seek) must error rather than serve
|
||||||
|
|
@ -242,7 +352,7 @@ func TestDebridReaderRejectsServerIgnoringRange(t *testing.T) {
|
||||||
w.Write(data)
|
w.Write(data)
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
p, err := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", int64(len(data)))
|
p, err := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", int64(len(data)), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("provider: %v", err)
|
t.Fatalf("provider: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue