unarr/internal/engine/stream_server_test.go
Deivid Soto f1b4f2e327 fix(stream): fix black screen on remote/Tailscale streaming
Three root-cause fixes for VLC showing a black screen when opening a
stream from a different network or via Tailscale:

1. PrioritizeTail: when VLC opens an MKV/MP4 stream it immediately seeks
   to the end of the file to read the container index (seekhead/moov
   atom). For active torrents those end-pieces aren't downloaded yet, so
   the reader blocks indefinitely. PrioritizeTail() opens a background
   reader positioned at the last 5 MB, keeping those pieces at high
   priority until ctx is cancelled or they finish downloading.

2. /health endpoint: GET /health returns a lightweight JSON response
   {"status":"ok","streaming":bool,...} so connectivity can be tested
   with a simple curl from any device before involving VLC.

3. Per-request logging: every incoming /stream request now logs the
   client IP and Range header, making it trivial to confirm whether
   remote/Tailscale clients are reaching the server at all.
2026-04-09 16:15:41 +02:00

406 lines
11 KiB
Go

package engine
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"sync"
"testing"
"time"
)
// readSeekNopCloser envuelve un strings.Reader como ReadSeekCloser.
type readSeekNopCloser struct {
*strings.Reader
}
func (r *readSeekNopCloser) Close() error { return nil }
func newFakeProvider(name string, content []byte) FileProvider {
return &fakeFileProviderSeekable{name: name, content: content}
}
// fakeFileProviderSeekable implementa FileProvider con un reader buscable.
type fakeFileProviderSeekable struct {
name string
content []byte
}
func (f *fakeFileProviderSeekable) FileName() string { return f.name }
func (f *fakeFileProviderSeekable) FileSize() int64 { return int64(len(f.content)) }
func (f *fakeFileProviderSeekable) NewFileReader(_ context.Context) io.ReadSeekCloser {
return &readSeekNopCloser{strings.NewReader(string(f.content))}
}
// TestStreamServer_Listen_BindsPort verifica que Listen() enlaza a un puerto
// y URL() devuelve una URL accesible.
func TestStreamServer_Listen_BindsPort(t *testing.T) {
srv := NewStreamServer(0) // puerto aleatorio
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(context.Background())
url := srv.URL()
if url == "" {
t.Fatal("URL() returned empty string after Listen()")
}
if !strings.HasPrefix(url, "http://") {
t.Errorf("URL() = %q, want http:// prefix", url)
}
if srv.Port() == 0 {
t.Error("Port() should be non-zero after Listen()")
}
}
// TestStreamServer_Listen_RandomPort verifica que port=0 asigna un puerto disponible.
func TestStreamServer_Listen_RandomPort(t *testing.T) {
srv := NewStreamServer(0)
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(ctx)
port := srv.Port()
if port <= 0 || port > 65535 {
t.Errorf("Port() = %d, want valid port 1-65535", port)
}
}
// TestStreamServer_URL_Format verifica que la URL tiene el formato correcto
// con host y puerto.
func TestStreamServer_URL_Format(t *testing.T) {
srv := NewStreamServer(0)
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(ctx)
url := srv.URL()
port := srv.Port()
expectedSuffix := fmt.Sprintf(":%d/stream", port)
if !strings.Contains(url, expectedSuffix) {
t.Errorf("URL() = %q, want to contain %q", url, expectedSuffix)
}
}
// TestStreamServer_HasFile verifica que HasFile() refleja el estado correcto.
func TestStreamServer_HasFile(t *testing.T) {
srv := NewStreamServer(0)
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(ctx)
if srv.HasFile() {
t.Error("HasFile() = true before SetFile(), want false")
}
provider := newFakeProvider("test.mkv", []byte("fake video content"))
srv.SetFile(provider, "task-123")
if !srv.HasFile() {
t.Error("HasFile() = false after SetFile(), want true")
}
if srv.CurrentTaskID() != "task-123" {
t.Errorf("CurrentTaskID() = %q, want task-123", srv.CurrentTaskID())
}
}
// TestStreamServer_ClearFile verifica que ClearFile() elimina el provider actual.
func TestStreamServer_ClearFile(t *testing.T) {
srv := NewStreamServer(0)
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(ctx)
provider := newFakeProvider("video.mkv", []byte("content"))
srv.SetFile(provider, "task-xyz")
srv.ClearFile()
if srv.HasFile() {
t.Error("HasFile() = true after ClearFile(), want false")
}
if srv.CurrentTaskID() != "" {
t.Errorf("CurrentTaskID() = %q, want empty after ClearFile()", srv.CurrentTaskID())
}
}
// TestStreamServer_NoFile_Returns404 verifica que sin archivo configurado
// el servidor devuelve 404.
func TestStreamServer_NoFile_Returns404(t *testing.T) {
srv := NewStreamServer(0)
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(ctx)
resp, err := http.Get(srv.URL())
if err != nil {
t.Fatalf("GET %s: %v", srv.URL(), err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status = %d, want 404 when no file set", resp.StatusCode)
}
}
// TestStreamServer_WithFile_Returns200 verifica que con archivo configurado
// el servidor sirve el contenido correctamente.
func TestStreamServer_WithFile_Returns200(t *testing.T) {
content := []byte("fake video bytes for testing")
srv := NewStreamServer(0)
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(ctx)
provider := newFakeProvider("movie.mkv", content)
srv.SetFile(provider, "task-abc")
resp, err := http.Get(srv.URL())
if err != nil {
t.Fatalf("GET %s: %v", srv.URL(), err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
if len(body) == 0 {
t.Error("response body is empty, expected file content")
}
}
// TestStreamServer_Shutdown_ReleasesPort verifica que después de Shutdown()
// el servidor no sigue respondiendo.
func TestStreamServer_Shutdown_ReleasesPort(t *testing.T) {
srv := NewStreamServer(0)
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
url := srv.URL()
// Verificar que funciona antes de Shutdown
provider := newFakeProvider("test.mkv", []byte("data"))
srv.SetFile(provider, "t1")
resp, err := http.Get(url)
if err != nil {
t.Fatalf("GET before shutdown: %v", err)
}
resp.Body.Close()
// Shutdown
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
t.Errorf("Shutdown() error: %v", err)
}
// Después de shutdown, las conexiones deben fallar
client := &http.Client{Timeout: 500 * time.Millisecond}
if resp2, getErr := client.Get(url); getErr == nil {
resp2.Body.Close()
t.Error("expected error after Shutdown(), server should not be accessible")
}
}
// TestStreamServer_Concurrent verifica que múltiples requests concurrentes
// son manejados correctamente.
func TestStreamServer_Concurrent(t *testing.T) {
content := []byte("streaming content for concurrent access")
srv := NewStreamServer(0)
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(ctx)
provider := newFakeProvider("concurrent.mkv", content)
srv.SetFile(provider, "task-concurrent")
const numRequests = 5
var wg sync.WaitGroup
errors := make([]error, numRequests)
for i := 0; i < numRequests; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
resp, err := http.Get(srv.URL())
if err != nil {
errors[idx] = err
return
}
defer resp.Body.Close()
io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
errors[idx] = fmt.Errorf("request %d: status %d", idx, resp.StatusCode)
}
}(i)
}
wg.Wait()
for i, err := range errors {
if err != nil {
t.Errorf("concurrent request %d failed: %v", i, err)
}
}
}
// TestStreamServer_SetFile_SwapsProvider verifica que SetFile() reemplaza
// el provider anterior correctamente.
func TestStreamServer_SetFile_SwapsProvider(t *testing.T) {
srv := NewStreamServer(0)
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(ctx)
// Primer archivo
p1 := newFakeProvider("first.mkv", []byte("first content"))
srv.SetFile(p1, "task-1")
if srv.CurrentTaskID() != "task-1" {
t.Errorf("after first SetFile: taskID = %q, want task-1", srv.CurrentTaskID())
}
// Swap a segundo archivo
p2 := newFakeProvider("second.mkv", []byte("second content"))
srv.SetFile(p2, "task-2")
if srv.CurrentTaskID() != "task-2" {
t.Errorf("after second SetFile: taskID = %q, want task-2", srv.CurrentTaskID())
}
}
// TestStreamServer_Health_NoFile verifica que /health devuelve streaming:false
// cuando no hay archivo configurado.
func TestStreamServer_Health_NoFile(t *testing.T) {
srv := NewStreamServer(0)
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(ctx)
healthURL := fmt.Sprintf("http://127.0.0.1:%d/health", srv.Port())
resp, err := http.Get(healthURL)
if err != nil {
t.Fatalf("GET /health: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
ct := resp.Header.Get("Content-Type")
if !strings.Contains(ct, "application/json") {
t.Errorf("Content-Type = %q, want application/json", ct)
}
body, _ := io.ReadAll(resp.Body)
bodyStr := string(body)
if !strings.Contains(bodyStr, `"streaming":false`) {
t.Errorf("body = %q, want streaming:false", bodyStr)
}
if !strings.Contains(bodyStr, `"status":"ok"`) {
t.Errorf("body = %q, want status:ok", bodyStr)
}
}
// TestStreamServer_Health_WithFile verifica que /health devuelve streaming:true
// y el nombre del archivo cuando hay un archivo configurado.
func TestStreamServer_Health_WithFile(t *testing.T) {
srv := NewStreamServer(0)
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(ctx)
provider := newFakeProvider("pelicula.mkv", []byte("contenido de prueba"))
srv.SetFile(provider, "task-health-test")
healthURL := fmt.Sprintf("http://127.0.0.1:%d/health", srv.Port())
resp, err := http.Get(healthURL)
if err != nil {
t.Fatalf("GET /health: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
bodyStr := string(body)
if !strings.Contains(bodyStr, `"streaming":true`) {
t.Errorf("body = %q, want streaming:true", bodyStr)
}
if !strings.Contains(bodyStr, "pelicula.mkv") {
t.Errorf("body = %q, want file name pelicula.mkv", bodyStr)
}
if !strings.Contains(bodyStr, "task-hea") { // primeros 8 chars de "task-health-test"
t.Errorf("body = %q, want task short ID", bodyStr)
}
}
// TestStreamServer_MKV_ContentType verifica que el Content-Type para .mkv
// es el correcto.
func TestStreamServer_MKV_ContentType(t *testing.T) {
srv := NewStreamServer(0)
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(ctx)
provider := newFakeProvider("movie.mkv", []byte("mkv content"))
srv.SetFile(provider, "task-mkv")
resp, err := http.Get(srv.URL())
if err != nil {
t.Fatalf("GET: %v", err)
}
defer resp.Body.Close()
ct := resp.Header.Get("Content-Type")
if !strings.Contains(ct, "matroska") && !strings.Contains(ct, "mkv") {
t.Errorf("Content-Type = %q, want matroska/mkv MIME type", ct)
}
}