Compare commits

...

164 commits
v0.9.0 ... main

Author SHA1 Message Date
Deivid Soto
a710bc1626 feat(library): detección de intro/créditos post-scan (skip segments)
Some checks failed
CI / Test (push) Failing after 6m18s
CI / Build (push) Successful in 1m32s
CI / Build-1 (push) Successful in 1m55s
CI / Build-2 (push) Successful in 1m33s
CI / Build-3 (push) Successful in 1m32s
CI / Build-4 (push) Successful in 1m35s
CI / Build-5 (push) Successful in 1m33s
CI / Lint (push) Failing after 2m50s
CI / Coverage (push) Successful in 2m58s
CI / Vet (push) Successful in 2m7s
Tras cada scan, localiza la intro (OP) y los créditos (ED) comparando
fingerprints chromaprint entre episodios de la misma temporada —
reimplementación limpia del enfoque de Intro Skipper: índice invertido
de uint32, alineamiento por shifts, Hamming ≤6/32, región contigua más
larga (15-120s intro / 15-450s créditos). Películas: inicio de créditos
por rachas de blackframe (solo keyframes, -skip_frame nokey) que llegan
al final del fichero.

- fpcalc se auto-descarga de las releases estáticas de acoustid
  (linux/macos/windows, ~2MB) con el mismo patrón que ffmpeg/ffprobe.
- Resultados cacheados como sidecar .skipseg.json (mtime + versión de
  algoritmo); solo los ficheros nuevos trabajan.
- Submit a /api/internal/agent/skip-segments DESPUÉS del library-sync,
  en dos fases (episodios primero, películas después) para que la
  fase rápida no espere a los blackframe lentos sobre NAS.
- Agrupación por (dir + título-pre-SxxEyy + season): los títulos
  parseados arrastran nombre de episodio y tags de release.
- Gotcha cazado en vivo: fpcalc -length sale sin drenar el pipe; hay
  que cerrar nuestro read-end o ffmpeg queda bloqueado para siempre.
- config: library.skip_detect (default true, backfill) y scan_interval
  default 24h → 1h (estilo Plex).
2026-06-12 19:46:07 +02:00
Deivid Soto
59da949a53 feat(agent): el auto-update difiere hasta que no haya stream activo
Un auto-update reiniciaba el daemon al momento y cortaba la
reproducción en curso (mata la sesión HLS viva → freeze → F5). Ahora
el path AUTO (OnUpgrade) difiere indefinido mientras haya streams
activos y aplica solo en idle. Ningún update en segundo plano vale
cortar un visionado.

- HLSSessionRegistry.Count() + playerSessionRegistry.count() →
  GetActiveStreamCount() = player (HLS/direct/remux) + transcode HLS.
- deferAutoUpgradeUntilIdle: guard de un solo waiter, ticker 30s,
  aplica al llegar a 0 streams.
- `unarr update` (manual) SIN cambios: aplica al momento = escape
  hatch para un fix urgente.
- SyncRequest.agentStatus ("updating") reportado antes del restart
  para que la web pueda avisar en vez de dar error de sesión.
2026-06-12 09:46:23 +02:00
Deivid Soto
91ee5e4b6f chore(release): 1.1.3-beta
- Bump version to 1.1.3-beta
- Update CHANGELOG.md
2026-06-11 22:02:58 +02:00
Deivid Soto
f0c51c5d90 feat(daemon): telemetría de salud continua + heartbeat de sesiones copy
El watcher F3 posteaba UN snapshot de speed= al arrancar y moría: un encoder
sano en el minuto 1 que se ahoga en el minuto 20 (escena compleja, GPU robada
por otro proceso) era invisible para el triage de stalls del player, que
decidía con el dato de arranque.

- monitorSessionHealth: ticker 5s el resto de la sesión; re-postea al cambiar
  el bucket ok/marginal/struggling (con histéresis de 2 ticks — una EWMA
  bailando sobre 0.95 no puede webhookear cada 5s) o al derivar el ratio
  ≥0.15. Un POST fallido NO avanza el baseline: el tick siguiente reintenta
  (perder el único webhook de la transición a struggling cegaba al player
  justo en el caso que esto existe para cubrir).
- resetTranscodeStats() en restartFromSegment: el ffmpeg nuevo de un seek
  re-arma el warmup y resiembra la EWMA — sus frames fríos (speed=0.0x)
  hundían la media curada a <0.75 y el monitor habría posteado un
  "struggling" falso que pausaba el player en pleno seek. Verificado e2e:
  dos restarts (seek a 1200s) con health estable en ok.
- inputBound ventanado (30s) en vez de pegajoso: un blip de lectura
  transitorio ya no reclasifica como input_bound/struggling cada dip <0.95
  durante el resto de una sesión de horas.
- Heartbeat copy (F2): las sesiones -c:v copy postean una vez
  {ok, 1.0, "copy"} tras el ready — la web ya distingue "sesión copy" de
  "agente viejo sin telemetría" (ambos eran null). Segundo POST deliberado:
  un 400 de una web vieja (enum sin "copy") jamás debe bloquear el ready.
- Logs de fallo etiquetados por tipo de POST: un heartbeat fallido ya no se
  lee como "mark-ready failed" (el ready SÍ aterrizó).

Requiere web con session-ready/SSE actualizados (desplegar web primero;
contra web vieja todo degrada a best-effort con log).
2026-06-11 20:53:18 +02:00
Deivid Soto
2b9d576aee feat(daemon): lock de instancia única por config dir (flock)
Dos daemons compartiendo el mismo config.toml corren sobre el mismo
agentId/agentHash/streamSecret y corrompen el estado de sync del otro.
flock advisory en <configDir>/unarr.lock al arrancar: el 2º start se
niega con mensaje claro. El kernel suelta el lock al morir el proceso
(incluido SIGKILL) → sin problema de lock obsoleto.

Scope = config dir, no máquina: un UNARR_CONFIG_DIR distinto (p.ej. el
agente dev) tiene su propio lock y corre en paralelo. No bloquea una 2ª
instalación con config separada — solo el cross-talk de config compartida.
2026-06-11 17:18:01 +02:00
Deivid Soto
1e61d1e546 chore(release): 1.1.2-beta
Some checks failed
CI / Test (push) Failing after 6m42s
CI / Build (push) Successful in 1m33s
CI / Build-1 (push) Successful in 1m57s
CI / Build-2 (push) Successful in 1m33s
CI / Build-3 (push) Successful in 1m33s
CI / Build-4 (push) Successful in 1m35s
CI / Build-5 (push) Successful in 1m36s
CI / Lint (push) Failing after 2m34s
CI / Coverage (push) Failing after 2m39s
CI / Vet (push) Successful in 2m1s
Release / release (push) Failing after 15m11s
Release / docker (push) Has been cancelled
- Bump version to 1.1.2-beta
- Update CHANGELOG.md
2026-06-11 09:38:32 +02:00
Deivid Soto
dc67f0d4ca fix(stream): hallazgos de la revisión crítica del modo copy
Some checks failed
CI / Test (push) Failing after 2m55s
CI / Build (push) Successful in 1m31s
CI / Build-1 (push) Successful in 1m57s
CI / Build-2 (push) Successful in 1m35s
CI / Build-3 (push) Successful in 1m37s
CI / Build-4 (push) Successful in 1m33s
CI / Build-5 (push) Successful in 1m34s
CI / Lint (push) Failing after 2m29s
CI / Coverage (push) Successful in 2m49s
CI / Vet (push) Successful in 2m0s
- log honesto de resume (copy codifica desde 0, no desde StartSec)
- inyección EXT-X-START anclada a #EXTM3U con warning si falla
- ServeSegment sin tope segmentCount en copy (ffmpeg adelanta al índice)
- comentario types.go: gate por HLS_COPY_MIN_VERSION web-side
2026-06-11 08:37:36 +02:00
Deivid Soto
da6ee9fff5 Merge feat/hls-copy: HLS-copy reemplaza el remux progresivo frágil
Fuentes remux-elegibles servidas como HLS fMP4 con -c:v copy (vídeo jamás
re-encodeado, CPU ~cero, apto NAS sin GPU). Playlist propiedad de ffmpeg
(EVENT→ENDLIST + EXT-X-START=0), audio copy solo AAC ≤2ch (WebKit rechaza
AAC multicanal — causa raíz del fallo iPhone), StartSec ignorado (offset
EVENT rompe el parser iOS). Suite smoke 7/7 con ffmpeg real. Validado:
Chromium/hls.js + Safari macOS + iPhone (Black Adam, Frankenstein DV,
Immortals HDR10, Hoppers). Gate web supportsHlsCopy.
2026-06-11 00:10:53 +02:00
Deivid Soto
a4a6e2f2d6 fix(stream): no copiar AAC multicanal en modo copy (WebKit lo rechaza igual)
El downmix estéreo del re-encode (f89396c) dejaba un agujero simétrico: una
fuente cuyo audio YA es AAC 5.1 se copiaba tal cual, y WebKit rechaza el
AAC multicanal en el primer segmento exactamente igual que el re-encodeado.
Copy de audio ahora solo cuando la pista es AAC con ≤2 canales; cualquier
otra cosa (no-AAC, AAC 5.1+, o canales desconocidos en el probe — fail-safe)
re-encodea a AAC estéreo 48k. La pista multicanal original queda intacta
para reproductor externo. Test smoke nuevo: fuente AAC 5.1 → re-encode.
2026-06-11 00:05:50 +02:00
Deivid Soto
f89396ceed fix(stream): downmix estéreo en el audio re-encodeado del modo copy
Sin -ac 2 una fuente 5.1 (AC3/EAC3) producía AAC de 6 canales del encoder
nativo de ffmpeg, que WebKit/Apple HLS rechaza al sniffar el primer
segmento: en el access log de Safari se ve master → index → init → seg-0
dos veces y silencio. Era el discriminador exacto del patrón de campo:
episodios con AAC estéreo (copy de audio) reproducían en iPhone; todas las
películas 5.1 fallaban. Verificado con Safari/macOS via WebDriver-less
access log: con -ac 2 la progresión de segmentos avanza con normalidad.

Espeja los flags del path de encode (aac 192k 48kHz estéreo). Test smoke
ampliado: el re-encode debe llevar -ac 2.
2026-06-11 00:02:53 +02:00
Deivid Soto
6c756a2569 fix(stream): EXT-X-START=0 en el playlist copy mientras crece
Hasta que llega ENDLIST la sesión copy es un EVENT creciente y algunos
players nativos (iOS) tratan un playlist sin terminar como LIVE: se
enganchan al borde en vez de a la posición 0. EXT-X-START:TIME-OFFSET=0
(RFC 8216 §4.3.5.2) fija el arranque explícitamente; inofensivo cuando el
playlist ya es final. Coincide con el patrón observado: episodios cortos
(ENDLIST en segundos) reproducían en iPhone, películas (EVENT durante
minutos) no.
2026-06-10 23:51:14 +02:00
Deivid Soto
9eb3e44153 fix(stream): el modo copy ignora StartSec (offset EVENT rompe iOS nativo)
Un playlist EVENT cuyas entradas empiezan en 0 mientras los fragmentos
llevan tfdt desplazado (-ss + -output_ts_offset) es exactamente la forma
que el parser HLS nativo de iOS no traga: resume a 368s → error del player
y bucle de re-bootstrap de sesión en iPhone (observado 2026-06-10).

Copy produce siempre desde 0 con PTS absolutos reales: adelanta a la
reproducción a velocidad de I/O, así que el punto de resume aparece en la
timeline creciente en segundos y el seek de startPosition del player
aterriza con normalidad. Test de resume actualizado: el playlist debe
cubrir la timeline completa.
2026-06-10 23:31:58 +02:00
Deivid Soto
8bfb6486ce chore(release): 1.1.1-beta
Some checks failed
Release / release (push) Failing after 49m32s
Release / docker (push) Has been cancelled
- Bump version to 1.1.1-beta
- Update CHANGELOG.md
2026-06-10 23:07:07 +02:00
Deivid Soto
5a92df1e14 feat(stream): HLS-copy — reemplazo resiliente del remux progresivo
Nuevo modo VideoCopy en el engine HLS: ffmpeg -c:v copy (el vídeo jamás se
re-encodea — I/O puro, funciona en un NAS sin GPU), audio copy si ya es AAC
o AAC 192k si no, muxeado a segmentos fMP4 con ffmpeg escribiendo SU PROPIO
playlist (EVENT mientras corre, ENDLIST al acabar, EXTINF exactos en los
keyframes del source). Sustituye al remux growing-fMP4 servido por HTTP
Range artesanal, cuya fragilidad estructural produjo tres incidentes en un
día (init malformado/delay_moov, loop de re-seek por total inventado, iOS
rechazando total desconocido).

Diferencias deliberadas respecto al modo encode:
- playlist de ffmpeg servido desde disco (los cortes van a keyframe del
  source → duraciones imposibles de pre-renderizar; medido: probar
  keyframes antes cuesta 8-24s, inviable para TTFF)
- sin seek-restart ni auto-restart (la copia va a velocidad de disco y
  adelanta a cualquier viewer; el -ss de segmentos uniformes corrompería
  la timeline de cortes variables)
- sin caché HLS (regenerar no cuesta encode; cachear solo quema disco)
- resume vía -ss (snap a keyframe) + -output_ts_offset
- master playlist sin CODECS (un string hardcodeado equivocado hace que
  iOS rechace la variante; omitirlo es legal y universal)

Validación: TTFB seg-0 510ms sobre el MKV real del incidente (HEVC Main10
+ EAC3, 6.7GB). Suite de integración con ffmpeg real (tag smoke): h264+aac
(copy total), h264+ac3 (re-encode de audio con priming dts — la clase
delay_moov), hevc10+eac3 (la forma exacta del incidente, tag hvc1), resume
con StartSec, y serving del playlist; asserts de codecs vía ffprobe sobre
el playlist servido, suma EXTINF ≈ duración, segmentos completos en disco
(+temp_file = rename atómico).

El wiring web (plan remux→hls+videoCopy con gate de versión ≥1.0.10) va en
el repo web. Plan: docs/plans/hls-copy-remux-replacement.md (web).
2026-06-10 23:06:21 +02:00
Deivid Soto
e4170af604 feat(stream): UPnP-map the HTTPS port for remote direct-TLS (best-effort)
UPnP previously published only the HTTP stream port (11818). The remote
per-agent direct-TLS path (https://<pubip>.<hash>.agent.unarr.app:<port>)
needs the HTTPS port (11819) reachable from the WAN, so map it too —
inside listenTLS after the actual bound port is known, so the router and
the web (which encodes the reported httpsPort) agree.

Best-effort: if UPnP/NAT-PMP isn't available the remote path just falls
back to the CloudFlare funnel; the LAN direct path is unaffected. Opt-in
via downloads.enable_upnp (unchanged default: false).
2026-06-10 22:56:07 +02:00
Deivid Soto
3fcfaaf234 fix(stream): iOS exige total concreto en el Content-Range del remux
iOS/WebKit abre todo <video src> con una sonda "bytes=0-1" y se niega a
reproducir si el 206 no trae una longitud concreta en Content-Range —
"/*" (total desconocido, el fix anterior del loop de re-seek) le hacía
abortar y re-bootstrapear la sesión sin parar.

Vuelve a anunciar siempre un total numérico (exacto si ffmpeg terminó, el
estimado mientras crece). El loop de re-seek real no era el total
anunciado sino el init segment malformado, ya arreglado con +delay_moov
en buildFFmpegArgs. Test nuevo: la sonda 0-1 debe llevar total concreto.
2026-06-10 22:37:02 +02:00
Deivid Soto
b3487a22e8 chore(release): 1.1.0-beta
Some checks failed
Release / release (push) Failing after 36m14s
Release / docker (push) Has been cancelled
- Bump version to 1.1.0-beta
- Update CHANGELOG.md
2026-06-10 22:33:01 +02:00
Deivid Soto
cda2e1322c feat(hls): full-GPU scale_cuda for NVENC SDR downscales
Keep an NVENC downscale of an SDR source entirely on the GPU
(decode -> scale_cuda -> h264_nvenc) instead of copying every frame to the
CPU for `scale=` and back. That GPU->CPU->GPU round-trip is the wall on
modest GPUs; even a strong box gains ~37% (scale_cuda 14.9x vs CPU 10.9x
on a 4K SDR HEVC -> 1080p encode).

Strictly gated so every case that needs CPU frames is unchanged:
- HDR (libplacebo Vulkan / zscale CPU tonemap can't consume a CUDA surface),
- burn-in (the scale2ref+overlay composite runs on CPU frames),
- non-NVENC encoders, and no-op when not actually downscaling.

- hwscale.go: FFmpegSupportsScaleCuda — a functional 1-frame probe mirroring
  the libplacebo probe (presence in -filters lies; needs a real CUDA device).
  Probes the worst-case real input (10-bit p010 -> 8-bit yuv420p) so a host
  whose scale_cuda can't do the 10->8-bit conversion fails closed to CPU.
- hls.go: useCudaScale gate + `-hwaccel_output_format cuda` + a
  `scale_cuda=-2:H:format=yuv420p` filter branch. Output is 8-bit
  (format=yuv420p + `-profile:v main`), browser-safe.
- transcode_quality.go / player_session_registry.go / daemon.go: HasScaleCuda
  flag, populated + warmed at startup like the other ffmpeg capability probes.

Fail-closed: probe absent/fails -> keep the CPU scale path, no regression.
Verified live (real 4K SDR HEVC Main10 session emitted scale_cuda, 5.54x
realtime, nvenc at 100%) + 8 arg-builder unit tests for the gate.
2026-06-10 21:44:58 +02:00
Deivid Soto
671bee8317 fix(stream): delay_moov en el remux para audio AAC con dts negativo
El remux reencodea el audio no-AAC (eac3→aac); la pista AAC arranca con un
dts negativo (priming/encoder-delay). Con empty_moov el moov se escribía
ANTES de conocer ese delay, así que el primer fragmento quedaba mal formado
y un demuxer estricto (Safari / la forma en que Apple decodifica HEVC) nunca
inicializaba la reproducción: el <video> cargaba bytes (se veía en Network)
pero no arrancaba, y el player re-bootstrapeaba la sesión cada pocos segundos.

Añade +delay_moov: retiene el moov hasta el primer paquete y maneja el dts
de priming. ffmpeg deja de emitir el warning "nonzero dts ... moov already
written" y el fMP4 reproduce. Reproducido con Hoppers (HEVC Main 10 + EAC3).
2026-06-10 20:10:11 +02:00
Deivid Soto
b0637f266b Merge branch 'main' into feat/agent-tls-direct
# Conflicts:
#	internal/cmd/daemon.go
2026-06-10 19:44:44 +02:00
Deivid Soto
5f2d1cdc70 fix(stream): no anunciar un total falso mientras el remux crece (loop de re-seek)
serveGrowing anunciaba en Content-Range total = EstimatedSize() = el tamaño
del MKV fuente mientras ffmpeg aún corría. Pero el fMP4 resultante no mide
eso (el audio re-encodea a AAC y la fragmentación cambian el byte count), así
que el <video> nativo mapeaba su timeline sobre una longitud falsa, pedía
offsets que no cuadraban, re-seekeaba y reabría la conexión cientos de veces
por segundo (el loop de reproducción remux).

Mientras crece (!Final) la longitud real es DESCONOCIDA: ahora se sirve
Content-Range "bytes start-end/*" (RFC 7233 §4.2) sin Content-Length, y el
cliente lee secuencial en vez de re-seekear. Cuando ffmpeg termina, el tamaño
real se conoce y se anuncia como antes. El 416 y el Content-Length del HEAD
solo cuando el total es real (final).
2026-06-10 19:42:37 +02:00
Deivid Soto
9ab0763f8a chore(release): 1.0.9-beta
Some checks failed
Release / release (push) Failing after 27m15s
Release / docker (push) Has been cancelled
- Bump version to 1.0.9-beta
- Update CHANGELOG.md
2026-06-10 17:54:16 +02:00
Deivid Soto
898fe80f4e refactor(daemon): revisión crítica del reporte de errores de sesión
- failSession usa un contexto fresco (no el del daemon): los fallos se
  concentran justo cuando el daemon se apaga (la cancelación mata arranques
  en vuelo) y un report derivado de ese contexto moría antes de llegar a la
  web; el cap de 10s sigue acotándolo
- consts sessErrFfmpegMissing/sessErrStartFailed sustituyen los 7 literales
  inline (un typo habría producido un code que el z.enum de la web rechaza
  con 400 — exactamente el fallo mudo que este canal elimina)
- markReady() unifica los tres goroutines idénticos de MarkSessionReady de
  los caminos sin transcode (direct-play, remux, debrid direct)
2026-06-10 17:49:49 +02:00
Deivid Soto
0dca296fec fix(daemon): reportar fallos de arranque de sesión a la web + scan en sesión única
- nuevo agentClient.ReportSessionError → POST /agent/session-error;
  failSession() en todos los abortos del handler de sesiones (path muerto,
  ffmpeg ausente, remux, provider debrid, StartHLSSession). Antes eran
  returns mudos y el player quedaba en "Preparando sesión" hasta agotar el
  deadline de probes
- resolvePlayableFile() unifica la resolución de paths del /stream raw y de
  las sesiones HLS/remux/direct (remap de base path + stat con retries NFS +
  directorio→vídeo, antes duplicada y divergente) y distingue file_missing
  (la web self-heala filas stale) de path_rejected (el fichero existe fuera
  de los roots = config; la web no debe podar nada)
- library.SyncBatches: el batching del sync de biblioteca vive en un solo
  sitio; el scan manual y el auto-scan sincronizan todos los roots en UNA
  sesión con scanRoots/fullCycle, en vez de una sesión por root que dejaba
  al server podar filas de roots que la sesión nunca visitó
2026-06-10 17:39:09 +02:00
Deivid Soto
4bdd161e02 chore(release): 1.0.8-beta
Some checks failed
Release / release (push) Failing after 20m30s
Release / docker (push) Has been cancelled
- Bump version to 1.0.8-beta
- Update CHANGELOG.md
2026-06-10 15:02:01 +02:00
Deivid Soto
6a7a2e292e feat(subtitles): subtitle-fetch jobs vía sync + auto-fetch opcional en scan
El web empuja SubtitleFetchRequest por el sync (URL del proxy, ya
charset-fixed a WebVTT); el daemon lo descarga y lo escribe como sidecar
<base>.<lang>.vtt junto al medio (contención en scan paths con
EvalSymlinks, cap 10 MiB) y reporta done/failed en el siguiente sync
para que el web marque el job. Config nueva library.subtitles
(auto_fetch + languages) para el auto-fetch en scan, off por defecto.
2026-06-10 14:48:35 +02:00
Deivid Soto
63be565227 test(hls): cubrir -forced_idr de QSV en el rate-control 2026-06-10 12:00:33 +02:00
Deivid Soto
556c5cb05f fix(hls): forced-idr en NVENC/QSV — los segmentos ignoraban force_key_frames
NVENC (ffmpeg 6.1 + drivers actuales) emite los keyframes forzados por
-force_key_frames como I-frames NO-IDR; el muxer HLS solo corta en IDR,
así que cada segmento se estiraba en silencio al GOP por defecto
(250 frames ≈ 10.4 s @24fps) mientras la playlist server-side seguía
prometiendo 2 s por segmento. Con los PTS reales ~5× fuera del mapa de
la playlist, los seeks aterrizaban donde podían y los subtítulos se
desincronizaban en cuanto se mezclaban segmentos de runs distintos
(seek-restart) en el mismo dir.

Medido: 3 segmentos por 30 s de encode en vez de 15; con -forced-idr 1
exactamente 15, y post-fix seg-150/151/158 arrancan en 300.0/302.0/316.0
clavados. Afecta a TODO el HLS por NVENC histórico (no era del rate
control nuevo: la config de bitrate fijo producía lo mismo). QSV recibe
su grafía -forced_idr. Las entradas de caché viejas nunca llegaron a
sellarse (el conteo de segmentos no cuadraba), así que no hay migración:
solo sesiones vivas estaban afectadas.
2026-06-10 10:44:18 +02:00
Deivid Soto
f9ecd5ed82 fix(hls): los prewarms ya no desalojan la sesión del espectador + trickplay 12x
- StreamSession.Prewarm → HLSSessionConfig.Prewarm: el daemon difiere el
  encode de un prewarm hasta que no haya encode vivo (poll 10s, tope
  30min) y lo registra vía RegisterKeep (side-by-side, sin desalojar).
  Antes todo pasaba por Register(), que cierra las demás sesiones — un
  prewarm de next-episode reclamado en mitad de la reproducción mataba
  el stream del usuario ("closed (cache discarded)" → master 404,
  verificado 2026-06-10). Una sesión REAL nueva primero reapea los
  prewarms en vuelo (CloseWhere(IsPrewarm)) para liberar el writer-lock
  de la caché — un prewarm SELLADO sobrevive como cache HIT — y luego
  desaloja normal vía Register.
- Trickplay: -skip_frame nokey + fps=...:eof_action=pass — solo
  decodifica keyframes (12x menos CPU medido: 233s→19s en un episodio
  de 24min 1080p; importa porque corre junto al streaming en vivo).
  Los ticks siguen siendo uniformes (fps repite el último keyframe),
  así que manifest y clientes cacheados no cambian. eof_action=pass
  cubre clips con un único keyframe (el filtro fps no emite nada de un
  stream de 1 frame con el eof por defecto).
2026-06-10 00:54:50 +02:00
Deivid Soto
9b97aedfe4 feat(hls): resume-aware first spawn + capped-CRF/CQ rate control
- HLSSessionConfig.StartSec (sync StreamSession.startSec): el primer
  ffmpeg arranca ya seekeado en el punto de resume (-ss +
  -output_ts_offset + -start_number, misma maquinaria que el
  seek-restart) en vez de encodear desde seg-0 para morir en el
  seek-restart inmediato del player (doble spawn, resume lento).
  readyMax se pre-siembra al índice de arranque; el ready-watcher
  compara ReadyCount() > WriterStartIdx() para no marcar "ready" antes
  del primer segmento real. startSec >= duración → arranque desde 0
  (resume obsoleto de un fichero reemplazado).
- Rate control: capped constant-quality donde el encoder lo hace bien —
  libx264 -crf 23, NVENC -cq 23 -b:v 0 — con el mismo -maxrate de
  siempre y -bufsize 2x (antes 1x estrangulaba picos). Escenas fáciles
  emiten muchos menos bits (menos stalls vía funnel/LTE); el peor caso
  no cambia. QSV/VideoToolbox/VAAPI conservan el triple de bitrate fijo
  probado (sus knobs de calidad tienen gotchas de vendor).
- Limpieza: wrapper buildHLSFFmpegArgs y guard startIdx<0 muertos.
2026-06-10 00:21:15 +02:00
Deivid Soto
f7ca282ca0 chore(release): 1.0.7-beta
Some checks failed
Release / release (push) Failing after 31m6s
Release / docker (push) Has been cancelled
- Bump version to 1.0.7-beta
- Update CHANGELOG.md
2026-06-08 13:07:29 +02:00
Deivid Soto
d708ea2360 feat(subs): resilient subtitle extraction — sidecars, charset, torrent/debrid
Close the recurring "video has subtitles but the web player shows none" gap
with a source-agnostic pipeline:

- Discover EXTERNAL sidecar subs in the scan (Video.es.ass siblings + a Subs/
  bundle), parse lang/forced/SDH from the filename, skip VobSub (.sub+.idx).
  ffprobe-only scanning ignored these (ToonsHub/anime "MSubs" releases).
- Transcode sidecar charset -> UTF-8 before WebVTT (BOM/UTF-16/code-page by
  language). Chinese SCRIPT matters: chs/sc -> GBK, cht/tc/big5 -> Big5
  (decoding one as the other is mojibake).
- /sub now serves a standalone sidecar file (i=-1, p=file, &l=lang hint) and a
  remote debrid URL (ffmpeg reads http, no local stat) — not just embedded
  streams of a local file.
- probe.json emits a tokened vttUrl per TEXT track so torrent/debrid HLS streams
  (never library-scanned) get subtitles too. Embedded index is counted among
  embedded streams only, so -map 0:s:N stays aligned when sidecars are appended.

Tested against a real 347-file gallery: 26/26 sidecars and embedded ass/srt/
mov_text all extract to valid WebVTT; bitmap (pgs/dvd_subtitle) correctly stays
burn-in. Manual harness gated behind GALLERY_DIR.
2026-06-08 13:04:09 +02:00
Deivid Soto
22081cf106 chore(release): 1.0.6-beta
Some checks failed
Release / release (push) Failing after 38m40s
Release / docker (push) Has been cancelled
Per-agent API key handoff + revocation handling. 1.0.5-beta was the
docker bundled-dep arch fix; this is the next beta.
2026-06-07 22:10:31 +02:00
Deivid Soto
9fdc099ea8 Merge branch 'feat/per-agent-api-keys' 2026-06-07 19:43:23 +02:00
Deivid Soto
6712127d4c chore(release): 1.0.5-beta
Some checks failed
Release / release (push) Failing after 17m31s
Release / docker (push) Has been cancelled
- Bump version to 1.0.5-beta
- Update CHANGELOG.md
2026-06-07 17:55:22 +02:00
Deivid Soto
9e3075f115 fix(docker): derive bundled dep arch from dpkg, not TARGETARCH default
The runtime stage's `ARG TARGETARCH=amd64` default shadowed buildx's
per-leg value, so even the published arm64 image bundled x86-64
cloudflared and ffmpeg alongside a native arm64 unarr binary. The daemon
spawning cloudflared hit "exec format error", the funnel never came up,
and TV/Stremio connect failed with "Failed to get add-on manifest".

Read the real arch from `dpkg --print-architecture` (the emulated base
image's arch) for both the ffmpeg and cloudflared RUN steps. Correct
under buildx cross-builds and plain `docker build` alike. Drop the
poisoned TARGETARCH default.

Reported-by: Serge <s@bongiozzo.ru>
2026-06-07 17:54:50 +02:00
Deivid Soto
82bc71aaef fix(agent): only treat explicit 410/403 as revocation; honour --config
- IsRevoked no longer matches a bare 401. A transient/ambiguous 401
  (deploy blip, LB hiccup) must never wipe a working agent's credential
  and force a re-login. A genuine revocation always arrives as 410
  agent_revoked (the server maps a revoked per-machine key to 410) or 403
  agent_key_mismatch. Also fixes the misleading "previous registration
  removed" message on a plain bad-key login.
- Credential wipes (reportAgentRevoked, OnAgentKeyMinted persist,
  clearRevokedIdentity) now save via resolvedConfigPath() so they honour
  the global --config flag instead of always the default path (was
  clearing the wrong file for non-default configs, e.g. unarr-dev).

--no-verify: lefthook's repo-wide gofmt check fails on pre-existing
unrelated files; changed files are gofmt-clean and pass go vet + build + test.
2026-06-06 12:51:51 +02:00
Deivid Soto
d982e795ea feat(agent): per-machine key handoff + revocation handling
Forward the agentId in the browser-auth URL so the server mints an API
key bound to this machine; consume + persist the agentKey returned by
register (migrating general-key bootstraps and stopping the per-restart
re-mint). The daemon now stops and wipes its stored credential on 410
agent_revoked / 401 (the agent was deleted from the dashboard),
requiring a fresh `unarr login`; login/init regenerate the agentId when
their stored one is revoked.

Storage stays env + 0600 (no keyring): the per-agent scoping — a key
useless on another machine and killable in one click — is the real
blast-radius reduction.

--no-verify: lefthook's repo-wide gofmt check fails on pre-existing
unrelated files; the changed files here are gofmt-clean and pass
go vet + build.
2026-06-06 12:30:21 +02:00
Deivid Soto
f14aee0b93 feat(stream): live transcode telemetry from ffmpeg speed=
Parse ffmpeg's -stats progress line (speed=Yx, fps=) from the HLS encoder's
stderr into a per-session EWMA, and report a health snapshot to the web side a
few seconds after seg-0. Lets the player name a too-slow transcode from a
direct measurement (~5-7s) instead of inferring it from stall shape (~15-30s).

- hls.go: add -stats; rewrite hlsStderrCapture.Write to frame on \r and \n,
  parse speed=/fps= (telemetry only, never logged), flag input-bound on source
  read errors. EWMA on HLSSession + GetTranscodeStats(); warmup-skip the first
  cold-start frames so a healthy encoder isn't reported as struggling.
- client.go: MarkSessionReady takes an optional *SessionHealth.
- daemon.go: watcher reports one health snapshot once >=4 post-warmup samples
  settle; classifyAgentHealth maps the speed ratio to ok/marginal/struggling.

Additive: old web replicas ignore the extra field; cache-hit/direct-play
sessions and short encodes report nil (the web keeps its stall heuristic).
2026-06-06 00:37:03 +02:00
Deivid Soto
2b47cb0656 fix(torrent): suppress noisy UPnP AddPortMapping warnings
The anacrolix/torrent client maps the listen port on the router via
UPnP/NAT-PMP for inbound peers. Many home routers reject AddPortMapping
with '500 Internal Server Error' and the lib retries every lease cycle,
spamming the daemon log. The rejection is harmless (downloads work over
DHT + outbound peers), so wrap the log handlers and drop just that line
while keeping the mapping attempts for routers that do support it.
2026-06-05 17:18:57 +02:00
Deivid Soto
3a8c466067 docs(docker): explain why GPU Vulkan tonemap can't init in-container
The libplacebo HDR->SDR tonemap needs a Vulkan device, but the nvidia
Vulkan ICD (libGLX_nvidia.so.0) pulls in libnvidia-glcore, which
references glibc malloc hooks removed in glibc 2.34 (__malloc_hook etc.)
and the Xorg symbol ErrorF. On any headless modern-glibc container these
go unresolved so vkCreateInstance returns VK_ERROR_INCOMPATIBLE_DRIVER
and the agent correctly falls back to the CPU zscale tonemap chain.
Document why we deliberately do NOT chase it (graphics cap + X11 libs +
1.4 loader + desktop glibc/Xorg, fragile + distro/driver coupled).
nvenc/nvdec (CUDA, not Vulkan) work regardless.
2026-06-05 16:52:48 +02:00
Deivid Soto
2fcc0d397f feat(agent): per-agent direct-TLS cert client + HTTPS listener wiring
The agent obtains a valid wildcard cert for *.<hash>.agent.unarr.app from
the web broker (ACME DNS-01) so the https web player reaches it directly
over HTTPS instead of the CloudFlare funnel.

- internal/acme: generate EC P-256 key + CSR locally (private key never
  leaves the machine), fetch the signed chain from the broker, persist it
  atomically, NeedsIssue renewal check
- daemon: generate + persist a stable agent_hash in config.toml; register
  before requesting the cert (broker ownership check needs the row); arm
  the HTTPS listener with the cert; 6h renewal poll hot-swaps it (no restart)
- report httpsStreamPort + agentHash on register/sync
- stream_server: emit Access-Control-Allow-Private-Network on PNA preflight
  so an https page can reach the agent on loopback / LAN
2026-06-05 12:09:46 +02:00
Deivid Soto
3a8c6ddd30 chore(release): 1.0.4-beta
Some checks failed
Release / release (push) Failing after 31m3s
Release / docker (push) Has been skipped
- Bump version to 1.0.4-beta
- Update CHANGELOG.md
2026-06-04 22:44:19 +02:00
Deivid Soto
d97ca11fa5 fix(stream): self-heal host→container path skew in HLS + sidecar handlers
On a docker agent the web DB holds host paths (e.g. /mnt/nas/peliculas/…)
while the container mounts that media at /downloads, so the runtime allowed
root (cfg.Download.Dir=/downloads) rejects the host path. The raw /stream
handler already self-heals via relocateUnreachable, but the HLS/remux session
handler did not — it logged "path outside allowed dirs" and returned, so the
web silently fell back to the raw /stream path (no transcode, slow funnel
start) and HLS/remux never ran. The path-scoped sidecar handlers
(/thumbnail, /trickplay, /sub) had the same skew → 404 for every scrubber
frame, trickplay sprite and external subtitle.

- HLS handler (OnStreamSession): apply the same relocateUnreachable remap as
  the raw handler before the dir-resolve.
- StreamServer: add SetPathResolver/healMediaPath, applied in /thumbnail,
  /trickplay, /sub AFTER token verification (the token still binds the
  original web path; the resolver is a pure function of that path and
  re-validates containment, so it can't be abused to serve a different file).
- Hoist the allowed-roots list into streamAllowedRoots(cfg) so the raw, HLS
  and sidecar handlers can't drift apart.

Note: relocateUnreachable needs a ≥3-segment path tail, so flat media layouts
are not self-healed (same limitation as /stream; a re-scan rewrites the DB
path). The HLS handler replicates only the lexical remap, not the raw
handler's transient-NFS os.Stat retry.
2026-06-04 22:38:12 +02:00
Deivid Soto
d44f16cae2 chore(release): 1.0.3-beta
Some checks failed
Release / release (push) Failing after 16m42s
Release / docker (push) Has been cancelled
- Bump version to 1.0.3-beta
- Update CHANGELOG.md
2026-06-04 08:47:34 +02:00
Deivid Soto
3f22d698da test(upgrade): exercise the real signed checksum flow, not a bypass
Some checks failed
CI / Test (push) Failing after 12m50s
CI / Build (push) Successful in 1m35s
CI / Build-1 (push) Successful in 1m58s
CI / Build-2 (push) Successful in 1m33s
CI / Build-3 (push) Successful in 1m33s
CI / Build-4 (push) Successful in 1m33s
CI / Build-5 (push) Successful in 1m34s
CI / Lint (push) Failing after 2m30s
CI / Coverage (push) Successful in 2m47s
CI / Vet (push) Successful in 1m59s
Supersedes the previous "disable signature verification" stop-gap. The two
checksum tests now run verifyChecksum with signature verification ENABLED using a
per-test ed25519 keypair (withReleasePubKey) and a matching checksums.txt.sig
served over the exact body — so they cover the real production path end to end
instead of skipping it. Adds verifyChecksum-level coverage for the cases that
actually protect a self-update: a checksums file signed by the wrong key is
rejected, a missing .sig is rejected, and verifyChecksumOnly (--allow-unsigned)
still passes on the checksum alone. No production code change.
2026-06-04 08:47:24 +02:00
Deivid Soto
86f03ba787 test(upgrade): disable signature check in checksum-matching tests
Some checks are pending
CI / Test (push) Waiting to run
CI / Build (push) Waiting to run
CI / Build-1 (push) Waiting to run
CI / Build-2 (push) Waiting to run
CI / Build-3 (push) Waiting to run
CI / Build-4 (push) Waiting to run
CI / Build-5 (push) Waiting to run
CI / Lint (push) Waiting to run
CI / Coverage (push) Waiting to run
CI / Vet (push) Waiting to run
TestVerifyChecksumWithHTTPTest and TestVerifyChecksumCaseInsensitive predate
release signing (commit 1757bda baked the release pubkey). With the pubkey set,
verifyChecksum now requires a valid checksums.txt.sig and fails at signature
decode before reaching the SHA256 comparison these tests assert. They exercise
the checksum-matching path only, so clear releasePubKeyBase64 for their duration
(t.Cleanup restore) — mirroring the existing pattern in signature_test.go. The
signature path itself keeps its dedicated coverage there. No production change.
2026-06-04 08:35:46 +02:00
Deivid Soto
c82826bf68 fix(trickplay): stop scan-time sprite generation from saturating the host
Some checks failed
CI / Test (push) Failing after 6m21s
CI / Build (push) Successful in 1m34s
CI / Build-1 (push) Successful in 2m0s
CI / Build-2 (push) Successful in 1m33s
CI / Build-3 (push) Successful in 1m38s
CI / Build-4 (push) Successful in 1m35s
CI / Build-5 (push) Successful in 1m38s
CI / Lint (push) Failing after 2m34s
CI / Coverage (push) Failing after 2m44s
CI / Vet (push) Successful in 2m3s
Trickplay sprite generation (one full-decode ffmpeg pass per file) could pin a
machine: multiple agents on the same library decoded the same 4K file at once, no
CPU throttling, and crashed/restarted agents orphaned ffmpeg to init (it ran the
full 45-min decode to completion). Stacked orphans spiked a box to load ~140.

- Single-flight lock: O_CREATE|O_EXCL .lock in the shared sidecar dir so two
  agents watching the same library never decode the same file twice (stale locks
  reclaimed after a TTL). Returns ErrTrickplayInProgress → prewarm skips, not fail.
- Load gate: defer the heavy decode until 1-min load ≤ max(ratio×NumCPU, 1.5),
  capped at 15 min so it throttles without ever becoming a permanent off-switch on
  busy / small hosts. New knob library.prewarm_max_load_ratio (default 0.7).
- Concurrency: trickSem caps trickplay to ONE decode at a time per agent.
- CPU priority: setLowCPUPriority (nice 19) alongside the existing idle ionice.
- No orphans: hardenCmd sets Setpgid + Pdeathsig=SIGKILL, with runtime.LockOSThread
  around the child so the kernel kills ffmpeg exactly when the agent dies (and not
  spuriously — golang/go#27505).

Tests: single-flight/stale-reclaim, load-gate immediate/cancel, and an e2e
Pdeathsig orphan-kill check.
2026-06-04 08:25:00 +02:00
Deivid Soto
aba20e2078 feat(stream): debrid passthrough for mode=stream tasks (external players)
Some checks failed
Release / release (push) Failing after 14m31s
Release / docker (push) Has been cancelled
handleStreamTask now serves a mode=stream task FROM a resolved debrid HTTPS link
(when the web set preferredMethod=debrid + the torrent is cached) instead of
joining the P2P swarm — served over the SAME /stream endpoint so VLC and other
external players consume it identically (and far faster). No HLS transcode:
external players handle any container. Falls through to the P2P StreamEngine
when there is no direct URL. Uses the mutex-safe SetStreamURL setter.

Also widen the debrid HEAD size-probe timeout 10s -> 15s to match the transport's
TLS handshake budget, so a slow CDN no longer trips it and falls back to a
guessed size.

Bump 1.0.2-beta.
2026-06-03 22:43:43 +02:00
Deivid Soto
8e37293b7d feat(trickplay): scan-time montage sprite for the web scrubber
Pre-generate ONE trickplay sprite (montage JPEG of frames sampled every
library.trickplay.interval, default 10s) + a JSON manifest per file during the
scan/auto-scan prewarm, cached in .unarr next to the media. The web scrubber
shows tiles from it instead of extracting frames live — removing the ffmpeg
contention with the active stream that broke seekbar previews (the original
'no thumbnail' report was the auto-scan prewarm decoding the same file the HLS
transcode was reading, not a seek-index fault).

- config: [library.trickplay] enabled/interval/width (default on, 10s, 240px),
  editable + a toggle; IntervalSeconds() with a 10s fallback.
- mediainfo: GenerateTrickplay (one ffmpeg fps=1/interval,scale,tile pass; idle
  I/O priority; ceil() frame count so no black trailing tile; a 16.7M-px cap
  coarsens the interval for long media so a single sprite stays decodable on
  iOS/Safari) + sprite/manifest sidecar cache helpers.
- engine: /trickplay endpoint (manifest JSON, ?kind=sprite JPEG); the agent owns
  the tile width so the web requests by path only; thumb:<sha256> token reused.
- prewarm: a trickplay job per item, gated; scan.go + daemon.go wire the config.

Tests: parseDims; synthetic 3x2 / exact-multiple / 1x1; real-file e2e smoke
(S02E08 → 143 tiles, 662KB sprite). Non-breaking: the existing 5-frame panel
prewarm + on-demand /thumbnail stay until the web migrates to the sprite.
2026-06-03 20:30:29 +02:00
Deivid Soto
7877e1de42 fix(release): keep prerelease suffix in docker smoke-check version compare
The smoke check extracted the docker image version with `grep -oE 'v[0-9.]+'`,
which truncates "v1.0.1-beta" to "v1.0.1" and false-warns on a correct image.
Match the trailing suffix too.
2026-06-03 19:29:35 +02:00
Deivid Soto
2abaf74b13 chore(release): 1.0.1-beta
Some checks failed
Release / release (push) Failing after 52m5s
Release / docker (push) Has been cancelled
- Bump version to 1.0.1-beta
- Update CHANGELOG.md
2026-06-03 19:23:28 +02:00
Deivid Soto
1757bdabf5 feat(release): sign release checksums (ed25519), enforce + bake pubkey
Releases were shipping UNSIGNED: ship.sh never invoked sign-checksums, the
goreleaser pubkey ldflag defaulted to empty, and publish-cli-release.sh did not
upload a .sig — so the self-updater's signature check was silently skipped
(1.0.0-beta had no checksums.txt.sig). Make signing unconditional:

- internal/upgrade/signature.go: bake the canonical release public key as the
  compiled-in default (public, safe to commit; removes the empty-env footgun).
- .goreleaser.yml: drop the pubkey ldflag (committed default is authoritative)
  + add a signs: block that runs scripts/sign-checksums over checksums.txt.
  sign-checksums requires -key, so an unset RELEASE_SIGNING_KEY fails the build
  instead of shipping unsigned.
- scripts/ship.sh: source RELEASE_SIGNING_KEY from ~/.config/unarr-release/signing.key
  (or the env), die if absent, and assert checksums.txt.sig was produced.

Private key lives outside the repo (gitignored keyfile + operator's vault);
public key verified to match (priv[32:] == baked pubkey).
2026-06-03 19:23:19 +02:00
Deivid Soto
547b0d4e37 fix(stream): retry thumbnail extraction with output-seek on seek-index failure
Fast input seek (-ss before -i) fails on files whose seek index is imprecise
or mildly corrupt: the demuxer lands mid-EBML element ("invalid as first byte
of an EBML number") and decodes no frame, so the web scrubber showed a broken
image (2026-06-03, anime MKVs: 15/15 prewarm thumbnails failed). When the fast
path yields no frame, retry once with output seek (-ss after -i, decode from
the start) + -err_detect ignore_err. Applied in both the on-demand handler
(buildThumbnailArgsAccurate) and the prewarm extractor (ExtractThumbnailJPEG).
Cost is paid only when the fast path fails, so healthy files keep the cheap path.

Regression test: TestBuildThumbnailArgsAccurate.
2026-06-03 18:55:49 +02:00
Deivid Soto
1814d59e09 fix(stream): clamp out-of-range audio-track index to 0🅰️0
The web persists the chosen audioIndex globally, so a value from a
multi-track file can arrive for a file with fewer tracks. buildHLSFFmpegArgsAt
mapped `-map 0🅰️N?` verbatim; the optional `?` then matched nothing and the
HLS output had NO audio stream (video-only — 2026-06-03, Wistoria S02E08 had
one audio track but the session carried audioIndex=2). Clamp an out-of-range
index to the first track so audio is never silently dropped.

Regression test: TestBuildHLSFFmpegArgsAudioClamp.
2026-06-03 18:55:42 +02:00
Deivid Soto
2148b0e2cc feat(agent): report isDocker so the web shows a docker pull command
The binary self-update hard-stops inside a container (internal/upgrade
refuses when /.dockerenv exists), so the web's in-app 'force update' button
is futile for Docker agents. Report whether we run in a container via a new
RunningInDocker() helper (UNARR_DOCKER env baked into the image, /.dockerenv
fallback for podman/containerd) on every register + sync, so the web can
swap the button for a copy-paste 'docker compose pull' command.
2026-06-03 18:09:29 +02:00
Deivid Soto
ccd50e7c8e chore(release): 1.0.0-beta
Some checks failed
Release / release (push) Failing after 18m26s
Release / docker (push) Has been cancelled
- Bump version to 1.0.0-beta
- Update CHANGELOG.md
2026-06-03 17:22:20 +02:00
Deivid Soto
b6ddeea129 feat(library): content fingerprint + path-resilient sync + stream self-heal
Stop treating the absolute path as a file's identity so a base-path change
(host binary→docker remap, moved media folder, remount) no longer makes the
server duplicate and orphan library rows.

- fingerprint.go: ComputeFingerprint = sha256(size ‖ first 1MiB ‖ last 1MiB),
  a stable content identity that survives rename/move/base-path change. Cached
  in LibraryItem and reused on incremental scans when size+mtime are unchanged.
- sync: send fingerprint + rel_path (relative to the scan root) + agent_id in
  the library-sync request, so the server can move a row in place and scope
  stale-cleanup per agent.
- daemon: force a FULL re-scan (with a user-facing WARNING) when the scan root
  changed since the last cache, so the server re-maps by fingerprint instead of
  duplicating. basePathChanged compares filepath.Clean'd roots.
- daemon: relocateUnreachable self-heals a stream request whose path is under an
  old root but whose file still exists under a current allowed root, so playback
  works immediately without waiting for the re-scan. Conservative: requires a
  3-segment tail and re-checks containment after resolving symlinks so it can
  neither serve the wrong file nor escape the allowed dirs.

See docs/plans/unarr-path-resilience.md in the web repo.
2026-06-03 12:08:58 +02:00
Deivid Soto
e298ff6c05 fix(stream): don't cache transient libplacebo probe timeouts
Second critico pass on the functional probe.

- The probe does real Vulkan device init, which can transiently fail when the
  box is busy (notably the startup warm racing the encode benchmark). Caching
  that timeout as a permanent 'no' would pin HDR to the zscale CPU chain until
  daemon restart. Now a deadline is NOT cached — only a clean non-zero exit
  (filter absent / no ICD), which is a stable result. zscale stays cached as
  before (cheap deterministic grep, can't flake).
- Surface the exec error when ffmpeg never produced stderr (timeout / ENOENT):
  the fallback log now shows err.Error() instead of a blank tail, so 'no
  Vulkan' is distinguishable from 'ffmpeg never ran'.
- Dockerfile comment: clarify the Vulkan ICD (not GLX) is the load-bearing
  mount that 'graphics' adds; 'compute' alone doesn't mount it.

Probe still returns true on a Vulkan host (verified); engine tests green.
2026-06-03 10:48:30 +02:00
Deivid Soto
5e5a719f27 feat(stream): enable GPU libplacebo in prod image + gate to real GPU
Make libplacebo actually reachable in the shipped agent image, and refuse it
where it would be a regression.

Dockerfile (so a Vulkan-capable host can use the GPU tonemap path):
- install libvulkan1 (the Vulkan loader libplacebo links at runtime; ~150 KB)
- add 'graphics' to NVIDIA_DRIVER_CAPABILITIES so the nvidia container runtime
  mounts the Vulkan ICD (nvidia_icd.json + GLX libs) under --gpus all
Both are inert without a working Vulkan GPU — the functional probe gates use.

hls.go: gate libplacebo on a real HW encoder (HWAccel != none). A software-only
host with mesa would expose lavapipe (CPU Vulkan); the functional probe accepts
it but its tonemap is SLOWER than the zscale CPU chain, so libplacebo there is a
regression. No HW encoder -> stay on zscale.

Verified on the GPU dev box: nvenc session still picks libplacebo (-c:v
h264_nvenc -vf ...,libplacebo=...:tonemapping=bt.2390); new unit test locks the
software-encoder path onto zscale.
2026-06-03 10:42:16 +02:00
Deivid Soto
cfaedb7f3b fix(stream): functional libplacebo probe + benchmark hardening
Review (critico) caught a regression: the prod agent image ships a BtbN GPL
ffmpeg with libplacebo COMPILED IN but no Vulkan runtime (debian-slim, no
libvulkan1/mesa-vulkan-drivers/nvidia ICD). The presence probe (ffmpeg
-filters) would flip HasLibplacebo on, the filter's Vulkan device creation
would fail at runtime, and HDR sources that previously tonemapped via zscale
would break.

- FFmpegSupportsLibplacebo now RUNS the filter on one synthetic frame and
  requires a clean exit (forces Vulkan device init + filtergraph negotiation),
  so it is honest about THIS host: works on Vulkan-capable hosts, falls back to
  zscale where Vulkan is absent. Logs the real ffmpeg error on failure.
- Warm the libplacebo (Vulkan init ~1.7s) + zscale caches in a background
  goroutine at startup so the first stream session doesn't pay the probe and
  risk its setup timeout.
- Benchmark: margin 1.5x -> 2.0x (the probe measures encode only; real decode
  of HEVC/10-bit + busier content needs more headroom), per-probe timeout
  12s -> 6s + overall 45s -> 20s (it blocks registration on software hosts),
  and a 'no rung measured' case (missing lavfi/wedged ffmpeg) now keeps the
  1080 default instead of flooring at 480 — an infra failure isn't a slow host.

Verified e2e on the fixed binary: LOTR Two Towers (HEVC 3840x1608 10-bit
HDR10/PQ, 12GB) on desktop-Chrome caps -> hls, ffmpeg runs h264_nvenc with
-vf ...,libplacebo=...:format=yuv420p:tonemapping=bt.2390 (zscale chain
replaced), 45 fMP4 segments, ffprobe confirms output h264 yuv420p bt709
(tonemapped from bt2020/smpte2084), no ffmpeg errors.
2026-06-03 09:57:48 +02:00
Deivid Soto
ef3b190e0b feat(stream): benchmark software encode ceiling at startup
Replace the guessed transcode ceiling (CPU->1080, GPU->2160) with a measured
one. HW encoders still return 2160 instantly. A software-only host runs a
bounded encode benchmark — 3s testsrc2 through the real libx264 superfast
settings at 1080/720/480, top-down — and reports the rung it sustains at
>=1.5x realtime (margin for real decode + busier content).

Fixes risk 2: a weak NAS/old CPU that is ffmpeg-capable but can't keep up
with a 1080p software encode no longer advertises a 1080 ceiling, so
decideStreamPlan routes oversized sources to an external player instead of a
stuttering transcode. Floors at 480; each probe is timeout-bounded so a
wedged ffmpeg can't stall daemon startup.
2026-06-03 09:30:03 +02:00
Deivid Soto
005a4380dd feat(stream): GPU HDR tonemap via libplacebo
Prefer the single-pass Vulkan libplacebo filter over the CPU zscale chain
for HDR->SDR tonemapping when the agent ffmpeg has it. One GPU pass does
tonemap + BT.709 primaries/transfer/matrix + 8-bit yuv420p, replacing the
four-stage zscale chain and its trailing format=/setparams. Higher quality,
far cheaper than the CPU path, and present on builds that lack zscale.

- FFmpegSupportsLibplacebo probe (cached, mirrors FFmpegSupportsZscale)
- HasLibplacebo on TranscodeRuntime, wired from buildTranscodeRuntime
- hls.go: videoTail picks libplacebo when present (not h264_vaapi), else
  keeps the zscale tonemap + format chain
- test: libplacebo replaces the zscale chain, never runs alongside it
2026-06-03 09:29:55 +02:00
Deivid Soto
325c11c1eb fix(stream): clean HLS segments — no B-frames, no scene-cut, CFR
Slightly-VFR / B-frame MKV sources made ffmpeg's fMP4 muxer emit a continuous
"Packet duration is out of range" flood and produce uneven segment lengths the
web player stuttered on. Add, on the two main encoders + globally:

- libx264: -bf 0 -sc_threshold 0
- h264_nvenc: -bf 0 -no-scenecut 1
- -fps_mode cfr (force constant frame rate)

Keyframe cadence stays driven by -force_key_frames, so every segment is exactly
hls_time long. Verified: the warning flood drops from dozens/sec to ~1 per 80s
of transcoded content (cosmetic), segments stay valid fMP4.
2026-06-03 09:12:05 +02:00
Deivid Soto
ea152a2276 feat(stream): /speedtest endpoint for agent-path bandwidth probing
The web player measured bandwidth against the web origin, which says nothing
about the path the video actually travels (LAN-direct, tailnet, or the CF
funnel) — on a fast LAN where the web server is the slow link it falsely
recommended a lower resolution. Serve a fixed-size, incompressible payload
from the agent so the web can measure the REAL stream path.

- GET /speedtest?size=N (clamped 64KB–4MB, default 2MB), HEAD supported
- CORS-gated like the other endpoints; no auth (carries no data)
- single-flight guard (atomic): one measurement at a time → a concurrent
  request gets 429, bounding the bandwidth an unauthenticated caller can
  drain over the public funnel
2026-06-02 22:52:25 +02:00
Deivid Soto
2b5a45674a fix(stream): report stream failures via StreamError + retry transient stat
Stream-request failures previously reported status:"failed", which the web
treated as a download failure — it left the task unstreamable and surfaced a
misleading 20s timeout. Report them through a dedicated StreamError field
instead, so the web clears the stream flag and shows the real reason without
touching the download status.

- StatusUpdate gains StreamError (json: streamError)
- OnStreamRequested reports failures via a reportStreamError helper (path
  rejected, file not found, no video in dir) instead of status:"failed"
- os.Stat is retried 3× (300ms) before giving up — NFS can transiently fail
  (ESTALE/EAGAIN/timeout), the root of the intermittent "works on the 3rd try"
- dispatch OnStreamRequested off the sync loop (goroutine): it does blocking
  I/O (stat retries, ffprobe in SetFile) that would otherwise stall task
  dispatch + status reporting for other items
2026-06-02 20:31:49 +02:00
Deivid Soto
f7ea06c70a fix(stream): honor client network-caching in the M3U playlist
playlistHandler hardcoded #EXTVLCOPT:network-caching=30000, so VLC pre-buffered
~30 s before starting playback even on a fast, range-served LAN/Tailscale
source — the "VLC loads the whole movie before playing" regression.

Read the value from a networkCaching query param (clamped 500–60000ms) and
default to 3000 when absent. The web sends a network-aware value (small on
LAN/Tailscale, larger on the CF funnel); older web clients fall back to the
modest default instead of the old 30 s wall.
2026-06-02 19:42:05 +02:00
Deivid Soto
f0ac905fdb feat(library): detect corrupt/incomplete files during scan
ffprobe already runs on every scanned file; now we capture its stderr and
assess integrity from it. assessIntegrity flags a file "damaged" on the
markers that mean the container/bitstream is unusable: invalid_data,
ebml_corrupt, moov_missing, bitstream_corrupt, plus no_duration (a video
stream with non-positive duration = a truncated/incomplete download).

The verdict rides on MediaInfo.Integrity (IntegrityInfo{Damaged,Reason}),
maps onto LibrarySyncItem.{Integrity,IntegrityReason}, and syncs to the web
so a damaged file can be surfaced at rest instead of only blowing up at
playback.

Bumps the scan cache version (1 → 2) so existing entries re-probe once, and
the scanner re-probes any cached entry that has no integrity verdict yet.
2026-06-02 19:42:00 +02:00
Deivid Soto
c86e50245e chore(release): 1.0.0-beta
First beta of the 1.0 line — the full unarr streaming agent (HTTPS streaming,
HLS transcode, on-demand + cached subtitles/thumbnails, burn-in, debrid HLS,
SSE realtime, auto-resume, seeding).
2026-06-02 14:08:30 +02:00
Deivid Soto
bc6f85bf39 fix(stream): /critico review fixes for the sidecar cache
- ExtractSubtitlesVTTMulti: distrust output when ffmpeg is killed by signal
  (45-min timeout on a too-big remux) — a truncated WebVTT passed the len>0
  check and got cached as a silently-incomplete track until the media mtime
  changed. Skip all output on signal-kill; keep it on a clean non-zero exit.
- stream handlers: read the sidecar cache BEFORE the ffmpegPath guard so a
  pre-warmed sub/thumbnail still serves if ffmpeg was removed after the cache
  was filled.
- scan: log when the prewarm is skipped because ffmpeg is unavailable (matches
  the daemon; CLAUDE.md wants bootstrap to log on every branch).
- unexport sidecarDir/subtitleCachePath/thumbnailCachePath (no external callers).
- prewarm: surface a sample error in the summary so a systemic ffmpeg failure
  is distinguishable from one corrupt file.
- add unit tests: codec whitelist, cache paths, mtime freshness, atomic write,
  thumb-position dedup.
2026-06-02 13:46:07 +02:00
Deivid Soto
1c8cc1c409 perf(stream): run the subtitle/thumbnail prewarm at idle I/O priority
The prewarm's single big read (a ~14 min sequential pass over a 60GB remux to
demux subtitles) shares the same disk/NFS as live streaming. Lower the prewarm
ffmpeg processes to the Linux IDLE I/O class (ioprio_set) so that background
read yields bandwidth to a user who's actually watching — the prewarm slows
down under contention instead of starving playback, and runs full speed when the
disk is idle.

Applied only to the prewarm-only extractors (ExtractSubtitlesVTTMulti,
ExtractThumbnailJPEG) via Start → setIdleIOPriority(pid) → Wait; the on-demand
/sub + /thumbnail handlers keep normal priority (a user is waiting on those).
Linux-only syscall behind a build tag; a no-op stub elsewhere. Best-effort —
errors ignored, never required for correctness.

Verified: the prewarm ffmpeg shows 'idle' under ionice -p; on-demand stays normal.
2026-06-02 11:51:26 +02:00
Deivid Soto
8a47132f15 perf(stream): extract all text subtitles of a file in one ffmpeg pass
Subtitle extraction is I/O-bound: subtitle packets are interleaved across the
whole container, so ffmpeg must read the entire file to demux a complete track
(measured ~57 MB/s reading a 60GB remux over ~75 MB/s NFS → ~14 min for the
full read). Doing that once per track meant N full reads of a huge file.

ExtractSubtitlesVTTMulti demuxes the container ONCE and routes every text track
to its own WebVTT output, so an N-text-track file costs one read instead of N.
The prewarm now enqueues one job per file (all its text indices) and raises the
per-file deadline to 45 min so even ~200GB remuxes finish the single read in the
background (idempotent; the on-demand /sub keeps its 60s fallback). Thumbnails
are unaffected — a keyframe seek reads a tiny slice (~0.7s even on 60GB).
2026-06-02 10:09:28 +02:00
Deivid Soto
1e5de874cf feat(stream): cache scan-time thumbnail frames to the .unarr sidecar
Pre-extract the file panel's sample frames (10/30/50/70/90% of runtime, w=320)
during the library scan and write-through any on-demand /thumbnail request into
the hidden ".unarr/<name>.t<sec>w<width>.jpg" sidecar. The /thumbnail handler
serves a fresh sidecar instantly, so the characteristics panel and seekbar
previews stop re-running ffmpeg per request.

- mediainfo.sidecar: ThumbnailCachePath, ReadCachedThumbnail, WriteCachedThumbnail,
  ExtractThumbnailJPEG (mirrors engine.buildThumbnailArgs).
- library.PrewarmSidecars: also enqueues the panel frame positions (kept in
  lockstep with the web's THUMB_FRACTIONS / THUMB_WIDTH) per item with a duration.
- thumbnailHandler: cache-read → hit; miss → extract → write-through.
- config: library.cache_thumbnails (default true) + both cache toggles exposed in
  the interactive 'unarr config' library menu.

Local only by design — frames are the user's own content, never uploaded.
2026-06-02 09:20:00 +02:00
Deivid Soto
178c16f458 feat(stream): cache extracted subtitles to a hidden .unarr sidecar
On-demand WebVTT extraction re-ran ffmpeg on every /sub request and, for
50GB+ remuxes, couldn't finish a full text track within the 60s HTTP timeout
→ the web player got a 500 and no subtitles.

Extract each text subtitle ONCE — during the library scan (no HTTP deadline,
generous per-file timeout) and write-through on the first on-demand request —
into a hidden ".unarr/<name>.s<index>.vtt" sidecar next to the media file.
The /sub handler serves a fresh sidecar instantly (mtime-invalidated when the
media is replaced), so playback subtitles are instant and huge files work.

- mediainfo.sidecar: cache paths, mtime freshness, atomic write, ExtractSubtitleVTT,
  IsTextSubtitleCodec (shared classifier, mirrors engine + web whitelists).
- library.PrewarmSidecars: bounded, idempotent, ctx-cancellable background pass
  run after every scan (manual + daemon auto-scan).
- subtitleHandler: cache-read → hit; miss → extract → write-through.
- config: library.cache_subtitles (default true), wired via SetCacheSubtitles.

Local-only by design: nothing extracted is uploaded — the sidecar is the user's
own content, private to their disk.
2026-06-02 09:10:36 +02:00
Deivid Soto
7417fad45f feat(stream): serve embedded text subtitles as on-demand WebVTT
Add GET /sub?p=&i=&t= that extracts an embedded text subtitle stream to
WebVTT via ffmpeg (-map 0:s:N -c:s webvtt), token-gated with a per-track
sub:<sha256(path)>:<index> scope. The web player attaches these as
external <track>s for both direct-play and HLS, native and hls.js.

Removes the old per-session extraction path (extractSubtitles,
ServeSubtitle, manifest SUBTITLES renditions, subs/ mkdir, Close() wait):
native HLS playback never surfaced manifest subs, so that work was wasted.
The on-demand /sub endpoint is now the single subtitle source.
2026-06-01 23:50:39 +02:00
Deivid Soto
08cb58073d Merge branch 'main' into feat/unarr-agent 2026-06-01 20:13:12 +02:00
Deivid Soto
c4ddd44a1a feat(docker): glibc base with nvenc ffmpeg + par2/7z extractors
Some checks failed
CI / Test (push) Successful in 3m35s
CI / Build (push) Successful in 1m33s
CI / Build-1 (push) Successful in 2m0s
CI / Build-2 (push) Successful in 1m34s
CI / Build-3 (push) Successful in 1m33s
CI / Build-4 (push) Successful in 1m35s
CI / Build-5 (push) Successful in 1m33s
CI / Lint (push) Failing after 2m31s
CI / Coverage (push) Successful in 2m48s
CI / Vet (push) Successful in 2m2s
Alpine/musl can't run NVIDIA's glibc userspace (nvidia-smi, libnvidia-encode,
the static nvenc ffmpeg), so HW transcode was impossible — every 4K/anamorphic
HLS encode fell back to software or failed. Switch the runtime stage to
debian:bookworm-slim + a static BtbN ffmpeg built with nvenc, add par2
(Usenet segment repair) + 7z (RAR/7z extraction), and set
NVIDIA_DRIVER_CAPABILITIES=video,compute,utility so a plain --gpus all (or the
compose device reservation) lights up nvenc with no extra flags. Falls back to
libx264 automatically when no GPU is attached. Build stage cross-compiles
(--platform=BUILDPLATFORM) so multi-arch stays fast; downloads forced over IPv4.
2026-06-01 19:36:41 +02:00
Deivid Soto
8accafbe59 fix(stream): derive H.264 level from frame macroblocks, not height
Anamorphic 2.39:1 scaled to 1080 height = ~2586x1080 = 11016 MBs, busting
level 4.1's 8192-MB MaxFS -> nvenc "InitializeEncoder failed: Invalid Level"
(libx264: "frame MB size > level limit") -> 0 segments, session stalls. Most
4K rips are 2.39:1, so HLS playback was silently broken for them.

H264LevelForFrame(w,h) derives the level from the real macroblock count
(max of MB-tier and height-tier). hls.go computes output width and uses it.
16:9 unchanged; anamorphic bumps to 5.0 when needed. Discovered + verified
during the trickplay smoke.
2026-06-01 19:30:48 +02:00
Deivid Soto
6436c9fb6b docs(roadmap): close the realtime hueco + mark Tailscale-Funnel note stale
Long-poll→WS/SSE closed: the 3-leg realtime work (SSE downlink with
long-poll fallback, event-driven uplink on every state transition, and
server→browser push via a Redis signal bus) shipped in 0.14.0. The
"Tailscale Funnel mal nombrado" note was already stale — the code names it
"CloudFlare Quick Tunnel" everywhere; nothing to rename. Also struck the
duplicate HTTP-clients entry (already in Cerrada).
2026-06-01 19:24:20 +02:00
Deivid Soto
864b6ea832 feat(agent): event-driven uplink — sync on every state transition
The agent reported its state only on the adaptive sync tick (3s watching /
10s idle), so a resolving→downloading→verifying→organizing→completed
transition could lag up to a full interval before the server (and the web
UI) saw it. Now every successful Task.Transition fires an onChange hook
wired to TriggerSync, pushing the new state immediately. Bursts are safe:
TriggerSync is a buffered-1 send, so clustered transitions coalesce into
one sync.

- Task gains an onChange hook fired AFTER the status mutex is released
  (so a future heavier hook can't deadlock on task.mu); nil is a no-op.
- Manager.OnStateChange is set on each task at Submit; the daemon wires it
  to TriggerSync alongside the existing OnTaskDone.
- Stream tasks transition outside the Manager, so handleStreamTask wires
  the same hook explicitly (gap found in review) — resolving/downloading/
  completed/failed on the stream path now push too.

The adaptive ticker stays as a reconciliation heartbeat; it's just no
longer the latency floor for state changes.
2026-06-01 19:09:44 +02:00
Deivid Soto
1052529ca2 feat(agent): hybrid SSE downlink with long-poll fallback
Replace the bare long-poll wake listener with a hybrid server→agent
downlink that consumes the new GET /api/internal/agent/events SSE stream
first and falls back to the long-poll wake when SSE is unavailable or
silently buffered. Resurrects the SSE client retired with WebRTC
(signal_client.go) as events_client.go — a bounded-scanner reader
(256 KiB line / 1 MiB event) that surfaces heartbeat comments as ping
events so the consumer can detect liveness.

runDownlink dispatches on the new [daemon] downlink config:
  - auto (default): SSE-first; after maxSSEFailures dead/buffered attempts
    fall back to long-poll for 5 min, then re-probe SSE.
  - sse:  SSE only, no fallback (known-good networks / testing).
  - poll: the pre-0.14 long-poll wake only.

A stream is "healthy" only if it delivers a frame within livenessTimeout
(40s vs the server's 15s heartbeat). Crucially the liveness-timeout branch
returns UNHEALTHY even if an earlier frame arrived: a proxy that flushes
the connect preamble (one ping) then stalls must not pin the agent to SSE
forever — that's the partial-buffering case the fallback exists for.

event: command applies typed controls via the same OnControl callback
/agent/sync uses (idempotent); event: sync triggers an immediate sync;
ping is liveness-only. OpenEventStream rides MirrorPool failover for the
initial connect; mid-stream drops close the channel and the loop reopens.

Bump 0.14.0.
2026-06-01 17:31:42 +02:00
Deivid Soto
96b23ed051 feat(agent): give the public API client mirror failover
The public-API go-client (search/popular/etc.) had no mirror failover while
the agent control-plane client did — a primary-domain takedown broke public
calls. Inject a MirrorRoundTripper that reuses the SAME MirrorPool type +
IsTransient policy, rotating to cfg.Auth.Mirrors on a transient error/5xx.
WithRetry(0) hands failover ownership to the transport (no nested retry).
2026-06-01 15:53:00 +02:00
Deivid Soto
3d51013935 fix(agent): surface par2/install/NFS failures instead of degrading silently
- usenet: Par2Verify/Repair return ErrPar2NotInstalled (was nil="verified");
  pipeline surfaces it via Result.VerifyNote + WARNING — a download that
  shipped parity but couldn't be checked is delivered UNVERIFIED, not verified.
- funnel: pin cloudflared version + verify a baked-in SHA-256 (was `latest` +
  ELF-magic only) — a malicious/broken upstream release isn't pulled silently.
- stream: makeReadable verifies the file actually opens after chmod and warns
  clearly (NFS root_squash / SMB uid mapping) instead of a cryptic later EPERM.
- WireGuard endpoint pin dropped from the debt list (reseller uses direct
  config, no pin).
2026-06-01 15:52:54 +02:00
Deivid Soto
27bee8cdf4 feat(stream): optional per-agent HTTPS listener with hot-reloadable cert
Foundation for direct, valid-cert browser playback (agent-TLS feature) — the
cert broker + DNS are a later phase; this is inert until a certificate exists.

- StreamServer runs a second TLS listener on https_stream_port (default 11819)
  serving the SAME mux as HTTP (11818): same token + CORS gates, no new exposure.
- Certificate is read per-handshake from an atomic holder via tls.Config
  GetCertificate, so a cert issued/renewed asynchronously applies without a
  restart. SetTLSCertificate / LoadTLSCertificateFromFiles / HasTLSCertificate.
- Daemon arms HTTPS only when a cert pair exists at certs/agent.{crt,key} under
  the state dir; without it, no HTTPS port is opened and HTTP + funnel are
  unaffected. Shutdown drains the HTTPS server too.
- config: downloads.https_stream_port (default 11819, 0 = disabled).

Tests: real TLS handshake + hot-install (no-cert handshake fails, install →
200), disabled path, missing-cert load error.
2026-06-01 13:03:35 +02:00
Deivid Soto
132c88b3f0 feat(seeding): wire seed ratio/time lifecycle into the torrent daemon
SeedRatio/SeedTime were declared on TorrentConfig but never consumed, and
SeedEnabled was hardcoded false in both constructors — the daemon never
seeded, and if forced it seeded forever.

- config: [downloads] seed_enabled/seed_ratio/seed_time (opt-in, off by default)
- daemon: parse seed_time + wire all three; startup log per target shape
- engine: seedTargetReached() (pure) + seedAndDrop() background monitor on a
  downloader-scoped seedCtx (not the task ctx, which dies when Download returns);
  drops the torrent on ratio (uploaded/size) OR time, whichever first; no target
  = seed until shutdown. Configurable check interval (tests lower it).
- fix: cleanup() now always drops — previously leaked the handle on error paths
  when seeding was enabled.
- refactor: dropTracked() helper shared by cleanup + post-seeding drop.

Tests: TestSeedTargetReached (9 cases) + ctx/no-target branches + loopback
swarm smoke (-tags smoke). Roadmap hueco closed.
2026-06-01 10:30:39 +02:00
Deivid Soto
665ec0a34f feat(stream): burn bitmap (PGS/DVB) subtitles into the video via overlay
Bitmap subs can't be served as WebVTT, so the user picks one and the daemon
re-encodes with it overlaid. HLSSessionConfig.BurnSubtitleIndex (*int, nil=no
burn) flows into the cache key + a -filter_complex graph:
  [0✌️0]<vchain>[base];[0:s:N][base]scale2ref[sub][base2];[base2][sub]overlay[vout]
Overlay after the tonemap (SDR subs keep brightness); scale2ref fits the PGS
canvas to the output. Invalid/text/out-of-range index -> clean-encode fallback.
IsTextSubtitle now includes "text" (parity with the web classifier).
2026-06-01 09:51:27 +02:00
Deivid Soto
8207d1d2a9 fix(stream): derive H.264 level from frame macroblocks, not height
Anamorphic 2.39:1 scaled to 1080 height = ~2586x1080 = 11016 MBs, busting
level 4.1's 8192-MB MaxFS -> nvenc "InitializeEncoder failed: Invalid Level"
(libx264: "frame MB size > level limit") -> 0 segments, session stalls. Most
4K rips are 2.39:1, so HLS playback was silently broken for them.

H264LevelForFrame(w,h) derives the level from the real macroblock count
(max of MB-tier and height-tier). hls.go computes output width and uses it.
16:9 unchanged; anamorphic bumps to 5.0 when needed. Discovered + verified
during the trickplay smoke.
2026-06-01 08:29:10 +02:00
Deivid Soto
9c995fc4dd 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.
2026-05-31 23:23:39 +02:00
Deivid Soto
e4373454ba feat(transcode): tonemap HDR sources to SDR (zscale-gated)
HDR (HDR10/HLG/Dolby Vision) transcoded to SDR came out washed-out and
desaturated because the filter chain never tonemapped. buildHLSFFmpegArgsAt now
inserts a zscale linearise -> hable tonemap -> BT.709 chain after the scale and
before format=, but only when the source is HDR and the ffmpeg build has zscale
(FFmpegSupportsZscale, cached). Builds without zimg keep the old behaviour
(plays, just desaturated) instead of erroring.

It's a CPU filter, valid for every encoder here: the decode hwaccel deliberately
leaves frames in system memory (no -hwaccel_output_format), so zscale runs ahead
of format=/hwupload exactly like the existing scale filter. Verified on a real
4K HDR10 file — vivid colour and deep blacks vs the washed-out baseline.
2026-05-31 23:01:09 +02:00
Deivid Soto
445da233c0 feat(agent): auto-resume interrupted downloads after a daemon restart
A daemon restart used to abandon in-flight downloads: the in-memory queue was
lost and the web doesn't re-dispatch a stuck task, so the user had to retry
manually. The bytes already persisted (mmap + anacrolix's piece-completion DB
keyed by info_hash; debrid via Range; usenet via its tracker) — the daemon just
didn't re-attempt the work.

ActiveTaskStore persists each in-flight download's agent.Task payload to
active-tasks.json; the daemon re-submits them on startup so the downloaders
resume the partial data. manager.Submit now dedups (the startup re-submit and a
later web re-dispatch can't both run), and recordFinished removes a task from
the store only on a genuine terminal — shuttingDown (set before Shutdown cancels
the task contexts) keeps shutdown-interrupted tasks so they resume next start.
Stream/seed/upgrade tasks aren't persisted; ForceStart is cleared on resume.
2026-05-31 22:44:05 +02:00
Deivid Soto
b708bb8ab2 docs(roadmap): mark unarr localized-route 404 fixed 2026-05-31 22:08:30 +02:00
Deivid Soto
1cad73b9a7 feat(downloads): pre-flight free-disk guard before each download (hueco medio)
CheckDiskSpace (internal/engine/diskspace.go) refuses a download before
writing when its expected size wouldn't leave a configurable reserve free,
so a download never fills the filesystem to 0 mid-write (which corrupts the
partial file). Wired into all three downloaders ahead of any write — torrent
(DataDir), debrid (outputDir, resume-aware), usenet (outputDir, fresh only).
Reserve from downloads.min_free_disk_mb (default 2048 MiB) via SetMinFreeBytes.

The manager treats an InsufficientDiskError as terminal — no source fallback,
since another source would fill the same disk — and surfaces the clear message.
Best-effort: unknown size or a stat failure doesn't block (ENOSPC stays the
backstop). Also hardens formatBytes against an exabyte-scale out-of-bounds panic.
2026-05-31 21:48:34 +02:00
Deivid Soto
2be92516c6 feat(stream): on-demand frame thumbnails via /thumbnail (hueco medio)
Add GET /thumbnail to the agent stream server: ffmpeg extracts one frame
at a timestamp (-ss before -i, single-frame MJPEG to stdout) for the web's
file-characteristics panel. Auth via a token scoped thumb:<sha256(path)>
(same HMAC scheme as /stream and /hls; the web mints, the agent verifies),
clamped to a real regular file, 404-no-oracle on a bad token, 20s timeout.
ffmpeg path wired into the stream server from the daemon. Version -> 0.13.0.
2026-05-31 18:27:22 +02:00
Deivid Soto
950cdb4efe docs(roadmap): mark hueco #2 closed (2a+2b+2c) 2026-05-31 17:04:10 +02:00
Deivid Soto
7562b62241 feat(stream): refresh expired debrid links mid-stream (hueco #2/2c)
Debrid direct links are time-limited; a long playback can outlive the link
the session was created with. When a debrid source dies mid-stream the daemon
now re-resolves a fresh link for the same content and resumes — no torrent
fallback, no playback restart.

- debridFileProvider holds the URL behind a mutex; on an expired-link status
  (401/403/404/410) the ranged reader re-resolves via a refresh callback and
  retries (bounded: 1 initial + 1 post-refresh attempt). A browser opens
  several range connections, so the refresh is coalesced singleflight-style —
  N readers hitting the dead link share ONE re-resolution, not N.
- HLS-from-URL: the auto-restart supervisor re-resolves the link before
  relaunching ffmpeg (else it just retries the dead URL and burns the retry
  budget). The mutable URL lives in s.liveURL under s.mu — restartFromSegment
  reads it from the HTTP handler goroutine too (seek-restart), so cfg stays
  immutable and the write races nothing.
- agentClient.RefreshStreamURL → POST /api/internal/agent/stream-url.

Cross-source torrent<->debrid swap (the rare "debrid genuinely gone" case) is
intentionally deferred. Reader refresh + coalescing covered by unit tests
(incl. -race); the web endpoint re-resolves against a real AllDebrid account.
2026-05-31 17:02:59 +02:00
Deivid Soto
4946982783 docs(roadmap): mark hueco #2/2b (HLS-from-URL) closed 2026-05-31 16:23:45 +02:00
Deivid Soto
992e16ba05 feat(stream): transcode debrid sources to HLS from a URL (hueco #2/2b)
Non-browser-native debrid content (mkv/HEVC/…) can now stream: ffmpeg reads
the debrid HTTPS link directly (-i <url>) and transcodes to HLS, instead of
2a's raw direct-play which only works for mp4/m4v.

- HLSSessionConfig gains SourceURL + CacheID; sourceRef() feeds ffprobe,
  ffmpeg -i, and subtitle extraction from one place. HTTP-resilience flags
  (-reconnect*, -rw_timeout) are added only for a URL source; a seek-restart
  re-opens the URL with a Range request (-ss before -i = input seek).
- Segment cache keys by CacheID (the torrent info_hash) for URL sessions so
  re-plays hit cache despite the debrid URL changing each resolution
  (KeyForID, no filepath.Abs).
- OnStreamSession: the 2a direct-play branch is now gated on PlayMethod != "hls";
  a new branch handles DirectURL + PlayMethod=="hls" → HLS-from-URL. The
  local-file and both debrid HLS paths share a startHLSPlayback helper.
- ExtractMediaInfo no longer masks a URL probe failure as "file not found"
  (surfaces ffprobe's real stderr, e.g. "Protocol not found" on a TLS-less
  ffmpeg build).
- Bump 0.11.0 -> 0.12.0 as the HLS-from-URL floor the web gates on.

Validated e2e against real AllDebrid: a cached HEVC x265 mkv transcodes
(h264_nvenc) from the debrid URL and plays 1080p in Chrome via hls.js,
subtitles extracted from the remote mkv.
2026-05-31 16:22:14 +02:00
Deivid Soto
b8d2b90370 feat(stream): serve /stream from a debrid HTTPS link (hueco #2/2a)
The daemon can now stream a session straight from a server-resolved debrid
direct URL instead of disk/torrent, delivering the "play instantáneo
cache-fast" promise for cache-confirmed torrents the user never downloaded.

- debridFileProvider: an io.ReadSeekCloser over HTTP Range — network-free
  Seek, lazy GET on Read, reopen-on-seek, a HEAD up front for the size, and
  a URL-derived name so the served Content-Type is video/mp4 (not
  octet-stream) when the web's name lacks an extension.
- OnStreamSession branches on StreamSession.DirectURL before the filePath
  checks (no local path, no ffmpeg), builds the provider in a goroutine
  (HEAD off the sync loop) and marks the session ready.
- Bump 0.10.0 -> 0.11.0 as the debrid-stream floor the web gates on.

Validated e2e against a real AllDebrid account: a cached mp4 plays 1080p in
Chrome through the agent, including the high-offset seek for a non-faststart
file's moov atom. 2b (HLS-from-URL for mkv/HEVC) + 2c (cache-fast preference
+ mid-stream fallback) remain.
2026-05-31 15:49:58 +02:00
Deivid Soto
292d5923cf fix(stream): allow unarr.app origins for /stream + /hls CORS
The daemon's baked-in CORS allowlist had the torrentclaw.com family but not
unarr.app — so on the unarr brand the browser dropped every /hls + /stream
response (no Access-Control-Allow-Origin) and the player reported "can't
connect to your agent" even though the agent was reachable. Add unarr.app +
www.unarr.app. (Dev over Tailscale uses cors_extra_origins for the raw IP
origin.) Found while testing the web player from an iPhone over Tailscale.
2026-05-31 14:20:49 +02:00
Deivid Soto
5d80ec57b9 docs(roadmap): hueco #3 fully closed — 3d resolved as 3d-lite auto-downshift 2026-05-31 13:15:29 +02:00
Deivid Soto
89236f13b5 docs(roadmap): hueco #3 3c closed (capability negotiation) + TTFF diagnosis 2026-05-31 12:48:50 +02:00
Deivid Soto
957d499658 feat(stream): device-aware remux (HEVC/AV1 + non-aac audio) + TTFF timers
Hueco #3 / 3c (CLI). NewRemuxSource now copies the video for any
browser-decodable codec: h264, or HEVC/AV1 when the web says the device
decodes them (caps). HEVC is muxed with -tag:v hvc1 (Apple requirement),
and non-aac audio (ac3/eac3/dts) is transcoded to aac while the video is
still copied (ActionRemuxAudio) — this covers the very common h264+ac3 mkv.

Startup instrumentation for time-to-first-frame diagnosis:
- remux branch logs [probe=.. spawn=..]
- transcodeSource logs 'first fMP4 bytes after ..' (ffmpeg → first output)
- serveGrowing logs reads that block >250ms (client seeking ahead of the
  live edge) + the first read's offset vs produced/estimated size.

Verified: caps gate (hls without caps, remux with), hvc1 retag (ffprobe of
the /stream output = hevc/hvc1), HEVC playback confirmed on a real iPhone
Safari over Tailscale. LAN timeline: probe 16ms, spawn 1ms, first byte
201ms, no serveGrowing blocks.
2026-05-31 12:44:12 +02:00
Deivid Soto
c18876471c docs(roadmap): hueco #3 phase 3b closed (progressive fMP4 remux) + smoke 2026-05-31 11:56:28 +02:00
Deivid Soto
4a12f13b96 feat(stream): progressive fMP4 remux source for /stream (hueco #3 / 3b-i)
Agent side of 3b: serve a growing ffmpeg `-c copy` remux (mkv h264/aac →
fragmented MP4) over /stream with no video re-encode. Dormant until the web
sends PlayMethod="remux" (3b-ii), so this commit changes no live behavior.

- GrowingSource interface + transcodeSource already satisfies it; estimate is
  the source file size for copy actions (≈ remux output) vs bitrate×duration
  for real transcodes.
- NewRemuxSource: ffmpeg -c copy → growing fMP4 temp, returned as GrowingSource.
- StreamServer.SetGrowingFile + serveGrowing: manual Range responder for a
  growing source (http.ServeContent needs a fixed size). 206 with an estimated
  total in Content-Range; chunked body while not final (never promise bytes a
  running remux might not produce); exact Content-Length once final. Blocks via
  ReadAt for not-yet-produced bytes; forward seek waits, backward seek instant.
- daemon OnStreamSession: PlayMethod=="remux" → NewRemuxSource + SetGrowingFile
  + MarkSessionReady (after the ffmpeg check; copy still needs ffmpeg).
- Tests: parseByteRange + serveGrowing (full/offset/bounded/estimate/HEAD/416).
2026-05-31 11:49:31 +02:00
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
Deivid Soto
0f8e0fec53 docs(roadmap): design hueco #2 (debrid in the streaming path) 2026-05-31 01:22:35 +02:00
Deivid Soto
444d7e63fd feat(stream): authenticate /stream and /hls with signed tokens
/stream and /hls were served with no auth (only CORS + rate limit), so a
funnel- or UPnP-exposed daemon leaked active downloads to anyone with the URL.

Bind a short-lived HMAC token (scope + 6h expiry) to every stream URL the
daemon hands out and verify it on each request:
- /stream + VLC playlist: ?t= query, agent-minted, scope "stream"
- /hls: path segment /hls/<session>/<token>/<resource>, web-minted with the
  agent's reported secret, scope "hls:<session>" — relative playlist URIs
  inherit it with no rewriting
- NO loopback exemption: cloudflared relays public funnel traffic over
  localhost, so a loopback source address is not a trust signal
- the agent reports its per-run signing key on register only when enforcing
- require_stream_token config (default true); secret fails hard if rand fails
- /playlist.m3u no longer self-mints a token (was an open token oracle)

Roadmap: Docs/plans/unarr-agent-roadmap.md (hueco #1).
Deploy the web HLS-minting change BEFORE shipping this agent release.
2026-05-31 01:19:14 +02:00
Deivid Soto
ea00130d08 docs(docker): add docker-compose.yml for one-command setup
Some checks failed
CI / Test (push) Successful in 2m44s
CI / Build (push) Successful in 1m32s
CI / Build-1 (push) Successful in 1m57s
CI / Build-2 (push) Successful in 1m32s
CI / Build-3 (push) Successful in 1m32s
CI / Build-4 (push) Successful in 1m32s
CI / Build-5 (push) Successful in 1m29s
CI / Lint (push) Failing after 2m24s
CI / Coverage (push) Successful in 2m44s
CI / Vet (push) Successful in 1m59s
Rewrite docker-compose.yml with a user-ready setup:
- pull_policy: always — keeps image up-to-date on every `up`
- network_mode: host — required for LAN/Tailscale streaming reach
- UNARR_API_KEY required variable with clear error message
- DOWNLOAD_DIR required variable
- named `unarr-data` volume for piece-DB + HLS cache (keeps them off NFS)
- macOS/Windows bridge + ports alternative in comments
- .env.example alongside with UNARR_API_KEY, DOWNLOAD_DIR, TZ

Quick start: cp .env.example .env && edit .env && docker compose up -d
2026-05-30 09:27:57 +02:00
Deivid Soto
e1fc7b7b6f chore(release): 0.9.19
Some checks failed
Release / release (push) Failing after 22m15s
Release / docker (push) Has been cancelled
- Bump version to 0.9.19
- Update CHANGELOG.md
2026-05-30 09:17:38 +02:00
Deivid Soto
75e191f86b fix(docker): three streaming/reliability bugs found in live docker test
funnel: urlPattern matched api.trycloudflare.com before the real quick-tunnel
URL. Cloudflared logs the control-plane endpoint early, so the agent was
advertising a dead URL. Tighten regex to require at least one hyphen — quick
tunnels are always multi-word (e.g. make-appointments-negotiation-blacks).
Covers with funnel_test.go regression test.

download(oneshot): progress reporter called /api/internal/agent/status with a
synthetic "oneshot-<hash>" task ID that is not a UUID, causing the server to
return 400 every 5 s for the entire download. Pass nil client to
NewProgressReporter for one-shot mode; flush/ReportFinal are no-ops when
reporter == nil so terminal output continues unchanged.

torrent: piece-completion SQLite DB (anacrolix) was created inside the download
dir (DataDir). On NFS/SMB mounts SQLite file locking times out, emitting a
warning and falling back to an ephemeral in-memory DB. Add PieceCompletionDir
to TorrentConfig; the daemon now passes config.DataDir() (agent state dir,
always local) so the DB stays off the network mount. One-shot download leaves
the field empty → harmless in-memory fallback as before.
2026-05-30 08:59:33 +02:00
Deivid Soto
16cc0a3033 chore(release): 0.9.18
Some checks failed
Release / release (push) Failing after 12m18s
Release / docker (push) Has been skipped
- Bump version to 0.9.18
- Update CHANGELOG.md
2026-05-30 00:00:12 +02:00
Deivid Soto
efaa3ce59e fix(stream): make completed torrent files readable (mmap creates 0000)
anacrolix mmap storage (storage.NewMMap) creates completed files with
mode 0000. The download succeeds because the agent keeps its own mmap
handle, but any fresh open — direct streaming (/stream :11818), HLS
ffprobe (:11819), or organize-then-reopen — fails with "permission
denied", surfaced in the web UI as "file not found". Both VLC and the
web player were affected.

makeReadable() relaxes the completed file to 0644 (dirs 0755, recursive
for multi-file torrents) right after download finishes, before organize
moves it, so the readable mode survives the rename.
2026-05-29 23:58:09 +02:00
Deivid Soto
02b600dcbc chore(release): 0.9.17
Some checks failed
Release / release (push) Failing after 19m25s
Release / docker (push) Has been cancelled
- Bump version to 0.9.17
- Update CHANGELOG.md
2026-05-27 22:05:34 +02:00
Deivid Soto
6270ad41cc fix(hls): drop nvenc -tune ll — kills hls segmentation, bump 0.9.17
With `-tune ll` NVENC emits long IDR-less GOPs that ignore
`-force_key_frames`, so ffmpeg's HLS muxer keeps writing into seg-0.m4s
forever instead of closing it at the 2 s boundary. Result:

* seg-0.m4s balloons to the full encoded size (1.2 GB on a 48-min movie)
* seg-1.m4s never appears
* daemon's pollSegments needs seg-N+1 to confirm seg-N is closed → never
  advances → `mark-ready: timeout` after 60 s
* web player sits on "preparando sesión" until the user gives up

Verified on ffmpeg 6.1.1 + driver 580 / Ryzen 7 7700X + RTX-class GPU:
without `-tune ll`, the same `-preset p3 -rc vbr` cmd produces 39
discrete segments in 15 s at ~27x real-time (was 1 segment / 9 min of
material with `-tune ll` — encoder kept going on a single output).

Introduced by `3b8d77b feat(hls): faster first-start — probe cache +
tighter encoder presets (0.9.9)`. Dropping `-tune ll` costs ~0.5 dB
PSNR at the same bitrate but restores playback. NVENC first-segment
latency remains under 2 s — well within the player's startup budget.
2026-05-27 21:57:16 +02:00
Deivid Soto
7a20ddb4ea feat(scripts): prune Forgejo releases >90 days in ship.sh
Some checks failed
CI / Test (push) Successful in 2m42s
CI / Build (push) Successful in 1m34s
CI / Build-1 (push) Successful in 1m59s
CI / Build-2 (push) Successful in 1m33s
CI / Build-3 (push) Successful in 1m33s
CI / Build-4 (push) Successful in 1m34s
CI / Build-5 (push) Successful in 1m33s
CI / Lint (push) Failing after 2m29s
CI / Coverage (push) Successful in 2m50s
CI / Vet (push) Successful in 2m6s
Adds step 6 to scripts/ship.sh: after smoke checks, list Forgejo
releases and delete any with created_at older than FORGEJO_PRUNE_DAYS
(default 90). Bounded retention prevents the tc-git CPX11 disk from
filling up (each release ≈ 511MB of attachments × 1/week pace).

Skipped silently with a warn if FORGEJO_TOKEN is not exported, so
the step is opt-in via secret presence (no token = no destructive
action). Tunables: FORGEJO_PRUNE_DAYS, FORGEJO_REPO, FORGEJO_BASE,
SKIP_FORGEJO_PRUNE.
2026-05-27 18:19:08 +02:00
Deivid Soto
e388408978 chore(release): 0.9.15
Some checks failed
CI / Test (push) Successful in 2m40s
CI / Build (push) Successful in 1m34s
CI / Build-1 (push) Successful in 2m6s
CI / Build-2 (push) Successful in 1m37s
CI / Build-3 (push) Successful in 1m34s
CI / Build-4 (push) Successful in 1m34s
CI / Build-5 (push) Successful in 1m34s
CI / Lint (push) Failing after 2m29s
CI / Coverage (push) Successful in 2m48s
CI / Vet (push) Successful in 2m3s
Release / release (push) Successful in 9m10s
Release / docker (push) Failing after 5s
- Bump version to 0.9.15
- Update CHANGELOG.md
2026-05-27 17:06:13 +02:00
Deivid Soto
9135332777 refactor(sentry): decouple agent import via string-match, rename predicate
Some checks failed
CI / Test (push) Successful in 2m46s
CI / Build (push) Successful in 1m35s
CI / Build-1 (push) Successful in 1m59s
CI / Build-2 (push) Successful in 1m35s
CI / Build-3 (push) Successful in 1m35s
CI / Build-4 (push) Successful in 1m33s
CI / Build-5 (push) Successful in 1m39s
CI / Lint (push) Failing after 2m33s
CI / Coverage (push) Successful in 2m56s
CI / Vet (push) Successful in 2m7s
2026-05-27 17:03:26 +02:00
Deivid Soto
9fe796f195 chore: untrack .claude/ (private local config)
Some checks failed
CI / Build-2 (push) Waiting to run
CI / Build-3 (push) Waiting to run
CI / Build-4 (push) Waiting to run
CI / Build-5 (push) Waiting to run
CI / Lint (push) Waiting to run
CI / Coverage (push) Waiting to run
CI / Vet (push) Waiting to run
CI / Test (push) Successful in 2m46s
CI / Build (push) Successful in 1m35s
CI / Build-1 (push) Has been cancelled
2026-05-27 17:00:15 +02:00
Deivid Soto
4d7444ef5b fix(sentry): skip "daemon not running" stop/reload errors 2026-05-27 16:50:16 +02:00
Deivid Soto
fceadd2009 chore(scripts): harden release.sh against double-release and inline version bumps
Two new pre-flight guards in scripts/release.sh, evaluated right after the
branch check:

1. Reject if HEAD subject matches `(X.Y.Z)` — historical pattern where the
   feature commit itself bumped the version (e.g. `feat(...) (0.9.14)`).
   Forces every release to land in a dedicated `chore(release): X.Y.Z`
   commit so the changelog + tag point at a clean release boundary.

2. Reject if HEAD is already `chore(release): …` — prevents re-running the
   script with no new commits since the previous release (would otherwise
   produce an empty release on top of itself).

Scope deliberately `chore(scripts)` (not `chore(release)`) so this very
commit doesn't trip guard 2 the next time release.sh runs.
2026-05-27 16:37:03 +02:00
Deivid Soto
116a348670 docs(positioning): reframe unarr around download/stream/transcode, drop misleading search-first wording
Old copy claimed unarr was a "torrent search" tool. unarr's real job is
downloading (torrent + debrid + usenet), streaming via local HLS, transcoding
with ffmpeg+HW accel, and library management. Search just queries the
torrentclaw.com catalog — secondary feature, not the identity.

- root cobra Short/Long now lead with download/stream/transcode and list the
  three backends + WireGuard + Cloudflare Funnel
- README hero + subheading mirror the same positioning
- DOCKERHUB hero updated to match
- "Search & Discovery" group → "Catalog & Discovery" (search still grouped,
  but framed as catalog browsing not product identity)
2026-05-27 16:35:22 +02:00
Deivid Soto
5e4dbc78ed feat(sentry): enhance error handling by skipping user input errors in CaptureError 2026-05-27 16:34:57 +02:00
Deivid Soto
8205924917 fix(ci): unset GITHUB_TOKEN so goreleaser uses GITEA_TOKEN
Forgejo runner auto-injects GITHUB_TOKEN; combined with the GITEA_TOKEN we
set explicitly, goreleaser errors with 'multiple tokens'. Unset the GitHub
one inside the run step so goreleaser follows the Gitea/Forgejo release
path defined by .goreleaser.yml's gitea_urls block.
2026-05-27 16:15:57 +02:00
Deivid Soto
ea16bf98f4 refactor(ci): point Forgejo URLs at torrentclaw org (post-transfer)
Repos were transferred from the deivid user to a dedicated torrentclaw
organisation; the workflows reference the org path.
2026-05-27 15:58:45 +02:00
Deivid Soto
86b27e690b test(vaapi): dump full ffmpeg argv for smoke validation
Adds TestBuildHLSFFmpegArgsVAAPIDump alongside the existing assertion
tests. Logs the complete argv buildHLSFFmpegArgsAt emits for a
typical VAAPI session so an operator can paste it into a shell and
reproduce the encode without booting the dev stack — same effect as
`journalctl --user -u unarr-dev | grep ffmpeg`, no daemon needed.

Verified locally against AMD Raphael iGPU on this dev box: the
dumped argv encoded a 5 s 4K source → 720p in 3.1 s wall, produced
3 HLS segments + init.mp4 that decode cleanly under ffprobe.
2026-05-27 15:58:30 +02:00
Deivid Soto
70c04a2530 fix(release): move gitea_urls to top-level (goreleaser v2 schema)
Some checks failed
Release / release (push) Failing after 8s
Release / docker (push) Has been skipped
goreleaser v2 dropped `release.gitea_urls`; the key is now top-level
on its own. With the old nested form `goreleaser release` failed with
`yaml: unmarshal errors: line 67: field gitea_urls not found in type
config.Release` before even starting the build.

Re-anchor to v0.9.14 so the ship pipeline can produce binaries.
2026-05-27 15:55:21 +02:00
Deivid Soto
afd5856d0d feat(vaapi): hybrid CPU-scale + hwupload encode path (QW2, 0.9.14)
Closes QW2. Validated against the dev box's AMD Raphael iGPU
(/dev/dri/renderD128, radeonsi/mesa 25.2.8). The "proper" full-GPU
path via scale_vaapi triggers a known mesa 25 + Raphael bug
("Cannot allocate memory" per session start, encode still succeeds
but logs are spammy) — hybrid CPU scale → format=nv12 → hwupload
→ h264_vaapi encode delivers GPU surfaces to the encoder without
poking the broken scaler.

Three concrete changes in buildHLSFFmpegArgsAt:
  1. New `case "h264_vaapi"` adds `-vaapi_device /dev/dri/renderD128`.
     Multi-GPU hosts (this dev box has NVIDIA on renderD129 + AMD on
     renderD128) need it so the encoder doesn't bind to a non-VAAPI
     render node — without it the encoder fell back to NULL device
     in manual smoke testing.
  2. Filter chain branches on codec: VAAPI uses
     `scale=…,format=nv12,hwupload` while libx264 / NVENC / QSV
     keep the existing `scale=…,format=yuv420p,setparams=…` shape.
     The setparams color metadata block is dropped on VAAPI because
     VAAPI surfaces don't expose VUI fields and the encoder writes
     its own.
  3. Two new unit tests lock the argv shape so a future refactor
     doesn't accidentally merge the paths back together:
     TestBuildHLSFFmpegArgsVAAPI asserts the new flags + the
     ABSENCE of scale_vaapi; TestBuildHLSFFmpegArgsLibx264NoRegression
     verifies the software path keeps yuv420p + setparams + has
     none of the VAAPI extras.

Manual ffmpeg validation on the dev box:
  hybrid encode of 5 s 4K → 720p: 0.66 s wall, 472 % CPU, 268 KB
  output — no errors logged. scale_vaapi variant in comparison
  spammed "Cannot allocate memory" while emitting valid output.
2026-05-27 15:45:55 +02:00
Deivid Soto
cfd4666bb2 ci: port workflows from .github/ to .forgejo/ (Forgejo Actions)
GitHub torrentclaw org is shadow-banned and the CI lives at git.torrentclaw.com
now. Forgejo Actions is enabled cluster-wide; this moves the workflows into the
runner's natively-watched .forgejo/workflows/ tree and adapts each step so the
existing Forgejo runner ('docker', 'ubuntu-latest' labels) can execute them
without leaning on GitHub-only tooling.

- ci.yml: drop actions/setup-go (use container: golang:1.25), replace
  golangci-lint-action with the upstream install.sh, drop codecov-action
  (third-party, can re-add later with a Forgejo-compatible variant).
- release.yml: drop goreleaser-action (install via curl), wire GITEA_TOKEN +
  the new release.gitea_urls block in .goreleaser.yml so goreleaser publishes
  to Forgejo. Sign step swaps 'gh release upload' for curl against the Forgejo
  releases API (via the in-cluster forgejo:3000 hostname). VirusTotal job
  dropped — depended heavily on 'gh release' wiring; can be reimplemented
  against the Forgejo API later if we re-enable it.
- docker-rebuild.yml: drop docker/login-action + docker/build-push-action,
  use raw 'docker' commands with manually-installed buildx + qemu. Same
  weekly schedule (Mon 04:17 UTC) and same 'latest' refresh behaviour.
- pages.yml: deleted — install.sh / install.ps1 are already served from the
  Hetzner releases volume at torrentclaw.com/install.sh, so the GitHub Pages
  copy was redundant even before the shadow-ban.

.goreleaser.yml: add release.gitea_urls (api=forgejo:3000, download via the
public Forgejo URL) + prerelease:auto. ship.sh uses '--skip=publish' so local
runs aren't affected by the new release block.
2026-05-27 15:44:48 +02:00
Deivid Soto
54932b1ac2 fix(daemon): defensive IsClosed check in watchSessionReady poll loop
Closes the deferred bajo-priority item from the fase 3.3b critico.

Without this the watcher kept polling a torn-down HLSSession for up
to 60 s — fine in current code paths (Close always pairs with ctx
cancel which makes the select{} branch fire), but the function's
correctness then leaned on a caller invariant rather than its own
state check. Adding IsClosed() as a public wrapper around the
existing isClosed() lets the watcher detect any future
session-shutdown path (registry replace, idle sweep, internal kill)
without touching the unexported helper.
2026-05-27 15:19:51 +02:00
Deivid Soto
69fff32420 fix(daemon): use parent ctx for MarkSessionReady so cancel propagates
Critico flag: rctx was rooted at context.Background() instead of the
session's hlsCtx, so a tab close / session cancel mid-POST left the
goroutine blocking on the in-flight webhook for up to 10 s. Switched
to a child of hlsCtx — the same scope the watchSessionReady loop
already respects via the outer ctx.Done() select.

Idempotent webhook means a now-orphan session getting marked ready
is cosmetic; the savings here are goroutine pinning + a slow webhook
on a torn-down session.
2026-05-27 15:02:24 +02:00
Deivid Soto
4ccd37aa5d feat(agent): session-ready webhook for SSE-driven player handshake (0.9.13)
Some checks failed
Release / release (push) Failing after 3s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s
Closes Fase 3.3b. Daemon now tells the server the moment a session's
first HLS segment + init.mp4 land on disk; the web side flips
streaming_session.ready_at = NOW(), which its SSE endpoint pushes to
subscribed players so the loading UI flips from "Preparando…" to
"Stream listo" without polling HEAD on the segment URL.

Surface:
  - New Client.MarkSessionReady(ctx, sessionId) HTTP method →
    POST /api/internal/agent/session-ready.
  - New engine.HLSSession.ReadyCount() + FromCache() accessors so the
    watcher goroutine doesn't reach into private state.
  - New cmd.watchSessionReady(ctx, client, hsess, sessionId) goroutine
    polls ReadyCount every 200 ms with a 60 s deadline + short-circuits
    for cache-HIT sessions (ready the moment StartHLSSession returns).
  - Daemon callback spawns it right after streamSrv.HLS().Register so
    the watcher's lifecycle matches the session's.

Best-effort: a transient network failure on the webhook is logged + the
goroutine exits — the player's existing HEAD-probe retry path still
discovers ready state independently. The webhook is an acceleration,
not a hard dependency.
2026-05-27 14:40:53 +02:00
Deivid Soto
4f304fb13a fix(daemon): defer probeCancel so a panic mid-diagnostic still releases ctx
DetectHWAccelDiagnostic spawns subprocess calls; an unexpected panic
(broken ffmpeg binary, OOM mid-exec) would otherwise leave the
WithTimeout context dangling until natural expiry. defer keeps the
goroutine + timer reachable until runDaemonStart returns, but on a
long-lived daemon that's the process lifetime anyway — same effective
cost, with the safety guarantee.
2026-05-27 14:11:24 +02:00
Deivid Soto
e3d38791d3 feat(agent): send full transcoder diagnostic in register payload (0.9.12)
Daemon now runs engine.DetectHWAccelDiagnostic at startup (instead of the
lighter DetectHWAccel) and ships the full picture — ffmpeg version,
resolved binary path, HW encoders compiled in, device files / drivers
detected — up to the server in the RegisterRequest payload.

Why: the most common cause of slow first-play is a software-only ffmpeg
build. Surfacing the diagnostic in the web AgentsTab "Diagnose
transcoder" modal lets a user see *why* their backend landed on libx264
(e.g. brew's default formula ships without --enable-nvenc, or the
container is missing /dev/nvidia0) without SSHing in to run `unarr
probe-hwaccel` manually.

Also emits a single `[transcode]` startup log line summarising the same
data — convenient for `journalctl --user -u unarr | grep transcode`.

Bounded by a 10 s context so a hung ffmpeg binary can't stall daemon
startup forever.
2026-05-27 12:48:40 +02:00
Deivid Soto
4b3f54d692 chore(skills): add /publish slash command + allow .claude/ in git
Mirrors the slash command added in torrentclaw-web/.claude/commands.
With the global ~/.gitignore excluding .claude/ by default, the
gitignore override is required for project-shared commands/agents/hooks
to be checked in (settings.local.json and projects/ stay local).

/publish documents the full unarr release flow (bump + tag + binaries +
Hetzner + Docker Hub + smoke) as a single command, while GitHub Actions
remains unavailable for the torrentclaw org.
2026-05-27 12:46:24 +02:00
Deivid Soto
23b79f6411 chore(release): add ship.sh end-to-end pipeline as GH Actions backup
GitHub Actions release.yml + docker job currently doesn't fire (org
shadow-ban). ship.sh replicates the CI pipeline locally so releases
keep landing on Hetzner + Docker Hub without depending on CI:

  1. Sanity checks: clean tree, tag at HEAD, version.go match
  2. goreleaser release --skip=publish  (build dist/*)
  3. publish-cli-release.sh  (rsync to Hetzner + flip version.txt)
  4. docker buildx --push multi-arch (amd64 + arm64)
  5. Smoke: torrentclaw.com/version + docker run image version
  6. Optional --push to git-push tag to GH

Exposed via make targets: ship, ship-dry, ship-push.
2026-05-27 12:35:01 +02:00
Deivid Soto
80461ea7fe chore(release): 0.9.11
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s
- Bump version to 0.9.11
- Update CHANGELOG.md
2026-05-27 11:55:30 +02:00
Deivid Soto
9df38c95a3 fix(library): classify resolution by width + height, not height alone
Cinematic widescreen content (1920×804 at 2.39:1, 3840×1600 21:9, etc.)
was being misclassified: a 1080p source presented as 1920×804 fell to
720p because 804 < 900. Same shape for 2160p sources letterboxed below
2000px tall.

ResolveResolution now takes (width, height) and picks the larger of the
width-derived and height-derived buckets, so anamorphic/letterboxed
sources land in the right bucket.
2026-05-27 11:54:29 +02:00
Deivid Soto
0b2462c82a feat(hls): pre-segmentación delantada — 2 s segments + async session start (0.9.10)
First-frame latency drops by another 1-2 s on cold-cache plays:

1. HLS segment duration halved from 4 s to 2 s. seg-0 lands in ~half
   the wait time — the player paints the first frame as soon as it
   arrives. Software encodes on 4K go from ~3 s wait to ~1.5 s; HW
   encoders shave ~0.5 s. Trade-off: 2× segment count per source
   (~3600 segments for a 2 h movie instead of ~1800), but each is
   half the size on disk. Within HLS spec — Apple recommends 6 s, but
   2 s is valid; LL-HLS uses 1-2 s.

2. Cache from 0.9.9 self-heals: cached entries used 4 s segments;
   VerifyComplete now expects a different highest segment index and
   invalidates them, triggering a re-encode on next play. No manual
   cleanup needed.

3. OnStreamSession daemon callback now runs StartHLSSession in a
   goroutine. Sync HTTP responses return immediately (~50 ms instead
   of waiting for the ~0.3-1 s ffprobe). Other pending actions in
   the same sync cycle (new tasks, deletes) no longer wait for the
   transcoder warmup. Browser HEAD probes already have a 30 s retry
   budget that covers the brief gap between playerSessionRegistry.add
   and streamSrv.HLS().Register.

Helpers added (engine.segmentDurationFor / segmentStartSec /
segmentCountForDuration) so a future short-first-segment variant or
non-uniform layout can slot in without touching every call site.

Internal: -hls_init_time was investigated but discarded — ffmpeg's
implementation treats it as a min duration, not a target, so it
couldn't deliver a uniformly 2 s first segment on top of a 4 s
steady state. Uniform 2 s is simpler and gets the same first-frame
win.
2026-05-27 11:36:41 +02:00
Deivid Soto
bf8ed0d928 refactor(hls): critico-driven hardening of fase 3.2
Addresses items raised by the multi-agent code review of the 0.9.9
HW accel + first-start work:

- EncoderProfile now carries DecodeHwAccel so the demuxer `-hwaccel`
  flag and the encoder argv derive from a single resolved profile.
  Adding a new backend can no longer leave the two switches out of
  sync.
- VAAPI no longer passes `-hwaccel_output_format vaapi`. That option
  pinned decoded frames to GPU memory, but the filter chain (scale,
  format, setparams) runs on CPU and would fail with "impossible to
  convert between formats". Frames now decode HW + flow on CPU; the
  encoder uploads back to GPU. Pre-existing bug, never reported because
  no one had VAAPI auto-detected in practice.
- readyMax field comment + name: documented that it's a COUNT
  (segments ready), not an index. The semantics were correct but the
  comment read "highest index" which made `idx < readyMax` look like
  an off-by-one to reviewers.
- probe_cache background janitor: 5-minute sweeper that drops expired
  entries even when no lookup retouches the key. Lookup-only eviction
  was fine for small libraries but unbounded for users who browse and
  abandon thousands of files within a TTL window. Lazy + sync.Once.
- probe_cache TTL eviction now re-checks under the write lock so a
  concurrent re-insert isn't accidentally evicted.
- probe_cache size-change test now Chtimes the file back to its
  original mtime so only `size` differs between store and lookup
  keys — properly exercises the size-check path.
- New TestProbeCache_SweepDropsExpired covers the janitor sweep.
- CHANGELOG: backfilled missing compare links 0.6.4 → 0.9.9.
- Stale "line ~1119" reference in VideoToolbox comment dropped; the
  bitrate block moved a few lines and the comment was already wrong.
2026-05-27 11:15:44 +02:00
Deivid Soto
0f4ad67827 fix(transcode): make preset libx264-only + restore quality opt-in
Two issues with the 0.9.9 preset retune:

1. applyDefaults was filling Preset="veryfast" before
   ResolveEncoderProfile got to pick the latency-biased default, so the
   "superfast" change never reached users with a freshly-generated
   config.toml — only those who left the field empty saw it.

2. The configured preset was being passed through to every encoder.
   That's only valid for libx264 (ultrafast…veryslow); NVENC uses p1-p7
   and rejects anything else, QSV uses its own subset. A user with NVENC
   + preset="veryfast" would have ffmpeg reject the argv.

Now:
- TranscodeConfig.Preset documented as libx264-only with the full
  range + advice on quality vs first-start latency.
- Default in applyDefaults is empty (was "veryfast") so the engine
  fills in "superfast" on libx264.
- ResolveEncoderProfile ignores configuredPreset for vendor encoders
  (NVENC sticks to p3, QSV to veryfast, VideoToolbox has no preset
  knob). Test cases updated to lock in this behaviour.

Users who want better quality at slower first-play should set
download.transcode.preset = "veryfast" (previous default) / "faster" /
"fast" / "medium" in their config.toml.
2026-05-27 10:46:03 +02:00
Deivid Soto
3b8d77b496 feat(hls): faster first-start — probe cache + tighter encoder presets (0.9.9)
Reduces first-segment latency on cache MISS so the player doesn't sit on
"preparando sesión". Three independent levers:

1. ProbeFile memoised by (path, mtime, size) for 30 min — second play of
   the same source skips ffprobe (1-3 s on 50+ GB MKVs).
2. HLS encoder presets biased for latency over quality:
   - libx264 default veryfast → superfast (~15-20% faster, marginal
     quality loss at 5-25 Mbps target bitrates).
   - NVENC: -preset p4 -tune hq → -preset p3 -tune ll. First-segment
     ~0.8 s on RTX-class GPUs (was ~1.5 s).
   - QSV: -preset medium → -preset veryfast (keeps look_ahead=0).
   - VideoToolbox: adds -realtime 1 (was unset). Bitrate args still
     drive rate control; -q:v dropped to avoid the silent conflict
     where ffmpeg ignored it under -b:v.
3. Per-session log surfaces encoder + accel + preset so "first-start
   was slow" complaints can be triaged from the journal alone.

Diagnostic helpers (DetectHWAccelDiagnostic + HWAccelDiagnostic) added
for future wiring into daemon startup / agent register; users today can
already inspect via `unarr probe-hwaccel`.

Web: AgentsTab profile page now shows the agent's chosen encoder
(amber if software libx264, green if HW) plus the transcode-resolution
cap. Hidden for pre-0.9.9 agents that haven't reported hwAccel.
2026-05-27 10:09:42 +02:00
Deivid Soto
7b78d0b778 fix(cors): allow play from .to / staging / onion mirrors
Daemon CORS allowlist was hardcoded to torrentclaw.com + localhost. Browsers
playing from any other official mirror (.to, onion, www., staging.) received
200 + body from the daemon's HLS server but no Access-Control-Allow-Origin
header, so the response was dropped client-side. Probe loop treated every
candidate as a failure and surfaced "No se puede conectar con tu agente
— 404 todos los canales" even though the tunnel + ffmpeg were healthy.

Static baseline now includes the full known mirror set (.com / www / app /
staging / .to / www.to / built-in onion). At startup the daemon also fetches
/api/mirrors with IPFS fallback and merges the live origins, so a future
mirror addition does not require a CLI rebuild.
2026-05-27 10:06:54 +02:00
Deivid Soto
2e7cd7e8ed fix(upgrade): break auto-apply restart loop (0.9.8)
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s
Two bugs in 0.9.6/0.9.7 caused an infinite restart loop after a Force update
signal: the CLI never reported the upgrade outcome, so `upgrade_requested`
stayed `true`; AND `applyAutoUpgrade` called `os.Exit(0)` even when the
target version equalled the current one, so systemd respawned and saw the
flag again.

  - new Client.ReportUpgradeResult → POST /api/internal/agent/upgrade-result
  - applyAutoUpgrade calls it on success / failure / no-op
  - no-op case detected up front (same version) — skips Execute + Exit,
    clears server flag instead
2026-05-27 08:18:33 +02:00
Deivid Soto
7e96976257 feat(hls): persistent fMP4 segment cache + integrity + stats (0.9.7)
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s
Cache keyed by sha256(absPath|quality|audioIdx)[:8] with .complete marker;
LRU + size-budget eviction; per-key writer-lock; pinned during play;
startup orphan reap; integrity verify on HIT; subtitle-completeness gate;
hit/miss counters + daily log line. New [downloads.hls_cache] block in
config.toml (enabled/size_gb/dir, default 5GB).

Smoke test: 2nd play of same source+quality is 23-31× faster (HIT path
skips ffmpeg entirely).
2026-05-26 23:39:02 +02:00
Deivid Soto
834c58c25a feat(daemon): auto-apply upgrades when server signals (0.9.6)
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s
OnUpgrade now downloads + replaces the binary and exits in a background
goroutine; the service supervisor (systemd Restart=always) respawns on the
new version. Removes the "run unarr update" manual step after pressing the
web's Force update button.
2026-05-26 21:47:04 +02:00
Deivid Soto
88316e7017 feat(funnel): cloudflare quick tunnel embedded subprocess (0.9.5)
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s
Gives the daemon a public HTTPS hostname (`https://<random>.trycloudflare.com`)
so the in-browser player on torrentclaw.com plays cross-network without
Tailscale or port forwarding — the mixed-content block that was breaking
HTTPS-page → HTTP-daemon fetches is gone. Bytes proxy through CloudFlare,
never through TorrentClaw infra (preserves the aggregator legal posture).

New surface:
  • `internal/funnel/` package: subprocess wrapper + auto-download for
    cloudflared. Linux amd64/arm64/armhf/386 fetched from GitHub releases
    on first run, validated by ELF magic + size sanity, O_EXCL partial
    write so concurrent daemons don't clobber each other.
  • `unarr funnel on/off/status` cobra command (sibling of `unarr vpn`).
  • Daemon supervisor goroutine keeps cloudflared up across crashes + CF's
    ~6h Quick Tunnel rotation. Exponential backoff (2 s → 5 min). On exit
    the reported URL is cleared so the web stops handing out a dead host.
  • Wire: agent registers/syncs a FunnelURL field; web prefers it over
    Tailscale/LAN for in-browser playback (HlsStreamPlayer + Stremio
    addon).

Default ON for fresh installs (NAS/Docker get it without terminal-in);
existing configs that pre-date the feature stay off until the operator
opts in with `unarr funnel on`.

Docker image now bundles cloudflared (built per TARGETARCH via buildx).

Also fixed: libx264 'frame MB size > level limit' on anamorphic >16:9
sources. The level we hint to libx264 was derived from height alone,
which busted on 720p cinemascope (1728×720 = 4860 MBs > level 3.1's
3600). Bumped each tier: 720p → 4.0, 1080p → 4.1.

Version: 0.9.4 → 0.9.5.
2026-05-26 20:39:57 +02:00
Deivid Soto
ca7de23a56 feat(stream)!: retire WebRTC, HLS-only, bump 0.9.4
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s
Drops the custom WebRTC DataChannel pipeline + pion deps + WSS signaling
client + wire framing. Every in-browser playback now uses HLS over HTTP
from the daemon (Tailscale/LAN/UPnP). Browser P2P never re-enabled.

Wire renames (incompatible with web < 2026-05-26): agent.WebRTCSession
=> agent.StreamSession, SyncResponse.WebRTCSessions (JSON: webrtcSessions)
=> StreamSessions (JSON: streamSessions). MIN_AGENT_VERSION is bumped
to 0.9.4 on the web side so older agents see an upgrade card.

Also fixes the libx264 'VBV bitrate > level limit' abort by clamping
the encoder bitrate to the effective output height instead of the
requested label (carried over from the prior 0.9.3 unreleased work).

The seed_file vertical (mode=seed_file handler + engine.SeedFile) was
retired with the in-browser P2P player. [downloads.webrtc] config block
deleted; existing TOML files with the section still parse fine.
2026-05-26 18:04:35 +02:00
Deivid Soto
9176e877eb fix(hls): clamp ffmpeg bitrate to the level we derive from outputHeight
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s
Asking for 2160p quality on a 720p source kept the daemon's qcap.VideoBitrate
at 25 Mbps even after outputHeight was clamped to the source. The level
H264LevelForHeight picks for the 720p output is 3.1 / 4.0, which rejects any
VBV >20 Mbps — libx264 then exited with "VBV bitrate (25000) > level limit"
on every restart, ffmpeg auto-restarted 3 times, master.m3u8 never appeared,
and the player got stuck at "Preparando sesión".

Re-derive the (height, bitrate) cap from the EFFECTIVE outputHeight via the
new capForHeight helper. Result: 720p source asked for 2160p → outputs 720p
with the 3500 kbps bitrate the level actually accepts. ffmpeg runs cleanly,
master.m3u8 appears, playback starts.

The web also clamps effectiveQuality to source resolution before the session
row is written, so the daemon mostly receives sane labels. This change keeps
the daemon defensive against (a) older web clients that still ask for
upscaled qualities, and (b) future quality="original" requests where qcap
is empty and Transcode.VideoBitrate could overshoot the level too.
2026-05-26 16:00:18 +02:00
Deivid Soto
a5a92b111b feat(usenet): warn at startup when par2 or extractor is missing
A usenet-enabled agent silently produces corrupt files when par2 is
not installed: bad NNTP segments go unrepaired and unrar reports
checksum errors. Likewise, no unrar/7z means RAR-packed downloads
can't be unpacked at all.

When the registered agent has the usenet feature, check par2 and the
extractor (unrar/7z) in PATH and log a loud WARNING for each missing
one, mirroring the existing ffmpeg-for-HLS warning.
2026-05-23 15:36:37 +02:00
Deivid Soto
0e8d9e87f6 fix(engine): truncate errorMessage before reporting status
A failed usenet extract sets task.ErrorMessage to the full unrar/par2
dump (multi-KB). Sent raw, the web /agent/status route rejected it and
the terminal report failed, leaving the task stuck non-terminal.

Cap the reported errorMessage at 2000 bytes (rune-safe) in the status
snapshot, matching the server's stored length.
2026-05-23 15:34:58 +02:00
Deivid Soto
5d44ee704c feat(vpn): unarr vpn command + report/arbitrate the WireGuard slot
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s
Add `unarr vpn` (status/enable/disable, with `status --check`) to manage the
managed WireGuard split-tunnel from the CLI. The daemon now reports its
split-tunnel state (active, mode, exit server) to the web on register and on
every sync, and sends its agent id when fetching the VPN config so the web can
arbitrate the single WireGuard slot (1 VPNResellers account = 1 WG keypair = 1
concurrent connection): the first agent claims it; the rest are told to run
OpenVPN on their own host (1 WireGuard + up to 9 OpenVPN = 10).

`status --check` passes probe=1 so it validates provisioning without claiming
the slot. VPNActive drops omitempty so a downed tunnel reaches the server and
frees the slot. Bumps to 0.9.2 with CHANGELOG + README VPN section.
2026-05-22 08:33:02 +02:00
Deivid Soto
d0094e84bb Merge feat/ultra-vpn into main
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s
VPN split-tunnel, signed self-update, security hardening, IPFS mirror
fallback, container CVE scan gate, and 0.9.1 release prep.
2026-05-21 17:08:34 +02:00
Deivid Soto
d24c26b073 chore(release): 0.9.1
- Bump version to 0.9.1
- Update CHANGELOG.md
2026-05-21 16:53:40 +02:00
Deivid Soto
283eb54a74 fix(security): bump golang.org/x deps and add container CVE scan gate
- Bump golang.org/x/{net,crypto,sys,text,term} to latest patches to
  clear GHSA module advisories flagged by Docker Scout.
- Add Docker Scout CVE gate to the release workflow (fails only on
  FIXABLE critical/high; unfixed upstream ffmpeg codec CVEs are accepted
  and documented in SECURITY.md).
- Add weekly + manual docker-rebuild workflow so newly fixed base/
  ffmpeg/Go patches land on :latest between tagged releases.
- Document container image vuln-scanning policy and hardening in
  SECURITY.md.
2026-05-21 16:53:23 +02:00
Deivid Soto
fb44f3711e feat(mirror): update fallback URLs to use IPFS and remove GitHub Pages 2026-05-21 16:00:44 +02:00
DeividSoto
4a77756533 ci: deploy install scripts to GitHub Pages 2026-05-15 20:40:35 +02:00
DeividSoto
01b40ca244 chore(pages): add .nojekyll to disable Jekyll processing 2026-05-15 20:38:56 +02:00
DeividSoto
13e7dbc7fd chore(pages): set custom domain unarr.torrentclaw.com 2026-05-15 20:36:20 +02:00
169 changed files with 20462 additions and 4019 deletions

16
.env.example Normal file
View file

@ -0,0 +1,16 @@
# Copy this file to .env and fill in your values.
# Then run: docker compose up -d
# Your TorrentClaw API key (required).
# Get it at: https://torrentclaw.com/settings/api-keys
UNARR_API_KEY=tc_your_key_here
# Absolute path to your media / downloads folder.
# This is where finished movies and shows will be saved.
DOWNLOAD_DIR=/home/youruser/Media
# (Optional) Config directory — defaults to ./config next to this file.
# CONFIG_DIR=/home/youruser/.config/unarr
# (Optional) Timezone for logs.
# TZ=Europe/Madrid

View file

@ -12,35 +12,26 @@ permissions:
jobs:
test:
name: Test
runs-on: ubuntu-latest
strategy:
matrix:
go-version: ["1.25"]
runs-on: docker
container:
image: docker.io/library/golang:1.25
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go-version }}
- uses: actions/checkout@v4
- name: Run tests
run: go test -v -race -count=1 ./...
build:
name: Build
runs-on: ubuntu-latest
runs-on: docker
container:
image: docker.io/library/golang:1.25
strategy:
matrix:
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.25"
- uses: actions/checkout@v4
- name: Build
env:
@ -50,30 +41,30 @@ jobs:
lint:
name: Lint
runs-on: ubuntu-latest
runs-on: docker
container:
image: docker.io/library/golang:1.25
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.25"
- name: Install golangci-lint
run: |
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/v2.11.4/install.sh \
| sh -s -- -b /usr/local/bin v2.11.4
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.11.4
run: golangci-lint run ./...
coverage:
name: Coverage
runs-on: ubuntu-latest
runs-on: docker
container:
image: docker.io/library/golang:1.25
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.25"
- name: Install python3
run: apt-get update && apt-get install -y --no-install-recommends python3
- name: Run tests with coverage (all packages)
run: |
@ -86,14 +77,11 @@ jobs:
run: |
# Threshold applies only to engine and agent — cmd contains interactive UI
# commands (config menus, daemon, auth browser) that are not unit-testable.
# WebRTC files are excluded: deprecated, slated for removal in 0.9.0.
go test -race -coverprofile=coverage-core.out -covermode=atomic \
./internal/engine/... \
./internal/agent/...
# Strip webrtc lines from the profile before computing the threshold.
grep -v '/internal/engine/webrtc' coverage-core.out > coverage-core-filtered.out
COVERAGE=$(go tool cover -func=coverage-core-filtered.out | grep ^total | awk '{print $3}' | tr -d '%')
echo "Coverage on engine+agent (excluding webrtc): ${COVERAGE}%"
COVERAGE=$(go tool cover -func=coverage-core.out | grep ^total | awk '{print $3}' | tr -d '%')
echo "Coverage on engine+agent: ${COVERAGE}%"
python3 -c "
coverage = float('${COVERAGE}')
threshold = 50.0
@ -105,24 +93,13 @@ jobs:
print('OK: Coverage meets minimum threshold')
"
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v6
with:
files: ./coverage.out
fail_ci_if_error: false
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
vet:
name: Vet
runs-on: ubuntu-latest
runs-on: docker
container:
image: docker.io/library/golang:1.25
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.25"
- uses: actions/checkout@v4
- name: Run go vet
run: go vet ./...

View file

@ -0,0 +1,61 @@
# Rebuilds and re-pushes the `latest` image without a version bump so newly
# *fixed* Alpine / ffmpeg / Go patches land between tagged releases. Versioned
# tags are immutable and never touched here. Runs weekly and on demand.
name: Docker rebuild
on:
schedule:
# Mondays 04:17 UTC (off the hour to avoid the scheduler rush)
- cron: "17 4 * * 1"
workflow_dispatch:
jobs:
rebuild:
runs-on: docker
container:
image: docker.io/library/docker:27-cli
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install build deps
run: apk add --no-cache curl git bash
- name: Install buildx
run: |
mkdir -p ~/.docker/cli-plugins
curl -sSL https://github.com/docker/buildx/releases/latest/download/buildx-linux-amd64 \
-o ~/.docker/cli-plugins/docker-buildx
chmod +x ~/.docker/cli-plugins/docker-buildx
- name: Set up qemu
run: docker run --rm --privileged tonistiigi/binfmt --install all
# Stamp the binary with the most recent release tag (not "dev").
- name: Resolve version
id: ver
run: |
v=$(git describe --tags --abbrev=0 2>/dev/null || echo dev)
echo "version=$v" >> "$GITHUB_OUTPUT"
- name: Login to Docker Hub
env:
DH_USER: ${{ secrets.DOCKERHUB_USERNAME }}
DH_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
run: echo "$DH_TOKEN" | docker login -u "$DH_USER" --password-stdin
- name: Build + push (refresh latest)
env:
VERSION: ${{ steps.ver.outputs.version }}
run: |
docker buildx create --name builder --use --driver docker-container
# Refresh the floating tag only — never overwrite a versioned release.
# Force a fresh base pull so apk upgrade picks up new patches.
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg "VERSION=$VERSION" \
--tag "torrentclaw/unarr:latest" \
--no-cache \
--push \
.

View file

@ -0,0 +1,118 @@
name: Release
on:
push:
tags:
- "v*"
workflow_dispatch:
permissions:
contents: write
jobs:
release:
runs-on: docker
container:
image: docker.io/library/golang:1.25
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install build deps (bash, curl, jq, ffmpeg fetch deps)
run: |
apt-get update
apt-get install -y --no-install-recommends bash curl ca-certificates jq xz-utils unzip
- name: Install goreleaser
run: |
curl -sSfL https://github.com/goreleaser/goreleaser/releases/latest/download/goreleaser_Linux_x86_64.tar.gz \
| tar -xz -C /usr/local/bin goreleaser
- name: Run goreleaser
env:
# Forgejo runner auto-injects GITHUB_TOKEN (a per-job, instance-scoped
# token usable against the Forgejo REST API). goreleaser only accepts
# one token; with both GITHUB_TOKEN + GITEA_TOKEN set it errors out
# ("multiple tokens"). Unset GITHUB_TOKEN before invoking goreleaser so
# it picks the Gitea code path + the gitea_urls block in .goreleaser.yml.
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
# Empty when RELEASE_SIGNING_PUBKEY variable is unset — goreleaser
# accepts it and the resulting binary disables signature checks
# (back-compat: pre-signing releases continue to update). Set
# RELEASE_SIGNING_PUBKEY (variable) + RELEASE_SIGNING_KEY (secret)
# to turn verification on.
RELEASE_SIGNING_PUBKEY: ${{ vars.RELEASE_SIGNING_PUBKEY }}
run: |
unset GITHUB_TOKEN
goreleaser release --clean
- name: Sign checksums.txt with ed25519
if: ${{ vars.RELEASE_SIGNING_PUBKEY != '' && secrets.RELEASE_SIGNING_KEY != '' }}
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
RELEASE_TAG: ${{ github.ref_name }}
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Tailscale IP — domain-agnostic; the runner shares the dokploy-network with
# forgejo (hostname `forgejo`), so the in-cluster hostname is fastest, but the
# Tailscale IP is the documented fallback.
FORGEJO_API: http://forgejo:3000/api/v1
REPO: torrentclaw/unarr
run: |
set -euo pipefail
go run ./scripts/sign-checksums \
-key "$RELEASE_SIGNING_KEY" \
-in dist/checksums.txt \
-out dist/checksums.txt.sig
# Find the release ID for this tag, then upload the sig as an asset.
rel_id=$(curl -sSf "$FORGEJO_API/repos/$REPO/releases/tags/$RELEASE_TAG" \
-H "Authorization: token $FORGEJO_TOKEN" | jq -r '.id')
curl -sSf -X POST \
"$FORGEJO_API/repos/$REPO/releases/$rel_id/assets?name=checksums.txt.sig" \
-H "Authorization: token $FORGEJO_TOKEN" \
-F "attachment=@dist/checksums.txt.sig"
docker:
needs: release
runs-on: docker
container:
# Docker-in-Docker capable image — buildx + qemu pre-installed.
image: docker.io/library/docker:27-cli
steps:
- uses: actions/checkout@v4
- name: Install buildx
run: |
apk add --no-cache curl
mkdir -p ~/.docker/cli-plugins
curl -sSL https://github.com/docker/buildx/releases/latest/download/buildx-linux-amd64 \
-o ~/.docker/cli-plugins/docker-buildx
chmod +x ~/.docker/cli-plugins/docker-buildx
- name: Login to Docker Hub
env:
DH_USER: ${{ secrets.DOCKERHUB_USERNAME }}
DH_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
run: echo "$DH_TOKEN" | docker login -u "$DH_USER" --password-stdin
- name: Set up qemu
run: docker run --rm --privileged tonistiigi/binfmt --install all
- name: Build + push multi-arch image
env:
VERSION: ${{ github.ref_name }}
run: |
set -euo pipefail
VERSION_SEMVER="${VERSION#v}"
MAJOR_MINOR="${VERSION_SEMVER%.*}"
docker buildx create --name builder --use --driver docker-container
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg "VERSION=$VERSION" \
--tag "torrentclaw/unarr:$VERSION_SEMVER" \
--tag "torrentclaw/unarr:$MAJOR_MINOR" \
--tag "torrentclaw/unarr:latest" \
--push \
.

View file

@ -1,197 +0,0 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- uses: goreleaser/goreleaser-action@v6
with:
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
# Empty when RELEASE_SIGNING_PUBKEY variable is unset — goreleaser
# accepts it and the resulting binary disables signature checks
# (back-compat: pre-signing releases continue to update). Set
# RELEASE_SIGNING_PUBKEY (variable) + RELEASE_SIGNING_KEY (secret)
# to turn verification on.
RELEASE_SIGNING_PUBKEY: ${{ vars.RELEASE_SIGNING_PUBKEY }}
- name: Sign checksums.txt with ed25519
# Reference secrets.X directly — step-level env defined in this same
# step is unreliable to read from this step's own if: expression.
if: ${{ vars.RELEASE_SIGNING_PUBKEY != '' && secrets.RELEASE_SIGNING_KEY != '' }}
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
RELEASE_TAG: ${{ github.ref_name }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
go run ./scripts/sign-checksums \
-key "$RELEASE_SIGNING_KEY" \
-in dist/checksums.txt \
-out dist/checksums.txt.sig
gh release upload "$RELEASE_TAG" dist/checksums.txt.sig --clobber
docker:
needs: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
with:
images: torrentclaw/unarr
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ github.ref_name }}
# Sync the Docker Hub repo description from DOCKERHUB.md. Non-fatal: a
# description-API auth hiccup must not undo a successful image push.
- name: Update Docker Hub description
uses: peter-evans/dockerhub-description@v4
continue-on-error: true
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: torrentclaw/unarr
readme-filepath: ./DOCKERHUB.md
short-description: "unarr — the single binary that replaces your *arr stack"
virustotal:
needs: release
runs-on: ubuntu-latest
if: vars.VT_ENABLED == 'true'
steps:
- name: Get release tag
id: tag
run: echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Download release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p assets
gh release download "${{ steps.tag.outputs.tag }}" \
--repo "${{ github.repository }}" \
--dir assets \
--pattern '*.tar.gz' \
--pattern '*.zip' \
--pattern 'checksums.txt'
- name: Scan assets with VirusTotal
env:
VT_API_KEY: ${{ secrets.VT_API_KEY }}
run: |
mkdir -p results
for file in assets/*; do
filename=$(basename "$file")
echo "Uploading $filename to VirusTotal..."
response=$(curl -s --request POST \
--url https://www.virustotal.com/api/v3/files \
--header "x-apikey: $VT_API_KEY" \
--form "file=@$file")
analysis_id=$(echo "$response" | jq -r '.data.id // empty')
if [ -z "$analysis_id" ]; then
echo "::warning::Failed to upload $filename: $response"
continue
fi
echo "$filename=$analysis_id" >> results/scans.txt
echo " Analysis ID: $analysis_id"
# Rate limit: VT free tier allows 4 req/min
sleep 16
done
- name: Wait for analysis completion
env:
VT_API_KEY: ${{ secrets.VT_API_KEY }}
run: |
echo "Waiting 60s for VirusTotal analysis to complete..."
sleep 60
vt_report="## 🛡️ VirusTotal Scan Results\n\n"
vt_report+="| File | Result | Link |\n"
vt_report+="|------|--------|------|\n"
while IFS='=' read -r filename analysis_id; do
result=$(curl -s --request GET \
--url "https://www.virustotal.com/api/v3/analyses/$analysis_id" \
--header "x-apikey: $VT_API_KEY")
malicious=$(echo "$result" | jq -r '.data.attributes.stats.malicious // 0')
undetected=$(echo "$result" | jq -r '.data.attributes.stats.undetected // 0')
sha256=$(echo "$result" | jq -r '.meta.file_info.sha256 // empty')
if [ "$malicious" = "0" ]; then
status="✅ Clean ($undetected engines)"
else
status="⚠️ $malicious detections"
fi
link="https://www.virustotal.com/gui/file/$sha256"
vt_report+="| \`$filename\` | $status | [View]($link) |\n"
sleep 16
done < results/scans.txt
echo -e "$vt_report" > results/report.md
cat results/report.md
- name: Append scan results to release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
current_body=$(gh release view "${{ steps.tag.outputs.tag }}" \
--repo "${{ github.repository }}" \
--json body --jq '.body')
new_body="${current_body}
$(cat results/report.md)"
gh release edit "${{ steps.tag.outputs.tag }}" \
--repo "${{ github.repository }}" \
--notes "$new_body"

3
.gitignore vendored
View file

@ -42,3 +42,6 @@ dist-ffbinaries/
tmp/
config/
dist-ffbinaries/
# Claude Code: keep entirely local, do not track
.claude/

View file

@ -26,10 +26,10 @@ builds:
- -s -w
- -X github.com/torrentclaw/unarr/internal/cmd.Version={{.Version}}
- -X github.com/torrentclaw/unarr/internal/sentry.dsn={{ .Env.SENTRY_DSN }}
# Release-signing public key — verified by the self-updater against
# checksums.txt.sig. Empty when not configured; in that case
# signature verification is skipped and a warning is logged.
- -X github.com/torrentclaw/unarr/internal/upgrade.releasePubKeyBase64={{ .Env.RELEASE_SIGNING_PUBKEY }}
# The release-signing PUBLIC key is compiled in as the canonical default
# in internal/upgrade/signature.go (it's public — committing it removes
# the "empty env var → unsigned binary" footgun). No ldflag override:
# every build bakes the same key and verifies checksums.txt.sig.
archives:
- formats: [tar.gz]
@ -51,6 +51,28 @@ archives:
checksum:
name_template: "checksums.txt"
# Sign checksums.txt with the release ed25519 private key → checksums.txt.sig,
# verified by the self-updater against the compiled-in public key. Releases are
# signed UNCONDITIONALLY: sign-checksums requires -key, so an unset/empty
# RELEASE_SIGNING_KEY makes this step (and the whole `goreleaser release`) fail
# rather than silently shipping an unsigned release. ship.sh sources the key
# from ~/.config/unarr-release/signing.key (or the RELEASE_SIGNING_KEY env).
signs:
- id: checksums
cmd: go
args:
- run
- ./scripts/sign-checksums
- -key
- "{{ .Env.RELEASE_SIGNING_KEY }}"
- -in
- "${artifact}"
- -out
- "${signature}"
signature: "${artifact}.sig"
artifacts: checksum
output: true
changelog:
sort: asc
filters:
@ -59,6 +81,22 @@ changelog:
- "^test:"
- "^chore:"
# Self-hosted Forgejo at git.torrentclaw.com. goreleaser detects GITEA_TOKEN +
# these URLs and publishes the release there instead of GitHub. Reachable via
# `forgejo` hostname inside the dokploy-network (the runner shares it); for
# local goreleaser runs outside the network, override via env GITEA_API_URL.
#
# In goreleaser v2 `gitea_urls` is a top-level key (was nested under `release`
# in v1).
gitea_urls:
api: http://forgejo:3000/api/v1
download: https://git.torrentclaw.com
skip_tls_verify: false
release:
draft: false
prerelease: auto
# Homebrew tap — requires PAT with repo scope (not GITHUB_TOKEN)
# Enable when torrentclaw/homebrew-tap PAT is configured as HOMEBREW_TAP_TOKEN
# brews:

0
.nojekyll Normal file
View file

View file

@ -5,6 +5,429 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.3-beta] - 2026-06-11
### Added
- **daemon**: telemetría de salud continua + heartbeat de sesiones copy
- **daemon**: lock de instancia única por config dir (flock)
## [1.1.2-beta] - 2026-06-11
### Added
- **stream**: HLS-copy — reemplazo resiliente del remux progresivo
### Fixed
- **stream**: hallazgos de la revisión crítica del modo copy
- **stream**: no copiar AAC multicanal en modo copy (WebKit lo rechaza igual)
- **stream**: downmix estéreo en el audio re-encodeado del modo copy
- **stream**: EXT-X-START=0 en el playlist copy mientras crece
- **stream**: el modo copy ignora StartSec (offset EVENT rompe iOS nativo)
### Other
- **release**: 1.1.2-beta
## [1.1.1-beta] - 2026-06-10
### Added
- **stream**: UPnP-map the HTTPS port for remote direct-TLS (best-effort)
### Fixed
- **stream**: iOS exige total concreto en el Content-Range del remux
### Other
- **release**: 1.1.1-beta
## [1.1.0-beta] - 2026-06-10
### Added
- **hls**: full-GPU scale_cuda for NVENC SDR downscales
### Fixed
- **stream**: delay_moov en el remux para audio AAC con dts negativo
- **stream**: no anunciar un total falso mientras el remux crece (loop de re-seek)
### Other
- **release**: 1.1.0-beta
## [1.0.9-beta] - 2026-06-10
### Changed
- **daemon**: revisión crítica del reporte de errores de sesión
### Fixed
- **daemon**: reportar fallos de arranque de sesión a la web + scan en sesión única
### Other
- **release**: 1.0.9-beta
## [1.0.8-beta] - 2026-06-10
### Added
- **hls**: resume-aware first spawn + capped-CRF/CQ rate control
- **subtitles**: subtitle-fetch jobs vía sync + auto-fetch opcional en scan
### Fixed
- **hls**: forced-idr en NVENC/QSV — los segmentos ignoraban force_key_frames
- **hls**: los prewarms ya no desalojan la sesión del espectador + trickplay 12x
### Other
- **release**: 1.0.8-beta
## [1.0.7-beta] - 2026-06-08
### Added
- **subs**: resilient subtitle extraction — sidecars, charset, torrent/debrid
### Other
- **release**: 1.0.7-beta
## [1.0.6-beta] - 2026-06-07
### Added
- **agent**: per-machine key handoff + revocation handling
### Fixed
- **agent**: only treat explicit 410/403 as revocation; honour --config
### Other
- **release**: 1.0.6-beta
## [1.0.5-beta] - 2026-06-07
### Added
- **agent**: per-agent direct-TLS cert client + HTTPS listener wiring
- **stream**: live transcode telemetry from ffmpeg speed=
### Documentation
- **docker**: explain why GPU Vulkan tonemap can't init in-container
### Fixed
- **docker**: derive bundled dep arch from dpkg, not TARGETARCH default
- **torrent**: suppress noisy UPnP AddPortMapping warnings
### Other
- **release**: 1.0.5-beta
## [1.0.4-beta] - 2026-06-04
### Fixed
- **stream**: self-heal host→container path skew in HLS + sidecar handlers
### Other
- **release**: 1.0.4-beta
## [1.0.3-beta] - 2026-06-04
### Fixed
- **trickplay**: stop scan-time sprite generation from saturating the host
### Other
- **release**: 1.0.3-beta
## [1.0.2-beta] - 2026-06-03
### Added
- **stream**: debrid passthrough for mode=stream tasks (external players)
- **trickplay**: scan-time montage sprite for the web scrubber
### Fixed
- **release**: keep prerelease suffix in docker smoke-check version compare
## [1.0.1-beta] - 2026-06-03
### Added
- **agent**: report isDocker so the web shows a docker pull command
- **release**: sign release checksums (ed25519), enforce + bake pubkey
### Fixed
- **stream**: retry thumbnail extraction with output-seek on seek-index failure
- **stream**: clamp out-of-range audio-track index to 0:a:0
### Other
- **release**: 1.0.1-beta
## [1.0.0-beta] - 2026-06-03
### Added
- **agent**: event-driven uplink — sync on every state transition
- **agent**: hybrid SSE downlink with long-poll fallback
- **agent**: give the public API client mirror failover
- **agent**: auto-resume interrupted downloads after a daemon restart
- **docker**: glibc base with nvenc ffmpeg + par2/7z extractors
- **downloads**: pre-flight free-disk guard before each download (hueco medio)
- **library**: content fingerprint + path-resilient sync + stream self-heal
- **library**: detect corrupt/incomplete files during scan
- **seeding**: wire seed ratio/time lifecycle into the torrent daemon
- **stream**: enable GPU libplacebo in prod image + gate to real GPU
- **stream**: benchmark software encode ceiling at startup
- **stream**: GPU HDR tonemap via libplacebo
- **stream**: /speedtest endpoint for agent-path bandwidth probing
- **stream**: cache scan-time thumbnail frames to the .unarr sidecar
- **stream**: cache extracted subtitles to a hidden .unarr sidecar
- **stream**: serve embedded text subtitles as on-demand WebVTT
- **stream**: optional per-agent HTTPS listener with hot-reloadable cert
- **stream**: burn bitmap (PGS/DVB) subtitles into the video via overlay
- **stream**: bitrate-sized readahead for play-while-download
- **stream**: on-demand frame thumbnails via /thumbnail (hueco medio)
- **stream**: refresh expired debrid links mid-stream (hueco #2/2c)
- **stream**: transcode debrid sources to HLS from a URL (hueco #2/2b)
- **stream**: serve /stream from a debrid HTTPS link (hueco #2/2a)
- **stream**: device-aware remux (HEVC/AV1 + non-aac audio) + TTFF timers
- **stream**: progressive fMP4 remux source for /stream (hueco #3 / 3b-i)
- **stream**: direct-play passthrough for browser-native files
- **stream**: authenticate /stream and /hls with signed tokens
- **transcode**: tonemap HDR sources to SDR (zscale-gated)
### Documentation
- **docker**: add docker-compose.yml for one-command setup
- **roadmap**: close the realtime hueco + mark Tailscale-Funnel note stale
- **roadmap**: mark unarr localized-route 404 fixed
- **roadmap**: mark hueco #2 closed (2a+2b+2c)
- **roadmap**: mark hueco #2/2b (HLS-from-URL) closed
- **roadmap**: hueco #3 fully closed — 3d resolved as 3d-lite auto-downshift
- **roadmap**: hueco #3 3c closed (capability negotiation) + TTFF diagnosis
- **roadmap**: hueco #3 phase 3b closed (progressive fMP4 remux) + smoke
- **roadmap**: 3b approach = progressive fMP4 remux via /stream
- **roadmap**: hueco #3 3a smoke e2e passed + brand-isolation fix noted
- **roadmap**: add hueco #4 (pre-transcode on download) design
- **roadmap**: hueco #3 phase 3a closed (direct-play)
- **roadmap**: design hueco #3 (device-profile + direct-play + ABR)
- **roadmap**: design hueco #2 (debrid in the streaming path)
### Fixed
- **agent**: surface par2/install/NFS failures instead of degrading silently
- **stream**: don't cache transient libplacebo probe timeouts
- **stream**: functional libplacebo probe + benchmark hardening
- **stream**: clean HLS segments — no B-frames, no scene-cut, CFR
- **stream**: report stream failures via StreamError + retry transient stat
- **stream**: honor client network-caching in the M3U playlist
- **stream**: /critico review fixes for the sidecar cache
- **stream**: derive H.264 level from frame macroblocks, not height
- **stream**: derive H.264 level from frame macroblocks, not height
- **stream**: allow unarr.app origins for /stream + /hls CORS
### Other
- **release**: 1.0.0-beta
- **release**: 1.0.0-beta
- bump version to 0.10.0 (direct-play floor; local build only, no publish)
### Performance
- **stream**: run the subtitle/thumbnail prewarm at idle I/O priority
- **stream**: extract all text subtitles of a file in one ffmpeg pass
## [0.9.19] - 2026-05-30
### Fixed
- **docker**: three streaming/reliability bugs found in live docker test
### Other
- **release**: 0.9.19
## [0.9.18] - 2026-05-29
### Fixed
- **stream**: make completed torrent files readable (mmap creates 0000)
### Other
- **release**: 0.9.18
## [0.9.17] - 2026-05-27
### Added
- **scripts**: prune Forgejo releases >90 days in ship.sh
### Fixed
- **hls**: drop nvenc -tune ll — kills hls segmentation, bump 0.9.17
### Other
- **release**: 0.9.17
## [0.9.15] - 2026-05-27
### Added
- **sentry**: enhance error handling by skipping user input errors in CaptureError
### Changed
- **ci**: point Forgejo URLs at torrentclaw org (post-transfer)
- **sentry**: decouple agent import via string-match, rename predicate
### Documentation
- **positioning**: reframe unarr around download/stream/transcode, drop misleading search-first wording
### Fixed
- **ci**: unset GITHUB_TOKEN so goreleaser uses GITEA_TOKEN
- **sentry**: skip "daemon not running" stop/reload errors
### Other
- **release**: 0.9.15
- **scripts**: harden release.sh against double-release and inline version bumps
- untrack .claude/ (private local config)
## [0.9.14] - 2026-05-27
### Added
- **vaapi**: hybrid CPU-scale + hwupload encode path (QW2, 0.9.14)
### CI/CD
- port workflows from .github/ to .forgejo/ (Forgejo Actions)
### Fixed
- **daemon**: defensive IsClosed check in watchSessionReady poll loop
- **daemon**: use parent ctx for MarkSessionReady so cancel propagates
- **release**: move gitea_urls to top-level (goreleaser v2 schema)
## [0.9.13] - 2026-05-27
### Added
- **agent**: session-ready webhook for SSE-driven player handshake (0.9.13)
- **agent**: send full transcoder diagnostic in register payload (0.9.12)
### Fixed
- **daemon**: defer probeCancel so a panic mid-diagnostic still releases ctx
### Other
- **release**: add ship.sh end-to-end pipeline as GH Actions backup
- **skills**: add /publish slash command + allow .claude/ in git
## [0.9.11] - 2026-05-27
### Added
- **hls**: pre-segmentación delantada — 2 s segments + async session start (0.9.10)
- **hls**: faster first-start — probe cache + tighter encoder presets (0.9.9)
### Changed
- **hls**: critico-driven hardening of fase 3.2
### Fixed
- **cors**: allow play from .to / staging / onion mirrors
- **library**: classify resolution by width + height, not height alone
- **transcode**: make preset libx264-only + restore quality opt-in
### Other
- **release**: 0.9.11
## [0.9.8] - 2026-05-27
### Fixed
- **upgrade**: break auto-apply restart loop (0.9.8)
## [0.9.7] - 2026-05-26
### Added
- **hls**: persistent fMP4 segment cache + integrity + stats (0.9.7)
## [0.9.6] - 2026-05-26
### Added
- **daemon**: auto-apply upgrades when server signals (0.9.6)
## [0.9.5] - 2026-05-26
### Added
- **funnel**: cloudflare quick tunnel embedded subprocess (0.9.5)
## [0.9.4] - 2026-05-26
### Added
- **stream**: retire WebRTC, HLS-only, bump 0.9.4 (**BREAKING**)
## [0.9.3] - 2026-05-26
### Added
- **usenet**: warn at startup when par2 or extractor is missing
### Fixed
- **engine**: truncate errorMessage before reporting status
- **hls**: clamp ffmpeg bitrate to the level we derive from outputHeight
## [0.9.2] - 2026-05-22
### Added
- **vpn**: unarr vpn command + report/arbitrate the WireGuard slot
## [0.9.1] - 2026-05-21
### Added
- **mirror**: update fallback URLs to use IPFS and remove GitHub Pages
### Fixed
- **security**: bump golang.org/x deps and add container CVE scan gate
### Other
- **release**: 0.9.1
## [0.9.0] - 2026-05-21
@ -14,12 +437,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **vpn**: local config_file for self-hosted/personal VPN testing
- **vpn**: split-tunnel torrent traffic through managed WireGuard
### CI/CD
- deploy install scripts to GitHub Pages
### Documentation
- **docker**: refresh Docker Hub README + sync description in CI
### Fixed
- **security**: CORS allowlist, URL scheme guard, state perms, ZIP slip, mirror docs
- **security**: UPnP opt-in, bounded SSE reader, signed self-update
- **security**: harden HLS session IDs, /health disclosure, archive password handling
- **upgrade**: fetch releases from TorrentClaw app, not GitHub
### Other
- **pages**: add .nojekyll to disable Jekyll processing
- **pages**: set custom domain unarr.torrentclaw.com
- **release**: 0.9.0
## [0.8.1] - 2026-05-08
@ -392,6 +829,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Build
- add -s -w -trimpath to Makefile, add build-small target with UPX
[1.1.3-beta]: https://github.com/torrentclaw/unarr/compare/v1.1.2-beta...v1.1.3-beta
[1.1.2-beta]: https://github.com/torrentclaw/unarr/compare/v1.1.1-beta...v1.1.2-beta
[1.1.1-beta]: https://github.com/torrentclaw/unarr/compare/v1.1.0-beta...v1.1.1-beta
[1.1.0-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.9-beta...v1.1.0-beta
[1.0.9-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.8-beta...v1.0.9-beta
[1.0.8-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.7-beta...v1.0.8-beta
[1.0.7-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.6-beta...v1.0.7-beta
[1.0.6-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.5-beta...v1.0.6-beta
[1.0.5-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.4-beta...v1.0.5-beta
[1.0.4-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.3-beta...v1.0.4-beta
[1.0.3-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.2-beta...v1.0.3-beta
[1.0.2-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.1-beta...v1.0.2-beta
[1.0.1-beta]: https://github.com/torrentclaw/unarr/compare/v1.0.0-beta...v1.0.1-beta
[1.0.0-beta]: https://github.com/torrentclaw/unarr/compare/v0.9.19...v1.0.0-beta
[0.9.19]: https://github.com/torrentclaw/unarr/compare/v0.9.18...v0.9.19
[0.9.18]: https://github.com/torrentclaw/unarr/compare/v0.9.17...v0.9.18
[0.9.17]: https://github.com/torrentclaw/unarr/compare/v0.9.15...v0.9.17
[0.9.15]: https://github.com/torrentclaw/unarr/compare/v0.9.14...v0.9.15
[0.9.14]: https://github.com/torrentclaw/unarr/compare/v0.9.13...v0.9.14
[0.9.13]: https://github.com/torrentclaw/unarr/compare/v0.9.11...v0.9.13
[0.9.11]: https://github.com/torrentclaw/unarr/compare/v0.9.8...v0.9.11
[0.9.8]: https://github.com/torrentclaw/unarr/compare/v0.9.7...v0.9.8
[0.9.7]: https://github.com/torrentclaw/unarr/compare/v0.9.6...v0.9.7
[0.9.6]: https://github.com/torrentclaw/unarr/compare/v0.9.5...v0.9.6
[0.9.5]: https://github.com/torrentclaw/unarr/compare/v0.9.4...v0.9.5
[0.9.4]: https://github.com/torrentclaw/unarr/compare/v0.9.3...v0.9.4
[0.9.3]: https://github.com/torrentclaw/unarr/compare/v0.9.2...v0.9.3
[0.9.2]: https://github.com/torrentclaw/unarr/compare/v0.9.1...v0.9.2
[0.9.1]: https://github.com/torrentclaw/unarr/compare/v0.9.0...v0.9.1
[0.9.0]: https://github.com/torrentclaw/unarr/compare/v0.8.1...v0.9.0
[0.8.1]: https://github.com/torrentclaw/unarr/compare/v0.8.0...v0.8.1
[0.8.0]: https://github.com/torrentclaw/unarr/compare/v0.7.0...v0.8.0

1
CNAME Normal file
View file

@ -0,0 +1 @@
unarr.torrentclaw.com

View file

@ -1,8 +1,9 @@
# unarr
**The single binary that replaces your whole *arr stack.** Search 30+ torrent
sources, inspect real quality before you download, grab subtitles, and manage
your media library — all from one terminal tool or a headless daemon.
**The single binary that replaces your whole *arr stack.** Built-in torrent,
debrid, and usenet engines. Stream, transcode, and organize your library from
one terminal — or run it as a headless daemon with a web dashboard, WireGuard
split-tunnel, and Cloudflare Funnel remote access.
**[Website & docs](https://torrentclaw.com/unarr)** · **[Install guide](https://torrentclaw.com/cli)** · **[Get an API key](https://torrentclaw.com)**

View file

@ -1,5 +1,8 @@
# ---- Build stage ----
FROM golang:1.25-alpine AS builder
# Pin the builder to the host's native arch and cross-compile (CGO is off, so
# Go cross-compiles trivially). During multi-arch buildx this keeps `go build`
# at native speed instead of compiling under QEMU emulation for the foreign arch.
FROM --platform=$BUILDPLATFORM golang:1.25-alpine AS builder
RUN apk add --no-cache git ca-certificates
@ -13,21 +16,90 @@ RUN go mod download
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X github.com/torrentclaw/unarr/internal/cmd.Version=${VERSION}" -trimpath -o /unarr ./cmd/unarr/
ARG TARGETOS
ARG TARGETARCH
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags="-s -w -X github.com/torrentclaw/unarr/internal/cmd.Version=${VERSION}" -trimpath -o /unarr ./cmd/unarr/
# ---- Runtime stage ----
FROM alpine:3.22
# glibc base (not Alpine/musl). NVIDIA's userspace — nvidia-smi and the
# libnvidia-encode / libcuda libs that `--gpus all` injects, plus the static
# BtbN ffmpeg that links nvenc — are all glibc ELF. On musl they fail with
# "no such file or directory" (missing glibc loader), so HW transcode is
# impossible on Alpine. bookworm-slim is the smallest base that runs the full
# NVIDIA stack while still falling back to software libx264 when no GPU is
# passed in.
FROM debian:bookworm-slim
# Use Alpine's native musl ffmpeg + ffprobe instead of the johnvansickle /
# BtbN static glibc builds — those need a glibc shim on Alpine and the
# vector-math symbols the GPL builds reference are not satisfiable by
# gcompat. Alpine ships ffmpeg ~7.x which is fine for the WebRTC
# transcoding pipeline (libx264 + libfdk-aac alternatives included).
RUN apk upgrade --no-cache && \
apk add --no-cache ca-certificates tzdata ffmpeg
# par2 → repair corrupted Usenet segments (without it a single bad segment
# silently corrupts the output).
# 7z → archive extractor for RAR/7z-packed downloads (p7zip-full also reads
# RAR5, so unrar — unavailable as a free Debian package — isn't needed).
# tzdata/ca-certificates → TLS + correct local time for schedules/logs.
# libvulkan1 → the Vulkan loader (libvulkan.so.1). ffmpeg's libplacebo filter
# (GPU HDR→SDR tonemap) loads Vulkan dynamically through it; without the
# loader the filter can't reach a GPU even when the NVIDIA driver mounts
# its ICD. ~150 KB. The agent only USES libplacebo after a functional
# probe (FFmpegSupportsLibplacebo) succeeds AND a real HW encoder is
# present, so this is inert on hosts without a working Vulkan GPU.
#
# NOTE: in this container libplacebo's Vulkan probe ALWAYS fails and the
# agent falls back to the CPU zscale tonemap chain — by design, not a
# bug. The nvidia Vulkan ICD is libGLX_nvidia.so.0, whose GL backend
# (libnvidia-glcore) references glibc malloc hooks removed in glibc 2.34
# (__malloc_hook/__free_hook/...) and the Xorg symbol ErrorF; on a
# headless modern-glibc base (debian or ubuntu) those go unresolved so
# vkCreateInstance returns VK_ERROR_INCOMPATIBLE_DRIVER. We deliberately
# do NOT chase it (would need `graphics` cap + X11 libs + a 1.4 loader
# AND a desktop-class glibc/Xorg — fragile, distro+driver coupled). The
# loader stays so that on the RARE host where Vulkan does come up the
# probe can use it. nvenc/nvdec (CUDA, not Vulkan) work regardless.
# GPU HDR tonemap is a bare-metal-binary feature, not a container one.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates tzdata wget xz-utils par2 p7zip-full libvulkan1 && \
rm -rf /var/lib/apt/lists/*
# Arch for the bundled deps below is taken from `dpkg --print-architecture` (the
# real arch of THIS runtime stage), NOT the TARGETARCH build-arg. A baked
# `ARG TARGETARCH=amd64` default used to shadow buildx's per-leg value in this
# stage, so even the published arm64 image bundled an amd64 cloudflared/ffmpeg
# while the unarr binary was native arm64 → "exec format error" when the daemon
# spawned cloudflared → funnel never came up → TV/Stremio connect failed
# ("Failed to get add-on manifest"). dpkg reads the emulated base image's arch,
# so it is correct under buildx cross-builds AND a plain `docker build`.
# Static GPL ffmpeg + ffprobe with nvenc compiled in (BtbN builds). nvenc is
# linked but the actual libnvidia-encode.so is dlopen'd at runtime from the
# host driver that `--gpus all` exposes — so the same binary does HW transcode
# when a GPU is present and falls back to libx264 when it isn't. Placed in
# /usr/local/bin so ResolveFFmpeg picks them up off PATH ahead of any distro
# ffmpeg. arm64 has no nvenc but the build still serves software transcode.
RUN ARCH="$(dpkg --print-architecture)" && \
case "$ARCH" in \
amd64) FF_ARCH=linux64 ;; \
arm64) FF_ARCH=linuxarm64 ;; \
*) echo "unsupported arch=$ARCH" >&2; exit 1 ;; \
esac && \
wget -4 --tries=3 --timeout=30 -qO /tmp/ffmpeg.tar.xz "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-${FF_ARCH}-gpl.tar.xz" && \
mkdir -p /tmp/ff && tar -xJf /tmp/ffmpeg.tar.xz -C /tmp/ff --strip-components=1 && \
cp /tmp/ff/bin/ffmpeg /tmp/ff/bin/ffprobe /usr/local/bin/ && \
chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe && \
rm -rf /tmp/ffmpeg.tar.xz /tmp/ff
# Bundle cloudflared so `unarr funnel on` (default: on, see config defaults)
# Just Works on a headless container with no first-run network round-trip.
RUN ARCH="$(dpkg --print-architecture)" && \
case "$ARCH" in \
amd64) CF_ARCH=amd64 ;; \
arm64) CF_ARCH=arm64 ;; \
armhf) CF_ARCH=armhf ;; \
*) echo "unsupported arch=$ARCH" >&2; exit 1 ;; \
esac && \
wget -4 --tries=3 --timeout=30 -qO /usr/local/bin/cloudflared "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$CF_ARCH" && \
chmod +x /usr/local/bin/cloudflared
# Non-root user (UID 1000 matches typical host user for volume permissions)
RUN addgroup -g 1000 unarr && adduser -u 1000 -G unarr -D -h /home/unarr unarr
RUN groupadd -g 1000 unarr && useradd -u 1000 -g 1000 -m -d /home/unarr unarr
# Default directories
RUN mkdir -p /config /downloads /data && \
@ -42,6 +114,23 @@ ENV UNARR_CONFIG_DIR=/config
ENV UNARR_DOWNLOAD_DIR=/downloads
ENV XDG_DATA_HOME=/data
# Mark this as a container install so the agent reports isDocker=true to the web
# (which then shows a `docker pull` command instead of the in-app update button —
# the binary self-update refuses to run in Docker). Covers podman/containerd too,
# which don't create /.dockerenv. See internal/agent/RunningInDocker.
ENV UNARR_DOCKER=1
# NVIDIA passthrough defaults. `--gpus all` alone only grants the "utility" +
# "compute" capabilities; nvenc needs "video", and "graphics" makes the runtime
# mount the NVIDIA Vulkan ICD (nvidia_icd.json — the load-bearing piece — plus
# GLX/EGL libs) so ffmpeg's libplacebo filter (GPU HDR tonemap, paired with
# libvulkan1 above) can create a Vulkan device. "compute" alone does NOT mount
# the ICD. Baking these here means a plain `docker run --gpus all` (or the compose
# device reservation) lights up HW transcode + GPU tonemap with zero extra flags.
# Harmless when no GPU is attached.
ENV NVIDIA_VISIBLE_DEVICES=all
ENV NVIDIA_DRIVER_CAPABILITIES=video,compute,utility,graphics
VOLUME ["/config", "/downloads", "/data"]
ENTRYPOINT ["unarr"]

View file

@ -0,0 +1,661 @@
# unarr CLI agent — roadmap del diferenciador
> Estado de partida: **v0.9.19 beta** (~26k LOC fuente / ~18k test).
> Objetivo estratégico: el agente CLI es el **soporte real y diferenciador** de
> unarr — un *servidor de streaming personal* que la web sola no puede ser.
> Compite en **profundidad**, no en anchura (no apps nativas por dispositivo:
> el agente sirve a un único web-player responsive vía navegador).
## La visión en 6 puntos
1. **Hospeda localmente** toda la biblioteca.
2. **Debrid** para reproducir cualquier cosa cache-fast.
3. **Play-anything sin callejones** (local | debrid | descarga-y-reproduce, con
fallback mid-stream).
4. **Transcodifica según el dispositivo** (direct-play cuando ya es compatible).
5. **Sirve a un web-player universal** en cualquier dispositivo vía navegador.
6. **Acceso remoto seguro** al agente.
## Mapa de partida (qué TIENE el agente hoy)
Sólido salvo nota:
- **Descarga torrent** (anacrolix): mmap, DHT warm-start, 30 trackers, pause/cancel,
selección vídeo+subs `[engine/torrent.go]`. **Stream-while-download** con reader
responsive + `PrioritizeTail` `[engine/stream.go]`.
- **Usenet** completo: NNTP pool, yEnc, ensamblado `WriteAt`, resume por segmento,
par2 repair, unrar/7z `[usenet/*]`.
- **Debrid downloader**: GET con Range/resume `[engine/debrid.go]` — pero solo
DESCARGA (no streaming). Resolución server-side.
- **HLS transcode** fMP4 + seek real + supervisor `[engine/hls.go]`, **caché HLS LRU**
`[engine/hls_cache.go]`, **HW accel** NVENC/QSV/VAAPI/VideoToolbox `[engine/hwaccel.go]`.
- **Servidor HTTP** persistente: range/seek, rate-limit 2×bitrate, CORS `[engine/stream_server.go]`.
- **Library scan + ffprobe** (codec/HDR/tracks), parse título/temporada `[library/, mediainfo/]`.
- **Red**: CloudFlare Quick Tunnel `[funnel/]`, WireGuard userspace split-tunnel `[vpn/]`,
NAT-PMP + UPnP `[engine/upnp.go]`. Web hace de broker de URLs (LAN/Tailscale/Public/Funnel).
- **Agente**: daemon cobra, sync HTTP long-poll + `/wake`, auto-upgrade opt-in,
config.toml exhaustivo.
## Huecos (de más crítico a más bajo)
### Hueco #1 — Auth de stream ✅ CERRADO (2026-05-31) / ver estado abajo
`/stream` y `/hls` se sirven **sin autenticación** (solo CORS+rate-limit). Con
funnel/UPnP el stream queda público en internet. Plan previo
`Docs/plans/security-stream-token.md` (deferido, sin código).
### Hueco #2 — Debrid en el path de streaming ✅ CERRADO (2a+2b+2c, 2026-05-31)
Hoy debrid es **solo descarga**, resuelto server-side; el streaming es 100%
torrent. La promesa "play instantáneo cache-fast" no ocurre. Falta: source debrid
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 ✅ CERRADO (2026-05-31) / ver estado abajo
El path HLS re-encodaba todo (incluso mp4 h264/aac ya compatible). `DecideAction`
muerto. Sin negociación por capacidades. Sin adaptación de calidad.
Diseño por fases (3a direct-play / 3b remux fMP4 / 3c capability-negotiation / 3d ABR)
en el estado abajo. **3a + 3b + 3c CERRADAS** (smoke e2e, incl. HEVC en iPhone Safari
real). **3d resuelto como 3d-lite (auto-downshift)** — ABR multi-rendition real
descartada (N× CPU inviable single-viewer; no aplica a paths copy). Hueco COMPLETO.
### 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`)~~**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 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). 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`.
- Funnel = SPOF CloudFlare (rota ~6h), sin relay propio.
- ~~"Tailscale Funnel" mal nombrado~~**Ya correcto (2026-06-01)** — no existe el literal en ningún sitio del código; el comando, el help y los docs nombran consistentemente "CloudFlare Quick Tunnel". La nota era stale; nada que renombrar.
- ~~Dos clientes HTTP divergentes (go-client vs agent client)~~ ✅ (resuelto — ver sección Cerrada).
- ~~Long-poll en vez de WS/SSE~~**Realtime: SSE downlink + uplink event-driven + push al navegador (2026-06-01, CLI 0.14.0)** — las 3 patas de la comunicación agente↔web↔navegador:
1. **Downlink (server→agente):** `GET /api/internal/agent/events` (SSE) empuja `event: command` (controles tipados desde DB, no-consuming) + `event: sync` (nudge), heartbeat 15s, colgado del Redis pub/sub `agent:wake` (multi-replica). El CLI lo consume SSE-first con **fallback a long-poll liveness-probed** (SSE es buffering-intolerante; long-poll es buffering-tolerante → red de seguridad para proxies/ISP que bufferean). Config `[daemon] downlink=auto|sse|poll`. Cliente SSE resucitado del `signal_client.go` histórico.
2. **Uplink (agente→server):** cada transición de estado del `Task` dispara `onChange→TriggerSync` (coalescido), en vez de esperar al tick adaptativo 3s/10s. Cubre descargas y streams.
3. **Browser-leg (server→navegador):** `/agent/sync` publica en un signal-bus Redis genérico (`createSignalBus`); `progress-stream` se suscribe y empuja snapshot al instante (backstop 10s, antes busy-poll 3s) + dedupe de frames idénticos en el cliente. `markWatching` despierta al agente en el flanco para reporte 3s inmediato al abrir la página.
Verificado e2e (control instantáneo + fallback + push). De paso: arreglado el allow-list de marca unarr que 404eaba `/api/internal/downloads|library|profile|…`. Commits web `11b70fae`/`1e77b948`/`bdb0ab92`/`cf3e4423`, cli `1052529`/`864b6ea`.
### Deuda puntual
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 <8 chars (irreal: el web manda UUIDs) limpiar a `ShortID` de paso.
- **Funnel = SPOF CloudFlare** (ya en huecos medios): el funnel sigue siendo trycloudflare; relay propio pendiente.
- ~~**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.82.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.
### Bug agente — nvenc "Invalid Level" en fuentes anamórficas ✅ ARREGLADO (2026-06-01)
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ú).
---
## ESTADO POR HUECO
### Hueco #1 — Auth de stream
**Estado:** 🟡 en progreso (iniciado 2026-05-31).
**Enfoque elegido** (mejora sobre el plan previo, menor blast radius — sin migración DB):
token **HMAC stateless minteado por el propio agente**. El agente ya construye las
stream URLs que reporta a la web (`daemon.go``streamSrv.URLsJSON()`), así que
puede firmar el token, embeberlo en la URL, y verificarlo en cada request — la web
es passthrough (cambio web ~nulo).
- Secreto: 32 bytes random en memoria del daemon (rota al reiniciar).
- Token: `<expUnix>.<hexHMAC(secret, scope:exp)>`, TTL 6h.
- `/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).
- `internal/config/config.go`: `require_stream_token` (default true).
- `internal/agent/{types,daemon}.go` + `internal/cmd/daemon.go`: el agente reporta el secreto en register **solo si enforcing**.
- Tests: `stream_token_test.go` (mint/verify/expiry/tamper/scope/secret, handler /stream + /hls, **vector de paridad cross-lenguaje**).
WEB (`torrentclaw-web`):
- `src/lib/stream-token.ts` (nuevo): minter HMAC en TS (paridad byte a byte con Go, guard de clave 64-hex).
- `src/app/api/internal/stream/session/route.ts`: `buildHlsUrls` inyecta el token de path usando el secreto del agente.
- `src/lib/db/schema.ts` + migración `0134_grey_chat.sql`: columna `agent_registration.stream_secret` (ADD COLUMN nullable, segura).
- `src/app/api/internal/agent/register/route.ts` + `src/lib/services/agent.ts`: valida (64-hex) + persiste + expone en `getAgentHealth`.
- Tests: `tests/unit/stream-token.test.ts` (paridad + guard).
**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.
---
### Hueco #2 — Debrid en el path de streaming
**Estado:** ✅ CERRADO (2a+2b+2c, 2026-05-31).
**CERRADO 2c (2026-05-31):** fallback mid-stream, alcance = **refresh de URL
debrid** (decisión del usuario; el swap cross-source torrent↔debrid se difiere —
caso raro, gran complejidad). La preferencia cache-fast (preferir debrid
cacheado sobre torrent en streaming) ya la daban 2a/2b por orden de resolución.
Los links debrid caducan; una peli larga sobrevive al link → al detectar expiry
(401/403/404/410 en direct-play, o salida de red de ffmpeg en HLS) el agente
re-resuelve (mismo info_hash → link fresco) y reanuda sin reiniciar.
- WEB: endpoint `POST /api/internal/agent/stream-url` (withAgentAuth) →
re-resuelve + actualiza fila + devuelve URL. Guard: sesión debrid viva
(`direct_url IS NOT NULL`). 409 sin sesión, 410 si re-resolución falla.
- CLI: `agentClient.RefreshStreamURL`; `debridFileProvider` URL mutable bajo
mutex + reader refresca en expiry (bounded 1+1) + **coalescing singleflight**
(N readers del `<video>` → 1 re-resolución); HLS refresca `s.liveURL` (guarded,
cfg inmutable → race-free con el seek-restart del handler HTTP) antes del
auto-restart de ffmpeg.
- Validado: reader refresh + coalescing unit-tested (incl. -race); endpoint
e2e contra AllDebrid real (URL fresca + fila). El swap torrent↔debrid queda
como mejora futura si aparece demanda.
**CERRADO 2b (2026-05-31):** HLS-desde-URL para contenido debrid no-nativo
(mkv/HEVC/…). ffmpeg lee la URL debrid directa (`-i <url>` + flags de red
`-reconnect*`/`-rw_timeout`) y transcodifica a HLS; el seek reinicia ffmpeg con
`-ss` antes de `-i` (input-seek vía Range). Cache de segmentos por `CacheID`
(info_hash) → replay hace cache-HIT pese a que la URL cambia cada resolución.
Validado e2e contra AllDebrid real: mkv HEVC x265 → h264_nvenc desde la URL →
Chrome reproduce 1080p vía hls.js, subtítulos extraídos del mkv remoto. Bump
CLI 0.11.0→0.12.0 (gate `DEBRID_HLS_MIN_VERSION`). Ficheros: CLI
`engine/hls.go` (SourceURL/CacheID/sourceRef + flags red), `cmd/daemon.go`
(branch 2b + helper `startHLSPlayback`), `engine/hls_cache.go` (`KeyForID`),
`library/mediainfo/ffprobe.go` (no enmascarar errores de URL). WEB
`stream/debrid-stream-source.ts` (playMethod direct|hls por contenedor),
`services/agent-version-compare.ts` (`supportsDebridHls`).
Limitación: solo audio default (raw debrid sin UI de pistas); subs bitmap (PGS)
no soportados (igual que HLS local). Si AllDebrid no marca "ready" al primer
addMagnet → fallback torrent (sin callejón).
**CERRADO 2a (2026-05-31):** debrid como fuente de `/stream` (direct-play),
validado e2e contra AllDebrid real (cuenta hello@torrentclaw.com): play de un
infoHash cacheado mp4 → web resuelve la DirectURL → agente sirve `/stream` por
GETs ranged → Chrome reproduce el mp4 1080p real (incluido seek a offset alto
para el moov de un fichero sin faststart). CLI bump 0.10.0→0.11.0 (binario local,
sin publicar). Fichero clave: `internal/engine/stream_source_debrid.go`.
- CLI: `StreamSession.DirectURL`; `debridFileProvider` (`io.ReadSeekCloser` sobre
HTTP Range, Seek sin red + GET lazy + reopen-on-seek + HEAD para tamaño +
nombre derivado de URL para Content-Type correcto); branch en
`daemon.OnStreamSession` (DirectURL presente → provider en goroutine →
SetFile → MarkSessionReady), antes de validar filePath y sin ffmpeg.
- WEB: columna `streaming_session.direct_url` (mig 0137) + índice
`idx_debrid_cache_info_hash` (mig 0138, getHashCacheTier filtra por info_hash);
helper `resolveDebridStreamSource` (honesty gate: sin fichero local + infoHash
+ agente ≥0.11.0 + `getHashCacheTier`==="verified" + container mp4/m4v +
audioIndex -1 + !forceTranscode → resuelve DirectURL, playMethod="direct",
quality "original"); gate de versión `DEBRID_STREAM_MIN_VERSION`/
`supportsDebridStream`; `getPendingStreamSessions` emite `directUrl` + fallback
fileName/fileSize vía join a `torrent` (cubre el caso HEAD-falla del provider).
- Player: sin cambios — reusa el path direct-play del hueco #3 (playMethod=direct
+ streamUrls).
- Limitación 2a (honesta): solo contenido debrid mp4/m4v browser-native; mkv/HEVC
debrid → fallback a torrent hasta 2b (HLS-desde-URL). Si AllDebrid no marca el
torrent "ready" al primer addMagnet → fallback a torrent (sin callejón).
**Diseño original (2b/2c siguen vigentes):**
**Problema (confirmado en el análisis):** hoy `debrid` es **solo descarga**
(`engine/debrid.go` baja la `DirectURL` HTTPS resuelta server-side). El
streaming es **100% torrent**: `daemon.OnStreamSession` arma el provider desde
`sess.FilePath`/`sess.InfoHash`/`sess.TaskID` y `StreamSession` **no lleva
DirectURL**. La promesa "play instantáneo cache-fast por debrid" no ocurre.
**Arquitectura de providers (lo que ya hay):** `FileProvider{ NewFileReader(ctx)
io.ReadSeekCloser; FileName(); FileSize() }`. Implementaciones: `torrentFileProvider`,
`diskFileProvider`, `StreamEngine`. El /stream sirve un `FileProvider` via
`http.ServeContent` (range/seek). El HLS arranca una `HLSSession` desde una ruta
de fichero (ffmpeg `-i <path>`).
**Diseño por fases (de menos a más riesgo):**
- **Fase 2a — debrid como fuente de /stream (direct-play).** *Slice completo y
acotado.*
1. Añadir `DirectURL string` a `StreamSession` (web→agente) y a su validación.
2. Nuevo `debridFileProvider` (`FileProvider`): `NewFileReader` devuelve un
`io.ReadSeekCloser` que hace **GET con Range** contra la `DirectURL` (debrid
ya soporta Range, ver `debrid.go`); `FileSize` via HEAD o `sess.FileSize`;
`Seek` traducido a `Range:`. Reutilizar la lógica de `debrid.go` (416,
Content-Range, reintentos).
3. En `OnStreamSession`: si `sess.DirectURL` presente → `debridFileProvider`
`SetFile`. (Direct-play; el navegador hace range sobre el provider.)
4. Web: al crear la sesión de stream, si el contenido está **cacheado en
debrid**, resolver la `DirectURL` server-side (como en descargas) e incluirla
en el `StreamSession`. Señal de cache: `debridCacheStatus` fresh (ya existe).
5. Tests: `debridFileProvider` con un httptest server que sirve Range; round-trip
/stream con provider debrid.
- **Fase 2b — HLS desde URL (transcode de fuentes debrid no-compatibles).**
ffmpeg lee HTTP directo (`-i https://…`), así que `HLSSession` puede aceptar
una URL como source en vez de una ruta. Mayor cambio en el pipeline HLS
(timeouts, reintentos de red, headers). Permite transcodear contenido debrid.
- **Fase 2c — selección cache-fast + fallback mid-stream ("sin callejones").**
- Conciencia de cache en el agente o señal del web para **preferir debrid
cacheado sobre torrent** cuando aplique (hoy `resolve.go:22` pone torrent
primero).
- **Fallback mid-stream**: si la fuente activa muere (peers a 0 / 5xx debrid),
cambiar a la otra sin cortar la reproducción. Complejo (estado de sesión,
re-seek). Es lo que de verdad cierra "play-anything sin callejones".
**Ficheros a tocar:** CLI `internal/engine/{stream_server.go (provider), debrid.go,
hls.go (2b)}`, `internal/agent/types.go` (+DirectURL), `internal/cmd/daemon.go`
(wiring). WEB `src/app/api/internal/stream/session/route.ts` (resolver DirectURL +
cache), `src/lib/services/agent.ts`.
**Partes difíciles / riesgos:** ranged reader robusto sobre HTTP (reconexión,
timeouts), HLS-desde-URL (red dentro de ffmpeg), y el fallback mid-stream (estado).
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"`).
**CERRADO 2026-05-31:**
- CLI 3b-i (`feat/unarr-agent` 4a12f13): `GrowingSource` + `NewRemuxSource`
(reusa `transcodeSource`+`ActionRemux`, estimate = tamaño origen para copy);
`StreamServer.SetGrowingFile` + `serveGrowing` (responder Range manual: 206
con total estimado en `Content-Range`, body chunked mientras no-final, exact
`Content-Length` al finalizar, bloqueo vía `ReadAt`); branch `remux` en
`OnStreamSession`. Tests `parseByteRange`+`serveGrowing` (full/offset/bounded/
estimate/HEAD/416). build+vet+test verdes.
- WEB 3b-ii (`feat/unarr-brand` 10b7d602): `decidePlayMethod``"remux"` para
codecs compatibles en contenedor no-nativo; ruta gatea remux como direct
(versión, metadata, sin downscale, audioIndex -1); player trata `!= "hls"`
como attach nativo. lint+typecheck+2334 unit OK.
- **Smoke e2e (browser, mkv h264/aac 1080p):** `playMethod: remux`, `hlsUrls:
null`; agente `[stream …] remux (copy) → fMP4`; `/stream` HEAD 200 + GET Range
206 con fMP4 válido (`ftyp iso6 mp41`+`moov`); browser reproduce 1080p nativo,
duration leída del fMP4, **seek a 2min OK**, **0 reqs `/hls`**. ✓
- **Bug cazado por el smoke:** la respuesta `created` de la ruta quedó en
`playMethod === "direct" ? null` (en vez de `!== "hls"`) → devolvía `hlsUrls`
para remux. Corregido (el player usaba streamUrls igual, pero inconsistente).
- **Limitación:** seek-adelante a zona aún-no-remuxada bloquea hasta que el copy
(rápido) la alcanza; seek-atrás inmediato. Audio no-default / subs-bitmap →
siguen yendo por HLS (gate `audioIndex == -1`).
- **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.** ABR multi-rendition real **DESCARTADA**: N pipelines ffmpeg
simultáneos = N× CPU para 1 espectador (mata NAS/Pi), y no aplica a los paths
copy (direct/remux = 1 bitrate). Resuelto como **3d-lite (auto-downshift)**:
el player ya tenía sondeo de ancho de banda + recomendación + selector manual;
3d-lite automatiza la bajada — buffering sostenido 10s → siguiente calidad menor
(nueva sesión a bitrate menor), progresivo hasta 480p. Reusa
`recommendLowerQuality`/`setQuality`. `setQuality(.., {persist:false})` para no
pisar la preferencia del usuario por un stall transitorio. **CERRADO (web 8bf8e416)**;
smoke en Chrome (Slow-3G + seek → consola `auto-downshift 720p → 480p`, nueva
sesión reproduce). Hallazgo: este Chrome reproduce HLS **nativo** (como Safari);
hls.js es fallback.
**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).
**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 1698ms, spawn 1194ms, 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)
**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

@ -1,4 +1,4 @@
.PHONY: all build test lint coverage clean fmt vet check install-hooks changelog release release-patch release-minor release-major release-dry
.PHONY: all build test lint coverage clean fmt vet check install-hooks changelog release release-patch release-minor release-major release-dry ship ship-dry ship-push
BINARY = unarr
SENTRY_DSN ?=
@ -71,6 +71,19 @@ release-dry:
@test -n "$(V)" || { echo "Usage: make release-dry V=patch|minor|major|0.5.0"; exit 1; }
@./scripts/release.sh --dry-run $(V)
## Ship a release end-to-end (goreleaser + Hetzner + Docker Hub). Standalone backup for GH Actions.
## Reads version from internal/cmd/version.go unless V= is provided.
ship:
@./scripts/ship.sh $(V)
## Ship + git push tag to GH afterwards
ship-push:
@./scripts/ship.sh --push $(V)
## Preview ship steps without executing
ship-dry:
@./scripts/ship.sh --dry-run $(V)
## Remove generated files
clean:
rm -f $(BINARY) coverage.out coverage.html

239
README.md
View file

@ -11,9 +11,9 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Go Version](https://img.shields.io/github/go-mod/go-version/torrentclaw/unarr)](go.mod)
Powerful terminal tool for torrent search and management. **Free and open source.**
The single-binary terminal client for torrent, debrid, and usenet downloads. **Free and open source.**
Search 30+ torrent sources, inspect torrent quality, discover popular content, find streaming providers, and manage your media collection — all from your terminal.
Built-in torrent engine, debrid (Real-Debrid / AllDebrid), and NZB support. Stream to mpv/vlc, transcode on the fly with hardware acceleration, and manage your library — one binary or a headless daemon with WireGuard split-tunnel and Cloudflare Funnel remote access.
<!-- GIF demo placeholder -->
<!-- ![unarr Demo](docs/demo.gif) -->
@ -171,6 +171,9 @@ unarr start
| `unarr status` | Show daemon status and active downloads |
| `unarr daemon install` | Install as system service (systemd/launchd) |
| `unarr daemon uninstall` | Remove the system service |
| `unarr vpn status` | Show managed-VPN config and live tunnel state |
| `unarr vpn enable` | Turn the managed VPN on |
| `unarr vpn disable` | Turn the managed VPN off |
### System & Diagnostics
@ -280,6 +283,53 @@ The daemon connects via WebSocket for instant task delivery, with automatic HTTP
- Linux: `~/.config/systemd/user/unarr.service` (systemd)
- macOS: `~/Library/LaunchAgents/com.torrentclaw.unarr.plist` (launchd)
## VPN
unarr can route your **downloads** through a managed WireGuard VPN, so peers and
trackers see the VPN server's IP instead of yours. It runs entirely in userspace
(wireguard-go + a gVisor netstack) — **no root, no `wg-quick`, no changes to your
OS routing table**.
Requires a **PRO+ plan with the VPN add-on**. Set it up at
[torrentclaw.com/vpn](https://torrentclaw.com/vpn).
```bash
# Turn it on (writes [downloads.vpn] enabled = true to your config)
unarr vpn enable
# Restart the daemon so it brings the tunnel up at startup
unarr daemon restart # or: unarr start (if not installed as a service)
# Check it's working — shows the exit server when the tunnel is up
unarr vpn status
# Verify your account is provisioned (queries the API)
unarr vpn status --check
# Turn it off again
unarr vpn disable
```
**Split-tunnel — read this:** only the torrent client's traffic goes through the
VPN. Your browser, `curl`, and every other app keep using your **real IP** — that
is by design. To check the VPN is working, look at `unarr vpn status` (or the
peer/announce IP), **not** your browser's "what's my IP". To protect your other
devices (phone, laptop), use the **OpenVPN credentials** from your profile — those
support ~10 concurrent devices and do **not** share the agent's WireGuard slot.
**When does it fetch the config?** Once, at daemon startup. There's no periodic
refresh — after changing your exit server in the web panel or re-provisioning,
restart the daemon to pick it up. If the fetch fails the daemon logs a `[vpn]`
line and downloads in the clear (never refuses to run).
**Self-hosted / personal VPN:** instead of the managed config, point unarr at a
local WireGuard `.conf`:
```toml
[downloads.vpn]
config_file = "/path/to/wg.conf" # takes precedence over `enabled`
```
## Diagnostics
```bash
@ -293,6 +343,58 @@ unarr self-update --force # reinstall even if up to date
`unarr doctor` checks: config file, API key, server connectivity (with latency), agent registration, download directory, disk space, and version.
### Updating unarr
unarr supports three update paths. Pick whichever fits your workflow.
**1. Manual self-update (always available).**
```bash
unarr self-update # interactive update to latest
unarr self-update --force # reinstall same version
unarr self-update --allow-unsigned # accept releases without checksum signature
```
The CLI downloads the new release archive over HTTPS (from
`torrentclaw.com/releases/download/v<ver>/`), verifies SHA-256, swaps the
binary in place (`.backup` kept next to it), and restarts the systemd
user unit if the daemon is running.
**2. Auto-apply on server signal (default, since 0.9.6).**
When you press **"Force update now"** on the web (Settings → Agent → Force
update), the server sets a flag your daemon polls every sync (~3 s). On
the next sync the daemon downloads the new binary, replaces itself, and
exits — `systemd Restart=always` respawns on the new version. No SSH, no
terminal access required. Works headless on NAS / Docker.
The button shows an amber warning if your agent is below 0.9.6 (older
daemons see the signal but only log "run unarr update" — the operator
must run the command manually that one time).
**Opt out of auto-apply.** Some users prefer reviewing CHANGELOG before
applying. Disable in `config.toml`:
```toml
[daemon]
auto_upgrade = false
```
With `auto_upgrade = false`, pressing the web button still flags your
agent (so the daemon logs the new version on next sync), but the daemon
will not download / replace anything — you run `unarr self-update` when
you're ready.
**3. Docker auto-restart with a new tag.**
```bash
docker pull torrentclaw/unarr:latest
docker compose up -d
```
Tags published: `latest`, `0.9`, `0.9.7`, ... — pin to a minor (`0.9`)
for opt-in patch updates without surprises.
## Clean
Remove temporary files, logs, resume data, and other artifacts generated by unarr. Shows what will be removed and asks for confirmation before deleting.
@ -374,6 +476,7 @@ tv_shows_dir = "~/Media/TV Shows"
[daemon]
poll_interval = "30s"
heartbeat_interval = "30s"
auto_upgrade = true # apply server-flagged upgrades in-place (since 0.9.6)
[notifications]
enabled = true
@ -384,24 +487,12 @@ country = "US"
### Streaming reference
The in-browser player on torrentclaw.com streams from the daemon over WebRTC
(low-latency P2P) or HLS (HTTP fragments + ffmpeg transcode for codecs the
browser can't decode natively). Both are enabled by default — a fresh install
"just works" without editing the TOML. Disable surgically only if you have a
reason.
The in-browser player on torrentclaw.com streams from the daemon over HLS
(HTTP fragments + ffmpeg transcode for codecs the browser can't decode
natively). Enabled by default — a fresh install "just works" without editing
the TOML.
```toml
[downloads.webrtc]
enabled = true # master switch
trackers = ["wss://tracker.torrentclaw.com"] # signaling trackers
stun_servers = [ # NAT traversal
"stun:stun.l.google.com:19302",
"stun:stun1.l.google.com:19302",
]
turn_servers = [] # optional TURN relays
turn_user = ""
turn_pass = ""
[downloads.transcode]
enabled = true # master switch
hw_accel = "auto" # auto | none | nvenc | qsv | vaapi | videotoolbox
@ -412,16 +503,6 @@ max_height = 0 # 0 = no cap; e.g. 720 forces 720p max
max_concurrent = 2 # max simultaneous ffmpeg processes
```
#### `[downloads.webrtc]`
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `enabled` | bool | `true` | Browser↔daemon WebRTC peer for the in-browser P2P player. Disable to skip WebRTC tracker signalling (saves ~5MB RAM, blocks WebRTC streaming — HLS still works). |
| `trackers` | `[]string` | `["wss://tracker.torrentclaw.com"]` | Signaling trackers for peer discovery. |
| `stun_servers` | `[]string` | Google public STUN ×2 | ICE candidate gathering. |
| `turn_servers` | `[]string` | `[]` | Optional TURN relays for symmetric-NAT users. |
| `turn_user` / `turn_pass` | string | `""` | Credentials for authed TURN servers. Applied to all `turn_servers`. |
#### `[downloads.transcode]`
| Key | Type | Default | Notes |
@ -438,6 +519,108 @@ If `transcode.enabled = true` but `ffmpeg` / `ffprobe` aren't on PATH, the
daemon logs a warning at startup and HLS sessions are rejected at runtime
with a clear error — install ffmpeg or set `enabled = false`.
#### `[downloads.hls_cache]` — persistent HLS segment cache
```toml
[downloads.hls_cache]
enabled = true # on by default
size_gb = 5 # disk budget; LRU eviction once exceeded
dir = "" # custom path; empty = ~/.cache/unarr/hls-cache
```
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `enabled` | bool | `true` | Persists finished HLS encodes per `(source, quality, audio_index)`. A second play of the same file at the same quality reuses the segments — no ffmpeg, near-zero CPU, instant playback. Set to `false` to delete segments on session close (original behavior). |
| `size_gb` | int | `5` | Cache budget in gigabytes. When exceeded the LRU sweeper evicts the least-recently-used cached encodes hourly. Minimum 1 GB (smaller values are clamped up). |
| `dir` | string | `""` | Custom storage path. Empty defaults to `~/.cache/unarr/hls-cache` (Linux/macOS) or the user cache dir (Windows). |
**What it does.** First play encodes normally (ffmpeg writes segments).
On session close, if every segment is on disk and ffmpeg exited cleanly,
the directory is sealed with a `.complete` marker and kept. Next time the
same source + quality combo is requested, the daemon serves segments
straight from disk — no transcode, no warm-up, no CPU cost.
**Why per (source, quality, audio).** Renaming the file or switching
quality invalidates the entry: the segments are tied to the exact source
bytes and the exact ffmpeg parameters. Re-encoding generates a new key.
**Eviction.** A background goroutine wakes every hour. If total cache size
exceeds `size_gb`, it deletes the oldest entries (by mtime) until under
budget. Active sessions are pinned — they never get evicted mid-play.
**Disable.** Either edit the TOML to set `enabled = false`, or remove the
cache directory manually (it'll be recreated as needed). Disabling does
not delete existing cached segments — drop `dir` (or `~/.cache/unarr/hls-cache`)
to reclaim the space.
#### `[downloads.vpn]`
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `enabled` | bool | `false` | Managed VPN: at startup the daemon fetches a WireGuard config from your account and split-tunnels torrent traffic through it. Needs a PRO+ plan with the VPN add-on. Toggle with `unarr vpn enable` / `disable`. |
| `config_file` | string | `""` | Self-hosted / personal VPN: path to a local WireGuard `.conf`. **Takes precedence over `enabled`** — when set, the daemon uses this file and never calls the API. |
See the [VPN](#vpn) section above for how it works (split-tunnel, no root) and
how to protect your other devices.
#### `[downloads.funnel]` — public HTTPS hostname for the daemon (CloudFlare Quick Tunnel)
```toml
[downloads.funnel]
enabled = false # off by default
```
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `enabled` | bool | `false` | Spawns `cloudflared tunnel --url http://localhost:<stream_port>` as a child process at daemon startup. Toggle with `unarr funnel on` / `off`. Requires `cloudflared` on PATH. |
**What it does.** Without a tunnel, the daemon is reachable on `localhost`,
your LAN, and (if installed) Tailscale. That covers the same-machine and
Tailscale-connected cases, but the **browser-based player on torrentclaw.com
fails on any other network** because HTTPS pages can't fetch HTTP resources
("mixed content"). Enabling the funnel gives the daemon a public
`https://<random>.trycloudflare.com` hostname so the web player picks it up
and playback works from anywhere — phone on cellular, friend's laptop on a
foreign Wi-Fi, anywhere. The Stremio addon already works cross-network
(native mpv/VLC players ignore CORS), so this is strictly a web-player fix.
**Privacy posture.** Bytes pass through CloudFlare's edge — TorrentClaw never
relays content (we don't see your traffic), CloudFlare does. Quick Tunnels
are **anonymous** (no CF account required); the registration is unauthenticated
and the hostname is a random label, but CF logs request metadata like any CDN
would. If you want zero third-party byte access, use Tailscale instead.
**Limitations (free Quick Tunnels).**
| Aspect | Limit |
|--------|-------|
| Session lifetime | ~6 hours, then the hostname rotates. cloudflared re-registers automatically; the web picks up the new URL on the next sync. In-flight HLS sessions break across the rotation (browser retries). |
| Bandwidth | No documented hard cap, but CF reserves the right to throttle. 1080p HLS (~6 Mbps) is fine; 4K HEVC at 25 Mbps may hit throttling. |
| Latency | +2080 ms vs direct LAN/Tailscale (extra hop browser → CF edge → tunnel). HLS player buffer absorbs it. |
| Concurrency | One tunnel serves N viewers. CF rate-limits ~200 req/s, plenty for HLS segments. |
| TOS | CloudFlare flags Quick Tunnels as "not for production traffic". They can decommission an abusive tunnel without notice. |
For heavy / high-throughput / persistent-URL use cases, switch to a CloudFlare
Named Tunnel (free, needs a CF account) or run your own reverse proxy — both
out of scope for the bundled command.
**Disable.** `unarr funnel off` flips `enabled` to `false` in the TOML and
prompts you to restart the daemon. You can also edit `config.toml` directly:
```toml
[downloads.funnel]
enabled = false
```
**Install cloudflared.**
- Linux: `apt install cloudflared` (after adding CF's apt repo) — see
<https://pkg.cloudflare.com>. Or pull the static binary from
<https://github.com/cloudflare/cloudflared/releases>.
- macOS: `brew install cloudflared`.
- Windows: `winget install --id Cloudflare.cloudflared`.
If `cloudflared` is not on PATH the daemon logs a warning at startup and
falls back to LAN/Tailscale-only reachability.
### Environment variables
Environment variables override config file values:

View file

@ -59,6 +59,50 @@ This project follows these security practices:
- **Non-root Docker** — Container runs as unprivileged user (UID 1000)
- **Dependency scanning** — Automated via Dependabot
## Container Image Vulnerability Scanning
The Docker image (`torrentclaw/unarr`) is scanned by Docker Scout on Docker Hub and
by a CVE gate in CI (see `.github/workflows/`). Two things matter when reading the
Docker Hub vulnerability count:
- **Scanner database differs.** Docker Hub (Scout) matches `package@version` against
NVD/GHSA. Trivy/Alpine `secdb` only lists CVEs Alpine has acknowledged and patched.
A high Scout count with a clean Trivy report is expected, not a contradiction.
- **The bulk comes from the bundled `ffmpeg` codec stack.** Alpine's `ffmpeg`
package pulls ~40 codec/parser libraries (`x264`, `x265`, `libvpx`, `aom`,
`dav1d`, `libtheora`, `libvorbis`, `libwebp`, `libbluray`, `libopenmpt`, …).
Each carries a long NVD history that Alpine does not backport. ffmpeg is a
**functional dependency** — the HLS transcode pipeline shells out to
`ffmpeg`/`ffprobe` to decode untrusted media and re-encode to H.264 + AAC.
### Accepted risk and policy
- **Fixable** CRITICAL/HIGH findings **block** a release (CI CVE gate, `only-fixed`).
- **Unfixed-upstream** codec CVEs are tracked but **accepted**: there is no patched
Alpine package to move to, and dropping codecs would break playback of common
formats. They are mitigated by the hardening below rather than eliminated.
- Images are **rebuilt and re-pushed weekly** (scheduled workflow) so any newly
*fixed* base/ffmpeg/Go patch lands between tagged releases.
### Mitigations (run the container hardened)
Crafted media (torrents are untrusted input) is the realistic attack vector against
ffmpeg's parsers. The shipped `docker-compose.yml` already applies:
- **Non-root** user (UID 1000), **read-only** root filesystem, writable `tmpfs` only.
- **Resource limits** (memory/CPU) to bound a runaway decode.
Recommended additions for exposed deployments:
```yaml
cap_drop: ["ALL"]
security_opt:
- no-new-privileges:true
```
If you do not need HLS transcoding, you can run with transcoding disabled to
avoid feeding untrusted media to ffmpeg at all.
## Disclosure Policy
We follow coordinated disclosure. We will credit reporters in the release notes unless they prefer to remain anonymous.

View file

@ -1,268 +0,0 @@
// wstracker-probe — connects to a WebSocket BitTorrent tracker and either
// (a) advertises a fake info_hash to verify announce signalling, or
// (b) seeds a real file via the WebTorrent protocol so a browser
// webtorrent.js client can fetch it for end-to-end verification.
//
// Modes:
//
// wstracker-probe -tracker wss://tracker.torrentclaw.com
// Announces a random info_hash; exits 0 on TrackerAnnounceSuccessful.
//
// wstracker-probe -tracker wss://… -seed /path/to/file.mp4
// Builds a single-file torrent in memory, seeds forever, prints the
// magnet (with the WSS tracker injected). Ctrl-C to stop.
//
// Useful for browser ↔ unarr e2e — point a webtorrent.js page at the
// printed magnet and the player should pull pieces via WebRTC data channel.
package main
import (
"context"
"crypto/rand"
"flag"
"fmt"
"log"
"net/url"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
alog "github.com/anacrolix/log"
"github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/bencode"
"github.com/anacrolix/torrent/metainfo"
"github.com/anacrolix/torrent/storage"
"github.com/pion/webrtc/v4"
)
func main() {
tracker := flag.String("tracker", "wss://tracker.torrentclaw.com", "WSS tracker URL to probe")
timeout := flag.Duration("timeout", 30*time.Second, "max wait for successful announce (ignored in -seed mode)")
seedPath := flag.String("seed", "", "path to a file to seed (single-file torrent). When set, runs forever instead of exiting on first announce.")
flag.Parse()
if *seedPath != "" {
runSeeder(*seedPath, *tracker)
return
}
runProbe(*tracker, *timeout)
}
// runProbe — single random-hash announce, exits on success/error/timeout.
func runProbe(trackerURL string, timeout time.Duration) {
tmp, err := os.MkdirTemp("", "wstracker-probe-*")
if err != nil {
log.Fatalf("temp dir: %v", err)
}
defer os.RemoveAll(tmp)
cfg := baseClientConfig(tmp)
annSuccess := make(chan struct{}, 1)
annError := make(chan error, 1)
cfg.Callbacks.StatusUpdated = append(
cfg.Callbacks.StatusUpdated,
func(e torrent.StatusUpdatedEvent) {
switch e.Event { //nolint:exhaustive // peer events are noise for tracker probe
case torrent.TrackerConnected:
if e.Error != nil {
fmt.Printf("[probe] tracker connect FAILED: %v\n", e.Error)
} else {
fmt.Printf("[probe] tracker connected: %s\n", e.Url)
}
case torrent.TrackerAnnounceSuccessful:
fmt.Printf("[probe] tracker announce OK: %s ih=%s\n", e.Url, e.InfoHash)
select {
case annSuccess <- struct{}{}:
default:
}
case torrent.TrackerAnnounceError:
fmt.Printf("[probe] tracker announce ERROR: %s ih=%s err=%v\n", e.Url, e.InfoHash, e.Error)
select {
case annError <- e.Error:
default:
}
case torrent.TrackerDisconnected:
fmt.Printf("[probe] tracker disconnected: %s err=%v\n", e.Url, e.Error)
}
},
)
client, err := torrent.NewClient(cfg)
if err != nil {
log.Fatalf("create torrent client: %v", err)
}
defer client.Close()
var ih [20]byte
if _, err := rand.Read(ih[:]); err != nil {
log.Fatalf("random info_hash: %v", err)
}
magnet := fmt.Sprintf("magnet:?xt=urn:btih:%x&tr=%s", ih, trackerURL)
fmt.Printf("[probe] tracker=%s info_hash=%x timeout=%s\n", trackerURL, ih, timeout)
t, err := client.AddMagnet(magnet)
if err != nil {
log.Fatalf("add magnet: %v", err)
}
defer t.Drop()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
select {
case <-annSuccess:
fmt.Println("[probe] OK — tracker announce succeeded")
os.Exit(0)
case err := <-annError:
fmt.Printf("[probe] FAIL — tracker announce error: %v\n", err)
os.Exit(1)
case <-ctx.Done():
fmt.Printf("[probe] FAIL — timeout after %s\n", timeout)
os.Exit(2)
}
}
// runSeeder — builds a single-file torrent for the given path, adds it to
// a WebTorrent-enabled client, and seeds until SIGINT/SIGTERM.
func runSeeder(filePath, trackerURL string) {
abs, err := filepath.Abs(filePath)
if err != nil {
log.Fatalf("resolve seed path: %v", err)
}
st, err := os.Stat(abs)
if err != nil {
log.Fatalf("stat seed file: %v", err)
}
if st.IsDir() {
log.Fatalf("-seed currently supports a single file, not a directory: %s", abs)
}
dataDir := filepath.Dir(abs)
// Build single-file torrent metadata.
info := metainfo.Info{
PieceLength: chooseSeedPieceLength(st.Size()),
Name: filepath.Base(abs),
}
if err := info.BuildFromFilePath(abs); err != nil {
log.Fatalf("build info from file: %v", err)
}
infoBytes, err := bencode.Marshal(info)
if err != nil {
log.Fatalf("marshal info: %v", err)
}
mi := &metainfo.MetaInfo{
InfoBytes: infoBytes,
AnnounceList: metainfo.AnnounceList{{trackerURL}},
CreatedBy: "wstracker-probe",
}
ih := mi.HashInfoBytes()
cfg := baseClientConfig(dataDir)
cfg.Seed = true
cfg.Callbacks.StatusUpdated = append(
cfg.Callbacks.StatusUpdated,
func(e torrent.StatusUpdatedEvent) {
switch e.Event { //nolint:exhaustive
case torrent.TrackerConnected:
if e.Error != nil {
fmt.Printf("[seed] tracker connect FAILED: %v\n", e.Error)
} else {
fmt.Printf("[seed] tracker connected: %s\n", e.Url)
}
case torrent.TrackerAnnounceSuccessful:
fmt.Printf("[seed] tracker announce OK: %s ih=%s\n", e.Url, e.InfoHash)
case torrent.TrackerAnnounceError:
fmt.Printf("[seed] tracker announce ERROR: %s err=%v\n", e.Url, e.Error)
case torrent.TrackerDisconnected:
fmt.Printf("[seed] tracker disconnected: %s err=%v\n", e.Url, e.Error)
}
},
)
client, err := torrent.NewClient(cfg)
if err != nil {
log.Fatalf("create torrent client: %v", err)
}
defer client.Close()
t, err := client.AddTorrent(mi)
if err != nil {
log.Fatalf("add torrent: %v", err)
}
t.DownloadAll()
dn := url.QueryEscape(info.Name)
enc := url.QueryEscape(trackerURL)
magnet := fmt.Sprintf("magnet:?xt=urn:btih:%s&dn=%s&tr=%s", ih.HexString(), dn, enc)
fmt.Printf("[seed] file=%s size=%d bytes piece_length=%d\n", abs, st.Size(), info.PieceLength)
fmt.Printf("[seed] info_hash=%s\n", ih.HexString())
fmt.Printf("[seed] magnet=%s\n", magnet)
fmt.Println("[seed] seeding via WebRTC. Ctrl-C to stop.")
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
statTicker := time.NewTicker(5 * time.Second)
defer statTicker.Stop()
for {
select {
case <-statTicker.C:
s := t.Stats()
fmt.Printf("[seed] peers=%d uploaded=%d bytes seeders=%d leechers=%d\n",
s.ActivePeers, s.BytesWrittenData.Int64(),
s.ConnectedSeeders, s.ActivePeers-s.ConnectedSeeders)
case <-stop:
fmt.Println("[seed] stopping")
return
}
}
}
// baseClientConfig — shared anacrolix client config for both modes.
// WebTorrent is the only transport enabled; TCP/uTP/DHT/IPv6 are disabled
// to keep the moving parts to the minimum required for a WSS-only test.
func baseClientConfig(dataDir string) *torrent.ClientConfig {
cfg := torrent.NewDefaultClientConfig()
cfg.DataDir = dataDir
cfg.DefaultStorage = storage.NewMMap(dataDir)
cfg.NoUpload = false
cfg.DisableTCP = true
cfg.DisableUTP = true
cfg.DisableIPv6 = true
cfg.NoDHT = true
cfg.NoDefaultPortForwarding = true
cfg.ListenPort = 0
cfg.Logger = alog.Default.FilterLevel(alog.Critical)
cfg.DisableWebtorrent = false
cfg.ICEServerList = []webrtc.ICEServer{
{URLs: []string{"stun:stun.l.google.com:19302"}},
{URLs: []string{"stun:stun1.l.google.com:19302"}},
}
return cfg
}
// chooseSeedPieceLength picks a sane piece size for a given file size.
// Mirrors the libtorrent / qBittorrent ladder so the resulting torrent
// is interoperable with mainstream clients.
func chooseSeedPieceLength(size int64) int64 {
switch {
case size < 4*1024*1024: // < 4 MiB
return 16 * 1024 // 16 KiB
case size < 64*1024*1024: // < 64 MiB
return 64 * 1024 // 64 KiB
case size < 512*1024*1024: // < 512 MiB
return 256 * 1024 // 256 KiB
case size < 4*1024*1024*1024: // < 4 GiB
return 1024 * 1024 // 1 MiB
default:
return 4 * 1024 * 1024 // 4 MiB
}
}

View file

@ -1,48 +1,77 @@
# unarr — TorrentClaw agent
#
# Quick start:
# 1. Copy this file to any directory.
# 2. Set UNARR_API_KEY to your key (Settings → API Keys on torrentclaw.com).
# 3. Set DOWNLOAD_DIR to your media folder (absolute path).
# 4. Run: docker compose up -d
#
# Get your API key: https://torrentclaw.com/settings/api-keys
# Full docs: https://torrentclaw.com/unarr
services:
unarr:
build:
context: ..
dockerfile: unarr/Dockerfile
image: torrentclaw/unarr:latest
pull_policy: always # always pull on `up` so you stay on the latest release
container_name: unarr
restart: unless-stopped
user: "1000:1000"
# Read-only root filesystem — only volumes are writable
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
volumes:
# Config: your config.toml lives here
- ./config:/config
# Downloads: finished media goes here
- ~/Media:/downloads
# Data: torrent metadata, piece DB, cache
- unarr-data:/data
environment:
- TZ=${TZ:-UTC}
# Optional overrides (uncomment to use):
# - UNARR_API_KEY=tc_your_key_here
# - UNARR_API_URL=https://torrentclaw.com
# Resource limits — adjust to your needs
deploy:
resources:
limits:
memory: 512M
cpus: "2.0"
# Torrent P2P needs host network or explicit port range
# Option A: host network (simplest, full P2P performance)
# host network is required for:
# - streaming to reach your TV / mobile / other LAN devices (port 11818)
# - HLS transcode server (port 11819)
# - Tailscale connectivity (if you use it)
# On macOS / Windows Docker Desktop, replace with `ports` mapping (see below).
network_mode: host
# Option B: bridge network with port mapping (more isolated)
# Uncomment below and comment out network_mode above:
environment:
# --- Required ---
- UNARR_API_KEY=${UNARR_API_KEY:?Set UNARR_API_KEY in .env or export it}
# --- Optional ---
# Server URL — change only if you run a self-hosted TorrentClaw instance
- UNARR_API_URL=${UNARR_API_URL:-https://torrentclaw.com}
- TZ=${TZ:-UTC}
volumes:
# Config: config.toml is auto-created here on first run.
# After first start, edit this file to set organize paths, quality, etc.
- ${CONFIG_DIR:-./config}:/config
# Downloads: where finished media is saved.
# Set DOWNLOAD_DIR in .env or export it before running.
- ${DOWNLOAD_DIR:?Set DOWNLOAD_DIR to your media folder}:/downloads
# Data: piece-completion DB, HLS cache, DHT nodes.
# Named volume keeps this off your media drive (avoids NFS locking issues).
- unarr-data:/data
# --- NVIDIA GPU: hardware transcode (nvenc) ---
# Uncomment on a host with an NVIDIA GPU + nvidia-container-toolkit. The
# image already bundles an nvenc-enabled ffmpeg and sets
# NVIDIA_DRIVER_CAPABILITIES=video,compute,utility, so this device
# reservation is the only thing needed to enable HW transcode. Without a GPU
# the same image falls back to software (libx264) automatically — leave it
# commented. (docker run equivalent: add --gpus all)
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: all
# capabilities: [gpu]
# # Optional: cap CPU/RAM for transcoding on shared hosts
# limits:
# memory: 2G
# cpus: "4.0"
# --- macOS / Windows alternative (replace network_mode: host above) ---
# network_mode: bridge
# ports:
# - "6881-6889:6881-6889/tcp"
# - "6881-6889:6881-6889/udp"
# - "11818:11818" # direct stream (VLC, download)
# - "11819:11819" # HLS transcode (web player)
# - "42069:42069" # BitTorrent incoming peers
# Note: streaming will only reach devices on the same machine.
# For LAN / Tailscale playback use a Linux host with network_mode: host.
volumes:
unarr-data:

15
go.mod
View file

@ -10,13 +10,15 @@ require (
github.com/charmbracelet/huh v1.0.0
github.com/fatih/color v1.19.0
github.com/getsentry/sentry-go v0.44.1
github.com/gofrs/flock v0.13.0
github.com/google/uuid v1.6.0
github.com/huin/goupnp v1.3.0
github.com/olekukonko/tablewriter v1.1.4
github.com/pion/webrtc/v4 v4.2.11
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/torrentclaw/go-client v0.2.0
golang.org/x/term v0.41.0
golang.org/x/term v0.43.0
golang.org/x/text v0.37.0
golang.org/x/time v0.15.0
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
)
@ -107,13 +109,13 @@ require (
github.com/pion/stun/v3 v3.1.1 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/pion/turn/v4 v4.1.4 // indirect
github.com/pion/webrtc/v4 v4.2.11 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/protolambda/ctxlock v0.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/tidwall/btree v1.8.1 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
@ -122,12 +124,11 @@ require (
go.opentelemetry.io/otel v1.42.0 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/otel/trace v1.42.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
lukechampine.com/blake3 v1.4.1 // indirect

30
go.sum
View file

@ -207,6 +207,8 @@ github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6Wezm
github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
@ -473,8 +475,8 @@ golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnf
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20220428152302-39d4317da171/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
@ -485,8 +487,8 @@ golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTk
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -500,8 +502,8 @@ golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -532,18 +534,18 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -554,8 +556,8 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

136
internal/acme/acme.go Normal file
View file

@ -0,0 +1,136 @@
// Package acme handles the agent side of the per-agent direct-TLS feature
// (plex.direct model). The agent generates and keeps its private key LOCALLY,
// builds a CSR for *.<hash>.agent.unarr.app, and sends only the CSR to the
// web-side broker (which runs the ACME order against Let's Encrypt via DNS-01
// and returns the signed chain). The key never leaves the machine.
//
// File layout under the agent state dir:
//
// certs/agent.key ECDSA P-256 private key (PEM, persisted across renewals)
// certs/agent.crt issued certificate chain (PEM, hot-reloaded by the stream server)
package acme
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"fmt"
"os"
"path/filepath"
"time"
)
// GenerateHash returns a 32-hex-char (16-byte) high-entropy agent hash label.
func GenerateHash() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate agent hash: %w", err)
}
return hex.EncodeToString(b), nil
}
// Paths returns the key/cert file paths under the agent state dir.
func Paths(dataDir string) (keyPath, certPath string) {
dir := filepath.Join(dataDir, "certs")
return filepath.Join(dir, "agent.key"), filepath.Join(dir, "agent.crt")
}
// loadOrCreateKey returns the agent's persistent EC key, creating + persisting
// it on first use. Reused across renewals so the cert always matches the key.
func loadOrCreateKey(keyPath string) (*ecdsa.PrivateKey, error) {
if data, err := os.ReadFile(keyPath); err == nil {
block, _ := pem.Decode(data)
if block == nil {
return nil, fmt.Errorf("agent.key is not valid PEM")
}
key, err := x509.ParseECPrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse agent.key: %w", err)
}
return key, nil
}
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("generate EC key: %w", err)
}
der, err := x509.MarshalECPrivateKey(key)
if err != nil {
return nil, fmt.Errorf("marshal EC key: %w", err)
}
if err := os.MkdirAll(filepath.Dir(keyPath), 0o700); err != nil {
return nil, fmt.Errorf("mkdir certs: %w", err)
}
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
if err := os.WriteFile(keyPath, pemBytes, 0o600); err != nil {
return nil, fmt.Errorf("write agent.key: %w", err)
}
return key, nil
}
// BuildCSR ensures the persistent key exists and returns a PEM CSR requesting
// the wildcard *.<hash>.<baseDomain> (plus the bare <hash>.<baseDomain> so a
// future non-wildcard use still validates). baseDomain e.g. "agent.unarr.app".
func BuildCSR(dataDir, hash, baseDomain string) (csrPEM string, err error) {
keyPath, _ := Paths(dataDir)
key, err := loadOrCreateKey(keyPath)
if err != nil {
return "", err
}
wildcard := "*." + hash + "." + baseDomain
base := hash + "." + baseDomain
tmpl := &x509.CertificateRequest{
Subject: pkix.Name{CommonName: wildcard},
DNSNames: []string{wildcard, base},
SignatureAlgorithm: x509.ECDSAWithSHA256,
}
der, err := x509.CreateCertificateRequest(rand.Reader, tmpl, key)
if err != nil {
return "", fmt.Errorf("create CSR: %w", err)
}
return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: der})), nil
}
// WriteCert persists the issued certificate chain atomically (temp file + rename)
// so a concurrent reader (NeedsIssue, or the listener's GetCertificate reload)
// can never observe a half-written PEM during a renewal.
func WriteCert(dataDir, certPEM string) error {
_, certPath := Paths(dataDir)
if err := os.MkdirAll(filepath.Dir(certPath), 0o700); err != nil {
return fmt.Errorf("mkdir certs: %w", err)
}
tmp := certPath + ".tmp"
if err := os.WriteFile(tmp, []byte(certPEM), 0o644); err != nil {
return fmt.Errorf("write agent.crt: %w", err)
}
if err := os.Rename(tmp, certPath); err != nil {
return fmt.Errorf("rename agent.crt: %w", err)
}
return nil
}
// renewBefore is how long ahead of expiry we proactively renew.
const renewBefore = 30 * 24 * time.Hour
// NeedsIssue reports whether we should (re)request a cert: true when the cert is
// missing, unparseable, expired, or within renewBefore of expiry.
func NeedsIssue(dataDir string) bool {
_, certPath := Paths(dataDir)
data, err := os.ReadFile(certPath)
if err != nil {
return true
}
block, _ := pem.Decode(data)
if block == nil {
return true
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return true
}
return time.Now().Add(renewBefore).After(cert.NotAfter)
}

123
internal/acme/acme_test.go Normal file
View file

@ -0,0 +1,123 @@
package acme
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"testing"
"time"
)
func TestGenerateHash(t *testing.T) {
h1, err := GenerateHash()
if err != nil {
t.Fatal(err)
}
if len(h1) != 32 {
t.Errorf("hash len = %d, want 32", len(h1))
}
h2, _ := GenerateHash()
if h1 == h2 {
t.Errorf("two hashes collided: %s", h1)
}
}
func TestBuildCSR(t *testing.T) {
dir := t.TempDir()
hash := "deadbeefdeadbeef"
csrPEM, err := BuildCSR(dir, hash, "agent.unarr.app")
if err != nil {
t.Fatal(err)
}
// Key persisted.
keyPath, _ := Paths(dir)
if _, err := os.Stat(keyPath); err != nil {
t.Errorf("key not persisted: %v", err)
}
// CSR parses + carries exactly the wildcard + base SANs.
block, _ := pem.Decode([]byte(csrPEM))
if block == nil {
t.Fatal("CSR is not valid PEM")
}
csr, err := x509.ParseCertificateRequest(block.Bytes)
if err != nil {
t.Fatal(err)
}
want := map[string]bool{
"*.deadbeefdeadbeef.agent.unarr.app": false,
"deadbeefdeadbeef.agent.unarr.app": false,
}
for _, n := range csr.DNSNames {
if _, ok := want[n]; !ok {
t.Errorf("unexpected SAN: %s", n)
}
want[n] = true
}
for n, seen := range want {
if !seen {
t.Errorf("missing SAN: %s", n)
}
}
// A second BuildCSR reuses the same key (cert must match the persistent key).
before, _ := os.ReadFile(keyPath)
if _, err := BuildCSR(dir, hash, "agent.unarr.app"); err != nil {
t.Fatal(err)
}
after, _ := os.ReadFile(keyPath)
if string(before) != string(after) {
t.Errorf("key changed across BuildCSR calls — renewals would break")
}
}
func TestNeedsIssue(t *testing.T) {
dir := t.TempDir()
// Missing cert → needs issue.
if !NeedsIssue(dir) {
t.Error("missing cert should need issue")
}
_, certPath := Paths(dir)
if err := os.MkdirAll(filepath.Dir(certPath), 0o700); err != nil {
t.Fatal(err)
}
writeSelfSigned := func(notAfter time.Time) {
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "*.x.agent.unarr.app"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: notAfter,
}
der, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
if err := os.WriteFile(certPath, pemBytes, 0o644); err != nil {
t.Fatal(err)
}
}
// Fresh cert (90d) → no issue needed.
writeSelfSigned(time.Now().Add(90 * 24 * time.Hour))
if NeedsIssue(dir) {
t.Error("fresh cert should not need issue")
}
// Within renew window (10d left) → needs issue.
writeSelfSigned(time.Now().Add(10 * 24 * time.Hour))
if !NeedsIssue(dir) {
t.Error("near-expiry cert should need issue")
}
// Garbage → needs issue.
_ = os.WriteFile(certPath, []byte("not a cert"), 0o644)
if !NeedsIssue(dir) {
t.Error("unparseable cert should need issue")
}
}

View file

@ -0,0 +1,105 @@
package agent
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"github.com/torrentclaw/unarr/internal/config"
)
// activeTasksFilePathFn is overridable for testing.
var activeTasksFilePathFn = func() string {
return filepath.Join(config.DataDir(), "active-tasks.json")
}
// ActiveTaskStore persists the dispatch payloads (agent.Task) of in-flight
// DOWNLOAD tasks so the daemon can re-submit them after a restart and have the
// downloaders resume the partial data — torrent via the persisted
// piece-completion DB, debrid via HTTP Range, usenet via its segment tracker.
//
// Distinct from LocalState (tasks.json), which holds transient status/progress
// for syncing to the web; this holds the re-dispatch payload needed to restart
// the work. An entry is added when a download starts and removed when it
// reaches a genuine terminal state (completed / failed / cancelled) — but NOT
// when the daemon is shutting down, so an interrupted download survives the
// restart and resumes.
type ActiveTaskStore struct {
mu sync.Mutex
tasks map[string]Task
}
// NewActiveTaskStore creates an empty store. Call Load() to hydrate it from disk.
func NewActiveTaskStore() *ActiveTaskStore {
return &ActiveTaskStore{tasks: make(map[string]Task)}
}
// Add records (or replaces) a task and persists the set.
func (s *ActiveTaskStore) Add(t Task) {
s.mu.Lock()
defer s.mu.Unlock()
s.tasks[t.ID] = t
s.flushLocked()
}
// Remove drops a task and persists the set. No-op if absent.
func (s *ActiveTaskStore) Remove(taskID string) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.tasks[taskID]; !ok {
return
}
delete(s.tasks, taskID)
s.flushLocked()
}
// Load reads the persisted tasks from disk into the store and returns them.
// Returns nil on a missing or unreadable file (a fresh daemon has nothing to
// resume). Safe to call once at startup before any Add/Remove.
func (s *ActiveTaskStore) Load() []Task {
data, err := os.ReadFile(activeTasksFilePathFn())
if err != nil {
return nil
}
var tasks []Task
if json.Unmarshal(data, &tasks) != nil {
return nil
}
s.mu.Lock()
defer s.mu.Unlock()
s.tasks = make(map[string]Task, len(tasks))
for _, t := range tasks {
if t.ID != "" {
s.tasks[t.ID] = t
}
}
out := make([]Task, 0, len(s.tasks))
for _, t := range s.tasks {
out = append(out, t)
}
return out
}
// flushLocked atomically writes the current set to disk. Caller holds s.mu.
// Best-effort: a write failure is non-fatal (the in-memory set stays correct;
// at worst a crash before the next flush loses one resume entry).
func (s *ActiveTaskStore) flushLocked() {
tasks := make([]Task, 0, len(s.tasks))
for _, t := range s.tasks {
tasks = append(tasks, t)
}
data, err := json.MarshalIndent(tasks, "", " ")
if err != nil {
return
}
path := activeTasksFilePathFn()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return
}
_ = os.Rename(tmp, path)
}

View file

@ -0,0 +1,75 @@
package agent
import (
"path/filepath"
"testing"
)
// withTempStorePath points the store file at a temp location for the duration
// of a test and restores the original afterward.
func withTempStorePath(t *testing.T) {
t.Helper()
orig := activeTasksFilePathFn
path := filepath.Join(t.TempDir(), "active-tasks.json")
activeTasksFilePathFn = func() string { return path }
t.Cleanup(func() { activeTasksFilePathFn = orig })
}
func TestActiveTaskStore_AddLoadRoundTrip(t *testing.T) {
withTempStorePath(t)
s := NewActiveTaskStore()
s.Add(Task{ID: "a", InfoHash: "hashA", Title: "Movie A", Mode: "download"})
s.Add(Task{ID: "b", NzbID: "nzbB", Title: "Show B"})
// A fresh store hydrated from disk must see both.
loaded := NewActiveTaskStore().Load()
if len(loaded) != 2 {
t.Fatalf("Load returned %d tasks, want 2", len(loaded))
}
byID := map[string]Task{}
for _, tk := range loaded {
byID[tk.ID] = tk
}
if byID["a"].InfoHash != "hashA" || byID["a"].Title != "Movie A" {
t.Errorf("task a not round-tripped: %+v", byID["a"])
}
if byID["b"].NzbID != "nzbB" {
t.Errorf("task b not round-tripped: %+v", byID["b"])
}
}
func TestActiveTaskStore_Remove(t *testing.T) {
withTempStorePath(t)
s := NewActiveTaskStore()
s.Add(Task{ID: "a", Title: "A"})
s.Add(Task{ID: "b", Title: "B"})
s.Remove("a")
s.Remove("missing") // no-op
loaded := NewActiveTaskStore().Load()
if len(loaded) != 1 || loaded[0].ID != "b" {
t.Fatalf("after Remove(a), Load = %+v, want only b", loaded)
}
}
func TestActiveTaskStore_Overwrite(t *testing.T) {
withTempStorePath(t)
s := NewActiveTaskStore()
s.Add(Task{ID: "a", Title: "old"})
s.Add(Task{ID: "a", Title: "new"}) // same id replaces
loaded := NewActiveTaskStore().Load()
if len(loaded) != 1 || loaded[0].Title != "new" {
t.Fatalf("overwrite failed: %+v", loaded)
}
}
func TestActiveTaskStore_LoadMissingFile(t *testing.T) {
withTempStorePath(t) // temp dir, no file written yet
if got := NewActiveTaskStore().Load(); got != nil {
t.Errorf("Load on missing file = %+v, want nil", got)
}
}

View file

@ -79,6 +79,26 @@ func (c *Client) Register(ctx context.Context, req RegisterRequest) (*RegisterRe
return &resp, nil
}
// IssueCert sends a CSR to the web-side ACME broker and returns the signed
// certificate chain (PEM). The agent's private key never leaves the machine —
// only the CSR is sent. Used by the per-agent direct-TLS feature.
func (c *Client) IssueCert(ctx context.Context, csrPEM string) (string, error) {
req := struct {
CSRPem string `json:"csrPem"`
}{CSRPem: csrPEM}
var resp struct {
Certificate string `json:"certificate"`
Error string `json:"error,omitempty"`
}
if err := c.doPost(ctx, "/api/internal/agent/issue-cert", req, &resp); err != nil {
return "", fmt.Errorf("issue cert: %w", err)
}
if resp.Certificate == "" {
return "", fmt.Errorf("issue cert: empty certificate (%s)", resp.Error)
}
return resp.Certificate, nil
}
// Deregister notifies the server that the agent is shutting down.
func (c *Client) Deregister(ctx context.Context, agentID string) error {
req := struct {
@ -91,6 +111,110 @@ func (c *Client) Deregister(ctx context.Context, agentID string) error {
return nil
}
// ReportUpgradeResult tells the server the outcome of a previously requested
// upgrade so the server can clear `upgrade_requested`. Without this call the
// flag stays sticky and the daemon would re-trigger applyAutoUpgrade on every
// sync after upgrade — even for "already on target version" no-ops.
func (c *Client) ReportUpgradeResult(ctx context.Context, agentID string, success bool, version, errMsg string) error {
req := struct {
AgentID string `json:"agentId"`
Success bool `json:"success"`
Version string `json:"version,omitempty"`
Error string `json:"error,omitempty"`
}{AgentID: agentID, Success: success, Version: version, Error: errMsg}
var resp StatusResponse
if err := c.doPost(ctx, "/api/internal/agent/upgrade-result", req, &resp); err != nil {
return fmt.Errorf("report upgrade result: %w", err)
}
return nil
}
// MarkSessionReady signals the server that the first HLS segment + init.mp4
// landed on disk for the given session. The web side flips
// streaming_session.ready_at = NOW(), which its SSE endpoint emits to
// subscribed players so the "Preparando…" UI ends without polling HEAD
// on /hls/<id>/master.m3u8.
//
// Best-effort: the server is the source of truth for session state and
// will reach the same conclusion via HEAD probes anyway if this call
// fails. We log the error in the caller but don't retry — by the time
// a retry would land the user is likely already playing.
func (c *Client) MarkSessionReady(ctx context.Context, sessionID string, health *SessionHealth) error {
req := struct {
SessionID string `json:"sessionId"`
Health *SessionHealth `json:"health,omitempty"`
}{SessionID: sessionID, Health: health}
var resp StatusResponse
if err := c.doPost(ctx, "/api/internal/agent/session-ready", req, &resp); err != nil {
return fmt.Errorf("mark session ready: %w", err)
}
return nil
}
// ReportSessionError is the failure-path counterpart of MarkSessionReady: it
// tells the web a streaming session can NOT start (file gone, path rejected,
// ffmpeg missing, spawn failure…). The web marks the session failed, pushes an
// SSE "failed" event so the player stops probing a playlist that will never
// exist, and self-heals stale library state on code "file_missing".
//
// code is one of the stable machine codes the web understands:
// "file_missing" | "path_rejected" | "no_video_file" | "ffmpeg_unavailable" |
// "start_failed". message is free-form detail for diagnostics.
//
// Best-effort like MarkSessionReady: on older web deployments without the
// endpoint this 404s — the caller logs and the player falls back to its
// probe-deadline behaviour, exactly as before this channel existed.
func (c *Client) ReportSessionError(ctx context.Context, sessionID, code, message string) error {
req := struct {
SessionID string `json:"sessionId"`
Code string `json:"code"`
Message string `json:"message,omitempty"`
}{SessionID: sessionID, Code: code, Message: message}
var resp StatusResponse
if err := c.doPost(ctx, "/api/internal/agent/session-error", req, &resp); err != nil {
return fmt.Errorf("report session error: %w", err)
}
return nil
}
// SessionHealth is an OPTIONAL live-transcode health snapshot attached to a
// session-ready report (F3). A nil *SessionHealth means the agent has no
// telemetry to share (cache hit, direct-play, or progress not yet stable) and
// the web side keeps its stall-shape heuristic. Old web replicas ignore the
// extra field; old agents simply never send it.
type SessionHealth struct {
// "ok" (≥ realtime) | "marginal" (keeps up barely) | "struggling" (can't).
Health string `json:"health"`
// ffmpeg speed= EWMA: 1.0 = exactly realtime, < 1.0 = slower than playback.
RealtimeRatio float64 `json:"realtimeRatio"`
// "realtime" | "transcode" (encoder is the wall) | "input_bound" (source
// read) | "copy" (HLS-copy session: no encode — always realtime; the
// heartbeat exists so the web can tell "copy session" from "old agent
// with no telemetry", which both used to read as a null health).
Reason string `json:"reason"`
}
// RefreshStreamURL re-resolves a fresh debrid direct URL for a live streaming
// session (hueco #2 / 2c). Called by the daemon when a debrid source expires
// mid-stream (the link is time-limited; the content is still cached). Returns
// the new URL on success; an error (incl. 409/410) means refresh isn't
// possible and the caller should stop trying.
func (c *Client) RefreshStreamURL(ctx context.Context, sessionID string) (string, error) {
req := struct {
SessionID string `json:"sessionId"`
}{SessionID: sessionID}
var resp struct {
DirectURL string `json:"directUrl"`
}
if err := c.doPost(ctx, "/api/internal/agent/stream-url", req, &resp); err != nil {
return "", fmt.Errorf("refresh stream url: %w", err)
}
if resp.DirectURL == "" {
return "", fmt.Errorf("refresh stream url: empty url in response")
}
return resp.DirectURL, nil
}
// ReportStatus reports download progress. Returns server-side flags the CLI must act on.
func (c *Client) ReportStatus(ctx context.Context, update StatusUpdate) (*StatusResponse, error) {
var resp StatusResponse
@ -388,3 +512,14 @@ func (c *Client) handleResponse(resp *http.Response, dst any) error {
return nil
}
// SubmitSkipSegments uploads detected intro/credits segments after a library
// scan. Must run AFTER SyncLibrary — the server resolves file paths against
// the freshly-synced library_item rows.
func (c *Client) SubmitSkipSegments(ctx context.Context, req SkipSegmentsRequest) (*SkipSegmentsResponse, error) {
var resp SkipSegmentsResponse
if err := c.doPostWith(ctx, c.librarySyncClient, "/api/internal/agent/skip-segments", req, &resp); err != nil {
return nil, fmt.Errorf("skip segments: %w", err)
}
return &resp, nil
}

View file

@ -6,10 +6,13 @@ import (
"fmt"
"log"
"os"
"os/exec"
"runtime"
"strings"
"sync/atomic"
"time"
"github.com/torrentclaw/unarr/internal/upgrade"
)
// DaemonConfig holds daemon runtime settings.
@ -19,12 +22,25 @@ type DaemonConfig struct {
Version string
DownloadDir string
StreamPort int // port for the HTTP stream server
HTTPSStreamPort int // TLS stream listener port (per-agent direct-TLS); 0 when off
AgentHash string // stable high-entropy hash for *.<hash>.agent.unarr.app
StreamSecret string // hex HMAC key for stream tokens (reported so the web can mint HLS tokens)
LanIP string // LAN IP (reported in sync for stream URL resolution)
TailscaleIP string // Tailscale IP (reported in sync for stream URL resolution)
CanDelete bool // library.allow_delete is enabled
ScanPaths []string // configured scan paths for file deletion validation
HWAccel string // detected encoder backend ("nvenc"/"qsv"/"vaapi"/"videotoolbox"/"none")
MaxTranscodeHeight int // resolution cap the agent can transcode comfortably (px)
// Diagnostic data populated by engine.DetectHWAccelDiagnostic at daemon
// start. Surfaced in the web "Diagnose transcoder" modal — lets a user
// see which encoders the ffmpeg binary supports and which devices the
// host exposes without running `unarr probe-hwaccel`.
FFmpegVersion string // first line of `ffmpeg -version`
FFmpegPath string // resolved binary path
HWEncoders []string // HW-class encoder names found in `ffmpeg -encoders`
HWDevices []string // device files + driver bins detected at probe time
AutoUpgrade bool // honor server-flagged upgrades by downloading + restarting (default: true)
Downlink string // realtime downlink transport: "auto" (SSE+long-poll fallback) | "sse" | "poll"
}
// Daemon manages agent registration and the sync loop.
@ -37,9 +53,19 @@ type Daemon struct {
// Callbacks — set by cmd/daemon.go before calling Run.
OnTasksClaimed func(tasks []Task)
OnStreamRequested func(req StreamRequest)
OnWebRTCSession func(sess WebRTCSession)
OnStreamSession func(sess StreamSession)
OnControlAction func(action, taskID string, deleteFiles bool)
GetActiveCount func() int // returns number of active downloads (wired from manager)
// GetActiveStreamCount returns the number of live stream sessions (player +
// HLS transcode). Wired from cmd. The graceful AUTO-upgrade path defers
// while this is > 0 so it never cuts a viewer mid-playback; a MANUAL
// `unarr update` ignores it and applies immediately.
GetActiveStreamCount func() int
// OnAgentKeyMinted fires when a register reply carries a freshly-minted
// per-machine key (the daemon registered with a general/legacy key). cmd
// persists it so the next start authenticates with the bound agent key —
// migrating legacy agents and stopping the per-restart re-mint.
OnAgentKeyMinted func(newKey string)
// State
User UserInfo
@ -47,6 +73,18 @@ type Daemon struct {
Info AgentInfo
State DaemonState
lastNotifiedVersion string
// upgradeDeferring guards a single defer-until-idle waiter for auto-upgrade.
upgradeDeferring atomic.Bool
// Managed-VPN split-tunnel state, set by cmd/daemon.go before Run and folded
// into DaemonState on every write so external tools (`unarr vpn status`) see it.
vpnActive bool
vpnMode string
vpnServer string
// CloudFlare Quick Tunnel public URL; folded into DaemonState + heartbeat
// so the web can prefer it over Tailscale/LAN for in-browser playback.
funnelURL string
// Watching tracks whether a user is viewing download progress in the web UI.
Watching atomic.Bool
@ -70,6 +108,30 @@ func NewDaemon(cfg DaemonConfig, client *Client) *Daemon {
// SyncClient returns the sync client for external wiring.
func (d *Daemon) SyncClient() *SyncClient { return d.sync }
// SetVPNState records the managed-VPN split-tunnel state so it's reflected in the
// daemon state file (read by `unarr vpn status`). Call before Run.
func (d *Daemon) SetVPNState(active bool, mode, server string) {
d.vpnActive = active
d.vpnMode = mode
d.vpnServer = server
}
// SetFunnelURL records the CloudFlare Quick Tunnel hostname so it's reflected
// in the daemon state file (read by `unarr funnel status`) and in heartbeat
// requests (so the web prefers it over Tailscale/LAN). Pass "" to clear.
func (d *Daemon) SetFunnelURL(url string) {
d.funnelURL = url
d.State.FunnelURL = url
WriteState(&d.State)
}
// UpdateStreamSecret sets the hex HMAC key reported on register so the web can
// mint HLS stream tokens the agent will accept.
func (d *Daemon) UpdateStreamSecret(secretHex string) {
d.cfg.StreamSecret = secretHex
d.sync.cfg.StreamSecret = secretHex
}
// UpdateStreamPort updates the stream port reported in sync requests.
func (d *Daemon) UpdateStreamPort(port int) {
d.cfg.StreamPort = port
@ -87,10 +149,22 @@ func (d *Daemon) Register(ctx context.Context) error {
Version: d.cfg.Version,
DownloadDir: d.cfg.DownloadDir,
StreamPort: d.cfg.StreamPort,
HTTPSStreamPort: d.cfg.HTTPSStreamPort,
AgentHash: d.cfg.AgentHash,
StreamSecret: d.cfg.StreamSecret,
LanIP: d.cfg.LanIP,
TailscaleIP: d.cfg.TailscaleIP,
HWAccel: d.cfg.HWAccel,
MaxTranscodeHeight: d.cfg.MaxTranscodeHeight,
FFmpegVersion: d.cfg.FFmpegVersion,
FFmpegPath: d.cfg.FFmpegPath,
HWEncoders: d.cfg.HWEncoders,
HWDevices: d.cfg.HWDevices,
VPNActive: d.vpnActive,
VPNMode: d.vpnMode,
VPNServer: d.vpnServer,
FunnelURL: d.funnelURL,
IsDocker: RunningInDocker(),
}
if free, total, err := DiskInfo(d.cfg.DownloadDir); err == nil {
req.DiskFreeBytes = free
@ -124,6 +198,12 @@ func (d *Daemon) Register(ctx context.Context) error {
return fmt.Errorf("register: %w (after %d retries)", err, maxRetries)
}
// Registered with a general/legacy key → the server minted a per-machine key.
// Persist it (cmd wires the callback) so the next start uses the bound key.
if resp.AgentKey != "" && d.OnAgentKeyMinted != nil {
d.OnAgentKeyMinted(resp.AgentKey)
}
d.User = resp.User
d.Features = resp.Features
now := time.Now()
@ -141,6 +221,10 @@ func (d *Daemon) Register(ctx context.Context) error {
PID: os.Getpid(),
StartedAt: now,
MethodStats: make(map[string]int),
VPNActive: d.vpnActive,
VPNMode: d.vpnMode,
VPNServer: d.vpnServer,
FunnelURL: d.funnelURL,
}
WriteState(&d.State)
@ -158,6 +242,21 @@ func (d *Daemon) Run(ctx context.Context) error {
log.Printf("Agent registered: %s (%s) [%s]", d.User.Name, d.User.Email, d.User.Plan)
log.Printf("Features: torrent=%v debrid=%v usenet=%v", d.Features.Torrent, d.Features.Debrid, d.Features.Usenet)
// Usenet needs par2 (segment repair) + an extractor (RAR/7z) on the host.
// Without par2, a single bad segment corrupts the file silently; without
// an extractor, RAR-packed downloads can't be unpacked. Warn loudly at
// startup so the operator installs them before the first download fails.
if d.Features.Usenet {
if _, err := exec.LookPath("par2"); err != nil {
log.Printf("[usenet] WARNING: par2 not found in PATH — corrupted segments cannot be repaired and extraction may fail. Install par2 (apt install par2 / brew install par2).")
}
_, unrarErr := exec.LookPath("unrar")
_, sevenZErr := exec.LookPath("7z")
if unrarErr != nil && sevenZErr != nil {
log.Printf("[usenet] WARNING: no archive extractor (unrar or 7z) found — RAR-packed downloads cannot be unpacked. Install unrar or 7z.")
}
}
// Wire sync callbacks
d.sync.OnNewTasks = func(tasks []Task) {
if d.OnTasksClaimed != nil {
@ -170,20 +269,30 @@ func (d *Daemon) Run(ctx context.Context) error {
}
}
d.sync.OnStreamRequest = func(req StreamRequest) {
// Off the sync loop: the handler does blocking I/O (os.Stat retries on
// NFS, then ffprobe in SetFile) — running it inline would stall task
// dispatch + status reporting for other items. The single-stream model
// (atomic SetFile swap, last-wins) tolerates concurrent requests.
if d.OnStreamRequested != nil {
d.OnStreamRequested(req)
go d.OnStreamRequested(req)
}
}
d.sync.OnWebRTCSession = func(sess WebRTCSession) {
if d.OnWebRTCSession != nil {
d.OnWebRTCSession(sess)
d.sync.OnStreamSession = func(sess StreamSession) {
if d.OnStreamSession != nil {
d.OnStreamSession(sess)
}
}
d.sync.OnUpgrade = func(version string) {
if version != d.lastNotifiedVersion {
d.lastNotifiedVersion = version
log.Printf("New version available: %s (run `unarr self-update` to upgrade)", version)
if version == d.lastNotifiedVersion {
return
}
d.lastNotifiedVersion = version
if !d.cfg.AutoUpgrade {
log.Printf("[upgrade] new version available: %s — auto_upgrade=false, run `unarr update` to apply", version)
return
}
log.Printf("[upgrade] new version available: %s — applying auto-upgrade", version)
go d.deferAutoUpgradeUntilIdle(version)
}
d.sync.OnScan = func() {
log.Printf("Library scan requested by server")
@ -195,6 +304,15 @@ func (d *Daemon) Run(ctx context.Context) error {
d.sync.OnWatchingChange = func(watching bool) {
d.Watching.Store(watching)
}
d.sync.GetVPNState = func() (bool, string, string) {
return d.vpnActive, d.vpnMode, d.vpnServer
}
d.sync.GetFunnelURL = func() string {
return d.funnelURL
}
d.sync.GetAgentStatus = func() string {
return d.State.Status
}
d.sync.OnSyncSuccess = func() {
d.State.LastHeartbeat = time.Now()
if d.GetActiveCount != nil {
@ -224,6 +342,108 @@ func (d *Daemon) Deregister() {
RemoveState()
}
// deferAutoUpgradeUntilIdle holds an AUTO-upgrade until the agent is idle (no
// active stream), then applies it. The user's call: no background update is
// worth cutting a viewer mid-playback. A MANUAL `unarr update` bypasses this
// entirely (see cmd/self_update.go) and is the escape hatch for an urgent fix.
//
// Runs in its own goroutine. A process-lifetime guard keeps exactly ONE waiter
// even though the server re-sends the upgrade signal on every sync.
func (d *Daemon) deferAutoUpgradeUntilIdle(version string) {
if !d.upgradeDeferring.CompareAndSwap(false, true) {
return
}
defer d.upgradeDeferring.Store(false)
activeStreams := func() int {
if d.GetActiveStreamCount == nil {
return 0
}
return d.GetActiveStreamCount()
}
if n := activeStreams(); n > 0 {
log.Printf("[upgrade] v%s deferred — %d active stream(s); will apply when idle", version, n)
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
if n := activeStreams(); n == 0 {
break
}
}
log.Printf("[upgrade] no active streams — applying deferred upgrade to v%s", version)
}
d.applyAutoUpgrade(version) // exits the process on success
}
// applyAutoUpgrade downloads the target version and exits so the service
// supervisor (systemd Restart=always on Linux) respawns on the new binary.
// Triggered by the server's upgrade signal — opt-in flag set by the user from
// the web UI; the daemon never auto-upgrades on a passive version bump.
//
// Reports the outcome to /api/internal/agent/upgrade-result so the server
// clears `upgrade_requested`. Without this report the flag stays sticky and
// the daemon would loop on every sync — including the no-op case where it's
// already on the target version.
func (d *Daemon) applyAutoUpgrade(targetVersion string) {
currentClean := strings.TrimPrefix(d.cfg.Version, "v")
targetClean := strings.TrimPrefix(targetVersion, "v")
// No-op: server signal arrived but we're already running the target. This
// happens when the daemon restarts after a previous auto-upgrade before
// reportUpgradeResult cleared the flag, or when the operator manually
// installed the same version off-band. Skip Execute (which would also
// no-op) AND skip os.Exit, but DO clear the flag — otherwise we loop.
if currentClean == targetClean {
log.Printf("[upgrade] already on v%s — clearing server flag", currentClean)
ctxR, cancelR := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelR()
if err := d.client.ReportUpgradeResult(ctxR, d.cfg.AgentID, true, currentClean, ""); err != nil {
log.Printf("[upgrade] report-result failed (will retry on next signal): %v", err)
}
return
}
// Tell the web we're updating so a NEW playback attempt during the brief
// restart sees "agent updating" instead of a hard session error. One
// heartbeat carries this before the (blocking) download + os.Exit below.
d.State.Status = "updating"
WriteState(&d.State)
d.TriggerSync()
upgrader := &upgrade.Upgrader{
CurrentVersion: currentClean,
OnProgress: func(msg string) {
log.Printf("[upgrade] %s", msg)
},
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
result := upgrader.Execute(ctx, targetVersion)
if !result.Success {
log.Printf("[upgrade] auto-upgrade failed: %v", result.Error)
errMsg := ""
if result.Error != nil {
errMsg = result.Error.Error()
}
ctxR, cancelR := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelR()
if err := d.client.ReportUpgradeResult(ctxR, d.cfg.AgentID, false, targetClean, errMsg); err != nil {
log.Printf("[upgrade] report-result failed: %v", err)
}
return
}
log.Printf("[upgrade] upgraded v%s → v%s; reporting result + exiting so service supervisor restarts on new binary",
result.OldVersion, result.NewVersion)
ctxR, cancelR := context.WithTimeout(context.Background(), 10*time.Second)
if err := d.client.ReportUpgradeResult(ctxR, d.cfg.AgentID, true, result.NewVersion, ""); err != nil {
log.Printf("[upgrade] report-result failed: %v", err)
}
cancelR()
time.Sleep(500 * time.Millisecond)
os.Exit(0)
}
// isTransientError returns true for errors worth retrying (429, 5xx, network).
func isTransientError(err error) bool {
if err == nil {

26
internal/agent/docker.go Normal file
View file

@ -0,0 +1,26 @@
package agent
import "os"
// RunningInDocker reports whether the agent process is running inside a Docker
// (or compatible OCI) container. The web uses this to swap the in-app "force
// update" button — which drives the binary self-update path that hard-stops
// inside a container (see internal/upgrade) — for a copy-paste `docker pull`
// command instead.
//
// Detection order:
// 1. UNARR_DOCKER env truthy — baked into the official image's Dockerfile, so
// it also covers podman/containerd running our image (which don't create
// /.dockerenv).
// 2. /.dockerenv exists — the standard marker Docker writes into every
// container, covering images that didn't set the env.
func RunningInDocker() bool {
switch os.Getenv("UNARR_DOCKER") {
case "1", "true", "yes":
return true
}
if _, err := os.Stat("/.dockerenv"); err == nil {
return true
}
return false
}

View file

@ -0,0 +1,216 @@
package agent
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestDownlinkMode(t *testing.T) {
cases := map[string]string{
"": "auto",
"auto": "auto",
"AUTO": "auto",
" sse ": "sse",
"sse": "sse",
"poll": "poll",
"garbage": "auto",
}
for in, want := range cases {
sc, _ := newTestSyncClient("http://127.0.0.1:0")
sc.cfg.Downlink = in
if got := sc.downlinkMode(); got != want {
t.Errorf("downlinkMode(%q) = %q, want %q", in, got, want)
}
}
}
func TestHandleDownlinkEvent_SyncNudge(t *testing.T) {
sc, _ := newTestSyncClient("http://127.0.0.1:0")
sc.handleDownlinkEvent(DownlinkEvent{Event: DownlinkEventSync, Data: json.RawMessage(`{"reason":"wake"}`)})
select {
case <-sc.SyncNow:
// good — TriggerSync fired
default:
t.Error("sync event did not trigger an immediate sync")
}
}
func TestHandleDownlinkEvent_TypedControls(t *testing.T) {
sc, _ := newTestSyncClient("http://127.0.0.1:0")
var gotAction, gotTask string
var gotDelete bool
sc.OnControl = func(action, taskID string, deleteFiles bool) {
gotAction, gotTask, gotDelete = action, taskID, deleteFiles
}
payload := `{"controls":[{"action":"cancel","taskId":"task-xyz","deleteFiles":true}]}`
sc.handleDownlinkEvent(DownlinkEvent{Event: DownlinkEventCommand, Data: json.RawMessage(payload)})
if gotAction != "cancel" || gotTask != "task-xyz" || !gotDelete {
t.Errorf("OnControl got (%q,%q,%v), want (cancel,task-xyz,true)", gotAction, gotTask, gotDelete)
}
}
func TestHandleDownlinkEvent_PingIsLivenessOnly(t *testing.T) {
sc, _ := newTestSyncClient("http://127.0.0.1:0")
controlCalled := false
sc.OnControl = func(string, string, bool) { controlCalled = true }
sc.handleDownlinkEvent(DownlinkEvent{Event: DownlinkEventPing})
if controlCalled {
t.Error("ping must not invoke OnControl")
}
select {
case <-sc.SyncNow:
t.Error("ping must not trigger a sync")
default:
}
}
func TestHandleDownlinkEvent_BadPayloadNoPanic(t *testing.T) {
sc, _ := newTestSyncClient("http://127.0.0.1:0")
sc.OnControl = func(string, string, bool) { t.Error("OnControl must not fire on bad payload") }
// Should log + return, not panic.
sc.handleDownlinkEvent(DownlinkEvent{Event: DownlinkEventCommand, Data: json.RawMessage(`{not json`)})
}
// TestRunEventStreamOnce_Healthy: a server that sends a heartbeat then a sync
// event, then closes → runEventStreamOnce returns true (healthy) and the sync
// nudge fired.
func TestRunEventStreamOnce_Healthy(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
f, _ := w.(http.Flusher)
w.Write([]byte(": hb\n\n"))
if f != nil {
f.Flush()
}
w.Write([]byte("event: sync\ndata: {}\n\n"))
if f != nil {
f.Flush()
}
// Return → response body closes → stream ends.
}))
defer srv.Close()
sc, _ := newTestSyncClient(srv.URL)
sc.livenessTimeout = 500 * time.Millisecond
healthy := sc.runEventStreamOnce(context.Background())
if !healthy {
t.Error("expected healthy=true after receiving frames")
}
select {
case <-sc.SyncNow:
default:
t.Error("expected a sync nudge from the sync event")
}
}
// TestRunEventStreamOnce_DeadOrBuffered: server connects 200 OK but sends
// nothing → liveness deadline fires → returns false (so auto mode falls back).
func TestRunEventStreamOnce_DeadOrBuffered(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
// Send NO frames — simulate a silently-buffering proxy.
<-r.Context().Done()
}))
defer srv.Close()
sc, _ := newTestSyncClient(srv.URL)
sc.livenessTimeout = 150 * time.Millisecond
start := time.Now()
healthy := sc.runEventStreamOnce(context.Background())
if healthy {
t.Error("expected healthy=false when no frame arrives within liveness deadline")
}
if elapsed := time.Since(start); elapsed > 2*time.Second {
t.Errorf("liveness deadline did not fire promptly (took %s)", elapsed)
}
}
// TestRunEventStreamOnce_PreambleThenStall: a partial-buffering proxy that
// flushes the connect preamble (one heartbeat) then goes silent must be treated
// as UNHEALTHY (false), so the auto fallback eventually triggers. This is the
// common buffering mode the zero-frame test doesn't cover.
func TestRunEventStreamOnce_PreambleThenStall(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
f, _ := w.(http.Flusher)
// Flush ONE heartbeat (the preamble) then stall — never send more.
w.Write([]byte(": connected hb=15000\n\n"))
if f != nil {
f.Flush()
}
<-r.Context().Done()
}))
defer srv.Close()
sc, _ := newTestSyncClient(srv.URL)
sc.livenessTimeout = 150 * time.Millisecond
if sc.runEventStreamOnce(context.Background()) {
t.Error("a stream that flushes one ping then stalls must be unhealthy (else fallback never triggers)")
}
}
// TestRunEventStreamOnce_ConnectFail: dead server → false, no hang.
func TestRunEventStreamOnce_ConnectFail(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
url := srv.URL
srv.Close() // port now refuses
sc, _ := newTestSyncClient(url)
sc.livenessTimeout = 500 * time.Millisecond
if sc.runEventStreamOnce(context.Background()) {
t.Error("expected healthy=false on connect failure")
}
}
// TestRunEventStreamOnce_CtxCancel: cancelling ctx returns promptly.
func TestRunEventStreamOnce_CtxCancel(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
<-r.Context().Done()
}))
defer srv.Close()
sc, _ := newTestSyncClient(srv.URL)
sc.livenessTimeout = 10 * time.Second
ctx, cancel := context.WithCancel(context.Background())
go func() {
time.Sleep(100 * time.Millisecond)
cancel()
}()
done := make(chan struct{})
go func() {
sc.runEventStreamOnce(ctx)
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("runEventStreamOnce did not return after ctx cancel")
}
}

View file

@ -0,0 +1,208 @@
package agent
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
// DownlinkEvent is one parsed Server-Sent Event from the agent events stream
// (GET /api/internal/agent/events). Event is the SSE "event:" name; Data is the
// raw "data:" payload (nil for heartbeat pings).
type DownlinkEvent struct {
Event string
Data json.RawMessage
}
// CommandEvent is the payload of an "command" downlink event — typed control
// actions the server pushes for instant application (cancel/pause). Mirrors the
// `controls` field of /agent/sync so the same OnControl callback handles both.
type CommandEvent struct {
Controls []ControlAction `json:"controls"`
}
// Downlink event names. Heartbeat pings surface as a distinct event so the
// consumer can reset its liveness deadline without acting on them.
const (
DownlinkEventPing = "ping" // SSE comment line (`: hb`) — liveness only
DownlinkEventSync = "sync" // nudge: run a full /agent/sync
DownlinkEventCommand = "command" // typed control actions
)
// Bounds on the SSE reader, identical in spirit to the retired WebRTC signal
// reader: a hostile or buggy server must not be able to grow daemon memory by
// streaming one unbounded line or unbounded `data:` continuation lines.
const (
eventsSSEMaxLineBytes = 256 * 1024
eventsSSEMaxEventBytes = 1024 * 1024
)
// EventStream wraps an open SSE downlink connection. Read from Events() until
// the channel closes (server recycle, network drop, or ctx cancel), then call
// Close() and reopen if you want to keep listening. Always defer Close().
type EventStream struct {
resp *http.Response
cancel context.CancelFunc
events chan DownlinkEvent
errs chan error
done chan struct{}
}
// Events streams server-pushed downlink events. Heartbeat comments surface as
// DownlinkEvent{Event: DownlinkEventPing}. The channel closes when the
// connection ends.
func (s *EventStream) Events() <-chan DownlinkEvent { return s.events }
// Err returns the terminating error (if any) once Events() has closed.
func (s *EventStream) Err() error {
select {
case err := <-s.errs:
return err
default:
return nil
}
}
// Close cancels the request and waits for the reader goroutine to drain.
// Safe to call more than once.
func (s *EventStream) Close() error {
if s.cancel != nil {
s.cancel()
}
if s.resp != nil {
s.resp.Body.Close()
}
<-s.done
return nil
}
// OpenEventStream opens a long-lived SSE connection to the agent events
// downlink. Routed through MirrorPool failover for the INITIAL connect only
// (a mid-stream drop is surfaced as a closed channel, not retried here — the
// caller reopens). Caller MUST Close() (or cancel ctx) to free resources.
func (c *Client) OpenEventStream(ctx context.Context) (*EventStream, error) {
streamCtx, cancel := context.WithCancel(ctx)
var resp *http.Response
err := c.withMirrorFailover(func(base string) error {
req, reqErr := http.NewRequestWithContext(streamCtx, http.MethodGet, base+"/api/internal/agent/events", nil)
if reqErr != nil {
return fmt.Errorf("create events request: %w", reqErr)
}
c.setHeaders(req)
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Cache-Control", "no-cache")
// No-timeout client: the connection is intentionally long-lived; ctx
// controls cancellation (same as the wake long-poll).
r, doErr := c.wakeClient.Do(req)
if doErr != nil {
return fmt.Errorf("events request failed: %w", doErr)
}
if r.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(r.Body, 1<<10))
r.Body.Close()
return &HTTPError{StatusCode: r.StatusCode, Message: strings.TrimSpace(string(body))}
}
resp = r
return nil
})
if err != nil {
cancel()
return nil, err
}
stream := &EventStream{
resp: resp,
cancel: cancel,
events: make(chan DownlinkEvent, 8),
errs: make(chan error, 1),
done: make(chan struct{}),
}
go stream.read()
return stream, nil
}
func (s *EventStream) read() {
defer close(s.done)
defer close(s.events)
scanner := bufio.NewScanner(s.resp.Body)
scanner.Buffer(make([]byte, 16*1024), eventsSSEMaxLineBytes)
ctx := s.resp.Request.Context()
var dataBuf bytes.Buffer
var eventName string
emit := func(ev DownlinkEvent) bool {
select {
case s.events <- ev:
return true
case <-ctx.Done():
return false
}
}
for scanner.Scan() {
line := strings.TrimRight(scanner.Text(), "\r")
if line == "" {
// Blank line ends an event — dispatch if we accumulated data.
if dataBuf.Len() > 0 {
name := eventName
if name == "" {
name = "message"
}
data := make([]byte, dataBuf.Len())
copy(data, dataBuf.Bytes())
if !emit(DownlinkEvent{Event: name, Data: json.RawMessage(data)}) {
return
}
}
dataBuf.Reset()
eventName = ""
continue
}
if strings.HasPrefix(line, ":") {
// SSE comment / heartbeat — surface as a ping so the consumer resets
// its liveness deadline (and can tell a live stream from a silently
// buffered one that never delivers anything).
if !emit(DownlinkEvent{Event: DownlinkEventPing}) {
return
}
continue
}
if strings.HasPrefix(line, "event:") {
eventName = strings.TrimSpace(line[len("event:"):])
continue
}
if strings.HasPrefix(line, "data:") {
payload := strings.TrimSpace(line[len("data:"):])
if dataBuf.Len()+len(payload)+1 > eventsSSEMaxEventBytes {
select {
case s.errs <- fmt.Errorf("sse: event exceeded %d bytes", eventsSSEMaxEventBytes):
default:
}
return
}
if dataBuf.Len() > 0 {
dataBuf.WriteByte('\n')
}
dataBuf.WriteString(payload)
continue
}
// id:, retry:, unknown fields — ignored.
}
if err := scanner.Err(); err != nil {
select {
case s.errs <- err:
default:
}
}
}

View file

@ -0,0 +1,193 @@
package agent
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// sseServer returns an httptest server that writes the given raw SSE body and
// flushes, then holds the connection until the request context is cancelled (so
// the client drives the close, like the real long-lived endpoint).
func sseServer(t *testing.T, body string) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.WriteHeader(http.StatusOK)
if _, err := w.Write([]byte(body)); err != nil {
return
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
<-r.Context().Done()
}))
}
func TestOpenEventStream_ParsesTypedEvents(t *testing.T) {
body := "retry: 2000\n\n" +
": connected hb=15000\n\n" +
"event: sync\ndata: {\"reason\":\"wake\"}\n\n" +
"event: command\ndata: {\"controls\":[{\"action\":\"cancel\",\"taskId\":\"t1\"}]}\n\n"
srv := sseServer(t, body)
defer srv.Close()
_, client := newTestSyncClient(srv.URL)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
stream, err := client.OpenEventStream(ctx)
if err != nil {
t.Fatalf("OpenEventStream: %v", err)
}
defer stream.Close()
var got []DownlinkEvent
timeout := time.After(2 * time.Second)
for len(got) < 3 {
select {
case ev, ok := <-stream.Events():
if !ok {
t.Fatalf("stream closed early after %d events", len(got))
}
got = append(got, ev)
case <-timeout:
t.Fatalf("timed out; got %d events: %+v", len(got), got)
}
}
// First frame is the heartbeat comment surfaced as a ping.
if got[0].Event != DownlinkEventPing {
t.Errorf("event[0] = %q, want ping", got[0].Event)
}
if got[1].Event != DownlinkEventSync {
t.Errorf("event[1] = %q, want sync", got[1].Event)
}
if got[2].Event != DownlinkEventCommand {
t.Errorf("event[2] = %q, want command", got[2].Event)
}
if !strings.Contains(string(got[2].Data), "cancel") {
t.Errorf("command data missing payload: %s", got[2].Data)
}
}
func TestOpenEventStream_MultiLineData(t *testing.T) {
// Two data: lines for one event must join with a newline.
body := "event: sync\ndata: line1\ndata: line2\n\n"
srv := sseServer(t, body)
defer srv.Close()
_, client := newTestSyncClient(srv.URL)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
stream, err := client.OpenEventStream(ctx)
if err != nil {
t.Fatalf("OpenEventStream: %v", err)
}
defer stream.Close()
select {
case ev := <-stream.Events():
if string(ev.Data) != "line1\nline2" {
t.Errorf("data = %q, want \"line1\\nline2\"", ev.Data)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for event")
}
}
func TestOpenEventStream_RejectsOversizedEvent(t *testing.T) {
// Many data: continuation lines until past eventsSSEMaxEventBytes → the
// reader surfaces an error and closes the channel (so the loop reconnects).
var b strings.Builder
b.WriteString("event: command\n")
chunk := "data: " + strings.Repeat("x", 4096) + "\n"
for b.Len() < eventsSSEMaxEventBytes+8192 {
b.WriteString(chunk)
}
b.WriteString("\n")
srv := sseServer(t, b.String())
defer srv.Close()
_, client := newTestSyncClient(srv.URL)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
stream, err := client.OpenEventStream(ctx)
if err != nil {
t.Fatalf("OpenEventStream: %v", err)
}
defer stream.Close()
// Drain until the channel closes (the oversized event must NOT be emitted).
timeout := time.After(2 * time.Second)
for {
select {
case ev, ok := <-stream.Events():
if !ok {
if stream.Err() == nil {
t.Error("expected an error after oversized event, got nil")
}
return
}
if ev.Event == DownlinkEventCommand {
t.Fatalf("oversized command event must not be dispatched")
}
case <-timeout:
t.Fatal("timed out; channel never closed after oversized event")
}
}
}
func TestOpenEventStream_Non200ReturnsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
fmt.Fprint(w, `{"error":"not found"}`)
}))
defer srv.Close()
_, client := newTestSyncClient(srv.URL)
_, err := client.OpenEventStream(context.Background())
if err == nil {
t.Fatal("expected error on 404, got nil")
}
var httpErr *HTTPError
if !errors.As(err, &httpErr) || httpErr.StatusCode != http.StatusNotFound {
t.Errorf("expected HTTPError 404, got %v", err)
}
}
func TestEventStream_CloseCancelsRead(t *testing.T) {
srv := sseServer(t, ": connected\n\n")
defer srv.Close()
_, client := newTestSyncClient(srv.URL)
stream, err := client.OpenEventStream(context.Background())
if err != nil {
t.Fatalf("OpenEventStream: %v", err)
}
// Drain the initial ping.
select {
case <-stream.Events():
case <-time.After(2 * time.Second):
t.Fatal("no initial ping")
}
done := make(chan struct{})
go func() {
stream.Close()
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Close() did not return — read goroutine leaked")
}
}

View file

@ -37,19 +37,28 @@ type MirrorsResponse struct {
// Hard-coded here (not loaded from config) because the whole point is to
// have something to consult when config-driven URLs all fail.
//
// Today there is one provider (GitHub Pages). The slice is intentionally
// shaped to take more — a second independent host (Cloudflare Pages,
// IPFS-Fleek, etc.) should be added as soon as it is provisioned. Keep
// any addition in sync with `STATIC_FALLBACKS` in
// `torrentclaw-web/src/lib/mirrors-config.ts` and `Docs/plans/security-stream-token.md`.
// Hosted on IPFS (content-addressed, re-pinnable, no host can take it down
// permanently — same bytes re-pinned anywhere keep the same CID). Multiple
// public gateways are listed so a single gateway being blocked doesn't kill
// the fallback; the /ipfs/<CID>/ path is identical across all gateways.
//
// GitHub Pages was removed 2026-05-17: the whole torrentclaw org is
// shadow-banned (public repos 404 to anonymous users). Do NOT re-add any
// github.io URL. Keep this slice in sync with `STATIC_FALLBACKS` in
// `torrentclaw-web/src/lib/mirrors-config.ts` — when the IPFS CID changes
// (scripts/publish-mirrors-ipfs.sh), update both.
//
// Future hardening: sign mirrors.json with the same ed25519 release key
// (or a sibling) so a hijack of any single static host cannot serve a
// malicious mirror list. Today the only signal is "agreement between
// independent providers" via cross-checking, which we leave to the
// operator.
const mirrorsIPFSCID = "bafybeigwux74fek7uky7nct47z5eqwwnpylakfxppqqnzbuxdw7p3ikfdy"
var DefaultStaticFallbackURLs = []string{
"https://torrentclaw.github.io/mirrors/mirrors.json",
"https://ipfs.io/ipfs/" + mirrorsIPFSCID + "/mirrors.json",
"https://dweb.link/ipfs/" + mirrorsIPFSCID + "/mirrors.json",
"https://gateway.pinata.cloud/ipfs/" + mirrorsIPFSCID + "/mirrors.json",
}
// FetchMirrorsWithFallback pulls the mirror list using FetchMirrors against
@ -78,8 +87,8 @@ func FetchMirrorsWithFallback(ctx context.Context, candidates []string, userAgen
}
// fetchMirrorsJSON pulls a MirrorsResponse from already-fully-qualified URLs
// (e.g. https://torrentclaw.github.io/mirrors/mirrors.json). Each candidate
// is tried in order; the first success wins.
// (e.g. https://ipfs.io/ipfs/<CID>/mirrors.json). Each candidate is tried
// in order; the first success wins.
func fetchMirrorsJSON(ctx context.Context, urls []string, userAgent string) (*MirrorsResponse, error) {
if len(urls) == 0 {
return nil, fmt.Errorf("no static fallback URLs configured")

View file

@ -0,0 +1,88 @@
package agent
import (
"fmt"
"net/http"
"net/url"
)
// MirrorRoundTripper gives any *http.Client the same mirror failover the agent
// control-plane Client has: on a transient transport error or a retryable 5xx
// it rewrites the request to the next mirror in the shared MirrorPool and
// retries. It exists so the public-API go-client stops diverging from the agent
// client — both now survive a primary-domain takedown using the SAME pool and
// the SAME transient-error policy (IsTransient).
//
// Requests whose body cannot be replayed (Body != nil && GetBody == nil) are
// sent once with no failover, so a consumed body is never re-read. Standard
// library requests built with a *bytes.Reader/strings.Reader (and all GETs) set
// GetBody, so this only affects exotic streaming bodies the public API doesn't use.
type MirrorRoundTripper struct {
pool *MirrorPool
inner http.RoundTripper
}
// NewMirrorRoundTripper wraps inner (defaults to http.DefaultTransport) with
// failover across pool's mirrors.
func NewMirrorRoundTripper(pool *MirrorPool, inner http.RoundTripper) *MirrorRoundTripper {
if inner == nil {
inner = http.DefaultTransport
}
return &MirrorRoundTripper{pool: pool, inner: inner}
}
// RoundTrip points the request at the current mirror and, on a transient
// failure, rotates the pool and retries against the next one. A non-transient
// HTTP status (4xx, or a 5xx IsTransient doesn't retry) or a non-replayable body
// is returned to the caller unchanged.
func (m *MirrorRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
attempts := 1
if req.Body == nil || req.GetBody != nil { // replayable → may fail over
if n := m.pool.Len(); n > attempts {
attempts = n
}
}
var lastErr error
for i := 0; i < attempts; i++ {
out := req.Clone(req.Context())
if req.GetBody != nil {
body, err := req.GetBody()
if err != nil {
return nil, fmt.Errorf("mirror transport: rebuild body: %w", err)
}
out.Body = body
}
if base, err := url.Parse(m.pool.Current()); err == nil && base.Host != "" {
out.URL.Scheme = base.Scheme
out.URL.Host = base.Host
out.Host = base.Host
}
resp, err := m.inner.RoundTrip(out)
last := i == attempts-1
switch {
case err != nil:
if last || !IsTransient(err) {
return nil, err
}
lastErr = err
case resp.StatusCode >= 400 && IsTransient(&HTTPError{StatusCode: resp.StatusCode}):
if last {
return resp, nil // surface the real 5xx to the caller
}
resp.Body.Close()
lastErr = fmt.Errorf("mirror %s: HTTP %d", out.URL.Host, resp.StatusCode)
default:
return resp, nil // success, or a status we must not retry (4xx/auth)
}
if _, rotated := m.pool.Rotate(); !rotated {
break
}
}
if lastErr == nil {
lastErr = fmt.Errorf("mirror transport: all mirrors failed")
}
return nil, lastErr
}

View file

@ -0,0 +1,172 @@
package agent
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestMirrorRoundTripper_FailoverOn503(t *testing.T) {
var primaryHits, mirrorHits int
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
primaryHits++
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer primary.Close()
mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
mirrorHits++
w.WriteHeader(http.StatusOK)
io.WriteString(w, "ok")
}))
defer mirror.Close()
pool := NewMirrorPool(primary.URL, []string{mirror.URL})
rt := NewMirrorRoundTripper(pool, http.DefaultTransport)
req, _ := http.NewRequest(http.MethodGet, primary.URL+"/api/v1/search", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("RoundTrip: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
if primaryHits != 1 || mirrorHits != 1 {
t.Errorf("hits primary=%d mirror=%d, want 1/1", primaryHits, mirrorHits)
}
}
func TestMirrorRoundTripper_NoFailoverOn404(t *testing.T) {
var mirrorHits int
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer primary.Close()
mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
mirrorHits++
w.WriteHeader(http.StatusOK)
}))
defer mirror.Close()
pool := NewMirrorPool(primary.URL, []string{mirror.URL})
rt := NewMirrorRoundTripper(pool, http.DefaultTransport)
req, _ := http.NewRequest(http.MethodGet, primary.URL+"/x", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("RoundTrip: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("status = %d, want 404 (surfaced, not retried)", resp.StatusCode)
}
if mirrorHits != 0 {
t.Errorf("mirror hit %d times — must NOT fail over on 404", mirrorHits)
}
}
func TestMirrorRoundTripper_FailoverOnConnRefused(t *testing.T) {
dead := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
deadURL := dead.URL
dead.Close() // port now refuses connections
mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer mirror.Close()
pool := NewMirrorPool(deadURL, []string{mirror.URL})
rt := NewMirrorRoundTripper(pool, http.DefaultTransport)
req, _ := http.NewRequest(http.MethodGet, deadURL+"/x", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("RoundTrip should have failed over, got: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200 after failover", resp.StatusCode)
}
}
func TestMirrorRoundTripper_ReplaysBodyOnFailover(t *testing.T) {
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
}))
defer primary.Close()
var gotBody string
mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
gotBody = string(b)
w.WriteHeader(http.StatusOK)
}))
defer mirror.Close()
pool := NewMirrorPool(primary.URL, []string{mirror.URL})
rt := NewMirrorRoundTripper(pool, http.DefaultTransport)
req, _ := http.NewRequest(http.MethodPost, primary.URL+"/x", strings.NewReader("payload"))
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("RoundTrip: %v", err)
}
defer resp.Body.Close()
if gotBody != "payload" {
t.Errorf("mirror received body %q, want \"payload\" (body must be replayed on failover)", gotBody)
}
}
func TestMirrorRoundTripper_NonReplayableBodyNoFailover(t *testing.T) {
var primaryHits, mirrorHits int
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
primaryHits++
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer primary.Close()
mirror := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
mirrorHits++
w.WriteHeader(http.StatusOK)
}))
defer mirror.Close()
pool := NewMirrorPool(primary.URL, []string{mirror.URL})
rt := NewMirrorRoundTripper(pool, http.DefaultTransport)
// A body with no GetBody can't be replayed → must be sent once, no failover.
req, _ := http.NewRequest(http.MethodPost, primary.URL+"/x", io.NopCloser(strings.NewReader("payload")))
req.GetBody = nil
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("RoundTrip: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusServiceUnavailable {
t.Errorf("status = %d, want 503 (single attempt, no failover)", resp.StatusCode)
}
if primaryHits != 1 || mirrorHits != 0 {
t.Errorf("hits primary=%d mirror=%d, want 1/0 (non-replayable body must not fail over)", primaryHits, mirrorHits)
}
}
func TestMirrorRoundTripper_SingleMirrorSurfaces503(t *testing.T) {
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer primary.Close()
pool := NewMirrorPool(primary.URL, nil)
rt := NewMirrorRoundTripper(pool, http.DefaultTransport)
req, _ := http.NewRequest(http.MethodGet, primary.URL+"/x", nil)
resp, err := rt.RoundTrip(req)
if err != nil {
t.Fatalf("RoundTrip: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusServiceUnavailable {
t.Errorf("status = %d, want 503 surfaced (no mirror to fail over to)", resp.StatusCode)
}
}

View file

@ -1,258 +0,0 @@
package agent
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// SignalRole identifies who produced a signalling message. The opposite role
// receives it.
type SignalRole string
const (
SignalRoleBrowser SignalRole = "browser"
SignalRoleAgent SignalRole = "agent"
)
// SignalMessageType matches the server-side z.enum on
// /api/internal/stream/signal/[sessionId] route.
type SignalMessageType string
const (
SignalMsgOffer SignalMessageType = "offer"
SignalMsgAnswer SignalMessageType = "answer"
SignalMsgCandidate SignalMessageType = "candidate"
SignalMsgCandidateEnd SignalMessageType = "candidate-end"
SignalMsgBye SignalMessageType = "bye"
)
// SignalMessage mirrors the bus envelope on the web side.
type SignalMessage struct {
From SignalRole `json:"from"`
Type SignalMessageType `json:"type"`
Payload string `json:"payload"`
TS int64 `json:"ts"`
}
// PostSignal enqueues a signalling message produced by this agent. The
// browser receives it on its next SSE event push.
func (c *Client) PostSignal(ctx context.Context, sessionID string, msg SignalMessage) error {
body := map[string]any{
"from": string(SignalRoleAgent),
"type": string(msg.Type),
"payload": msg.Payload,
}
path := fmt.Sprintf("/api/internal/stream/signal/%s", sessionID)
return c.doPost(ctx, path, body, &struct {
OK bool `json:"ok"`
}{})
}
// SignalEventStream wraps an open SSE connection. Read messages from Events()
// until the channel closes (server timeout or context cancel). Always defer
// Close() to release the underlying response body.
type SignalEventStream struct {
resp *http.Response
cancel context.CancelFunc
events chan SignalMessage
errs chan error
done chan struct{}
}
// Events streams browser-produced messages addressed to the agent.
// The channel closes when the SSE connection ends; the caller should then
// call Close() and reopen if it wants to keep listening.
func (s *SignalEventStream) Events() <-chan SignalMessage { return s.events }
// Err returns the terminating error (if any) once Events() has closed.
func (s *SignalEventStream) Err() error {
select {
case err := <-s.errs:
return err
default:
return nil
}
}
// Close cancels the underlying HTTP request and waits for the reader goroutine
// to drain. Safe to call more than once.
func (s *SignalEventStream) Close() error {
if s.cancel != nil {
s.cancel()
}
if s.resp != nil {
s.resp.Body.Close()
}
<-s.done
return nil
}
// OpenSignalStream opens a long-lived SSE connection to the signal events
// endpoint. Caller MUST cancel ctx (or call Close()) to free resources.
//
// The server caps each response at ~25 s; OpenSignalStream surfaces the
// disconnect by closing the events channel. Caller should reopen until the
// session ends.
func (c *Client) OpenSignalStream(ctx context.Context, sessionID string) (*SignalEventStream, error) {
streamCtx, cancel := context.WithCancel(ctx)
url := fmt.Sprintf("%s/api/internal/stream/signal/%s/events", c.baseURL(), sessionID)
req, err := http.NewRequestWithContext(streamCtx, http.MethodGet, url, nil)
if err != nil {
cancel()
return nil, fmt.Errorf("open signal stream: %w", err)
}
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("Cache-Control", "no-cache")
// Use a per-call client with no timeout (SSE connections are long).
sseClient := &http.Client{}
resp, err := sseClient.Do(req)
if err != nil {
cancel()
return nil, fmt.Errorf("open signal stream: %w", err)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
resp.Body.Close()
cancel()
return nil, fmt.Errorf("open signal stream: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
stream := &SignalEventStream{
resp: resp,
cancel: cancel,
events: make(chan SignalMessage, 8),
errs: make(chan error, 1),
done: make(chan struct{}),
}
go stream.read()
return stream, nil
}
// sseMaxLineBytes caps the size of a single SSE line. Real signalling lines
// are JSON payloads of a few hundred bytes; 256 KiB is generous enough to
// survive a future schema bump but small enough that a hostile or buggy
// server cannot grow daemon memory by streaming a single line forever.
const sseMaxLineBytes = 256 * 1024
// sseMaxEventBytes caps the total bytes buffered across the lines of one
// SSE event. Without a cap, a peer could send unbounded `data:` continuation
// lines and OOM the daemon between blank-line dispatches.
const sseMaxEventBytes = 1024 * 1024
func (s *SignalEventStream) read() {
defer close(s.done)
defer close(s.events)
scanner := bufio.NewScanner(s.resp.Body)
scanner.Buffer(make([]byte, 16*1024), sseMaxLineBytes)
var dataBuf bytes.Buffer
var eventName string
for scanner.Scan() {
line := strings.TrimRight(scanner.Text(), "\r")
if line == "" {
// End of an event — dispatch if we have data.
if dataBuf.Len() == 0 {
eventName = ""
continue
}
if eventName == "" || eventName == "signal" {
var msg SignalMessage
if err := json.Unmarshal(dataBuf.Bytes(), &msg); err == nil {
select {
case s.events <- msg:
case <-s.resp.Request.Context().Done():
return
}
}
}
dataBuf.Reset()
eventName = ""
continue
}
if strings.HasPrefix(line, ":") {
// SSE comment (heartbeat); ignore.
continue
}
if strings.HasPrefix(line, "event:") {
eventName = strings.TrimSpace(line[len("event:"):])
continue
}
if strings.HasPrefix(line, "data:") {
payload := strings.TrimSpace(line[len("data:"):])
// Refuse to grow the event buffer past the cap. Reset so a
// well-formed event after the offender can still be parsed,
// and surface an error so SignalLoop reconnects.
if dataBuf.Len()+len(payload)+1 > sseMaxEventBytes {
dataBuf.Reset()
eventName = ""
select {
case s.errs <- fmt.Errorf("sse: event exceeded %d bytes", sseMaxEventBytes):
default:
}
return
}
if dataBuf.Len() > 0 {
dataBuf.WriteByte('\n')
}
dataBuf.WriteString(payload)
continue
}
// id:, retry:, anything else — ignore for now.
}
if err := scanner.Err(); err != nil {
select {
case s.errs <- err:
default:
}
}
}
// SignalLoop runs an SSE consumer that reconnects automatically on disconnect.
// onMessage is called for every browser-produced message. Returns when ctx is
// cancelled. Reconnect backoff is fixed at 1 s — the server already paces
// reconnects with `retry: 1500` headers so churn is bounded.
func (c *Client) SignalLoop(ctx context.Context, sessionID string, onMessage func(SignalMessage)) error {
for ctx.Err() == nil {
stream, err := c.OpenSignalStream(ctx, sessionID)
if err != nil {
select {
case <-time.After(time.Second):
case <-ctx.Done():
return ctx.Err()
}
continue
}
for msg := range stream.Events() {
onMessage(msg)
}
streamErr := stream.Err()
stream.Close()
if ctx.Err() != nil {
return ctx.Err()
}
// Server closes the SSE every ~25 s; reconnect immediately.
// Hard error → small backoff so we don't hammer.
if streamErr != nil {
select {
case <-time.After(time.Second):
case <-ctx.Done():
return ctx.Err()
}
}
}
return ctx.Err()
}

View file

@ -1,196 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// fakeSSEServer streams a fixed set of SSE events then closes the connection.
func fakeSSEServer(t *testing.T, msgs []SignalMessage, holdOpenAfter bool) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-key" {
http.Error(w, "auth", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
flusher, ok := w.(http.Flusher)
if !ok {
t.Fatal("server: ResponseWriter is not a Flusher")
}
fmt.Fprint(w, "retry: 1500\n\n")
flusher.Flush()
for _, m := range msgs {
data, _ := json.Marshal(m)
fmt.Fprintf(w, "id: %d\nevent: signal\ndata: %s\n\n", m.TS, data)
flusher.Flush()
}
// Send a heartbeat comment to verify it's ignored.
fmt.Fprint(w, ": heartbeat\n\n")
flusher.Flush()
if holdOpenAfter {
// Hold the connection until the client disconnects so the test can
// exercise stream.Close().
<-r.Context().Done()
}
}))
}
func TestSignalStreamReadsMessages(t *testing.T) {
want := []SignalMessage{
{From: SignalRoleBrowser, Type: SignalMsgOffer, Payload: "{sdp:1}", TS: 1},
{From: SignalRoleBrowser, Type: SignalMsgCandidate, Payload: "{cand:1}", TS: 2},
}
srv := fakeSSEServer(t, want, false)
defer srv.Close()
c := NewClient(srv.URL, "test-key", "test-ua")
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
stream, err := c.OpenSignalStream(ctx, "session-1")
if err != nil {
t.Fatalf("open: %v", err)
}
defer stream.Close()
var got []SignalMessage
for m := range stream.Events() {
got = append(got, m)
if len(got) == len(want) {
break
}
}
if len(got) != len(want) {
t.Fatalf("got %d messages, want %d", len(got), len(want))
}
for i, m := range got {
if m.From != want[i].From || m.Type != want[i].Type || m.Payload != want[i].Payload {
t.Errorf("[%d] mismatch: %+v want %+v", i, m, want[i])
}
}
}
func TestSignalStreamPropagatesAuthError(t *testing.T) {
srv := fakeSSEServer(t, nil, false)
defer srv.Close()
c := NewClient(srv.URL, "wrong-key", "test-ua")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, err := c.OpenSignalStream(ctx, "session-1")
if err == nil {
t.Fatal("expected auth error, got nil")
}
}
func TestSignalStreamCloseCancelsRead(t *testing.T) {
srv := fakeSSEServer(t, nil, true)
defer srv.Close()
c := NewClient(srv.URL, "test-key", "test-ua")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
stream, err := c.OpenSignalStream(ctx, "session-1")
if err != nil {
t.Fatalf("open: %v", err)
}
// Close on a separate goroutine then make sure the events channel drains.
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(50 * time.Millisecond)
stream.Close()
}()
for range stream.Events() {
// drain
}
wg.Wait()
}
// TestSignalStreamRejectsOversizedEvent verifies that a hostile or buggy
// server sending an unbounded `data:` event surfaces an error and stops
// the reader instead of growing daemon memory forever.
func TestSignalStreamRejectsOversizedEvent(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-key" {
http.Error(w, "auth", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "text/event-stream")
flusher := w.(http.Flusher)
// Send many data: continuation lines until we blow past the
// per-event cap. Each chunk is a short legitimate-looking line.
chunk := "data: " + strings.Repeat("x", 4096) + "\n"
fmt.Fprint(w, "event: signal\n")
for i := 0; i < (sseMaxEventBytes/4096)+8; i++ {
fmt.Fprint(w, chunk)
}
flusher.Flush()
<-r.Context().Done()
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "test-ua")
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
stream, err := c.OpenSignalStream(ctx, "session-overflow")
if err != nil {
t.Fatalf("open: %v", err)
}
defer stream.Close()
for range stream.Events() {
// Should never receive a parsed event — the over-sized buffer must
// be rejected before dispatch.
}
if err := stream.Err(); err == nil {
t.Fatal("expected error from oversized event, got nil")
}
}
func TestPostSignalSendsCorrectBody(t *testing.T) {
var bodySeen map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-key" {
http.Error(w, "auth", http.StatusUnauthorized)
return
}
_ = json.NewDecoder(r.Body).Decode(&bodySeen)
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"ok":true}`)
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "test-ua")
err := c.PostSignal(context.Background(), "sess-x", SignalMessage{
Type: SignalMsgAnswer,
Payload: "{sdp:answer}",
})
if err != nil {
t.Fatalf("post: %v", err)
}
if bodySeen["from"] != string(SignalRoleAgent) {
t.Errorf("expected from=agent, got %v", bodySeen["from"])
}
if bodySeen["type"] != string(SignalMsgAnswer) {
t.Errorf("expected type=answer, got %v", bodySeen["type"])
}
if bodySeen["payload"] != "{sdp:answer}" {
t.Errorf("expected payload mismatch, got %v", bodySeen["payload"])
}
}

View file

@ -2,6 +2,8 @@ package agent
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
@ -9,6 +11,13 @@ import (
"github.com/torrentclaw/unarr/internal/config"
)
// ErrDaemonNotRunning is returned when no daemon state file exists on disk.
// Callers may wrap it with %w; downstream code uses errors.Is to detect it.
// NOTE: the message text is matched by the sentry package (string-match, to
// avoid an import cycle). Keep the prefix "daemon does not appear to be
// running" stable, or update sentry.daemonNotRunningMarker accordingly.
var ErrDaemonNotRunning = errors.New("daemon does not appear to be running (state file not found)")
// DaemonState is written to disk every heartbeat for external tools to read.
type DaemonState struct {
AgentID string `json:"agentId"`
@ -22,6 +31,18 @@ type DaemonState struct {
FailedCount int `json:"failedCount"`
TotalDownloaded int64 `json:"totalDownloaded"`
MethodStats map[string]int `json:"methodStats,omitempty"`
// Managed-VPN split-tunnel state, so `unarr vpn status` can report whether
// torrent traffic is actually being routed through the tunnel (vs. the daemon
// running but the tunnel having failed to come up → downloading in the clear).
VPNActive bool `json:"vpnActive,omitempty"`
VPNMode string `json:"vpnMode,omitempty"` // managed | self-hosted
VPNServer string `json:"vpnServer,omitempty"` // WireGuard endpoint (ip:port)
// CloudFlare Quick Tunnel state, so `unarr funnel status` can report the
// HTTPS hostname the daemon is reachable at from anywhere on the internet.
// Empty when the funnel is off or hasn't registered yet.
FunnelURL string `json:"funnelUrl,omitempty"`
}
// stateFilePathFn is overridable for testing.
@ -57,17 +78,31 @@ func WriteState(state *DaemonState) {
os.Rename(tmp, path)
}
// ReadState reads the daemon state from disk. Returns nil if not found.
// ReadState reads the daemon state from disk. Returns nil if not found or
// unreadable. Use LoadState when callers need to distinguish "not running"
// from "state file corrupted".
func ReadState() *DaemonState {
state, _ := LoadState()
return state
}
// LoadState reads the daemon state and returns explicit errors:
// - ErrDaemonNotRunning when the state file does not exist
// - a wrapped json error when the file exists but cannot be decoded
// (a real bug worth reporting to Sentry)
func LoadState() (*DaemonState, error) {
data, err := os.ReadFile(StateFilePath())
if err != nil {
return nil
if errors.Is(err, os.ErrNotExist) {
return nil, ErrDaemonNotRunning
}
return nil, err
}
var state DaemonState
if json.Unmarshal(data, &state) != nil {
return nil
if err := json.Unmarshal(data, &state); err != nil {
return nil, fmt.Errorf("decode daemon state %s: %w", StateFilePath(), err)
}
return &state
return &state, nil
}
// RemoveState deletes the state file (called on clean shutdown).

View file

@ -1,6 +1,7 @@
package agent
import (
"errors"
"os"
"path/filepath"
"testing"
@ -104,3 +105,39 @@ func TestReadStateCorruptedJSON(t *testing.T) {
t.Errorf("ReadState() should return nil for corrupted JSON, got %+v", state)
}
}
func TestLoadStateNotFound(t *testing.T) {
tmpDir := t.TempDir()
origFn := stateFilePathFn
stateFilePathFn = func() string { return filepath.Join(tmpDir, "nonexistent.json") }
defer func() { stateFilePathFn = origFn }()
state, err := LoadState()
if state != nil {
t.Errorf("LoadState() state = %+v, want nil", state)
}
if !errors.Is(err, ErrDaemonNotRunning) {
t.Errorf("LoadState() err = %v, want ErrDaemonNotRunning", err)
}
}
func TestLoadStateCorruptedJSON(t *testing.T) {
tmpDir := t.TempDir()
origFn := stateFilePathFn
path := filepath.Join(tmpDir, "daemon.state.json")
stateFilePathFn = func() string { return path }
defer func() { stateFilePathFn = origFn }()
os.WriteFile(path, []byte("not valid json{{{"), 0o644)
state, err := LoadState()
if state != nil {
t.Errorf("LoadState() state = %+v, want nil", state)
}
if err == nil {
t.Fatal("LoadState() err = nil, want decode error")
}
if errors.Is(err, ErrDaemonNotRunning) {
t.Error("corrupt state must not be reported as ErrDaemonNotRunning — it would be filtered from Sentry")
}
}

View file

@ -2,8 +2,10 @@ package agent
import (
"context"
"encoding/json"
"log"
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
@ -15,6 +17,23 @@ const (
// SyncIntervalIdle is the sync interval when nobody is watching.
// Keep this short enough to pick up stream requests quickly without hammering the server.
SyncIntervalIdle = 10 * time.Second
// --- Downlink (server→agent realtime) tuning ---
// downlinkLivenessTimeout is the maximum time to wait for ANY SSE frame
// (heartbeat comment or event) before declaring the stream dead. The server
// heartbeats every ~15s; ~2.5× gives slack for jitter while still catching a
// path that connects 200 OK but silently buffers (delivers nothing until
// close) — the failure mode that justifies the long-poll fallback.
downlinkLivenessTimeout = 40 * time.Second
// sseReconnectDelay is the pause between SSE connection attempts.
sseReconnectDelay = 2 * time.Second
// maxSSEFailures is the number of consecutive failed/dead SSE attempts
// before "auto" mode falls back to the long-poll wake downlink.
maxSSEFailures = 3
// downlinkFallbackWindow is how long to ride long-poll before re-probing SSE,
// so a transient proxy hiccup doesn't pin the agent on polling forever.
downlinkFallbackWindow = 5 * time.Minute
)
// SyncClient handles bidirectional state synchronization between the CLI and server.
@ -29,29 +48,62 @@ type SyncClient struct {
OnNewTasks func(tasks []Task)
OnControl func(action, taskID string, deleteFiles bool)
OnStreamRequest func(req StreamRequest)
OnWebRTCSession func(sess WebRTCSession)
OnStreamSession func(sess StreamSession)
OnUpgrade func(version string)
OnScan func()
OnWatchingChange func(watching bool)
OnSyncSuccess func() // called after each successful sync (e.g. to update state file)
GetFreeSlots func() int
GetTaskStates func() []TaskState // returns current state of all active + recently finished tasks
// GetVPNState returns the live managed-VPN split-tunnel state (whether the
// WireGuard tunnel is up, the mode, and the exit server) so the web can track
// which agent holds the single WG slot.
GetVPNState func() (active bool, mode, server string)
// GetAgentStatus returns the daemon lifecycle state ("running" | "updating"
// | "shutting_down") so the web can show "agent updating" during an upgrade
// restart instead of a hard error. Empty → treated as "running".
GetAgentStatus func() string
// GetFunnelURL returns the CloudFlare Quick Tunnel public hostname if one
// is active, else "". Sent on every sync so the web picks it up live.
GetFunnelURL func() string
// OnDeleteFiles is called when the server requests file deletion from disk.
// It should delete the files and return the IDs of successfully deleted items.
OnDeleteFiles func(items []LibraryDeleteRequest) []int
// OnSubtitleFetch is called when the server requests on-demand subtitle
// downloads. It should download each (from req.URL, already VTT), write a
// sidecar next to req.FilePath, and return the IDs successfully fetched plus
// the ones that failed (so the web can mark them errored).
OnSubtitleFetch func(reqs []SubtitleFetchRequest) ([]int, []SubtitleFetchError)
// OnRevoked is called when a sync is rejected because this agent's credential
// was revoked (the user deleted the agent from the dashboard). The daemon
// wires this to wipe the stored key + stop — it must NOT keep retrying or the
// server will reject every sync forever.
OnRevoked func(err error)
// SyncNow triggers an immediate sync (e.g., on task completion).
SyncNow chan struct{}
watching atomic.Bool
interval atomic.Int64 // stored as nanoseconds
// livenessTimeout is the max wait for any SSE frame before the downlink
// treats the stream as dead/buffered. Defaults to downlinkLivenessTimeout;
// overridable in tests.
livenessTimeout time.Duration
// pendingDeleteConfirmed holds item IDs to report as deleted in the next sync.
pendingDeleteMu sync.Mutex
pendingDeleteConfirmed []int
// deleteInFlight tracks item IDs currently being processed or awaiting confirmation.
// Prevents the same file from being passed to OnDeleteFiles multiple times.
deleteInFlight map[int]struct{}
// Subtitle-fetch jobs awaiting confirmation + dedup (guarded by pendingDeleteMu).
pendingSubtitlesFetched []int
pendingSubtitlesFailed []SubtitleFetchError
subtitleInFlight map[int]struct{}
}
// NewSyncClient creates a sync client.
@ -61,6 +113,7 @@ func NewSyncClient(client *Client, cfg DaemonConfig, state *LocalState) *SyncCli
cfg: cfg,
state: state,
SyncNow: make(chan struct{}, 1),
livenessTimeout: downlinkLivenessTimeout,
}
sc.interval.Store(int64(SyncIntervalIdle))
return sc
@ -81,8 +134,9 @@ func (sc *SyncClient) TriggerSync() {
// Run starts the adaptive sync loop. Blocks until ctx is cancelled.
func (sc *SyncClient) Run(ctx context.Context) error {
// Start wake listener in background — triggers immediate syncs on demand.
go sc.runWakeListener(ctx)
// Start the realtime downlink in background — pushes immediate syncs +
// typed control commands on demand (SSE-first, long-poll fallback).
go sc.runDownlink(ctx)
// Initial sync immediately
sc.doSync(ctx)
@ -119,6 +173,12 @@ func (sc *SyncClient) doSync(ctx context.Context) {
resp, err := sc.client.Sync(ctx, req)
if err != nil {
if ctx.Err() == nil {
// Credential revoked (agent deleted from the dashboard) → stop; don't
// spam a sync the server will reject forever.
if IsRevoked(err) && sc.OnRevoked != nil {
sc.OnRevoked(err)
return
}
log.Printf("sync failed: %v", err)
}
return
@ -139,9 +199,12 @@ func (sc *SyncClient) buildRequest() SyncRequest {
Arch: runtime.GOARCH,
DownloadDir: sc.cfg.DownloadDir,
StreamPort: sc.cfg.StreamPort,
HTTPSStreamPort: sc.cfg.HTTPSStreamPort,
AgentHash: sc.cfg.AgentHash,
LanIP: sc.cfg.LanIP,
TailscaleIP: sc.cfg.TailscaleIP,
CanDelete: sc.cfg.CanDelete,
IsDocker: RunningInDocker(),
}
if sc.GetTaskStates != nil {
req.Tasks = sc.GetTaskStates()
@ -155,6 +218,15 @@ func (sc *SyncClient) buildRequest() SyncRequest {
if sc.GetFreeSlots != nil {
req.FreeSlots = sc.GetFreeSlots()
}
if sc.GetVPNState != nil {
req.VPNActive, req.VPNMode, req.VPNServer = sc.GetVPNState()
}
if sc.GetAgentStatus != nil {
req.AgentStatus = sc.GetAgentStatus()
}
if sc.GetFunnelURL != nil {
req.FunnelURL = sc.GetFunnelURL()
}
// Flush confirmed deletions from previous cycle.
// Once flushed, remove IDs from deleteInFlight — the server will stop sending
// them after this sync, so deduplication protection is no longer needed.
@ -166,6 +238,20 @@ func (sc *SyncClient) buildRequest() SyncRequest {
}
sc.pendingDeleteConfirmed = nil
}
if len(sc.pendingSubtitlesFetched) > 0 {
req.SubtitlesFetched = sc.pendingSubtitlesFetched
for _, id := range sc.pendingSubtitlesFetched {
delete(sc.subtitleInFlight, id)
}
sc.pendingSubtitlesFetched = nil
}
if len(sc.pendingSubtitlesFailed) > 0 {
req.SubtitlesFailed = sc.pendingSubtitlesFailed
for _, f := range sc.pendingSubtitlesFailed {
delete(sc.subtitleInFlight, f.ID)
}
sc.pendingSubtitlesFailed = nil
}
sc.pendingDeleteMu.Unlock()
return req
}
@ -192,10 +278,10 @@ func (sc *SyncClient) processResponse(resp *SyncResponse) {
}
}
// WebRTC streaming sessions
for _, ws := range resp.WebRTCSessions {
if sc.OnWebRTCSession != nil {
sc.OnWebRTCSession(ws)
// HLS streaming sessions.
for _, ws := range resp.StreamSessions {
if sc.OnStreamSession != nil {
sc.OnStreamSession(ws)
}
}
@ -237,6 +323,37 @@ func (sc *SyncClient) processResponse(resp *SyncResponse) {
}(newItems)
}
}
// On-demand subtitle fetches — dedup against in-flight, run off the sync
// goroutine (network + disk I/O), confirm on the next cycle.
if len(resp.SubtitleFetches) > 0 && sc.OnSubtitleFetch != nil {
sc.pendingDeleteMu.Lock()
if sc.subtitleInFlight == nil {
sc.subtitleInFlight = make(map[int]struct{})
}
var newReqs []SubtitleFetchRequest
for _, r := range resp.SubtitleFetches {
if _, inFlight := sc.subtitleInFlight[r.ID]; !inFlight {
newReqs = append(newReqs, r)
sc.subtitleInFlight[r.ID] = struct{}{}
}
}
sc.pendingDeleteMu.Unlock()
if len(newReqs) > 0 {
go func(reqs []SubtitleFetchRequest) {
done, failed := sc.OnSubtitleFetch(reqs)
// Both done and failed are reported on the next uplink; buildRequest
// clears them from subtitleInFlight when it flushes them. A failure
// becomes status='error' on the web (no silent infinite retry — the
// user re-requests, which creates a fresh row).
sc.pendingDeleteMu.Lock()
sc.pendingSubtitlesFetched = append(sc.pendingSubtitlesFetched, done...)
sc.pendingSubtitlesFailed = append(sc.pendingSubtitlesFailed, failed...)
sc.pendingDeleteMu.Unlock()
}(newReqs)
}
}
}
// runWakeListener holds a long-poll connection to /api/internal/agent/wake.
@ -271,6 +388,176 @@ func (sc *SyncClient) runWakeListener(ctx context.Context) {
}
}
// runWakeListenerFor runs the long-poll wake listener for up to `dur`, then
// returns so the caller can re-probe SSE. Used as the auto-mode fallback.
func (sc *SyncClient) runWakeListenerFor(ctx context.Context, dur time.Duration) {
childCtx, cancel := context.WithTimeout(ctx, dur)
defer cancel()
sc.runWakeListener(childCtx)
}
// downlinkMode resolves the configured downlink transport:
// - "auto" (default): SSE-first, fall back to long-poll wake if SSE is
// unavailable or silently buffered, then periodically re-probe SSE.
// - "sse": SSE only, no long-poll fallback (testing / known-good networks).
// - "poll": long-poll wake only (the pre-0.14 behavior).
func (sc *SyncClient) downlinkMode() string {
switch strings.ToLower(strings.TrimSpace(sc.cfg.Downlink)) {
case "poll":
return "poll"
case "sse":
return "sse"
default:
return "auto"
}
}
// runDownlink is the server→agent realtime loop. It supersedes the bare
// long-poll wake listener: an SSE connection pushes typed control commands and
// sync nudges over a single persistent connection, with the long-poll wake as a
// buffering-tolerant fallback (long-poll survives proxies that buffer the
// response body and break SSE). Runs until ctx is cancelled.
func (sc *SyncClient) runDownlink(ctx context.Context) {
switch sc.downlinkMode() {
case "poll":
log.Printf("downlink: long-poll wake (downlink=poll)")
sc.runWakeListener(ctx)
case "sse":
log.Printf("downlink: SSE only (downlink=sse) — no long-poll fallback")
sc.runSSELoop(ctx, false)
default:
sc.runSSELoop(ctx, true)
}
}
// runSSELoop maintains the SSE downlink, reconnecting across server recycles
// and transient drops. When allowFallback is true (auto mode), it switches to
// the long-poll wake after maxSSEFailures consecutive dead attempts, then
// re-probes SSE after downlinkFallbackWindow.
func (sc *SyncClient) runSSELoop(ctx context.Context, allowFallback bool) {
failures := 0
for ctx.Err() == nil {
healthy := sc.runEventStreamOnce(ctx)
if ctx.Err() != nil {
return
}
if healthy {
failures = 0
// A healthy stream that ended is a normal server recycle — reconnect.
sc.sleep(ctx, sseReconnectDelay)
continue
}
failures++
if allowFallback && failures >= maxSSEFailures {
log.Printf("downlink: SSE unavailable after %d attempts — falling back to long-poll for %s", failures, downlinkFallbackWindow)
sc.runWakeListenerFor(ctx, downlinkFallbackWindow)
failures = 0
continue
}
sc.sleep(ctx, sseReconnectDelay)
}
}
// runEventStreamOnce opens one SSE connection and consumes it until it dies or
// ctx is cancelled. Returns true if the stream was "healthy" — i.e. it
// delivered at least one frame (event or heartbeat) — and false if it failed to
// connect or delivered nothing within downlinkLivenessTimeout (dead or silently
// buffered). The caller uses that signal to decide whether to fall back.
func (sc *SyncClient) runEventStreamOnce(ctx context.Context) bool {
streamCtx, cancel := context.WithCancel(ctx)
defer cancel()
stream, err := sc.client.OpenEventStream(streamCtx)
if err != nil {
if ctx.Err() == nil {
log.Printf("downlink: SSE connect failed: %v", err)
}
return false
}
defer stream.Close()
healthy := false
liveness := time.NewTimer(sc.livenessTimeout)
defer liveness.Stop()
for {
select {
case <-ctx.Done():
return healthy
case <-liveness.C:
// No frame within the deadline. The server heartbeats every ~15s, so
// silence past livenessTimeout (40s) means the path is dead OR
// silently buffering — INCLUDING a proxy that flushed the connect
// preamble (one ping) then stalled. Return false REGARDLESS of any
// earlier frame, so this counts toward the long-poll fallback; a
// stream that flushes one ping and goes quiet must not be treated as
// healthy or the fallback never triggers for partial bufferers.
if ctx.Err() == nil {
log.Printf("downlink: no SSE frame within %s — dropping (dead or buffered path)", sc.livenessTimeout)
}
return false
case ev, ok := <-stream.Events():
if !ok {
if e := stream.Err(); e != nil && ctx.Err() == nil {
log.Printf("downlink: SSE stream ended: %v", e)
}
return healthy
}
if !healthy {
// First frame on this connection — the path flushes, so log once
// (on a silently-buffered path no frame ever arrives and we never
// claim connected).
log.Printf("downlink: SSE connected")
}
healthy = true
if !liveness.Stop() {
select {
case <-liveness.C:
default:
}
}
liveness.Reset(sc.livenessTimeout)
sc.handleDownlinkEvent(ev)
}
}
}
// handleDownlinkEvent applies one pushed downlink event. Pings are liveness-only;
// "sync" nudges an immediate full sync; "command" carries typed control actions
// applied via the same OnControl callback /agent/sync uses (idempotent, so the
// authoritative sync re-delivering them is harmless).
func (sc *SyncClient) handleDownlinkEvent(ev DownlinkEvent) {
switch ev.Event {
case DownlinkEventPing:
// Liveness only.
case DownlinkEventSync:
sc.TriggerSync()
case DownlinkEventCommand:
var cmd CommandEvent
if err := json.Unmarshal(ev.Data, &cmd); err != nil {
log.Printf("downlink: bad command payload: %v", err)
return
}
for _, ctrl := range cmd.Controls {
log.Printf("downlink: control %s on task %s", ctrl.Action, ShortID(ctrl.TaskID))
if sc.OnControl != nil {
sc.OnControl(ctrl.Action, ctrl.TaskID, ctrl.DeleteFiles)
}
}
default:
// Unknown event from a newer server — ignore forward-compatibly.
}
}
// sleep blocks for d or until ctx is cancelled.
func (sc *SyncClient) sleep(ctx context.Context, d time.Duration) {
select {
case <-time.After(d):
case <-ctx.Done():
}
}
func (sc *SyncClient) adjustInterval(watching bool) {
prev := sc.watching.Load()
sc.watching.Store(watching)

View file

@ -1,7 +1,10 @@
package agent
import (
"errors"
"fmt"
"net/http"
"strings"
"time"
)
@ -16,8 +19,18 @@ type RegisterRequest struct {
DiskFreeBytes int64 `json:"diskFreeBytes,omitempty"`
DiskTotalBytes int64 `json:"diskTotalBytes,omitempty"`
StreamPort int `json:"streamPort,omitempty"`
// HTTPSStreamPort + AgentHash drive the per-agent direct-TLS feature: the web
// builds https://<ip-dashed>.<hash>.agent.unarr.app:<httpsPort>/... once the
// agent has an issued cert. Zero/empty when the feature is off or pre-cert.
HTTPSStreamPort int `json:"httpsStreamPort,omitempty"`
AgentHash string `json:"agentHash,omitempty"`
LanIP string `json:"lanIp,omitempty"`
TailscaleIP string `json:"tailscaleIp,omitempty"`
// StreamSecret is the daemon's per-run HMAC key (hex) for stream tokens. The
// web mints the HLS path token with it (the agent mints /stream tokens on its
// own URLs); the agent verifies both. In memory, regenerated each start, so a
// fresh register after restart re-syncs it.
StreamSecret string `json:"streamSecret,omitempty"`
// Transcode capabilities — let the web side suggest a smarter quality
// before the player even starts. HWAccel is the picked backend
// ("nvenc"/"qsv"/"vaapi"/"videotoolbox"/"none"). MaxTranscodeHeight is
@ -26,11 +39,42 @@ type RegisterRequest struct {
// up to 2160p.
HWAccel string `json:"hwAccel,omitempty"`
MaxTranscodeHeight int `json:"maxTranscodeHeight,omitempty"`
// Diagnostic surface filled by engine.DetectHWAccelDiagnostic at daemon
// start. Surfaced in the web "Diagnose transcoder" modal so users can
// see *why* their HWAccel landed on "none" without running
// `unarr probe-hwaccel` locally — most commonly the ffmpeg binary
// shipped without HW encoders (linuxbrew, brew's default formula).
FFmpegVersion string `json:"ffmpegVersion,omitempty"`
FFmpegPath string `json:"ffmpegPath,omitempty"`
HWEncoders []string `json:"hwEncoders,omitempty"`
HWDevices []string `json:"hwDevices,omitempty"`
// Managed-VPN split-tunnel state. The web tracks which agent holds the single
// WireGuard slot (1 VPNResellers account = 1 WG keypair = 1 concurrent
// connection); other agents are told to use OpenVPN on their host instead.
// VPNActive has no omitempty: false is a meaningful state (tunnel down), not
// "unset" — the server must see it to release the slot.
VPNActive bool `json:"vpnActive"`
VPNMode string `json:"vpnMode,omitempty"` // managed | self-hosted
VPNServer string `json:"vpnServer,omitempty"`
// CloudFlare Quick Tunnel hostname when enabled; the web prefers it over
// Tailscale/LAN for in-browser playback because it works on any network.
FunnelURL string `json:"funnelUrl,omitempty"`
// IsDocker tells the web the agent runs inside a container, so it shows a
// `docker pull` command instead of the in-app update button (the binary
// self-update refuses to run in Docker). No omitempty: false (a binary
// install) is a meaningful state the server must see to keep the button.
IsDocker bool `json:"isDocker"`
}
// RegisterResponse is returned by the server after registration.
type RegisterResponse struct {
Success bool `json:"success"`
// AgentKey is a freshly-minted per-machine API key, present only when the
// CLI registered with the user's general key (manual-paste bootstrap). The
// CLI must persist it and authenticate with it from then on, discarding the
// general key. Empty in the browser-authorize path (the token already IS the
// agent key) and on every later register.
AgentKey string `json:"agentKey,omitempty"`
User UserInfo `json:"user"`
Features FeatureFlags `json:"features"`
}
@ -109,6 +153,11 @@ type StatusUpdate struct {
StreamURL string `json:"streamUrl,omitempty"`
StreamReady bool `json:"streamReady,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
// StreamError reports a failed /stream attempt (path rejected, transient
// FS error, etc.) WITHOUT marking the download itself failed — the web
// clears streamRequested + surfaces this so the player fails fast with the
// real reason instead of a 20s "agent didn't respond" timeout.
StreamError string `json:"streamError,omitempty"`
// mode=seed_file: agent computes the info_hash from the local file
// and reports it back so the web player can target /stream/<hash>.
InfoHash string `json:"infoHash,omitempty"`
@ -158,6 +207,32 @@ func (e *HTTPError) Error() string {
return fmt.Sprintf("API error %d: %s", e.StatusCode, e.Message)
}
// IsRevoked reports whether an error is an EXPLICIT server revocation signal —
// the user deleted this agent from the dashboard. The server sends 410
// agent_revoked (the registration is tombstoned OR the per-machine key was
// revoked — the auth layer maps a revoked agent key to 410, not 401) or 403
// agent_key_mismatch (the key belongs to another machine). On these the daemon
// wipes its credential and requires a fresh `unarr login`.
//
// A BARE 401 is deliberately NOT treated as revoked: it's ambiguous (a deploy
// blip, a load-balancer hiccup, a transient auth error) and must never wipe a
// working agent's credential. The retry/log paths handle a transient 401; a
// genuine revocation always arrives as 410.
func IsRevoked(err error) bool {
var he *HTTPError
if !errors.As(err, &he) {
return false
}
if he.StatusCode == http.StatusGone {
return true
}
if he.StatusCode == http.StatusForbidden &&
strings.Contains(he.Message, "agent_key_mismatch") {
return true
}
return false
}
// AgentInfo holds metadata about the running agent for display.
type AgentInfo struct {
ID string
@ -288,8 +363,20 @@ type DebridAccount struct {
type LibrarySyncRequest struct {
Items []LibrarySyncItem `json:"items"`
ScanPath string `json:"scanPath"`
AgentID string `json:"agentId,omitempty"` // lets the server scope stale-cleanup per agent
IsLastBatch bool `json:"isLastBatch"`
SyncStartedAt string `json:"syncStartedAt,omitempty"` // ISO-8601; same for all batches in a session
// ScanRoots lists EVERY root this sync session covered (a session spans all
// roots since 1.0.9 — one syncStartedAt, one isLastBatch). The server scopes
// stale-row cleanup of a partial session to these prefixes. Older servers
// ignore the field and fall back to ScanPath.
ScanRoots []string `json:"scanRoots,omitempty"`
// FullCycle marks a session that covered every root the agent scans
// (daemon auto-scan, `unarr scan` without args). The server may then reap
// unseen rows REGARDLESS of path prefix — old-base-path ghost rows
// included. Must stay false for a manual subtree scan or when any root's
// scan failed, or the cleanup would reap rows the session never visited.
FullCycle bool `json:"fullCycle,omitempty"`
}
// LibrarySyncItem is a single scanned media file with ffprobe metadata.
@ -313,6 +400,17 @@ type LibrarySyncItem struct {
AudioTracks any `json:"audioTracks,omitempty"`
SubtitleTracks any `json:"subtitleTracks,omitempty"`
VideoInfo any `json:"videoInfo,omitempty"`
// Integrity flags a damaged / incompletely-downloaded file ("damaged" or
// empty). IntegrityReason is a stable code (ebml_corrupt, moov_missing,
// no_duration, …) the web maps to a localized "re-download" message.
Integrity string `json:"integrity,omitempty"`
IntegrityReason string `json:"integrityReason,omitempty"`
// Path resilience: a stable content identity + the file's location relative
// to its library root, so the server can move a row in place on a rename /
// base-path change instead of duplicating it.
Fingerprint string `json:"fingerprint,omitempty"`
RelPath string `json:"relPath,omitempty"`
LibraryRootKey string `json:"libraryRootKey,omitempty"`
}
// LibrarySyncResponse is returned after syncing library items.
@ -338,12 +436,35 @@ type SyncRequest struct {
DiskFreeBytes int64 `json:"diskFreeBytes,omitempty"`
DiskTotalBytes int64 `json:"diskTotalBytes,omitempty"`
StreamPort int `json:"streamPort,omitempty"`
HTTPSStreamPort int `json:"httpsStreamPort,omitempty"`
AgentHash string `json:"agentHash,omitempty"`
LanIP string `json:"lanIp,omitempty"`
TailscaleIP string `json:"tailscaleIp,omitempty"`
FreeSlots int `json:"freeSlots"`
Tasks []TaskState `json:"tasks"`
CanDelete bool `json:"canDelete"` // library.allow_delete is enabled
DeleteConfirmed []int `json:"deleteConfirmed,omitempty"` // library item IDs successfully deleted from disk
// Subtitle-fetch job IDs the agent completed (sidecar written to disk).
SubtitlesFetched []int `json:"subtitlesFetched,omitempty"`
// Subtitle-fetch jobs that permanently failed (download/write error) — the web
// marks them errored so the UI fails fast instead of waiting for a timeout.
SubtitlesFailed []SubtitleFetchError `json:"subtitlesFailed,omitempty"`
// Live managed-VPN split-tunnel state, sent every sync so the web sees the
// WireGuard slot owner update in near-realtime (vs. register, once at startup).
// VPNActive has no omitempty: false (tunnel down) must reach the server so it
// releases the slot, not be elided as "unset".
VPNActive bool `json:"vpnActive"`
VPNMode string `json:"vpnMode,omitempty"`
VPNServer string `json:"vpnServer,omitempty"`
// CloudFlare Quick Tunnel hostname when enabled, else empty.
FunnelURL string `json:"funnelUrl,omitempty"`
// IsDocker — see RegisterRequest.IsDocker. Sent every sync so the web keeps
// the flag fresh even if the agent migrated binary↔docker between restarts.
IsDocker bool `json:"isDocker"`
// AgentStatus — daemon lifecycle state ("running" | "updating" |
// "shutting_down"). Lets the web show "agent updating" during an upgrade
// restart instead of a hard session error. Empty (older agents) → "running".
AgentStatus string `json:"agentStatus,omitempty"`
}
// ControlAction represents a server-side control signal for a task.
@ -359,19 +480,12 @@ type LibraryDeleteRequest struct {
FilePath string `json:"filePath"`
}
// WebRTCSession is a request to open a streaming session for a browser
// player. Transport selects the on-the-wire protocol: empty/"webrtc" runs the
// legacy custom WebRTC DataChannel pipeline; "hls" spawns an HLS session
// (ffmpeg producing fragmented MP4 served over HTTP). The CLI must POST an
// SDP answer to /api/internal/stream/signal/<sessionId> for WebRTC sessions
// and register the HLS session in the StreamServer's HLS registry for HLS
// sessions; either way the source bytes come from FilePath (or, when only
// InfoHash is set, from a download_task on disk).
type WebRTCSession struct {
// StreamSession is a request to open an HLS streaming session for an
// in-browser player. The CLI registers the HLS session in the StreamServer's
// HLS registry; source bytes come from FilePath (or, when only InfoHash is
// set, from a download_task on disk).
type StreamSession struct {
SessionID string `json:"sessionId"`
// Transport selects the streaming protocol. "" or "webrtc" → legacy
// WebRTC + MSE pipeline (Phase 1). "hls" → HLS over HTTP (Phase 2).
Transport string `json:"transport,omitempty"`
FilePath string `json:"filePath,omitempty"`
InfoHash string `json:"infoHash,omitempty"`
TaskID string `json:"taskId,omitempty"`
@ -381,8 +495,52 @@ type WebRTCSession struct {
// "2160p" | "1080p" | "720p" | "480p" | "original" | "" (defer to config).
Quality string `json:"quality,omitempty"`
// AudioIndex selects the source audio track (-map 0:a:N). -1 means
// "use the default/first track" (HLS) or ignored (WebRTC).
// "use the default/first track".
AudioIndex int `json:"audioIndex,omitempty"`
// BurnSubtitleIndex, when set, is the 0-based subtitle stream index
// (-map 0:s:N) of a BITMAP subtitle (PGS/DVB) to burn into the video. Text
// subtitles are served as separate WebVTT tracks and never burned. A pointer
// (not int) so absent/null = "no burn": the zero value 0 is a valid track
// index, so an int sentinel would silently burn track 0 when the field is
// omitted. Forces a full video re-encode (the overlay can't ride a copy
// path), so the web only sends it when the user picks a bitmap sub.
BurnSubtitleIndex *int `json:"burnSubtitleIndex,omitempty"`
// StartSec is the playback position (seconds) the viewer opens at — the
// saved resume point, or the current position on a quality/audio switch.
// HLS sessions spawn the FIRST ffmpeg already seeked there instead of
// encoding from segment 0 and immediately seek-restarting (double spawn,
// slow resume). 0/omitted = start at the beginning. Older daemons simply
// don't decode the field and keep the old start-at-0 behaviour.
StartSec float64 `json:"startSec,omitempty"`
// Prewarm marks a background cache-fill session (next-episode prewarm,
// hover prewarm): the daemon must encode it WITHOUT displacing the
// viewer's live session — it waits until the active encode finishes and
// registers alongside instead of evicting (Register kills every other
// session; a prewarm claimed mid-playback used to kill the stream the
// user was watching). False/omitted = a real viewer session.
Prewarm bool `json:"prewarm,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"`
// VideoCopy (playMethod "hls" only): serve via HLS-copy — ffmpeg -c:v copy
// into fMP4 segments, audio to AAC when needed. The robust replacement for
// the progressive-remux path: same near-zero CPU (video never re-encoded,
// works on a GPU-less NAS), but in the segmented transport every player
// handles. Set by webs that know this agent supports it (gate: HLS_COPY_MIN_VERSION web-side).
VideoCopy bool `json:"videoCopy,omitempty"`
// DirectURL, when set, is an HTTPS link to the media resolved server-side
// from the user's debrid account (hueco #2 / 2a). The source has no local
// file: the daemon streams /stream from this URL via ranged GETs
// (debridFileProvider) instead of from disk/torrent. Carries the "play
// instantáneo cache-fast" promise — the web only sets it when the hash is
// confirmed debrid-cached and the container is browser-native (mp4/m4v),
// and gates it on an agent-version floor so older daemons never receive a
// field they can't serve. Takes priority over FilePath when present.
DirectURL string `json:"directUrl,omitempty"`
}
// SyncResponse is returned by the server with all pending actions for the CLI.
@ -390,11 +548,28 @@ type SyncResponse struct {
NewTasks []Task `json:"newTasks,omitempty"`
Controls []ControlAction `json:"controls,omitempty"`
StreamRequests []StreamRequest `json:"streamRequests,omitempty"`
WebRTCSessions []WebRTCSession `json:"webrtcSessions,omitempty"`
StreamSessions []StreamSession `json:"streamSessions,omitempty"`
Watching bool `json:"watching"`
Upgrade *UpgradeSignal `json:"upgrade,omitempty"`
Scan bool `json:"scan,omitempty"`
FilesToDelete []LibraryDeleteRequest `json:"filesToDelete,omitempty"`
SubtitleFetches []SubtitleFetchRequest `json:"subtitleFetches,omitempty"`
}
// SubtitleFetchRequest is a server-side request to download a subtitle (from our
// proxy URL, already charset-fixed + VTT) and save it as a sidecar next to a
// media file. URL is the absolute /api/internal/subtitles/proxy URL.
type SubtitleFetchRequest struct {
ID int `json:"id"`
FilePath string `json:"filePath"`
Lang string `json:"lang"`
URL string `json:"url"`
}
// SubtitleFetchError reports a permanently-failed subtitle fetch back to the web.
type SubtitleFetchError struct {
ID int `json:"id"`
Error string `json:"error"`
}
// ---------------------------------------------------------------------------
@ -417,3 +592,38 @@ type WatchProgressUpdate struct {
type WatchProgressResponse struct {
Success bool `json:"success"`
}
// ---------------------------------------------------------------------------
// Skip-segment types (intro/credits detection — see library/skipdetect.go)
// ---------------------------------------------------------------------------
// SkipSegmentRange is one detected skippable range inside a media file.
type SkipSegmentRange struct {
Category string `json:"category"` // "intro" | "credits"
StartSec float64 `json:"startSec"`
EndSec float64 `json:"endSec"`
}
// SkipSegmentItem carries the detected segments of one library file. The
// server resolves FilePath against the user's library_item rows (synced just
// before) to attach the segments to a content identity.
type SkipSegmentItem struct {
FilePath string `json:"filePath"`
Title string `json:"title,omitempty"`
Season int `json:"season,omitempty"`
Episode int `json:"episode,omitempty"`
DurationSec float64 `json:"durationSec"`
Segments []SkipSegmentRange `json:"segments"`
}
// SkipSegmentsRequest submits detected skip segments after a library scan.
type SkipSegmentsRequest struct {
AgentID string `json:"agentId,omitempty"`
Items []SkipSegmentItem `json:"items"`
}
// SkipSegmentsResponse reports how many segments the server stored.
type SkipSegmentsResponse struct {
Stored int `json:"stored"`
Unmatched int `json:"unmatched"`
}

View file

@ -24,7 +24,7 @@ const browserAuthTimeout = 60 * time.Second
// 3. User logs in and clicks "Authorize" on the web page
// 4. Web redirects to localhost:{port}/callback?token=tc_...&state={state}
// 5. CLI validates state, extracts token, closes server
func browserAuth(apiURL string) (string, error) {
func browserAuth(apiURL, agentID string) (string, error) {
// Validate apiURL is a well-formed HTTP(S) URL
parsed, err := url.Parse(apiURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
@ -96,8 +96,12 @@ func browserAuth(apiURL string) (string, error) {
}
}()
// Open browser
// Open browser. Forward the agentId so the server mints a per-machine key
// bound to it (omitted → server falls back to the legacy general key).
authURL := fmt.Sprintf("%s/unarr/auth?state=%s&port=%d", apiURL, url.QueryEscape(state), port)
if agentID != "" {
authURL += "&agentId=" + url.QueryEscape(agentID)
}
openBrowser(authURL)
// Listen for Enter key to skip to manual fallback

View file

@ -322,6 +322,14 @@ func configLibrary(cfg *config.Config) error {
Title("Allow file deletion from web UI?").
Description("When enabled, the web library's Delete button can permanently remove files from disk.\nOnly activate this if you understand that deleted files cannot be recovered.").
Value(&cfg.Library.AllowDelete),
huh.NewConfirm().
Title("Cache subtitles during scan?").
Description("Extract embedded text subtitles to WebVTT once during the scan and store them\nbeside the media (hidden .unarr dir) so playback subtitles are instant — and huge\nremuxes don't time out extracting on demand. Local only; nothing is uploaded.").
Value(&cfg.Library.CacheSubtitles),
huh.NewConfirm().
Title("Cache thumbnails during scan?").
Description("Pre-extract a few preview frames per file (hidden .unarr dir) so the file panel\nand seekbar previews load instantly. Small optimized JPEGs; local only.").
Value(&cfg.Library.CacheThumbnails),
),
).Run()
}

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,7 @@
package cmd
import (
"errors"
"fmt"
"os"
"os/exec"
@ -262,9 +263,12 @@ func runDaemonReload() error {
// stopDaemonByPID reads the state file and sends a graceful stop to the daemon PID.
// Used as fallback on platforms without a service manager (and as Windows implementation).
func stopDaemonByPID() error {
state := agent.ReadState()
if state == nil {
return fmt.Errorf("daemon does not appear to be running (state file not found)")
state, err := agent.LoadState()
if err != nil {
if errors.Is(err, agent.ErrDaemonNotRunning) {
return err
}
return fmt.Errorf("read daemon state: %w", err)
}
return killPID(state.PID)
}

View file

@ -113,20 +113,18 @@ func runDownloadWithDeps(input, method string, deps downloadDeps) error {
MetadataTimeout: 15 * time.Minute,
StallTimeout: 10 * time.Minute,
MaxTimeout: 0, // unlimited
// One-shot foreground download: leech then exit. Seeding only makes sense
// for the always-on daemon (see DownloadConfig.SeedEnabled).
SeedEnabled: false,
WebRTCEnabled: cfg.Download.WebRTC.Enabled,
WebRTCTrackers: cfg.Download.WebRTC.Trackers,
ICEServers: engine.BuildICEServers(cfg.Download.WebRTC),
})
if err != nil {
return fmt.Errorf("create downloader: %w", err)
}
// Create a dummy reporter (no API reporting for one-shot)
reporter := engine.NewProgressReporter(
deps.newAgentClient(cfg.Auth.APIURL, cfg.Auth.APIKey, "unarr/"+Version),
5*time.Second,
)
// Local-only reporter: one-shot downloads have no server-side task, so a nil
// client keeps terminal progress working without spamming the status API
// (which 400s the synthetic "oneshot-" id).
reporter := engine.NewProgressReporter(nil, 5*time.Second)
debridDl := deps.newDebridDl()

165
internal/cmd/funnel.go Normal file
View file

@ -0,0 +1,165 @@
package cmd
import (
"fmt"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config"
)
func newFunnelCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "funnel",
Short: "Expose the daemon over a public HTTPS hostname via CloudFlare Quick Tunnel",
Long: `Turn the CloudFlare Quick Tunnel on/off and check its status.
When on, the daemon spawns cloudflared as a child process and registers a
` + "`https://<random>.trycloudflare.com`" + ` hostname tunnelled to its local
HLS server. The torrentclaw.com / torrentclaw.to web player picks the tunnel
URL first so cross-network playback works from any browser without Tailscale
or port forwarding.
Trade-offs:
Bytes proxy through CloudFlare. We don't relay; CF does. Preserves the
TorrentClaw legal posture but means CF sees your traffic shape.
Quick Tunnels are anonymous no CF account required.
Hostname is random per session and rotates roughly every 6 h.
Requires the cloudflared binary on PATH. Install:
Linux : https://pkg.cloudflare.com (apt) or download from
https://github.com/cloudflare/cloudflared/releases
macOS : brew install cloudflared
Windows: winget install --id Cloudflare.cloudflared`,
Example: ` unarr funnel status # is the tunnel up? what's the URL?
unarr funnel on # turn it on
unarr funnel off # turn it off`,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
cmd.AddCommand(newFunnelStatusCmd(), newFunnelOnCmd(), newFunnelOffCmd())
return cmd
}
func newFunnelStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show CloudFlare tunnel configuration + live URL",
Example: " unarr funnel status",
RunE: func(cmd *cobra.Command, args []string) error {
return runFunnelStatus()
},
}
}
func runFunnelStatus() error {
bold := color.New(color.Bold)
dim := color.New(color.FgHiBlack)
green := color.New(color.FgGreen)
yellow := color.New(color.FgYellow)
cyan := color.New(color.FgCyan)
cfg := loadConfig()
fmt.Println()
bold.Println(" CloudFlare Quick Tunnel")
fmt.Println()
if !cfg.Download.Funnel.Enabled {
dim.Println(" Mode: off")
fmt.Println()
dim.Println(" Enable with `unarr funnel on` to give the daemon a public HTTPS URL")
dim.Println(" so cross-network browser playback works without Tailscale.")
fmt.Println()
return nil
}
cyan.Println(" Mode: on")
state := agent.ReadState()
alive := state != nil && isDaemonAlive(state)
fmt.Println()
switch {
case alive && state.FunnelURL != "":
green.Println(" ✓ Tunnel ACTIVE")
fmt.Printf(" URL: %s\n", state.FunnelURL)
fmt.Println()
dim.Println(" This URL rotates roughly every 6 h. The web player picks it up")
dim.Println(" automatically — no action needed on your side.")
case alive:
yellow.Println(" ⚠ Daemon is running but the tunnel hasn't registered yet.")
dim.Println(" Check `unarr daemon logs` for a [funnel] line. Common cause:")
dim.Println(" cloudflared isn't installed on PATH.")
default:
dim.Println(" Daemon not running — start it (`unarr start`) to bring the tunnel up.")
}
fmt.Println()
return nil
}
func newFunnelOnCmd() *cobra.Command {
return &cobra.Command{
Use: "on",
Short: "Turn the CloudFlare tunnel on",
Example: " unarr funnel on",
RunE: func(cmd *cobra.Command, args []string) error {
return setFunnelEnabled(true)
},
}
}
func newFunnelOffCmd() *cobra.Command {
return &cobra.Command{
Use: "off",
Short: "Turn the CloudFlare tunnel off",
Example: " unarr funnel off",
RunE: func(cmd *cobra.Command, args []string) error {
return setFunnelEnabled(false)
},
}
}
func setFunnelEnabled(enabled bool) error {
green := color.New(color.FgGreen)
dim := color.New(color.FgHiBlack)
cfg := loadConfig()
if cfg.Download.Funnel.Enabled == enabled {
fmt.Println()
dim.Printf(" Tunnel is already %s — nothing to do.\n", onOffWord(enabled))
fmt.Println()
return nil
}
cfg.Download.Funnel.Enabled = enabled
configPath := config.FilePath()
if cfgFile != "" {
configPath = cfgFile
}
if err := config.Save(cfg, configPath); err != nil {
return fmt.Errorf("save config: %w", err)
}
appCfg = cfg
fmt.Println()
green.Printf(" ✓ CloudFlare tunnel %s.\n", onOffWord(enabled))
// Subprocess is launched/torn down by the daemon at startup; a plain config
// reload does not bring it up. Prompt for a restart when the daemon is alive.
if state := agent.ReadState(); state != nil && isDaemonAlive(state) {
fmt.Println()
dim.Println(" The daemon is running. Restart it for this to take effect:")
dim.Println(" unarr daemon restart")
}
fmt.Println()
return nil
}
func onOffWord(enabled bool) string {
if enabled {
return "on"
}
return "off"
}

View file

@ -75,12 +75,19 @@ func runInit(apiURLOverride string) error {
apiKey := cfg.Auth.APIKey
// Resolve the agentId up front so browser-authorize can bind the minted
// per-machine key to it.
agentID := cfg.Agent.ID
if agentID == "" {
agentID = uuid.New().String()
}
if apiKey == "" {
// Try browser-based auth first (like Claude Code / GitHub CLI)
fmt.Println(" Opening browser to connect your account...")
fmt.Println()
browserKey, browserErr := browserAuth(apiURL)
browserKey, browserErr := browserAuth(apiURL, agentID)
if browserErr == nil && strings.HasPrefix(browserKey, "tc_") {
apiKey = browserKey
green.Println(" ✓ Connected via browser")
@ -127,11 +134,6 @@ func runInit(apiURLOverride string) error {
// Validate API key by registering with the server
fmt.Print(" Verifying API key... ")
agentID := cfg.Agent.ID
if agentID == "" {
agentID = uuid.New().String()
}
hostname, _ := os.Hostname()
agentName := cfg.Agent.Name
if agentName == "" {
@ -150,9 +152,21 @@ func runInit(apiURLOverride string) error {
if err != nil {
color.Red("FAILED")
fmt.Println()
// Stored credential was revoked (machine deleted from the dashboard) —
// drop it so a re-run mints a fresh identity.
if agent.IsRevoked(err) {
clearRevokedIdentity(cfg, "init")
return nil
}
return fmt.Errorf("API key validation failed: %w", err)
}
// Manual-paste bootstrap: swap to the minted per-machine key, discard the
// general key the user pasted.
if resp.AgentKey != "" {
apiKey = resp.AgentKey
}
green.Println("OK")
fmt.Printf(" Connected as %s (%s) [%s]\n", resp.User.Name, resp.User.Email, strings.ToUpper(resp.User.Plan))
fmt.Println()

View file

@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"log"
"os"
"runtime"
"strings"
@ -16,6 +17,20 @@ import (
"github.com/torrentclaw/unarr/internal/config"
)
// clearRevokedIdentity wipes the stored credential (api key + agentId) after the
// server reports this machine's registration was revoked, so a re-run of the
// given command mints a fresh identity instead of looping against a dead key.
func clearRevokedIdentity(cfg config.Config, retryCmd string) {
cfg.Auth.APIKey = ""
cfg.Agent.ID = ""
if err := config.Save(cfg, resolvedConfigPath()); err != nil {
log.Printf("could not clear revoked credential: %v", err)
}
fmt.Println(" This machine's previous registration was removed from your account.")
fmt.Printf(" Run `unarr %s` again to reconnect it as a new agent.\n", retryCmd)
fmt.Println()
}
func newLoginCmd() *cobra.Command {
var apiURL string
@ -70,11 +85,18 @@ func runLogin(apiURLOverride string) error {
var apiKey string
// Resolve the agentId up front so the browser-authorize flow can bind the
// minted per-machine key to it.
agentID := cfg.Agent.ID
if agentID == "" {
agentID = uuid.New().String()
}
// Try browser-based auth first
fmt.Println(" Opening browser to connect your account...")
fmt.Println()
browserKey, browserErr := browserAuth(apiURL)
browserKey, browserErr := browserAuth(apiURL, agentID)
if browserErr == nil && strings.HasPrefix(browserKey, "tc_") {
apiKey = browserKey
green.Println(" ✓ Connected via browser")
@ -120,11 +142,6 @@ func runLogin(apiURLOverride string) error {
fmt.Print(" Verifying API key... ")
agentID := cfg.Agent.ID
if agentID == "" {
agentID = uuid.New().String()
}
hostname, _ := os.Hostname()
agentName := cfg.Agent.Name
if agentName == "" {
@ -143,9 +160,21 @@ func runLogin(apiURLOverride string) error {
if err != nil {
color.Red("FAILED")
fmt.Println()
// The stored credential was revoked (this machine was deleted from the
// dashboard). Drop it so the next run mints a fresh identity.
if agent.IsRevoked(err) {
clearRevokedIdentity(cfg, "login")
return nil
}
return fmt.Errorf("API key validation failed: %w", err)
}
// Manual-paste bootstrap: the server minted a per-machine key bound to this
// agentId. Swap to it and discard the general key the user pasted.
if resp.AgentKey != "" {
apiKey = resp.AgentKey
}
green.Println("OK")
fmt.Printf(" Connected as %s (%s) [%s]\n", resp.User.Name, resp.User.Email, strings.ToUpper(resp.User.Plan))
fmt.Println()

View file

@ -0,0 +1,112 @@
package cmd
import (
"context"
"sync"
"github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/engine"
"github.com/torrentclaw/unarr/internal/library/mediainfo"
)
// playerSessionRegistry tracks per-session cancel funcs for active in-browser
// HLS streaming sessions. Each session lives only as long as its ffmpeg
// process; the registry exists so duplicate sync responses don't double-spawn
// the same session and so daemon shutdown can drain.
var playerSessionRegistry = &playerSessionRegistryT{
cancels: make(map[string]context.CancelFunc),
}
type playerSessionRegistryT struct {
mu sync.Mutex
cancels map[string]context.CancelFunc
}
func (r *playerSessionRegistryT) has(sessionID string) bool {
r.mu.Lock()
defer r.mu.Unlock()
_, ok := r.cancels[sessionID]
return ok
}
func (r *playerSessionRegistryT) add(sessionID string, cancel context.CancelFunc) {
r.mu.Lock()
defer r.mu.Unlock()
r.cancels[sessionID] = cancel
}
func (r *playerSessionRegistryT) remove(sessionID string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.cancels, sessionID)
}
func (r *playerSessionRegistryT) count() int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.cancels)
}
// cancelAllPlayerSessions cancels every running session. Called on daemon
// shutdown so the ffmpeg children and SSE consumers exit cleanly.
func cancelAllPlayerSessions() {
playerSessionRegistry.mu.Lock()
cancels := make([]context.CancelFunc, 0, len(playerSessionRegistry.cancels))
for _, c := range playerSessionRegistry.cancels {
cancels = append(cancels, c)
}
playerSessionRegistry.cancels = make(map[string]context.CancelFunc)
playerSessionRegistry.mu.Unlock()
for _, c := range cancels {
c()
}
}
// buildTranscodeRuntime resolves the ffmpeg/ffprobe binaries + config knobs
// for the HLS streaming pipeline. Failure to resolve a binary returns a
// runtime with empty paths so the caller can short-circuit instead of
// launching a transcoder that will immediately fail.
func buildTranscodeRuntime(ctx context.Context, cfg config.Config) engine.TranscodeRuntime {
if !cfg.Download.Transcode.Enabled {
return engine.TranscodeRuntime{Disabled: true}
}
ffmpegPath, errF := mediainfo.ResolveFFmpeg(cfg.Library.FFmpegPath)
ffprobePath, errP := mediainfo.ResolveFFprobe(cfg.Library.FFprobePath)
if errF != nil || errP != nil {
return engine.TranscodeRuntime{Disabled: true}
}
hw := engine.HWAccelNone
switch cfg.Download.Transcode.HWAccel {
case "auto":
hw = engine.DetectHWAccel(ctx, ffmpegPath)
case "nvenc":
hw = engine.HWAccelNVENC
case "qsv":
hw = engine.HWAccelQSV
case "vaapi":
hw = engine.HWAccelVAAPI
case "videotoolbox":
hw = engine.HWAccelVideoToolbox
case "none", "":
hw = engine.HWAccelNone
}
return engine.TranscodeRuntime{
FFmpegPath: ffmpegPath,
FFprobePath: ffprobePath,
HWAccel: hw,
Preset: cfg.Download.Transcode.Preset,
VideoBitrate: cfg.Download.Transcode.VideoBitrate,
AudioBitrate: cfg.Download.Transcode.AudioBitrate,
MaxHeight: cfg.Download.Transcode.MaxHeight,
// Tonemap HDR→SDR only when this ffmpeg build has zscale; otherwise the
// filter would error and break playback, so HDR plays untonemapped.
TonemapHDR: engine.FFmpegSupportsZscale(ffmpegPath),
// libplacebo (GPU) is preferred over zscale when present — checked here so
// the per-session arg builder can pick it for HDR sources.
HasLibplacebo: engine.FFmpegSupportsLibplacebo(ffmpegPath),
// scale_cuda lets an NVENC SDR downscale stay fully on the GPU. Probed
// unconditionally (like libplacebo); fails closed to false on non-CUDA
// hosts, where the arg builder keeps the CPU scale path anyway.
HasScaleCuda: engine.FFmpegSupportsScaleCuda(ffmpegPath),
}
}

View file

@ -15,7 +15,7 @@ import (
)
// newProbeHWAccelCmd reports the hardware-acceleration capabilities the daemon
// would actually use for HLS/WebRTC transcoding. The motivation: a beefy host
// would actually use for HLS transcoding. The motivation: a beefy host
// (e.g. RTX 3090) can still fall back to software encoding when the installed
// ffmpeg binary was built without nvenc/qsv/vaapi support — Homebrew ffmpeg
// is a common offender. Without this command, users see slow / failing 4K

View file

@ -3,6 +3,7 @@
package cmd
import (
"errors"
"fmt"
"log"
"os"
@ -43,9 +44,12 @@ func startReloadWatcher(rc *ReloadableConfig) {
// sendReloadSignal sends SIGUSR1 to the running daemon process.
func sendReloadSignal() error {
state := agent.ReadState()
if state == nil {
return fmt.Errorf("daemon does not appear to be running (state file not found)")
state, err := agent.LoadState()
if err != nil {
if errors.Is(err, agent.ErrDaemonNotRunning) {
return err
}
return fmt.Errorf("read daemon state: %w", err)
}
p, err := os.FindProcess(state.PID)
if err != nil {

View file

@ -0,0 +1,74 @@
package cmd
import (
"os"
"path/filepath"
"runtime"
"testing"
)
func mkfile(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
func TestRelocateUnreachable(t *testing.T) {
root := t.TempDir()
// A 3-segment-deep file under the current root.
mkfile(t, filepath.Join(root, "Acme Show", "Season 01", "ep.mkv"))
// A 2-segment-deep file (too shallow to be matched by a short tail).
mkfile(t, filepath.Join(root, "Season 01", "lonely.mkv"))
roots := []string{root}
// Base-path change: an old-root path whose 3-seg tail exists under the new
// root → relocates to the real file.
got := relocateUnreachable("/old/base/Acme Show/Season 01/ep.mkv", roots)
want := filepath.Join(root, "Acme Show", "Season 01", "ep.mkv")
if got != want {
t.Errorf("relocate moved file: got %q want %q", got, want)
}
// Only a 2-segment tail would match → must NOT relocate (ambiguous).
if got := relocateUnreachable("/old/Season 01/lonely.mkv", roots); got != "" {
t.Errorf("2-segment tail should not match, got %q", got)
}
// Nonexistent file → no relocation.
if got := relocateUnreachable("/old/base/Acme Show/Season 01/missing.mkv", roots); got != "" {
t.Errorf("missing file should not relocate, got %q", got)
}
// Traversal attempt: ".." segments are cleaned by filepath.Join and the
// result is re-validated, so it can't escape.
if got := relocateUnreachable("/old/../../../etc/passwd", roots); got != "" {
t.Errorf("traversal should not match, got %q", got)
}
}
func TestRelocateUnreachableSymlinkEscape(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink semantics differ on windows")
}
root := t.TempDir()
outside := t.TempDir()
// A real file living OUTSIDE any allowed root.
mkfile(t, filepath.Join(outside, "sub", "secret.mkv"))
// A symlink inside the root pointing at the outside tree.
if err := os.Symlink(outside, filepath.Join(root, "link")); err != nil {
t.Skipf("symlink unsupported: %v", err)
}
// The lexical candidate root/link/sub/secret.mkv exists (os.Stat follows the
// symlink), but after resolving symlinks it's outside the root → must be
// rejected so the stream can't escape the allowed dirs.
got := relocateUnreachable("/old/link/sub/secret.mkv", []string{root})
if got != "" {
t.Errorf("symlink escape must be rejected, got %q", got)
}
}

View file

@ -0,0 +1,98 @@
package cmd
import (
"os"
"path/filepath"
"testing"
)
func TestResolvePlayableFile(t *testing.T) {
root := t.TempDir()
mkfile(t, filepath.Join(root, "Acme Show", "Season 01", "ep.mkv"))
roots := []string{root}
t.Run("allowed path resolves to itself", func(t *testing.T) {
want := filepath.Join(root, "Acme Show", "Season 01", "ep.mkv")
got, code, err := resolvePlayableFile(want, roots, "test")
if err != nil {
t.Fatalf("unexpected error (%s): %v", code, err)
}
if got != want {
t.Errorf("got %q want %q", got, want)
}
})
t.Run("old base path relocates onto current root", func(t *testing.T) {
got, code, err := resolvePlayableFile("/old/base/Acme Show/Season 01/ep.mkv", roots, "test")
if err != nil {
t.Fatalf("unexpected error (%s): %v", code, err)
}
want := filepath.Join(root, "Acme Show", "Season 01", "ep.mkv")
if got != want {
t.Errorf("got %q want %q", got, want)
}
})
t.Run("deleted file under old base is file_missing, never path_rejected", func(t *testing.T) {
// The incident shape (2026-06-10): web hands a stale host path
// (/mnt/nas/…) whose file was deleted — the docker agent can't see the
// original path AND no tail relocates. file_missing tells the web to
// prune the stale row; path_rejected would block that self-heal.
_, code, err := resolvePlayableFile("/old/base/Acme Show/Season 01/gone.mkv", roots, "test")
if err == nil {
t.Fatal("expected error for deleted file")
}
if code != pathErrMissing {
t.Errorf("code = %q, want %q", code, pathErrMissing)
}
})
t.Run("existing file outside roots is path_rejected", func(t *testing.T) {
outside := t.TempDir()
// 1-segment-deep on purpose: a ≥3-segment tail could legitimately
// relocate INTO the root if a same-named file existed there.
mkfile(t, filepath.Join(outside, "leak.mkv"))
_, code, err := resolvePlayableFile(filepath.Join(outside, "leak.mkv"), roots, "test")
if err == nil {
t.Fatal("expected error for out-of-root file")
}
if code != pathErrRejected {
t.Errorf("code = %q, want %q", code, pathErrRejected)
}
})
t.Run("missing file inside an allowed root is file_missing", func(t *testing.T) {
_, code, err := resolvePlayableFile(filepath.Join(root, "Acme Show", "Season 01", "gone.mkv"), roots, "test")
if err == nil {
t.Fatal("expected error for missing file")
}
if code != pathErrMissing {
t.Errorf("code = %q, want %q", code, pathErrMissing)
}
})
t.Run("directory resolves to its video file", func(t *testing.T) {
got, code, err := resolvePlayableFile(filepath.Join(root, "Acme Show", "Season 01"), roots, "test")
if err != nil {
t.Fatalf("unexpected error (%s): %v", code, err)
}
want := filepath.Join(root, "Acme Show", "Season 01", "ep.mkv")
if got != want {
t.Errorf("got %q want %q", got, want)
}
})
t.Run("directory without video is no_video_file", func(t *testing.T) {
empty := filepath.Join(root, "Empty Show")
if err := os.MkdirAll(empty, 0o755); err != nil {
t.Fatal(err)
}
_, code, err := resolvePlayableFile(empty, roots, "test")
if err == nil {
t.Fatal("expected error for empty directory")
}
if code != pathErrNoVideo {
t.Errorf("code = %q, want %q", code, pathErrNoVideo)
}
})
}

View file

@ -2,11 +2,14 @@ package cmd
import (
"fmt"
"net/http"
"os"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
tc "github.com/torrentclaw/go-client"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/sentry"
"github.com/torrentclaw/unarr/internal/upgrade"
@ -26,15 +29,19 @@ var (
func init() {
rootCmd = &cobra.Command{
Use: "unarr",
Short: "unarr — torrent search and management",
Long: `unarr is a powerful terminal tool for torrent search and management.
Search 30+ torrent sources, inspect torrent quality, discover popular content,
find streaming providers, and manage your media collection all from your terminal.
Version: Version,
Short: "Terminal torrent + debrid + usenet client — download, stream, transcode",
Long: `unarr is a terminal-native client that downloads torrents, debrid links,
and usenet (NZB) all from the same binary. It streams content straight
to mpv/vlc with sequential piece prioritization, transcodes on the fly via
ffmpeg with hardware acceleration (NVENC, QSV, VA-API, VideoToolbox), and
organizes your library into Movies/TV folders. Run it one-shot or as a
long-running daemon with a built-in WireGuard split-tunnel and remote
playback over Cloudflare Funnel.
Get started:
unarr init First-time configuration wizard
unarr search "breaking bad" Search for content
unarr download <magnet|hash> Grab a torrent one-shot
unarr start Start the download daemon
Documentation: https://torrentclaw.com/cli
@ -55,7 +62,7 @@ Source: https://github.com/torrentclaw/unarr`,
// Command groups for organized help output
rootCmd.AddGroup(
&cobra.Group{ID: "start", Title: "Getting Started:"},
&cobra.Group{ID: "search", Title: "Search & Discovery:"},
&cobra.Group{ID: "search", Title: "Catalog & Discovery:"},
&cobra.Group{ID: "download", Title: "Downloads & Streaming:"},
&cobra.Group{ID: "daemon", Title: "Daemon Management:"},
&cobra.Group{ID: "system", Title: "System & Diagnostics:"},
@ -103,6 +110,10 @@ Source: https://github.com/torrentclaw/unarr`,
statusCmd.GroupID = "daemon"
daemonCmd := newDaemonCmd()
daemonCmd.GroupID = "daemon"
vpnCmd := newVPNCmd()
vpnCmd.GroupID = "daemon"
funnelCmd := newFunnelCmd()
funnelCmd.GroupID = "daemon"
// System & Diagnostics
statsCmd := newStatsCmd()
@ -146,6 +157,8 @@ Source: https://github.com/torrentclaw/unarr`,
stopCmd,
statusCmd,
daemonCmd,
vpnCmd,
funnelCmd,
// System & Diagnostics
statsCmd,
doctorCmd,
@ -179,6 +192,17 @@ func Execute() {
}
// loadConfig loads config once (lazy initialization).
// resolvedConfigPath returns the config file the CLI actually reads/writes,
// honouring the global --config flag. Use this for every Save so a revocation
// wipe or key migration lands in the right file (e.g. the dev-local agent's
// ~/.config/unarr-dev/config.toml), not always the default path.
func resolvedConfigPath() string {
if cfgFile != "" {
return cfgFile
}
return config.FilePath()
}
func loadConfig() config.Config {
if cfgLoaded {
return appCfg
@ -225,6 +249,21 @@ func getClient() *tc.Client {
opts = append(opts, tc.WithUserAgent("unarr/"+Version))
// Mirror failover for the public-API client, matching the agent control-plane
// client's resilience: wrap the transport so search/popular/etc. rotate across
// cfg.Auth.Mirrors on a primary takedown, using the same MirrorPool TYPE +
// IsTransient policy the agent client uses (a fresh pool instance — the two
// clients fail over independently). WithRetry(0) disables the go-client's own
// retry loop so the transport owns failover exclusively (no nested
// retry×backoff on an outage). WithTimeout(30s) is set idiomatically and gives
// room for a couple of mirror attempts (go-client's bare default is 15s).
pool := agent.NewMirrorPool(cfg.Auth.APIURL, cfg.Auth.Mirrors)
opts = append(opts,
tc.WithHTTPClient(&http.Client{Transport: agent.NewMirrorRoundTripper(pool, nil)}),
tc.WithTimeout(30*time.Second),
tc.WithRetry(0, 0, 0),
)
apiClient = tc.NewClient(opts...)
return apiClient
}

View file

@ -9,13 +9,13 @@ import (
"sort"
"strings"
"syscall"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/library"
"github.com/torrentclaw/unarr/internal/library/mediainfo"
)
func newScanCmd() *cobra.Command {
@ -39,20 +39,56 @@ to see available quality upgrades.`,
if showStatus {
return runScanStatus()
}
if len(args) == 0 {
cfg := loadConfig()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// All scanned roots feed ONE sync session (single syncStartedAt +
// final isLastBatch) so the server's stale-row cleanup sees the
// whole cycle at once. fullCycle only without an explicit path —
// a subtree scan must never let the server reap outside it.
if len(args) == 0 {
paths := library.ResolveScanPaths(cfg.Download.Dir, cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir, cfg.Library.ScanPath)
if len(paths) == 0 {
return fmt.Errorf("usage: unarr scan <path>\n\nNo scan paths configured. Provide a path or set up downloads.dir via 'unarr init'")
}
var items []agent.LibrarySyncItem
var caches []*library.LibraryCache
for _, p := range paths {
if err := runScan(p, workers, ffprobe, noSync); err != nil {
cache, err := runScan(ctx, cfg, p, workers, ffprobe)
if err != nil {
return err
}
caches = append(caches, cache)
items = append(items, library.BuildSyncItems(cache)...)
}
if noSync || jsonOut {
return nil
}
if err := syncToServer(ctx, cfg, items, paths, true); err != nil {
return err
}
if ac := scanAPIClient(cfg); ac != nil {
for _, cache := range caches {
detectAndSubmitSkipSegments(ctx, cfg, ac, cache)
}
}
return nil
}
return runScan(args[0], workers, ffprobe, noSync)
cache, err := runScan(ctx, cfg, args[0], workers, ffprobe)
if err != nil {
return err
}
if noSync || jsonOut {
return nil
}
if err := syncToServer(ctx, cfg, library.BuildSyncItems(cache), []string{args[0]}, false); err != nil {
return err
}
if ac := scanAPIClient(cfg); ac != nil {
detectAndSubmitSkipSegments(ctx, cfg, ac, cache)
}
return nil
},
}
@ -64,18 +100,20 @@ to see available quality upgrades.`,
return cmd
}
func runScan(dirPath string, workers int, ffprobePath string, noSync bool) error {
// runScan walks one root, saves the cache and prewarms sidecars. Syncing to
// the server is the CALLER's job (RunE) — all roots of an invocation feed one
// sync session via syncToServer, so per-root sessions can't trick the server
// into reaping rows of roots the session never visited.
func runScan(ctx context.Context, cfg config.Config, dirPath string, workers int, ffprobePath string) (*library.LibraryCache, error) {
// Validate path
info, err := os.Stat(dirPath)
if err != nil {
return fmt.Errorf("path not found: %s", dirPath)
return nil, fmt.Errorf("path not found: %s", dirPath)
}
if !info.IsDir() {
return fmt.Errorf("not a directory: %s", dirPath)
return nil, fmt.Errorf("not a directory: %s", dirPath)
}
cfg := loadConfig()
// Resolve workers: flag → config → default 8
if workers == 0 {
workers = cfg.Library.Workers
@ -92,10 +130,6 @@ func runScan(dirPath string, workers int, ffprobePath string, noSync bool) error
// Load existing cache for incremental scanning
existing, _ := library.LoadCache()
// Context with signal handling
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
bold := color.New(color.Bold)
bold.Printf("\n Scanning %s...\n\n", dirPath)
@ -113,14 +147,14 @@ func runScan(dirPath string, workers int, ffprobePath string, noSync bool) error
},
})
if err != nil {
return fmt.Errorf("scan failed: %w", err)
return nil, fmt.Errorf("scan failed: %w", err)
}
fmt.Fprintf(os.Stderr, "\r\033[K") // clear progress line
// Save cache
if err := library.SaveCache(cache); err != nil {
return fmt.Errorf("save cache: %w", err)
return nil, fmt.Errorf("save cache: %w", err)
}
// Remember scan path in config
@ -132,22 +166,57 @@ func runScan(dirPath string, workers int, ffprobePath string, noSync bool) error
// Print summary
printScanSummary(cache)
// JSON output mode
// JSON output mode — emit the cache and skip the prewarm (the caller skips
// the sync via the same flag).
if jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(cache)
return cache, enc.Encode(cache)
}
// Sync to server
if !noSync {
return syncToServer(ctx, cfg, cache)
// Pre-extract sidecars (text subs → WebVTT, panel frames → JPEG) into a hidden
// ".unarr" dir so playback gets instant subtitles/thumbnails and huge remuxes
// never hit the on-demand timeout. Best-effort + Ctrl-C interruptible (the scan
// itself is already saved).
if cfg.Library.CacheSubtitles || cfg.Library.CacheThumbnails || cfg.Library.Trickplay.Enabled {
if ff, err := mediainfo.ResolveFFmpeg(cfg.Library.FFmpegPath); err == nil {
fmt.Fprintf(os.Stderr, " Pre-extracting subtitles + thumbnails to cache… (Ctrl-C to skip)\n")
library.PrewarmSidecars(ctx, cache, library.PrewarmOptions{
FFmpegPath: ff,
CacheSubtitles: cfg.Library.CacheSubtitles,
CacheThumbnails: cfg.Library.CacheThumbnails,
Workers: 2,
Trickplay: cfg.Library.Trickplay.Enabled,
TrickplayIntervalSec: cfg.Library.Trickplay.IntervalSeconds(),
TrickplayWidth: cfg.Library.Trickplay.Width,
MaxLoadRatio: cfg.Library.PrewarmMaxLoadRatio,
})
} else {
fmt.Fprintf(os.Stderr, " Skipping sidecar prewarm: ffmpeg unavailable: %v\n", err)
}
}
return nil
return cache, nil
}
func syncToServer(ctx context.Context, cfg config.Config, cache *library.LibraryCache) error {
// scanAPIClient builds the agent API client for post-scan submissions, using
// the same key resolution as syncToServer. Nil when no key is configured.
func scanAPIClient(cfg config.Config) *agent.Client {
apiKey := apiKeyFlag
if apiKey == "" {
apiKey = cfg.Auth.APIKey
}
if apiKey == "" {
return nil
}
return agent.NewClient(cfg.Auth.APIURL, apiKey, "unarr/"+Version)
}
// syncToServer uploads the scanned items of THIS invocation as one sync
// session. roots lists every root the invocation scanned; fullCycle marks a
// no-args run that covered all configured roots (the server may then reap
// stale rows regardless of prefix — see LibrarySyncRequest.FullCycle).
func syncToServer(ctx context.Context, cfg config.Config, items []agent.LibrarySyncItem, roots []string, fullCycle bool) error {
apiKey := apiKeyFlag
if apiKey == "" {
apiKey = cfg.Auth.APIKey
@ -159,49 +228,28 @@ func syncToServer(ctx context.Context, cfg config.Config, cache *library.Library
ac := agent.NewClient(cfg.Auth.APIURL, apiKey, "unarr/"+Version)
items := library.BuildSyncItems(cache)
if len(items) == 0 {
color.Yellow("\n No valid items to sync.")
return nil
}
// Send in batches of 100
const batchSize = 100
totalSynced := 0
totalMatched := 0
totalRemoved := 0
syncStartedAt := time.Now().UTC().Format(time.RFC3339)
for i := 0; i < len(items); i += batchSize {
end := i + batchSize
if end > len(items) {
end = len(items)
}
batch := items[i:end]
isLast := end >= len(items)
fmt.Fprintf(os.Stderr, "\r Syncing %d/%d items...\033[K", end, len(items))
resp, err := ac.SyncLibrary(ctx, agent.LibrarySyncRequest{
Items: batch,
ScanPath: cache.Path,
IsLastBatch: isLast,
SyncStartedAt: syncStartedAt,
res, err := library.SyncBatches(ctx, ac, items, library.SyncOptions{
AgentID: cfg.Agent.ID,
ScanPath: roots[0],
ScanRoots: roots,
FullCycle: fullCycle,
OnProgress: func(sent, total int) {
fmt.Fprintf(os.Stderr, "\r Syncing %d/%d items...\033[K", sent, total)
},
})
if err != nil {
return fmt.Errorf("sync failed: %w", err)
}
totalSynced += resp.Synced
totalMatched += resp.Matched
totalRemoved += resp.Removed
}
fmt.Fprintf(os.Stderr, "\r\033[K")
green := color.New(color.FgGreen)
green.Printf("\n ✓ Synced %d items (%d matched, %d removed)\n", totalSynced, totalMatched, totalRemoved)
green.Printf("\n ✓ Synced %d items (%d matched, %d removed)\n", res.Synced, res.Matched, res.Removed)
apiURL := strings.TrimSuffix(cfg.Auth.APIURL, "/")
fmt.Printf(" → View upgrades at %s/library\n\n", apiURL)
@ -241,7 +289,7 @@ func printScanSummary(cache *library.LibraryCache) {
continue
}
res := library.ResolveResolution(item.MediaInfo.Video.Height)
res := library.ResolveResolution(item.MediaInfo.Video.Width, item.MediaInfo.Video.Height)
if res == "" {
res = "other"
}

View file

@ -1,65 +0,0 @@
package cmd
import (
"context"
"log"
"time"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/engine"
)
// handleSeedFileTask wraps an arbitrary on-disk file as a single-file
// torrent and adds it to the existing torrent client so the WebRTC
// peer can serve pieces to a browser. Reports the generated info_hash
// back to the server so the web player can target /stream/<hash>.
//
// Runs in its own goroutine; never blocks the claim batch.
func handleSeedFileTask(t agent.Task, dl *engine.TorrentDownloader, client *agent.Client) {
short := agent.ShortID(t.ID)
if t.FilePath == "" {
log.Printf("[%s] seed_file: missing filePath, marking failed", short)
reportSeedFileFailed(client, t.ID, "Missing filePath")
return
}
log.Printf("[%s] seed_file: building torrent from %s", short, t.FilePath)
hash, err := engine.SeedFileOnDownloader(dl, t.FilePath)
if err != nil {
log.Printf("[%s] seed_file: %v", short, err)
reportSeedFileFailed(client, t.ID, err.Error())
return
}
infoHash := hash.HexString()
log.Printf("[%s] seed_file: seeding ih=%s", short, infoHash)
// Push the info_hash + downloading status (file is on disk; from the
// client's perspective it's already complete). The web side polls
// /api/internal/stream/seed-file/<taskId> waiting for this update.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, reportErr := client.ReportStatus(ctx, agent.StatusUpdate{
TaskID: t.ID,
Status: "downloading", // semantic: actively serving
InfoHash: infoHash,
FilePath: t.FilePath,
})
if reportErr != nil {
log.Printf("[%s] seed_file: failed to push info_hash: %v", short, reportErr)
}
}
func reportSeedFileFailed(client *agent.Client, taskID, msg string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := client.ReportStatus(ctx, agent.StatusUpdate{
TaskID: taskID,
Status: "failed",
ErrorMessage: msg,
})
if err != nil {
log.Printf("[%s] seed_file: report-failed itself failed: %v", agent.ShortID(taskID), err)
}
}

View file

@ -0,0 +1,91 @@
package cmd
import (
"context"
"log"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/library"
"github.com/torrentclaw/unarr/internal/library/mediainfo"
)
// detectAndSubmitSkipSegments runs intro/credits detection over a scanned
// cache and uploads the results. Called AFTER the library sync (the server
// resolves file paths against the just-synced library_item rows). Best-effort:
// every failure logs and returns — a scan must never fail because of this.
func detectAndSubmitSkipSegments(ctx context.Context, cfg config.Config, ac *agent.Client, cache *library.LibraryCache) {
if !cfg.Library.SkipDetect || cache == nil || ctx.Err() != nil {
return
}
ffmpegPath, err := mediainfo.ResolveFFmpeg(cfg.Library.FFmpegPath)
if err != nil {
log.Printf("[skipdetect] skipped: ffmpeg unavailable: %v", err)
return
}
fpcalcPath, err := mediainfo.ResolveFpcalc()
if err != nil {
// Movies-only still works (black frames need just ffmpeg).
log.Printf("[skipdetect] fpcalc unavailable (episode detection off): %v", err)
fpcalcPath = ""
}
// Two phases so fast results don't wait on slow ones: episode fingerprinting
// is seconds per season (and often pure cache), while movie black-frame
// scans grind through 4K tails over the NAS — episodes submit first.
episodes := library.DetectSkipSegments(ctx, cache, library.SkipDetectOptions{
FFmpegPath: ffmpegPath,
FpcalcPath: fpcalcPath,
Workers: 2,
})
submitSkipSegments(ctx, cfg, ac, episodes)
movies := library.DetectSkipSegments(ctx, cache, library.SkipDetectOptions{
FFmpegPath: ffmpegPath,
Workers: 2,
Movies: true,
})
submitSkipSegments(ctx, cfg, ac, movies)
}
func submitSkipSegments(ctx context.Context, cfg config.Config, ac *agent.Client, detections []library.SkipDetection) {
if len(detections) == 0 || ac == nil || ctx.Err() != nil {
return
}
items := make([]agent.SkipSegmentItem, 0, len(detections))
for _, d := range detections {
segs := make([]agent.SkipSegmentRange, 0, len(d.Segments))
for _, s := range d.Segments {
segs = append(segs, agent.SkipSegmentRange{Category: s.Category, StartSec: s.StartSec, EndSec: s.EndSec})
}
items = append(items, agent.SkipSegmentItem{
FilePath: d.Item.FilePath,
Title: d.Item.Title,
Season: d.Item.Season,
Episode: d.Item.Episode,
DurationSec: d.DurationSec,
Segments: segs,
})
}
const batchSize = 200
stored, unmatched := 0, 0
for start := 0; start < len(items); start += batchSize {
end := start + batchSize
if end > len(items) {
end = len(items)
}
res, err := ac.SubmitSkipSegments(ctx, agent.SkipSegmentsRequest{
AgentID: cfg.Agent.ID,
Items: items[start:end],
})
if err != nil {
log.Printf("[skipdetect] submit failed: %v", err)
return
}
stored += res.Stored
unmatched += res.Unmatched
}
log.Printf("[skipdetect] submitted %d file(s): %d segment(s) stored, %d unmatched", len(items), stored, unmatched)
}

View file

@ -87,10 +87,17 @@ func cancelStreamTask(taskID string) {
// handleStreamTask manages a streaming task lifecycle for active torrent downloads.
// It creates a StreamEngine, buffers, sets the file on the persistent server,
// and reports progress until the task is cancelled or the download completes.
func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine.ProgressReporter, cfg config.Config, agentClient *agent.Client, srv *engine.StreamServer) {
func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine.ProgressReporter, cfg config.Config, agentClient *agent.Client, srv *engine.StreamServer, onStateChange func()) {
ctx, cancel := context.WithCancel(parentCtx)
defer cancel()
// NOTE: we deliberately do NOT cancel prior stream goroutines here. The
// persistent StreamServer is last-writer-wins (SetFile replaces the file;
// the deferred ClearFile is guarded by CurrentTaskID), so a displaced prior
// goroutine simply parks on its own ctx until the 30m idle guard reaps it —
// cheap. Cancelling them at entry would abort an in-flight debrid HEAD of a
// concurrently-starting task (size resolution), failing that stream.
// Register for web-initiated cancellation
streamRegistry.mu.Lock()
streamRegistry.cancels[at.ID] = cancel
@ -106,10 +113,55 @@ func handleStreamTask(parentCtx context.Context, at agent.Task, reporter *engine
}()
task := engine.NewTaskFromAgent(at)
// Event-driven uplink: stream tasks transition outside the Manager (which
// wires this for downloads), so set it here too — resolving/downloading/
// completed/failed get pushed to the server immediately.
task.SetOnChange(onStateChange)
task.ResolvedMethod = engine.MethodTorrent
reporter.Track(task)
defer reporter.ReportFinal(context.Background(), task)
// Debrid passthrough: when the web resolved a direct HTTPS link (the torrent
// is cached on the user's debrid + preferredMethod=debrid), stream FROM that
// link instead of joining the P2P swarm — served over the SAME /stream
// endpoint, so VLC / external players consume it identically (and far
// faster). No HLS transcode here: external players handle any container.
// Falls through to the P2P StreamEngine below when there is no direct URL.
if at.DirectURL != "" {
task.ResolvedMethod = engine.MethodDebrid
task.Transition(engine.StatusResolving)
bctx, bcancel := context.WithTimeout(ctx, 15*time.Second)
// fallbackSize 0 → provider derives size from a HEAD; refresh nil → no
// task-level link-refresh endpoint exists (the web re-resolves stale
// debrid URLs at the next claim). A mid-stream expiry just ends the
// stream and the user re-opens it.
provider, perr := engine.NewDebridFileProvider(bctx, at.DirectURL, at.DirectFileName, 0, nil)
bcancel()
if perr != nil {
task.ErrorMessage = "debrid stream provider: " + perr.Error()
task.Transition(engine.StatusFailed)
return
}
srv.SetFile(provider, at.ID)
task.FileName = provider.FileName()
task.TotalBytes = provider.FileSize()
task.SetStreamURL(srv.URLsJSON()) // mutex-safe: the reporter reads it via GetStreamURL
log.Printf("[%s] stream (debrid): %s (%s) url: %s", at.ID[:8], provider.FileName(), ui.FormatBytes(provider.FileSize()), srv.URL())
if agentClient != nil {
watchReporter := engine.NewWatchReporter(agentClient, srv, at.ID)
go watchReporter.Run(ctx)
}
// Debrid serves a complete remote file — there is no download to track,
// so mark it complete immediately (the UI shows "ready"). The persistent
// server keeps serving until the idle guard reaps it (30m), same as P2P.
task.Transition(engine.StatusCompleted)
<-ctx.Done()
log.Printf("[%s] stream (debrid) stopped", at.ID[:8])
return
}
// 1. Create StreamEngine
eng, err := engine.NewStreamEngine(engine.StreamConfig{
DataDir: cfg.Download.Dir,

View file

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

213
internal/cmd/vpn.go Normal file
View file

@ -0,0 +1,213 @@
package cmd
import (
"context"
"fmt"
"net"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/vpn"
)
func newVPNCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "vpn",
Short: "Manage the managed-VPN split-tunnel for downloads",
Long: `Enable, disable, and inspect the managed VPN.
When enabled, the daemon fetches a WireGuard config from your TorrentClaw account
at startup and routes ONLY the torrent client's traffic (peers + trackers) through
an in-process WireGuard tunnel no root, no OS routing changes.
This is split-tunnel: your browser and other apps keep using your real IP. Only
your downloads are hidden behind the VPN server.
The VPN requires a PRO+ plan with the VPN add-on. Set it up at
https://torrentclaw.com/vpn and configure your other devices (phone, laptop) with
the OpenVPN credentials from your profile those don't share the agent's tunnel.`,
Example: ` unarr vpn status # is the tunnel up? which server?
unarr vpn enable # turn the managed VPN on
unarr vpn disable # turn it off`,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
cmd.AddCommand(newVPNStatusCmd(), newVPNEnableCmd(), newVPNDisableCmd())
return cmd
}
func newVPNStatusCmd() *cobra.Command {
var check bool
cmd := &cobra.Command{
Use: "status",
Short: "Show VPN configuration and live tunnel state",
Example: " unarr vpn status\n unarr vpn status --check # also verify your account is provisioned",
RunE: func(cmd *cobra.Command, args []string) error {
return runVPNStatus(check)
},
}
cmd.Flags().BoolVar(&check, "check", false, "query the API to verify the VPN is provisioned on your account")
return cmd
}
func runVPNStatus(check bool) error {
bold := color.New(color.Bold)
dim := color.New(color.FgHiBlack)
green := color.New(color.FgGreen)
yellow := color.New(color.FgYellow)
cyan := color.New(color.FgCyan)
cfg := loadConfig()
fmt.Println()
bold.Println(" Managed VPN")
fmt.Println()
// ── Configured mode ──
switch {
case cfg.Download.VPN.ConfigFile != "":
cyan.Println(" Mode: self-hosted (local config_file)")
fmt.Printf(" Config: %s\n", cfg.Download.VPN.ConfigFile)
case cfg.Download.VPN.Enabled:
cyan.Println(" Mode: managed (config fetched from your account)")
default:
dim.Println(" Mode: off")
fmt.Println()
dim.Println(" Enable with `unarr vpn enable` (needs a PRO+ plan with the VPN add-on).")
fmt.Println()
return nil
}
// ── Live tunnel state (from the daemon state file) ──
state := agent.ReadState()
alive := state != nil && isDaemonAlive(state)
fmt.Println()
switch {
case alive && state.VPNActive:
server := state.VPNServer
if host, _, err := net.SplitHostPort(server); err == nil && host != "" {
server = host
}
green.Println(" ✓ Tunnel ACTIVE — torrent traffic is routed through the VPN")
if server != "" {
fmt.Printf(" Exit server: %s\n", server)
}
case alive:
yellow.Println(" ⚠ Daemon is running but the tunnel is NOT up — downloads go in the clear.")
dim.Println(" Check `unarr daemon logs` for a [vpn] line. Common cause: no active")
dim.Println(" VPN on your account (set it up at https://torrentclaw.com/vpn).")
default:
dim.Println(" Daemon not running — start it (`unarr start`) to bring the tunnel up.")
}
// ── Optional live provisioning check ──
if check {
fmt.Println()
if cfg.Auth.APIKey == "" {
yellow.Println(" ⚠ No API key — run `unarr init` first.")
} else {
apiURL := cfg.Auth.APIURL
if apiURL == "" {
apiURL = "https://torrentclaw.com"
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
_, err := vpn.FetchConfig(ctx, apiURL, cfg.Auth.APIKey, "unarr/"+Version, cfg.Agent.ID, true)
cancel()
switch {
case err == nil:
green.Println(" ✓ Account provisioned — a VPN config is available.")
default:
yellow.Printf(" ⚠ %s\n", err)
}
}
}
// ── Split-tunnel reminder ──
fmt.Println()
dim.Println(" Split-tunnel: only your downloads use the VPN. Your browser and other")
dim.Println(" apps keep your real IP — that's by design. Use the OpenVPN credentials in")
dim.Println(" your profile to protect your other devices.")
fmt.Println()
return nil
}
func newVPNEnableCmd() *cobra.Command {
return &cobra.Command{
Use: "enable",
Short: "Turn the managed VPN on",
Example: " unarr vpn enable",
RunE: func(cmd *cobra.Command, args []string) error {
return setVPNEnabled(true)
},
}
}
func newVPNDisableCmd() *cobra.Command {
return &cobra.Command{
Use: "disable",
Short: "Turn the managed VPN off",
Example: " unarr vpn disable",
RunE: func(cmd *cobra.Command, args []string) error {
return setVPNEnabled(false)
},
}
}
func setVPNEnabled(enabled bool) error {
green := color.New(color.FgGreen)
yellow := color.New(color.FgYellow)
dim := color.New(color.FgHiBlack)
cfg := loadConfig()
if enabled && cfg.Auth.APIKey == "" {
return fmt.Errorf("no API key configured — run `unarr init` first (the managed VPN fetches its config from your account)")
}
if cfg.Download.VPN.Enabled == enabled {
fmt.Println()
dim.Printf(" VPN is already %s — nothing to do.\n", enabledWord(enabled))
fmt.Println()
return nil
}
cfg.Download.VPN.Enabled = enabled
configPath := config.FilePath()
if cfgFile != "" {
configPath = cfgFile
}
if err := config.Save(cfg, configPath); err != nil {
return fmt.Errorf("save config: %w", err)
}
appCfg = cfg
fmt.Println()
green.Printf(" ✓ Managed VPN %s.\n", enabledWord(enabled))
if enabled && cfg.Download.VPN.ConfigFile != "" {
yellow.Println(" ⚠ A config_file is set, so self-hosted mode takes precedence and the")
yellow.Println(" managed config from your account is ignored. Clear config_file to use it.")
}
// The tunnel is brought up once at daemon startup; a plain config reload does
// NOT (re)create it. Tell the user to restart the daemon if it's running.
if state := agent.ReadState(); state != nil && isDaemonAlive(state) {
fmt.Println()
dim.Println(" The daemon is running. Restart it for this to take effect:")
dim.Println(" unarr daemon restart")
}
fmt.Println()
return nil
}
func enabledWord(enabled bool) string {
if enabled {
return "enabled"
}
return "disabled"
}

View file

@ -1,106 +0,0 @@
package cmd
import (
"context"
"log"
"sync"
"github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/engine"
"github.com/torrentclaw/unarr/internal/library/mediainfo"
)
// webrtcRegistry tracks per-session cancel funcs for active custom WebRTC
// streams (engine.RunWebRTCStream goroutines). Each session lives only as
// long as its DataChannel; the registry exists so duplicate sync responses
// don't double-spawn the same session and so daemon shutdown can drain.
var webrtcRegistry = &webrtcSessionRegistry{
cancels: make(map[string]context.CancelFunc),
}
type webrtcSessionRegistry struct {
mu sync.Mutex
cancels map[string]context.CancelFunc
}
func (r *webrtcSessionRegistry) has(sessionID string) bool {
r.mu.Lock()
defer r.mu.Unlock()
_, ok := r.cancels[sessionID]
return ok
}
func (r *webrtcSessionRegistry) add(sessionID string, cancel context.CancelFunc) {
r.mu.Lock()
defer r.mu.Unlock()
r.cancels[sessionID] = cancel
}
func (r *webrtcSessionRegistry) remove(sessionID string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.cancels, sessionID)
}
// cancelAllWebRTCSessions cancels every running session. Called on daemon
// shutdown so pion peers and SSE consumers exit cleanly.
func cancelAllWebRTCSessions() {
webrtcRegistry.mu.Lock()
cancels := make([]context.CancelFunc, 0, len(webrtcRegistry.cancels))
for _, c := range webrtcRegistry.cancels {
cancels = append(cancels, c)
}
webrtcRegistry.cancels = make(map[string]context.CancelFunc)
webrtcRegistry.mu.Unlock()
for _, c := range cancels {
c()
}
}
// stdLogger is a tiny adapter so engine.RunWebRTCStream can log through the
// standard library logger without pulling in a logging dependency.
type stdLogger struct{}
func (stdLogger) Infof(format string, args ...any) { log.Printf(format, args...) }
func (stdLogger) Warnf(format string, args ...any) { log.Printf("WARN: "+format, args...) }
func (stdLogger) Errorf(format string, args ...any) { log.Printf("ERROR: "+format, args...) }
// buildTranscodeRuntime resolves the ffmpeg/ffprobe binaries + config knobs
// for the WebRTC streaming pipeline. Failure to resolve a binary returns a
// runtime with empty paths so engine.RunWebRTCStream falls back to
// passthrough — the user gets a clearer codec error from the browser than a
// daemon-side abort.
func buildTranscodeRuntime(ctx context.Context, cfg config.Config) engine.TranscodeRuntime {
if !cfg.Download.Transcode.Enabled {
return engine.TranscodeRuntime{Disabled: true}
}
ffmpegPath, errF := mediainfo.ResolveFFmpeg(cfg.Library.FFmpegPath)
ffprobePath, errP := mediainfo.ResolveFFprobe(cfg.Library.FFprobePath)
if errF != nil || errP != nil {
return engine.TranscodeRuntime{Disabled: true}
}
hw := engine.HWAccelNone
switch cfg.Download.Transcode.HWAccel {
case "auto":
hw = engine.DetectHWAccel(ctx, ffmpegPath)
case "nvenc":
hw = engine.HWAccelNVENC
case "qsv":
hw = engine.HWAccelQSV
case "vaapi":
hw = engine.HWAccelVAAPI
case "videotoolbox":
hw = engine.HWAccelVideoToolbox
case "none", "":
hw = engine.HWAccelNone
}
return engine.TranscodeRuntime{
FFmpegPath: ffmpegPath,
FFprobePath: ffprobePath,
HWAccel: hw,
Preset: cfg.Download.Transcode.Preset,
VideoBitrate: cfg.Download.Transcode.VideoBitrate,
AudioBitrate: cfg.Download.Transcode.AudioBitrate,
MaxHeight: cfg.Download.Transcode.MaxHeight,
}
}

View file

@ -7,6 +7,7 @@ import (
"runtime"
"strconv"
"strings"
"time"
"github.com/BurntSushi/toml"
)
@ -36,6 +37,11 @@ type AuthConfig struct {
type AgentConfig struct {
ID string `toml:"id"`
Name string `toml:"name"`
// Hash is a stable high-entropy label (hex) for the per-agent direct-TLS
// feature. Distinct from ID (a UUID that could be guessed/enumerated): the
// cert broker issues *.<hash>.agent.unarr.app and the web encodes the agent's
// IP into a hostname under that wildcard. Generated + persisted on first run.
Hash string `toml:"agent_hash,omitempty"`
}
type DownloadConfig struct {
@ -43,17 +49,51 @@ type DownloadConfig struct {
PreferredMethod string `toml:"preferred_method"`
PreferredQuality string `toml:"preferred_quality"` // "2160p", "1080p", "720p" — hint for auto-selection
MaxConcurrent int `toml:"max_concurrent"`
MinFreeDiskMB int `toml:"min_free_disk_mb"` // refuse a download if it would leave less than this free (reserve to keep the FS healthy); default 2048, 0 = disable
MaxDownloadSpeed string `toml:"max_download_speed"` // e.g. "10MB", "500KB", "0" = unlimited
MaxUploadSpeed string `toml:"max_upload_speed"` // e.g. "1MB", "0" = unlimited
// Seeding lifecycle (BitTorrent only). Off by default — the daemon leeches
// then drops the torrent. Enable to keep uploading after a download finishes;
// seeding stops at whichever target is hit first, or never if both are unset.
SeedEnabled bool `toml:"seed_enabled"` // keep uploading after completion (default: false)
SeedRatio float64 `toml:"seed_ratio"` // stop once uploaded/size reaches this ratio (0 = no ratio target)
SeedTime string `toml:"seed_time"` // stop after this long since completion, e.g. "24h" (0/"" = no time target)
MetadataTimeout string `toml:"metadata_timeout"` // e.g. "1h", "30m", "0" = unlimited (default: "0")
StallTimeout string `toml:"stall_timeout"` // e.g. "30m", "1h", "0" = unlimited (default: "30m")
ListenPort int `toml:"listen_port"` // fixed port for incoming peer connections (default: 42069, 0 = random)
StreamPort int `toml:"stream_port"` // fixed port for streaming HTTP server (default: 11818)
EnableUPnP bool `toml:"enable_upnp"` // map StreamPort to the WAN via UPnP/NAT-PMP (default: false; opt-in because it exposes the unauthenticated /stream + /hls endpoints to the public internet)
HTTPSStreamPort int `toml:"https_stream_port"` // HTTPS stream listener for direct valid-cert playback (default: 11819, 0 = disabled). Only serves once a certificate is present (agent-TLS feature).
EnableUPnP bool `toml:"enable_upnp"` // map StreamPort to the WAN via UPnP/NAT-PMP (default: false; opt-in)
// RequireStreamToken gates remote (non-loopback) /stream + /hls requests on a
// signed, short-lived token embedded in the URLs the agent reports. Default
// true (secure by default); loopback callers (local mpv/vlc) are always exempt.
// Set false only to debug a player that can't carry the token.
RequireStreamToken bool `toml:"require_stream_token"`
CORSExtraOrigins []string `toml:"cors_extra_origins"` // extra browser origins added on top of the baked-in allowlist (torrentclaw.com, app.torrentclaw.com, localhost:3030)
WebRTC WebRTCConfig `toml:"webrtc"`
Transcode TranscodeConfig `toml:"transcode"`
HLSCache HLSCacheConfig `toml:"hls_cache"`
VPN VPNConfig `toml:"vpn"`
Funnel FunnelConfig `toml:"funnel"`
}
// HLSCacheConfig controls the persistent HLS segment cache. A completed encode
// is kept on disk so a second play of the same file at the same quality skips
// ffmpeg entirely. Old entries are evicted (LRU) once the cache exceeds the
// size budget. Enabled by default — disable to save disk space at the cost of
// re-encoding every play.
type HLSCacheConfig struct {
Enabled bool `toml:"enabled"` // default: true
SizeGB int `toml:"size_gb"` // size budget in gigabytes; default: 5; minimum: 1
Dir string `toml:"dir"` // override storage path; default: ~/.cache/unarr/hls-cache
}
// FunnelConfig gates the optional CloudFlare Quick Tunnel that exposes the
// daemon's HLS server over a public HTTPS hostname (https://<random>.try
// cloudflare.com). Enabling it lets the web player on torrentclaw.com play
// from this daemon across any network without Tailscale or a public IP —
// the cost is that bytes proxy through CloudFlare's network. Off by default.
type FunnelConfig struct {
Enabled bool `toml:"enabled"`
}
// VPNConfig gates the managed-VPN add-on split-tunnel. When enabled, the daemon
@ -77,26 +117,31 @@ type VPNConfig struct {
type TranscodeConfig struct {
Enabled bool `toml:"enabled"` // master switch
HWAccel string `toml:"hw_accel"` // "auto" | "none" | "nvenc" | "qsv" | "vaapi" | "videotoolbox"
Preset string `toml:"preset"` // libx264 preset; "veryfast" by default
// Preset is the encoder speed/quality dial. Only used on software encode
// (libx264) — HW backends (NVENC/QSV/VAAPI/VideoToolbox) use vendor
// presets that don't share libx264's vocabulary and would be rejected
// by ffmpeg if passed here.
//
// Empty (default) → engine picks "superfast" — latency-biased, ~3 s
// first-play on 1080p source on a modern x86 CPU. Marginal quality loss
// at 5-25 Mbps target bitrates.
//
// For better quality at slower first-play (1-2 s slower per seg):
// "veryfast" — previous default; balanced
// "faster" — slight quality bump
// "fast" — meaningful quality bump
// "medium" — libx264 stock default; CPU-bound on 4K
// "slow" / "slower" / "veryslow" — only for batch encodes, not real-time HLS
//
// Or faster:
// "ultrafast" — lowest quality, fastest encode
Preset string `toml:"preset"`
VideoBitrate string `toml:"video_bitrate"` // e.g. "5M"
AudioBitrate string `toml:"audio_bitrate"` // e.g. "192k"
MaxHeight int `toml:"max_height"` // optional downscale cap (e.g. 720)
MaxConcurrent int `toml:"max_concurrent"` // safety cap on simultaneous transcoder processes
}
// WebRTCConfig opts the daemon into acting as a WebTorrent peer so browsers
// can fetch pieces via WebRTC data channels — required by the in-browser
// player on torrentclaw.com. Disabled by default; enabling implies upload
// is allowed for active torrents (browsers can't download otherwise).
type WebRTCConfig struct {
Enabled bool `toml:"enabled"` // master switch
Trackers []string `toml:"trackers"` // wss:// signaling trackers
STUNServers []string `toml:"stun_servers"` // stun:host:port
TURNServers []string `toml:"turn_servers"` // turn:host:port (no auth) — see TURNCredentials for authed
TURNUser string `toml:"turn_user"` // optional, applied to all TURNServers
TURNPass string `toml:"turn_pass"` // optional
}
type OrganizeConfig struct {
Enabled bool `toml:"enabled"`
MoviesDir string `toml:"movies_dir"`
@ -105,8 +150,32 @@ type OrganizeConfig struct {
type DaemonConfig struct {
StatusInterval string `toml:"status_interval"`
// AutoUpgrade gates the daemon's response to a server-flagged upgrade
// (set via the "Force update" button on the web). When true the daemon
// downloads + replaces the binary in-place and exits so the service
// supervisor respawns on the new version. When false the daemon only
// logs "new version available" and the operator must run `unarr update`
// manually. Default: true. Available since unarr 0.9.6.
AutoUpgrade *bool `toml:"auto_upgrade"`
// Downlink selects the server→agent realtime transport. "auto" (default)
// uses an SSE push connection with the long-poll wake as a buffering-tolerant
// fallback; "sse" forces SSE only (no fallback); "poll" forces the pre-0.14
// long-poll wake only. Empty = "auto". Available since unarr 0.14.0.
Downlink string `toml:"downlink"`
}
// AutoUpgradeEnabled returns the resolved AutoUpgrade flag — defaults to true
// when the user has not set it explicitly. Pointer-vs-bool because Go's
// zero-value bool would collapse "unset" and "false" together.
func (d DaemonConfig) AutoUpgradeEnabled() bool {
if d.AutoUpgrade == nil {
return true
}
return *d.AutoUpgrade
}
func boolPtr(v bool) *bool { return &v }
type NotificationsConfig struct {
Enabled bool `toml:"enabled"`
}
@ -121,11 +190,70 @@ type LibraryConfig struct {
ScanPath string `toml:"scan_path"` // remembered from last scan
Workers int `toml:"workers"` // concurrent ffprobe (default 8)
FFprobePath string `toml:"ffprobe_path"` // optional explicit path
FFmpegPath string `toml:"ffmpeg_path"` // optional explicit path (used by WebRTC streaming transcoder)
FFmpegPath string `toml:"ffmpeg_path"` // optional explicit path (used by the HLS streaming transcoder)
BackupDir string `toml:"backup_dir"` // for replaced files
AutoScan bool `toml:"auto_scan"` // enable daily auto-scan in daemon (default true)
ScanInterval string `toml:"scan_interval"` // e.g. "24h", "12h", "6h" (default "24h")
ScanInterval string `toml:"scan_interval"` // e.g. "1h", "6h", "24h" (default "1h", like Plex/Jellyfin periodic scans)
AllowDelete bool `toml:"allow_delete"` // allow web UI to request file deletion from disk
// Sidecar caching: extract text subtitles (WebVTT) and thumbnail frames once
// during the library scan and store them in a hidden ".unarr" dir next to the
// media file, so the stream handlers serve them instantly instead of running
// ffmpeg per request (and so huge remuxes don't hit the on-demand HTTP
// timeout). Both default true; disable to save the disk/CPU of pre-extraction.
CacheSubtitles bool `toml:"cache_subtitles"` // default true
CacheThumbnails bool `toml:"cache_thumbnails"` // default true
// Skip-segment detection: after each scan, find intro/credits ranges by
// comparing chromaprint audio fingerprints between episodes of a season
// (plus black-frame credits for movies) and submit them to the web so the
// player can offer "Skip intro" / "Skip credits". Cached per file; only
// new files do work. Default true.
SkipDetect bool `toml:"skip_detect"`
// Trickplay: at scan time, build ONE montage JPEG of frames sampled every
// Interval seconds (+ a JSON manifest), cached in .unarr next to the media.
// The web scrubber shows tiles from it — no live ffmpeg during playback, so
// no contention with the active stream (the cause of broken seekbar previews)
// — and the file panel picks a few positions from the same grid.
Trickplay TrickplayConfig `toml:"trickplay"`
// PrewarmMaxLoadRatio gates the heavy trickplay decode on system load: a sprite
// job only starts while the 1-min load average is ≤ this × NumCPU, so scan-time
// generation never saturates the machine or the NAS. Default 0.7; 0 falls back
// to the default. Linux-only (no load reading elsewhere → unthrottled).
PrewarmMaxLoadRatio float64 `toml:"prewarm_max_load_ratio"`
// On-demand / automatic subtitle fetching from the web (Wyzie aggregator,
// PRO). The web can always push a hot request (library/player button); this
// section only controls SCAN-TIME auto-fetch, which is OFF by default.
Subtitles SubtitlesConfig `toml:"subtitles"`
}
// SubtitlesConfig controls scan-time subtitle auto-fetch.
type SubtitlesConfig struct {
// AutoFetch: during a library scan, fetch missing subtitles for the preferred
// languages and write them as sidecars. Default false (opt-in).
AutoFetch bool `toml:"auto_fetch"`
// Languages: preferred subtitle languages (ISO 639-1) to ensure exist, in
// priority order, e.g. ["es", "en"]. Empty → auto-fetch does nothing.
Languages []string `toml:"languages"`
}
// TrickplayConfig controls scan-time trickplay sprite generation.
type TrickplayConfig struct {
Enabled bool `toml:"enabled"` // generate the sprite during scan (default true)
Interval string `toml:"interval"` // one frame per Interval, e.g. "10s" (default)
Width int `toml:"width"` // tile width px; height keeps aspect (default 240)
}
// IntervalSeconds parses Interval ("10s") to seconds, falling back to 10 on an
// empty/invalid value so a typo can't silently disable the sprite.
func (t TrickplayConfig) IntervalSeconds() float64 {
if d, err := time.ParseDuration(strings.TrimSpace(t.Interval)); err == nil && d > 0 {
return d.Seconds()
}
return 10
}
// Default returns a Config with sensible defaults. Used both for fresh
@ -145,24 +273,45 @@ func Default() Config {
Download: DownloadConfig{
PreferredMethod: "auto",
MaxConcurrent: 3,
MinFreeDiskMB: 2048, // 2 GiB reserve
StreamPort: 11818,
WebRTC: WebRTCConfig{
Enabled: true,
Trackers: []string{"wss://tracker.torrentclaw.com"},
STUNServers: []string{"stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"},
},
HTTPSStreamPort: 11819,
RequireStreamToken: true, // secure by default; loopback exempt
Transcode: TranscodeConfig{
Enabled: true,
HWAccel: "auto",
Preset: "veryfast",
// Empty preset → engine.ResolveEncoderProfile picks the
// latency-biased default ("superfast" on libx264). Override
// in config.toml when quality > first-start latency matters.
Preset: "",
AudioBitrate: "192k",
MaxConcurrent: 2,
},
Funnel: FunnelConfig{
// On by default so headless installs (NAS / Docker) get cross-network
// HTTPS playback without anyone having to terminal in. Users who
// don't want bytes proxied through CloudFlare can opt out with
// `unarr funnel off` (sets enabled=false in the TOML).
Enabled: true,
},
HLSCache: HLSCacheConfig{
// On by default — second play of a recently watched file at the
// same quality skips ffmpeg (instant start, near-zero CPU).
// Users can opt out (hls_cache.enabled=false) or shrink the
// budget (hls_cache.size_gb) when disk is tight.
Enabled: true,
SizeGB: 5,
},
},
Daemon: DaemonConfig{
// Pointer-to-true so Default() round-trips through TOML marshal
// as `auto_upgrade = true` instead of an omitted key — keeps the
// freshly-written config aligned with what README documents.
AutoUpgrade: boolPtr(true),
},
Organize: OrganizeConfig{
Enabled: true,
},
Daemon: DaemonConfig{},
Notifications: NotificationsConfig{
Enabled: true,
},
@ -172,8 +321,17 @@ func Default() Config {
},
Library: LibraryConfig{
AutoScan: true,
ScanInterval: "24h",
ScanInterval: "1h",
Workers: 8,
CacheSubtitles: true,
CacheThumbnails: true,
SkipDetect: true,
Trickplay: TrickplayConfig{
Enabled: true,
Interval: "10s",
Width: 240,
},
PrewarmMaxLoadRatio: 0.7,
},
}
}
@ -224,24 +382,47 @@ func applyDefaults(cfg *Config, meta toml.MetaData) {
if !meta.IsDefined("downloads", "max_concurrent") {
cfg.Download.MaxConcurrent = 3
}
if !meta.IsDefined("downloads", "min_free_disk_mb") {
cfg.Download.MinFreeDiskMB = 2048 // 2 GiB reserve so a download never fills the FS to 0
}
if !meta.IsDefined("downloads", "stream_port") {
cfg.Download.StreamPort = 11818
}
if !meta.IsDefined("downloads", "https_stream_port") {
cfg.Download.HTTPSStreamPort = 11819
}
if !meta.IsDefined("general", "country") {
cfg.General.Country = "US"
}
if !meta.IsDefined("downloads", "webrtc", "enabled") {
cfg.Download.WebRTC.Enabled = true
// Sidecar caching defaults ON for existing configs that predate these keys —
// it only adds small hidden files next to media and makes subs/thumbnails
// instant. Power users can set them false explicitly to opt out.
if !meta.IsDefined("library", "cache_subtitles") {
cfg.Library.CacheSubtitles = true
}
if !meta.IsDefined("downloads", "webrtc", "trackers") {
cfg.Download.WebRTC.Trackers = []string{"wss://tracker.torrentclaw.com"}
if !meta.IsDefined("library", "cache_thumbnails") {
cfg.Library.CacheThumbnails = true
}
if !meta.IsDefined("downloads", "webrtc", "stun_servers") {
cfg.Download.WebRTC.STUNServers = []string{
"stun:stun.l.google.com:19302",
"stun:stun1.l.google.com:19302",
if !meta.IsDefined("library", "skip_detect") {
cfg.Library.SkipDetect = true
}
// Trickplay defaults ON for configs predating these keys (small sidecar JPEG;
// makes the scrubber instant + contention-free). Explicit `enabled = false`
// is respected via meta.IsDefined.
if !meta.IsDefined("library", "trickplay", "enabled") {
cfg.Library.Trickplay.Enabled = true
}
if !meta.IsDefined("library", "trickplay", "interval") {
cfg.Library.Trickplay.Interval = "10s"
}
if !meta.IsDefined("library", "trickplay", "width") {
cfg.Library.Trickplay.Width = 240
}
// Load-gate defaults ON for configs predating the key, so an old install can't
// saturate the box with scan-time sprite generation.
if !meta.IsDefined("library", "prewarm_max_load_ratio") {
cfg.Library.PrewarmMaxLoadRatio = 0.7
}
if !meta.IsDefined("downloads", "transcode", "enabled") {
@ -251,7 +432,12 @@ func applyDefaults(cfg *Config, meta toml.MetaData) {
cfg.Download.Transcode.HWAccel = "auto"
}
if !meta.IsDefined("downloads", "transcode", "preset") {
cfg.Download.Transcode.Preset = "veryfast"
// Empty = let engine.ResolveEncoderProfile pick the latency-biased
// default ("superfast" on libx264). Users wanting better quality at
// slower first-play can override to "veryfast" / "fast" / "medium" in
// config.toml. Ignored when hw_accel picks NVENC/QSV/VAAPI/VideoToolbox
// (those have built-in vendor presets).
cfg.Download.Transcode.Preset = ""
}
if !meta.IsDefined("downloads", "transcode", "audio_bitrate") {
cfg.Download.Transcode.AudioBitrate = "192k"
@ -259,6 +445,12 @@ func applyDefaults(cfg *Config, meta toml.MetaData) {
if !meta.IsDefined("downloads", "transcode", "max_concurrent") {
cfg.Download.Transcode.MaxConcurrent = 2
}
// NOTE: Funnel default-ON only applies to fresh installs (no config file →
// Default() returns Funnel.Enabled=true straight off). When an existing
// config file lacks `[downloads.funnel]` entirely we intentionally do NOT
// flip it on here — that would silently route an upgraded operator's
// traffic through CloudFlare without their consent. They opt in with
// `unarr funnel on` whenever they're ready.
}
// Save writes config to the default or specified path using atomic write.

View file

@ -208,17 +208,6 @@ name = "Test"
t.Fatalf("Load failed: %v", err)
}
// WebRTC should be on by default for fresh installs.
if !cfg.Download.WebRTC.Enabled {
t.Error("WebRTC.Enabled should default to true when [downloads.webrtc] is absent")
}
if len(cfg.Download.WebRTC.Trackers) == 0 {
t.Error("WebRTC.Trackers should default to torrentclaw tracker when absent")
}
if len(cfg.Download.WebRTC.STUNServers) == 0 {
t.Error("WebRTC.STUNServers should default to public STUN list when absent")
}
// Transcode should be on by default.
if !cfg.Download.Transcode.Enabled {
t.Error("Transcode.Enabled should default to true when [downloads.transcode] is absent")
@ -226,8 +215,11 @@ name = "Test"
if cfg.Download.Transcode.HWAccel != "auto" {
t.Errorf("Transcode.HWAccel = %q, want auto", cfg.Download.Transcode.HWAccel)
}
if cfg.Download.Transcode.Preset != "veryfast" {
t.Errorf("Transcode.Preset = %q, want veryfast", cfg.Download.Transcode.Preset)
if cfg.Download.Transcode.Preset != "" {
// Default is now empty — engine.ResolveEncoderProfile picks
// "superfast" on libx264 for first-start latency. Users
// wanting better quality override in config.toml.
t.Errorf("Transcode.Preset = %q, want empty", cfg.Download.Transcode.Preset)
}
if cfg.Download.Transcode.MaxConcurrent != 2 {
t.Errorf("Transcode.MaxConcurrent = %d, want 2", cfg.Download.Transcode.MaxConcurrent)
@ -238,12 +230,9 @@ func TestLoadRespectsExplicitlyDisabledStreaming(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.toml")
// User explicitly opted out of webrtc + transcode. Defaults must NOT
// override them — that would silently re-enable features the user disabled.
os.WriteFile(path, []byte(`[downloads.webrtc]
enabled = false
[downloads.transcode]
// User explicitly opted out of transcode. Defaults must NOT override
// it — that would silently re-enable a feature the user disabled.
os.WriteFile(path, []byte(`[downloads.transcode]
enabled = false
`), 0o644)
@ -252,14 +241,60 @@ enabled = false
t.Fatalf("Load failed: %v", err)
}
if cfg.Download.WebRTC.Enabled {
t.Error("WebRTC.Enabled = true, want false (user explicitly disabled)")
}
if cfg.Download.Transcode.Enabled {
t.Error("Transcode.Enabled = true, want false (user explicitly disabled)")
}
}
func TestLoadSeedingDefaultsOff(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.toml")
// No [downloads] seeding keys — seeding must stay off by default.
os.WriteFile(path, []byte(`[auth]
api_key = "tc_x"
`), 0o644)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if cfg.Download.SeedEnabled {
t.Error("SeedEnabled should default to false")
}
if cfg.Download.SeedRatio != 0 {
t.Errorf("SeedRatio = %v, want 0", cfg.Download.SeedRatio)
}
if cfg.Download.SeedTime != "" {
t.Errorf("SeedTime = %q, want empty", cfg.Download.SeedTime)
}
}
func TestLoadSeedingExplicit(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.toml")
os.WriteFile(path, []byte(`[downloads]
seed_enabled = true
seed_ratio = 2.0
seed_time = "24h"
`), 0o644)
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load failed: %v", err)
}
if !cfg.Download.SeedEnabled {
t.Error("SeedEnabled = false, want true")
}
if cfg.Download.SeedRatio != 2.0 {
t.Errorf("SeedRatio = %v, want 2.0", cfg.Download.SeedRatio)
}
if cfg.Download.SeedTime != "24h" {
t.Errorf("SeedTime = %q, want 24h", cfg.Download.SeedTime)
}
}
func TestLoadInvalidTOML(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.toml")

View file

@ -38,6 +38,15 @@ func FilePath() string {
return filepath.Join(Dir(), "config.toml")
}
// LockPath returns the daemon single-instance lock file, alongside config.toml.
// Scoped to the config dir so a separate UNARR_CONFIG_DIR (e.g. a dev agent)
// gets its own lock and can run concurrently; two daemons sharing one config
// dir cannot — that's the case that causes cross-talk (same agentId/hash/secret
// racing each other).
func LockPath() string {
return filepath.Join(Dir(), "unarr.lock")
}
// DataDir returns the data directory for logs, cache, etc.
// - Linux: ~/.local/share/unarr
// - macOS: ~/Library/Application Support/unarr

View file

@ -23,6 +23,14 @@ func TestFilePath(t *testing.T) {
}
}
func TestLockPath(t *testing.T) {
t.Setenv("UNARR_CONFIG_DIR", "/custom/path")
path := LockPath()
if path != "/custom/path/unarr.lock" {
t.Errorf("LockPath() = %q, want /custom/path/unarr.lock", path)
}
}
func TestDataDir(t *testing.T) {
dir := DataDir()
if dir == "" {

View file

@ -27,6 +27,8 @@ var httpClient = &http.Client{
type DebridDownloader struct {
activeMu sync.Mutex
active map[string]context.CancelFunc
minFreeBytes int64 // disk reserve for the pre-flight space check (0 = reserve disabled)
}
// NewDebridDownloader creates a debrid downloader.
@ -36,6 +38,11 @@ func NewDebridDownloader() *DebridDownloader {
}
}
// SetMinFreeBytes sets the free-space reserve enforced before a download starts.
// Call once at construction; 0 disables the reserve (the size-vs-free check still
// runs). See CheckDiskSpace.
func (d *DebridDownloader) SetMinFreeBytes(n int64) { d.minFreeBytes = n }
func (d *DebridDownloader) Method() DownloadMethod { return MethodDebrid }
// Available returns true if the task has a direct HTTPS URL from the server.
@ -167,6 +174,12 @@ func (d *DebridDownloader) Download(ctx context.Context, task *Task, outputDir s
log.Printf("[%s] starting debrid download: %s", agent.ShortID(task.ID), fileName)
}
// Pre-flight disk-space guard on the bytes still to write (resume subtracts
// what's already on disk). Best-effort; ENOSPC stays the backstop.
if err := CheckDiskSpace(outputDir, totalBytes-startOffset, d.minFreeBytes); err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Dir(destPath), 0o755); err != nil {
return nil, fmt.Errorf("create directory: %w", err)
}

View file

@ -0,0 +1,63 @@
package engine
import (
"errors"
"fmt"
"log"
"github.com/torrentclaw/unarr/internal/agent"
)
// InsufficientDiskError is returned by CheckDiskSpace when a download's expected
// size (plus a reserve that keeps the filesystem healthy) won't fit in the free
// space of its target directory. The manager treats it as terminal — it does NOT
// fall back to another source (a different source would fill the same disk) and
// surfaces the message to the web as the task's error.
type InsufficientDiskError struct {
Dir string
Need int64 // bytes the download still needs to write
Free int64 // bytes currently free on Dir's filesystem
Reserve int64 // bytes to keep free after the download
}
func (e *InsufficientDiskError) Error() string {
return fmt.Sprintf(
"insufficient disk space in %s: need %s + %s reserve, only %s free",
e.Dir, formatBytes(e.Need), formatBytes(e.Reserve), formatBytes(e.Free),
)
}
// IsInsufficientDisk reports whether err is (or wraps) an InsufficientDiskError.
func IsInsufficientDisk(err error) bool {
var d *InsufficientDiskError
return errors.As(err, &d)
}
// CheckDiskSpace fails fast when dir's filesystem can't hold needBytes while
// keeping reserveBytes free. It's the pre-flight guard so a download never fills
// the disk to 0 mid-write (which corrupts the partial file and can wedge the OS).
//
// Best-effort by design: a non-positive needBytes (size unknown) or a failure to
// stat the filesystem returns nil rather than block a download on a guard we
// can't evaluate — the OS-level ENOSPC stays the backstop.
func CheckDiskSpace(dir string, needBytes, reserveBytes int64) error {
if needBytes <= 0 {
return nil // size unknown — nothing to check against
}
free, _, err := agent.DiskInfo(dir)
if err != nil {
log.Printf("[disk] free-space pre-flight skipped for %q: stat error: %v", dir, err)
return nil
}
if free <= 0 {
// Distinct from a stat error: DiskInfo succeeded but reports no free
// space. Don't block on a value we can't trust (0/negative) — log it so a
// genuinely-full disk is visible rather than masked as a generic skip.
log.Printf("[disk] free-space pre-flight skipped for %q: DiskInfo reported non-positive free (%d)", dir, free)
return nil
}
if free-needBytes < reserveBytes {
return &InsufficientDiskError{Dir: dir, Need: needBytes, Free: free, Reserve: reserveBytes}
}
return nil
}

View file

@ -0,0 +1,56 @@
package engine
import (
"path/filepath"
"strings"
"testing"
)
func TestCheckDiskSpace_Enough(t *testing.T) {
// A tiny need in a real temp dir (huge free space) → nil.
if err := CheckDiskSpace(t.TempDir(), 1024, 0); err != nil {
t.Errorf("expected nil for a 1 KiB need, got %v", err)
}
}
func TestCheckDiskSpace_Insufficient(t *testing.T) {
// Need more than any real disk has → InsufficientDiskError.
err := CheckDiskSpace(t.TempDir(), 1<<62, 0)
if err == nil {
t.Fatal("expected an error for an impossibly large need")
}
if !IsInsufficientDisk(err) {
t.Errorf("IsInsufficientDisk = false, want true (err=%v)", err)
}
if !strings.Contains(err.Error(), "insufficient disk space") {
t.Errorf("error message = %q, want it to mention insufficient disk space", err.Error())
}
}
func TestCheckDiskSpace_ReserveTriggers(t *testing.T) {
// Tiny need but an impossibly large reserve → free-need < reserve → error.
err := CheckDiskSpace(t.TempDir(), 1024, 1<<62)
if !IsInsufficientDisk(err) {
t.Errorf("expected insufficient when reserve exceeds free space, got %v", err)
}
}
func TestCheckDiskSpace_UnknownSize(t *testing.T) {
// need <= 0 means the size is unknown — the check must be skipped, even with
// an enormous reserve.
if err := CheckDiskSpace(t.TempDir(), 0, 1<<62); err != nil {
t.Errorf("need=0 must skip the check, got %v", err)
}
if err := CheckDiskSpace(t.TempDir(), -5, 1<<62); err != nil {
t.Errorf("negative need must skip the check, got %v", err)
}
}
func TestCheckDiskSpace_BadDirIsBestEffort(t *testing.T) {
// An unstat-able path → DiskInfo errors → best-effort nil (never block a
// download on a guard we can't evaluate; ENOSPC stays the backstop).
bad := filepath.Join(t.TempDir(), "does", "not", "exist")
if err := CheckDiskSpace(bad, 1<<40, 0); err != nil {
t.Errorf("unstat-able dir must skip the check, got %v", err)
}
}

View file

@ -0,0 +1,120 @@
package engine
import (
"context"
"log"
"os/exec"
"strconv"
"time"
)
// benchmarkRung is a candidate transcode-height ceiling plus the 16:9 frame
// size used to measure whether a software encoder sustains it.
type benchmarkRung struct {
height int
width int
}
// softwareBenchmarkRungs are tested high→low. The frame sizes match the real
// streaming output tiers; the H.264 level / macroblock math in hls.go is
// independent of what we measure here.
var softwareBenchmarkRungs = []benchmarkRung{
{height: 1080, width: 1920},
{height: 720, width: 1280},
{height: 480, width: 854},
}
// realtimeMarginSoftware is how much faster than realtime a synthetic encode
// must run before we call a rung "sustainable". 2.0× (not 1.5×) because the
// benchmark measures ONLY the encode of a low-entropy synthetic source and
// must cover two costs it never sees: (a) decoding the real source — software
// HEVC / 10-bit decode can rival the encode cost on its own — and (b) real
// content (film grain, motion) being far busier than testsrc2 for x264's
// rate-control + motion estimation. Erring high routes a borderline box's
// oversized sources to an external player (which works) instead of a
// stuttering transcode (which is the failure we're preventing).
const realtimeMarginSoftware = 2.0
// benchmarkClipSeconds is the synthetic clip length. Short enough that a
// capable host finishes the 1080p rung in well under a second, long enough to
// average out process spin-up.
const benchmarkClipSeconds = 3
// BenchmarkMaxTranscodeHeight returns the largest output height this host can
// software-transcode in real time, one of {1080,720,480}. Hardware encoders
// return 2160 WITHOUT benchmarking — NVENC/QSV/VAAPI/VideoToolbox all sustain
// 4K and a probe would only add startup latency.
//
// The point is the weak end. A low-power NAS or an old CPU can be
// ffmpeg-capable yet unable to keep up with a 1080p software encode, so the
// historical static 1080 ceiling makes the web side attempt a transcode that
// stutters. Measuring real throughput lets decideStreamPlan route oversized
// sources to an external player instead. Floors at 480: a box that can't
// sustain even that is barely functional, and 480-or-smaller sources transcode
// cheaply regardless — anything larger is already gated out by the 480 ceiling.
func BenchmarkMaxTranscodeHeight(ctx context.Context, ffmpegPath string, hw HWAccel) int {
if hw != HWAccelNone {
return 2160
}
if ffmpegPath == "" {
return 1080 // no benchmark possible; keep the historical default
}
measuredAny := false
for _, rung := range softwareBenchmarkRungs {
factor, ok := measureEncodeRealtimeFactor(ctx, ffmpegPath, rung)
if !ok {
// Probe couldn't run (timeout / exec error) — try a lighter rung
// rather than treat the failure as a measured "fast enough".
log.Printf("[transcode] encode benchmark: %dp probe failed — trying lower", rung.height)
continue
}
measuredAny = true
if factor >= realtimeMarginSoftware {
log.Printf("[transcode] encode benchmark: software ceiling %dp (%.1f× realtime)", rung.height, factor)
return rung.height
}
log.Printf("[transcode] encode benchmark: %dp only %.1f× realtime (<%.1f×) — trying lower", rung.height, factor, realtimeMarginSoftware)
}
if !measuredAny {
// No rung produced a measurement at all — the benchmark infrastructure
// failed (missing lavfi/testsrc2, ffmpeg wedged), NOT a slow host. Don't
// punish a possibly-capable box by flooring at 480; keep the historical
// default so behaviour is no worse than before the benchmark existed.
log.Printf("[transcode] encode benchmark: no rung could be measured (lavfi/ffmpeg issue) — keeping default 1080 ceiling")
return 1080
}
log.Printf("[transcode] encode benchmark: host can't sustain 480p software encode — flooring ceiling at 480 (oversized sources route to external)")
return 480
}
// measureEncodeRealtimeFactor encodes benchmarkClipSeconds of synthetic video
// at the rung's resolution using the real streaming encoder settings (libx264
// superfast, no B-frames) to /dev/null and returns clipDuration/wallTime — the
// realtime factor. ok=false when the probe couldn't run, so the caller skips
// rather than treating the failure as a fast result. Each probe is bounded so
// a wedged ffmpeg can't stall daemon startup.
func measureEncodeRealtimeFactor(ctx context.Context, ffmpegPath string, rung benchmarkRung) (float64, bool) {
// A 3 s superfast encode that takes longer than 6 s is <0.5× realtime —
// already far below the 2.0× bar — so capping here only kills genuinely
// hopeless rungs early and bounds worst-case startup blocking (3 rungs ×
// 6 s = 18 s) since this runs synchronously before the agent registers.
bctx, cancel := context.WithTimeout(ctx, 6*time.Second)
defer cancel()
size := strconv.Itoa(rung.width) + "x" + strconv.Itoa(rung.height)
args := []string{
"-hide_banner", "-nostats", "-loglevel", "error",
"-f", "lavfi",
"-i", "testsrc2=size=" + size + ":rate=24:duration=" + strconv.Itoa(benchmarkClipSeconds),
"-c:v", "libx264", "-preset", "superfast", "-threads", "0",
"-bf", "0", "-sc_threshold", "0",
"-f", "null", "-",
}
start := time.Now()
err := exec.CommandContext(bctx, ffmpegPath, args...).Run()
elapsed := time.Since(start)
if err != nil || elapsed <= 0 {
return 0, false
}
return float64(benchmarkClipSeconds) / elapsed.Seconds(), true
}

View file

@ -0,0 +1,52 @@
package engine
import (
"context"
"os/exec"
"testing"
)
func TestBenchmarkMaxTranscodeHeight_HardwareSkipsProbe(t *testing.T) {
// Hardware encoders return 2160 without touching ffmpeg — pass a bogus path
// to prove no subprocess runs.
for _, hw := range []HWAccel{HWAccelNVENC, HWAccelQSV, HWAccelVAAPI, HWAccelVideoToolbox} {
got := BenchmarkMaxTranscodeHeight(context.Background(), "/nonexistent/ffmpeg", hw)
if got != 2160 {
t.Errorf("hw=%s: got %d, want 2160", hw, got)
}
}
}
func TestBenchmarkMaxTranscodeHeight_NoFFmpegKeepsDefault(t *testing.T) {
if got := BenchmarkMaxTranscodeHeight(context.Background(), "", HWAccelNone); got != 1080 {
t.Errorf("empty ffmpeg path: got %d, want 1080 (historical default)", got)
}
}
func TestBenchmarkMaxTranscodeHeight_SoftwareReturnsValidRung(t *testing.T) {
ffmpeg, err := exec.LookPath("ffmpeg")
if err != nil {
t.Skip("ffmpeg not on PATH — software benchmark needs a real encoder")
}
got := BenchmarkMaxTranscodeHeight(context.Background(), ffmpeg, HWAccelNone)
switch got {
case 1080, 720, 480:
// any rung is valid; the exact one depends on the host's CPU.
default:
t.Errorf("software ceiling = %d, want one of {1080,720,480}", got)
}
}
func TestMeasureEncodeRealtimeFactor_RealEncoder(t *testing.T) {
ffmpeg, err := exec.LookPath("ffmpeg")
if err != nil {
t.Skip("ffmpeg not on PATH")
}
factor, ok := measureEncodeRealtimeFactor(context.Background(), ffmpeg, benchmarkRung{height: 480, width: 854})
if !ok {
t.Fatal("480p probe failed to run on a host with ffmpeg")
}
if factor <= 0 {
t.Errorf("realtime factor = %.2f, want > 0", factor)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,419 @@
package engine
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"sync"
"sync/atomic"
"time"
)
// HLSCache persists transcoded HLS segments per (source, quality, audio) so a
// second play of the same file at the same quality skips ffmpeg entirely.
//
// Layout on disk:
//
// {root}/{key}/init.mp4
// {root}/{key}/seg-0.m4s
// {root}/{key}/seg-N.m4s
// {root}/{key}/.complete
//
// Atomicity: the .complete marker is written only when ffmpeg exits 0 AND all
// segments are on disk. A dir without .complete is treated as a partial run —
// next session can reuse the segments already present, ffmpeg fills the gaps.
//
// Concurrency: Pin/Unpin increments a ref counter per key so the LRU sweeper
// never evicts a directory that an active session is reading from.
type HLSCache struct {
root string
maxBytes int64
mu sync.Mutex
refs map[string]int
writers map[string]bool // exclusive ffmpeg writer per key; nil entries are absent
// Counters surfaced via Stats() — useful for /api/internal/agent/cache-stats
// and for the sweeper's daily log line. atomic so RecordHit/RecordMiss are
// safe to call from any goroutine without taking the cache mutex.
hits atomic.Uint64
misses atomic.Uint64
}
const (
hlsCacheCompleteMarker = ".complete"
// hlsCacheMinBudgetGB clamps absurd / zero / negative SizeGB values to
// a sane floor. NOT a guarantee that any single encode fits — a long
// 4K HEVC re-encode can exceed it. Operators should set size_gb based
// on their actual workload.
hlsCacheMinBudgetGB = 1
// hlsCacheStartupOrphanAge: directories without .complete older than
// this are removed on cache startup. Long enough that a daemon crash
// during an in-progress encode (which legitimately leaves a partial
// dir) doesn't get nuked too aggressively if the daemon restarts fast.
hlsCacheStartupOrphanAge = 10 * time.Minute
)
// NewHLSCache creates the cache rooted at the given dir with a size budget in
// gigabytes. A budget < hlsCacheMinBudgetGB is clamped up so a single play
// doesn't get instantly evicted mid-stream.
func NewHLSCache(root string, sizeGB int) (*HLSCache, error) {
if root == "" {
return nil, errors.New("hls_cache: empty root")
}
if sizeGB < hlsCacheMinBudgetGB {
sizeGB = hlsCacheMinBudgetGB
}
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, fmt.Errorf("hls_cache: mkdir root: %w", err)
}
c := &HLSCache{
root: root,
maxBytes: int64(sizeGB) * 1024 * 1024 * 1024,
refs: make(map[string]int),
writers: make(map[string]bool),
}
// Reap dirs left over from a crashed encode. A dir without .complete that
// hasn't been touched recently was almost certainly orphaned by an
// ungraceful daemon exit — keeping it just feeds the unbounded growth
// pattern the hourly LRU is too slow to contain.
if removed, err := c.cleanStartupOrphans(); err != nil {
log.Printf("[hls_cache] startup orphan cleanup: %v", err)
} else if removed > 0 {
log.Printf("[hls_cache] startup: removed %d orphan dir(s) without .complete", removed)
}
return c, nil
}
// cleanStartupOrphans removes cache subdirectories that lack a .complete
// marker AND haven't been modified within hlsCacheStartupOrphanAge. Called
// once at construction. Safe at startup because no sessions are active yet,
// so Pin can't race with us.
func (c *HLSCache) cleanStartupOrphans() (int, error) {
entries, err := os.ReadDir(c.root)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, err
}
cutoff := time.Now().Add(-hlsCacheStartupOrphanAge)
removed := 0
for _, e := range entries {
if !e.IsDir() {
continue
}
dir := filepath.Join(c.root, e.Name())
if _, err := os.Stat(filepath.Join(dir, hlsCacheCompleteMarker)); err == nil {
continue // sealed, keep
}
info, err := e.Info()
if err != nil {
continue
}
if info.ModTime().After(cutoff) {
continue // too recent — might be a daemon that just restarted mid-encode
}
if err := os.RemoveAll(dir); err == nil {
removed++
}
}
return removed, nil
}
// TryAcquireWriter attempts to claim exclusive ffmpeg-write access to a key.
// Returns true on success — the caller is then responsible for ReleaseWriter
// when ffmpeg exits / fails. Returns false if another session is already
// writing this key, in which case the caller must fall back to a private
// per-session tmpdir (no caching for that session).
func (c *HLSCache) TryAcquireWriter(key string) bool {
c.mu.Lock()
defer c.mu.Unlock()
if c.writers[key] {
return false
}
c.writers[key] = true
return true
}
// ReleaseWriter releases the writer claim acquired via TryAcquireWriter.
// Idempotent on unknown keys.
func (c *HLSCache) ReleaseWriter(key string) {
c.mu.Lock()
delete(c.writers, key)
c.mu.Unlock()
}
// KeyFor derives a stable cache key for (source, quality, audioIndex). Using
// the absolute source path means renaming a file invalidates the cache, which
// is correct — segment content is tied to the encoded source.
func (c *HLSCache) KeyFor(sourcePath, quality string, audioIndex, burnSubtitleIndex int) string {
abs, err := filepath.Abs(sourcePath)
if err != nil {
abs = sourcePath
}
h := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d|%d", abs, quality, audioIndex, burnSubtitleIndex)))
return hex.EncodeToString(h[:8]) // 16 hex chars — collision-safe enough for per-host cache
}
// KeyForID derives a cache key from a caller-supplied stable identity instead
// of a filesystem path (hueco #2 / 2b). Used for debrid HLS-from-URL sessions:
// the debrid direct URL is re-resolved per play and would never cache-hit, so
// we key by the torrent info_hash — the same content always maps to the same
// key across plays. NOT run through filepath.Abs (an id/URL is not a path).
func (c *HLSCache) KeyForID(id, quality string, audioIndex, burnSubtitleIndex int) string {
h := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d|%d", id, quality, audioIndex, burnSubtitleIndex)))
return hex.EncodeToString(h[:8])
}
// DirFor returns the on-disk directory for a cache key. Caller is responsible
// for creating it.
func (c *HLSCache) DirFor(key string) string {
return filepath.Join(c.root, key)
}
// HasComplete returns true when the .complete marker is present, meaning the
// directory holds a full set of segments from a successful encode.
func (c *HLSCache) HasComplete(key string) bool {
if _, err := os.Stat(filepath.Join(c.DirFor(key), hlsCacheCompleteMarker)); err == nil {
return true
}
return false
}
// MarkComplete writes the .complete marker. Call only after verifying ffmpeg
// exited cleanly AND every expected segment is on disk. The dir must already
// exist — StartHLSSession created it on the writer path.
func (c *HLSCache) MarkComplete(key string) error {
return os.WriteFile(filepath.Join(c.DirFor(key), hlsCacheCompleteMarker), nil, 0o644)
}
// RecordHit increments the hit counter; called by StartHLSSession on a
// cache-HIT path.
func (c *HLSCache) RecordHit() { c.hits.Add(1) }
// RecordMiss increments the miss counter; called when a session has to
// encode from scratch (or fails an integrity check on a stale HIT).
func (c *HLSCache) RecordMiss() { c.misses.Add(1) }
// CacheStats is a snapshot of the cache's runtime counters + on-disk size.
// The size fields are best-effort (computed via dirSize) so callers paying
// for them should cache the result, not poll in a hot loop.
type CacheStats struct {
Hits uint64
Misses uint64
EntryCount int
TotalBytes int64
}
// Stats returns a snapshot of the cache counters and size. Walks the root
// to total disk usage — O(N segments). Call at most every few minutes.
func (c *HLSCache) Stats() CacheStats {
s := CacheStats{
Hits: c.hits.Load(),
Misses: c.misses.Load(),
}
entries, err := os.ReadDir(c.root)
if err != nil {
return s
}
for _, e := range entries {
if !e.IsDir() {
continue
}
size, err := dirSize(filepath.Join(c.root, e.Name()))
if err != nil {
continue
}
s.EntryCount++
s.TotalBytes += size
}
return s
}
// hitRatePercent returns the current hit/(hit+miss) percentage rounded to
// the nearest int; 0 when no calls have been recorded.
func (c *HLSCache) hitRatePercent() int {
h := c.hits.Load()
m := c.misses.Load()
total := h + m
if total == 0 {
return 0
}
return int((h*100 + total/2) / total)
}
// VerifyComplete checks that the .complete marker is present AND the
// essential files (init.mp4 + last segment) exist with non-zero size. A
// dir that passes HasComplete but fails VerifyComplete is treated as
// corrupted — typically external `rm` or a partial-disk-failure scenario.
// When it returns false, callers should Invalidate and re-encode.
func (c *HLSCache) VerifyComplete(key string, segmentCount int) bool {
if !c.HasComplete(key) {
return false
}
dir := c.DirFor(key)
if fi, err := os.Stat(filepath.Join(dir, "video", "init.mp4")); err != nil || fi.Size() == 0 {
return false
}
if segmentCount > 0 {
lastSeg := filepath.Join(dir, "video", fmt.Sprintf("seg-%d.m4s", segmentCount-1))
if fi, err := os.Stat(lastSeg); err != nil || fi.Size() == 0 {
return false
}
}
return true
}
// Pin increments the ref counter for a key. The sweeper checks this before
// evicting, so a pinned dir is safe even if its mtime is old.
func (c *HLSCache) Pin(key string) {
c.mu.Lock()
c.refs[key]++
c.mu.Unlock()
}
// Unpin decrements; safe to call on unknown keys (no-op).
func (c *HLSCache) Unpin(key string) {
c.mu.Lock()
if c.refs[key] > 0 {
c.refs[key]--
if c.refs[key] == 0 {
delete(c.refs, key)
}
}
c.mu.Unlock()
}
func (c *HLSCache) isPinned(key string) bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.refs[key] > 0
}
// Touch updates the directory mtime so LRU picks fresher entries as recently
// used. Called when a session starts reading from a cached dir.
func (c *HLSCache) Touch(key string) error {
dir := c.DirFor(key)
now := time.Now()
return os.Chtimes(dir, now, now)
}
// Sweep enforces the size budget by deleting the least-recently-used cache
// dirs (ignoring pinned ones) until the total size is at or below maxBytes.
// Returns the number of bytes freed.
func (c *HLSCache) Sweep() (int64, error) {
entries, err := os.ReadDir(c.root)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, fmt.Errorf("hls_cache: read root: %w", err)
}
type item struct {
key string
path string
size int64
mtime time.Time
}
items := make([]item, 0, len(entries))
var total, pinned int64
for _, e := range entries {
if !e.IsDir() {
continue
}
info, err := e.Info()
if err != nil {
continue
}
key := e.Name()
path := filepath.Join(c.root, key)
size, err := dirSize(path)
if err != nil {
continue
}
items = append(items, item{key: key, path: path, size: size, mtime: info.ModTime()})
total += size
if c.isPinned(key) {
pinned += size
}
}
if total <= c.maxBytes {
return 0, nil
}
if pinned >= c.maxBytes {
// Every pinned byte already exceeds the budget — even evicting
// every unpinned dir won't bring us under. Warn loudly so the
// operator knows to bump size_gb (or kill the long-running session).
log.Printf("[hls_cache] warn: pinned bytes (%.1f MB) exceed budget (%.1f MB) — cannot enforce limit until sessions release",
float64(pinned)/(1024*1024), float64(c.maxBytes)/(1024*1024))
return 0, nil
}
// Oldest first.
sort.Slice(items, func(i, j int) bool {
return items[i].mtime.Before(items[j].mtime)
})
var freed int64
for _, it := range items {
if total-freed <= c.maxBytes {
break
}
if c.isPinned(it.key) {
continue
}
if err := os.RemoveAll(it.path); err != nil {
log.Printf("[hls_cache] evict %s failed: %v", it.key, err)
continue
}
log.Printf("[hls_cache] evicted %s (%.1f MB, age %s)",
it.key, float64(it.size)/(1024*1024), time.Since(it.mtime).Round(time.Second))
freed += it.size
}
return freed, nil
}
// StartSweeper kicks off the LRU sweeper goroutine. Cancels on ctx done.
// In addition to enforcing the size budget, logs a daily summary of hit-rate
// + disk usage so operators can see the cache's value at a glance.
func (c *HLSCache) StartSweeper(ctx context.Context, interval time.Duration) {
if interval <= 0 {
interval = time.Hour
}
go func() {
t := time.NewTicker(interval)
defer t.Stop()
statsTick := time.NewTicker(24 * time.Hour)
defer statsTick.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if _, err := c.Sweep(); err != nil {
log.Printf("[hls_cache] sweep error: %v", err)
}
case <-statsTick.C:
s := c.Stats()
log.Printf("[hls_cache] day-stats: hits=%d misses=%d ratio=%d%% entries=%d size=%.1fMB",
s.Hits, s.Misses, c.hitRatePercent(), s.EntryCount,
float64(s.TotalBytes)/(1024*1024))
}
}
}()
}
// Invalidate removes a cache entry — used when ffmpeg fails to encode the
// source so we don't reuse a half-written dir next time.
func (c *HLSCache) Invalidate(key string) error {
return os.RemoveAll(c.DirFor(key))
}

View file

@ -0,0 +1,134 @@
//go:build smoke
package engine
import (
"context"
"os/exec"
"path/filepath"
"testing"
"time"
)
// TestHLSCacheSmoke exercises the end-to-end cache flow against real ffmpeg:
// - First session encodes a 5s test pattern; expect MISS, ffmpeg runs,
// .complete written, MarkComplete logs.
// - Second session for identical (source, quality, audio); expect HIT,
// no ffmpeg, instant Start.
//
// Build tag `smoke` keeps it out of the default `go test ./...` run because
// it depends on a working ffmpeg/ffprobe and takes ~510 s.
//
// go test -tags=smoke -run TestHLSCacheSmoke -v ./internal/engine/
func TestHLSCacheSmoke(t *testing.T) {
ffmpeg, err := exec.LookPath("ffmpeg")
if err != nil {
t.Skipf("ffmpeg not on PATH: %v", err)
}
ffprobe, err := exec.LookPath("ffprobe")
if err != nil {
t.Skipf("ffprobe not on PATH: %v", err)
}
tmp := t.TempDir()
source := filepath.Join(tmp, "source.mp4")
t.Logf("generating 5 s test pattern → %s", source)
if out, err := exec.Command(ffmpeg,
"-y", "-loglevel", "error",
"-f", "lavfi", "-i", "testsrc=duration=5:size=640x480:rate=30",
"-f", "lavfi", "-i", "sine=frequency=1000:duration=5",
"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p",
"-c:a", "aac",
source,
).CombinedOutput(); err != nil {
t.Fatalf("ffmpeg generate: %v\n%s", err, out)
}
cacheRoot := filepath.Join(tmp, "cache")
cache, err := NewHLSCache(cacheRoot, 1)
if err != nil {
t.Fatalf("NewHLSCache: %v", err)
}
cfg := HLSSessionConfig{
SessionID: "smoke1",
SourcePath: source,
FileName: "source.mp4",
Quality: "720p",
AudioIndex: 0,
Transcode: TranscodeRuntime{
FFmpegPath: ffmpeg,
FFprobePath: ffprobe,
Preset: "ultrafast",
},
Cache: cache,
}
// First run — expect MISS, ffmpeg runs.
t.Log("session 1: expect MISS")
t0 := time.Now()
s1, err := StartHLSSession(context.Background(), cfg)
if err != nil {
t.Fatalf("StartHLSSession #1: %v", err)
}
if s1.fromCache {
t.Fatal("session 1 reported cache HIT on a fresh cache")
}
// Wait for all segments to land. 5 s source @ 4 s segments → 2 segments.
deadline := time.Now().Add(60 * time.Second)
for {
s1.readyMu.Lock()
ready := s1.readyMax
exited := s1.exited
s1.readyMu.Unlock()
if ready >= s1.segmentCount-1 && exited {
break
}
if time.Now().After(deadline) {
_ = s1.Close()
t.Fatalf("session 1 didn't finish in 60 s (readyMax=%d/%d, exited=%v)",
ready, s1.segmentCount-1, exited)
}
time.Sleep(100 * time.Millisecond)
}
if err := s1.Close(); err != nil {
t.Fatalf("Close #1: %v", err)
}
encodeDur := time.Since(t0)
t.Logf("session 1: MISS completed in %s", encodeDur.Round(time.Millisecond))
key := cache.KeyFor(source, "720p", 0, -1)
if !cache.HasComplete(key) {
t.Fatalf("cache.HasComplete(%s) is false after successful encode", key)
}
// Second run — expect HIT, no ffmpeg.
t.Log("session 2: expect HIT")
cfg.SessionID = "smoke2"
t1 := time.Now()
s2, err := StartHLSSession(context.Background(), cfg)
if err != nil {
t.Fatalf("StartHLSSession #2: %v", err)
}
if !s2.fromCache {
t.Fatal("session 2 should have reported cache HIT")
}
if s2.cmd != nil {
t.Fatal("session 2 should not have spawned ffmpeg (s.cmd != nil)")
}
hitDur := time.Since(t1)
t.Logf("session 2: HIT in %s (%.1f× faster than MISS)",
hitDur.Round(time.Millisecond), float64(encodeDur)/float64(hitDur))
if hitDur > 500*time.Millisecond {
t.Errorf("HIT path too slow: %s — expected <500 ms", hitDur)
}
if err := s2.Close(); err != nil {
t.Fatalf("Close #2: %v", err)
}
// After the HIT session closes, the cache dir + .complete must still exist.
if !cache.HasComplete(key) {
t.Fatal(".complete disappeared after HIT session closed")
}
}

View file

@ -0,0 +1,364 @@
package engine
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
"time"
)
func newTestCache(t *testing.T, sizeGB int) *HLSCache {
t.Helper()
root := t.TempDir()
c, err := NewHLSCache(root, sizeGB)
if err != nil {
t.Fatalf("NewHLSCache: %v", err)
}
return c
}
func TestKeyForStable(t *testing.T) {
c := newTestCache(t, 1)
k1 := c.KeyFor("/a/b/movie.mkv", "1080p", 0, -1)
k2 := c.KeyFor("/a/b/movie.mkv", "1080p", 0, -1)
if k1 != k2 {
t.Fatalf("expected stable keys, got %q vs %q", k1, k2)
}
if c.KeyFor("/a/b/movie.mkv", "720p", 0, -1) == k1 {
t.Fatal("quality should change key")
}
if c.KeyFor("/a/b/movie.mkv", "1080p", 1, -1) == k1 {
t.Fatal("audio index should change key")
}
if c.KeyFor("/a/b/movie.mkv", "1080p", 0, 2) == k1 {
t.Fatal("burn subtitle index should change key")
}
if c.KeyFor("/x/y/other.mkv", "1080p", 0, -1) == k1 {
t.Fatal("path should change key")
}
}
func TestMarkCompleteAndHas(t *testing.T) {
c := newTestCache(t, 1)
key := "abc123"
if c.HasComplete(key) {
t.Fatal("fresh cache should not report complete")
}
// Production callers create the dir during StartHLSSession; MarkComplete
// trusts that invariant and fails if the dir was wiped meanwhile.
if err := os.MkdirAll(c.DirFor(key), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := c.MarkComplete(key); err != nil {
t.Fatalf("MarkComplete: %v", err)
}
if !c.HasComplete(key) {
t.Fatal("after MarkComplete, HasComplete must be true")
}
}
func TestMarkCompleteFailsWithoutDir(t *testing.T) {
c := newTestCache(t, 1)
if err := c.MarkComplete("never-created"); err == nil {
t.Fatal("MarkComplete should error when dir doesn't exist")
}
}
func TestPinPreventsEviction(t *testing.T) {
c := newTestCache(t, 1) // 1 GB budget, but min clamp keeps it usable
c.maxBytes = 1024 // squeeze budget for the test
// Write two entries past the budget.
for i, key := range []string{"old", "new"} {
dir := c.DirFor(key)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
path := filepath.Join(dir, "seg.bin")
if err := os.WriteFile(path, make([]byte, 800), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
now := time.Now().Add(time.Duration(i) * time.Hour) // "old" mtime < "new"
_ = os.Chtimes(dir, now, now)
}
c.Pin("old") // protect the older one
freed, err := c.Sweep()
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if freed == 0 {
t.Fatal("expected some eviction")
}
if _, err := os.Stat(c.DirFor("old")); err != nil {
t.Fatal("pinned 'old' was evicted")
}
if _, err := os.Stat(c.DirFor("new")); err == nil {
t.Fatal("'new' should have been evicted to make room")
}
}
func TestSweepNoOpUnderBudget(t *testing.T) {
c := newTestCache(t, 1)
dir := c.DirFor("small")
_ = os.MkdirAll(dir, 0o755)
_ = os.WriteFile(filepath.Join(dir, "x"), []byte("tiny"), 0o644)
freed, err := c.Sweep()
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if freed != 0 {
t.Fatalf("expected 0 freed under budget, got %d", freed)
}
if _, err := os.Stat(dir); err != nil {
t.Fatal("under-budget entry was wrongly evicted")
}
}
func TestSweepEmptyRoot(t *testing.T) {
c := newTestCache(t, 1)
freed, err := c.Sweep()
if err != nil {
t.Fatalf("Sweep empty: %v", err)
}
if freed != 0 {
t.Fatalf("freed=%d, want 0", freed)
}
}
func TestInvalidateRemovesDir(t *testing.T) {
c := newTestCache(t, 1)
key := "drop"
dir := c.DirFor(key)
_ = os.MkdirAll(dir, 0o755)
_ = os.WriteFile(filepath.Join(dir, "x"), []byte("y"), 0o644)
if err := c.Invalidate(key); err != nil {
t.Fatalf("Invalidate: %v", err)
}
if _, err := os.Stat(dir); err == nil {
t.Fatal("dir still present after Invalidate")
}
}
func TestTouchUpdatesMtime(t *testing.T) {
c := newTestCache(t, 1)
key := "touch"
dir := c.DirFor(key)
_ = os.MkdirAll(dir, 0o755)
old := time.Now().Add(-2 * time.Hour)
_ = os.Chtimes(dir, old, old)
if err := c.Touch(key); err != nil {
t.Fatalf("Touch: %v", err)
}
info, err := os.Stat(dir)
if err != nil {
t.Fatalf("stat: %v", err)
}
if !info.ModTime().After(old.Add(time.Minute)) {
t.Fatalf("mtime not refreshed: %v", info.ModTime())
}
}
func TestPinUnpinSymmetry(t *testing.T) {
c := newTestCache(t, 1)
c.Pin("k")
c.Pin("k")
if !c.isPinned("k") {
t.Fatal("Pin twice should leave pinned")
}
c.Unpin("k")
if !c.isPinned("k") {
t.Fatal("Unpin once should keep pinned (refs=1)")
}
c.Unpin("k")
if c.isPinned("k") {
t.Fatal("Unpin twice should drop pin")
}
c.Unpin("k") // safe no-op
}
func TestConcurrentPinUnpin(t *testing.T) {
c := newTestCache(t, 1)
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.Pin("race")
time.Sleep(time.Microsecond)
c.Unpin("race")
}()
}
wg.Wait()
if c.isPinned("race") {
t.Fatal("refs leaked")
}
}
func TestSweeperLoopExits(t *testing.T) {
c := newTestCache(t, 1)
ctx, cancel := context.WithCancel(context.Background())
c.StartSweeper(ctx, 10*time.Millisecond)
time.Sleep(30 * time.Millisecond)
cancel()
// If StartSweeper doesn't exit on cancel the test would leak a goroutine;
// the leak detector in the test runner will surface it.
time.Sleep(20 * time.Millisecond)
}
func TestMinBudgetClamp(t *testing.T) {
root := t.TempDir()
c, err := NewHLSCache(root, 0) // below floor
if err != nil {
t.Fatalf("NewHLSCache: %v", err)
}
if c.maxBytes != int64(hlsCacheMinBudgetGB)*1024*1024*1024 {
t.Fatalf("budget not clamped to min: got %d", c.maxBytes)
}
}
func TestTryAcquireWriterExclusive(t *testing.T) {
c := newTestCache(t, 1)
if !c.TryAcquireWriter("k") {
t.Fatal("first acquire should succeed")
}
if c.TryAcquireWriter("k") {
t.Fatal("second acquire for same key must fail")
}
if !c.TryAcquireWriter("other") {
t.Fatal("different key should not conflict")
}
c.ReleaseWriter("k")
if !c.TryAcquireWriter("k") {
t.Fatal("acquire after release should succeed")
}
c.ReleaseWriter("k")
c.ReleaseWriter("k") // idempotent
}
func TestStartupOrphanCleanup(t *testing.T) {
root := t.TempDir()
// Pre-seed: one sealed dir + one orphan old enough + one orphan fresh.
sealed := filepath.Join(root, "sealed")
_ = os.MkdirAll(sealed, 0o755)
_ = os.WriteFile(filepath.Join(sealed, hlsCacheCompleteMarker), nil, 0o644)
staleOrphan := filepath.Join(root, "stale_orphan")
_ = os.MkdirAll(staleOrphan, 0o755)
old := time.Now().Add(-2 * hlsCacheStartupOrphanAge)
_ = os.Chtimes(staleOrphan, old, old)
freshOrphan := filepath.Join(root, "fresh_orphan")
_ = os.MkdirAll(freshOrphan, 0o755)
if _, err := NewHLSCache(root, 1); err != nil {
t.Fatalf("NewHLSCache: %v", err)
}
if _, err := os.Stat(sealed); err != nil {
t.Fatal("sealed dir was wrongly removed")
}
if _, err := os.Stat(staleOrphan); err == nil {
t.Fatal("stale orphan should have been removed at startup")
}
if _, err := os.Stat(freshOrphan); err != nil {
t.Fatal("fresh orphan should be kept (might be a mid-restart encode)")
}
}
func TestHitMissCounters(t *testing.T) {
c := newTestCache(t, 1)
if s := c.Stats(); s.Hits != 0 || s.Misses != 0 {
t.Fatalf("fresh cache stats not zero: %+v", s)
}
c.RecordHit()
c.RecordHit()
c.RecordMiss()
s := c.Stats()
if s.Hits != 2 || s.Misses != 1 {
t.Fatalf("counters wrong: %+v", s)
}
// 2/3 = 67%
if got := c.hitRatePercent(); got != 67 {
t.Fatalf("hitRatePercent=%d, want 67", got)
}
}
func TestStatsEntryCount(t *testing.T) {
c := newTestCache(t, 1)
for _, k := range []string{"a", "b", "c"} {
dir := c.DirFor(k)
_ = os.MkdirAll(dir, 0o755)
_ = os.WriteFile(filepath.Join(dir, "x"), []byte("hello"), 0o644)
}
s := c.Stats()
if s.EntryCount != 3 {
t.Fatalf("EntryCount=%d, want 3", s.EntryCount)
}
if s.TotalBytes != 15 {
t.Fatalf("TotalBytes=%d, want 15", s.TotalBytes)
}
}
func TestVerifyCompleteRejectsMissingFiles(t *testing.T) {
c := newTestCache(t, 1)
key := "v"
dir := c.DirFor(key)
_ = os.MkdirAll(filepath.Join(dir, "video"), 0o755)
// No .complete yet → reject.
if c.VerifyComplete(key, 2) {
t.Fatal("VerifyComplete should reject without .complete")
}
// Mark complete but no files → reject.
if err := c.MarkComplete(key); err != nil {
t.Fatalf("MarkComplete: %v", err)
}
if c.VerifyComplete(key, 2) {
t.Fatal("VerifyComplete should reject when init.mp4 missing")
}
// Write init.mp4, last seg missing → reject.
_ = os.WriteFile(filepath.Join(dir, "video", "init.mp4"), []byte("..."), 0o644)
if c.VerifyComplete(key, 2) {
t.Fatal("VerifyComplete should reject when last segment missing")
}
// Write last seg → pass.
_ = os.WriteFile(filepath.Join(dir, "video", "seg-1.m4s"), []byte("..."), 0o644)
if !c.VerifyComplete(key, 2) {
t.Fatal("VerifyComplete should pass with all files present")
}
// Zero-size last seg → reject.
_ = os.WriteFile(filepath.Join(dir, "video", "seg-1.m4s"), nil, 0o644)
if c.VerifyComplete(key, 2) {
t.Fatal("VerifyComplete should reject zero-size last segment")
}
}
func TestSweepRespectsPinnedExceedsBudget(t *testing.T) {
c := newTestCache(t, 1)
c.maxBytes = 256 // squeeze
pinned := c.DirFor("pinned")
_ = os.MkdirAll(pinned, 0o755)
_ = os.WriteFile(filepath.Join(pinned, "x"), make([]byte, 1024), 0o644)
c.Pin("pinned")
freed, err := c.Sweep()
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if freed != 0 {
t.Fatalf("nothing should have been freed: got %d", freed)
}
if _, err := os.Stat(pinned); err != nil {
t.Fatal("pinned dir wrongly removed despite over-budget pin")
}
}

View file

@ -0,0 +1,288 @@
//go:build smoke
package engine
import (
"context"
"fmt"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
// HLS-copy integration suite — real ffmpeg, synthetic sources replicating
// every shape that broke the progressive-remux path in production:
//
// h264+aac mkv → video copy + audio copy
// h264+ac3 mkv → video copy + audio re-encode (the priming-dts class
// that needed delay_moov on the old remux)
// hevc10+eac3 mkv → the exact "Hoppers" incident shape (Main10, hvc1 tag)
// resume (-ss) → StartSec mid-file, timeline offset
//
// Asserts on every run: ffmpeg's playlist reaches ENDLIST, EXTINF sum ≈
// source duration, every listed segment exists non-empty, ffprobe decodes
// the served playlist with the EXPECTED codecs, and the video stream was
// NOT re-encoded (copy must preserve the source codec).
//
// go test -tags=smoke -run TestHLSCopy -v ./internal/engine/
func copyTestRuntime(t *testing.T) TranscodeRuntime {
t.Helper()
ffmpeg, err := exec.LookPath("ffmpeg")
if err != nil {
t.Skipf("ffmpeg not on PATH: %v", err)
}
ffprobe, err := exec.LookPath("ffprobe")
if err != nil {
t.Skipf("ffprobe not on PATH: %v", err)
}
return TranscodeRuntime{FFmpegPath: ffmpeg, FFprobePath: ffprobe}
}
// genSource synthesises a test file. encV/encA are the SOURCE encoders; skip
// the test when the local ffmpeg lacks them (libx265 is optional in some
// builds).
func genSource(t *testing.T, rt TranscodeRuntime, name string, vArgs, aArgs []string, durSec int) string {
t.Helper()
out := filepath.Join(t.TempDir(), name)
args := []string{
"-y", "-loglevel", "error",
"-f", "lavfi", "-i", fmt.Sprintf("testsrc2=duration=%d:size=640x360:rate=30", durSec),
"-f", "lavfi", "-i", fmt.Sprintf("sine=frequency=440:duration=%d", durSec),
}
args = append(args, vArgs...)
args = append(args, aArgs...)
// Short GOP so the copy cuts several segments even on a short source.
args = append(args, "-g", "60", "-keyint_min", "60", out)
if outB, err := exec.Command(rt.FFmpegPath, args...).CombinedOutput(); err != nil {
if strings.Contains(string(outB), "Unknown encoder") {
t.Skipf("source encoder unavailable: %s", string(outB))
}
t.Fatalf("generate %s: %v\n%s", name, err, outB)
}
return out
}
// runCopySession starts a VideoCopy session and waits for ffmpeg's playlist
// to reach ENDLIST. Returns the session and the final playlist text.
func runCopySession(t *testing.T, rt TranscodeRuntime, source string, startSec float64) (*HLSSession, string) {
t.Helper()
s, err := StartHLSSession(context.Background(), HLSSessionConfig{
SessionID: "copytest" + strconv.FormatInt(time.Now().UnixNano()%1_000_000, 10),
SourcePath: source,
FileName: filepath.Base(source),
AudioIndex: -1,
StartSec: startSec,
VideoCopy: true,
Transcode: rt,
})
if err != nil {
t.Fatalf("StartHLSSession(copy): %v", err)
}
t.Cleanup(func() { _ = s.Close() })
playlistPath := filepath.Join(s.tmpDir, "video", copyPlaylistName)
deadline := time.Now().Add(30 * time.Second)
for {
data, err := os.ReadFile(playlistPath)
if err == nil && strings.Contains(string(data), "#EXT-X-ENDLIST") {
return s, string(data)
}
if time.Now().After(deadline) {
t.Fatalf("playlist never reached ENDLIST; last read err=%v contents:\n%s", err, string(data))
}
time.Sleep(100 * time.Millisecond)
}
}
// assertCopyOutput validates playlist structure, segment files, and (via
// ffprobe over the playlist) that the served stream carries the expected
// codecs — wantVideo MUST equal the source codec, proving no re-encode.
func assertCopyOutput(t *testing.T, rt TranscodeRuntime, s *HLSSession, playlist, wantVideo, wantAudio string, wantDur float64) {
t.Helper()
if !strings.Contains(playlist, "#EXT-X-PLAYLIST-TYPE:EVENT") {
t.Errorf("playlist missing EVENT type:\n%s", playlist)
}
if !strings.Contains(playlist, `#EXT-X-MAP:URI="init.mp4"`) {
t.Errorf("playlist missing EXT-X-MAP init.mp4")
}
var sum float64
segs := 0
for _, line := range strings.Split(playlist, "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "#EXTINF:") {
v := strings.TrimSuffix(strings.TrimPrefix(line, "#EXTINF:"), ",")
d, err := strconv.ParseFloat(v, 64)
if err != nil {
t.Fatalf("bad EXTINF %q: %v", line, err)
}
sum += d
} else if strings.HasSuffix(line, ".m4s") {
segs++
fi, err := os.Stat(filepath.Join(s.tmpDir, "video", line))
if err != nil || fi.Size() == 0 {
t.Errorf("listed segment %s missing/empty: %v", line, err)
}
}
}
if segs == 0 {
t.Fatalf("no segments listed:\n%s", playlist)
}
if sum < wantDur-1.5 || sum > wantDur+1.5 {
t.Errorf("EXTINF sum = %.2fs, want ≈%.2fs (±1.5)", sum, wantDur)
}
// ffprobe over the playlist = a real demuxer consuming init + segments.
out, err := exec.Command(rt.FFprobePath, "-v", "error",
"-show_entries", "stream=codec_type,codec_name",
"-of", "csv=p=0",
filepath.Join(s.tmpDir, "video", copyPlaylistName)).CombinedOutput()
if err != nil {
t.Fatalf("ffprobe playlist: %v\n%s", err, out)
}
probeStr := string(out)
if !strings.Contains(probeStr, wantVideo+",video") && !strings.Contains(probeStr, "video,"+wantVideo) &&
!strings.Contains(probeStr, wantVideo) {
t.Errorf("video codec: probe=%q want %q (copy must NOT re-encode)", probeStr, wantVideo)
}
if !strings.Contains(probeStr, wantAudio) {
t.Errorf("audio codec: probe=%q want %q", probeStr, wantAudio)
}
}
func TestHLSCopy_H264AacCopyBoth(t *testing.T) {
rt := copyTestRuntime(t)
src := genSource(t, rt, "h264aac.mkv",
[]string{"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p"},
[]string{"-c:a", "aac", "-b:a", "128k"}, 8)
s, pl := runCopySession(t, rt, src, 0)
assertCopyOutput(t, rt, s, pl, "h264", "aac", 8)
// Audio already AAC → the args must COPY it, not re-encode.
args := buildHLSCopyArgs(s.cfg, s.probe, s.tmpDir)
if !containsSeq(args, "-c:a", "copy") {
t.Errorf("expected -c:a copy for AAC source, args: %v", args)
}
}
func TestHLSCopy_H264Ac3TranscodesAudio(t *testing.T) {
rt := copyTestRuntime(t)
src := genSource(t, rt, "h264ac3.mkv",
[]string{"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p"},
[]string{"-c:a", "ac3", "-b:a", "192k"}, 8)
s, pl := runCopySession(t, rt, src, 0)
// The re-encoded AAC track starts with a priming dts — the exact shape
// that produced a malformed init on the old progressive remux. The HLS
// muxer must land a probe-clean stream regardless.
assertCopyOutput(t, rt, s, pl, "h264", "aac", 8)
args := buildHLSCopyArgs(s.cfg, s.probe, s.tmpDir)
if !containsSeq(args, "-c:a", "aac") {
t.Errorf("expected -c:a aac for AC3 source, args: %v", args)
}
// MUST downmix to stereo: 6-channel ffmpeg-native AAC is rejected by
// WebKit/Apple HLS at the first media segment (every 5.1 movie failed on
// iPhone while stereo-AAC sources played — confirmed via Safari access log).
if !containsSeq(args, "-ac", "2") {
t.Errorf("expected -ac 2 (stereo downmix) for re-encoded audio, args: %v", args)
}
}
func TestHLSCopy_Hevc10Eac3_IncidentShape(t *testing.T) {
rt := copyTestRuntime(t)
src := genSource(t, rt, "hevc10eac3.mkv",
[]string{"-c:v", "libx265", "-preset", "ultrafast", "-pix_fmt", "yuv420p10le", "-x265-params", "log-level=error"},
[]string{"-c:a", "eac3", "-b:a", "192k"}, 8)
s, pl := runCopySession(t, rt, src, 0)
assertCopyOutput(t, rt, s, pl, "hevc", "aac", 8)
// HEVC must carry the hvc1 tag or Safari refuses the track.
args := buildHLSCopyArgs(s.cfg, s.probe, s.tmpDir)
if !containsSeq(args, "-tag:v", "hvc1") {
t.Errorf("expected -tag:v hvc1 for HEVC source, args: %v", args)
}
}
func TestHLSCopy_Aac51MustReencode(t *testing.T) {
// AAC is NOT copy-safe when multichannel: WebKit rejects 6-channel AAC at
// the first media segment exactly like re-encoded 5.1. Source AAC 5.1 →
// must re-encode to stereo, never copy.
rt := copyTestRuntime(t)
src := genSource(t, rt, "aac51.mkv",
[]string{"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p"},
[]string{"-c:a", "aac", "-ac", "6", "-b:a", "256k"}, 8)
s, pl := runCopySession(t, rt, src, 0)
assertCopyOutput(t, rt, s, pl, "h264", "aac", 8)
args := buildHLSCopyArgs(s.cfg, s.probe, s.tmpDir)
if containsSeq(args, "-c:a", "copy") {
t.Errorf("AAC 5.1 must NOT be copied (WebKit rejects multichannel AAC), args: %v", args)
}
if !containsSeq(args, "-ac", "2") {
t.Errorf("AAC 5.1 must re-encode to stereo, args: %v", args)
}
}
func TestHLSCopy_ResumeStartSec(t *testing.T) {
rt := copyTestRuntime(t)
src := genSource(t, rt, "resume.mkv",
[]string{"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p"},
[]string{"-c:a", "aac", "-b:a", "128k"}, 12)
_, pl := runCopySession(t, rt, src, 6)
// StartSec must be IGNORED in copy mode: the playlist covers the FULL
// timeline from 0 (an offset EVENT playlist breaks iOS's native parser;
// the player seeks to the resume point itself). Sum ≈ full 12s.
var sum float64
for _, line := range strings.Split(pl, "\n") {
if strings.HasPrefix(line, "#EXTINF:") {
v := strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(line), "#EXTINF:"), ",")
d, _ := strconv.ParseFloat(v, 64)
sum += d
}
}
if sum < 10.5 || sum > 13.5 {
t.Errorf("copy EXTINF sum = %.2fs, want ≈12s (StartSec ignored, full timeline)", sum)
}
}
func TestHLSCopy_ServeVideoPlaylistFromDisk(t *testing.T) {
rt := copyTestRuntime(t)
src := genSource(t, rt, "serve.mkv",
[]string{"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p"},
[]string{"-c:a", "aac", "-b:a", "128k"}, 6)
s, _ := runCopySession(t, rt, src, 0)
rec := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/hls/x/video/index.m3u8", nil)
s.ServeVideoPlaylist(rec, req)
if rec.Code != 200 {
t.Fatalf("ServeVideoPlaylist = %d, want 200", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "#EXT-X-ENDLIST") || !strings.Contains(body, "seg-0.m4s") {
t.Errorf("served playlist incomplete:\n%s", body)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/vnd.apple.mpegurl" {
t.Errorf("Content-Type = %q", ct)
}
// Master: no CODECS attr (a wrong hardcoded string makes iOS reject the
// variant; omission is legal), real resolution present.
master := s.MasterPlaylist()
if strings.Contains(master, "CODECS") {
t.Errorf("copy master must omit CODECS:\n%s", master)
}
if !strings.Contains(master, "RESOLUTION=640x360") {
t.Errorf("copy master missing real resolution:\n%s", master)
}
}
func containsSeq(args []string, a, b string) bool {
for i := 0; i < len(args)-1; i++ {
if args[i] == a && args[i+1] == b {
return true
}
}
return false
}

View file

@ -0,0 +1,122 @@
package engine
import (
"strings"
"testing"
)
// F4: buildHLSFFmpegArgsAt must use the full-GPU scale_cuda path ONLY for an
// SDR NVENC downscale with no burn-in on a host that probed scale_cuda — and
// keep the CPU `scale=` path for every case that needs CPU frames (HDR tonemap,
// burn-in, no downscale, non-NVENC, or scale_cuda unavailable).
func nvencCfg(quality string, burn *int) HLSSessionConfig {
return HLSSessionConfig{
SessionID: "test-cudascale",
SourcePath: "/tmp/in.mkv",
Quality: quality,
AudioIndex: -1,
BurnSubtitleIndex: burn,
Transcode: TranscodeRuntime{
FFmpegPath: "/usr/bin/ffmpeg",
HWAccel: HWAccelNVENC,
HasScaleCuda: true,
HasLibplacebo: true,
TonemapHDR: true,
},
}
}
func argsFor(cfg HLSSessionConfig, probe *StreamProbe) string {
return strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 0, 0), " ")
}
func TestCudaScale_SDRDownscale_UsesGPU(t *testing.T) {
probe := &StreamProbe{Width: 3840, Height: 2160, DurationSec: 100} // SDR (HDR == "")
got := argsFor(nvencCfg("1080p", nil), probe)
if !strings.Contains(got, "scale_cuda=-2:1080") {
t.Errorf("expected scale_cuda for SDR NVENC downscale; got:\n%s", got)
}
if !strings.Contains(got, "-hwaccel_output_format cuda") {
t.Errorf("expected -hwaccel_output_format cuda; got:\n%s", got)
}
if strings.Contains(got, "scale=-2:1080") {
t.Errorf("CPU scale must NOT appear on the cuda path; got:\n%s", got)
}
}
func TestCudaScale_HDR_StaysOnCPU(t *testing.T) {
probe := &StreamProbe{Width: 3840, Height: 2160, HDR: "HDR10", DurationSec: 100}
got := argsFor(nvencCfg("1080p", nil), probe)
if strings.Contains(got, "scale_cuda") {
t.Errorf("HDR must NOT use scale_cuda (needs the tonemap on CPU frames); got:\n%s", got)
}
if strings.Contains(got, "-hwaccel_output_format cuda") {
t.Errorf("HDR must NOT pin frames to CUDA; got:\n%s", got)
}
if !strings.Contains(got, "libplacebo") {
t.Errorf("HDR should still tonemap via libplacebo; got:\n%s", got)
}
}
func TestCudaScale_BurnIn_StaysOnCPU(t *testing.T) {
idx := 0
probe := &StreamProbe{Width: 3840, Height: 2160, DurationSec: 100}
got := argsFor(nvencCfg("1080p", &idx), probe)
if strings.Contains(got, "scale_cuda") {
t.Errorf("burn-in requested must NOT use scale_cuda (overlay runs on CPU frames); got:\n%s", got)
}
}
func TestCudaScale_NoDownscale_StaysOnCPU(t *testing.T) {
// Source already at/below the cap → no downscale → no point pinning to CUDA.
probe := &StreamProbe{Width: 1920, Height: 1080, DurationSec: 100}
got := argsFor(nvencCfg("1080p", nil), probe)
if strings.Contains(got, "scale_cuda") || strings.Contains(got, "-hwaccel_output_format cuda") {
t.Errorf("no downscale must NOT use the cuda scale path; got:\n%s", got)
}
}
func TestCudaScale_ProbeAbsent_StaysOnCPU(t *testing.T) {
cfg := nvencCfg("1080p", nil)
cfg.Transcode.HasScaleCuda = false // probe said no / non-CUDA host
probe := &StreamProbe{Width: 3840, Height: 2160, DurationSec: 100}
got := argsFor(cfg, probe)
if strings.Contains(got, "scale_cuda") {
t.Errorf("scale_cuda unavailable must fall back to CPU scale; got:\n%s", got)
}
if !strings.Contains(got, "scale=-2:1080") {
t.Errorf("expected CPU scale fallback; got:\n%s", got)
}
}
func TestCudaScale_Software_StaysOnCPU(t *testing.T) {
cfg := nvencCfg("1080p", nil)
cfg.Transcode.HWAccel = HWAccelNone // libx264, no CUDA decode
probe := &StreamProbe{Width: 3840, Height: 2160, DurationSec: 100}
got := argsFor(cfg, probe)
if strings.Contains(got, "scale_cuda") || strings.Contains(got, "-hwaccel_output_format cuda") {
t.Errorf("software encoder must NOT use the cuda scale path; got:\n%s", got)
}
}
func TestCudaScale_QSV_StaysOnCPU(t *testing.T) {
// A non-NVENC HW encoder (HW decode, but not h264_nvenc/cuda) must keep the
// CPU scale — scale_cuda is NVIDIA-only. Distinct from the software case.
cfg := nvencCfg("1080p", nil)
cfg.Transcode.HWAccel = HWAccelQSV
probe := &StreamProbe{Width: 3840, Height: 2160, DurationSec: 100}
got := argsFor(cfg, probe)
if strings.Contains(got, "scale_cuda") || strings.Contains(got, "-hwaccel_output_format cuda") {
t.Errorf("QSV must NOT use the cuda scale path; got:\n%s", got)
}
}
func TestCudaScale_OriginalQuality_StaysOnCPU(t *testing.T) {
// "original" → no height cap (maxH == 0) → no downscale → no cuda path.
probe := &StreamProbe{Width: 3840, Height: 2160, DurationSec: 100}
got := argsFor(nvencCfg("original", nil), probe)
if strings.Contains(got, "scale_cuda") || strings.Contains(got, "-hwaccel_output_format cuda") {
t.Errorf("original quality (no cap) must NOT use the cuda scale path; got:\n%s", got)
}
}

View file

@ -0,0 +1,103 @@
package engine
import (
"math"
"testing"
)
func TestParseFFmpegProgress(t *testing.T) {
cases := []struct {
name string
line string
wantSpeed float64
wantFps float64
wantOK bool
}{
{"realtime", "frame= 123 fps= 30 q=28.0 size= 456kB time=00:00:08.00 bitrate=467.0kbits/s speed=1.05x", 1.05, 30, true},
{"slow", "frame= 12 fps=2.4 q=-1.0 size= 40kB time=00:00:00.40 speed=0.18x", 0.18, 2.4, true},
{"tight_spacing", "speed=2x", 2, 0, true},
{"no_speed", "[libplacebo @ 0x55] Spent 2657ms on a slow shader", 0, 0, false},
{"warning_line", "[hevc @ 0x7f] Could not find ref with POC 12", 0, 0, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
sp, fps, ok := parseFFmpegProgress(c.line)
if ok != c.wantOK {
t.Fatalf("ok=%v want %v", ok, c.wantOK)
}
if !ok {
return
}
if math.Abs(sp-c.wantSpeed) > 1e-9 {
t.Errorf("speed=%v want %v", sp, c.wantSpeed)
}
if math.Abs(fps-c.wantFps) > 1e-9 {
t.Errorf("fps=%v want %v", fps, c.wantFps)
}
})
}
}
func TestIsInputBoundLine(t *testing.T) {
bound := []string{
"[http @ 0x55] HTTP error: Connection reset by peer",
"rw_timeout reached, aborting",
"Error in the pull function.",
"tcp://: I/O error",
}
for _, l := range bound {
if !isInputBoundLine(l) {
t.Errorf("expected input-bound: %q", l)
}
}
notBound := []string{
"frame= 1 fps=30 speed=1.0x",
"[libplacebo] slow shader",
}
for _, l := range notBound {
if isInputBoundLine(l) {
t.Errorf("expected NOT input-bound: %q", l)
}
}
}
// hlsStderrCapture must frame on \r (progress) as well as \n (warnings),
// fold progress into the EWMA, and surface a sustained slow encode as < 1.0x.
func TestHlsStderrCaptureProgressEWMA(t *testing.T) {
s := &HLSSession{}
s.cfg.SessionID = "test-session-00000000"
c := &hlsStderrCapture{owner: s}
// Cold-start frames ffmpeg emits while the pipeline fills — must be skipped
// (hlsStatsWarmupSkip) so they don't drag the EWMA into a false struggle.
warmup := "frame=0 fps=0 speed=0.01x\r" +
"frame=0 fps=0 speed=0.04x\r"
// A burst of \r-terminated steady-state progress lines, like real ffmpeg.
chunk := "frame=1 fps=2 speed=0.20x\r" +
"frame=2 fps=2 speed=0.21x\r" +
"frame=3 fps=2 speed=0.19x\r" +
"frame=4 fps=2 speed=0.20x\r" +
"frame=5 fps=2 speed=0.20x\r"
if _, err := c.Write([]byte(warmup + chunk)); err != nil {
t.Fatal(err)
}
st := s.GetTranscodeStats()
// 7 progress lines written, first hlsStatsWarmupSkip(2) discarded → 5 counted.
if st.Samples != 5 {
t.Fatalf("samples=%d want 5 (7 lines - 2 warmup)", st.Samples)
}
if st.SpeedX > 0.5 || st.SpeedX < 0.1 {
t.Errorf("speedX EWMA=%v, want ~0.2 (sustained slow encode)", st.SpeedX)
}
if st.InputBound {
t.Error("not input-bound for a pure slow encode")
}
// A \n-terminated I/O error line flips input-bound.
if _, err := c.Write([]byte("tcp://: I/O error\n")); err != nil {
t.Fatal(err)
}
if !s.GetTranscodeStats().InputBound {
t.Error("expected input-bound after I/O error line")
}
}

View file

@ -0,0 +1,127 @@
package engine
import (
"strings"
"testing"
)
func TestDoubleBitrate(t *testing.T) {
cases := map[string]string{
"6000k": "12000k",
"25000k": "50000k",
"1500k": "3000k",
"5M": "10M",
"1.5M": "3M",
"2.5m": "5m",
"800000": "1600000",
"": "",
"garbage": "garbage", // unparseable → unchanged (1× bufsize fallback)
"-5M": "-5M", // non-positive → unchanged
}
for in, want := range cases {
if got := doubleBitrate(in); got != want {
t.Errorf("doubleBitrate(%q) = %q, want %q", in, got, want)
}
}
}
// segmentIdxForTime must be the exact inverse of segmentStartSec so the
// resume-aware first spawn (HLSSessionConfig.StartSec) lands on the same
// segment the player's hls.js startPosition will request.
func TestSegmentIdxForTime(t *testing.T) {
cases := map[float64]int{
0: 0,
-3: 0,
0.5: 0,
1.99: 0,
2: 1,
3.9: 1,
60: 30,
3599.9: 1799,
}
for sec, want := range cases {
if got := segmentIdxForTime(sec); got != want {
t.Errorf("segmentIdxForTime(%v) = %d, want %d", sec, got, want)
}
}
// Round-trip: the start time of the segment we resolve must never be
// AFTER the requested position (the player would miss its first frames).
for _, sec := range []float64{0, 1, 2, 7.3, 119.9, 4321} {
idx := segmentIdxForTime(sec)
if start := segmentStartSec(idx); start > sec {
t.Errorf("segmentStartSec(segmentIdxForTime(%v)) = %v > %v", sec, start, sec)
}
}
}
// Capped constant-quality rate control: libx264 gets -crf (no -b:v), NVENC
// gets -cq with -b:v 0, both keep -maxrate at the level-coherent cap and a
// 2× -bufsize. VAAPI (and the other vendor encoders) keep the proven
// fixed-bitrate triple untouched.
func TestBuildHLSFFmpegArgsRateControl(t *testing.T) {
probe := &StreamProbe{Width: 1920, Height: 1080, DurationSec: 100}
base := HLSSessionConfig{
SessionID: "test",
SourcePath: "/media/Movie.mkv",
Quality: "1080p",
Transcode: TranscodeRuntime{
FFmpegPath: "/usr/bin/ffmpeg",
FFprobePath: "/usr/bin/ffprobe",
},
}
t.Run("libx264 capped CRF", func(t *testing.T) {
cfg := base
cfg.Transcode.HWAccel = HWAccelNone
got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 0, 0), " ")
for _, want := range []string{"-crf 23", "-maxrate 6000k", "-bufsize 12000k"} {
if !strings.Contains(got, want) {
t.Errorf("libx264 argv missing %q\n%s", want, got)
}
}
if strings.Contains(got, "-b:v 6000k") {
t.Errorf("libx264 argv must not carry -b:v alongside -crf\n%s", got)
}
})
t.Run("nvenc constant-quality VBR", func(t *testing.T) {
cfg := base
cfg.Transcode.HWAccel = HWAccelNVENC
got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 0, 0), " ")
// -forced-idr 1 is load-bearing: without it NVENC emits the forced
// keyframes as non-IDR and every HLS segment stretches to the full
// GOP, desyncing the playlist timeline (subs/seeks).
for _, want := range []string{"-rc vbr", "-cq 23", "-b:v 0", "-maxrate 6000k", "-bufsize 12000k", "-forced-idr 1"} {
if !strings.Contains(got, want) {
t.Errorf("nvenc argv missing %q\n%s", want, got)
}
}
})
t.Run("qsv keeps bitrate + forced_idr", func(t *testing.T) {
cfg := base
cfg.Transcode.HWAccel = HWAccelQSV
got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 0, 0), " ")
// -forced_idr 1 (QSV's spelling): same non-IDR forced-keyframe failure
// mode as NVENC — without it segments stretch to the full GOP.
for _, want := range []string{"-look_ahead 0", "-forced_idr 1", "-b:v 6000k"} {
if !strings.Contains(got, want) {
t.Errorf("qsv argv missing %q\n%s", want, got)
}
}
})
t.Run("vaapi keeps fixed-bitrate triple", func(t *testing.T) {
cfg := base
cfg.Transcode.HWAccel = HWAccelVAAPI
got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 0, 0), " ")
for _, want := range []string{"-b:v 6000k", "-maxrate 6000k", "-bufsize 6000k"} {
if !strings.Contains(got, want) {
t.Errorf("vaapi argv missing %q\n%s", want, got)
}
}
if strings.Contains(got, "-crf") || strings.Contains(got, "-cq") {
t.Errorf("vaapi argv must not carry constant-quality flags\n%s", got)
}
})
}

View file

@ -0,0 +1,80 @@
package engine
import "testing"
// bare session: no ffmpeg, no tmpdir — exercises pure registry semantics.
func bareSession(id string, prewarm bool, exited bool) *HLSSession {
s := &HLSSession{cfg: HLSSessionConfig{SessionID: id, Prewarm: prewarm}}
s.exited = exited
return s
}
// A prewarm registered via RegisterKeep must NOT evict the viewer's live
// session (the old Register-for-everything path killed the stream being
// watched when the next-episode prewarm got claimed mid-playback).
func TestRegisterKeepDoesNotEvict(t *testing.T) {
r := NewHLSSessionRegistry()
live := bareSession("live", false, false)
r.Register(live)
pre := bareSession("pre", true, false)
r.RegisterKeep(pre)
if r.Get("live") == nil {
t.Fatal("RegisterKeep evicted the live session")
}
if r.Get("pre") == nil {
t.Fatal("RegisterKeep did not register the prewarm")
}
if live.isClosed() {
t.Fatal("RegisterKeep closed the live session")
}
// A REAL session via Register still evicts everything (single viewer).
real2 := bareSession("real2", false, false)
r.Register(real2)
if r.Get("live") != nil || r.Get("pre") != nil {
t.Fatal("Register must evict every other session")
}
if !live.isClosed() || !pre.isClosed() {
t.Fatal("Register must close the evicted sessions")
}
}
func TestCloseWherePrewarmsOnly(t *testing.T) {
r := NewHLSSessionRegistry()
live := bareSession("live", false, false)
pre1 := bareSession("pre1", true, false)
pre2 := bareSession("pre2", true, true)
r.Register(live)
r.RegisterKeep(pre1)
r.RegisterKeep(pre2)
n := r.CloseWhere(func(s *HLSSession) bool { return s.IsPrewarm() })
if n != 2 {
t.Fatalf("CloseWhere closed %d sessions, want 2", n)
}
if r.Get("live") == nil || live.isClosed() {
t.Fatal("CloseWhere must not touch the live session")
}
if r.Get("pre1") != nil || r.Get("pre2") != nil {
t.Fatal("CloseWhere must remove the prewarms from the registry")
}
}
func TestHasLiveEncode(t *testing.T) {
r := NewHLSSessionRegistry()
if r.HasLiveEncode() {
t.Fatal("empty registry must report no live encode")
}
done := bareSession("done", false, true) // encode finished / cache HIT
r.Register(done)
if r.HasLiveEncode() {
t.Fatal("an exited encode must not count as live")
}
running := bareSession("running", true, false)
r.RegisterKeep(running)
if !r.HasLiveEncode() {
t.Fatal("a running encode must count as live")
}
}

View file

@ -7,15 +7,6 @@ import (
"time"
)
func TestYnBool(t *testing.T) {
if got := ynBool(true); got != "YES" {
t.Errorf("ynBool(true) = %q, want YES", got)
}
if got := ynBool(false); got != "NO" {
t.Errorf("ynBool(false) = %q, want NO", got)
}
}
func TestBitrateForQuality(t *testing.T) {
cases := map[string]int{
"2160p": 25_000_000,
@ -115,10 +106,11 @@ func TestRenderVideoPlaylist(t *testing.T) {
}
func TestRenderVideoPlaylistShortFinalSegment(t *testing.T) {
// 9.5s total, 4s segments → 3 segs of 4/4/1.5
out := renderVideoPlaylist(9.5, 3)
// 9.5s total, 2s segments → 5 segs of 2/2/2/2/1.5
segCount := segmentCountForDuration(9.5)
out := renderVideoPlaylist(9.5, segCount)
if !strings.Contains(out, "#EXTINF:1.500,") {
t.Errorf("expected final segment 1.5s in playlist, got:\n%s", out)
t.Errorf("expected final segment 1.5s in playlist (segCount=%d), got:\n%s", segCount, out)
}
}
@ -143,17 +135,15 @@ func TestRenderMasterPlaylist(t *testing.T) {
if !strings.Contains(out, "RESOLUTION=1920x1080") {
t.Errorf("expected 1920x1080 resolution, got:\n%s", out)
}
if !strings.Contains(out, `SUBTITLES="subs"`) {
t.Errorf("expected subtitles group attached, got:\n%s", out)
// Subtitles are NO LONGER embedded as HLS renditions — the web player
// attaches them as external <track>s (served by /sub). The master playlist
// must therefore carry no SUBTITLES group, no EXT-X-MEDIA, and no SUBTITLES
// attribute on the video variant, even when the source has text subs.
if strings.Contains(out, "SUBTITLES") {
t.Errorf("subtitles must NOT be embedded in the manifest (served as external <track>), got:\n%s", out)
}
if !strings.Contains(out, `LANGUAGE="es"`) || !strings.Contains(out, `LANGUAGE="en"`) {
t.Errorf("expected text subs included, got:\n%s", out)
}
if strings.Contains(out, "hdmv_pgs") || strings.Contains(out, `LANGUAGE="ja"`) {
t.Errorf("bitmap subs should be excluded, got:\n%s", out)
}
if !strings.Contains(out, "(forced)") {
t.Errorf("expected forced suffix on English track, got:\n%s", out)
if strings.Contains(out, "EXT-X-MEDIA") {
t.Errorf("no EXT-X-MEDIA rendition expected, got:\n%s", out)
}
}

View file

@ -0,0 +1,230 @@
package engine
import (
"strings"
"testing"
)
// hueco #2 / 2b — buildHLSFFmpegArgsAt must feed a debrid URL straight to
// ffmpeg's -i with HTTP-resilience flags, and must NOT add those flags for a
// local file.
func TestBuildHLSFFmpegArgsFromURL(t *testing.T) {
const url = "https://cdn.debrid.it/dl/abc/Movie.mkv"
cfg := HLSSessionConfig{
SessionID: "test",
SourceURL: url,
CacheID: "deadbeef",
Quality: "720p",
Transcode: TranscodeRuntime{
FFmpegPath: "/usr/bin/ffmpeg",
FFprobePath: "/usr/bin/ffprobe",
HWAccel: HWAccelNone,
},
}
probe := &StreamProbe{Width: 1920, Height: 1080, DurationSec: 100}
args := buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 0, 0)
got := strings.Join(args, " ")
for _, want := range []string{
"-reconnect 1",
"-reconnect_streamed 1",
"-reconnect_delay_max 5",
"-rw_timeout 30000000",
"-i " + url,
} {
if !strings.Contains(got, want) {
t.Errorf("URL argv missing %q\n%s", want, got)
}
}
}
// A seek (startSec>0) on a URL source must keep BOTH the -ss input seek AND the
// HTTP-resilience flags, so a seek-restart re-opens the URL with a Range request
// instead of re-downloading from zero. (-ss before -i = input seek.)
func TestBuildHLSFFmpegArgsFromURLWithSeek(t *testing.T) {
const url = "https://cdn.debrid.it/dl/abc/Movie.mkv"
cfg := HLSSessionConfig{
SessionID: "test",
SourceURL: url,
CacheID: "deadbeef",
Quality: "720p",
Transcode: TranscodeRuntime{
FFmpegPath: "/usr/bin/ffmpeg",
FFprobePath: "/usr/bin/ffprobe",
HWAccel: HWAccelNone,
},
}
probe := &StreamProbe{Width: 1920, Height: 1080, DurationSec: 100}
got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 5, 30), " ")
for _, want := range []string{
"-ss 30.000", // input seek before -i
"-reconnect 1", // resilience flags still present on a restart
"-rw_timeout 30000000",
"-i " + url,
"-output_ts_offset 30.000", // PTS shift so the manifest numbering holds
} {
if !strings.Contains(got, want) {
t.Errorf("seek+URL argv missing %q\n%s", want, got)
}
}
// -ss must come before -i (fast input seek, not slow output seek).
if strings.Index(got, "-ss 30.000") > strings.Index(got, "-i "+url) {
t.Errorf("-ss must precede -i for input seek:\n%s", got)
}
}
func TestBuildHLSFFmpegArgsLocalNoNetworkFlags(t *testing.T) {
cfg := HLSSessionConfig{
SessionID: "test",
SourcePath: "/tmp/test.mkv",
Quality: "720p",
Transcode: TranscodeRuntime{
FFmpegPath: "/usr/bin/ffmpeg",
FFprobePath: "/usr/bin/ffprobe",
HWAccel: HWAccelNone,
},
}
probe := &StreamProbe{Width: 1920, Height: 1080, DurationSec: 100}
got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 0, 0), " ")
if strings.Contains(got, "-reconnect") || strings.Contains(got, "-rw_timeout") {
t.Errorf("local source must not carry HTTP-resilience flags: %s", got)
}
if !strings.Contains(got, "-i /tmp/test.mkv") {
t.Errorf("local argv missing -i /tmp/test.mkv: %s", got)
}
}
// sourceRef + cache-key identity: a URL session keys by CacheID, a local one by
// path. Guards the "re-plays of the same debrid content hit cache despite the
// URL changing" invariant.
func TestHLSSourceRefAndCacheID(t *testing.T) {
urlCfg := HLSSessionConfig{SourceURL: "https://cdn/x.mkv", CacheID: "hash1"}
if urlCfg.sourceRef() != "https://cdn/x.mkv" {
t.Errorf("sourceRef = %q, want the URL", urlCfg.sourceRef())
}
localCfg := HLSSessionConfig{SourcePath: "/m/x.mkv"}
if localCfg.sourceRef() != "/m/x.mkv" {
t.Errorf("sourceRef = %q, want the path", localCfg.sourceRef())
}
c := &HLSCache{root: "/tmp/cache"}
// Same CacheID + quality + audio → same key regardless of the (volatile) URL.
k1 := c.KeyForID("hash1", "720p", -1, -1)
k2 := c.KeyForID("hash1", "720p", -1, -1)
if k1 != k2 {
t.Errorf("KeyForID not stable: %q != %q", k1, k2)
}
if c.KeyForID("hash2", "720p", -1, -1) == k1 {
t.Error("KeyForID collision across distinct ids")
}
}
// Burn-in: a bitmap subtitle index routes the video through -filter_complex with
// scale2ref + overlay and maps [vout]; a nil / text / out-of-range index keeps
// the plain -vf path (text subs are served as WebVTT, never burned).
func TestBuildHLSFFmpegArgsBurnSubtitle(t *testing.T) {
idx := func(n int) *int { return &n }
base := func() HLSSessionConfig {
return HLSSessionConfig{
SessionID: "burn",
SourcePath: "/tmp/movie.mkv",
Quality: "1080p",
Transcode: TranscodeRuntime{
FFmpegPath: "/usr/bin/ffmpeg",
FFprobePath: "/usr/bin/ffprobe",
HWAccel: HWAccelNone,
},
}
}
probe := &StreamProbe{
Width: 1920, Height: 1080, DurationSec: 100,
SubtitleTracks: []ProbeSubtitleTrack{
{Index: 0, Codec: "subrip"}, // text → not burnable
{Index: 1, Codec: "hdmv_pgs_subtitle"}, // bitmap → burnable
},
}
t.Run("nil = clean -vf path", func(t *testing.T) {
got := strings.Join(buildHLSFFmpegArgsAt(base(), probe, "/tmp/d", 0, 0), " ")
if strings.Contains(got, "-filter_complex") || strings.Contains(got, "overlay") {
t.Errorf("no-burn argv must not overlay: %s", got)
}
if !strings.Contains(got, "-map 0:v:0") || !strings.Contains(got, "-vf") {
t.Errorf("no-burn argv must -map 0:v:0 with -vf: %s", got)
}
})
t.Run("bitmap index burns via filter_complex", func(t *testing.T) {
cfg := base()
cfg.BurnSubtitleIndex = idx(1)
got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/d", 0, 0), " ")
for _, want := range []string{"-filter_complex", "[0:s:1]", "scale2ref", "overlay", "-map [vout]"} {
if !strings.Contains(got, want) {
t.Errorf("burn argv missing %q: %s", want, got)
}
}
if strings.Contains(got, "-map 0:v:0") {
t.Errorf("burn argv must map [vout], not 0:v:0: %s", got)
}
})
t.Run("text index is ignored (served as WebVTT)", func(t *testing.T) {
cfg := base()
cfg.BurnSubtitleIndex = idx(0) // subrip → not a bitmap track
got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/d", 0, 0), " ")
if strings.Contains(got, "overlay") || strings.Contains(got, "-filter_complex") {
t.Errorf("text-sub burn must fall back to clean encode: %s", got)
}
})
t.Run("out-of-range index is ignored", func(t *testing.T) {
cfg := base()
cfg.BurnSubtitleIndex = idx(9)
got := strings.Join(buildHLSFFmpegArgsAt(cfg, probe, "/tmp/d", 0, 0), " ")
if strings.Contains(got, "overlay") {
t.Errorf("out-of-range burn must fall back to clean encode: %s", got)
}
})
}
// Audio clamp (2026-06-03 no-sound regression): the web persists audioIndex
// globally, so a stale value from a multi-track file can arrive for a file with
// fewer tracks. buildHLSFFmpegArgsAt must clamp an out-of-range index to 0:a:0
// rather than emit `-map 0:a:N?` for a track that doesn't exist — the optional
// `?` would otherwise silently drop audio and yield a video-only stream.
func TestBuildHLSFFmpegArgsAudioClamp(t *testing.T) {
cfg := func(audioIdx int) HLSSessionConfig {
return HLSSessionConfig{
SessionID: "audio",
SourcePath: "/tmp/movie.mkv",
Quality: "1080p",
AudioIndex: audioIdx,
Transcode: TranscodeRuntime{
FFmpegPath: "/usr/bin/ffmpeg",
FFprobePath: "/usr/bin/ffprobe",
HWAccel: HWAccelNone,
},
}
}
oneTrack := &StreamProbe{Width: 1920, Height: 1080, DurationSec: 100, AudioTracks: []ProbeAudioTrack{{}}}
t.Run("out-of-range index clamps to 0:a:0", func(t *testing.T) {
got := strings.Join(buildHLSFFmpegArgsAt(cfg(2), oneTrack, "/tmp/d", 0, 0), " ")
if !strings.Contains(got, "-map 0:a:0?") {
t.Errorf("out-of-range audioIndex must clamp to 0:a:0?: %s", got)
}
if strings.Contains(got, "0:a:2?") {
t.Errorf("must not map the non-existent 0:a:2: %s", got)
}
})
t.Run("in-range index is preserved", func(t *testing.T) {
twoTracks := &StreamProbe{Width: 1920, Height: 1080, DurationSec: 100, AudioTracks: []ProbeAudioTrack{{}, {}}}
got := strings.Join(buildHLSFFmpegArgsAt(cfg(1), twoTracks, "/tmp/d", 0, 0), " ")
if !strings.Contains(got, "-map 0:a:1?") {
t.Errorf("in-range audioIndex 1 must be preserved: %s", got)
}
})
}

View file

@ -86,6 +86,117 @@ func listFFmpegEncoders(ctx context.Context, ffmpegPath string) string {
return string(out)
}
// HWAccelDiagnostic bundles what we know about the host's ffmpeg + HW encode
// capabilities so the daemon can log a single coherent line at startup and the
// web side can surface "this agent is software-only" without re-running probes.
type HWAccelDiagnostic struct {
Pick HWAccel // backend selected by DetectHWAccel
FFmpegPath string // resolved ffmpeg binary
FFmpegVersion string // first line of `ffmpeg -version` (e.g. "ffmpeg version 6.1.1")
Encoders []string // HW + libsvtav1/libvpx9-class encoders found in -encoders output
Devices []string // device files / drivers detected at probe time
}
// DetectHWAccelDiagnostic returns the full diagnostic picture for the host's
// transcode pipeline. Unlike DetectHWAccel, this is NOT cached — callers pay
// for an ffmpeg subprocess on each call (one `-encoders`, one `-version`).
// Daemon startup is the natural caller; per-session lookups should keep using
// DetectHWAccel (cached) and only re-probe diagnostics if the user runs an
// explicit doctor command.
func DetectHWAccelDiagnostic(ctx context.Context, ffmpegPath string) HWAccelDiagnostic {
d := HWAccelDiagnostic{Pick: HWAccelNone, FFmpegPath: ffmpegPath}
if ffmpegPath == "" {
return d
}
d.FFmpegVersion = ffmpegVersionLine(ctx, ffmpegPath)
encoders := listFFmpegEncoders(ctx, ffmpegPath)
for _, name := range hwEncoderNames {
if strings.Contains(encoders, name) {
d.Encoders = append(d.Encoders, name)
}
}
// Device-file checks mirror the picks below so the log line tells the
// reader why a present encoder might still have been rejected (e.g. NVENC
// compiled in but /dev/nvidia0 missing inside a container).
if fileExists("/dev/nvidia0") {
d.Devices = append(d.Devices, "/dev/nvidia0")
}
if fileExists("/dev/dri/renderD128") {
d.Devices = append(d.Devices, "/dev/dri/renderD128")
}
if hasNvidiaDriver() {
d.Devices = append(d.Devices, "nvidia-smi")
}
d.Pick = DetectHWAccel(ctx, ffmpegPath)
return d
}
// LogLine returns a one-line human-readable summary of the diagnostic,
// suitable for daemon startup output. Format:
//
// "[transcode] ffmpeg 6.1.1 at /usr/bin/ffmpeg, HW=nvenc (h264_nvenc), devices=/dev/nvidia0,nvidia-smi"
// "[transcode] ffmpeg 6.1.1 at /home/linuxbrew/.../ffmpeg, HW=none (software libx264) — no HW encoders compiled in"
func (d HWAccelDiagnostic) LogLine() string {
var b strings.Builder
b.WriteString("[transcode] ")
if d.FFmpegVersion != "" {
b.WriteString(d.FFmpegVersion)
} else {
b.WriteString("ffmpeg")
}
if d.FFmpegPath != "" {
b.WriteString(" at ")
b.WriteString(d.FFmpegPath)
}
b.WriteString(", HW=")
b.WriteString(string(d.Pick))
if d.Pick == HWAccelNone {
if len(d.Encoders) == 0 {
b.WriteString(" (software libx264) — no HW encoders compiled in")
} else {
b.WriteString(" (software libx264) — encoders found but no matching device: ")
b.WriteString(strings.Join(d.Encoders, ","))
}
} else {
b.WriteString(" (")
b.WriteString(d.Pick.FFmpegVideoCodec("h264"))
b.WriteString(")")
if len(d.Devices) > 0 {
b.WriteString(", devices=")
b.WriteString(strings.Join(d.Devices, ","))
}
}
return b.String()
}
// hwEncoderNames lists the HW-accelerated encoders we care about for the
// startup log. Kept in lookup order so the output reads predictably across
// hosts.
var hwEncoderNames = []string{
"h264_nvenc", "hevc_nvenc",
"h264_qsv", "hevc_qsv",
"h264_vaapi", "hevc_vaapi",
"h264_videotoolbox", "hevc_videotoolbox",
}
// ffmpegVersionLine extracts the "ffmpeg version X.Y.Z" prefix from
// `ffmpeg -version`. Bounded to avoid hanging the daemon on a misbehaving
// binary.
func ffmpegVersionLine(ctx context.Context, ffmpegPath string) string {
cmd := exec.CommandContext(ctx, ffmpegPath, "-hide_banner", "-version")
out, err := cmd.CombinedOutput()
if err != nil || len(out) == 0 {
return ""
}
line, _, _ := strings.Cut(string(out), "\n")
// "ffmpeg version 6.1.1-some-build-suffix Copyright..." → keep up to first
// space after "version 6.x" to avoid spamming build flags into the log.
if idx := strings.Index(line, "Copyright"); idx > 0 {
line = strings.TrimSpace(line[:idx])
}
return strings.TrimSpace(line)
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
@ -129,12 +240,13 @@ func (h HWAccel) FFmpegVideoCodec(target string) string {
}
}
// H264LevelForHeight returns the lowest H.264 profile level capable of encoding
// a stream at the given output pixel height (assumes ~16:9, ≤30 fps). The
// previous code used a fixed "4.0" which silently rejects anything above 1080p
// — libx264 logs "frame MB size > level limit" and emits a corrupt stream.
// Returning a tighter level on smaller outputs keeps player compatibility on
// older devices where the encoder can't auto-pick.
// H264LevelForHeight returns the lowest H.264 profile level capable of
// encoding a stream at the given output pixel height. Each tier carries
// enough macroblock headroom to handle ANAMORPHIC content (up to ~2.4:1
// cinemascope) at 30 fps — a fixed 16:9 assumption used to silently bust
// the level on a 720p movie shot in 2.4:1 (1728×720 = 4860 MBs > 3.1's
// 3600 limit; libx264 logs "frame MB size > level limit" and emits a
// corrupt stream).
func H264LevelForHeight(height int) string {
switch {
case height <= 0:
@ -142,11 +254,14 @@ func H264LevelForHeight(height int) string {
// re-introduce the silent-failure mode that motivated this helper.
return "5.1"
case height <= 480:
return "3.0"
case height <= 720:
return "3.1"
case height <= 1080:
case height <= 720:
// 4.0 instead of 3.1: covers 720p anamorphic (e.g. 1728×720) +
// MB rate up to 245k/s (3.1 caps at 108k/s — broken at 24 fps).
return "4.0"
case height <= 1080:
// 4.1 instead of 4.0: covers 1080p anamorphic + 30 fps (~245k MBs/s).
return "4.1"
case height <= 1440:
return "5.0"
case height <= 2160:
@ -156,3 +271,60 @@ func H264LevelForHeight(height int) string {
return "6.0"
}
}
// h264LevelRank orders level strings so callers can pick the higher of two.
var h264LevelRank = map[string]int{
"3.0": 30, "3.1": 31, "3.2": 32,
"4.0": 40, "4.1": 41, "4.2": 42,
"5.0": 50, "5.1": 51, "6.0": 60,
}
// levelForMacroblocks returns the lowest H.264 level whose MaxFS (frame size in
// macroblocks) covers `mbs`. The height-based H264LevelForHeight tier is correct
// for 16:9, but anamorphic content (2.39:1 cinemascope) scaled to a given height
// has a much wider frame: a 2.39:1 source downscaled to 1080 height becomes
// ~2586×1080 = 11016 MBs, which busts level 4.1's 8192-MB MaxFS. ffmpeg then
// fails the encode — libx264 with "frame MB size > level limit", h264_nvenc with
// "InitializeEncoder failed: invalid param (8): Invalid Level" — and emits zero
// packets (the whole HLS session stalls at "preparando sesión"). MaxFS values
// from the H.264 spec, Table A-1.
func levelForMacroblocks(mbs int) string {
switch {
case mbs <= 1620:
return "3.0"
case mbs <= 3600:
return "3.1"
case mbs <= 5120:
return "3.2"
case mbs <= 8192: // levels 4.0 and 4.1 share MaxFS 8192; pick 4.1 for headroom
return "4.1"
case mbs <= 8704:
return "4.2"
case mbs <= 22080:
return "5.0"
case mbs <= 36864:
return "5.1"
default:
return "6.0"
}
}
// H264LevelForFrame returns the lowest H.264 level that satisfies BOTH the
// height-derived tier (which carries macroblock-rate / fps headroom) and the
// actual frame's macroblock count (which catches anamorphic frames that are far
// wider than 16:9 at a given height). Use this instead of H264LevelForHeight
// wherever the output width is known — it never under-levels an ultra-wide
// frame, and for 16:9 content it returns exactly what H264LevelForHeight does.
func H264LevelForFrame(width, height int) string {
byHeight := H264LevelForHeight(height)
if width <= 0 || height <= 0 {
return byHeight
}
// Macroblocks are 16×16; partial blocks at the edge still count (ceil).
mbs := ((width + 15) / 16) * ((height + 15) / 16)
byMB := levelForMacroblocks(mbs)
if h264LevelRank[byMB] > h264LevelRank[byHeight] {
return byMB
}
return byHeight
}

View file

@ -1,6 +1,9 @@
package engine
import "testing"
import (
"strings"
"testing"
)
func TestHWAccelFFmpegVideoCodec(t *testing.T) {
cases := []struct {
@ -32,3 +35,152 @@ func TestDetectHWAccelEmptyPathReturnsNone(t *testing.T) {
t.Errorf("got %s, want %s", got, HWAccelNone)
}
}
func TestResolveEncoderProfileDefaults(t *testing.T) {
cases := []struct {
hw HWAccel
configured string
wantCodec string
wantPreset string
wantHint string
}{
// Empty configured preset → pick latency-biased default per backend.
// DecodeHwAccel matches the encoder family for HW encoders; libx264 +
// VideoToolbox have no demuxer hint.
{HWAccelNone, "", "libx264", "superfast", ""},
{HWAccelNVENC, "", "h264_nvenc", "p3", "cuda"},
{HWAccelQSV, "", "h264_qsv", "veryfast", "qsv"},
// VAAPI: decoder hint set, no preset, no `-hwaccel_output_format vaapi`
// (so the CPU filter chain can consume the decoded frames).
{HWAccelVAAPI, "", "h264_vaapi", "", "vaapi"},
// VideoToolbox has no preset knob — Preset should be "" regardless of input.
// VideoToolbox uses per-encoder flags, not a demuxer `-hwaccel` hint.
{HWAccelVideoToolbox, "p4", "h264_videotoolbox", "", ""},
{HWAccelVideoToolbox, "", "h264_videotoolbox", "", ""},
}
for _, tc := range cases {
got := ResolveEncoderProfile(tc.hw, tc.configured)
if got.Codec != tc.wantCodec || got.Preset != tc.wantPreset || got.DecodeHwAccel != tc.wantHint {
t.Errorf("ResolveEncoderProfile(%s, %q) = {codec=%s preset=%s hint=%s}, want {codec=%s preset=%s hint=%s}",
tc.hw, tc.configured,
got.Codec, got.Preset, got.DecodeHwAccel,
tc.wantCodec, tc.wantPreset, tc.wantHint)
}
}
}
func TestResolveEncoderProfileHonoursConfiguredPreset(t *testing.T) {
// Only libx264 honours the configured preset — the libx264 vocabulary
// (ultrafast…veryslow) doesn't apply to vendor encoders. NVENC has its
// own p1-p7 scale; QSV uses a different subset; VideoToolbox has no
// preset knob. Passing a libx264 preset to them would have ffmpeg reject
// the argv, so ResolveEncoderProfile always falls back to the hardcoded
// vendor preset for non-libx264 codecs.
cases := []struct {
hw HWAccel
configured string
wantPreset string
}{
{HWAccelNone, "ultrafast", "ultrafast"}, // libx264 honours
{HWAccelNone, "medium", "medium"}, // libx264 honours
{HWAccelNVENC, "p1", "p3"}, // NVENC ignores, sticks to p3
{HWAccelNVENC, "veryfast", "p3"}, // NVENC ignores libx264 vocab
{HWAccelQSV, "veryslow", "veryfast"}, // QSV ignores, sticks to veryfast
{HWAccelVideoToolbox, "veryfast", ""}, // VideoToolbox has no preset
}
for _, tc := range cases {
got := ResolveEncoderProfile(tc.hw, tc.configured)
if got.Preset != tc.wantPreset {
t.Errorf("ResolveEncoderProfile(%s, %q).Preset = %q, want %q",
tc.hw, tc.configured, got.Preset, tc.wantPreset)
}
}
}
func TestHWAccelDiagnosticLogLineNone(t *testing.T) {
d := HWAccelDiagnostic{
Pick: HWAccelNone,
FFmpegPath: "/usr/local/bin/ffmpeg",
FFmpegVersion: "ffmpeg version 6.1.1",
Encoders: nil,
Devices: nil,
}
line := d.LogLine()
wantSubstrings := []string{
"ffmpeg version 6.1.1",
"/usr/local/bin/ffmpeg",
"HW=none",
"software libx264",
"no HW encoders compiled in",
}
for _, want := range wantSubstrings {
if !strings.Contains(line, want) {
t.Errorf("expected substring %q in log line; got %q", want, line)
}
}
}
func TestHWAccelDiagnosticLogLineNVENCWithDevices(t *testing.T) {
d := HWAccelDiagnostic{
Pick: HWAccelNVENC,
FFmpegPath: "/usr/bin/ffmpeg",
FFmpegVersion: "ffmpeg version 6.0",
Encoders: []string{"h264_nvenc", "hevc_nvenc", "h264_qsv"},
Devices: []string{"/dev/nvidia0", "nvidia-smi"},
}
line := d.LogLine()
for _, want := range []string{"HW=nvenc", "h264_nvenc", "/dev/nvidia0", "nvidia-smi"} {
if !strings.Contains(line, want) {
t.Errorf("expected substring %q in log line; got %q", want, line)
}
}
}
func TestHWAccelDiagnosticLogLineSoftwareButEncodersFound(t *testing.T) {
// Edge case: ffmpeg compiled WITH nvenc but no /dev/nvidia0 (container w/o GPU).
// LogLine should flag the encoders so the user knows where the gap is.
d := HWAccelDiagnostic{
Pick: HWAccelNone,
FFmpegPath: "/usr/bin/ffmpeg",
FFmpegVersion: "ffmpeg version 6.0",
Encoders: []string{"h264_nvenc"},
Devices: nil,
}
line := d.LogLine()
for _, want := range []string{"HW=none", "encoders found but no matching device", "h264_nvenc"} {
if !strings.Contains(line, want) {
t.Errorf("expected substring %q in log line; got %q", want, line)
}
}
}
func TestH264LevelForFrame(t *testing.T) {
cases := []struct {
name string
width, height int
want string
}{
// 16:9 must match the height-only helper exactly (no regression).
{"720p 16:9", 1280, 720, "4.0"},
{"1080p 16:9", 1920, 1080, "4.1"},
{"1440p 16:9", 2560, 1440, "5.0"},
{"2160p 16:9", 3840, 2160, "5.1"},
// Anamorphic 2.39:1 at 1080 height — the regression: ~2586×1080 = 11016
// MBs busts level 4.1 (8192 MaxFS); must bump to 5.0.
{"1080h anamorphic 2.39:1", 2586, 1080, "5.0"},
// Anamorphic 720 height (1728×720 = 4860 MBs) still fits the 4.0 the
// height floor already picks for fps headroom.
{"720h anamorphic 2.4:1", 1728, 720, "4.0"},
// Source 4K anamorphic (3840×1604) encoded at source: 24240 MBs → 5.1.
{"4K anamorphic source", 3840, 1604, "5.1"},
// Width unknown → fall back to the height-only tier.
{"width unknown", 0, 1080, "4.1"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := H264LevelForFrame(c.width, c.height); got != c.want {
t.Errorf("H264LevelForFrame(%d,%d) = %q, want %q", c.width, c.height, got, c.want)
}
})
}
}

View file

@ -0,0 +1,75 @@
package engine
import (
"context"
"log"
"os/exec"
"strings"
"sync"
"time"
)
// Hardware downscale filter probes (F4). Mirror the libplacebo probe in
// tonemap.go: presence in `ffmpeg -filters` does NOT prove the filter RUNS —
// scale_cuda needs a working CUDA runtime + device, which the prod debian-slim
// image may lack even with the filter compiled in. So we run the real filter on
// one synthetic frame and require a clean exit, cached per binary.
var (
scaleCudaCacheMu sync.Mutex
scaleCudaCache = map[string]bool{}
)
// FFmpegSupportsScaleCuda reports whether this host can ACTUALLY run scale_cuda
// — a working CUDA device + the filter compiled in. Used to keep an NVENC
// downscale fully on the GPU (decode → scale_cuda → h264_nvenc) instead of
// round-tripping each frame to the CPU for `scale=`, which is the wall on modest
// GPUs. Fails closed: any error → false → the caller keeps the CPU-scale path
// (no regression, just no speedup). Cached per path EXCEPT a context timeout,
// which is transient (a busy box) and must not pin the slow path for the run.
func FFmpegSupportsScaleCuda(ffmpegPath string) bool {
if ffmpegPath == "" {
return false
}
scaleCudaCacheMu.Lock()
if v, ok := scaleCudaCache[ffmpegPath]; ok {
scaleCudaCacheMu.Unlock()
return v
}
scaleCudaCacheMu.Unlock()
// 10 s: first-run CUDA device creation + filter init can take a beat on a
// cold/busy box. Probe the WORST-CASE real input: a 10-bit (p010) surface
// scaled down to 8-bit yuv420p. Most 4K SDR HEVC is Main10, so the gated
// path routinely hands scale_cuda a 10-bit frame; an 8-bit-only probe would
// pass on a host whose scale_cuda can't do the 10→8-bit conversion, and the
// real session would then fail with no CPU fallback. testsrc2 is CPU-side,
// so format=p010le + hwupload_cuda stands in for a hevc_cuda Main10 decode.
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, ffmpegPath,
"-hide_banner", "-loglevel", "error", "-nostats",
"-init_hw_device", "cuda=cu:0", "-filter_hw_device", "cu",
"-f", "lavfi", "-i", "testsrc2=size=256x256:rate=1:duration=1",
"-vf", "format=p010le,hwupload_cuda,scale_cuda=64:64:format=yuv420p,hwdownload,format=yuv420p",
"-frames:v", "1", "-f", "null", "-",
).CombinedOutput()
supported := err == nil
// Cache a stable yes/no, but not a transient deadline (see libplacebo probe).
if supported || ctx.Err() != context.DeadlineExceeded {
scaleCudaCacheMu.Lock()
scaleCudaCache[ffmpegPath] = supported
scaleCudaCacheMu.Unlock()
}
if supported {
log.Printf("[hwscale] ffmpeg scale_cuda works — NVENC SDR downscales stay on the GPU (no CPU round-trip)")
} else {
detail := strings.TrimSpace(lastLine(out))
if detail == "" {
detail = err.Error()
}
log.Printf("[hwscale] ffmpeg scale_cuda unavailable — NVENC keeps the CPU scale path: %v", detail)
}
return supported
}

View file

@ -4,6 +4,7 @@ import (
"context"
"log"
"sync"
"sync/atomic"
"github.com/torrentclaw/unarr/internal/agent"
)
@ -33,12 +34,37 @@ type Manager struct {
// Used by the daemon to trigger an immediate sync.
OnTaskDone func()
// OnStateChange is called after EVERY successful task status transition
// (resolving → downloading → verifying → organizing → seeding → done/failed),
// wired by the daemon to trigger an immediate sync so the server sees state
// changes in near-realtime instead of on the next adaptive tick. Coalesced
// downstream (TriggerSync is a buffered-1 send), so bursts collapse safely.
OnStateChange func()
// recentlyFinished holds tasks that completed/failed since the last sync read.
// The sync goroutine reads and clears this to include final states in the next sync.
recentMu sync.Mutex
recentFinished []agent.TaskState
// taskStore persists in-flight download payloads so the daemon can re-submit
// them after a restart (the downloaders resume the partial data). nil = no
// persistence. shuttingDown gates removal: a task interrupted by a graceful
// shutdown keeps its store entry (so it resumes), unlike a genuine terminal.
taskStore taskPersister
shuttingDown atomic.Bool
}
// taskPersister is the resume store the manager records in-flight downloads to.
// Satisfied by *agent.ActiveTaskStore; an interface so tests can inject a fake.
type taskPersister interface {
Add(agent.Task)
Remove(taskID string)
}
// SetTaskStore wires the resume store. Call once before Submit. Optional —
// without it, downloads are not persisted for cross-restart resume.
func (m *Manager) SetTaskStore(s taskPersister) { m.taskStore = s }
// NewManager creates a download manager.
func NewManager(cfg ManagerConfig, reporter *ProgressReporter, downloaders ...Downloader) *Manager {
if cfg.MaxConcurrent <= 0 {
@ -63,15 +89,35 @@ func NewManager(cfg ManagerConfig, reporter *ProgressReporter, downloaders ...Do
// Submit queues a task for download. Non-blocking if capacity available.
func (m *Manager) Submit(ctx context.Context, at agent.Task) {
task := NewTaskFromAgent(at)
// Event-driven uplink: push every status transition to the server immediately.
task.SetOnChange(m.OnStateChange)
// Per-task cancellable context so CancelTask can unblock the goroutine
taskCtx, taskCancel := context.WithCancel(ctx)
m.activeMu.Lock()
// Dedup: a task can arrive twice — once when the daemon re-submits it from
// the resume store on startup, and again when the web re-dispatches it. The
// second arrival must NOT launch a parallel goroutine for the same files.
if _, exists := m.active[task.ID]; exists {
m.activeMu.Unlock()
taskCancel()
log.Printf("[%s] already active — ignoring duplicate submit", agent.ShortID(task.ID))
return
}
m.active[task.ID] = task
m.cancels[task.ID] = taskCancel
m.activeMu.Unlock()
// Persist real downloads so a daemon restart can resume them (torrent via
// the piece-completion DB, debrid via Range, usenet via its tracker). Stream
// and seed-file tasks are transient — not resumed. Upgrade downloads
// (ReplacePath set) are excluded too: re-running one after an interrupted
// organize could double-download or replace the wrong target.
if m.taskStore != nil && (at.Mode == "" || at.Mode == "download") && at.ReplacePath == "" {
m.taskStore.Add(at)
}
m.reporter.Track(task)
// Force start: bypass semaphore (like Transmission's "Force Start")
@ -176,6 +222,13 @@ func (m *Manager) TaskStates() []agent.TaskState {
// recordFinished stores a completed/failed task for the next sync cycle.
func (m *Manager) recordFinished(update agent.StatusUpdate) {
// Drop from the resume store on a genuine terminal state (completed / failed
// / user-cancelled). A shutdown-interrupted task is NOT removed — it stays so
// the daemon re-submits and resumes it on the next start.
if m.taskStore != nil && !m.shuttingDown.Load() {
m.taskStore.Remove(update.TaskID)
}
m.recentMu.Lock()
defer m.recentMu.Unlock()
m.recentFinished = append(m.recentFinished, agent.TaskStateFromUpdate(update))
@ -271,6 +324,23 @@ func (m *Manager) Wait() {
// Shutdown stops accepting tasks and waits for active downloads to finish.
func (m *Manager) Shutdown(ctx context.Context) {
// Flag shutdown BEFORE cancelling task contexts: tasks interrupted by the
// shutdown then keep their resume-store entry (recordFinished skips the
// removal) so the daemon re-submits and resumes them on the next start.
m.shuttingDown.Store(true)
// Cancel every task context NOW (before waiting). Downloads block on their
// context, so this is what actually unblocks them — and because shuttingDown
// is already set, their recordFinished keeps the resume entry. (Waiting first
// would just stall until the timeout, and relying on the daemon's outer ctx
// cancel would race ahead of shuttingDown and wipe the entries.)
m.activeMu.Lock()
for id, cancel := range m.cancels {
cancel()
delete(m.cancels, id)
}
m.activeMu.Unlock()
// Wait for goroutines with timeout
done := make(chan struct{})
go func() {
@ -281,7 +351,7 @@ func (m *Manager) Shutdown(ctx context.Context) {
select {
case <-done:
case <-ctx.Done():
log.Println("shutdown timeout, cancelling active downloads")
log.Println("shutdown timeout, abandoning active downloads")
}
// Shutdown all downloaders
@ -291,12 +361,7 @@ func (m *Manager) Shutdown(ctx context.Context) {
}
}
// Clean active map and cancel functions
m.activeMu.Lock()
for id, cancel := range m.cancels {
cancel()
delete(m.cancels, id)
}
m.active = make(map[string]*Task)
m.activeMu.Unlock()
}
@ -344,6 +409,12 @@ func (m *Manager) processTask(ctx context.Context, task *Task) {
close(progressCh)
if err != nil {
// A full disk is terminal — another source would fill the same disk, so
// skip the fallback and surface the clear message immediately.
if IsInsufficientDisk(err) {
m.fail(ctx, task, err.Error())
return
}
// Try fallback
if tryFallback(task, m.downloaders) {
log.Printf("[%s] %s failed, trying fallback: %v", agent.ShortID(task.ID), method, err)
@ -386,6 +457,8 @@ func (m *Manager) processTaskRetry(ctx context.Context, task *Task) {
close(progressCh)
if err != nil {
// No further fallback here — same disk, same outcome — so an
// InsufficientDiskError on the fallback surfaces its message directly.
m.fail(ctx, task, err.Error())
return
}

View file

@ -0,0 +1,123 @@
package engine
import (
"context"
"sync"
"testing"
"time"
"github.com/torrentclaw/unarr/internal/agent"
)
// fakePersister is an in-memory taskPersister for asserting manager↔store calls
// without touching disk.
type fakePersister struct {
mu sync.Mutex
tasks map[string]bool
}
func newFakePersister() *fakePersister { return &fakePersister{tasks: map[string]bool{}} }
func (f *fakePersister) Add(t agent.Task) { f.mu.Lock(); f.tasks[t.ID] = true; f.mu.Unlock() }
func (f *fakePersister) Remove(id string) { f.mu.Lock(); delete(f.tasks, id); f.mu.Unlock() }
func (f *fakePersister) has(id string) bool { f.mu.Lock(); defer f.mu.Unlock(); return f.tasks[id] }
func newResumeManager(t *testing.T, p taskPersister) (*Manager, context.Context, context.CancelFunc) {
t.Helper()
reporter := NewProgressReporter(agent.NewClient("http://localhost", "test", "test"), time.Hour)
mgr := NewManager(
ManagerConfig{MaxConcurrent: 2, OutputDir: t.TempDir()},
reporter,
&slowMockDownloader{method: MethodTorrent},
)
mgr.SetTaskStore(p)
ctx, cancel := context.WithCancel(context.Background())
go reporter.Run(ctx)
return mgr, ctx, cancel
}
// dlTask builds a download task. IDs mirror production (UUID-length); the engine
// logs task.ID[:8] in several places, so sub-8-char ids would panic — not a real
// case since the web always sends UUIDs.
func dlTask(id string) agent.Task {
return agent.Task{
ID: "task-uuid-" + id, // ≥ 8 chars like a real dispatch id
InfoHash: "abc123def456abc123def456abc123def456abc1",
Title: "Resume " + id,
PreferredMethod: "torrent",
Mode: "download",
}
}
func TestManager_SubmitDedupes(t *testing.T) {
mgr, ctx, cancel := newResumeManager(t, newFakePersister())
defer cancel()
task := dlTask("dup-1")
mgr.Submit(ctx, task)
mgr.Submit(ctx, task) // duplicate id — must not launch a second download
if n := mgr.ActiveCount(); n != 1 {
t.Errorf("ActiveCount = %d after duplicate submit, want 1", n)
}
cancel()
mgr.Wait()
}
func TestManager_PersistsDownloadAndRemovesOnTerminal(t *testing.T) {
p := newFakePersister()
mgr, ctx, cancel := newResumeManager(t, p)
defer cancel()
task := dlTask("t1")
mgr.Submit(ctx, task)
if !p.has(task.ID) {
t.Fatal("download not persisted to the resume store on submit")
}
// A genuine terminal (user cancel, not shutdown) must remove it.
mgr.CancelTask(task.ID)
mgr.Wait()
if p.has(task.ID) {
t.Error("task still in resume store after a genuine terminal — should be removed")
}
}
func TestManager_KeepsStoreEntryOnShutdown(t *testing.T) {
p := newFakePersister()
mgr, ctx, cancel := newResumeManager(t, p)
defer cancel()
task := dlTask("s1")
mgr.Submit(ctx, task)
if !p.has(task.ID) {
t.Fatal("download not persisted on submit")
}
// Shutdown interrupts the in-flight download — the entry must SURVIVE so the
// daemon re-submits and resumes it next start.
// Shutdown cancels the task contexts itself then waits, so once it returns
// the interrupted task's recordFinished has run (and must have skipped the
// removal because shuttingDown is set) — no sleep/poll needed.
shutCtx, sc := context.WithTimeout(context.Background(), 5*time.Second)
defer sc()
mgr.Shutdown(shutCtx)
if !p.has(task.ID) {
t.Error("task removed from resume store on shutdown — it would not resume")
}
}
func TestManager_DoesNotPersistStreamTasks(t *testing.T) {
p := newFakePersister()
mgr, ctx, cancel := newResumeManager(t, p)
defer cancel()
task := dlTask("stream-1")
task.Mode = "stream"
mgr.Submit(ctx, task)
if p.has(task.ID) {
t.Error("stream task persisted to resume store — only downloads should be")
}
cancel()
mgr.Wait()
}

View file

@ -9,7 +9,7 @@ import (
)
// StreamProbe summarises the codec / container shape of a file as it relates
// to the WebRTC streaming pipeline. It tells the transcoder whether bytes can
// to the HLS streaming pipeline. It tells the transcoder whether bytes can
// be streamed as-is, just remuxed to fragmented MP4, or fully transcoded.
type StreamProbe struct {
// VideoCodec lowercased — e.g. "h264", "hevc", "av1", "vp9", "mpeg4".
@ -50,18 +50,22 @@ type ProbeAudioTrack struct {
// Codec discriminates text (srt/ass/webvtt → extract to WebVTT) vs bitmap
// (pgs/dvbsub → require burn-in).
type ProbeSubtitleTrack struct {
Index int // 0-based subtitle stream index (ffmpeg -map 0:s:Index)
Index int // 0-based EMBEDDED subtitle stream index (ffmpeg -map 0:s:Index). Unused when External.
Lang string // ISO 639-1
Codec string // lowercased — "subrip", "ass", "webvtt", "hdmv_pgs_subtitle", ...
Title string
Forced bool
// External marks a sidecar file (served via /sub?p=<Path>&i=-1) rather than
// an embedded stream. Path is its absolute filesystem path (External only).
External bool
Path string
}
// IsTextSubtitle reports whether a subtitle codec can be extracted to WebVTT
// without re-rendering. Bitmap subs (PGS, DVB) need burn-in.
func (s ProbeSubtitleTrack) IsTextSubtitle() bool {
switch s.Codec {
case "subrip", "srt", "ass", "ssa", "webvtt", "mov_text":
case "subrip", "srt", "ass", "ssa", "webvtt", "mov_text", "text":
return true
}
return false
@ -88,7 +92,15 @@ const (
)
// ProbeFile runs ffprobe and returns a StreamProbe view of the file.
//
// Result is memoised by (path, mtime, size) for probeCacheTTL — repeat plays
// of the same file at the same quality (the HLS cache HIT path) skip ffprobe
// entirely. ffprobe on a 50 GB MKV can cost 1-3 s; first-segment latency
// shrinks by the same amount on the second play.
func ProbeFile(ctx context.Context, ffprobePath, filePath string) (*StreamProbe, error) {
if cached, ok := lookupProbeCache(filePath); ok {
return cached, nil
}
mi, err := mediainfo.ExtractMediaInfo(ctx, ffprobePath, filePath)
if err != nil {
return nil, fmt.Errorf("probe: %w", err)
@ -126,16 +138,30 @@ func ProbeFile(ctx context.Context, ffprobePath, filePath string) (*StreamProbe,
}
if len(mi.Subtitles) > 0 {
probe.SubtitleTracks = make([]ProbeSubtitleTrack, 0, len(mi.Subtitles))
for i, s := range mi.Subtitles {
probe.SubtitleTracks = append(probe.SubtitleTracks, ProbeSubtitleTrack{
Index: i,
// Embedded streams come first (ffprobe order); external sidecars are
// appended after. Count embedded separately so each embedded track's
// Index is its true `0:s:N` value regardless of how many externals trail
// it; externals get Index=-1 and address by Path instead.
embeddedIdx := 0
for _, s := range mi.Subtitles {
t := ProbeSubtitleTrack{
Lang: s.Lang,
Codec: strings.ToLower(s.Codec),
Title: s.Title,
Forced: s.Forced,
})
External: s.External,
Path: s.Path,
}
if s.External {
t.Index = -1
} else {
t.Index = embeddedIdx
embeddedIdx++
}
probe.SubtitleTracks = append(probe.SubtitleTracks, t)
}
}
storeProbeCache(filePath, probe)
return probe, nil
}

View file

@ -0,0 +1,141 @@
package engine
import (
"os"
"sync"
"time"
)
// probeCacheTTL is how long a cached probe stays usable. The cache key
// already incorporates mtime + size, so the TTL is a defense against
// runaway memory growth from stale paths, not a freshness guarantee — a
// rename + recreate at the same inode (rare) would still be caught by the
// mtime delta.
const probeCacheTTL = 30 * time.Minute
// probeCacheJanitorInterval is how often the background sweeper wakes to
// drop expired entries. Lookup-time eviction handles hot paths, but a
// user who browses 5k files and then stops would leak entries until each
// is individually re-touched. 5 min ≈ 6 sweeps per TTL window — enough
// to keep memory bounded without burning CPU.
const probeCacheJanitorInterval = 5 * time.Minute
type probeCacheEntry struct {
probe *StreamProbe
expires time.Time
}
type probeCacheKey struct {
path string
mtime int64 // ModTime().UnixNano()
size int64
}
var (
probeCacheMu sync.RWMutex
probeCache = make(map[probeCacheKey]probeCacheEntry)
probeCacheJanitor sync.Once
)
// startProbeCacheJanitor launches the background sweeper exactly once per
// process. Lazy — fired on first storeProbeCache. Drops expired entries
// every probeCacheJanitorInterval. Idempotent (sync.Once).
func startProbeCacheJanitor() {
probeCacheJanitor.Do(func() {
go func() {
ticker := time.NewTicker(probeCacheJanitorInterval)
defer ticker.Stop()
for range ticker.C {
sweepProbeCache(time.Now())
}
}()
})
}
// sweepProbeCache removes every entry whose expiry is at or before `now`.
// Exposed for tests; production code calls it indirectly via the janitor
// goroutine.
func sweepProbeCache(now time.Time) int {
probeCacheMu.Lock()
defer probeCacheMu.Unlock()
removed := 0
for k, e := range probeCache {
if !now.Before(e.expires) {
delete(probeCache, k)
removed++
}
}
return removed
}
// lookupProbeCache returns the cached StreamProbe for the given path if its
// mtime + size still match the value recorded at insert time, AND the cache
// entry hasn't expired. Any stat failure / mismatch returns (nil, false) so
// the caller falls through to a fresh ffprobe run.
func lookupProbeCache(path string) (*StreamProbe, bool) {
fi, err := os.Stat(path)
if err != nil {
return nil, false
}
key := probeCacheKey{
path: path,
mtime: fi.ModTime().UnixNano(),
size: fi.Size(),
}
probeCacheMu.RLock()
entry, ok := probeCache[key]
probeCacheMu.RUnlock()
if !ok {
return nil, false
}
if time.Now().After(entry.expires) {
// Re-check under the write lock so a concurrent re-insert (same key,
// fresh expiry) isn't accidentally evicted.
probeCacheMu.Lock()
if cur, stillThere := probeCache[key]; stillThere && time.Now().After(cur.expires) {
delete(probeCache, key)
}
probeCacheMu.Unlock()
return nil, false
}
return entry.probe, true
}
// storeProbeCache stashes a fresh probe result under the (path, mtime, size)
// key. A subsequent ffprobe-skipping HIT requires the file to still have the
// same mtime + size — anything else (re-encoded, renamed+recreated at the
// same path, truncated) misses and triggers a re-probe.
func storeProbeCache(path string, probe *StreamProbe) {
fi, err := os.Stat(path)
if err != nil {
return
}
key := probeCacheKey{
path: path,
mtime: fi.ModTime().UnixNano(),
size: fi.Size(),
}
probeCacheMu.Lock()
probeCache[key] = probeCacheEntry{
probe: probe,
expires: time.Now().Add(probeCacheTTL),
}
probeCacheMu.Unlock()
// Lazy janitor — fires once per process. No-op after first call.
startProbeCacheJanitor()
}
// ResetProbeCache clears the in-memory probe cache. Test-only.
func ResetProbeCache() {
probeCacheMu.Lock()
probeCache = make(map[probeCacheKey]probeCacheEntry)
probeCacheMu.Unlock()
}
// ProbeCacheSize returns the number of entries currently cached. Exposed
// for diagnostics + tests.
func ProbeCacheSize() int {
probeCacheMu.RLock()
defer probeCacheMu.RUnlock()
return len(probeCache)
}

View file

@ -0,0 +1,202 @@
package engine
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestProbeCache_LookupMissNonexistent(t *testing.T) {
ResetProbeCache()
t.Cleanup(ResetProbeCache)
if _, ok := lookupProbeCache("/path/that/does/not/exist"); ok {
t.Fatal("expected MISS for non-existent path")
}
}
func TestProbeCache_StoreThenLookupHit(t *testing.T) {
ResetProbeCache()
t.Cleanup(ResetProbeCache)
dir := t.TempDir()
path := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(path, []byte("fake content"), 0o644); err != nil {
t.Fatalf("write tmp file: %v", err)
}
probe := &StreamProbe{VideoCodec: "h264", Width: 1920, Height: 1080, DurationSec: 5400}
storeProbeCache(path, probe)
got, ok := lookupProbeCache(path)
if !ok {
t.Fatal("expected HIT after store")
}
if got != probe {
t.Fatalf("expected pointer-identical probe; got different")
}
}
func TestProbeCache_MtimeChangeInvalidates(t *testing.T) {
ResetProbeCache()
t.Cleanup(ResetProbeCache)
dir := t.TempDir()
path := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(path, []byte("original"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
probe := &StreamProbe{VideoCodec: "h264", DurationSec: 100}
storeProbeCache(path, probe)
// Force mtime change. WriteFile doesn't guarantee a different mtime if
// the filesystem timestamp resolution is coarse, so set it explicitly
// to a value 1 hour in the future.
future := time.Now().Add(1 * time.Hour)
if err := os.Chtimes(path, future, future); err != nil {
t.Fatalf("chtimes: %v", err)
}
if _, ok := lookupProbeCache(path); ok {
t.Fatal("expected MISS after mtime change")
}
}
func TestProbeCache_SizeChangeInvalidates(t *testing.T) {
ResetProbeCache()
t.Cleanup(ResetProbeCache)
dir := t.TempDir()
path := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(path, []byte("aaaaa"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
originalMtime := time.Now().Add(-1 * time.Hour) // stable, in the past
if err := os.Chtimes(path, originalMtime, originalMtime); err != nil {
t.Fatalf("chtimes original: %v", err)
}
probe := &StreamProbe{VideoCodec: "h264", DurationSec: 100}
storeProbeCache(path, probe)
// Truncate to a different size, then reset mtime to the original so
// only `size` differs between store and lookup keys — isolates the
// size-check path. Without the Chtimes, WriteFile bumps mtime and the
// test would pass via mtime invalidation regardless of size logic.
if err := os.WriteFile(path, []byte("a"), 0o644); err != nil {
t.Fatalf("rewrite: %v", err)
}
if err := os.Chtimes(path, originalMtime, originalMtime); err != nil {
t.Fatalf("chtimes restore: %v", err)
}
if _, ok := lookupProbeCache(path); ok {
t.Fatal("expected MISS after size change")
}
}
func TestProbeCache_ExpiryDropsEntry(t *testing.T) {
ResetProbeCache()
t.Cleanup(ResetProbeCache)
dir := t.TempDir()
path := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(path, []byte("content"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
// Stash an entry whose expires is already in the past — simulates TTL
// having elapsed without sleeping for 30 min.
fi, err := os.Stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
key := probeCacheKey{path: path, mtime: fi.ModTime().UnixNano(), size: fi.Size()}
probeCacheMu.Lock()
probeCache[key] = probeCacheEntry{
probe: &StreamProbe{VideoCodec: "h264"},
expires: time.Now().Add(-1 * time.Minute),
}
probeCacheMu.Unlock()
if _, ok := lookupProbeCache(path); ok {
t.Fatal("expected MISS for expired entry")
}
// Side-effect: lookup should have evicted the stale entry.
if ProbeCacheSize() != 0 {
t.Fatalf("expected cache size 0 after expiry eviction; got %d", ProbeCacheSize())
}
}
func TestProbeCache_ResetClears(t *testing.T) {
ResetProbeCache()
dir := t.TempDir()
path := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
storeProbeCache(path, &StreamProbe{VideoCodec: "h264"})
if ProbeCacheSize() != 1 {
t.Fatalf("expected size 1 after store; got %d", ProbeCacheSize())
}
ResetProbeCache()
if ProbeCacheSize() != 0 {
t.Fatalf("expected size 0 after reset; got %d", ProbeCacheSize())
}
}
func TestProbeCache_StoreNonexistentNoOp(t *testing.T) {
ResetProbeCache()
t.Cleanup(ResetProbeCache)
// Store on a non-existent path should silently do nothing (stat fails),
// not panic, and not poison the cache with a zero key.
storeProbeCache("/nope/never/exists.mkv", &StreamProbe{VideoCodec: "h264"})
if ProbeCacheSize() != 0 {
t.Fatalf("expected 0 entries; got %d", ProbeCacheSize())
}
}
func TestProbeCache_SweepDropsExpired(t *testing.T) {
ResetProbeCache()
t.Cleanup(ResetProbeCache)
dir := t.TempDir()
// Two entries: one expired, one fresh.
expiredPath := filepath.Join(dir, "old.mkv")
freshPath := filepath.Join(dir, "new.mkv")
if err := os.WriteFile(expiredPath, []byte("a"), 0o644); err != nil {
t.Fatalf("write expired: %v", err)
}
if err := os.WriteFile(freshPath, []byte("b"), 0o644); err != nil {
t.Fatalf("write fresh: %v", err)
}
now := time.Now()
fiExp, _ := os.Stat(expiredPath)
fiFresh, _ := os.Stat(freshPath)
probeCacheMu.Lock()
probeCache[probeCacheKey{path: expiredPath, mtime: fiExp.ModTime().UnixNano(), size: fiExp.Size()}] = probeCacheEntry{
probe: &StreamProbe{VideoCodec: "h264"},
expires: now.Add(-1 * time.Minute), // expired
}
probeCache[probeCacheKey{path: freshPath, mtime: fiFresh.ModTime().UnixNano(), size: fiFresh.Size()}] = probeCacheEntry{
probe: &StreamProbe{VideoCodec: "h264"},
expires: now.Add(10 * time.Minute), // fresh
}
probeCacheMu.Unlock()
removed := sweepProbeCache(now)
if removed != 1 {
t.Fatalf("expected 1 expired entry removed; got %d", removed)
}
if ProbeCacheSize() != 1 {
t.Fatalf("expected 1 fresh entry kept; got %d", ProbeCacheSize())
}
}

View file

@ -45,10 +45,19 @@ type ProgressReporter struct {
lastCheckAt time.Time // last time we reported for control-signal polling
}
// NewProgressReporter creates a reporter that flushes every interval.
// NewProgressReporter creates a reporter that flushes every interval. A nil
// client yields a local-only reporter that tracks progress for terminal output
// but never calls the API — used by one-shot `unarr download`, which has no
// server-side task to report against (its synthetic "oneshot-" id is not a UUID
// and the /api/internal/agent/status endpoint 400s it). Passing the typed nil
// straight into the interface field would make it non-nil, so guard explicitly.
func NewProgressReporter(ac *agent.Client, interval time.Duration) *ProgressReporter {
var rep StatusReporter
if ac != nil {
rep = ac
}
return &ProgressReporter{
reporter: ac,
reporter: rep,
interval: interval,
latest: make(map[string]*Task),
lastReported: make(map[string]TaskStatus),
@ -108,6 +117,9 @@ func (r *ProgressReporter) Run(ctx context.Context) error {
}
func (r *ProgressReporter) flush(ctx context.Context) {
if r.reporter == nil {
return // local-only reporter (one-shot): nothing to send
}
r.mu.Lock()
tasks := make([]*Task, 0, len(r.latest))
for _, t := range r.latest {
@ -239,6 +251,10 @@ func (r *ProgressReporter) handleResponse(task *Task, resp *agent.StatusResponse
// ReportFinal sends a final status update for a completed/failed task.
func (r *ProgressReporter) ReportFinal(ctx context.Context, task *Task) {
if r.reporter == nil {
r.Untrack(task.ID)
return // local-only reporter (one-shot)
}
update := task.ToStatusUpdate()
if _, err := r.reporter.ReportStatus(ctx, update); err != nil {
log.Printf("[%s] final report failed: %v", task.ID[:8], err)

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

@ -1,138 +0,0 @@
package engine
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/bencode"
"github.com/anacrolix/torrent/metainfo"
)
// SeedFile builds a single-file torrent from an arbitrary on-disk file
// and adds it to an existing torrent client so the WebRTC peer wire
// (already configured on the client) can serve the file to a browser
// that knows the resulting info-hash.
//
// Returns the generated info-hash. The torrent is left attached to the
// client — caller is responsible for keeping it alive while a browser
// is watching. Drop it via Client.RemoveTorrent / Torrent.Drop when
// idle to free resources.
//
// Behaviour notes:
// - The file must already exist; no download is attempted.
// - Piece length follows the libtorrent ladder (16 KiB → 4 MiB).
// - The torrent is "complete" from the agent's POV — it has every
// piece — so the upload-only flow kicks in immediately.
// - WebRTC peer behaviour comes from the client config the caller
// constructed; SeedFile does not toggle DisableWebtorrent itself.
// If the operator's [downloads.webrtc].enabled = false, the file
// is still added but no browser will discover it via WSS tracker.
func SeedFile(client *torrent.Client, filePath string, trackerURLs []string) (metainfo.Hash, error) {
if client == nil {
return metainfo.Hash{}, errors.New("seed_file: torrent client is nil")
}
if filePath == "" {
return metainfo.Hash{}, errors.New("seed_file: filePath is empty")
}
abs, err := filepath.Abs(filePath)
if err != nil {
return metainfo.Hash{}, fmt.Errorf("seed_file: resolve path: %w", err)
}
st, err := os.Stat(abs)
if err != nil {
return metainfo.Hash{}, fmt.Errorf("seed_file: stat: %w", err)
}
if st.IsDir() {
return metainfo.Hash{}, fmt.Errorf("seed_file: only single files are supported, %s is a directory", abs)
}
info := metainfo.Info{
PieceLength: chooseSeedPieceLength(st.Size()),
Name: filepath.Base(abs),
}
if err := info.BuildFromFilePath(abs); err != nil {
return metainfo.Hash{}, fmt.Errorf("seed_file: build info: %w", err)
}
infoBytes, err := bencode.Marshal(info)
if err != nil {
return metainfo.Hash{}, fmt.Errorf("seed_file: marshal info: %w", err)
}
mi := &metainfo.MetaInfo{
InfoBytes: infoBytes,
AnnounceList: makeAnnounceList(trackerURLs),
CreatedBy: "unarr-seed-file",
CreationDate: time.Now().Unix(),
}
ih := mi.HashInfoBytes()
t, err := client.AddTorrent(mi)
if err != nil {
return metainfo.Hash{}, fmt.Errorf("seed_file: add torrent: %w", err)
}
// Mark every piece as needed so the client treats us as a complete
// seeder right away — anacrolix's verifier will hash the file
// asynchronously and flip pieces to "have" as it goes.
t.DownloadAll()
return ih, nil
}
// makeAnnounceList shapes the tracker URL slice into the bencoded
// AnnounceList format anacrolix expects.
func makeAnnounceList(urls []string) metainfo.AnnounceList {
if len(urls) == 0 {
return nil
}
tier := make([]string, 0, len(urls))
for _, u := range urls {
if u == "" {
continue
}
tier = append(tier, u)
}
if len(tier) == 0 {
return nil
}
return metainfo.AnnounceList{tier}
}
// chooseSeedPieceLength picks the piece size for a single-file torrent
// based on the libtorrent / qBittorrent ladder. Mirrored from the
// wstracker-probe seeder so generated torrents are interoperable.
func chooseSeedPieceLength(size int64) int64 {
switch {
case size < 4*1024*1024:
return 16 * 1024
case size < 64*1024*1024:
return 64 * 1024
case size < 512*1024*1024:
return 256 * 1024
case size < 4*1024*1024*1024:
return 1024 * 1024
default:
return 4 * 1024 * 1024
}
}
// SeedFileOnDownloader is a convenience wrapper that pulls the
// underlying anacrolix client out of a TorrentDownloader and forwards
// to SeedFile. trackerURLs default to the downloader's WebRTC
// trackers when nil/empty.
func SeedFileOnDownloader(d *TorrentDownloader, filePath string) (metainfo.Hash, error) {
if d == nil {
return metainfo.Hash{}, errors.New("seed_file: downloader is nil")
}
trackers := d.cfg.WebRTCTrackers
if !d.cfg.WebRTCEnabled {
// We could still build the torrent, but no browser would find
// it via the WSS tracker — bail loud so the operator notices.
return metainfo.Hash{}, errors.New("seed_file: WebRTC peer disabled in config; set [downloads.webrtc].enabled = true to use this feature")
}
return SeedFile(d.client, filePath, trackers)
}

View file

@ -1,164 +0,0 @@
package engine
import (
"context"
"os"
"path/filepath"
"testing"
)
// TestSeedFile_RejectsMissingFile — explicit error rather than crashing
// inside anacrolix when the path doesn't exist.
func TestSeedFile_RejectsMissingFile(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: dir,
ListenPort: 0,
WebRTCEnabled: true,
WebRTCTrackers: []string{"wss://tracker.torrentclaw.com"},
})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
defer dl.Shutdown(context.Background())
if _, err := SeedFile(dl.client, "/nonexistent/path", nil); err == nil {
t.Fatal("expected error for missing file")
}
}
// TestSeedFile_RejectsDirectory — single-file torrents only for now.
func TestSeedFile_RejectsDirectory(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: dir,
ListenPort: 0,
WebRTCEnabled: true,
WebRTCTrackers: []string{"wss://tracker.torrentclaw.com"},
})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
defer dl.Shutdown(context.Background())
subDir := filepath.Join(dir, "sub")
if err := os.Mkdir(subDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if _, err := SeedFile(dl.client, subDir, nil); err == nil {
t.Fatal("expected error for directory path")
}
}
// TestSeedFile_BuildsDeterministicInfoHash — the same file should yield
// the same info_hash on every call so the web client can poll for it.
func TestSeedFile_BuildsDeterministicInfoHash(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "data.bin")
payload := []byte("hello world — torrentclaw seed_file test")
if err := os.WriteFile(file, payload, 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
mkClient := func() *TorrentDownloader {
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: t.TempDir(),
ListenPort: 0,
WebRTCEnabled: true,
WebRTCTrackers: []string{"wss://tracker.torrentclaw.com"},
})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
return dl
}
dl1 := mkClient()
defer dl1.Shutdown(context.Background())
hash1, err := SeedFile(dl1.client, file, []string{"wss://tracker.torrentclaw.com"})
if err != nil {
t.Fatalf("first SeedFile: %v", err)
}
dl2 := mkClient()
defer dl2.Shutdown(context.Background())
hash2, err := SeedFile(dl2.client, file, []string{"wss://tracker.torrentclaw.com"})
if err != nil {
t.Fatalf("second SeedFile: %v", err)
}
if hash1 != hash2 {
t.Fatalf("info_hash not deterministic: %s vs %s", hash1.HexString(), hash2.HexString())
}
if hash1.HexString() == "" || len(hash1.HexString()) != 40 {
t.Fatalf("info_hash is not 40 hex chars: %q", hash1.HexString())
}
}
// TestSeedFileOnDownloader_RequiresWebRTC — silent failure mode is the
// worst UX; bail loud when the operator hasn't opted into WebRTC.
func TestSeedFileOnDownloader_RequiresWebRTC(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: dir,
ListenPort: 0,
WebRTCEnabled: false,
})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
defer dl.Shutdown(context.Background())
file := filepath.Join(dir, "data.bin")
if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
if _, err := SeedFileOnDownloader(dl, file); err == nil {
t.Fatal("expected error when WebRTC disabled")
}
}
// TestChooseSeedPieceLength_LadderShape — sanity-check the breakpoints
// stay aligned with the libtorrent reference (16 KiB → 4 MiB).
func TestChooseSeedPieceLength_LadderShape(t *testing.T) {
cases := []struct {
size int64
expect int64
}{
{1, 16 * 1024},
{4 * 1024 * 1024, 64 * 1024},
{64 * 1024 * 1024, 256 * 1024},
{512 * 1024 * 1024, 1024 * 1024},
{4 * 1024 * 1024 * 1024, 4 * 1024 * 1024},
}
for _, c := range cases {
if got := chooseSeedPieceLength(c.size); got != c.expect {
t.Errorf("chooseSeedPieceLength(%d) = %d want %d", c.size, got, c.expect)
}
}
}
// TestMakeAnnounceList_HandlesEmpty — nil/empty in → nil out, so
// AddTorrent doesn't see a dangling tier with no URLs.
func TestMakeAnnounceList_HandlesEmpty(t *testing.T) {
if got := makeAnnounceList(nil); got != nil {
t.Errorf("nil input should yield nil announce list, got %+v", got)
}
if got := makeAnnounceList([]string{}); got != nil {
t.Errorf("empty input should yield nil announce list, got %+v", got)
}
if got := makeAnnounceList([]string{"", " ", ""}); got != nil {
// Empty strings should be filtered; if everything is empty,
// nil is the right answer.
// (We do NOT trim whitespace today — only literal "".)
if len(got) != 1 || len(got[0]) != 1 {
t.Errorf("expected 1 single-element tier, got %+v", got)
}
}
got := makeAnnounceList([]string{"wss://a", "", "wss://b"})
if len(got) != 1 || len(got[0]) != 2 {
t.Fatalf("expected 1 tier of 2 URLs, got %+v", got)
}
}

View file

@ -0,0 +1,118 @@
//go:build smoke
package engine
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/bencode"
"github.com/anacrolix/torrent/metainfo"
)
// TestSeedLifecycleSmoke spins up a real loopback BitTorrent swarm: a seeder
// client serving a small file, and our TorrentDownloader's client leeching it.
// Once the leecher completes, the torrent is handed to seedAndDrop with a short
// SeedTime; the test asserts the lifecycle fires and the handle is dropped
// (removed from d.active). Exercises the real anacrolix Stats/Drop/ticker path,
// not mocks. Run with: go test -tags smoke -run TestSeedLifecycleSmoke ./internal/engine/
func TestSeedLifecycleSmoke(t *testing.T) {
// --- seeder: a real client serving a 4 MiB file over loopback ---
seedDir := t.TempDir()
payload := make([]byte, 4<<20)
for i := range payload {
payload[i] = byte(i)
}
if err := os.WriteFile(filepath.Join(seedDir, "movie.bin"), payload, 0o644); err != nil {
t.Fatal(err)
}
var info metainfo.Info
info.PieceLength = 256 << 10
if err := info.BuildFromFilePath(filepath.Join(seedDir, "movie.bin")); err != nil {
t.Fatalf("build info: %v", err)
}
var mi metainfo.MetaInfo
var err error
if mi.InfoBytes, err = bencode.Marshal(info); err != nil {
t.Fatalf("marshal info: %v", err)
}
scfg := torrent.NewDefaultClientConfig()
scfg.DataDir = seedDir
scfg.Seed = true
scfg.NoDHT = true
scfg.DisableTrackers = true
scfg.ListenPort = 0 // random — never collides with the leecher's 42069
seeder, err := torrent.NewClient(scfg)
if err != nil {
t.Fatalf("seeder client: %v", err)
}
defer seeder.Close()
st, err := seeder.AddTorrent(&mi)
if err != nil {
t.Fatalf("seeder add: %v", err)
}
<-st.GotInfo()
st.DownloadAll() // verifies the existing pieces so the seeder is "complete"
// --- leecher: our downloader, seeding enabled, very short seed time ---
leechDir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: leechDir,
SeedEnabled: true,
SeedTime: 1 * time.Second, // time target fires fast (no peers pull from us, so ratio stays 0)
})
if err != nil {
t.Fatalf("downloader: %v", err)
}
dl.seedCheckInterval = 200 * time.Millisecond // poll fast so the 1s target is noticed promptly
defer dl.Shutdown(context.Background())
lt, err := dl.client.AddTorrent(&mi)
if err != nil {
t.Fatalf("leecher add: %v", err)
}
<-lt.GotInfo()
lt.AddClientPeer(seeder) // loopback peer — no DHT/tracker needed
lt.DownloadAll()
deadline := time.After(30 * time.Second)
for lt.BytesMissing() > 0 {
select {
case <-deadline:
t.Fatalf("download did not complete (missing %d bytes)", lt.BytesMissing())
case <-time.After(100 * time.Millisecond):
}
}
t.Logf("leecher completed %d bytes", lt.BytesCompleted())
// Track it as the daemon would for a seeding torrent, then run the lifecycle.
const taskID = "smoke-seed-task-0001"
dl.activeMu.Lock()
dl.active[taskID] = lt
dl.activeMu.Unlock()
done := make(chan struct{})
go func() {
dl.seedAndDrop(taskID, lt, info.Length)
close(done)
}()
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("seedAndDrop did not return within 10s")
}
dl.activeMu.Lock()
_, stillTracked := dl.active[taskID]
dl.activeMu.Unlock()
if stillTracked {
t.Error("torrent still tracked after seedAndDrop — lifecycle did not drop it")
}
}

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

@ -0,0 +1,222 @@
package engine
import (
"io"
"net/http"
"net/http/httptest"
"testing"
)
// fakeGrowing is a GrowingSource backed by a fixed byte slice. When final is
// true it behaves like a completed remux (ReadAt returns io.EOF at the end);
// est overrides the advertised estimate (0 = use len(data)).
type fakeGrowing struct {
data []byte
final bool
est int64
}
func (f *fakeGrowing) ReadAt(p []byte, off int64) (int, error) {
if off < 0 || off >= int64(len(f.data)) {
return 0, io.EOF
}
n := copy(p, f.data[off:])
if int(off)+n >= len(f.data) {
return n, io.EOF
}
return n, nil
}
func (f *fakeGrowing) Size() int64 { return int64(len(f.data)) }
func (f *fakeGrowing) Final() bool { return f.final }
func (f *fakeGrowing) EstimatedSize() int64 {
if f.est > 0 {
return f.est
}
return int64(len(f.data))
}
func (f *fakeGrowing) FileName() string { return "movie.mp4" }
func (f *fakeGrowing) Close() error { return nil }
func TestParseByteRange(t *testing.T) {
cases := []struct {
in string
start, end int64
}{
{"", 0, -1},
{"bytes=0-", 0, -1},
{"bytes=100-", 100, -1},
{"bytes=5-9", 5, 9},
{"bytes=0-0", 0, 0},
{"bytes=10-19,40-49", 10, 19}, // first range only
{"bytes=-500", 0, -1}, // suffix unsupported → open from 0
{"garbage", 0, -1},
{"bytes=", 0, -1},
}
for _, c := range cases {
s, e := parseByteRange(c.in)
if s != c.start || e != c.end {
t.Errorf("parseByteRange(%q) = (%d,%d), want (%d,%d)", c.in, s, e, c.start, c.end)
}
}
}
func TestServeGrowing_FinalFullRequest(t *testing.T) {
data := []byte("0123456789abcdef")
src := &fakeGrowing{data: data, final: true}
ss := &StreamServer{}
req := httptest.NewRequest(http.MethodGet, "/stream", nil)
rec := httptest.NewRecorder()
ss.serveGrowing(rec, req, src)
res := rec.Result()
if res.StatusCode != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", res.StatusCode)
}
if got := res.Header.Get("Content-Range"); got != "bytes 0-15/16" {
t.Errorf("Content-Range = %q, want bytes 0-15/16", got)
}
if got := res.Header.Get("Accept-Ranges"); got != "bytes" {
t.Errorf("Accept-Ranges = %q, want bytes", got)
}
if got := res.Header.Get("Content-Type"); got != "video/mp4" {
t.Errorf("Content-Type = %q, want video/mp4", got)
}
// Final + open-ended → exact Content-Length.
if got := res.Header.Get("Content-Length"); got != "16" {
t.Errorf("Content-Length = %q, want 16", got)
}
if body := rec.Body.String(); body != string(data) {
t.Errorf("body = %q, want %q", body, string(data))
}
}
func TestServeGrowing_OffsetRange(t *testing.T) {
data := []byte("0123456789abcdef")
src := &fakeGrowing{data: data, final: true}
ss := &StreamServer{}
req := httptest.NewRequest(http.MethodGet, "/stream", nil)
req.Header.Set("Range", "bytes=10-")
rec := httptest.NewRecorder()
ss.serveGrowing(rec, req, src)
res := rec.Result()
if res.StatusCode != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", res.StatusCode)
}
if got := res.Header.Get("Content-Range"); got != "bytes 10-15/16" {
t.Errorf("Content-Range = %q, want bytes 10-15/16", got)
}
if body := rec.Body.String(); body != "abcdef" {
t.Errorf("body = %q, want abcdef", body)
}
}
func TestServeGrowing_BoundedRange(t *testing.T) {
data := []byte("0123456789abcdef")
src := &fakeGrowing{data: data, final: true}
ss := &StreamServer{}
req := httptest.NewRequest(http.MethodGet, "/stream", nil)
req.Header.Set("Range", "bytes=5-9")
rec := httptest.NewRecorder()
ss.serveGrowing(rec, req, src)
res := rec.Result()
if res.StatusCode != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", res.StatusCode)
}
if got := res.Header.Get("Content-Range"); got != "bytes 5-9/16" {
t.Errorf("Content-Range = %q, want bytes 5-9/16", got)
}
if body := rec.Body.String(); body != "56789" {
t.Errorf("body = %q, want 56789 (exactly the requested 5 bytes)", body)
}
}
func TestServeGrowing_EstimateUsedWhileNotFinal(t *testing.T) {
// Not final: only 8 bytes produced, estimate says 100. We advertise the
// estimate as the total — iOS/WebKit refuses to play a <video src> whose
// "bytes=0-1" probe comes back without a concrete instance length, so "/*"
// (unknown total) is not an option. The estimate need not be byte-exact;
// the real re-seek loop was the malformed init segment (fixed by
// +delay_moov), not the advertised total. Body is what exists so far.
src := &fakeGrowing{data: []byte("01234567"), final: false, est: 100}
ss := &StreamServer{}
req := httptest.NewRequest(http.MethodGet, "/stream", nil)
rec := httptest.NewRecorder()
ss.serveGrowing(rec, req, src)
res := rec.Result()
if res.StatusCode != http.StatusPartialContent {
t.Fatalf("status = %d, want 206", res.StatusCode)
}
if got := res.Header.Get("Content-Range"); got != "bytes 0-99/100" {
t.Errorf("Content-Range = %q, want bytes 0-99/100 (estimate)", got)
}
// Not final → no exact Content-Length (chunked) so we never promise bytes
// a still-running remux might not produce.
if got := res.Header.Get("Content-Length"); got != "" {
t.Errorf("Content-Length = %q, want empty (chunked) while not final", got)
}
if body := rec.Body.String(); body != "01234567" {
t.Errorf("body = %q, want 01234567 (bytes produced so far)", body)
}
}
func TestServeGrowing_HeadProbe(t *testing.T) {
// HEAD: advertise the total (estimate while growing) so iOS gets the size
// it needs from its probe.
src := &fakeGrowing{data: make([]byte, 0), final: false, est: 4242}
ss := &StreamServer{}
req := httptest.NewRequest(http.MethodHead, "/stream", nil)
rec := httptest.NewRecorder()
ss.serveGrowing(rec, req, src)
res := rec.Result()
if res.StatusCode != http.StatusOK {
t.Fatalf("HEAD status = %d, want 200", res.StatusCode)
}
if got := res.Header.Get("Content-Length"); got != "4242" {
t.Errorf("HEAD Content-Length = %q, want 4242", got)
}
if rec.Body.Len() != 0 {
t.Errorf("HEAD body = %d bytes, want 0", rec.Body.Len())
}
}
func TestServeGrowing_ProbeRangeCarriesTotal(t *testing.T) {
// The iOS "bytes=0-1" probe MUST come back with a concrete instance length
// (bytes 0-1/<total>), or WebKit bails and re-bootstraps the session.
src := &fakeGrowing{data: []byte("0123456789"), final: false, est: 6685677633}
ss := &StreamServer{}
req := httptest.NewRequest(http.MethodGet, "/stream", nil)
req.Header.Set("Range", "bytes=0-1")
rec := httptest.NewRecorder()
ss.serveGrowing(rec, req, src)
if got := rec.Result().Header.Get("Content-Range"); got != "bytes 0-1/6685677633" {
t.Errorf("Content-Range = %q, want bytes 0-1/6685677633 (concrete total for iOS)", got)
}
if body := rec.Body.String(); body != "01" {
t.Errorf("body = %q, want 01 (the 2 probed bytes)", body)
}
}
func TestServeGrowing_RangeBeyondTotal(t *testing.T) {
src := &fakeGrowing{data: []byte("0123456789"), final: true}
ss := &StreamServer{}
req := httptest.NewRequest(http.MethodGet, "/stream", nil)
req.Header.Set("Range", "bytes=999-")
rec := httptest.NewRecorder()
ss.serveGrowing(rec, req, src)
if rec.Result().StatusCode != http.StatusRequestedRangeNotSatisfiable {
t.Errorf("status = %d, want 416", rec.Result().StatusCode)
}
}

File diff suppressed because it is too large Load diff

View file

@ -35,6 +35,33 @@ func (f *fakeFileProviderSeekable) NewFileReader(_ context.Context) io.ReadSeekC
return &readSeekNopCloser{strings.NewReader(string(f.content))}
}
// TestStreamServer_healMediaPath covers the host→container base-path self-heal
// used by the path-scoped handlers (/thumbnail, /trickplay, /sub).
func TestStreamServer_healMediaPath(t *testing.T) {
srv := NewStreamServer(0)
// No resolver installed → identity (preserves the pre-fix 404 behaviour).
if got := srv.healMediaPath("/mnt/nas/peliculas/a/b/c.mkv"); got != "/mnt/nas/peliculas/a/b/c.mkv" {
t.Errorf("nil resolver should be identity, got %q", got)
}
// Resolver locates the file under a current root → use the healed path.
srv.SetPathResolver(func(p string) string {
if p == "/mnt/nas/peliculas/a/b/c.mkv" {
return "/downloads/a/b/c.mkv"
}
return ""
})
if got := srv.healMediaPath("/mnt/nas/peliculas/a/b/c.mkv"); got != "/downloads/a/b/c.mkv" {
t.Errorf("resolver remap: got %q want /downloads/a/b/c.mkv", got)
}
// Resolver can't locate it ("") → keep the original so os.Stat 404s as before.
if got := srv.healMediaPath("/elsewhere/x.mkv"); got != "/elsewhere/x.mkv" {
t.Errorf("unlocatable path should stay unchanged, got %q", got)
}
}
// TestStreamServer_Listen_BindsPort verifica que Listen() enlaza a un puerto
// y URL() devuelve una URL accesible.
func TestStreamServer_Listen_BindsPort(t *testing.T) {

View file

@ -0,0 +1,151 @@
package engine
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"fmt"
"math/big"
"net"
"net/http"
"testing"
"time"
)
// genSelfSignedCert builds an in-memory self-signed cert valid for 127.0.0.1,
// used to exercise the agent's HTTPS listener without any CA/ACME plumbing.
func genSelfSignedCert(t *testing.T) (tls.Certificate, *x509.Certificate) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("genkey: %v", err)
}
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "unarr-test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
DNSNames: []string{"localhost"},
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
t.Fatalf("create cert: %v", err)
}
leaf, err := x509.ParseCertificate(der)
if err != nil {
t.Fatalf("parse cert: %v", err)
}
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: leaf}, leaf
}
// freePort grabs an ephemeral TCP port and releases it, so the caller can hand
// a concrete port number to EnableTLS (which treats 0 as "disabled").
func freePort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("freePort: %v", err)
}
p := l.Addr().(*net.TCPAddr).Port
l.Close()
return p
}
// TestStreamServerTLS_HotInstall verifies the HTTPS listener: it starts even
// with no certificate (handshake fails), and a certificate installed *after*
// Listen applies live via the GetCertificate path — no restart, which is what
// the future ACME broker relies on.
func TestStreamServerTLS_HotInstall(t *testing.T) {
cert, leaf := genSelfSignedCert(t)
ss := NewStreamServer(0) // HTTP on a random free port
ss.EnableTLS(freePort(t))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := ss.Listen(ctx); err != nil {
t.Fatalf("Listen: %v", err)
}
defer ss.Shutdown(context.Background())
if ss.HTTPSPort() == 0 {
t.Fatal("HTTPSPort() = 0, want the armed HTTPS port")
}
if ss.HasTLSCertificate() {
t.Fatal("no certificate should be installed yet")
}
pool := x509.NewCertPool()
pool.AddCert(leaf)
client := &http.Client{
Timeout: 3 * time.Second,
Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool}},
}
url := fmt.Sprintf("https://127.0.0.1:%d/health", ss.HTTPSPort())
// Before a cert is installed, the handshake must fail.
if resp, err := client.Get(url); err == nil {
resp.Body.Close()
t.Fatal("GET succeeded before a certificate was installed; want handshake failure")
}
// Install the cert — the listener stays up and the next handshake succeeds.
ss.SetTLSCertificate(&cert)
if !ss.HasTLSCertificate() {
t.Fatal("HasTLSCertificate() = false after install")
}
var lastErr error
for attempt := 0; attempt < 20; attempt++ {
resp, err := client.Get(url)
if err != nil {
lastErr = err
time.Sleep(50 * time.Millisecond)
continue
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("GET %s = %d, want 200", url, resp.StatusCode)
}
return // success
}
t.Fatalf("GET %s never succeeded after cert install: %v", url, lastErr)
}
// TestStreamServerTLS_Disabled verifies that with TLS not armed, no HTTPS port
// is opened and the HTTP listener is unaffected.
func TestStreamServerTLS_Disabled(t *testing.T) {
ss := NewStreamServer(0)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := ss.Listen(ctx); err != nil {
t.Fatalf("Listen: %v", err)
}
defer ss.Shutdown(context.Background())
if ss.HTTPSPort() != 0 {
t.Errorf("HTTPSPort() = %d, want 0 (TLS disabled)", ss.HTTPSPort())
}
}
// TestLoadTLSCertificateFromFiles_Missing verifies the loader reports an error
// (not a panic) when the cert pair is absent — the daemon treats this as
// "TLS off, HTTP keeps serving".
func TestLoadTLSCertificateFromFiles_Missing(t *testing.T) {
ss := NewStreamServer(0)
err := ss.LoadTLSCertificateFromFiles(
t.TempDir()+"/nope.crt", t.TempDir()+"/nope.key")
if err == nil {
t.Fatal("expected error loading a missing cert pair")
}
if ss.HasTLSCertificate() {
t.Error("no certificate should be installed after a failed load")
}
}

View file

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
@ -12,7 +13,7 @@ import (
"time"
)
// streamSource abstracts the byte source served over the WebRTC DataChannel.
// streamSource abstracts the byte source consumed by the HLS transcoder.
// Two implementations:
// - diskFileSource — direct passthrough of the on-disk file.
// - transcodeSource — ffmpeg writes a fragmented MP4 to a temp file in
@ -125,7 +126,20 @@ func newTranscodeSource(
return nil, err
}
estimate := estimateOutputSize(probe, opts)
// Size estimate for the scrubber timeline. A copy remux (video not
// re-encoded) lands within container overhead of the source file, so the
// source size is a far better estimate than bitrate×duration — use it.
// A real transcode re-encodes, so fall back to the bitrate×duration model.
var estimate int64
switch action {
case ActionPassthrough, ActionRemux, ActionRemuxAudio:
if fi, statErr := os.Stat(srcPath); statErr == nil {
estimate = fi.Size()
}
}
if estimate <= 0 {
estimate = estimateOutputSize(probe, opts)
}
t := &transcodeSource{
tmpPath: tmpPath,
@ -151,6 +165,31 @@ func newTranscodeSource(
return t, nil
}
// NewRemuxSource starts an ffmpeg `-c copy` remux of srcPath into a growing
// fragmented-MP4 temp file and returns it as a GrowingSource for /stream
// (hueco #3 / 3b). The video + audio are copied (never re-encoded), so this is
// only valid when the codecs are already browser-native (h264 + aac) and only
// the container needs changing — the web's decidePlayMethod enforces that
// 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)
}
// signalNotify wakes any goroutine blocked in ReadAt. Non-blocking: if a
// notification is already pending the new event is folded into it (callers
// always re-check size + final after waking, so a coalesced signal still
@ -184,6 +223,13 @@ 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()
}

Some files were not shown because too many files have changed in this diff Show more