feat(stream): bitrate-sized readahead for play-while-download

The torrent reader used a static 5 MiB readahead — about 1.9s of a 20 Mbps 4K
stream — so streaming a torrent while it downloaded outran the download and
stalled. anacrolix's reader already prioritises the pieces in the readahead
window ahead of the playhead (and re-prioritises on seek); the window was just
too small. dynamicReadahead sizes it to ~30s of video (clamped 8-96 MiB, 24 MiB
default when bitrate is unknown). The torrent provider probes the bitrate
asynchronously so stream start never blocks on ffprobe; readers created after
the probe resolves pick up the accurate size. Real 4K (20.7 Mbps) -> 73 MiB.
This commit is contained in:
Deivid Soto 2026-05-31 23:23:39 +02:00
parent e4373454ba
commit 9c995fc4dd
6 changed files with 110 additions and 6 deletions

View file

@ -70,7 +70,7 @@ desde la web. Diseño + set de opciones en el estado abajo.
- ~~Sin gestión de espacio en disco (`Statfs`)~~**Pre-flight de espacio (2026-05-31)**`CheckDiskSpace` antes de cada descarga (torrent/usenet/debrid) con reserva configurable `downloads.min_free_disk_mb` (default 2048); manager NO hace fallback en disco lleno; aviso web 507 `INSUFFICIENT_DISK` al despachar (torrentclaw). Monitoreo mid-download diferido. Ver estado abajo.
- ~~Resume de torrent NO persiste reinicio del daemon~~**Auto-resume tras reinicio (2026-05-31)**`agent.ActiveTaskStore` persiste los `agent.Task` de descargas en vuelo (`active-tasks.json`); el daemon los re-somete al arrancar → los downloaders reanudan los bytes (torrent vía completion DB de anacrolix, debrid vía Range, usenet vía tracker). Dedup en `manager.Submit` (restore + re-despacho web no duplican). `shuttingDown` preserva el entry en apagado limpio (solo terminal genuino lo borra). Ver estado abajo.
- Sin seeding/ratio lifecycle (flags existen, nadie los aplica).
- Reproducir-mientras-baja: readahead estático 5MB, sin playhead→prioridad dinámica.
- ~~Reproducir-mientras-baja: readahead estático 5MB~~**Readahead dinámico (2026-05-31)**`dynamicReadahead(bitrate)` = ~30s de vídeo (clamp 896 MiB; default 24 MiB sin bitrate) en vez de 5 MiB fijos (~1.9s a 20 Mbps → se atascaba). anacrolix ya prioriza piezas en esa ventana por delante del playhead + en seek; solo faltaba dimensionarla. Bitrate probado async (sin coste TTFF). Ver estado abajo.
- ~~HDR→SDR sin tonemap~~**Tonemap HDR→SDR (2026-05-31)** — cadena `zscale+tonemap=hable` en el transcode HLS cuando la fuente es HDR y el ffmpeg trae zscale (detectado en runtime, `FFmpegSupportsZscale`; sin zscale → comportamiento actual, no rompe). Verificado en 4K HDR10 real: de lavado/desaturado a colores vívidos. Ver estado abajo.
- ~~Sin thumbnails~~**Fotogramas bajo demanda (2026-05-31)**`GET /thumbnail` (ffmpeg 1 frame, `-ss` antes de `-i`, MJPEG) + panel "Características del fichero" (ruta + mediainfo completa + tira de ~5 frames). Sprites/trickplay (scrubber pregenerado) siguen pendientes. Ver estado abajo.
- Subtítulos bitmap (PGS/DVB) sin burn-in.
@ -100,6 +100,12 @@ WireGuard endpoint sin pin · sesión única (1 viewer).
- ~~**Rutas localizadas unarr 404 (media)**~~**ARREGLADO (2026-05-31)**: bajo `NEXT_PUBLIC_BRAND=unarr` el allowlist `UNARR_PAGE_PREFIXES` (paths EN) no reconocía los localizados de next-intl (`/es/biblioteca`, `/es/descargas`, `/es/perfil`) → 404. Fix (web): `enFirstSegmentByLocalized` (mapa localizado→EN derivado de `routing.pathnames`) + `toCanonicalPath()` en `branding/routes.ts` traduce el 1er segmento antes del match. Assertion anti-colisión en el build del mapa (fail-fast si una ruta futura reusa un segmento → no puede colar una ruta denegada). Verificado: 175 entradas, cero crossover; denegadas siguen denegadas.
- **Thumbnails — sprites/trickplay (media)**: cerrado solo el camino bajo demanda (N frames en vivo). El scrubber pregenerado (sprite/BIF de toda la timeline, preview al pasar el ratón por la barra) queda como hueco propio: reaprovecharía `/thumbnail` + cacheo en disco del agente. Decidido alcance "solo bajo demanda" con el usuario (2026-05-31).
### Hueco medio — Readahead dinámico (ver-mientras-baja) ✅ CERRADO (2026-05-31)
El lector de torrent usaba un readahead **estático de 5 MiB** (~1.9s de un stream 4K de 20 Mbps) → al reproducir un torrent a medio bajar, la reproducción adelantaba a la descarga y se atascaba.
- `dynamicReadahead(bitrateBps)` (`readahead.go`): ~30s de vídeo, clamp [8, 96] MiB; default 24 MiB cuando el bitrate es desconocido (ya ~5× el viejo 5 MiB). anacrolix (`SetResponsive`+`SetReadahead`) ya prioriza las piezas de esa ventana por delante del read position y re-prioriza en seek — el feedback playhead→prioridad estaba; solo faltaba dimensionar la ventana.
- `torrentFileProvider` lleva `bitrateBps atomic.Int64`, sondeado **async** (`probeMediaInfo` en goroutine vía DataDir+DisplayPath) — sin coste de TTFF; hasta resolverse usa el default, y los readers posteriores (cada range/seek crea uno) cogen el valor preciso. StreamEngine (CLI) → default 24 MiB.
- **Smoke**: ffprobe en 4K real (20.7 Mbps) → readahead **73 MiB** (~28s) vs 5 MiB. Tests del func puro + -race limpio en el probe async. /critico: código sólido, fix aplicado (probe síncrono→async para eliminar 3s de TTFF si falta la cabecera).
### Hueco medio — HDR→SDR tonemap en transcode ✅ CERRADO (2026-05-31)
HDR (HDR10/HLG/DV) transcodificado a SDR salía lavado/desaturado (sin tonemap). Ahora `buildHLSFFmpegArgsAt` inserta `zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv` tras el scale y antes de `format=`, cuando `probe.HDR != "" && Transcode.TonemapHDR`.
- **Gate por capacidad**: `FFmpegSupportsZscale(ffmpegPath)` (cacheado, `ffmpeg -filters`) → solo activa si el build trae zscale/zimg. Sin zscale → no se inserta (la fuente sigue reproduciéndose, desaturada — no rompe). `transcoder.go:270` ya advertía que builds sin zimg no pueden tonemapear; el static ffbinaries puede faltarle, pero `/usr/bin/ffmpeg` (distro) y el docker sí lo traen.

