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:
parent
292d5923cf
commit
b8d2b90370
6 changed files with 573 additions and 3 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
243
internal/engine/stream_source_debrid.go
Normal file
243
internal/engine/stream_source_debrid.go
Normal file
|
|
@ -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 <video>
|
||||
// on strict clients (Safari). The debrid URL reliably ends in the real
|
||||
// file name *with* its extension, so derive from it whenever the passed
|
||||
// name lacks one.
|
||||
name := fileName
|
||||
if name == "" || path.Ext(name) == "" {
|
||||
name = debridNameFromURL(directURL)
|
||||
}
|
||||
return &debridFileProvider{
|
||||
url: directURL,
|
||||
name: name,
|
||||
size: size,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// debridFileProvider serves a file from a debrid HTTPS URL via ranged GETs.
|
||||
type debridFileProvider struct {
|
||||
url string
|
||||
name string
|
||||
size int64
|
||||
}
|
||||
|
||||
func (p *debridFileProvider) NewFileReader(ctx context.Context) io.ReadSeekCloser {
|
||||
return &debridRangeReader{
|
||||
ctx: ctx,
|
||||
url: p.url,
|
||||
size: p.size,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *debridFileProvider) FileName() string { return p.name }
|
||||
func (p *debridFileProvider) FileSize() int64 { return p.size }
|
||||
|
||||
// debridRangeReader is an io.ReadSeekCloser over an HTTP resource that supports
|
||||
// Range. Seek is network-free: it only moves the logical position. Read opens
|
||||
// (or reuses) a GET starting at the current position and streams the body; a
|
||||
// Seek that moves away from the open body's cursor forces a reopen on the next
|
||||
// Read. This matches how http.ServeContent works — Seek(0, SeekEnd) for size,
|
||||
// Seek to the range start, then sequential Reads — so seeks the user makes in
|
||||
// the player become a single reopened GET, never a full re-download.
|
||||
type debridRangeReader struct {
|
||||
ctx context.Context
|
||||
url string
|
||||
size int64
|
||||
|
||||
pos int64 // logical position (moved by Seek, advanced by Read)
|
||||
body io.ReadCloser // current open response body, or nil
|
||||
bodyAt int64 // position the open body's next byte maps to
|
||||
}
|
||||
|
||||
func (r *debridRangeReader) Read(p []byte) (int, error) {
|
||||
if r.size > 0 && r.pos >= r.size {
|
||||
return 0, io.EOF
|
||||
}
|
||||
// (Re)open when no body is held or a Seek moved us off the open body.
|
||||
if r.body == nil || r.pos != r.bodyAt {
|
||||
if err := r.reopen(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
n, err := r.body.Read(p)
|
||||
r.pos += int64(n)
|
||||
r.bodyAt = r.pos
|
||||
if err == io.EOF {
|
||||
// Body drained. Drop it so the next Read reopens (covers a server that
|
||||
// closed the connection before the logical EOF). Surface EOF to the
|
||||
// caller only when we've actually reached end-of-file; otherwise hand
|
||||
// back the bytes read with no error and let the caller Read again.
|
||||
_ = r.body.Close()
|
||||
r.body = nil
|
||||
if r.size > 0 && r.pos < r.size {
|
||||
return n, nil
|
||||
}
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (r *debridRangeReader) Seek(offset int64, whence int) (int64, error) {
|
||||
var abs int64
|
||||
switch whence {
|
||||
case io.SeekStart:
|
||||
abs = offset
|
||||
case io.SeekCurrent:
|
||||
abs = r.pos + offset
|
||||
case io.SeekEnd:
|
||||
abs = r.size + offset
|
||||
default:
|
||||
return 0, fmt.Errorf("debrid reader: invalid whence %d", whence)
|
||||
}
|
||||
if abs < 0 {
|
||||
return 0, errors.New("debrid reader: negative position")
|
||||
}
|
||||
r.pos = abs
|
||||
return abs, nil
|
||||
}
|
||||
|
||||
func (r *debridRangeReader) Close() error {
|
||||
if r.body != nil {
|
||||
err := r.body.Close()
|
||||
r.body = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reopen issues a fresh ranged GET from the current logical position. Closes
|
||||
// any previously held body first.
|
||||
func (r *debridRangeReader) reopen() error {
|
||||
if r.body != nil {
|
||||
_ = r.body.Close()
|
||||
r.body = nil
|
||||
}
|
||||
req, err := http.NewRequestWithContext(r.ctx, http.MethodGet, r.url, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("debrid reader: build request: %w", err)
|
||||
}
|
||||
// Always send a Range so a seek to 0 still gets a 206 (and so partial
|
||||
// reopens after a mid-file seek work). An open-ended range runs to EOF.
|
||||
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", r.pos))
|
||||
resp, err := debridHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("debrid reader: GET: %w", err)
|
||||
}
|
||||
switch resp.StatusCode {
|
||||
case http.StatusPartialContent:
|
||||
// Expected.
|
||||
case http.StatusOK:
|
||||
// Server ignored Range and is sending the whole file from 0. Only valid
|
||||
// when we asked from 0; otherwise the bytes wouldn't line up with pos.
|
||||
if r.pos != 0 {
|
||||
resp.Body.Close()
|
||||
return fmt.Errorf("debrid reader: server ignored Range at offset %d (got 200)", r.pos)
|
||||
}
|
||||
case http.StatusRequestedRangeNotSatisfiable:
|
||||
resp.Body.Close()
|
||||
return io.EOF // seeked past end — treat as EOF, not a hard error
|
||||
default:
|
||||
resp.Body.Close()
|
||||
return fmt.Errorf("debrid reader: unexpected status %d %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
r.body = resp.Body
|
||||
r.bodyAt = r.pos
|
||||
return nil
|
||||
}
|
||||
|
||||
// debridHeadSize issues a HEAD and returns the Content-Length when present.
|
||||
// Best-effort: any failure returns (0, false) so the caller falls back to the
|
||||
// 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)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(hctx, http.MethodHead, url, nil)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
resp, err := debridHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("[stream] debrid HEAD failed (using fallback size): %v", err)
|
||||
return 0, false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK || resp.ContentLength <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
return resp.ContentLength, true
|
||||
}
|
||||
|
||||
// debridNameFromURL extracts a filename from a URL path as a last resort when
|
||||
// the server didn't send one. Strips query/fragment via path.Base on the path.
|
||||
func debridNameFromURL(rawURL string) string {
|
||||
u := rawURL
|
||||
if i := strings.IndexAny(u, "?#"); i >= 0 {
|
||||
u = u[:i]
|
||||
}
|
||||
base := path.Base(u)
|
||||
if base == "" || base == "." || base == "/" {
|
||||
return "video.mp4"
|
||||
}
|
||||
return base
|
||||
}
|
||||
258
internal/engine/stream_source_debrid_test.go
Normal file
258
internal/engine/stream_source_debrid_test.go
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rangeServer serves a fixed byte slice with full HTTP Range support via
|
||||
// http.ServeContent (the same machinery a real debrid CDN exposes). Records
|
||||
// the number of GETs so a test can assert that a seek triggers exactly one
|
||||
// reopen rather than a full re-download.
|
||||
func rangeServer(data []byte) (*httptest.Server, *int) {
|
||||
gets := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
gets++
|
||||
}
|
||||
http.ServeContent(w, r, "movie.mp4", time.Time{}, bytes.NewReader(data))
|
||||
}))
|
||||
return srv, &gets
|
||||
}
|
||||
|
||||
func makeData(n int) []byte {
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = byte(i % 251) // non-trivial, deterministic pattern
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestDebridProviderHeadSize(t *testing.T) {
|
||||
data := makeData(4096)
|
||||
srv, _ := rangeServer(data)
|
||||
defer srv.Close()
|
||||
|
||||
// HEAD reports the real size; fallback is ignored when HEAD succeeds.
|
||||
p, err := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 999)
|
||||
if err != nil {
|
||||
t.Fatalf("NewDebridFileProvider: %v", err)
|
||||
}
|
||||
if got := p.FileSize(); got != int64(len(data)) {
|
||||
t.Fatalf("FileSize from HEAD = %d, want %d", got, len(data))
|
||||
}
|
||||
if p.FileName() != "movie.mp4" {
|
||||
t.Fatalf("FileName = %q, want movie.mp4", p.FileName())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebridProviderNameFromURLWhenNoExtension(t *testing.T) {
|
||||
// The web may pass a torrent title with no extension (its file-name
|
||||
// fallback). The provider must derive the name from the URL so the served
|
||||
// Content-Type is video/mp4, not application/octet-stream.
|
||||
data := makeData(1024)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeContent(w, r, "x", time.Time{}, bytes.NewReader(data))
|
||||
}))
|
||||
defer srv.Close()
|
||||
p, err := NewDebridFileProvider(context.Background(), srv.URL+"/Movie.2026.1080p.mp4?token=abc", "Project Hail Mary (2026) 1080p web", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("provider: %v", err)
|
||||
}
|
||||
if got := p.FileName(); got != "Movie.2026.1080p.mp4" {
|
||||
t.Fatalf("FileName = %q, want Movie.2026.1080p.mp4 (derived from URL)", got)
|
||||
}
|
||||
// A passed name WITH an extension is kept as-is.
|
||||
p2, _ := NewDebridFileProvider(context.Background(), srv.URL+"/whatever.mp4", "Nice Title.mp4", 0)
|
||||
if got := p2.FileName(); got != "Nice Title.mp4" {
|
||||
t.Fatalf("FileName = %q, want Nice Title.mp4 (kept)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebridProviderFallbackSizeWhenNoHead(t *testing.T) {
|
||||
// Server that refuses HEAD (405) but serves GET — provider must fall back
|
||||
// to the size the web reported.
|
||||
data := makeData(2048)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodHead {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
http.ServeContent(w, r, "movie.mp4", time.Time{}, bytes.NewReader(data))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p, err := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", int64(len(data)))
|
||||
if err != nil {
|
||||
t.Fatalf("NewDebridFileProvider: %v", err)
|
||||
}
|
||||
if got := p.FileSize(); got != int64(len(data)) {
|
||||
t.Fatalf("FileSize fallback = %d, want %d", got, len(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebridProviderNoSizeFails(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed) // no HEAD, no usable GET
|
||||
}))
|
||||
defer srv.Close()
|
||||
// No HEAD size and fallback 0 → must error (ServeContent can't range-serve
|
||||
// size 0 without handing the browser an empty file).
|
||||
if _, err := NewDebridFileProvider(context.Background(), srv.URL, "", 0); err == nil {
|
||||
t.Fatal("expected error when size is unknown, got nil")
|
||||
}
|
||||
if _, err := NewDebridFileProvider(context.Background(), "", "movie.mp4", 100); err == nil {
|
||||
t.Fatal("expected error for empty URL, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebridReaderSequentialRead(t *testing.T) {
|
||||
data := makeData(100_000)
|
||||
srv, gets := rangeServer(data)
|
||||
defer srv.Close()
|
||||
|
||||
p, err := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("provider: %v", err)
|
||||
}
|
||||
rd := p.NewFileReader(context.Background())
|
||||
defer rd.Close()
|
||||
|
||||
got, err := io.ReadAll(rd)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, data) {
|
||||
t.Fatalf("sequential read mismatch: got %d bytes, want %d", len(got), len(data))
|
||||
}
|
||||
// A pure sequential read holds a single body to EOF → exactly one GET.
|
||||
if *gets != 1 {
|
||||
t.Fatalf("sequential read issued %d GETs, want 1", *gets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebridReaderSeekEndReportsSize(t *testing.T) {
|
||||
data := makeData(5000)
|
||||
srv, _ := rangeServer(data)
|
||||
defer srv.Close()
|
||||
p, _ := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 0)
|
||||
rd := p.NewFileReader(context.Background())
|
||||
defer rd.Close()
|
||||
|
||||
// http.ServeContent calls Seek(0, SeekEnd) to learn the size — must be
|
||||
// network-free and return the total.
|
||||
size, err := rd.Seek(0, io.SeekEnd)
|
||||
if err != nil {
|
||||
t.Fatalf("Seek end: %v", err)
|
||||
}
|
||||
if size != int64(len(data)) {
|
||||
t.Fatalf("SeekEnd = %d, want %d", size, len(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebridReaderSeekThenRead(t *testing.T) {
|
||||
data := makeData(50_000)
|
||||
srv, gets := rangeServer(data)
|
||||
defer srv.Close()
|
||||
p, _ := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 0)
|
||||
rd := p.NewFileReader(context.Background())
|
||||
defer rd.Close()
|
||||
|
||||
const off = 12_345
|
||||
if _, err := rd.Seek(off, io.SeekStart); err != nil {
|
||||
t.Fatalf("seek: %v", err)
|
||||
}
|
||||
got, err := io.ReadAll(rd)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadAll after seek: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, data[off:]) {
|
||||
t.Fatalf("tail mismatch: got %d bytes, want %d", len(got), len(data)-off)
|
||||
}
|
||||
// Seek is network-free; the read after it is the only GET.
|
||||
if *gets != 1 {
|
||||
t.Fatalf("seek+read issued %d GETs, want 1", *gets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebridReaderServeContentRoundTrip(t *testing.T) {
|
||||
// Drive the reader exactly like StreamServer does: hand it to
|
||||
// http.ServeContent and issue a ranged request. Verifies the reader is a
|
||||
// correct io.ReadSeeker for the production serving path.
|
||||
data := makeData(80_000)
|
||||
srv, _ := rangeServer(data)
|
||||
defer srv.Close()
|
||||
p, _ := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 0)
|
||||
|
||||
front := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
rd := p.NewFileReader(r.Context())
|
||||
defer rd.Close()
|
||||
http.ServeContent(w, r, p.FileName(), time.Time{}, rd)
|
||||
}))
|
||||
defer front.Close()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, front.URL, nil)
|
||||
req.Header.Set("Range", "bytes=10000-19999")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("ranged GET: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusPartialContent {
|
||||
t.Fatalf("status = %d, want 206", resp.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
want := data[10000:20000]
|
||||
if !bytes.Equal(body, want) {
|
||||
t.Fatalf("ranged body mismatch: got %d bytes", len(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebridReaderSeekPastEnd(t *testing.T) {
|
||||
data := makeData(1000)
|
||||
srv, _ := rangeServer(data)
|
||||
defer srv.Close()
|
||||
p, _ := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", 0)
|
||||
rd := p.NewFileReader(context.Background())
|
||||
defer rd.Close()
|
||||
|
||||
// Seeking at/past size then reading yields EOF, no error, no bytes.
|
||||
if _, err := rd.Seek(int64(len(data)), io.SeekStart); err != nil {
|
||||
t.Fatalf("seek: %v", err)
|
||||
}
|
||||
n, err := rd.Read(make([]byte, 16))
|
||||
if n != 0 || err != io.EOF {
|
||||
t.Fatalf("read past end = (%d, %v), want (0, EOF)", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebridReaderRejectsServerIgnoringRange(t *testing.T) {
|
||||
// A server that always returns 200 (ignores Range) is only safe at pos 0.
|
||||
// A reopen at a non-zero offset (after a seek) must error rather than serve
|
||||
// misaligned bytes.
|
||||
data := makeData(4000)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Length", "4000")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(data)
|
||||
}))
|
||||
defer srv.Close()
|
||||
p, err := NewDebridFileProvider(context.Background(), srv.URL, "movie.mp4", int64(len(data)))
|
||||
if err != nil {
|
||||
t.Fatalf("provider: %v", err)
|
||||
}
|
||||
rd := p.NewFileReader(context.Background())
|
||||
defer rd.Close()
|
||||
|
||||
if _, err := rd.Seek(1000, io.SeekStart); err != nil {
|
||||
t.Fatalf("seek: %v", err)
|
||||
}
|
||||
if _, err := rd.Read(make([]byte, 16)); err == nil {
|
||||
t.Fatal("expected error when server ignores Range at non-zero offset, got nil")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue