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.
- 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).
- 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.
- 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.
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.
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).
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.
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.
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
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.
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.
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).
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).
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.
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.
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.
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.
/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.
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.
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.
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
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.
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.
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.
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.
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.
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.
Phase 2 security audit follow-up. Three independent hardenings against
the unauthenticated daemon surface, the long-lived agent SSE stream
and the self-update channel.
UPnP is now opt-in. The stream port + /hls endpoints have no auth, so
publishing them on the WAN via the gateway was a default that exposed
active downloads to anyone scanning the operator's external IP. New
config downloads.enable_upnp (default false) gates the mapping; LAN
and Tailscale clients continue to work unchanged. A startup log makes
the new default visible.
The agent SSE reader now uses a bounded bufio.Scanner instead of an
unbounded ReadString. A hostile or buggy server can no longer grow
daemon memory by streaming a single line forever or by emitting
unbounded data: continuation lines — both are capped at 256 KiB and
1 MiB respectively, and an error is surfaced so SignalLoop reconnects.
Self-update now verifies an ed25519 signature over checksums.txt when
the binary was built with a release public key embedded (injected via
goreleaser ldflags from RELEASE_SIGNING_PUBKEY). The companion
scripts/sign-checksums runs in the release workflow when both the
public-key variable and the private-key secret are present, uploading
checksums.txt.sig next to the existing checksums file. Builds without
the embedded key continue to update with SHA256-only verification; a
--allow-unsigned flag is provided so users on a signed build can
still install pre-signing releases or recover from an accidental
unsigned release.
A new scripts/gen-release-key helper documents the one-time keypair
generation procedure required before flipping signing on.
- Mirror pool with health tracking and exponential backoff for failed hosts
- Agent client routes requests through mirror pool with retry semantics
- New `unarr mirrors` command to inspect mirror state and force failover
- `unarr status` now detects 401 from /agent/register and suggests `unarr login`
instead of the generic "Could not fetch account info" message
- Config supports multiple ScanPaths for upcoming multi-path library scan
- Draft plan for bidirectional library sync (CLI ↔ Web) under Docs/plans/
Three related fixes around 4K-source transcoding that left the web
player stuck on "preparing session" with no useful diagnostics:
1. Dynamic -level:v derived from output height (hls.go, transcoder.go).
The previous fixed "4.0" silently rejected anything taller than 1080p
inside libx264 — "frame MB size > level limit", "DPB size > level
limit" — and emitted unplayable segments. Helper H264LevelForHeight()
now picks 4.0 / 5.0 / 5.1 / 6.0 from the actual encode height.
2. New `unarr probe-hwaccel` diagnostic command. Lists the HW encoders
compiled into ffmpeg, the device files / drivers present, and the
backend the daemon would actually pick today. Surfaces the canonical
gotcha: a host with an RTX 3090 + nvidia-smi but a Homebrew ffmpeg
built without --enable-nvenc still falls back to libx264 software.
3. Register payload now includes hwAccel + maxTranscodeHeight so the web
side can suggest a smaller alternate quality before the user even
tries to play a 4K source on a software-only host. Software-only =
1080p cap, any HW backend = 2160p cap.
Introduces an HLS-over-HTTP path as Plan B for in-browser streaming. The
WebRTC + MSE pipeline keeps working untouched; the new path is selected
when the backend sets transport="hls" on a streaming session.
Daemon scope:
- engine/hls.go: HLSSession + HLSSessionRegistry. Spawns ffmpeg with
-f hls -hls_segment_type fmp4 + force_key_frames aligned with 4 s
segments. Pre-renders master + media playlists from the probe duration
so the browser knows the total timeline before any segment exists,
fixing seek/duration/pause/multi-track issues seen with the live fMP4
pipe.
- engine/probe.go: enumerate every audio + subtitle track instead of
collapsing to a single default audio track.
- engine/stream_server.go: route /hls/<id>/{master.m3u8,video/...,
subs/...} to the matching session. Emit a synthesised single-VTT
subtitle playlist per text track; bitmap subs (PGS/DVB) skip silently.
- cmd/daemon.go: branch on WebRTCSession.Transport == "hls" to register
an HLS session instead of running the legacy DataChannel pump.
- agent/types.go: WebRTCSession.Transport + AudioIndex fields.
Backend + web sides land in a follow-up commit.
Adds WebRTCSession.Quality to the sync payload so the daemon can pick a
MaxHeight + bitrate per session instead of using the global config cap.
resolveQualityCap() maps the label to a (height, b:v) pair and
buildStreamSource() promotes a passthrough decision to ActionTranscodeVideo
when the source resolution exceeds the cap (4K source on a phone client
with quality="720p" must transcode, not pass-through).
Also lands the transcode-on-by-default fix for legacy configs without a
[downloads.transcode] section so existing installs pick up h264+aac
fallback for HEVC/AC3 content without re-running setup.
Closes the agent half of in-browser playback for arbitrary files. When
the web app inserts a download_task with mode="seed_file", the daemon
now wraps the on-disk file as a single-file torrent, adds it to the
existing WebRTC-enabled torrent client, and reports the generated
info_hash back so the browser can target /stream/<hash>.
Pieces:
- internal/agent/types.go: Task.FilePath (received from claim) +
StatusUpdate.InfoHash (sent back). Both serialise compatibly with
the matching Zod schemas in the Next.js sync route.
- internal/engine/seed_file.go: SeedFile(client, filePath, trackers)
builds the metainfo via metainfo.Info.BuildFromFilePath +
bencode.Marshal, then AddTorrent + DownloadAll() so anacrolix
hashes the file and flips pieces to "have" as it goes. The
libtorrent piece-size ladder is mirrored from wstracker-probe so
generated torrents are interoperable with mainstream clients.
SeedFileOnDownloader is the daemon-facing convenience wrapper —
bails loud when [downloads.webrtc].enabled = false instead of
silently producing a torrent no browser can find.
- internal/cmd/seed_file_handler.go: handleSeedFileTask invoked from
the existing OnTasksClaimed dispatcher in daemon.go for mode=
seed_file. Validates filePath, calls the engine helper, and pushes
the resulting info_hash via Client.ReportStatus. Failures (missing
file, WebRTC disabled, ffmpeg-style oddities) report status="failed"
+ errorMessage so the browser's WatchInBrowserButton can show the
reason instead of timing out at 60 s.
- internal/cmd/daemon.go: dispatcher learns the seed_file branch in
the same shape as the existing stream branch.
Tests (6 unit, all green):
- SeedFile rejects missing files + directories.
- SeedFile yields a deterministic info_hash for the same payload across
fresh clients (web client polls expecting this).
- SeedFileOnDownloader errors when WebRTC is disabled.
- chooseSeedPieceLength matches the ladder breakpoints.
- makeAnnounceList handles nil/empty/partial inputs.
Web side compatible: mode=seed_file is already accepted by the sync
schema; agent.Task.filePath + StatusUpdate.infoHash now propagate
through the existing claim/report endpoints. End-to-end browser ↔
unarr smoke is the next concrete verification step (needs a running
unarr-dev daemon plus library scan + a file with no source torrent).
- Use a dedicated 10-minute HTTP client for library-sync so libraries
with hundreds or thousands of items no longer time out
- Show actionable ffprobe-not-found error: detects Docker and suggests
FFPROBE_PATH env var, config.toml setting, or package install
- Include static ffprobe binary in Docker image (johnvansickle.com)
- Bump version to 0.6.2
- task.go: fix deadlock in ToStatusUpdate() — calling Percent() (which
RLocks) while already holding RLock caused deadlock when a writer was
waiting; compute percent inline instead
- usenet.go: fix data race in Cancel() — tracker and taskDir were read
without the mutex while Download() writes them under it; read all
fields under the same lock
- upnp.go: fix UPnP Remove() blocking shutdown — run cleanup in goroutine
with 10s deadline (removeNATPMP worst case is 3s dial + 5s deadline)
- daemon.go: add path traversal protection for stream requests — validate
sr.FilePath is within configured directories before os.Stat; defends
against compromised API server sending arbitrary paths
- client.go: add wakeClient without timeout for long-poll wake endpoint
where context controls cancellation
- sync.go: trigger immediate sync when entering watching mode so stream
requests are picked up without waiting for the next scheduled interval
Replace the WebSocket + Cloudflare Durable Object architecture with a
single POST /sync endpoint. The CLI now operates autonomously with local
state (tasks.json) and syncs bidirectionally via adaptive-interval HTTP
polling (3s watching, 60s idle).
- Remove transport_ws, transport_hybrid, transport_http (~2,600 lines)
- Add SyncClient with adaptive interval loop
- Add LocalState for CLI-side task persistence
- Add TaskStateFromUpdate() helper (DRY)
- Extract finalize() to deduplicate processTask/processTaskRetry
- Consolidate shortID() into agent.ShortID (was in 3 packages)
- Wire GetActiveCount so `unarr status` shows active tasks
- Remove poll_interval, heartbeat_interval, ws_url from config
- Simplify ProgressReporter (sync replaces direct HTTP reporting)
Without a SetReadDeadline, a silently dead WebSocket (e.g. Cloudflare
dropping the connection without a close frame) would block readLoop
forever. The daemon would appear connected but never receive tasks,
and never fall back to HTTP polling.
- Send RFC 6455 pings every 30s (resets Cloudflare's idle timer)
- SetReadDeadline of 45s, refreshed on every pong and text message
- SetWriteDeadline of 10s on all writes to prevent blocked sends
- On timeout, readLoop emits "disconnected" → HybridTransport falls
back to HTTP and starts WS reconnection loop
Include StreamPort, LanIP, and TailscaleIP in RegisterRequest so the
server knows the agent's stream endpoints from the moment it registers,
not just after the first heartbeat. Align HeartbeatRequest field order
with RegisterRequest for consistency.