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

@ -598,10 +598,16 @@ func runDaemonStart() error {
// no-op (matches the HLS branch's handoff rationale).
if sess.DirectURL != "" && sess.PlayMethod != "hls" {
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() {
bctx, cancel := context.WithTimeout(ctx, 15*time.Second)
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 {
playerSessionRegistry.remove(sess.SessionID)
log.Printf("[stream %s] debrid provider failed: %v", agent.ShortID(sess.SessionID), perr)
@ -640,6 +646,11 @@ func runDaemonStart() error {
AudioIndex: sess.AudioIndex,
Transcode: tcRuntime,
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)
log.Printf("[hls %s] debrid HLS-from-URL: %s", agent.ShortID(sess.SessionID), sess.FileName)
return