Commit graph

296 commits

Author SHA1 Message Date
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
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
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