The previous scale expression `min(iw,iw*H/ih)':'min(ih,H)` produced odd
widths (e.g. 1425×720 for a 16:9 source capped at 720p) which libx264
refuses with `width not divisible by 2`, killing the encoder before a
single byte was written.
Switch to `scale=-2:H:force_original_aspect_ratio=decrease`, which
derives a width that preserves aspect ratio AND is rounded to a multiple
of 2. Always set `-pix_fmt yuv420p` so 10-bit HEVC sources are downcast
to the 8-bit format browser <video> elements actually decode.
Also add `-y`, guard nil pipe in Close(), and the related transcode
plumbing for browser-decided per-session quality.
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.
Source files in HEVC, AV1, AC3, DTS, EAC3, etc. now transcode through ffmpeg
to fragmented MP4 (h264 + aac) on-the-fly when the browser would otherwise
play silent black. Decision matrix lives in engine.DecideAction:
passthrough → remux → audio-transcode → full video-transcode.
Architecture — temp file + growing-size source:
- engine.streamSource interface abstracts byte source. Two impls:
* diskFileSource: passthrough when codecs are already browser-friendly.
* transcodeSource: spawns ffmpeg writing to a /tmp/tc-stream-*.mp4 file.
A ticker polls file size and wakes blocked ReadAt callers as ffmpeg
produces output. Estimate of final size (bitrate × duration) is
announced over the wire so the browser's scrubber has something to
anchor on.
- dataChannelPump now reads from streamSource instead of *os.File. HELLO
carries Transcoding=true + an estimated total size; Seekable=true (we
read random-access from the temp file even while writing).
- Transcoder runtime resolved per session by buildTranscodeRuntime in
cmd/daemon: ffmpeg/ffprobe path lookup + HWAccel auto-detection
(NVENC/QSV/VAAPI/VideoToolbox).
- New [downloads.transcode] TOML section: enabled (default true), hw_accel
(auto), preset (veryfast), video_bitrate (5M), audio_bitrate (192k),
max_height (optional downscale), max_concurrent (safety cap).
Falls back to passthrough if ffprobe is missing, fails, or codecs are
already browser-friendly. tmp file is cleaned up on session shutdown.
Surfaces tracker-announce + WebRTC peer events that were previously
swallowed by the Critical filter. Required for diagnosing the browser
↔ Go piece-transfer issue uncovered during the e2e smoke (peers
connect, signalling brokers, WebRTC handshake completes, but
anacrolix's outbound seeding to webtorrent.js browsers — known
upstream weak spot, issues #402/#752/#805 — produces zero pieces).
No behaviour change in normal operation; only changes what gets
logged.
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).
Three end-to-end checks that the transcoder actually produces playable
output, not just plausible argv. Skip cleanly on hosts without ffmpeg
on PATH so unit-test CI keeps working.
- TestTranscoder_DirectPlayProducesH264 — synth h264+aac MP4 via
`ffmpeg -f lavfi testsrc/sine`, run Analyze (expect direct play),
Stream to disk, ffprobe the result, assert codecs are still h264+aac.
- TestTranscoder_TranscodeHEVCToH264 — synth hevc+ac3 MKV, expect
transcode decision, Stream to memory, ffprobe-verify the output is
h264+aac. Skipped if libx265 isn't compiled in.
- TestTranscoder_AnalyzeReportsRealMediaInfo — sanity check that
Analyze returns a usable mediainfo (320x240, ~2s duration) the API
handler can show to the player.
Verified locally:
PASS: TestTranscoder_DirectPlayProducesH264 (0.09s)
PASS: TestTranscoder_TranscodeHEVCToH264 (0.22s)
PASS: TestTranscoder_AnalyzeReportsRealMediaInfo (0.06s)
The browser-side WebRTC reproductor needs MP4 / H.264 / AAC / yuv420p to
keep MSE happy. This package decides per request whether to:
• direct-play — input already MSE-compatible, just remux to fMP4
• transcode — re-encode video (libx264 / NVENC / QSV / VAAPI /
VideoToolbox) + audio (AAC), fragment to fMP4
Pieces:
- internal/streaming/transcoder.go — AnalyzeCompatibility decides the
recipe from a parsed mediainfo. CompatibilityReport carries the reasons
so the player UI can show "transcoding video: HEVC → H.264".
- internal/streaming/ffmpeg_args.go — BuildFFmpegArgs assembles the argv
for ffmpeg. Direct play uses `-c copy`; transcode uses libx264 or the
selected HW encoder. Output is always fragmented MP4 piped to stdout
(-movflags frag_keyframe+empty_moov+default_base_moof) so the HTTP
handler can stream straight to the browser without disk I/O.
Quality ladder: 480p (1.5Mb), 720p (3.5Mb), 1080p (6Mb), 2160p (25Mb).
Default 1080p when unset / unknown. -ss seek for resume / scrubbing.
- internal/streaming/hwaccel.go — DetectHWAccel runs `ffmpeg -encoders`
once per process and caches the best available. Order: NVENC → QSV →
VAAPI → VideoToolbox → libx264. VAAPI is the only family that wires up
HW decode too (`-hwaccel vaapi`); the others software-decode and HW-
encode (works fine and avoids /dev/dri permission rabbit holes).
- internal/streaming/stream.go — Transcoder facade wires Analyze + Stream
together for the API handler in Fase 4. Captures the last 8 KiB of
ffmpeg stderr for diagnosable errors without unbounded memory.
Tests (20 unit, all green):
- AnalyzeCompatibility: h264+aac direct, video-only direct, HEVC →
transcode, 10-bit HDR → transcode, EAC3 audio → transcode, nil guards
- ResolveQuality: empty + unknown fallback to 1080p, 4-step ladder
- BuildFFmpegArgs: direct play -c copy, transcode libx264 + bitrate +
scale, NVENC swaps encoder & drops preset, VAAPI injects -hwaccel +
scale_vaapi, -ss timestamp formatting
- HWAccel: encoder-name table, VAAPI is the only one with HW decode
- formatDuration: zero, sub-second, HH:MM:SS, negative-clamped
- cappedBuffer: tail retention through multi-write and large-write paths
- NewTranscoder: rejects empty paths
Adds the ffmpeg-binary half of the resolution stack so the upcoming
WebRTC streaming transcoder (Fase 3.3) has a single point of entry.
Search order matches ResolveFFprobe so operators don't need to learn a
second mental model:
1. Explicit path (--ffmpeg flag / library.ffmpeg_path config)
2. FFMPEG_PATH env var
3. "ffmpeg" on PATH (system install)
4. Adjacent to the unarr executable (release tarball bundles it here —
this is the preferred path; see Fase 3.2 goreleaser changes)
5. Cache dir (sibling of the cached ffprobe binary)
6. Auto-download from ffbinaries.com (~70MB) as last resort
Includes:
- internal/library/mediainfo/ffmpeg.go — ResolveFFmpeg + actionable
Docker / non-Docker error messages
- internal/library/mediainfo/ffmpeg_download.go — DownloadFFmpeg, reuses
ffprobePlatformKey + ffprobeAPIClient + ffprobeDLClient + extractFromZip
helpers; bumps maxZipSize to 200MB (ffmpeg static is ~70-100MB)
- internal/config: LibraryConfig.FFmpegPath toml field for explicit paths
- 4 unit tests: explicit OK, explicit missing, env var, sibling cache path
Tarball bundling and the actual transcoding pipeline land in the next
two commits.
Wires anacrolix/torrent's built-in webtorrent package so a browser
running webtorrent.js can fetch pieces from this CLI via WebRTC data
channels. The daemon stays the seeder; we never relay bytes through
TorrentClaw infrastructure — same legal posture as today.
Changes:
- internal/config: new [downloads.webrtc] section
(enabled/trackers/stun_servers/turn_servers/turn_user/turn_pass).
Disabled by default, opt-in via config.toml. When enabled but
trackers / STUN slices are empty, defaults are reapplied on Load() so
users get a working setup with a single `enabled = true`.
- internal/engine: TorrentConfig gains WebRTCEnabled / WebRTCTrackers
/ ICEServers; NewTorrentDownloader populates ClientConfig.ICEServerList
and forces NoUpload=false when WebRTC is on (browsers can't pull
otherwise). buildMagnet now accepts variadic extra trackers and the
downloader method prepends WSS trackers so anacrolix's
webtorrent.TrackerClient picks them up first.
- internal/engine/webrtc.go: BuildICEServers helper converts the TOML
WebRTCConfig into []webrtc.ICEServer with shared TURN credentials.
- internal/cmd/daemon.go + download.go: pass WebRTC config through to
the engine.
Tests (8 new, all green; full suite 0 lint issues, 0 vet):
- buildMagnet free function: defaults-only, with extras, trim+empty-skip
- downloader method: WebRTC disabled keeps WSS out, enabled prepends them
- BuildICEServers: nil when disabled, STUN-only path, TURN+credentials
- NewTorrentDownloader: full WebRTC-enabled construction (logs WebRTC
peer enabled, magnet contains wss://tracker.torrentclaw.com)
End-to-end smoke (browser ↔ unarr peer transfer) is deferred to a
manual test once tracker.torrentclaw.com WSS is live.
ResolveScanPaths() collects downloads.dir, organize.movies_dir,
organize.tv_shows_dir, and library.scan_path (if set), then removes
paths that are subdirectories of a parent already in the list.
This ensures the daemon and CLI scan all configured dirs without
relying solely on scan_path being set.
Three root-cause fixes for VLC showing a black screen when opening a
stream from a different network or via Tailscale:
1. PrioritizeTail: when VLC opens an MKV/MP4 stream it immediately seeks
to the end of the file to read the container index (seekhead/moov
atom). For active torrents those end-pieces aren't downloaded yet, so
the reader blocks indefinitely. PrioritizeTail() opens a background
reader positioned at the last 5 MB, keeping those pieces at high
priority until ctx is cancelled or they finish downloading.
2. /health endpoint: GET /health returns a lightweight JSON response
{"status":"ok","streaming":bool,...} so connectivity can be tested
with a simple curl from any device before involving VLC.
3. Per-request logging: every incoming /stream request now logs the
client IP and Range header, making it trivial to confirm whether
remote/Tailscale clients are reaching the server at all.
Add downloadWithRetry with up to 3 attempts and quadratic backoff (5s, 20s)
to handle TLS timeouts and transient failures. Progress messages inform the
user of each failure and wait time before retrying.
- 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
CLI now holds a GET /api/internal/agent/wake connection open.
When the server calls triggerWake(userId) — on stream request,
download queue, pause, cancel, resume, scan, etc. — the CLI
receives the signal immediately and fires a sync cycle in <100ms
instead of waiting up to 10s for the next scheduled interval.
- Add WaitForWake(ctx) to Client using a no-timeout HTTP client
- Add runWakeListener goroutine to SyncClient (auto-reconnects)
- Start wake listener from SyncClient.Run()
Closes: sub-second stream latency from the web UI
- 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
- Register WatchReporter cancel funcs in streamRegistry so they get
cancelled when switching to a different stream (prevents goroutine leak)
- Re-notify streamReady when the server is already serving the requested
task (handles duplicate stream requests from the web UI)
- Rewrite tests for byte-based tracking semantics, remove dead
parseRangeStart tests
EstimatedProgress now returns video duration in seconds (from ffprobe).
WatchReporter sends Position and Duration fields when available, giving
the server precise playback time instead of just a percentage.
Replace Range-header-based progress tracking with a trackingReader that
measures actual bytes read per connection. This gives accurate playback
position even for local/NAS files where VLC buffers aggressively.
- Token bucket rate limiter at 2x video bitrate (from ffprobe)
- CAS loops for lock-free atomic progress updates without regression
- probeMediaInfo extracts bitrate + duration via ffprobe (3s timeout)
- Defense-in-depth: only probe regular files, reject FIFOs/devices
- Remove dead parseRangeStart function
- Consistent [stream] log prefix
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.