- 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).
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.
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.
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.
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.
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.
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.
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.
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
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
First beta of the 1.0 line — the full unarr streaming agent (HTTPS streaming,
HLS transcode, on-demand + cached subtitles/thumbnails, burn-in, debrid HLS,
SSE realtime, auto-resume, seeding).
- ExtractSubtitlesVTTMulti: distrust output when ffmpeg is killed by signal
(45-min timeout on a too-big remux) — a truncated WebVTT passed the len>0
check and got cached as a silently-incomplete track until the media mtime
changed. Skip all output on signal-kill; keep it on a clean non-zero exit.
- stream handlers: read the sidecar cache BEFORE the ffmpegPath guard so a
pre-warmed sub/thumbnail still serves if ffmpeg was removed after the cache
was filled.
- scan: log when the prewarm is skipped because ffmpeg is unavailable (matches
the daemon; CLAUDE.md wants bootstrap to log on every branch).
- unexport sidecarDir/subtitleCachePath/thumbnailCachePath (no external callers).
- prewarm: surface a sample error in the summary so a systemic ffmpeg failure
is distinguishable from one corrupt file.
- add unit tests: codec whitelist, cache paths, mtime freshness, atomic write,
thumb-position dedup.
Pre-extract the file panel's sample frames (10/30/50/70/90% of runtime, w=320)
during the library scan and write-through any on-demand /thumbnail request into
the hidden ".unarr/<name>.t<sec>w<width>.jpg" sidecar. The /thumbnail handler
serves a fresh sidecar instantly, so the characteristics panel and seekbar
previews stop re-running ffmpeg per request.
- mediainfo.sidecar: ThumbnailCachePath, ReadCachedThumbnail, WriteCachedThumbnail,
ExtractThumbnailJPEG (mirrors engine.buildThumbnailArgs).
- library.PrewarmSidecars: also enqueues the panel frame positions (kept in
lockstep with the web's THUMB_FRACTIONS / THUMB_WIDTH) per item with a duration.
- thumbnailHandler: cache-read → hit; miss → extract → write-through.
- config: library.cache_thumbnails (default true) + both cache toggles exposed in
the interactive 'unarr config' library menu.
Local only by design — frames are the user's own content, never uploaded.
On-demand WebVTT extraction re-ran ffmpeg on every /sub request and, for
50GB+ remuxes, couldn't finish a full text track within the 60s HTTP timeout
→ the web player got a 500 and no subtitles.
Extract each text subtitle ONCE — during the library scan (no HTTP deadline,
generous per-file timeout) and write-through on the first on-demand request —
into a hidden ".unarr/<name>.s<index>.vtt" sidecar next to the media file.
The /sub handler serves a fresh sidecar instantly (mtime-invalidated when the
media is replaced), so playback subtitles are instant and huge files work.
- mediainfo.sidecar: cache paths, mtime freshness, atomic write, ExtractSubtitleVTT,
IsTextSubtitleCodec (shared classifier, mirrors engine + web whitelists).
- library.PrewarmSidecars: bounded, idempotent, ctx-cancellable background pass
run after every scan (manual + daemon auto-scan).
- subtitleHandler: cache-read → hit; miss → extract → write-through.
- config: library.cache_subtitles (default true), wired via SetCacheSubtitles.
Local-only by design: nothing extracted is uploaded — the sidecar is the user's
own content, private to their disk.
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.
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).
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.
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.
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).
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.
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.
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.
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.
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.
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.
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 / 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.
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).
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.
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.
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.
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)