Compare commits

...

7 commits

Author SHA1 Message Date
Deivid Soto
6e8bca2ac4 docs(roadmap): 3b approach = progressive fMP4 remux via /stream 2026-05-31 11:28:37 +02:00
Deivid Soto
5fa8455b21 docs(roadmap): hueco #3 3a smoke e2e passed + brand-isolation fix noted 2026-05-31 11:14:28 +02:00
Deivid Soto
944d6529b2 chore: bump version to 0.10.0 (direct-play floor; local build only, no publish) 2026-05-31 11:03:03 +02:00
Deivid Soto
42fc408947 docs(roadmap): add hueco #4 (pre-transcode on download) design 2026-05-31 10:54:57 +02:00
Deivid Soto
192b474c60 docs(roadmap): hueco #3 phase 3a closed (direct-play) 2026-05-31 10:51:58 +02:00
Deivid Soto
c8d7c4bba5 feat(stream): direct-play passthrough for browser-native files
Hueco #3 / 3a (CLI side). StreamSession gains PlayMethod; when the web
sends "direct", the daemon serves the raw file over /stream (HTTP Range,
no ffmpeg) instead of transcoding to HLS — zero CPU, instant seek. Runs
before the ffmpeg-availability check so direct-play works even with
transcode disabled. Legacy/empty PlayMethod keeps the HLS path, so an old
web that never sends "direct" is unaffected.
2026-05-31 10:32:34 +02:00
Deivid Soto
3592b9f95a docs(roadmap): design hueco #3 (device-profile + direct-play + ABR) 2026-05-31 10:30:33 +02:00
4 changed files with 295 additions and 2 deletions

View file

