feat(stream): serve /stream from a debrid HTTPS link (hueco #2/2a)

The daemon can now stream a session straight from a server-resolved debrid
direct URL instead of disk/torrent, delivering the "play instantáneo
cache-fast" promise for cache-confirmed torrents the user never downloaded.

- debridFileProvider: an io.ReadSeekCloser over HTTP Range — network-free
  Seek, lazy GET on Read, reopen-on-seek, a HEAD up front for the size, and
  a URL-derived name so the served Content-Type is video/mp4 (not
  octet-stream) when the web's name lacks an extension.
- OnStreamSession branches on StreamSession.DirectURL before the filePath
  checks (no local path, no ffmpeg), builds the provider in a goroutine
  (HEAD off the sync loop) and marks the session ready.
- Bump 0.10.0 -> 0.11.0 as the debrid-stream floor the web gates on.

Validated e2e against a real AllDebrid account: a cached mp4 plays 1080p in
Chrome through the agent, including the high-offset seek for a non-faststart
file's moov atom. 2b (HLS-from-URL for mkv/HEVC) + 2c (cache-fast preference
+ mid-stream fallback) remain.
This commit is contained in:
Deivid Soto 2026-05-31 15:49:58 +02:00
parent 292d5923cf
commit b8d2b90370
6 changed files with 573 additions and 3 deletions

View file

@ -566,6 +566,38 @@ func runDaemonStart() error {
if playerSessionRegistry.has(sess.SessionID) {
return // already running
}
// Debrid direct-play (hueco #2 / 2a): the source has no local file — the
// web resolved an HTTPS debrid link (cache-confirmed, browser-native
// container) and the daemon streams /stream from it via ranged GETs.
// Runs BEFORE the filePath checks (there is no local path) and needs no
// ffmpeg. Provider setup does a HEAD, so hand it off to a goroutine to
// keep the sync loop from blocking other pending actions; register the
// session up front so a duplicate sync within the setup window is a
// no-op (matches the HLS branch's handoff rationale).
if sess.DirectURL != "" {
playerSessionRegistry.add(sess.SessionID, func() { streamSrv.ClearFile() })
go func() {
bctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
provider, perr := engine.NewDebridFileProvider(bctx, sess.DirectURL, sess.FileName, sess.FileSize)
if perr != nil {
playerSessionRegistry.remove(sess.SessionID)
log.Printf("[stream %s] debrid provider failed: %v", agent.ShortID(sess.SessionID), perr)
return
}
streamSrv.SetFile(provider, sess.TaskID)
log.Printf("[stream %s] debrid direct-play: %s (%d bytes)",
agent.ShortID(sess.SessionID), provider.FileName(), provider.FileSize())
rctx, rcancel := context.WithTimeout(ctx, 10*time.Second)
defer rcancel()
if err := agentClient.MarkSessionReady(rctx, sess.SessionID); err != nil {
log.Printf("[stream %s] mark-ready failed: %v", agent.ShortID(sess.SessionID), err)
}
}()
return
}
filePath := sess.FilePath
if filePath == "" {
log.Printf("[hls %s] rejected: empty file path", agent.ShortID(sess.SessionID))

View file

@ -1,4 +1,4 @@
package cmd
// Version is the CLI version. Overridden by goreleaser ldflags at release time.
var Version = "0.10.0"
var Version = "0.11.0"