diff --git a/Docs/plans/unarr-agent-roadmap.md b/Docs/plans/unarr-agent-roadmap.md index c3ec679..217ccc2 100644 --- a/Docs/plans/unarr-agent-roadmap.md +++ b/Docs/plans/unarr-agent-roadmap.md @@ -54,8 +54,7 @@ El path HLS **siempre re-encoda** (incluso mp4 h264/aac ya compatible). `DecideA (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 fMP4 / 3c capability-negotiation / 3d ABR) -en el estado abajo. **Fases 3a + 3b + 3c CERRADAS** (smoke e2e, incl. HEVC en iPhone -Safari real); 3d (ABR) pendiente, baja prioridad. +en el estado abajo. **Fases 3a + 3b CERRADAS** (smoke e2e en browser); 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 @@ -399,38 +398,6 @@ binario local para el smoke, **sin publicar nada**. `DIRECT_PLAY_MIN_VERSION = 0 - Listener `loadedmetadata {once:true}` del attach nativo no se limpia explícitamente en cleanup (idempotente, impacto nulo). -**Fase 3c CERRADA 2026-05-31** (capability-negotiation, alcance ampliado): -- CLI (`feat/unarr-agent` 957d499): `NewRemuxSource` copia el vídeo para cualquier - codec decodificable: h264, o HEVC/AV1 si el dispositivo lo declara. HEVC se muxea - con `-tag:v hvc1` (Apple lo exige). Audio no-aac (ac3/eac3/dts) se transcodifica a - aac copiando el vídeo (`ActionRemuxAudio`) → cubre el muy común **h264+ac3 mkv**. -- WEB (`feat/unarr-brand` b0681d99): player sondea `canPlayType` (`detectDeviceCaps`) - y envía `{hevc,av1}` en el POST; `decidePlayMethod(p, caps)` device-aware: - HEVC/AV1 → `remux` solo si el dispositivo decodifica; audio no-aac ya no fuerza - `hls`. Tests caps actualizados (10). -- **Smoke e2e:** caps gate (sin caps→`hls`, con caps→`remux`); h264+ac3 remux - reproduce en Chrome (audio transcodeado, vídeo copiado); retag verificado por - ffprobe (`codec_name=hevc`, `codec_tag_string=hvc1`); **HEVC reproduce en iPhone - Safari real (Tailscale) — confirmado por el usuario.** ✓ -- **Caveat:** playback HEVC en Apple no se puede smokear en este host (Chrome-Linux - no decodifica HEVC; Mac-mini Safari por SSH bloqueado por TCC: Automation + - Screen Recording necesitan click GUI). Verificado vía iPhone del usuario. - -**Diagnóstico time-to-first-frame (2026-05-31)** (instrumentación en 957d499: -timers `probe`/`spawn`, `first fMP4 bytes after`, `serveGrowing blocked`): -- Agente NO es el cuello: probe 16–98ms, spawn 1–194ms, primer byte fMP4 ~201ms, - **0 bloqueos** en `serveGrowing` (LAN ni remoto). Remux `-c copy` completo de un - fichero de ~780MB en ~16s (limitado por lectura NAS). -- `moov` al frente (empty_moov OK) → el player no busca metadata al final. -- Cliente (Chrome/LAN): POST→primer request ~480ms (sobre todo carga de página). -- **1ª reproducción lenta = warm-up de red (Tailscale); 2ª/3ª rápidas** (confirmado - por el usuario). No es un problema de código. -- Player YA da feedback (fases `loading-meta`/`probing-transport`/`playing` + - overlay "Preparando…" + spinner de buffering + mensaje stuck >10s). El "sin - feedback" del test fue por usar URL cruda (sin UI), no el flujo real. -- **Conclusión:** sin optimización de código necesaria. Arranque garantizado-instante - = **hueco #4 (pre-transcode)**: dejar el remux/encode hecho antes del play. - --- ### Hueco #4 — Pre-transcode (transcode-on-download) diff --git a/internal/cmd/daemon.go b/internal/cmd/daemon.go index 0c24d1c..ef999a3 100644 --- a/internal/cmd/daemon.go +++ b/internal/cmd/daemon.go @@ -624,7 +624,6 @@ func runDaemonStart() error { // over /stream — no video re-encode, no HLS. The web decided this from // scan metadata + version gate; we still need ffmpeg (copy uses it). if sess.PlayMethod == "remux" { - tStart := time.Now() probeCtx, cancelProbe := context.WithTimeout(ctx, 15*time.Second) probe, perr := engine.ProbeFile(probeCtx, tcRuntime.FFprobePath, filePath) cancelProbe() @@ -632,7 +631,6 @@ func runDaemonStart() error { log.Printf("[stream %s] remux probe failed: %v", agent.ShortID(sess.SessionID), perr) return } - tProbe := time.Now() remuxCtx, remuxCancel := context.WithCancel(ctx) src, serr := engine.NewRemuxSource(remuxCtx, filePath, probe, tcRuntime.FFmpegPath, sess.FileName) if serr != nil { @@ -644,13 +642,7 @@ func runDaemonStart() error { // cancel stops the ffmpeg copy; SetGrowingFile/ClearFile also Close() // the source, so the temp file is always cleaned up. playerSessionRegistry.add(sess.SessionID, func() { remuxCancel(); streamSrv.ClearFile() }) - // Startup timing (TTFF diagnosis): probe = ffprobe on the source; - // spawn = ffmpeg launch + tmp setup. First-fMP4-byte is logged by the - // source itself; serveGrowing logs any client read that blocks waiting - // for ffmpeg to catch up. - log.Printf("[stream %s] remux (copy) → fMP4: %s [probe=%v spawn=%v]", - agent.ShortID(sess.SessionID), filepath.Base(filePath), - tProbe.Sub(tStart).Round(time.Millisecond), time.Since(tProbe).Round(time.Millisecond)) + log.Printf("[stream %s] remux (copy) → fMP4: %s", agent.ShortID(sess.SessionID), filepath.Base(filePath)) go func() { rctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() diff --git a/internal/engine/stream_server.go b/internal/engine/stream_server.go index 83d4360..fb3e76c 100644 --- a/internal/engine/stream_server.go +++ b/internal/engine/stream_server.go @@ -863,7 +863,6 @@ func (ss *StreamServer) serveGrowing(w http.ResponseWriter, r *http.Request, src buf := make([]byte, 256*1024) off := start - firstRead := true for { if explicitEnd >= 0 && off > explicitEnd { return @@ -871,19 +870,7 @@ func (ss *StreamServer) serveGrowing(w http.ResponseWriter, r *http.Request, src if r.Context().Err() != nil { return // client disconnected / request cancelled } - readStart := time.Now() n, err := src.ReadAt(buf, off) - // TTFF diagnosis: a read that blocks means the client asked for bytes the - // remux hasn't produced yet (a seek ahead of the live edge, or the very - // first read before ffmpeg's init lands). Log it so a slow start is - // attributable to "waiting on ffmpeg" vs network/decoder. - if waited := time.Since(readStart); waited > 250*time.Millisecond { - log.Printf("[stream] serveGrowing read off=%d blocked %v (produced=%d est=%d)", - off, waited.Round(time.Millisecond), src.Size(), src.EstimatedSize()) - } else if firstRead { - log.Printf("[stream] serveGrowing start off=%d (produced=%d est=%d)", start, src.Size(), src.EstimatedSize()) - } - firstRead = false if n > 0 { toWrite := n if explicitEnd >= 0 { diff --git a/internal/engine/stream_source.go b/internal/engine/stream_source.go index 58f66ba..301a899 100644 --- a/internal/engine/stream_source.go +++ b/internal/engine/stream_source.go @@ -5,7 +5,6 @@ import ( "errors" "fmt" "io" - "log" "os" "path/filepath" "strings" @@ -173,21 +172,8 @@ func newTranscodeSource( // before sending PlayMethod="remux". The browser plays the result progressively // via byte-range. Caller MUST Close() it (kills ffmpeg + removes the temp file). func NewRemuxSource(ctx context.Context, srcPath string, probe *StreamProbe, ffmpegPath, displayName string) (GrowingSource, error) { - // Audio: copy when already AAC; otherwise transcode to AAC (ActionRemuxAudio). - // Either way the VIDEO is copied — the expensive part is never re-encoded. - // This lets remux cover the very common h264+AC3/DTS mkv case (hueco #3 / 3c), - // not just h264+AAC. - action := ActionRemux - if probe != nil && probe.AudioCodec != "" && probe.AudioCodec != "aac" { - action = ActionRemuxAudio - } - opts := TranscodeOpts{Action: action, FFmpegPath: ffmpegPath} - // HEVC muxed into MP4 must carry the hvc1 tag or Apple/Safari won't decode - // it (hueco #3 / 3c). h264 (avc1) needs no override. - if probe != nil && probe.VideoCodec == "hevc" { - opts.VideoTag = "hvc1" - } - return newTranscodeSource(ctx, srcPath, probe, action, opts, displayName) + opts := TranscodeOpts{Action: ActionRemux, FFmpegPath: ffmpegPath} + return newTranscodeSource(ctx, srcPath, probe, ActionRemux, opts, displayName) } // signalNotify wakes any goroutine blocked in ReadAt. Non-blocking: if a @@ -223,13 +209,6 @@ func (t *transcodeSource) watchSize(ctx context.Context) { } current := stat.Size() if current > t.size.Load() { - if t.size.Load() == 0 && current > 0 { - // TTFF diagnosis: how long from ffmpeg spawn to the first - // fMP4 bytes (init + first fragment) landing — the floor on - // when /stream can serve anything playable. - log.Printf("[stream] %s first fMP4 bytes after %v (%d KB)", - t.name, time.Since(t.startedAt).Round(time.Millisecond), current/1024) - } t.size.Store(current) t.signalNotify() } diff --git a/internal/engine/transcoder.go b/internal/engine/transcoder.go index 6bd613e..030c28c 100644 --- a/internal/engine/transcoder.go +++ b/internal/engine/transcoder.go @@ -27,11 +27,6 @@ type TranscodeOpts struct { SourceHeight int // probed source height — used to derive a sane H.264 level StartSeconds float64 FFmpegPath string - // VideoTag forces the output stream's codec tag on a copy remux. HEVC muxed - // into MP4 must carry the `hvc1` tag (not the default `hev1`) or Safari / - // Apple devices refuse to decode it. Empty = leave ffmpeg's default. Only - // applied on copy actions (passthrough/remux); a real re-encode sets its own. - VideoTag string } // Transcoder wraps a long-running ffmpeg child process whose stdout streams @@ -227,16 +222,8 @@ func buildFFmpegArgs(filePath string, opts TranscodeOpts) []string { switch opts.Action { case ActionPassthrough, ActionRemux: args = append(args, "-c:v", "copy", "-c:a", "copy") - // HEVC → MP4 needs the hvc1 tag for Apple/Safari (hueco #3 / 3c). - if opts.VideoTag != "" { - args = append(args, "-tag:v", opts.VideoTag) - } case ActionRemuxAudio: - args = append(args, "-c:v", "copy") - if opts.VideoTag != "" { - args = append(args, "-tag:v", opts.VideoTag) // HEVC → hvc1 for Apple - } - args = append(args, "-c:a", "aac", "-b:a", coalesce(opts.AudioBitrate, "192k")) + args = append(args, "-c:v", "copy", "-c:a", "aac", "-b:a", coalesce(opts.AudioBitrate, "192k")) case ActionTranscodeVideo: videoCodec := opts.HWAccel.FFmpegVideoCodec("h264") args = append(args, "-c:v", videoCodec)