- ~~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)~~ ✅ **Seeding/ratio lifecycle (2026-06-01)** — `seed_enabled`/`seed_ratio`/`seed_time` en `[downloads]` (opt-in, off por defecto) cableados al daemon; al completar una descarga con seeding activo el torrent sigue subiendo en background y un monitor lo dropea al alcanzar ratio (subido/tamaño) O tiempo (lo primero que toque); sin target = siembra hasta apagado. `cleanup()` ahora siempre dropea (arregla fuga en rutas de error con seeding on). Verificado con swarm loopback real. Ver estado abajo.
- ~~Reproducir-mientras-baja: readahead estático 5MB~~ ✅ **Readahead dinámico (2026-05-31)** — `dynamicReadahead(bitrate)` = ~30s de vídeo (clamp 8–96 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). Ver estado abajo.
- ~~Sin trickplay (preview en la barra)~~ ✅ **Trickplay bajo demanda (2026-06-01)** — pista WebVTT `thumbnails` con 1 cue/10s; cada cue es una URL `/thumbnail?pos=…#xywh=0,0,W,H` (frame completo), así media-chrome solo descarga el frame que se sobrevuela. Toggle on/off por navegador (`localStorage`, default ON) + doc (web `docs/architecture/trickplay.md`). Alcance "on-demand" decidido con el usuario. Sin pregenerar sprite/BIF (sigue siendo opción futura con cacheo en disco). Ver estado abajo.
- ~~Subtítulos bitmap (PGS/DVB) sin burn-in~~ ✅ **Burn-in PGS/DVB bajo demanda (2026-06-01)** — el usuario elige una pista bitmap en el reproductor → la sesión fuerza HLS y el agente re-codifica con `[0:v:0]<vchain>[base];[0:s:N][base]scale2ref[sub][base2];[base2][sub]overlay[vout]` (overlay tras el tonemap = brillo SDR correcto; scale2ref = encaje a cualquier resolución del PGS). En la cache key. Selector web alimentado de file-details (funciona también en direct-play). Caveat: PGS + seek pierde el subtítulo. Verificado en Sonic BDremux (ES quemado). Ver estado abajo.
- ~~Audio siempre downmix estéreo AAC (sin passthrough 5.1)~~ ✅ **Verificado/descartado (2026-06-01)** — el 5.1 in-browser NO es viable (el navegador decodifica+mezcla al dispositivo, no hace bitstream-passthrough; AC3/EAC3/DTS ni se decodifican en Chrome/FF). El downmix solo ocurre en el path HLS. El handoff a player nativo (VLC/mpv/IINA/MPC/Infuse + .m3u/.strm) ya usa `/stream`**crudo** (`http.ServeContent` + `NewFileReader`, sin transcode) → el 5.1/Atmos/DTS original llega intacto al reproductor nativo. Sin trabajo necesario.
- Mediaserver solo DETECTA Plex/Jellyfin/Emby — no biblioteca navegable propia. **(diferido al final por decisión del operador)**
- TLS solo vía funnel; LAN/Tailscale/UPnP = HTTP plano (mixed-content desde web HTTPS). ⏸️ **Cimiento construido + DIFERIDO (2026-06-01)** — listener HTTPS por-agente con cert hot-reload (commit `27bee8c`, inerte sin cert). Decisión: MVP CF-only (single-SAN por agente, DNS-01 vía CF API, sin DNS propio); fase broker+DNS diferida. Doc: web `docs/plans/agent-tls-direct.md`.
VAAPI workarounds por host · sesión única (1 viewer).
**Cerrada (2026-06-01):**
- ~~`makeReadable` parchea mmap 0000 (frágil NFS)~~ ✅ tras el chmod ahora **verifica** que el fichero abre; si no (NFS root_squash / mapeo uid SMB) emite un WARNING claro y accionable + cuenta de fallos en el walk, en vez de dejar un "permission denied" críptico aguas abajo.
- ~~par2/unrar degradan en silencio si falta binario~~ ✅ `Par2Verify`/`Par2Repair` devuelven `ErrPar2NotInstalled` (antes `nil`=verificado); el pipeline lo surfacea (`Result.VerifyNote` + WARNING) → la descarga se entrega marcada UNVERIFIED, no como verificada. (El lado extract ya fallaba claro.)
- ~~cloudflared sin verificación de firma~~ ✅ el auto-download ahora fija la versión (`pinnedCloudflaredVersion`) y **verifica SHA-256** contra hashes horneados (no `latest`); un release upstream malicioso/roto ya no se trae en silencio.
- ~~WireGuard endpoint sin pin~~ ✅ **descartado**: el reseller de VPN (VPNResellers) usa configuración WireGuard directa sin pin de endpoint; no aplica.
- ~~Dos clientes HTTP divergentes (go-client vs agent)~~ ✅ el go-client (API público: search/popular/etc.) ahora recibe **mirror-failover** vía un `MirrorRoundTripper` que reusa el mismo `MirrorPool` + política `IsTransient` del agent client (inyectado con `tc.WithHTTPClient`) → ambos sobreviven una caída del dominio primario igual; antes el público se quedaba clavado en el primario.
## Mejoras detectadas durante el trabajo (backlog)
> Se rellena a medida que se trabaja cada hueco. Cada entrada: qué, por qué, prioridad.
- **Clock-skew en verificación de token** (baja): `verifyStreamToken` no tolera skew; con TTL 6h y NTP es irrelevante, pero el HLS lo mintea el web y lo verifica el agente (relojes distintos). Considerar ~60s de gracia si aparecen 404 espurios.
- **Secreto de stream en claro en DB** (baja): `agent_registration.stream_secret` es una clave HMAC viva (por arranque) en la DB central; quien lea la DB puede mintear tokens HLS de cualquier agente. Inherente al diseño (el web debe mintear HLS). Mitigado por regeneración por arranque. Excluir esta columna de cualquier JSON admin/usuario.
- **Refrescar/limpiar streamUrl al re-registrar** (baja): tras reinicio del daemon el secreto cambia; URLs `?t=` ya guardadas en `download_task.streamUrl` quedan stale hasta re-stream. Es auto-curativo, pero el web podría limpiar streamUrl en el re-register del agente.
- **gofmt preexistente** en `internal/agent/types.go` (StreamSession) y `hls.go`/`torrent.go`/`stream_source.go` (no introducido por este trabajo) — chore aparte.
- **Data race preexistente manager↔reporter (baja)**: bajo `-race`, `Task.ToStatusUpdate()` (leído por `ProgressReporter.flushBatch`) corre sin lock contra la escritura de campos del task en `processTask` (`manager.go:371`). No introducido por el resume; expuesto al correr la suite con `-race` (la suite normal corre sin `-race`). Fix: proteger los campos de estado/progreso del `Task` con su `mu` en ToStatusUpdate + processTask. Chore aparte. Múltiples `task.ID[:8]` en `progress.go`/`torrent.go` paniquean con ids <8chars(irreal:elwebmandaUUIDs)—limpiara`ShortID`depaso.
- ~~**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)**~~ ✅ **Trickplay CERRADO bajo demanda (2026-06-01)**: la preview de barra usa cues `/thumbnail` en vivo (un frame por cue al sobrevolar), no un sprite pregenerado. El sprite/BIF de toda la timeline con cacheo en disco del agente sigue siendo una optimización futura (no necesaria para la UX actual). Ver estado abajo.
- **nvenc "Invalid Level" en fuentes anamórficas (alta — destapado en el smoke de trickplay)** ✅ **ARREGLADO (2026-06-01)**: el nivel H.264 del transcode HLS se derivaba solo de la altura → una fuente 2.39:1 escalada a 1080 (~2586×1080 = 11016 MBs) revienta el `MaxFS` de L4.1 (8192); ffmpeg fallaba (`InitializeEncoder failed: invalid param (8): Invalid Level` en nvenc, `frame MB size > level limit` en libx264) y la sesión no producía ningún segmento. Casi todos los rips 4K son anamórficos → reproducción HLS rota en silencio. Fix (`hwaccel.go`): `H264LevelForFrame(width,height)` deriva el nivel del recuento de macrobloques real (máx. entre el nivel por-altura y el por-MB); `hls.go` calcula el ancho de salida y lo usa. Ver estado abajo.
### 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 — Trickplay (preview en la barra) ✅ CERRADO (2026-06-01)
Preview de fotograma al pasar el ratón por la barra de búsqueda, **bajo demanda** (sin pregenerar sprite). Alcance decidido con el usuario: on-demand + UX no invasiva + activable/desactivable + documentado.
- **Web** (rama `feat/unarr-brand`): `buildTrickplayVtt()` (`src/lib/stream/trickplay.ts`) emite una pista WebVTT `thumbnails` con 1 cue/10s; cada cue apunta a `GET /thumbnail?pos=<seg>&w=320#xywh=0,0,W,H` (frame completo, alto par derivado del aspecto). media-chrome solo descarga el frame sobrevolado y lo cachea. Wiring en `HlsStreamPlayer` (fetch a `file-details` → blob VTT → `<track>`), botón on/off + var CSS de fondo en `MediaChromePlayer`, toggle por navegador en `localStorage` (`useTrickplay`, default ON). Doc: `docs/architecture/trickplay.md`. Tests: `trickplay.test.ts` (6, formato cue + alto par + token vacío + inputs insuficientes).
- **Smoke real** (iPhone-equiv en Chrome, F1 4K DV+HDR10): vídeo reproduce → hover en la barra renderiza un frame real en la posición (1:17:36) ≠ el frame en curso; etiqueta de tiempo inmediata; toggle off → `<track>` desaparece (sin preview) y persiste `localStorage="0"`; toggle on → vuelven los 932 cues. CORS del `<img crossorigin>` OK (allowlist del agente).
- **No invasivo**: nada carga hasta el hover; 1er frame ~0.8–2.1s en 4K-desde-NAS, re-hover instantáneo (caché navegador); la etiqueta de tiempo aparece ya aunque el frame se esté generando.
### Hueco medio — Burn-in de subtítulos bitmap (PGS/DVB) ✅ CERRADO (2026-06-01)
Los subs de imagen (PGS/DVB/VOBSUB) no se pueden servir como WebVTT; se incrustan en el vídeo durante el transcode. Alcance (decidido con el usuario): bajo demanda + nudge cuando el fichero SOLO tiene bitmap (sin auto-activar).
- **Agente** (rama `unarr-burnin` ex `feat/unarr-agent`): `HLSSessionConfig.BurnSubtitleIndex *int` (nil=sin burn; puntero para que el 0 no se confunda con "quema pista 0"); en la cache key (`KeyFor`/`KeyForID`). `buildHLSFFmpegArgsAt`: si el índice apunta a una pista bitmap válida, `-map [vout]` + `-filter_complex [0:v:0]<vchain>[base];[0:s:N][base]scale2ref[sub][base2];[base2][sub]overlay[vout]`. Overlay TRAS el tonemap (subs SDR no se aplastan); scale2ref encaja el lienzo PGS al frame. Índice inválido/texto/fuera de rango → fallback a encode limpio (log). `IsTextSubtitle` ahora incluye `"text"` (paridad con el clasificador web). Tests `TestBuildHLSFFmpegArgsBurnSubtitle` (filter_complex/overlay/[vout] vs -vf según bitmap/texto/rango) + cache-key.
- **Web** (rama `unarr-burnin` ex `feat/unarr-brand`): columna `streaming_session.burn_subtitle_index` (migración 0139, NOT NULL default -1) en identidad de sesión + dedup; `session/route` fuerza `playMethod=hls` cuando hay burn; `agent.ts` lo pasa al daemon. Selector en `MediaChromePlayer` alimentado de **file-details** (`subtitleTracks`, mediainfo estática) → aparece también en direct-play; posición del array = `-map 0:s:N`. `isBitmapSubtitleCodec` (`src/lib/stream/subtitles.ts`) espeja `IsTextSubtitle`. Notice: "incrustando" al quemar / nudge si solo-bitmap. Doc: `docs/architecture/subtitle-burn-in.md`.
- **Smoke real** (Sonic 2020 BDremux 1080p, 7 PGS + 1 subrip): selector lista los 7 PGS (EN/ES/NL · imagen), excluye el subrip; elegir ES (`0:s:2`) fuerza HLS, el agente transcodifica con overlay sin error y el frame muestra **"Sé lo que estáis pensando."** quemado (posición + brillo correctos). /critico 2 revisores: arreglado `"text"` (paridad), reset de burn al cambiar de ítem, `bitmapSubtitles` a flatMap.
- **Caveat**: PGS + seek pierde el subtítulo (el `-ss` antes de `-i` tira el estado del decoder PGS). Reproducción lineal desde el inicio = OK. Mitigación futura: decodificar PGS desde el epoch cercano.
- **Aislamiento**: este trabajo se hizo en worktrees dedicados (`/tmp/tc-unarr-{web,cli}`, rama `unarr-burnin`) tras una colisión de ramas en los checkouts primarios compartidos. Merge a `feat/unarr-{brand,agent}` pendiente de decisión del operador.
Destapado durante el smoke de trickplay: el HLS de un 4K anamórfico (3840×1604, 2.39:1) no producía **ningún** segmento.
- **Causa**: el nivel H.264 se derivaba solo de la altura de salida (`H264LevelForHeight`). Escalado a 1080 de alto, un 2.39:1 queda ~2586×1080 = 11016 macrobloques, que supera el `MaxFS` del nivel 4.1 (8192). ffmpeg fallaba al abrir el encoder (`InitializeEncoder failed: invalid param (8): Invalid Level` en h264_nvenc; el equivalente `frame MB size > level limit` en libx264) → 0 paquetes → la sesión se quedaba en "preparando sesión" hasta el timeout de mark-ready. Casi todo rip 4K es 2.39:1, así que la reproducción HLS estaba rota para la mayoría de pelis 4K (en silencio).
- **Fix** (`hwaccel.go` + `hls.go`): `H264LevelForFrame(width, height)` deriva el nivel del recuento de macrobloques real (`levelForMacroblocks`, tabla MaxFS de la spec) y devuelve el máximo entre ese y el nivel por-altura (que conserva el margen de fps/MBPS). `hls.go` calcula el ancho de salida (`probe.Width * outputHeight / probe.Height`, par) y llama a `H264LevelForFrame`. 16:9 no cambia (mismo resultado que antes); anamórfico sube a 5.0 cuando hace falta. `transcoder.go` no se toca (su `SourceHeight` nunca se rellena → ya cae al default seguro 5.1).
- **Reproducido + verificado**: con `/usr/bin/ffmpeg` 6.1.1 + nvenc, `testsrc=2586x1080 @ -level:v 4.1` reproduce el error exacto; `@ 5.0` codifica OK. Tras el fix, sesión HLS del F1 4K arranca sin "Invalid Level"/auto-restart/timeout y el `<video>` carga (`readyState 4`, `duration 9313s`). Tests `H264LevelForFrame` (16:9 sin regresión + anamórfico → 5.0).
### Hueco medio — Seeding/ratio lifecycle ✅ CERRADO (2026-06-01)
Los flags `SeedRatio`/`SeedTime` (`TorrentConfig`) estaban DECLARADOS pero nadie los consumía, y `SeedEnabled` estaba hardcodeado a `false` en ambos constructores → el daemon nunca sembraba y, si se forzaba, sembraba para siempre.
- **Config** (`config.go`): `[downloads]` += `seed_enabled` (bool), `seed_ratio` (float), `seed_time` (string duración tipo `"24h"`). Opt-in, off por defecto (zero-values = apagado, sin entradas en `applyDefaults`). Tests `TestLoadSeeding{DefaultsOff,Explicit}`.
- **Wiring** (`daemon.go`): parsea `seed_time` (`time.ParseDuration`) y cablea los 3 campos a `TorrentConfig`; log de arranque que distingue ratio / tiempo / ambos / indefinido. El `unarr download` one-shot (foreground) sigue `SeedEnabled:false` a propósito (leech + exit; comentado).
- **Ciclo** (`torrent.go`): `seedTargetReached(ratio, time, uploaded, size, elapsed)` puro (ratio = subido/tamaño-seleccionado, estable entre resumes; el primero de ratio>0 o tiempo>0 que se cumple gana; ambos 0 = nunca para). `seedAndDrop` corre detached en un `seedCtx` propio del downloader (cancelado en `Shutdown`) — NO el ctx de la task, que se cancela en cuanto `Download` retorna y el manager libera el slot. Tick configurable (`seedCheckInterval`, default 30s; tests lo bajan). Sale sin dropear si el handle ya se quitó de `d.active` (cancel/pause del usuario) → ni lee stats de un torrent cerrado ni dropea dos veces.
- **Bug latente arreglado de paso**: `cleanup()` tenía `if !SeedEnabled { Drop }` — en rutas de ERROR (metadata timeout, disco, poll) con seeding activo borraba de `d.active` pero NO dropeaba → fuga. Ahora `cleanup()` siempre dropea (solo lo llama el error-path y el éxito-sin-seeding); el éxito-con-seeding hace el handoff a `seedAndDrop`.
- **Smoke real** (`seed_lifecycle_smoke_test.go`, tag `smoke`): swarm loopback de dos clientes anacrolix (un seeder sirviendo 4 MiB + nuestro `TorrentDownloader` leecheándolo vía `AddClientPeer`). Tras completar (4194304 bytes reales transferidos), `seedAndDrop` con `SeedTime=1s` dispara el target de tiempo (`seed time 1s reached, uploaded 0 B — dropping`) y quita el torrent de `d.active`. Verifica el path real Stats/Drop/ticker, no mocks. Tests puros `TestSeedTargetReached` (9 casos: ratio/tiempo/ninguno/ambos/guarda-tamaño-0) + `TestTorrentDownloader_SeedRatioTime`.
### 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.
- **Filtro CPU válido para todos los encoders**: el decode hwaccel deja los frames en memoria de sistema (no se setea `-hwaccel_output_format`), así que el zscale CPU corre antes del `format=`/`hwupload` (VAAPI) igual que el scale existente.
- **Smoke real**: extraído un frame de un 4K HDR10 (Frankenstein DV+HDR10) con y sin la cadena → ambas válidas (sin error), la tonemapeada con rojo vívido + negros profundos vs la lavada. /critico 1 revisor: cadena correcta, sin bugs bloqueantes; fix aplicado (soltar mutex antes del exec en la detección), HLG/DV-only documentados como aproximación (mejor que el baseline).
### Hueco medio — Auto-resume de descargas tras reinicio ✅ CERRADO (2026-05-31)
Antes: tras reiniciar el daemon, una descarga en vuelo quedaba abandonada (cola in-memory perdida, el web no re-despacha una tarea "downloading" atascada) hasta reintento manual. Los BYTES ya persistían (mmap + completion DB BoltDB de anacrolix, keyed por info_hash; debrid Range; usenet tracker) — faltaba que el daemon se re-sometiera solo.
- **`agent.ActiveTaskStore`** (`active-tasks.json`, atómico tmp+rename): persiste el payload `agent.Task` re-submittable de descargas en vuelo. Add al arrancar la descarga, Remove en terminal genuino.
- **Manager**: interfaz `taskPersister` (inyectable/testeable) + `SetTaskStore`. `Submit` ahora DEDUPLICA (mismo id del restore + re-despacho web no lanzan 2 goroutines) y persiste descargas (no stream/seed/upgrade-ReplacePath). `recordFinished` borra del store SALVO `shuttingDown` (atomic) → un apagado limpio preserva el entry; terminal genuino (completado/fallo/cancel-usuario) lo borra. ForceStart se limpia en el re-submit (respeta MaxConcurrent).
- **Daemon**: construye el store, `SetTaskStore`, y al arrancar re-somete `Load()` antes del sync loop.
- **/critico**: 1 revisor → **bug CRÍTICO (conf 98)**: el daemon hacía `cancel()` (ctx padre) ANTES de `manager.Shutdown()` → contextos de tarea cancelados antes de marcar `shuttingDown` → recordFinished con shuttingDown=false → borraba el entry → NO resume (guard era código muerto). FIX: `Manager.Shutdown` cancela los contextos él mismo ANTES de `wg.Wait` (con shuttingDown ya puesto) + el daemon llama `Shutdown` antes de `cancel()`. + ForceStart-strip + excluir upgrade. Tests: store round-trip, dedup, persist/remove-terminal, keep-on-shutdown, stream-no-persiste.
- **Smoke**: cubierto por unit tests (incl. shutdown-keeps). El e2e real (descarga → kill daemon → restart → resume) no se ejecutó para no reiniciar el agente dev en uso por el usuario.
### Hueco medio — Gestión de espacio en disco (pre-flight) ✅ CERRADO (2026-05-31)
Una descarga ya no llena el disco a 0 a mitad (corrompía el fichero parcial).
- **CLI**: `internal/engine/diskspace.go` — `CheckDiskSpace(dir, need, reserve)` usa `agent.DiskInfo` (Statfs/GetDiskFreeSpaceEx, ya abstraído) y devuelve `*InsufficientDiskError` si `free-need < reserve`; best-effort (need≤0 o stat falla → nil, ENOSPC sigue de backstop). Cableado antes de escribir en los 3 downloaders (torrent: DataDir+totalBytes; debrid: outputDir+restantes; usenet: outputDir+totalBytes solo en fresh). Reserva por `SetMinFreeBytes` desde `downloads.min_free_disk_mb` (default 2048 MiB). `manager` falla sin fallback en disco lleno (otra fuente llena el mismo disco). Fix latente: `formatBytes` paniqueaba ≥1PB (array hasta TB) → +PB/EB+clamp.
- **WEB**: `/api/internal/download` rechaza 507 `INSUFFICIENT_DISK` antes de crear la tarea si `diskFreeBytes - sizeBytes < 2 GiB` (reserva = default agente). Solo single-file torrent + agente online (telemetría de disco ya fluía). Saltado: stream, usenet, episodios (sizeBytes=pack completo → falso reject), agente offline. `DownloadButton` muestra estado `diskfull` (i18n 7 locales, namespace torrent). Bajo unarr el endpoint está fuera del allowlist → unarr solo streamea; el pre-flight del agente cubre sus descargas.
- **Tests/smoke**: Go `diskspace_test` (Statfs real vía TempDir: enough/insufficient/reserve/unknown/bad-dir). Web reject no e2e-smokeable en el dev box (es unarr → endpoint 404); verificado por build+typecheck+lógica. /critico 2 revisores → 2 bugs reales (guard sin `health.online`; falso reject en season packs) + 4 clarity.
### Hueco medio — Características del fichero + thumbnails bajo demanda ✅ CERRADO (2026-05-31)
Panel "ver características del fichero" (ruta + mediainfo completa: codec/HDR/bit-depth/tracks audio+subs/tamaño/duración — ya en DB vía ffprobe, solo faltaba surface) + tira de fotogramas extraídos en vivo por el agente.
- **CLI**: `GET /thumbnail?p=&pos=&w=&t=` en el stream server (ffmpeg `-ss <pos>` antes de `-i`, `-frames:v 1`, MJPEG a stdout). Token scope `thumb:<sha256(path)>` (mismo HMAC que `/stream`/`/hls`; web mintea, agente verifica; vector cross-lang Go↔TS pinneado). Clamp a fichero regular, 404-sin-oracle, timeout 20s. `ffmpegPath` cableado en `daemon.go`. Floor `0.13.0`.
- **WEB**: endpoints bajo `/api/internal/stream/` (permitido en unarr; `/api/internal/library` NO) — `file-details` (mediainfo + URLs de frames vía funnel HTTPS) + `owned-files` (lista mínima por contentId, solo items con ffprobe). Lógica pura testeada en `src/lib/stream/thumbnails.ts`. Modal compartido `FileDetailsModal`/`useFileDetails` con skeleton + carga progresiva ("Generando X/N…") + fallback por frame. Gating `supportsThumbnails`/`THUMBNAIL_MIN_VERSION`.
- **Alcance en ambas marcas**: torrentclaw → acción en los 3 builders de menú de biblioteca (`fileInfoMenuItem` compartido). unarr → `UnarrFileDetailsButton` en `/title/<id>` (la biblioteca unarr son estanterías, no `LibraryPage`). Modal reutiliza labels neutrales (namespace `library`, no `torrent`) → marca limpia.
- **Tests/smoke**: Go (token vector, args, 400/404/503, stub-ffmpeg success) + web (resolveThumbnails, parity, version gate, i18n 7 locales). Smoke real contra biblioteca local 4K (Frankenstein, HEVC DV+HDR10): ffmpeg extrae JPEG válido, modal unarr muestra mediainfo + 5 frames vía funnel. /critico 4 revisores → 5 fixes (clipboard promise, dedup posiciones short-clip, tipos compartidos, guard videoInfo, helper menú).
-`/stream` + VLC: token en query `?t=`; scope `"stream"`.
-`/hls`: token en **path**`/hls/<sessionID>/<token>/<resource>`; scope `"hls:<sessionID>"`.
Los URIs hijos de los playlists son **relativos** → el token se propaga solo a
segmentos/subs sin reescribir playlists.
- **Loopback exento** (mpv/vlc local + health-probe siguen funcionando; el token solo
gatea acceso remoto LAN/Tailscale/Public/funnel).
- Config `require_stream_token` (default **true**, seguro por defecto).
**Hecho (CERRADO 2026-05-31):**
CLI (`torrentclaw-cli`):
-`internal/engine/stream_token.go` (nuevo): `mintStreamToken`/`verifyStreamToken` (HMAC-SHA256, constant-time), `newStreamSecret` (32 bytes; **fail-hard** si crypto/rand falla, sin fallback débil).
-`internal/engine/stream_server.go`: secreto + `requireToken` en StreamServer; `/stream` y `/hls` verifican el token; `URLsJSON`/`hlsBaseURLs`/`URL()` tokenizan; `StreamSecretHex()`; **sin exención de loopback**; `/playlist.m3u` ya no auto-mintea (cerrado el oracle).
**Revisión adversarial** (workflow 4 dimensiones) → 1 crítico + 3 high corregidos antes de cerrar:
- **CRÍTICO**: la exención de loopback dejaba el **funnel CloudFlare** sin protección (cloudflared proxya tráfico público vía `localhost` → todo el funnel llegaba como loopback). **Fix: eliminada la exención.** Toda URL entregada ya va tokenizada, así que ningún cliente legítimo se rompe; el funnel ahora lleva el token en la URL y verifica.
- **HIGH** `/playlist.m3u` era oracle de tokens (fallback self-minting) → **fix: 404 sin streamUrl**.
- **HIGH** gate de version-skew mal señalizado (el agente reportaba el secreto aunque enforcement=off) → **fix: reportar solo si enforcing**.
- **HIGH** new-agent+old-web rompe HLS remoto → **mitigación por orden de deploy (ver abajo)**, sin tolerar tokenless (no reabrir el agujero).
**Verificación:** CLI `go build/vet/test ./...` ✓; WEB typecheck+lint+2325 unit ✓; paridad cross-lenguaje verificada en ambos sentidos.
> ⚠️ **ORDEN DE DEPLOY (obligatorio):** desplegar **primero el WEB** (columna `stream_secret` + minteo HLS), **luego** publicar el binario del agente. Un agente nuevo (enforce por defecto) contra un web viejo (sin minteo HLS) rompería el HLS remoto. El web es retrocompatible (agente viejo sin secreto → URLs sin token). Smoke real de extremo a extremo (daemon + funnel + navegador) **pendiente de hacer con un agente desplegado** — los tests cubren mint/verify/handlers y la paridad, no el round-trip cloudflared en vivo.