diff --git a/Docs/plans/unarr-agent-roadmap.md b/Docs/plans/unarr-agent-roadmap.md index ee00183..fc5686b 100644 --- a/Docs/plans/unarr-agent-roadmap.md +++ b/Docs/plans/unarr-agent-roadmap.md @@ -43,7 +43,7 @@ Sólido salvo nota: funnel/UPnP el stream queda público en internet. Plan previo `Docs/plans/security-stream-token.md` (deferido, sin código). -### Hueco #2 — Debrid en el path de streaming 🔵 DISEÑADO (ver estado abajo) +### Hueco #2 — Debrid en el path de streaming 🟡 2a CERRADO (2026-05-31); 2b/2c pendientes Hoy debrid es **solo descarga**, resuelto server-side; el streaming es 100% torrent. La promesa "play instantáneo cache-fast" no ocurre. Falta: source debrid en el path de streaming + cache-availability + **fallback torrent↔debrid mid-stream**. @@ -149,7 +149,35 @@ WEB (`torrentclaw-web`): --- ### Hueco #2 — Debrid en el path de streaming -**Estado:** 🔵 DISEÑADO (2026-05-31), listo para implementar en sesión fresca. +**Estado:** 🟡 Fase 2a CERRADA (2026-05-31). 2b (HLS-desde-URL) + 2c (cache-fast ++ fallback mid-stream) pendientes. + +**CERRADO 2a (2026-05-31):** debrid como fuente de `/stream` (direct-play), +validado e2e contra AllDebrid real (cuenta hello@torrentclaw.com): play de un +infoHash cacheado mp4 → web resuelve la DirectURL → agente sirve `/stream` por +GETs ranged → Chrome reproduce el mp4 1080p real (incluido seek a offset alto +para el moov de un fichero sin faststart). CLI bump 0.10.0→0.11.0 (binario local, +sin publicar). Fichero clave: `internal/engine/stream_source_debrid.go`. +- CLI: `StreamSession.DirectURL`; `debridFileProvider` (`io.ReadSeekCloser` sobre + HTTP Range, Seek sin red + GET lazy + reopen-on-seek + HEAD para tamaño + + nombre derivado de URL para Content-Type correcto); branch en + `daemon.OnStreamSession` (DirectURL presente → provider en goroutine → + SetFile → MarkSessionReady), antes de validar filePath y sin ffmpeg. +- WEB: columna `streaming_session.direct_url` (mig 0137) + índice + `idx_debrid_cache_info_hash` (mig 0138, getHashCacheTier filtra por info_hash); + helper `resolveDebridStreamSource` (honesty gate: sin fichero local + infoHash + + agente ≥0.11.0 + `getHashCacheTier`==="verified" + container mp4/m4v + + audioIndex -1 + !forceTranscode → resuelve DirectURL, playMethod="direct", + quality "original"); gate de versión `DEBRID_STREAM_MIN_VERSION`/ + `supportsDebridStream`; `getPendingStreamSessions` emite `directUrl` + fallback + fileName/fileSize vía join a `torrent` (cubre el caso HEAD-falla del provider). +- Player: sin cambios — reusa el path direct-play del hueco #3 (playMethod=direct + + streamUrls). +- Limitación 2a (honesta): solo contenido debrid mp4/m4v browser-native; mkv/HEVC + debrid → fallback a torrent hasta 2b (HLS-desde-URL). Si AllDebrid no marca el + torrent "ready" al primer addMagnet → fallback a torrent (sin callejón). + +**Diseño original (2b/2c siguen vigentes):** **Problema (confirmado en el análisis):** hoy `debrid` es **solo descarga** (`engine/debrid.go` baja la `DirectURL` HTTPS resuelta server-side). El diff --git a/internal/agent/types.go b/internal/agent/types.go index 3a401f3..1874a1e 100644 --- a/internal/agent/types.go +++ b/internal/agent/types.go @@ -417,6 +417,15 @@ type StreamSession struct { // the raw file over /stream (HTTP Range, no ffmpeg) instead of // transcoding to HLS. See hueco #3 phase 3a in the roadmap. PlayMethod string `json:"playMethod,omitempty"` + // DirectURL, when set, is an HTTPS link to the media resolved server-side + // from the user's debrid account (hueco #2 / 2a). The source has no local + // file: the daemon streams /stream from this URL via ranged GETs + // (debridFileProvider) instead of from disk/torrent. Carries the "play + // instantáneo cache-fast" promise — the web only sets it when the hash is + // confirmed debrid-cached and the container is browser-native (mp4/m4v), + // and gates it on an agent-version floor so older daemons never receive a + // field they can't serve. Takes priority over FilePath when present. + DirectURL string `json:"directUrl,omitempty"` } // SyncResponse is returned by the server with all pending actions for the CLI. diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index 0c24d1c..61ade43 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -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)) diff --git a/internal/cmd/version.go b/internal/cmd/version.go index 8b4d388..3614aab 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 = "0.10.0" +var Version = "0.11.0" diff --git a/internal/engine/stream_source_debrid.go b/internal/engine/stream_source_debrid.go new file mode 100644 index 0000000..03b2c3a --- /dev/null +++ b/internal/engine/stream_source_debrid.go @@ -0,0 +1,243 @@ +// Package engine — stream_source_debrid.go implements a FileProvider that +// serves a /stream session straight from a debrid HTTPS direct URL (hueco #2 / +// 2a). No local file is involved: the browser's Range requests are translated +// into ranged GETs against the debrid link, so a cache-confirmed torrent plays +// instantly without ever hitting the swarm or touching disk. +// +// The web resolves the DirectURL server-side (resolveDebridDirectUrl) and only +// sends it when the hash is debrid-cached and the container is browser-native +// (mp4/m4v), so this provider stays a pure pass-through — same role as +// diskFileProvider/torrentFileProvider, just backed by HTTP Range instead of a +// file handle. http.ServeContent drives it exactly like a local file: it Seeks +// to discover size + the range start (no network), then Reads (lazy GET). +package engine + +import ( + "context" + "errors" + "fmt" + "io" + "log" + "net/http" + "path" + "strings" + "time" +) + +// debridHTTPClient is used for ranged debrid reads. Separate from the download +// httpClient so a slow streaming read can't starve a concurrent download's +// header-timeout budget, and vice versa. No overall timeout: a paused player +// can legitimately hold a body open for minutes; ResponseHeaderTimeout bounds +// the part that actually matters (a hung server before first byte). +var debridHTTPClient = &http.Client{ + Transport: &http.Transport{ + ResponseHeaderTimeout: 30 * time.Second, + // debrid CDNs are remote; a generous idle-conn pool avoids a fresh TLS + // handshake on every seek-driven reopen. + MaxIdleConns: 4, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 15 * time.Second, + }, +} + +// NewDebridFileProvider builds a FileProvider backed by a debrid HTTPS URL. +// It performs a single HEAD up front to learn the exact file size (the torrent +// size the web knows can differ from the resolved file's size). If the HEAD +// fails or omits Content-Length, fallbackSize (from the StreamSession) is used. +// 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 +// hand the browser an empty file. +func NewDebridFileProvider(ctx context.Context, directURL, fileName string, fallbackSize int64) (FileProvider, error) { + if directURL == "" { + return nil, errors.New("debrid provider: empty direct URL") + } + size := fallbackSize + if headSize, ok := debridHeadSize(ctx, directURL); ok { + size = headSize + } + if size <= 0 { + return nil, fmt.Errorf("debrid provider: unknown file size (HEAD gave nothing, no fallback)") + } + // The name drives the served Content-Type (mimeTypeFromExt on FileName). + // The web may pass a torrent title with no extension (its file-name + // fallback), which would yield application/octet-stream and break