Commit graph

127 commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
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
e1fc7b7b6f chore(release): 0.9.19
- 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
- Bump version to 0.9.18
- Update CHANGELOG.md
2026-05-30 00:00:12 +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
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
4d7444ef5b fix(sentry): skip "daemon not running" stop/reload errors 2026-05-27 16:50:16 +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
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
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
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
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
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
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
0537de0ec1 fix(upgrade): fetch releases from TorrentClaw app, not GitHub
The org GitHub shadow-ban 404s releases/raw/API to anonymous clients, so the
self-updater (api.github.com/releases/latest + github.com/.../releases/download)
was broken: `unarr upgrade` could neither check nor download.

- fetchLatestVersion → GET {base}/version (plain text)
- releaseURL → {base}/releases/download/v{ver}/{file}
- base resolves from cfg.Auth.APIURL via upgrade.SetBaseURL (PersistentPreRun),
  so mirrors / onion / staging / UNARR_API_URL all route updates correctly
- tests updated to the new endpoints
2026-05-21 14:46:10 +02:00
Deivid Soto
7de8955c4f feat(vpn): local config_file for self-hosted/personal VPN testing
downloads.vpn.config_file = path to a WireGuard .conf read directly by the
daemon (skips the web fetch). Lets you point unarr at your own WireGuard
server / personal VPN and split-tunnel torrent traffic through it without
the web provisioning plumbing — for testing and self-hosted setups.
2026-05-20 23:27:34 +02:00
Deivid Soto
bf279ca5ad feat(vpn): split-tunnel torrent traffic through managed WireGuard
In-process userspace WireGuard tunnel (wireguard-go + gVisor netstack) for
the managed-VPN add-on. No root, no OS routing changes: only the embedded
anacrolix/torrent client's peer + tracker traffic is routed through the
tunnel, so the swarm and trackers see the VPN IP, not the user's home IP.
unarr's control plane (API, heartbeats) keeps using the normal net.

- internal/vpn: FetchConfig (GET /api/internal/agent/vpn-config, Bearer auth,
  typed errors for disabled/not_provisioned/slot_on_device) + Up (parse .conf
  → uapi, CreateNetTUN, device Up) + DialContext/ListenPacket adapters.
- engine/torrent.go: when a tunnel is set, wire TrackerDialContext +
  HTTPDialContext + TrackerListenPacket to netstack, DisableUTP, and
  AddDialer(NetworkDialer{tcp, netstack}) for peer conns.
- config: downloads.vpn.enabled flag.
- daemon: bring up the tunnel before the torrent client; non-fatal on
  failure (logs + downloads in the clear); slot_on_device warns the user.
- version bump 0.8.1 → 0.9.0.

Pairs with the web VPN add-on (dormant behind NEXT_PUBLIC_VPN_ENABLED).
Runtime-verified once a VPNResellers trial provides a live endpoint.
2026-05-20 23:16:54 +02:00
Deivid Soto
060a3e48db fix(security): CORS allowlist, URL scheme guard, state perms, ZIP slip, mirror docs
Phase 3 security audit follow-up. Medium and low-severity hardenings
plus a deferred-work plan for the cross-repo stream-token rollout.

Stream server CORS: replace the wildcard Access-Control-Allow-Origin
with an allowlist that echoes back only torrentclaw.com,
app.torrentclaw.com, the local Next dev port (3030 — matches the web
repo package.json) and any extras the operator adds via the new
downloads.cors_extra_origins TOML key. A Vary: Origin header is now
emitted whenever the request carries an Origin header so an
intermediate cache cannot serve a stale ACAO to a different origin.

URL scheme guard: openBrowser and OpenPlayer refuse any URL that is
not http(s). Combined with passing the URL after "--" wherever the
launched helper supports it (open, mpv, vlc, cvlc), this stops a
leading "-" from being parsed as a switch by the spawned process.

State file permissions: WriteState now writes 0o600 so the agent ID,
PID and counters cannot be enumerated by another local user on a
shared host. Matches the existing config file mode.

ZIP slip defense-in-depth: extractZip extracts the safety check into
safeZipPath, which canonicalises the entry name (normalising
backslashes to "/"), rejects "..", "../" prefix and "/../" interior
components, and verifies the final destination stays inside destDir
before opening any file.

Mirror fallback: documented the design for multi-provider
mirrors.json hosting in the comment block on DefaultStaticFallbackURLs
and added a follow-up note about signing it with the same ed25519
release key. The list is kept at one provider until the second host
is provisioned and added to torrentclaw-web's STATIC_FALLBACKS.

Deferred work: a new plan document Docs/plans/security-stream-token.md
covers the per-task stream token (Phase 2.2 of the original audit)
which requires coordinated web + CLI work and ships separately.
2026-05-15 18:48:59 +02:00