View file

@ -0,0 +1,32 @@
package engine
// Torrent stream readahead sizing.
//
// anacrolix's Reader (SetResponsive + SetReadahead) already prioritises the
// pieces in a window ahead of the read position and re-prioritises on Seek —
// so the playhead→piece-priority feedback is built in. The problem was the
// window: a static 5 MiB is only ~1.6s of a 25 Mbps 4K stream, so playback
// outran the download and stalled. Sizing the window by bitrate (~30s of video)
// keeps a real buffer ahead of the playhead.
const (
readaheadSeconds = 30
minReadahead = 8 << 20 // 8 MiB
maxReadahead = 96 << 20 // 96 MiB — cap so a seek doesn't waste a huge fetch
defaultReadahead = 24 << 20 // 24 MiB — when bitrate is unknown (still ~5x the old 5 MiB)
)
// dynamicReadahead returns the bytes-ahead window for a torrent reader given the
// stream's bitrate (bits/sec). Unknown/zero bitrate → a generous default.
func dynamicReadahead(bitrateBps int64) int64 {
if bitrateBps <= 0 {
return defaultReadahead
}
ra := bitrateBps / 8 * readaheadSeconds
if ra < minReadahead {
return minReadahead
}
if ra > maxReadahead {
return maxReadahead
}
return ra
}

View file