@ -49,10 +49,21 @@ torrent. La promesa "play instantáneo cache-fast" no ocurre. Falta: source debr
en el path de streaming + cache-availability + **fallback torrent↔debrid mid-stream**.
Diseño por fases (2a direct-play / 2b HLS-desde-URL / 2c fallback) en el estado abajo.
### Hueco #3 — Device-profile + direct-play + ABR
### Hueco #3 — Device-profile + direct-play + ABR 🔵 EN CURSO (ver estado abajo)
El path HLS **siempre re-encoda** (incluso mp4 h264/aac ya compatible). `DecideAction`
(passthrough/remux) existe pero muerto en el path browser. Sin negociación por
capacidades del dispositivo. Sin ABR multi-bitrate.
Diseño por fases (3a direct-play / 3b remux-HLS / 3c capability-negotiation / 3d ABR)
en el estado abajo. **Fase 3a CERRADA** (CLI c8d7c4b + web 636fbe59); 3b/3c/3d pendientes.
### Hueco #4 — Pre-transcode (transcode-on-download) 🔵 DISEÑADO (ver estado abajo)
Al completar una descarga/import, transcodificar/remuxar en background para que el
PRIMER play sea instantáneo (direct o cache-HIT), sin transcode en vivo.
Optimización, nunca bloqueante: si no terminó a tiempo → fallback a transcode en
vivo (HLS actual). Reaprovecha `hls_cache.go` (cache-HIT ya sirve instantáneo) +
el pipeline de `prewarm` (ya hace encode de la siguiente ep) — generaliza prewarm a
"todo download, configurable" y puebla también el artefacto direct-play. Configurable
desde la web. Diseño + set de opciones en el estado abajo.
### Huecos medios ⬜
- Sin gestión de espacio en disco (`Statfs`) → disco lleno revienta a mitad.
@ -194,3 +205,254 @@ Empezar por 2a (valor inmediato, riesgo bajo), 2b y 2c como iteraciones.
**Mejora detectada:** `resolve.go:22` ordena `torrent > debrid > usenet`; para el
diferenciador cache-fast convendría que, **cuando hay cache debrid confirmada**,
el orden de STREAMING (no el de descarga) prefiera debrid.
---
### Hueco #3 — Device-profile + direct-play + ABR
**Estado:** 🔵 EN CURSO (2026-05-31). Análisis cerrado; fase 3a en implementación.
**Problema (confirmado en el análisis):**
- El path browser usa **HLS y SIEMPRE re-encoda**: `buildHLSFFmpegArgsAt`
(`engine/hls.go`) pone `-c:v libx264|nvenc|…` + cadena de filtros completa
(scale/format/setparams) + AAC, sin rama de copia. Un mp4 h264/aac 8-bit SDR
que el navegador reproduciría tal cual se transcodifica entero. Coste de CPU
puro desperdicio.
- `DecideAction` + `diskFileSource`/`transcodeSource` (`engine/probe.go`,
`engine/stream_source.go`) **son código muerto**: cero callers en producción,
solo tests. Distinguen `passthrough/remux/remux-audio/transcode-video` y detectan
10-bit/HDR — la lógica de decisión ya existe, no está cableada.
**Lo que ya hay y se reaprovecha:**
- El agente ya expone **dos paths** en el StreamServer (puerto 11818):
- `/stream` → sirve el fichero crudo con `http.ServeContent` (HTTP Range
completo, sin ffmpeg, ya tokenizado). **Direct-play ya es posible aquí.**
- `/hls/<id>/…` → transcode HLS.
- El web **construye las URLs** (HLS hoy) desde la info de red del agente
(`streamPort`, `tailscaleIp`, `lanIp`, `funnelUrl`, `streamSecret`) y **puede
mintear tokens** (`mintStreamToken`, scope `stream` es constante). O sea: el web
puede construir la URL `/stream?t=…` de direct-play él mismo.
- `libraryItem` ya guarda del scan: `videoCodec`, `audioCodec`, `bitDepth`, `hdr`,
`resolution`. Con el contenedor (extensión de `fileName`), el web tiene todo
para decidir direct-play SIN re-probar.
**Diseño por fases (de menos a más riesgo):**
- **Fase 3a — direct-play passthrough para items de biblioteca.** *El web decide.*
*Slice acotado, ambos sentidos de version-skew seguros vía gate de versión.*
1. WEB `decidePlayMethod({videoCodec,audioCodec,bitDepth,hdr,container})`
`"direct" | "hls"` (espeja la rama passthrough de Go `DecideAction`: solo
`mp4/m4v` + `h264` + `aac` + 8-bit + SDR → direct; todo lo demás → hls).
2. WEB gate: `supportsDirectPlay(agentVersion)` (constante de versión mínima).
Direct-play solo si el agente la soporta; si no → hls (sin regresión).
3. WEB sesión: en la rama `libraryItemPublicId`, seleccionar los campos codec;
calcular `playMethod` (gated); persistirlo en `streamingSession.play_method`
(migración aditiva, `db:generate`); devolver `playMethod` + `streamUrls`
(`/stream?t=` minteadas por el web, lan/ts/funnel) en la respuesta.
4. WEB sync: `getPendingStreamSessions` emite `playMethod` al agente.
5. CLI: `StreamSession.PlayMethod string`; en `OnStreamSession`, si
`PlayMethod=="direct"``streamSrv.SetFile(NewDiskFileProvider(path))` +
`MarkSessionReady` (sin ffmpeg). Else → `StartHLSSession` (actual).
6. WEB player (`HlsStreamPlayer.tsx`): si `data.playMethod==="direct"` → usar
`data.streamUrls` + attach nativo `<video src>` (mp4 = reproducible en todo
navegador, sin hls.js). Else → flujo HLS actual.
- **Limitación honesta:** solo cubre items de biblioteca (escaneados, con
metadata codec). Raw `infoHash`/`taskId` → hls (sin probe). Cubrir esos
casos = fase 3a-bis (el agente decide tras probar, reportando playMethod por
`MarkSessionReady` — requiere extender el payload + SSE + diferir el attach
del player al evento ready). Diferido por mayor superficie.
- **Fase 3b — remux fMP4 progresivo vía /stream (ENFOQUE ELEGIDO 2026-05-31).**
Caso `mkv` (u otro contenedor no-mp4) con h264 + aac + 8-bit + SDR: codecs ya
browser-native, solo el contenedor estorba. `-c copy` evita el re-encode de vídeo.
Descartado HLS-copy (duraciones de segmento variables vs manifiesto pre-render →
rompe seek; arreglarlo = probe de keyframes lento o reescribir el núcleo HLS).
**Enfoque:** ffmpeg `-c copy -movflags +frag_keyframe+empty_moov+default_base_moof
-f mp4` mkv→fMP4 a fichero temporal **creciente**; servir ese fMP4 por **/stream**
(mismo path direct-play 3a, attach nativo, sin hls.js, sin manifiesto).
**Núcleo real (la parte no-trivial):** servir un fichero que **crece** mientras
ffmpeg escribe. El `/stream` actual usa `http.ServeContent` (asume fichero completo
y seekable). Hay que:
- Resucitar/adaptar el `transcodeSource` muerto (`engine/stream_source.go`):
ffmpeg→tmp creciente, `ReadAt` con bloqueo hasta que los bytes existan
(`readBlockTimeout`), `EstimatedSize` = bitrate×duración para que la barra del
player tenga timeline.
- Un **responder de Range manual** en /stream para fuentes no-finales (en vez de
`http.ServeContent`): leer `Range`, `ReadAt` la fuente, escribir 206 +
`Content-Range` con el tamaño estimado. El path mp4-completo (3a) sigue usando
ServeContent (rápido).
- Caveat: seek-adelante a zona no-remuxada bloquea hasta que el copy la alcanza
(copy es I/O-bound, rápido). Seek-atrás (bytes ya en disco) inmediato.
**Plan de incrementos seguros:**
- **3b-i (agente, dormido):** `remuxSource` + responder Range para fuentes
crecientes, gateado tras `PlayMethod=="remux"` (que el web aún no envía) →
commiteable sin romper nada, con tests.
- **3b-ii (web+player):** `decidePlayMethod` devuelve `"remux"` para
contenedor-no-mp4 + h264/aac/8-bit/SDR; player trata `playMethod != "hls"` igual
que direct (streamUrls + attach nativo). Activa 3b. Mismo gate de versión.
**Ficheros:** CLI `engine/stream_source.go` (remuxSource), `engine/stream_server.go`
(range responder + provider creciente), `cmd/daemon.go` (branch `remux`),
`engine/transcoder.go` (args `-c copy` fMP4). WEB `lib/stream/play-method.ts`
(+"remux"), `stream/session/route.ts`, `HlsStreamPlayer.tsx` (`!= "hls"`).
- **Fase 3c — capability negotiation (device-profile).** El web envía
`{maxHeight, codecs:[h264,hevc,av1], containers}` (de UA + `canPlayType`).
`decidePlayMethod` se hace device-aware: p.ej. Safari/AppleTV que reproduce HEVC
nativo → passthrough HEVC en vez de transcode HEVC→h264. Reemplaza el heurístico
UA-burdo de `resolveAutoQuality`. Web+CLI.
- **Fase 3d — ABR multi-bitrate.** Ladder de renditions en el master playlist +
N pipelines ffmpeg / segmentos por rendition. Alto esfuerzo, baja prioridad;
el modelo single-viewer reduce su valor. Último.
**Ficheros a tocar (3a):** CLI `internal/agent/types.go` (+PlayMethod),
`internal/cmd/daemon.go` (branch SetFile vs HLS). WEB
`src/lib/services/agent-version-compare.ts` (gate), `src/lib/stream/play-method.ts`
(nuevo), `src/lib/stream-token.ts` (scope stream), `src/lib/db/schema.ts` +
migración (`streamingSession.play_method`), `src/app/api/internal/stream/session/route.ts`
(decisión + URLs), `src/lib/services/agent.ts` (`getPendingStreamSessions` emite
playMethod), `src/components/stream/HlsStreamPlayer.tsx` (attach nativo).
**Seguridad de version-skew (3a):**
- Web nuevo + agente viejo: gate `supportsDirectPlay` ve versión vieja → hls. ✓
- Web viejo + agente nuevo: web nunca manda `direct` → agente hls. ✓
- Campo `PlayMethod` desconocido en agente viejo = ignorado por el unmarshal. ✓
**Empezar por 3a** (valor inmediato — el caso primario de unarr es la biblioteca
local escaneada; mp4-h264-aac es común en web-dl/YIFY). 3b/3c/3d como iteraciones.
**Hecho (Fase 3a CERRADA 2026-05-31):**
- CLI (`feat/unarr-agent` c8d7c4b): `StreamSession.PlayMethod`; `OnStreamSession`
ramifica `direct``SetFile(NewDiskFileProvider)` + `MarkSessionReady` (sin
ffmpeg, antes del check de ffmpeg para funcionar con transcode off). `go build`
+ `vet` + tests verdes.
- WEB (`feat/unarr-brand` 636fbe59): `decidePlayMethod()` (espeja la rama
passthrough de Go, conservador) + test unitario; gate `supportsDirectPlay`
(`DIRECT_PLAY_MIN_VERSION = 0.10.0`); decisión en la ruta de sesión (solo
library item + sin downscale + `audioIndex == -1`); `buildStreamUrls` mintea
token scope `stream` (paridad Go); `streaming_session.play_method` (migración
0135) emitido al agente vía `getPendingStreamSessions`; player ramifica a
`<video src>` nativo. lint + typecheck:all + 2333 unit + build (brand unarr) OK.
- Revisión adversarial (correctness + security/parity, 2 agentes): **0 hallazgos
bloqueantes**. Token parity y version-skew (ambos sentidos) confirmados.
**Correcciones de la revisión propia (3a):** direct-play exige `audioIndex == -1`
(servir el fichero entero no respeta una pista de audio no-default elegida por el
usuario → esos casos van a HLS con `-map 0:a:N`).
**Smoke e2e (3a) — PASADO 2026-05-31** (agente dev 0.10.0 build local + item de
biblioteca mp4-h264-aac `/mnt/nas/peliculas/.../Tangled.Ever.After...mp4` + browser):
- POST `/api/internal/stream/session``playMethod: direct`, `streamUrls` con
`/stream?t=` (token web scope `stream`), `hlsUrls: null`. ✓
- Agente: `[stream …] direct-play: Tangled…mp4` (SetFile, sin ffmpeg). ✓
- `/stream`: HEAD 200 `video/mp4` `Content-Length 128321419`; GET Range 0-1023 →
206 + bytes mp4 reales (`ftyp isom…avc1`). **Token web verificado por Go → paridad
cross-lenguaje confirmada en vivo** (sin token → 404). ✓
- CORS desde origen browser (`localhost:3030`): ACAO correcto, preflight 204. ✓
- Browser: `<video>.currentSrc` = `/stream?t=…` (NO `/hls`), `readyState 4`,
reproduciéndose, 1920×1080 nativo, **13 reqs `/stream`, 0 `/hls`**, attach
**nativo** (`[hls] (native) loadedmetadata`, sin hls.js). Telemetría
metric/progress OK. ✓
**Bug pre-existente encontrado + arreglado durante el smoke** (web 764f5b01): el
allow-list de la marca unarr (`src/lib/branding/routes.ts`) NO incluía
`/api/internal/agent` ni `/api/internal/stream` → en unarr el agente daba 404 al
registrar y el player 404 al crear sesión. **El streaming + agente de unarr estaban
rotos de raíz.** Añadidos al allow-list (superficie del agente/media propio del
usuario, cero superficie torrent).
**Nota de release:** versión bumpeada a **0.10.0** (`version.go`, CLI 944d652) — solo
binario local para el smoke, **sin publicar nada**. `DIRECT_PLAY_MIN_VERSION = 0.10.0`
(web 52d958f0). Al publicar la release real del CLI, debe ser >= 0.10.0.
**Backlog detectado en 3a (baja prioridad):**
- `streaming_session.transport` queda `"hls"` también para sesiones direct
(el enum `TRANSPORT_VALUES` solo tiene `"hls"`); telemetría imprecisa, no bug.
Añadir `"direct"` al vocabulario cuando se toque la métrica.
- Modelo single-viewer: dos plays direct simultáneos → el último `SetFile` gana;
el tab viejo reproduciría contenido nuevo en silencio (HLS al menos 404ea).
- Direct-play no aplica `audioIndex` ni extrae subs a WebVTT (usa pistas
embebidas vía `<video>` nativo); subs bitmap no se ven. Aceptable en 3a.
- Listener `loadedmetadata {once:true}` del attach nativo no se limpia
explícitamente en cleanup (idempotente, impacto nulo).
---
### Hueco #4 — Pre-transcode (transcode-on-download)
**Estado:** 🔵 DISEÑADO (2026-05-31), pendiente de implementar.
**Qué es:** al completar una descarga (o import a biblioteca), procesar en
background para que la reproducción sea **instantánea** sin transcode en vivo.
Es una optimización: si no terminó cuando el usuario da play → fallback al
transcode en vivo (HLS actual). **Nunca bloquea.**
**Sinergia con lo existente (clave — gran parte de la infra ya está):**
- `hls_cache.go`: un encode HLS completo se cachea y el cache-HIT lo sirve
instantáneo (cero ffmpeg). Pre-transcode = poblar esa cache antes del play.
- `stream-prewarm.ts` + `createPrewarmSession`: ya lanza un encode HLS de la
siguiente ep en background. Pre-transcode = generalizar prewarm a "cualquier
download, configurable", + producir también el artefacto direct-play (3a).
- Por tanto el trabajo NUEVO es: (1) disparador on-download-complete, (2)
superficie de config en web, (3) gobernanza de recursos + cola, (4) decisión
"qué producir" (remux mp4 para 3a vs HLS cache vs nada si ya es native).
**Opciones a exponer en la web (set propuesto):**
1. **Activación + disparador**
- Toggle global on/off (default OFF — CPU/disco intensivo).
- Disparador: al completar descarga / al escanear-importar / manual
("optimizar ahora" por item) / programado (ventana horaria).
- Default recomendado: on-download-complete, pero solo en ventana idle + sin
stream en vivo activo.
2. **Qué producir (target) — modo Auto recomendado (por probe):**
- ya browser-native (mp4 h264/aac 8-bit SDR) → **nada** (3a lo sirve crudo).
- solo contenedor incompatible (mkv h264/aac) → **remux** a mp4 (barato, sin
re-encode; habilita 3a direct-play). *(necesita 3b para el manifiesto.)*
- codec incompatible (HEVC/AV1/10-bit/HDR) → **transcode** a H.264 (caro).
- Modos: solo-remux / remux+transcode / forzar H.264 universal.
- Formato salida: mp4 direct-play (seek nativo) vs HLS cache (multi-network)
vs ambos. Recomendado: mp4 si compatible, HLS si requiere transcode.
3. **Calidad**
- Mantener original (passthrough cuando se pueda) / cap 1080p / ladder ABR
(480/720/1080/original — encaja con 3d).
- "Solo transcodear si ayuda" (no tocar lo ya compatible).
4. **Selección / alcance**
- Todo / solo biblioteca (pelis+series) / solo lo problemático (p.ej. solo
4K HEVC, dejar h264).
- Solo watchlist / recién añadido / todo. Reglas por carpeta de biblioteca.
5. **Gobernanza de recursos (lo más importante — es pesado):**
- Concurrencia (N transcodes paralelos, default 1).
- HW accel si disponible (nvenc/qsv/vaapi); cap de threads CPU.
- Ventana horaria (solo idle, p.ej. 02:0008:00).
- **Pausar cuando hay stream en vivo** (no pelear por CPU con la reproducción).
- Prioridad de cola (watchlist primero / más pequeño primero / más nuevo).
- (Laptops) solo con AC / no en batería.
6. **Disco / retención (liga con el hueco medio de espacio en disco):**
- Dónde guardar (cache dir) + tamaño máx + evicción LRU (ya parcial en cache).
- Mantener SIEMPRE el original; el transcode es artefacto adicional.
- TTL: borrar pre-transcode no visto en N días; pin a visto/favorito.
- Re-transcodear al cambiar la config de calidad (invalidación).
7. **UX / estado**
- Cola + progreso por item en la web ("Optimizando para reproducción
instantánea…"). Badge en library card: "listo para play instantáneo" vs
"se transcodificará al reproducir". Notificación al terminar (opcional).
8. **Fallback / límites**
- Si no terminó a tiempo → transcode en vivo (HLS). Nunca bloquea el play.
- Solo ficheros locales en disco (no debrid/torrent sin bajar).
**MVP recomendado (fase 4a):** toggle on/off + disparador on-download-complete +
modo Auto (remux-si-compatible / transcode-si-no) + concurrencia 1 +
pausar-si-stream-activo + reusar `hls_cache` + badge "listo". El resto (ladder
ABR, ventanas horarias, reglas por carpeta, TTL avanzado, formato mp4 vs HLS
configurable) en fases 4b/4c.
**Dependencias:** el camino mp4/remux depende del hueco #3 (3a ya hecho; 3b para
el remux-a-mp4 con manifiesto correcto). El camino HLS-cache es implementable ya
(reusa cache + prewarm). La gobernanza (pausar-si-stream) necesita señal de
"stream activo" en el daemon (la hay: `streamSrv.HasFile()` + registro HLS).

