feat(stream): debrid passthrough for mode=stream tasks (external players)

handleStreamTask now serves a mode=stream task FROM a resolved debrid HTTPS link
(when the web set preferredMethod=debrid + the torrent is cached) instead of
joining the P2P swarm — served over the SAME /stream endpoint so VLC and other
external players consume it identically (and far faster). No HLS transcode:
external players handle any container. Falls through to the P2P StreamEngine
when there is no direct URL. Uses the mutex-safe SetStreamURL setter.

Also widen the debrid HEAD size-probe timeout 10s -> 15s to match the transport's
TLS handshake budget, so a slow CDN no longer trips it and falls back to a
guessed size.

Bump 1.0.2-beta.
This commit is contained in:
Deivid Soto 2026-06-03 22:43:43 +02:00
parent 8e37293b7d
commit aba20e2078
4 changed files with 64 additions and 2 deletions

View file

@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.2-beta] - 2026-06-03
### Added
- **stream**: serve a `mode=stream` task from a debrid HTTPS link when the torrent is cached (debrid passthrough for external players / VLC), falling back to P2P stream-while-download when it isn't
### Changed
- **stream**: widen the debrid HEAD size-probe timeout to 15s to match the TLS handshake budget — a slow CDN no longer trips the old 10s and falls back to a guessed size
## [1.0.1-beta] - 2026-06-03 ## [1.0.1-beta] - 2026-06-03

View file

@ -91,6 +91,13 @@ func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine
ctx, cancel := context.WithCancel(parentCtx) ctx, cancel := context.WithCancel(parentCtx)
defer cancel() defer cancel()
// NOTE: we deliberately do NOT cancel prior stream goroutines here. The
// persistent StreamServer is last-writer-wins (SetFile replaces the file;
// the deferred ClearFile is guarded by CurrentTaskID), so a displaced prior
// goroutine simply parks on its own ctx until the 30m idle guard reaps it —
// cheap. Cancelling them at entry would abort an in-flight debrid HEAD of a
// concurrently-starting task (size resolution), failing that stream.
// Register for web-initiated cancellation // Register for web-initiated cancellation
streamRegistry.mu.Lock() streamRegistry.mu.Lock()
streamRegistry.cancels[at.ID] = cancel streamRegistry.cancels[at.ID] = cancel
@ -114,6 +121,47 @@ func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine
reporter.Track(task) reporter.Track(task)
defer reporter.ReportFinal(context.Background(), task) defer reporter.ReportFinal(context.Background(), task)
// Debrid passthrough: when the web resolved a direct HTTPS link (the torrent
// is cached on the user's debrid + preferredMethod=debrid), stream FROM that
// link instead of joining the P2P swarm — served over the SAME /stream
// endpoint, so VLC / external players consume it identically (and far
// faster). No HLS transcode here: external players handle any container.
// Falls through to the P2P StreamEngine below when there is no direct URL.
if at.DirectURL != "" {
task.ResolvedMethod = engine.MethodDebrid
task.Transition(engine.StatusResolving)
bctx, bcancel := context.WithTimeout(ctx, 15*time.Second)
// fallbackSize 0 → provider derives size from a HEAD; refresh nil → no
// task-level link-refresh endpoint exists (the web re-resolves stale
// debrid URLs at the next claim). A mid-stream expiry just ends the
// stream and the user re-opens it.
provider, perr := engine.NewDebridFileProvider(bctx, at.DirectURL, at.DirectFileName, 0, nil)
bcancel()
if perr != nil {
task.ErrorMessage = "debrid stream provider: " + perr.Error()
task.Transition(engine.StatusFailed)
return
}
srv.SetFile(provider, at.ID)
task.FileName = provider.FileName()
task.TotalBytes = provider.FileSize()
task.SetStreamURL(srv.URLsJSON()) // mutex-safe: the reporter reads it via GetStreamURL
log.Printf("[%s] stream (debrid): %s (%s) url: %s", at.ID[:8], provider.FileName(), ui.FormatBytes(provider.FileSize()), srv.URL())
if agentClient != nil {
watchReporter := engine.NewWatchReporter(agentClient, srv, at.ID)
go watchReporter.Run(ctx)
}
// Debrid serves a complete remote file — there is no download to track,
// so mark it complete immediately (the UI shows "ready"). The persistent
// server keeps serving until the idle guard reaps it (30m), same as P2P.
task.Transition(engine.StatusCompleted)
<-ctx.Done()
log.Printf("[%s] stream (debrid) stopped", at.ID[:8])
return
}
// 1. Create StreamEngine // 1. Create StreamEngine
eng, err := engine.NewStreamEngine(engine.StreamConfig{ eng, err := engine.NewStreamEngine(engine.StreamConfig{
DataDir: cfg.Download.Dir, DataDir: cfg.Download.Dir,

View file

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

View file

@ -304,7 +304,10 @@ func (r *debridRangeReader) reopen() error {
// size the web reported. A short timeout keeps a slow/HEAD-hostile CDN from // size the web reported. A short timeout keeps a slow/HEAD-hostile CDN from
// stalling session setup — the fallback size is good enough to start. // stalling session setup — the fallback size is good enough to start.
func debridHeadSize(ctx context.Context, url string) (int64, bool) { func debridHeadSize(ctx context.Context, url string) (int64, bool) {
hctx, cancel := context.WithTimeout(ctx, 10*time.Second) // 15s (not 10s): the transport's TLS handshake budget alone is 15s, so a
// slow debrid CDN could trip the old 10s timeout before headers arrived,
// needlessly falling back to a guessed size.
hctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel() defer cancel()
req, err := http.NewRequestWithContext(hctx, http.MethodHead, url, nil) req, err := http.NewRequestWithContext(hctx, http.MethodHead, url, nil)
if err != nil { if err != nil {