@ -0,0 +1,43 @@
package engine
import "testing"
func TestDynamicReadahead(t *testing.T) {
cases := []struct {
name string
bitrateBps int64
want int64
}{
{"unknown bitrate → default", 0, defaultReadahead},
{"negative → default", -1, defaultReadahead},
{"low bitrate clamps to min", 1_000_000, minReadahead}, // 1 Mbps → ~3.75 MiB < 8 MiB
{"mid bitrate scales", 5_000_000, 5_000_000 / 8 * readaheadSeconds}, // 5 Mbps → ~18.75 MiB
{"high bitrate within range", 25_000_000, 25_000_000 / 8 * readaheadSeconds}, // 4K ~25 Mbps → ~93.75 MiB
{"very high clamps to max", 80_000_000, maxReadahead}, // 80 Mbps → 300 MiB > cap
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := dynamicReadahead(c.bitrateBps)
if got != c.want {
t.Errorf("dynamicReadahead(%d) = %d, want %d", c.bitrateBps, got, c.want)
}
if got < minReadahead && c.bitrateBps > 0 {
t.Errorf("result %d below min %d", got, minReadahead)
}
if got > maxReadahead {
t.Errorf("result %d above max %d", got, maxReadahead)
}
})
}
}
func TestDynamicReadahead_BeatsOldStatic(t *testing.T) {
// The whole point: every result is bigger than the old static 5 MiB that
// stalled HD/4K.
const oldStatic = 5 * 1024 * 1024
for _, b := range []int64{0, 1_000_000, 8_000_000, 25_000_000, 100_000_000} {
if got := dynamicReadahead(b); got <= oldStatic {
t.Errorf("dynamicReadahead(%d) = %d, not bigger than the old 5 MiB", b, got)
}
}
}

View file

@ -235,7 +235,9 @@ func (s *StreamEngine) WaitBuffer(ctx context.Context, progressFn func(buffered,
func (s *StreamEngine) NewFileReader(ctx context.Context) io.ReadSeekCloser {
reader := s.file.NewReader()
reader.SetResponsive()
reader.SetReadahead(5 * 1024 * 1024) // 5MB readahead
// Generous default window (vs the old static 5 MiB that stalled HD/4K). This
// CLI path has no bitrate probe, so dynamicReadahead(0) returns the default.
reader.SetReadahead(dynamicReadahead(0))
reader.SetContext(ctx)
return reader
}

View file

@ -1129,19 +1129,37 @@ func (p *diskFileProvider) FileSize() int64 {
}
// NewTorrentFileProvider creates a FileProvider from an active torrent file.
func NewTorrentFileProvider(file *torrent.File) FileProvider {
return &torrentFileProvider{file: file}
// dataDir locates the on-disk file for a best-effort bitrate probe that sizes
// the streaming readahead. The probe runs ASYNC so stream start never blocks on
// ffprobe (a missing header would otherwise stall up to the probe timeout);
// until it resolves, readers use the default window, and readers created after
// it resolves pick up the accurate size.
func NewTorrentFileProvider(file *torrent.File, dataDir string) FileProvider {
p := &torrentFileProvider{file: file}
if dataDir != "" {
go func() {
if bps := probeMediaInfo(filepath.Join(dataDir, file.DisplayPath())).bitrateBps; bps > 0 {
p.bitrateBps.Store(bps)
}
}()
}
return p
}
// torrentFileProvider wraps a torrent.File to implement FileProvider.
type torrentFileProvider struct {
file *torrent.File
// bitrateBps sizes the reader's readahead window (see dynamicReadahead).
// Set asynchronously by the bitrate probe; 0 until then → default window.
bitrateBps atomic.Int64
}
func (p *torrentFileProvider) NewFileReader(ctx context.Context) io.ReadSeekCloser {
reader := p.file.NewReader()
reader.SetResponsive()
reader.SetReadahead(5 * 1024 * 1024)
// Bitrate-sized window (vs the old static 5 MiB that stalled HD/4K). anacrolix
// prioritises pieces in this window ahead of the read position + on seek.
reader.SetReadahead(dynamicReadahead(p.bitrateBps.Load()))
reader.SetContext(ctx)
return reader
}

View file

@ -642,7 +642,10 @@ func (d *TorrentDownloader) GetStreamProvider(taskID string) (FileProvider, erro
return nil, fmt.Errorf("torrent has no files")
}
return NewTorrentFileProvider(video), nil
// The provider probes the bitrate asynchronously (to size the streaming
// readahead) — passing DataDir lets it locate the on-disk file without
// blocking stream start.
return NewTorrentFileProvider(video, d.cfg.DataDir), nil
}
// VideoExts is the canonical set of video file extensions used for file selection.