View file

@ -410,6 +410,13 @@ type StreamSession struct {
// AudioIndex selects the source audio track (-map 0:a:N). -1 means
// "use the default/first track".
AudioIndex int `json:"audioIndex,omitempty"`
// PlayMethod is how the daemon should serve this session:
// "" — default (HLS transcode); also what legacy servers send.
// "direct" — the source is already browser-native (the web decided this
// from library scan metadata + an agent-version gate). Serve
// 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"`
}
// SyncResponse is returned by the server with all pending actions for the CLI.

View file

@ -589,6 +589,30 @@ func runDaemonStart() error {
filePath = found
}
// Direct-play (hueco #3 / 3a): the web decided this source is already
// browser-native (mp4 h264/aac 8-bit SDR) from library scan metadata,
// gated on agent version. Serve the raw file over /stream (HTTP Range,
// no ffmpeg) instead of transcoding to HLS — zero CPU, instant seek.
// Runs BEFORE the ffmpeg-availability check on purpose: direct-play
// needs no ffmpeg, so it must work even when transcode is disabled.
if sess.PlayMethod == "direct" {
streamSrv.SetFile(engine.NewDiskFileProvider(filePath), sess.TaskID)
// cancel just clears the served file so daemon shutdown / drain
// stops exposing it on /stream. There's no ffmpeg child to kill.
playerSessionRegistry.add(sess.SessionID, func() { streamSrv.ClearFile() })
log.Printf("[stream %s] direct-play: %s", agent.ShortID(sess.SessionID), filepath.Base(filePath))
// File is on disk → ready immediately. Tell the web so the player
// attaches <video src> without burning its HEAD-probe retry budget.
go func() {
rctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := agentClient.MarkSessionReady(rctx, sess.SessionID); err != nil {
log.Printf("[stream %s] mark-ready failed: %v", agent.ShortID(sess.SessionID), err)
}
}()
return
}
tcRuntime := buildTranscodeRuntime(ctx, cfg)
if tcRuntime.FFmpegPath == "" || tcRuntime.FFprobePath == "" {
log.Printf("[hls %s] rejected: ffmpeg/ffprobe unavailable", agent.ShortID(sess.SessionID))

View file

@ -1,4 +1,4 @@
package cmd
// Version is the CLI version. Overridden by goreleaser ldflags at release time.
var Version = "0.9.19"
var Version = "0.10.0"