diff --git a/CHANGELOG.md b/CHANGELOG.md index 04230a3..affe5aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/), 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 diff --git a/internal/cmd/stream_handler.go b/internal/cmd/stream_handler.go index 8765041..4bf2ada 100644 --- a/internal/cmd/stream_handler.go +++ b/internal/cmd/stream_handler.go @@ -91,6 +91,13 @@ func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine ctx, cancel := context.WithCancel(parentCtx) 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 streamRegistry.mu.Lock() streamRegistry.cancels[at.ID] = cancel @@ -114,6 +121,47 @@ func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine reporter.Track(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 eng, err := engine.NewStreamEngine(engine.StreamConfig{ DataDir: cfg.Download.Dir, diff --git a/internal/cmd/version.go b/internal/cmd/version.go index 6e3a4e6..559b492 100644 --- a/internal/cmd/version.go +++ b/internal/cmd/version.go @@ -1,4 +1,4 @@ package cmd // Version is the CLI version. Overridden by goreleaser ldflags at release time. -var Version = "1.0.1-beta" +var Version = "1.0.2-beta" diff --git a/internal/engine/stream_source_debrid.go b/internal/engine/stream_source_debrid.go index ec74529..79c34e7 100644 --- a/internal/engine/stream_source_debrid.go +++ b/internal/engine/stream_source_debrid.go @@ -304,7 +304,10 @@ func (r *debridRangeReader) reopen() error { // 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. 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() req, err := http.NewRequestWithContext(hctx, http.MethodHead, url, nil) if err != nil {