Compare commits

..

54 commits

Author SHA1 Message Date
Deivid Soto
7a20ddb4ea feat(scripts): prune Forgejo releases >90 days in ship.sh
Some checks failed
CI / Test (push) Successful in 2m42s
CI / Build (push) Successful in 1m34s
CI / Build-1 (push) Successful in 1m59s
CI / Build-2 (push) Successful in 1m33s
CI / Build-3 (push) Successful in 1m33s
CI / Build-4 (push) Successful in 1m34s
CI / Build-5 (push) Successful in 1m33s
CI / Lint (push) Failing after 2m29s
CI / Coverage (push) Successful in 2m50s
CI / Vet (push) Successful in 2m6s
Adds step 6 to scripts/ship.sh: after smoke checks, list Forgejo
releases and delete any with created_at older than FORGEJO_PRUNE_DAYS
(default 90). Bounded retention prevents the tc-git CPX11 disk from
filling up (each release ≈ 511MB of attachments × 1/week pace).

Skipped silently with a warn if FORGEJO_TOKEN is not exported, so
the step is opt-in via secret presence (no token = no destructive
action). Tunables: FORGEJO_PRUNE_DAYS, FORGEJO_REPO, FORGEJO_BASE,
SKIP_FORGEJO_PRUNE.
2026-05-27 18:19:08 +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
9fe796f195 chore: untrack .claude/ (private local config)
Some checks failed
CI / Build-2 (push) Waiting to run
CI / Build-3 (push) Waiting to run
CI / Build-4 (push) Waiting to run
CI / Build-5 (push) Waiting to run
CI / Lint (push) Waiting to run
CI / Coverage (push) Waiting to run
CI / Vet (push) Waiting to run
CI / Test (push) Successful in 2m46s
CI / Build (push) Successful in 1m35s
CI / Build-1 (push) Has been cancelled
2026-05-27 17:00:15 +02:00
Deivid Soto
4d7444ef5b fix(sentry): skip "daemon not running" stop/reload errors 2026-05-27 16:50:16 +02:00
Deivid Soto
fceadd2009 chore(scripts): harden release.sh against double-release and inline version bumps
Two new pre-flight guards in scripts/release.sh, evaluated right after the
branch check:

1. Reject if HEAD subject matches `(X.Y.Z)` — historical pattern where the
   feature commit itself bumped the version (e.g. `feat(...) (0.9.14)`).
   Forces every release to land in a dedicated `chore(release): X.Y.Z`
   commit so the changelog + tag point at a clean release boundary.

2. Reject if HEAD is already `chore(release): …` — prevents re-running the
   script with no new commits since the previous release (would otherwise
   produce an empty release on top of itself).

Scope deliberately `chore(scripts)` (not `chore(release)`) so this very
commit doesn't trip guard 2 the next time release.sh runs.
2026-05-27 16:37:03 +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
8205924917 fix(ci): unset GITHUB_TOKEN so goreleaser uses GITEA_TOKEN
Forgejo runner auto-injects GITHUB_TOKEN; combined with the GITEA_TOKEN we
set explicitly, goreleaser errors with 'multiple tokens'. Unset the GitHub
one inside the run step so goreleaser follows the Gitea/Forgejo release
path defined by .goreleaser.yml's gitea_urls block.
2026-05-27 16:15:57 +02:00
Deivid Soto
ea16bf98f4 refactor(ci): point Forgejo URLs at torrentclaw org (post-transfer)
Repos were transferred from the deivid user to a dedicated torrentclaw
organisation; the workflows reference the org path.
2026-05-27 15:58:45 +02:00
Deivid Soto
86b27e690b test(vaapi): dump full ffmpeg argv for smoke validation
Adds TestBuildHLSFFmpegArgsVAAPIDump alongside the existing assertion
tests. Logs the complete argv buildHLSFFmpegArgsAt emits for a
typical VAAPI session so an operator can paste it into a shell and
reproduce the encode without booting the dev stack — same effect as
`journalctl --user -u unarr-dev | grep ffmpeg`, no daemon needed.

Verified locally against AMD Raphael iGPU on this dev box: the
dumped argv encoded a 5 s 4K source → 720p in 3.1 s wall, produced
3 HLS segments + init.mp4 that decode cleanly under ffprobe.
2026-05-27 15:58:30 +02:00
Deivid Soto
70c04a2530 fix(release): move gitea_urls to top-level (goreleaser v2 schema)
Some checks failed
Release / release (push) Failing after 8s
Release / docker (push) Has been skipped
goreleaser v2 dropped `release.gitea_urls`; the key is now top-level
on its own. With the old nested form `goreleaser release` failed with
`yaml: unmarshal errors: line 67: field gitea_urls not found in type
config.Release` before even starting the build.

Re-anchor to v0.9.14 so the ship pipeline can produce binaries.
2026-05-27 15:55:21 +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
cfd4666bb2 ci: port workflows from .github/ to .forgejo/ (Forgejo Actions)
GitHub torrentclaw org is shadow-banned and the CI lives at git.torrentclaw.com
now. Forgejo Actions is enabled cluster-wide; this moves the workflows into the
runner's natively-watched .forgejo/workflows/ tree and adapts each step so the
existing Forgejo runner ('docker', 'ubuntu-latest' labels) can execute them
without leaning on GitHub-only tooling.

- ci.yml: drop actions/setup-go (use container: golang:1.25), replace
  golangci-lint-action with the upstream install.sh, drop codecov-action
  (third-party, can re-add later with a Forgejo-compatible variant).
- release.yml: drop goreleaser-action (install via curl), wire GITEA_TOKEN +
  the new release.gitea_urls block in .goreleaser.yml so goreleaser publishes
  to Forgejo. Sign step swaps 'gh release upload' for curl against the Forgejo
  releases API (via the in-cluster forgejo:3000 hostname). VirusTotal job
  dropped — depended heavily on 'gh release' wiring; can be reimplemented
  against the Forgejo API later if we re-enable it.
- docker-rebuild.yml: drop docker/login-action + docker/build-push-action,
  use raw 'docker' commands with manually-installed buildx + qemu. Same
  weekly schedule (Mon 04:17 UTC) and same 'latest' refresh behaviour.
- pages.yml: deleted — install.sh / install.ps1 are already served from the
  Hetzner releases volume at torrentclaw.com/install.sh, so the GitHub Pages
  copy was redundant even before the shadow-ban.

.goreleaser.yml: add release.gitea_urls (api=forgejo:3000, download via the
public Forgejo URL) + prerelease:auto. ship.sh uses '--skip=publish' so local
runs aren't affected by the new release block.
2026-05-27 15:44:48 +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
4b3f54d692 chore(skills): add /publish slash command + allow .claude/ in git
Mirrors the slash command added in torrentclaw-web/.claude/commands.
With the global ~/.gitignore excluding .claude/ by default, the
gitignore override is required for project-shared commands/agents/hooks
to be checked in (settings.local.json and projects/ stay local).

/publish documents the full unarr release flow (bump + tag + binaries +
Hetzner + Docker Hub + smoke) as a single command, while GitHub Actions
remains unavailable for the torrentclaw org.
2026-05-27 12:46:24 +02:00
Deivid Soto
23b79f6411 chore(release): add ship.sh end-to-end pipeline as GH Actions backup
GitHub Actions release.yml + docker job currently doesn't fire (org
shadow-ban). ship.sh replicates the CI pipeline locally so releases
keep landing on Hetzner + Docker Hub without depending on CI:

  1. Sanity checks: clean tree, tag at HEAD, version.go match
  2. goreleaser release --skip=publish  (build dist/*)
  3. publish-cli-release.sh  (rsync to Hetzner + flip version.txt)
  4. docker buildx --push multi-arch (amd64 + arm64)
  5. Smoke: torrentclaw.com/version + docker run image version
  6. Optional --push to git-push tag to GH

Exposed via make targets: ship, ship-dry, ship-push.
2026-05-27 12:35:01 +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
bf8ed0d928 refactor(hls): critico-driven hardening of fase 3.2
Addresses items raised by the multi-agent code review of the 0.9.9
HW accel + first-start work:

- EncoderProfile now carries DecodeHwAccel so the demuxer `-hwaccel`
  flag and the encoder argv derive from a single resolved profile.
  Adding a new backend can no longer leave the two switches out of
  sync.
- VAAPI no longer passes `-hwaccel_output_format vaapi`. That option
  pinned decoded frames to GPU memory, but the filter chain (scale,
  format, setparams) runs on CPU and would fail with "impossible to
  convert between formats". Frames now decode HW + flow on CPU; the
  encoder uploads back to GPU. Pre-existing bug, never reported because
  no one had VAAPI auto-detected in practice.
- readyMax field comment + name: documented that it's a COUNT
  (segments ready), not an index. The semantics were correct but the
  comment read "highest index" which made `idx < readyMax` look like
  an off-by-one to reviewers.
- probe_cache background janitor: 5-minute sweeper that drops expired
  entries even when no lookup retouches the key. Lookup-only eviction
  was fine for small libraries but unbounded for users who browse and
  abandon thousands of files within a TTL window. Lazy + sync.Once.
- probe_cache TTL eviction now re-checks under the write lock so a
  concurrent re-insert isn't accidentally evicted.
- probe_cache size-change test now Chtimes the file back to its
  original mtime so only `size` differs between store and lookup
  keys — properly exercises the size-check path.
- New TestProbeCache_SweepDropsExpired covers the janitor sweep.
- CHANGELOG: backfilled missing compare links 0.6.4 → 0.9.9.
- Stale "line ~1119" reference in VideoToolbox comment dropped; the
  bitrate block moved a few lines and the comment was already wrong.
2026-05-27 11:15:44 +02:00
Deivid Soto
0f4ad67827 fix(transcode): make preset libx264-only + restore quality opt-in
Two issues with the 0.9.9 preset retune:

1. applyDefaults was filling Preset="veryfast" before
   ResolveEncoderProfile got to pick the latency-biased default, so the
   "superfast" change never reached users with a freshly-generated
   config.toml — only those who left the field empty saw it.

2. The configured preset was being passed through to every encoder.
   That's only valid for libx264 (ultrafast…veryslow); NVENC uses p1-p7
   and rejects anything else, QSV uses its own subset. A user with NVENC
   + preset="veryfast" would have ffmpeg reject the argv.

Now:
- TranscodeConfig.Preset documented as libx264-only with the full
  range + advice on quality vs first-start latency.
- Default in applyDefaults is empty (was "veryfast") so the engine
  fills in "superfast" on libx264.
- ResolveEncoderProfile ignores configuredPreset for vendor encoders
  (NVENC sticks to p3, QSV to veryfast, VideoToolbox has no preset
  knob). Test cases updated to lock in this behaviour.

Users who want better quality at slower first-play should set
download.transcode.preset = "veryfast" (previous default) / "faster" /
"fast" / "medium" in their config.toml.
2026-05-27 10:46:03 +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
a5a92b111b feat(usenet): warn at startup when par2 or extractor is missing
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.
2026-05-23 15:36:37 +02:00
Deivid Soto
0e8d9e87f6 fix(engine): truncate errorMessage before reporting status
A failed usenet extract sets task.ErrorMessage to the full unrar/par2
dump (multi-KB). Sent raw, the web /agent/status route rejected it and
the terminal report failed, leaving the task stuck non-terminal.

Cap the reported errorMessage at 2000 bytes (rune-safe) in the status
snapshot, matching the server's stored length.
2026-05-23 15:34:58 +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
d0094e84bb Merge feat/ultra-vpn into main
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s
VPN split-tunnel, signed self-update, security hardening, IPFS mirror
fallback, container CVE scan gate, and 0.9.1 release prep.
2026-05-21 17:08:34 +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
283eb54a74 fix(security): bump golang.org/x deps and add container CVE scan gate
- Bump golang.org/x/{net,crypto,sys,text,term} to latest patches to
  clear GHSA module advisories flagged by Docker Scout.
- Add Docker Scout CVE gate to the release workflow (fails only on
  FIXABLE critical/high; unfixed upstream ffmpeg codec CVEs are accepted
  and documented in SECURITY.md).
- Add weekly + manual docker-rebuild workflow so newly fixed base/
  ffmpeg/Go patches land on :latest between tagged releases.
- Document container image vuln-scanning policy and hardening in
  SECURITY.md.
2026-05-21 16:53:23 +02:00
Deivid Soto
fb44f3711e feat(mirror): update fallback URLs to use IPFS and remove GitHub Pages 2026-05-21 16:00:44 +02:00
Deivid Soto
c7af7681a2 docs(docker): refresh Docker Hub README + sync description in CI
Some checks failed
Release / release (push) Failing after 0s
Release / docker (push) Has been skipped
Release / virustotal (push) Failing after 0s
- DOCKERHUB.md: update stale 0.3.5 examples to 0.9.0, multi-arch note, mirrors
  section (.com/.to/.onion), point all links to torrentclaw.com (GitHub 404s
  anonymously under the org shadow-ban)
- release.yml: add peter-evans/dockerhub-description step so a tag push also
  syncs the Docker Hub page from DOCKERHUB.md (continue-on-error)
2026-05-21 15:56:07 +02:00
Deivid Soto
2efd5f2764 chore(release): 0.9.0
- Bump version to 0.9.0
- Update CHANGELOG.md
2026-05-21 14:56:27 +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
DeividSoto
4a77756533 ci: deploy install scripts to GitHub Pages 2026-05-15 20:40:35 +02:00
DeividSoto
01b40ca244 chore(pages): add .nojekyll to disable Jekyll processing 2026-05-15 20:38:56 +02:00
DeividSoto
13e7dbc7fd chore(pages): set custom domain unarr.torrentclaw.com 2026-05-15 20:36:20 +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
Deivid Soto
433e375def fix(security): UPnP opt-in, bounded SSE reader, signed self-update
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.
2026-05-15 17:29:22 +02:00
Deivid Soto
c148cb8ce7 fix(security): harden HLS session IDs, /health disclosure, archive password handling
Phase 1 security audit follow-up:

- Reject HLS session IDs that aren't safe filesystem components
  (regex allowlist) to defend against path traversal via a buggy or
  compromised server. Applied at StartHLSSession and at the /hls URL
  handler; invalid IDs share the 404 of unknown sessions so the
  accepted format isn't enumerable.
- /health no longer leaks the active filename, taskID prefix or client
  IP to non-loopback callers. Uses net.IP.IsLoopback so IPv4-mapped
  IPv6 (::ffff:127.0.0.1) is recognised and the empty-string parse
  failure stops bypassing the boundary.
- unrar/7z passwords now travel through stdin instead of -p<password>
  in argv, removing /proc/<pid>/cmdline disclosure. Control characters
  in the password are rejected up front so a hostile NZB cannot feed
  extra prompt answers. Both invocations are bounded by a 30-minute
  context to stop indefinite hangs if the tool ever decides to prompt.
2026-05-15 17:10:42 +02:00
Deivid Soto
a73e1a7756 feat(agent): add mirror failover, agent client refactor, status 401 detection
- 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/
2026-05-15 16:26:43 +02:00
Deivid Soto
bf18812a3d test(coverage): raise engine+agent coverage above 50% 2026-05-12 11:21:59 +02:00
106 changed files with 8307 additions and 3370 deletions

View file

@ -12,35 +12,26 @@ permissions:
jobs:
test:
name: Test
runs-on: ubuntu-latest
strategy:
matrix:
go-version: ["1.25"]
runs-on: docker
container:
image: docker.io/library/golang:1.25
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go-version }}
- uses: actions/checkout@v4
- name: Run tests
run: go test -v -race -count=1 ./...
build:
name: Build
runs-on: ubuntu-latest
runs-on: docker
container:
image: docker.io/library/golang:1.25
strategy:
matrix:
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.25"
- uses: actions/checkout@v4
- name: Build
env:
@ -50,30 +41,30 @@ jobs:
lint:
name: Lint
runs-on: ubuntu-latest
runs-on: docker
container:
image: docker.io/library/golang:1.25
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.25"
- name: Install golangci-lint
run: |
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/v2.11.4/install.sh \
| sh -s -- -b /usr/local/bin v2.11.4
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.11.4
run: golangci-lint run ./...
coverage:
name: Coverage
runs-on: ubuntu-latest
runs-on: docker
container:
image: docker.io/library/golang:1.25
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.25"
- name: Install python3
run: apt-get update && apt-get install -y --no-install-recommends python3
- name: Run tests with coverage (all packages)
run: |
@ -102,24 +93,13 @@ jobs:
print('OK: Coverage meets minimum threshold')
"
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v6
with:
files: ./coverage.out
fail_ci_if_error: false
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
vet:
name: Vet
runs-on: ubuntu-latest
runs-on: docker
container:
image: docker.io/library/golang:1.25
steps:
- uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: "1.25"
- uses: actions/checkout@v4
- name: Run go vet
run: go vet ./...

View file

@ -0,0 +1,61 @@
# Rebuilds and re-pushes the `latest` image without a version bump so newly
# *fixed* Alpine / ffmpeg / Go patches land between tagged releases. Versioned
# tags are immutable and never touched here. Runs weekly and on demand.
name: Docker rebuild
on:
schedule:
# Mondays 04:17 UTC (off the hour to avoid the scheduler rush)
- cron: "17 4 * * 1"
workflow_dispatch:
jobs:
rebuild:
runs-on: docker
container:
image: docker.io/library/docker:27-cli
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install build deps
run: apk add --no-cache curl git bash
- name: Install buildx
run: |
mkdir -p ~/.docker/cli-plugins
curl -sSL https://github.com/docker/buildx/releases/latest/download/buildx-linux-amd64 \
-o ~/.docker/cli-plugins/docker-buildx
chmod +x ~/.docker/cli-plugins/docker-buildx
- name: Set up qemu
run: docker run --rm --privileged tonistiigi/binfmt --install all
# Stamp the binary with the most recent release tag (not "dev").
- name: Resolve version
id: ver
run: |
v=$(git describe --tags --abbrev=0 2>/dev/null || echo dev)
echo "version=$v" >> "$GITHUB_OUTPUT"
- name: Login to Docker Hub
env:
DH_USER: ${{ secrets.DOCKERHUB_USERNAME }}
DH_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
run: echo "$DH_TOKEN" | docker login -u "$DH_USER" --password-stdin
- name: Build + push (refresh latest)
env:
VERSION: ${{ steps.ver.outputs.version }}
run: |
docker buildx create --name builder --use --driver docker-container
# Refresh the floating tag only — never overwrite a versioned release.
# Force a fresh base pull so apk upgrade picks up new patches.
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg "VERSION=$VERSION" \
--tag "torrentclaw/unarr:latest" \
--no-cache \
--push \
.

View file

@ -0,0 +1,118 @@
name: Release
on:
push:
tags:
- "v*"
workflow_dispatch:
permissions:
contents: write
jobs:
release:
runs-on: docker
container:
image: docker.io/library/golang:1.25
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install build deps (bash, curl, jq, ffmpeg fetch deps)
run: |
apt-get update
apt-get install -y --no-install-recommends bash curl ca-certificates jq xz-utils unzip
- name: Install goreleaser
run: |
curl -sSfL https://github.com/goreleaser/goreleaser/releases/latest/download/goreleaser_Linux_x86_64.tar.gz \
| tar -xz -C /usr/local/bin goreleaser
- name: Run goreleaser
env:
# Forgejo runner auto-injects GITHUB_TOKEN (a per-job, instance-scoped
# token usable against the Forgejo REST API). goreleaser only accepts
# one token; with both GITHUB_TOKEN + GITEA_TOKEN set it errors out
# ("multiple tokens"). Unset GITHUB_TOKEN before invoking goreleaser so
# it picks the Gitea code path + the gitea_urls block in .goreleaser.yml.
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
# Empty when RELEASE_SIGNING_PUBKEY variable is unset — goreleaser
# accepts it and the resulting binary disables signature checks
# (back-compat: pre-signing releases continue to update). Set
# RELEASE_SIGNING_PUBKEY (variable) + RELEASE_SIGNING_KEY (secret)
# to turn verification on.
RELEASE_SIGNING_PUBKEY: ${{ vars.RELEASE_SIGNING_PUBKEY }}
run: |
unset GITHUB_TOKEN
goreleaser release --clean
- name: Sign checksums.txt with ed25519
if: ${{ vars.RELEASE_SIGNING_PUBKEY != '' && secrets.RELEASE_SIGNING_KEY != '' }}
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
RELEASE_TAG: ${{ github.ref_name }}
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Tailscale IP — domain-agnostic; the runner shares the dokploy-network with
# forgejo (hostname `forgejo`), so the in-cluster hostname is fastest, but the
# Tailscale IP is the documented fallback.
FORGEJO_API: http://forgejo:3000/api/v1
REPO: torrentclaw/unarr
run: |
set -euo pipefail
go run ./scripts/sign-checksums \
-key "$RELEASE_SIGNING_KEY" \
-in dist/checksums.txt \
-out dist/checksums.txt.sig
# Find the release ID for this tag, then upload the sig as an asset.
rel_id=$(curl -sSf "$FORGEJO_API/repos/$REPO/releases/tags/$RELEASE_TAG" \
-H "Authorization: token $FORGEJO_TOKEN" | jq -r '.id')
curl -sSf -X POST \
"$FORGEJO_API/repos/$REPO/releases/$rel_id/assets?name=checksums.txt.sig" \
-H "Authorization: token $FORGEJO_TOKEN" \
-F "attachment=@dist/checksums.txt.sig"
docker:
needs: release
runs-on: docker
container:
# Docker-in-Docker capable image — buildx + qemu pre-installed.
image: docker.io/library/docker:27-cli
steps:
- uses: actions/checkout@v4
- name: Install buildx
run: |
apk add --no-cache curl
mkdir -p ~/.docker/cli-plugins
curl -sSL https://github.com/docker/buildx/releases/latest/download/buildx-linux-amd64 \
-o ~/.docker/cli-plugins/docker-buildx
chmod +x ~/.docker/cli-plugins/docker-buildx
- name: Login to Docker Hub
env:
DH_USER: ${{ secrets.DOCKERHUB_USERNAME }}
DH_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
run: echo "$DH_TOKEN" | docker login -u "$DH_USER" --password-stdin
- name: Set up qemu
run: docker run --rm --privileged tonistiigi/binfmt --install all
- name: Build + push multi-arch image
env:
VERSION: ${{ github.ref_name }}
run: |
set -euo pipefail
VERSION_SEMVER="${VERSION#v}"
MAJOR_MINOR="${VERSION_SEMVER%.*}"
docker buildx create --name builder --use --driver docker-container
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg "VERSION=$VERSION" \
--tag "torrentclaw/unarr:$VERSION_SEMVER" \
--tag "torrentclaw/unarr:$MAJOR_MINOR" \
--tag "torrentclaw/unarr:latest" \
--push \
.

View file

@ -1,163 +0,0 @@
name: Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
- uses: goreleaser/goreleaser-action@v6
with:
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
docker:
needs: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Docker meta
id: meta
uses: docker/metadata-action@v6
with:
images: torrentclaw/unarr
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v7
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ github.ref_name }}
virustotal:
needs: release
runs-on: ubuntu-latest
if: vars.VT_ENABLED == 'true'
steps:
- name: Get release tag
id: tag
run: echo "tag=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Download release assets
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
mkdir -p assets
gh release download "${{ steps.tag.outputs.tag }}" \
--repo "${{ github.repository }}" \
--dir assets \
--pattern '*.tar.gz' \
--pattern '*.zip' \
--pattern 'checksums.txt'
- name: Scan assets with VirusTotal
env:
VT_API_KEY: ${{ secrets.VT_API_KEY }}
run: |
mkdir -p results
for file in assets/*; do
filename=$(basename "$file")
echo "Uploading $filename to VirusTotal..."
response=$(curl -s --request POST \
--url https://www.virustotal.com/api/v3/files \
--header "x-apikey: $VT_API_KEY" \
--form "file=@$file")
analysis_id=$(echo "$response" | jq -r '.data.id // empty')
if [ -z "$analysis_id" ]; then
echo "::warning::Failed to upload $filename: $response"
continue
fi
echo "$filename=$analysis_id" >> results/scans.txt
echo " Analysis ID: $analysis_id"
# Rate limit: VT free tier allows 4 req/min
sleep 16
done
- name: Wait for analysis completion
env:
VT_API_KEY: ${{ secrets.VT_API_KEY }}
run: |
echo "Waiting 60s for VirusTotal analysis to complete..."
sleep 60
vt_report="## 🛡️ VirusTotal Scan Results\n\n"
vt_report+="| File | Result | Link |\n"
vt_report+="|------|--------|------|\n"
while IFS='=' read -r filename analysis_id; do
result=$(curl -s --request GET \
--url "https://www.virustotal.com/api/v3/analyses/$analysis_id" \
--header "x-apikey: $VT_API_KEY")
malicious=$(echo "$result" | jq -r '.data.attributes.stats.malicious // 0')
undetected=$(echo "$result" | jq -r '.data.attributes.stats.undetected // 0')
sha256=$(echo "$result" | jq -r '.meta.file_info.sha256 // empty')
if [ "$malicious" = "0" ]; then
status="✅ Clean ($undetected engines)"
else
status="⚠️ $malicious detections"
fi
link="https://www.virustotal.com/gui/file/$sha256"
vt_report+="| \`$filename\` | $status | [View]($link) |\n"
sleep 16
done < results/scans.txt
echo -e "$vt_report" > results/report.md
cat results/report.md
- name: Append scan results to release notes
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
current_body=$(gh release view "${{ steps.tag.outputs.tag }}" \
--repo "${{ github.repository }}" \
--json body --jq '.body')
new_body="${current_body}
$(cat results/report.md)"
gh release edit "${{ steps.tag.outputs.tag }}" \
--repo "${{ github.repository }}" \
--notes "$new_body"

5
.gitignore vendored
View file

@ -41,4 +41,7 @@ dist-ffbinaries/
# Docker
tmp/
config/
dist-ffbinaries/
dist-ffbinaries/
# Claude Code: keep entirely local, do not track
.claude/

View file

@ -26,6 +26,10 @@ builds:
- -s -w
- -X github.com/torrentclaw/unarr/internal/cmd.Version={{.Version}}
- -X github.com/torrentclaw/unarr/internal/sentry.dsn={{ .Env.SENTRY_DSN }}
# Release-signing public key — verified by the self-updater against
# checksums.txt.sig. Empty when not configured; in that case
# signature verification is skipped and a warning is logged.
- -X github.com/torrentclaw/unarr/internal/upgrade.releasePubKeyBase64={{ .Env.RELEASE_SIGNING_PUBKEY }}
archives:
- formats: [tar.gz]
@ -55,6 +59,22 @@ changelog:
- "^test:"
- "^chore:"
# Self-hosted Forgejo at git.torrentclaw.com. goreleaser detects GITEA_TOKEN +
# these URLs and publishes the release there instead of GitHub. Reachable via
# `forgejo` hostname inside the dokploy-network (the runner shares it); for
# local goreleaser runs outside the network, override via env GITEA_API_URL.
#
# In goreleaser v2 `gitea_urls` is a top-level key (was nested under `release`
# in v1).
gitea_urls:
api: http://forgejo:3000/api/v1
download: https://git.torrentclaw.com
skip_tls_verify: false
release:
draft: false
prerelease: auto
# Homebrew tap — requires PAT with repo scope (not GITHUB_TOKEN)
# Enable when torrentclaw/homebrew-tap PAT is configured as HOMEBREW_TAP_TOKEN
# brews:

0
.nojekyll Normal file
View file

View file

@ -5,6 +5,174 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.9.15] - 2026-05-27
### Added
- **sentry**: enhance error handling by skipping user input errors in CaptureError
### Changed
- **ci**: point Forgejo URLs at torrentclaw org (post-transfer)
- **sentry**: decouple agent import via string-match, rename predicate
### Documentation
- **positioning**: reframe unarr around download/stream/transcode, drop misleading search-first wording
### Fixed
- **ci**: unset GITHUB_TOKEN so goreleaser uses GITEA_TOKEN
- **sentry**: skip "daemon not running" stop/reload errors
### Other
- **scripts**: harden release.sh against double-release and inline version bumps
- untrack .claude/ (private local config)
## [0.9.14] - 2026-05-27
### Added
- **vaapi**: hybrid CPU-scale + hwupload encode path (QW2, 0.9.14)
### CI/CD
- port workflows from .github/ to .forgejo/ (Forgejo Actions)
### Fixed
- **daemon**: defensive IsClosed check in watchSessionReady poll loop
- **daemon**: use parent ctx for MarkSessionReady so cancel propagates
- **release**: move gitea_urls to top-level (goreleaser v2 schema)
## [0.9.13] - 2026-05-27
### Added
- **agent**: session-ready webhook for SSE-driven player handshake (0.9.13)
- **agent**: send full transcoder diagnostic in register payload (0.9.12)
### Fixed
- **daemon**: defer probeCancel so a panic mid-diagnostic still releases ctx
### Other
- **release**: add ship.sh end-to-end pipeline as GH Actions backup
- **skills**: add /publish slash command + allow .claude/ in git
## [0.9.11] - 2026-05-27
### Added
- **hls**: pre-segmentación delantada — 2 s segments + async session start (0.9.10)
- **hls**: faster first-start — probe cache + tighter encoder presets (0.9.9)
### Changed
- **hls**: critico-driven hardening of fase 3.2
### Fixed
- **cors**: allow play from .to / staging / onion mirrors
- **library**: classify resolution by width + height, not height alone
- **transcode**: make preset libx264-only + restore quality opt-in
### Other
- **release**: 0.9.11
## [0.9.8] - 2026-05-27
### Fixed
- **upgrade**: break auto-apply restart loop (0.9.8)
## [0.9.7] - 2026-05-26
### Added
- **hls**: persistent fMP4 segment cache + integrity + stats (0.9.7)
## [0.9.6] - 2026-05-26
### Added
- **daemon**: auto-apply upgrades when server signals (0.9.6)
## [0.9.5] - 2026-05-26
### Added
- **funnel**: cloudflare quick tunnel embedded subprocess (0.9.5)
## [0.9.4] - 2026-05-26
### Added
- **stream**: retire WebRTC, HLS-only, bump 0.9.4 (**BREAKING**)
## [0.9.3] - 2026-05-26
### Added
- **usenet**: warn at startup when par2 or extractor is missing
### Fixed
- **engine**: truncate errorMessage before reporting status
- **hls**: clamp ffmpeg bitrate to the level we derive from outputHeight
## [0.9.2] - 2026-05-22
### Added
- **vpn**: unarr vpn command + report/arbitrate the WireGuard slot
## [0.9.1] - 2026-05-21
### Added
- **mirror**: update fallback URLs to use IPFS and remove GitHub Pages
### Fixed
- **security**: bump golang.org/x deps and add container CVE scan gate
### Other
- **release**: 0.9.1
## [0.9.0] - 2026-05-21
### Added
- **agent**: add mirror failover, agent client refactor, status 401 detection
- **vpn**: local config_file for self-hosted/personal VPN testing
- **vpn**: split-tunnel torrent traffic through managed WireGuard
### CI/CD
- deploy install scripts to GitHub Pages
### Documentation
- **docker**: refresh Docker Hub README + sync description in CI
### Fixed
- **security**: CORS allowlist, URL scheme guard, state perms, ZIP slip, mirror docs
- **security**: UPnP opt-in, bounded SSE reader, signed self-update
- **security**: harden HLS session IDs, /health disclosure, archive password handling
- **upgrade**: fetch releases from TorrentClaw app, not GitHub
### Other
- **pages**: add .nojekyll to disable Jekyll processing
- **pages**: set custom domain unarr.torrentclaw.com
- **release**: 0.9.0
## [0.8.1] - 2026-05-08
@ -25,6 +193,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Other
- **gitignore**: add dist-ffbinaries to ignored files
- **release**: 0.8.1
## [0.8.0] - 2026-05-08
@ -238,16 +407,117 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.4.1] - 2026-04-01
### Added
- **cli**: add login command and refactor shared helpers
- **stream**: report watch progress to API via HTTP Range tracking
### Fixed
- **ci**: fix lint errors and pin CI to Go 1.25
- **lint**: remove unused newStubCmd function
### Other
- **cli**: remove moreseed stub command
- **cli**: remove redundant stub commands (monitor, open, add, compare)
## [0.4.0] - 2026-03-31
### Added
- **cli**: upgrade command, rich status, and version cache
### Fixed
- **progress**: always report status transitions and poll for control signals
## [0.3.7] - 2026-03-31
### CI/CD
- **docker**: remove dockerhub-description sync step
## [0.3.6] - 2026-03-31
### CI/CD
- **deps**: bump docker/metadata-action from 5 to 6
- **deps**: bump docker/setup-qemu-action from 3 to 4
- **deps**: bump docker/login-action from 3 to 4
- **deps**: bump docker/build-push-action from 6 to 7
- **deps**: bump codecov/codecov-action from 5 to 6
- **docker**: add Docker Hub description sync and DOCKERHUB.md
### Fixed
- **ci**: upgrade golangci-lint to v2.11.3 for Go 1.25 support
- **docker**: upgrade alpine packages to patch CVE-2025-60876 and CVE-2026-27171
- **lint**: use default:none to disable errcheck, fix all gofmt and exhaustive
- **lint**: disable errcheck, tune gosec/exclusions for codebase state
- **lint**: configure linters for codebase maturity, fix gofmt and ineffassign
- **lint**: exclude common fire-and-forget patterns from errcheck
- **lint**: resolve errcheck and bodyclose warnings for golangci-lint v2
## [0.3.5] - 2026-03-30
### Changed
- migrate lint config to v2, remove daemon auto-upgrade, add trust badges
## [0.3.3] - 2026-03-30
### Fixed
- **ci**: remove go-client checkout steps
## [0.3.2] - 2026-03-30
### Added
- **init**: add 60s countdown, skip key, and cancel detection to browser auth
### CI/CD
- **release**: add Docker Hub publish and VirusTotal scan jobs
### Documentation
- add beta notice, fix install URLs to get.torrentclaw.com
### Fixed
- **ci**: fix virustotal job condition syntax
- **docker**: simplify Dockerfile for CI builds (no local go-client)
- **release**: disable homebrew tap (needs PAT, not GITHUB_TOKEN)
### Other
- re-enable homebrew tap in goreleaser
## [0.3.1] - 2026-03-30
### Fixed
- **build**: unused variable in Windows process check
- **release**: disable homebrew tap until repo is created
### Other
- rename module from torrentclaw-cli to unarr
### Build
- remove UPX compression (antivirus false positives, startup penalty)
## [0.3.0] - 2026-03-29
### Added
- **agent**: add WebSocket transport with HTTP fallback
- **auth**: browser-based CLI authentication (like Claude Code)
- **cli**: add login command and refactor shared helpers
- **cli**: upgrade command, rich status, and version cache
- **daemon**: add auto-scan, force start, and stall timeout default
- **debrid**: add HTTPS downloader for debrid direct URLs
- **init**: add 60s countdown, skip key, and cancel detection to browser auth
- **stream**: report watch progress to API via HTTP Range tracking
- **stream**: UPnP port forwarding for remote video playback
- **usenet**: implement full NNTP download pipeline
- add migrate command, media server detection, and debrid auto-config
@ -257,61 +527,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- improve daemon resilience, streaming, and usenet downloads
- initial commit — unarr CLI
### CI/CD
- **deps**: bump docker/metadata-action from 5 to 6
- **deps**: bump docker/setup-qemu-action from 3 to 4
- **deps**: bump docker/login-action from 3 to 4
- **deps**: bump docker/build-push-action from 6 to 7
- **deps**: bump codecov/codecov-action from 5 to 6
- **docker**: remove dockerhub-description sync step
- **docker**: add Docker Hub description sync and DOCKERHUB.md
- **release**: add Docker Hub publish and VirusTotal scan jobs
### Changed
- migrate lint config to v2, remove daemon auto-upgrade, add trust badges
- extract BuildSyncItems to library package, remove duplication
### Documentation
- add beta notice, fix install URLs to get.torrentclaw.com
- improve CLI help, shell completion, and README
### Fixed
- **build**: unused variable in Windows process check
- **ci**: fix lint errors and pin CI to Go 1.25
- **ci**: upgrade golangci-lint to v2.11.3 for Go 1.25 support
- **ci**: remove go-client checkout steps
- **ci**: fix virustotal job condition syntax
- **docker**: upgrade alpine packages to patch CVE-2025-60876 and CVE-2026-27171
- **docker**: simplify Dockerfile for CI builds (no local go-client)
- **lint**: remove unused newStubCmd function
- **lint**: use default:none to disable errcheck, fix all gofmt and exhaustive
- **lint**: disable errcheck, tune gosec/exclusions for codebase state
- **lint**: configure linters for codebase maturity, fix gofmt and ineffassign
- **lint**: exclude common fire-and-forget patterns from errcheck
- **lint**: resolve errcheck and bodyclose warnings for golangci-lint v2
- **progress**: always report status transitions and poll for control signals
- **release**: disable homebrew tap (needs PAT, not GITHUB_TOKEN)
- **release**: disable homebrew tap until repo is created
- **torrent**: expand tracker list, add DHT persistence and configurable timeouts
- force-start tasks bypass HasCapacity check in dispatch loop
- add panic recovery to auto-scan, cap DHT nodes at 200
- harden usenet/debrid downloaders from critico review
### Other
- **cli**: remove moreseed stub command
- **cli**: remove redundant stub commands (monitor, open, add, compare)
- re-enable homebrew tap in goreleaser
- rename module from torrentclaw-cli to unarr
### Build
- remove UPX compression (antivirus false positives, startup penalty)
- add -s -w -trimpath to Makefile, add build-small target with UPX
[0.9.15]: https://github.com/torrentclaw/unarr/compare/v0.9.14...v0.9.15
[0.9.14]: https://github.com/torrentclaw/unarr/compare/v0.9.13...v0.9.14
[0.9.13]: https://github.com/torrentclaw/unarr/compare/v0.9.11...v0.9.13
[0.9.11]: https://github.com/torrentclaw/unarr/compare/v0.9.8...v0.9.11
[0.9.8]: https://github.com/torrentclaw/unarr/compare/v0.9.7...v0.9.8
[0.9.7]: https://github.com/torrentclaw/unarr/compare/v0.9.6...v0.9.7
[0.9.6]: https://github.com/torrentclaw/unarr/compare/v0.9.5...v0.9.6
[0.9.5]: https://github.com/torrentclaw/unarr/compare/v0.9.4...v0.9.5
[0.9.4]: https://github.com/torrentclaw/unarr/compare/v0.9.3...v0.9.4
[0.9.3]: https://github.com/torrentclaw/unarr/compare/v0.9.2...v0.9.3
[0.9.2]: https://github.com/torrentclaw/unarr/compare/v0.9.1...v0.9.2
[0.9.1]: https://github.com/torrentclaw/unarr/compare/v0.9.0...v0.9.1
[0.9.0]: https://github.com/torrentclaw/unarr/compare/v0.8.1...v0.9.0
[0.8.1]: https://github.com/torrentclaw/unarr/compare/v0.8.0...v0.8.1
[0.8.0]: https://github.com/torrentclaw/unarr/compare/v0.7.0...v0.8.0
[0.7.0]: https://github.com/torrentclaw/unarr/compare/v0.6.8...v0.7.0
@ -331,4 +577,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[0.5.1]: https://github.com/torrentclaw/unarr/compare/v0.5.0...v0.5.1
[0.5.0]: https://github.com/torrentclaw/unarr/compare/v0.4.1...v0.5.0
[0.4.1]: https://github.com/torrentclaw/unarr/compare/v0.4.0...v0.4.1
[0.4.0]: https://github.com/torrentclaw/unarr/compare/v0.3.7...v0.4.0
[0.3.7]: https://github.com/torrentclaw/unarr/compare/v0.3.6...v0.3.7
[0.3.6]: https://github.com/torrentclaw/unarr/compare/v0.3.5...v0.3.6
[0.3.5]: https://github.com/torrentclaw/unarr/compare/v0.3.3...v0.3.5
[0.3.3]: https://github.com/torrentclaw/unarr/compare/v0.3.2...v0.3.3
[0.3.2]: https://github.com/torrentclaw/unarr/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/torrentclaw/unarr/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/torrentclaw/unarr/releases/tag/v0.3.0

1
CNAME Normal file
View file

@ -0,0 +1 @@
unarr.torrentclaw.com

View file

@ -1,12 +1,21 @@
# unarr
Powerful terminal tool for torrent search and management. Search 30+ sources, inspect quality, discover popular content, find streaming providers, and manage downloads — all from your terminal.
**The single binary that replaces your whole *arr stack.** Built-in torrent,
debrid, and usenet engines. Stream, transcode, and organize your library from
one terminal — or run it as a headless daemon with a web dashboard, WireGuard
split-tunnel, and Cloudflare Funnel remote access.
**[GitHub](https://github.com/torrentclaw/unarr)** | **[Documentation](https://github.com/torrentclaw/unarr#readme)** | **[Releases](https://github.com/torrentclaw/unarr/releases)**
**[Website & docs](https://torrentclaw.com/unarr)** · **[Install guide](https://torrentclaw.com/cli)** · **[Get an API key](https://torrentclaw.com)**
## Quick Start
> Powered by [TorrentClaw](https://torrentclaw.com) — an aggregator that unifies
> YTS, EZTV, Knaben, Torrentio, Bitmagnet and more, enriched with TMDB metadata
> and a 0100 quality score per release.
### 1. Setup (interactive wizard)
---
## Quick start
### 1. First-time setup (interactive wizard)
```bash
docker run -it --rm \
@ -14,6 +23,9 @@ docker run -it --rm \
torrentclaw/unarr setup
```
The wizard asks for your TorrentClaw API key (free at
[torrentclaw.com](https://torrentclaw.com)) and your download directory.
### 2. Run the daemon
```bash
@ -26,6 +38,10 @@ docker run -d --name unarr \
torrentclaw/unarr
```
That's it — `unarr` now runs headless, watching for jobs and managing downloads.
---
## Docker Compose
```yaml
@ -45,45 +61,54 @@ services:
environment:
- TZ=UTC
# - UNARR_API_KEY=tc_your_key_here
network_mode: host # recommended for full P2P performance
deploy:
resources:
limits:
memory: 512M
cpus: "2.0"
network_mode: host
volumes:
unarr-data:
```
```bash
docker compose run --rm unarr setup # one-time wizard
docker compose up -d # start the daemon
```
---
## Volumes
| Path | Purpose |
|------|---------|
| `/config` | Configuration file (`config.toml`) |
| `/downloads` | Finished media downloads |
| `/data` | Internal state: torrent metadata, cache |
| Path | Purpose |
|--------------|--------------------------------------------------|
| `/config` | Configuration file (`config.toml`) |
| `/downloads` | Finished media downloads |
| `/data` | Internal state: torrent metadata, cache |
## Environment Variables
## Environment variables
| Variable | Description | Default |
|----------|-------------|---------|
| `TZ` | Timezone | `UTC` |
| `UNARR_API_KEY` | TorrentClaw API key | from config |
| `UNARR_API_URL` | API endpoint | `https://torrentclaw.com` |
| `UNARR_DOWNLOAD_DIR` | Download directory | `/downloads` |
| `UNARR_CONFIG_DIR` | Config directory | `/config` |
| `UNARR_COUNTRY` | Country code (ISO 3166) | `US` |
| Variable | Description | Default |
|------------------------|--------------------------------------|---------------------------|
| `UNARR_API_KEY` | TorrentClaw API key | from config |
| `UNARR_API_URL` | API endpoint | `https://torrentclaw.com` |
| `UNARR_DOWNLOAD_DIR` | Download directory | `/downloads` |
| `UNARR_CONFIG_DIR` | Config directory | `/config` |
| `UNARR_COUNTRY` | Country code (ISO 3166) | `US` |
| `TZ` | Timezone | `UTC` |
Any config value can be overridden by its matching `UNARR_*` environment variable.
## Networking
**Host mode** (recommended) gives full P2P performance with no port management:
**Host mode (recommended)** — full P2P performance, no port mapping:
```yaml
network_mode: host
```
**Bridge mode** — more isolated, but requires explicit ports:
**Bridge mode** — more isolated, but you must expose the BitTorrent ports:
```yaml
ports:
@ -91,7 +116,7 @@ ports:
- "6881-6889:6881-6889/udp"
```
## Running Commands
## Running commands
Use `docker exec` for one-off commands while the daemon is running:
@ -99,32 +124,77 @@ Use `docker exec` for one-off commands while the daemon is running:
docker exec unarr unarr search "inception" --quality 1080p
docker exec unarr unarr popular --limit 10
docker exec unarr unarr status
docker exec unarr unarr doctor
docker exec unarr unarr doctor # diagnose config / connectivity
```
## Supported Architectures
| Architecture | Tag |
|-------------|-----|
| `linux/amd64` | `latest`, `0.3`, `0.3.5` |
| `linux/arm64` | `latest`, `0.3`, `0.3.5` |
---
## Tags
| Tag | Description |
|-----|-------------|
| `latest` | Latest stable release |
| `X.Y.Z` | Specific version (e.g. `0.3.5`) |
| `X.Y` | Latest patch for minor version (e.g. `0.3`) |
| Tag | Description |
|----------|--------------------------------------------------|
| `latest` | Latest stable release |
| `X.Y.Z` | Exact version (e.g. `0.9.0`) |
| `X.Y` | Latest patch within a minor (e.g. `0.9`) |
## Image Details
Pin a tag in production (`torrentclaw/unarr:0.9.0`) for reproducible deploys.
- **Base image:** Alpine 3.22
- **User:** `unarr` (UID 1000, GID 1000)
## Supported architectures
Multi-arch image — Docker pulls the right one automatically:
- `linux/amd64`
- `linux/arm64` (Apple Silicon, Raspberry Pi 4/5, ARM servers)
## Image details
- **Base:** Alpine 3.22 (minimal, regularly patched)
- **User:** `unarr` (UID 1000, GID 1000) — runs as **non-root**
- **Entrypoint:** `unarr start` (daemon mode)
- **Read-only filesystem** — only mounted volumes are writable
- **No root required** — runs as non-root by default
- **Read-only rootfs** — only mounted volumes are writable
- **Bundled `ffmpeg` / `ffprobe`** for media inspection — nothing else to install
- **Self-contained updates** — binaries are served from TorrentClaw's own
infrastructure, no third-party registry dependency
---
## Other install methods
Not using Docker? Install the native binary instead:
```bash
# Linux / macOS
curl -fsSL https://torrentclaw.com/install.sh | sh
# Windows (PowerShell)
irm https://torrentclaw.com/install.ps1 | iex
# Go toolchain
go install github.com/torrentclaw/unarr/cmd/unarr@latest
```
## Mirrors
The installer and release binaries are served from every TorrentClaw mirror, so
you can install even if one domain is blocked in your region. Each mirror is
self-contained (it serves its own binaries — no cross-domain dependency):
| Mirror | Install command |
|--------|-----------------|
| `torrentclaw.com` (primary) | `curl -fsSL https://torrentclaw.com/install.sh \| sh` |
| `torrentclaw.to` | `curl -fsSL https://torrentclaw.to/install.sh \| sh` |
| Tor (`.onion`) | `torsocks sh -c "$(curl http://torrentf3aifidcsaaanmnmuhv2s53r6hqsl3zkmfidiaxainkeqk5id.onion/install.sh)"` |
The Tor address routes everything (install script + binaries) through the hidden
service, so no clearnet exit is needed.
## Links
- **Website & docs:** https://torrentclaw.com/unarr
- **CLI install guide:** https://torrentclaw.com/cli
- **API & account:** https://torrentclaw.com
- **Mirror status:** https://torrentclaw.com/mirrors
## License
MIT License — see [LICENSE](https://github.com/torrentclaw/unarr/blob/main/LICENSE) for details.
MIT.

View file

@ -21,10 +21,23 @@ FROM alpine:3.22
# Use Alpine's native musl ffmpeg + ffprobe instead of the johnvansickle /
# BtbN static glibc builds — those need a glibc shim on Alpine and the
# vector-math symbols the GPL builds reference are not satisfiable by
# gcompat. Alpine ships ffmpeg ~7.x which is fine for the WebRTC
# transcoding pipeline (libx264 + libfdk-aac alternatives included).
# gcompat. Alpine ships ffmpeg ~7.x which is fine for the HLS transcoding
# pipeline (libx264 + libfdk-aac alternatives included).
RUN apk upgrade --no-cache && \
apk add --no-cache ca-certificates tzdata ffmpeg
apk add --no-cache ca-certificates tzdata ffmpeg wget
# Bundle cloudflared so `unarr funnel on` (default: on, see config defaults)
# Just Works on a headless container with no first-run network round-trip.
# TARGETARCH is set automatically by Docker buildx during cross-builds.
ARG TARGETARCH=amd64
RUN case "$TARGETARCH" in \
amd64) CF_ARCH=amd64 ;; \
arm64) CF_ARCH=arm64 ;; \
arm) CF_ARCH=armhf ;; \
*) echo "unsupported TARGETARCH=$TARGETARCH" >&2; exit 1 ;; \
esac && \
wget -qO /usr/local/bin/cloudflared "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$CF_ARCH" && \
chmod +x /usr/local/bin/cloudflared
# Non-root user (UID 1000 matches typical host user for volume permissions)
RUN addgroup -g 1000 unarr && adduser -u 1000 -G unarr -D -h /home/unarr unarr

170
Docs/plans/library-sync.md Normal file
View file

@ -0,0 +1,170 @@
# Plan: Sincronización bidireccional de biblioteca (CLI ↔ Web)
## Context
La biblioteca web solo muestra descargas completadas (download_task + debrid). El `unarr scan` escanea ficheros con ffprobe y los sube al servidor, pero solo soporta un path, no detecta borrados del disco, y no permite borrar ficheros desde la web. El usuario quiere una biblioteca unificada que refleje el estado real de su colección y se sincronice en ambas direcciones.
## Protocolo de sincronización
### Forward Sync (Disco → Web)
1. CLI escanea todos los `ScanPaths` configurados
2. Para cada path: descubre ficheros, compara con cache (skip ffprobe si no cambió), sube a `/library-sync`
3. En `isLastBatch=true`: el servidor elimina items con ese `scanPath` que no estén en el batch (ficheros borrados del disco desaparecen de la web)
### Reverse Sync (Web → Disco)
1. CLI llama a `GET /agent/library-deletions` — items que el usuario soft-deleted desde la web
2. Si `AutoDelete=true` o `--yes`: borra ficheros del disco
3. Si no: muestra lista y pide confirmación interactiva
4. Llama a `POST /agent/library-deletions/confirm` con los IDs confirmados → hard-delete en DB
### Resolución de conflictos
- Fichero en disco pero no en web → forward sync lo añade
- Fichero en web pero no en disco → forward sync lo elimina (isLastBatch)
- Soft-deleted en web, aún en disco → reverse sync lo borra del disco y confirma
- Soft-deleted en web, ya borrado del disco → reverse sync confirma directamente
- Race condition (user borra en web mientras CLI escanea) → forward sync skippea rows con `deleted_at IS NOT NULL`
---
## Fase 1: Multi-path + Forward Sync mejorado
### 1.1 CLI — Config multi-path
**Archivo:** `torrentclaw-cli/internal/config/config.go`
- Añadir `ScanPaths []string` a `LibraryConfig`
- Migrar `ScanPath``ScanPaths[0]` en `Load()` si `ScanPaths` está vacío
- Añadir `AutoDelete bool` (default false)
### 1.2 CLI — Cache v2
**Archivo:** `torrentclaw-cli/internal/library/types.go`
- Cambiar `LibraryCache` a version 2: `Paths map[string][]LibraryItem`
- Migración v1→v2: `Path`+items → `Paths[Path]`
**Archivo:** `torrentclaw-cli/internal/library/cache.go`
- `LoadCache` detecta versión y migra
- `SaveCache` siempre guarda v2
### 1.3 CLI — Scan multi-path
**Archivo:** `torrentclaw-cli/internal/cmd/scan.go`
- `unarr scan` sin args → escanea todos los `ScanPaths`
- `unarr scan /path/a /path/b` → escanea paths específicos y los recuerda en config
- Loop: para cada path, scan + sync con su `scanPath`
### 1.4 CLI — Nuevo comando `unarr sync`
**Archivo nuevo:** `torrentclaw-cli/internal/cmd/sync.go`
- Forward sync: scan ligero (sin ffprobe para ficheros sin cambios) + upload
- Sin reverse sync todavía (Fase 3)
- Flags: `--dry-run`, `--paths`
### 1.5 Web — Columna `scan_path` en `library_item`
**Archivo:** `torrentclaw-web/src/lib/db/schema.ts`
- Añadir `scanPath: varchar(2048)` a tabla `libraryItem`
- Generar migración con `pnpm db:generate`
**Archivo:** `torrentclaw-web/src/lib/services/library-upgrade.ts`
- `syncLibraryItems()`: persistir `scanPath` en cada row al hacer upsert
### 1.6 CLI — Daemon multi-path
**Archivo:** `torrentclaw-cli/internal/cmd/daemon.go`
- `runAutoScan()` itera sobre todos los `ScanPaths`
---
## Fase 2: Reverse Sync (Web → Disco)
### 2.1 Web — Soft-delete
**Archivo:** `torrentclaw-web/src/lib/db/schema.ts`
- Añadir `deletedAt: timestamp` a tabla `libraryItem`
- Generar migración
### 2.2 Web — Endpoints de borrado
**Archivo nuevo:** `torrentclaw-web/src/app/api/internal/library/items/route.ts`
- `DELETE` — session auth, recibe `{itemIds: number[]}`, hace soft-delete (`deletedAt = NOW()`)
**Archivo nuevo:** `torrentclaw-web/src/app/api/internal/agent/library-deletions/route.ts`
- `GET` — agent auth, devuelve items con `deletedAt IS NOT NULL` para ese usuario
- `POST` — agent auth, recibe `{confirmedIds: number[]}`, hard-delete los rows
### 2.3 Web — Heartbeat con pendingDeletions
**Archivo:** endpoint de heartbeat del agente
- Añadir `pendingDeletions: number` al response (count de items con `deletedAt IS NOT NULL`)
### 2.4 Web — Forward sync respeta soft-deletes
**Archivo:** `torrentclaw-web/src/lib/services/library-upgrade.ts`
- `syncLibraryItems()` en `isLastBatch`: la query de DELETE excluye rows con `deletedAt IS NOT NULL`
### 2.5 CLI — Agent client nuevos métodos
**Archivo:** `torrentclaw-cli/internal/agent/client.go`
- `GetLibraryDeletions(ctx) → []DeletionItem`
- `ConfirmLibraryDeletions(ctx, ids []int) → error`
**Archivo:** `torrentclaw-cli/internal/agent/types.go`
- `DeletionItem {ID int, FilePath string, DeletedAt string}`
### 2.6 CLI — Sync reverse
**Archivo:** `torrentclaw-cli/internal/cmd/sync.go`
- Después del forward sync: llama a `GetLibraryDeletions()`
- Valida que cada fichero está dentro de un `ScanPaths` conocido (seguridad)
- Si `AutoDelete` o `--yes`: borra y confirma
- Si no: muestra lista interactiva, pide confirmación
- Flag `--no-delete` para skip reverse sync
- Si `BackupDir` configurado: mover a backup en vez de borrar
### 2.7 CLI — Daemon auto-delete
**Archivo:** `torrentclaw-cli/internal/cmd/daemon.go`
- Al final de `runAutoSync()`: si `AutoDelete=true`, procesa deletions automáticamente
- Si no: log warning "N files pending deletion, run `unarr sync`"
---
## Fase 3: Web UI (brief)
- Botón "Eliminar" en items de biblioteca → llama `DELETE /library/items`
- Badge "Pendiente de borrar" en items soft-deleted
- Posibilidad de cancelar el borrado (clear `deletedAt`)
- Vista unificada: scanned items + downloaded items en la misma vista
---
## Archivos clave
### CLI (Go)
| Archivo | Cambio |
|---------|--------|
| `internal/config/config.go` | ScanPaths, AutoDelete, migración |
| `internal/library/types.go` | Cache v2 con Paths map |
| `internal/library/cache.go` | Load/Save v2, migración v1 |
| `internal/library/sync.go` | BuildSyncItems (sin cambios) |
| `internal/cmd/scan.go` | Multi-path loop |
| `internal/cmd/sync.go` | **Nuevo** — comando sync bidireccional |
| `internal/cmd/daemon.go` | runAutoSync multi-path + reverse |
| `internal/agent/client.go` | GetLibraryDeletions, ConfirmLibraryDeletions |
| `internal/agent/types.go` | DeletionItem type |
### Web (TypeScript)
| Archivo | Cambio |
|---------|--------|
| `src/lib/db/schema.ts` | scanPath + deletedAt en library_item |
| `src/lib/services/library-upgrade.ts` | persistir scanPath, respetar soft-deletes |
| `src/app/api/internal/agent/library-deletions/route.ts` | **Nuevo** — GET + POST |
| `src/app/api/internal/library/items/route.ts` | **Nuevo** — DELETE soft-delete |
| Endpoint heartbeat del agente | pendingDeletions en response |
---
## Verificación
### Fase 1
1. `go build ./cmd/unarr/ && go test ./...`
2. Configurar 2 scan paths en config.toml, ejecutar `unarr scan` → ambos se escanean
3. Borrar un fichero del disco, ejecutar `unarr scan` → desaparece de la web
4. `pnpm build` en torrentclaw-web para verificar tipos
### Fase 2
1. Desde la web: borrar un item de la biblioteca
2. Ejecutar `unarr sync` → muestra el fichero pendiente de borrar, pedir confirmación
3. Confirmar → fichero se borra del disco y desaparece de la web
4. `unarr sync --dry-run` → muestra lo que haría sin hacer nada
5. Con `auto_delete = true` en config: el daemon borra automáticamente
### Fase 3
1. Verificar visualmente en Chrome DevTools la UI de borrado
2. Verificar que el badge "pendiente" aparece y desaparece correctamente

View file

@ -0,0 +1,131 @@
# Phase 2.2 — Per-task stream token (deferred)
Status: deferred. Requires coordinated change in the web app
(`torrentclaw-web`) and the CLI daemon. Pulled out of the Phase 2
security pass because the CLI-only fixes (UPnP opt-in, SSE caps,
signed self-update) ship without web-side work; the stream-token
work cannot.
## Problem
`/stream`, `/playlist.m3u` and `/hls/<sessionID>/...` on the daemon
HTTP server have no authentication. Today, anyone who can reach the
listener and guesses (or learns) the `taskID` (for `/stream`) or
`sessionID` (for `/hls`) can fetch the active file.
Mitigations already in place after Phase 1+2:
- `sessionID` is restricted to a safe regex and is a server-issued
UUID v4 (122-bit entropy, not enumerable in practice).
- `/health` no longer leaks the active filename, taskID prefix or
client IP to remote callers (loopback diagnostics preserved).
- UPnP is opt-in, so by default the daemon is not exposed to the
public internet.
- The web client probes `/health` to pick LAN vs Tailscale.
Residual risk:
- On a shared LAN (open Wi-Fi, office network, dorm) any device can
reach the listener and brute-force `?id=<taskID>` against
`/stream`. taskIDs are also UUIDs, so this is high entropy, but
the URL may leak through browser history, sharing, screen capture
or a passive logger and there is no second factor.
- A user who explicitly opts into UPnP exposes the same surface to
the entire internet.
A per-task secret carried in the URL closes this without breaking
the `<video src>` flow (the browser cannot attach `Authorization`
headers to media elements, but it can append a query parameter).
## Design
Both ends agree on a per-task secret token. The web generates it
when the user requests streaming; the daemon receives the
`(taskID, token)` pair and validates the token on every `/stream`
and `/hls/...` request.
### Web side (`torrentclaw-web`)
When the user clicks "Stream":
1. Generate `streamToken = crypto.randomBytes(32).toString("hex")`
server-side (NOT browser, so it never lives in client storage
longer than the page lifetime).
2. Persist `(taskID, streamToken, expiresAt)` in `download_task`
(new columns or a sibling table). Token expires e.g. 6 h after
issue or on explicit revoke.
3. Push the token to the daemon over the existing heartbeat / sync
channel that already carries `streamRequested`. Add a
`streamToken` field next to it. The daemon trusts that channel
(it is authenticated agent ↔ origin).
4. Include the token in the stream URLs the API returns to the
browser:
`http://<host>:<port>/stream?id=<taskID>&t=<streamToken>` and
the `/hls/<sessionID>` URLs gain `?t=<streamToken>` too.
Files that will need to change:
- `src/lib/services/agent.ts` — extend the stream-request payload
with `streamToken`.
- `src/lib/db/schema.ts` — column / table for the token.
- `src/lib/services/stream-resolve.ts` — append `&t=` to the URLs
it builds.
- `src/lib/stream-probe.ts` — keep probing `/health` (no token),
then append `&t=` to the winning stream URL before returning.
- `src/middleware.ts` — no CORS change required (browser still hits
daemon directly).
### CLI side
- `internal/agent/types.go` / `internal/agent/sync.go` — accept and
store `streamToken` next to `streamRequested`.
- `internal/agent/daemon.go` — when the heartbeat reports a new
active stream task, push the token into the stream server via a
setter: `streamSrv.SetTaskToken(taskID, token)`.
- `internal/engine/stream_server.go`:
- New field `tokens map[string]string` guarded by mutex.
- `SetTaskToken(taskID, token)` and `ClearTaskToken(taskID)`.
- `handler` (`/stream`) extracts `?id=` and `?t=`, checks the
token with `subtle.ConstantTimeCompare`; 404 on mismatch.
- `hlsHandler` (`/hls/<sessionID>/...`) needs an HLS-session
→ token mapping, since the path carries `sessionID` not
`taskID`. Store the token on the `HLSSession` at start and
validate per request.
### Backwards compatibility
- The daemon must accept token-less requests for one minor version
so a newer daemon can still serve an older web (and vice-versa).
Gate the check on a config flag (`require_stream_token`,
default false in the first release, default true in the next).
- The `<video src>` form supports query parameters, so the only
user-visible change is the URL string.
## Open questions to resolve before implementing
1. Token TTL. 6 h gives plenty of room for a movie + pause +
resume; longer means the post-leak window is wider.
2. Where to store the token in `download_task` — same row, or a
sibling `download_stream_token` table that we can rotate
without writing to the task row.
3. Should `/playlist.m3u` (VLC) embed the token directly, or use
a one-shot redeem URL? VLC URL ends up in history.
4. Token reuse across HLS reconnects — yes, scoped to the
`HLSSession`, invalidated on `Close()`.
5. Do we want a daemon flag `--require-stream-token` independent
of config, for users to flip on quickly without editing TOML?
## Effort estimate
- CLI: ~3 h
- Web: ~3 h
- Migration + rollout (config flag flip): 1 release cycle of soak.
## Why not now
- Cross-repo coordination raises commit blast radius beyond what
the Phase 2 security pass should carry.
- Web work needs DB migration + UI surfaces (the "stream link
expired" path).
- Phase 2 hardenings ship value today without it; this is the
defense-in-depth layer on top.

View file

@ -1,4 +1,4 @@
.PHONY: all build test lint coverage clean fmt vet check install-hooks changelog release release-patch release-minor release-major release-dry
.PHONY: all build test lint coverage clean fmt vet check install-hooks changelog release release-patch release-minor release-major release-dry ship ship-dry ship-push
BINARY = unarr
SENTRY_DSN ?=
@ -71,6 +71,19 @@ release-dry:
@test -n "$(V)" || { echo "Usage: make release-dry V=patch|minor|major|0.5.0"; exit 1; }
@./scripts/release.sh --dry-run $(V)
## Ship a release end-to-end (goreleaser + Hetzner + Docker Hub). Standalone backup for GH Actions.
## Reads version from internal/cmd/version.go unless V= is provided.
ship:
@./scripts/ship.sh $(V)
## Ship + git push tag to GH afterwards
ship-push:
@./scripts/ship.sh --push $(V)
## Preview ship steps without executing
ship-dry:
@./scripts/ship.sh --dry-run $(V)
## Remove generated files
clean:
rm -f $(BINARY) coverage.out coverage.html

239
README.md
View file

@ -11,9 +11,9 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Go Version](https://img.shields.io/github/go-mod/go-version/torrentclaw/unarr)](go.mod)
Powerful terminal tool for torrent search and management. **Free and open source.**
The single-binary terminal client for torrent, debrid, and usenet downloads. **Free and open source.**
Search 30+ torrent sources, inspect torrent quality, discover popular content, find streaming providers, and manage your media collection — all from your terminal.
Built-in torrent engine, debrid (Real-Debrid / AllDebrid), and NZB support. Stream to mpv/vlc, transcode on the fly with hardware acceleration, and manage your library — one binary or a headless daemon with WireGuard split-tunnel and Cloudflare Funnel remote access.
<!-- GIF demo placeholder -->
<!-- ![unarr Demo](docs/demo.gif) -->
@ -171,6 +171,9 @@ unarr start
| `unarr status` | Show daemon status and active downloads |
| `unarr daemon install` | Install as system service (systemd/launchd) |
| `unarr daemon uninstall` | Remove the system service |
| `unarr vpn status` | Show managed-VPN config and live tunnel state |
| `unarr vpn enable` | Turn the managed VPN on |
| `unarr vpn disable` | Turn the managed VPN off |
### System & Diagnostics
@ -280,6 +283,53 @@ The daemon connects via WebSocket for instant task delivery, with automatic HTTP
- Linux: `~/.config/systemd/user/unarr.service` (systemd)
- macOS: `~/Library/LaunchAgents/com.torrentclaw.unarr.plist` (launchd)
## VPN
unarr can route your **downloads** through a managed WireGuard VPN, so peers and
trackers see the VPN server's IP instead of yours. It runs entirely in userspace
(wireguard-go + a gVisor netstack) — **no root, no `wg-quick`, no changes to your
OS routing table**.
Requires a **PRO+ plan with the VPN add-on**. Set it up at
[torrentclaw.com/vpn](https://torrentclaw.com/vpn).
```bash
# Turn it on (writes [downloads.vpn] enabled = true to your config)
unarr vpn enable
# Restart the daemon so it brings the tunnel up at startup
unarr daemon restart # or: unarr start (if not installed as a service)
# Check it's working — shows the exit server when the tunnel is up
unarr vpn status
# Verify your account is provisioned (queries the API)
unarr vpn status --check
# Turn it off again
unarr vpn disable
```
**Split-tunnel — read this:** only the torrent client's traffic goes through the
VPN. Your browser, `curl`, and every other app keep using your **real IP** — that
is by design. To check the VPN is working, look at `unarr vpn status` (or the
peer/announce IP), **not** your browser's "what's my IP". To protect your other
devices (phone, laptop), use the **OpenVPN credentials** from your profile — those
support ~10 concurrent devices and do **not** share the agent's WireGuard slot.
**When does it fetch the config?** Once, at daemon startup. There's no periodic
refresh — after changing your exit server in the web panel or re-provisioning,
restart the daemon to pick it up. If the fetch fails the daemon logs a `[vpn]`
line and downloads in the clear (never refuses to run).
**Self-hosted / personal VPN:** instead of the managed config, point unarr at a
local WireGuard `.conf`:
```toml
[downloads.vpn]
config_file = "/path/to/wg.conf" # takes precedence over `enabled`
```
## Diagnostics
```bash
@ -293,6 +343,58 @@ unarr self-update --force # reinstall even if up to date
`unarr doctor` checks: config file, API key, server connectivity (with latency), agent registration, download directory, disk space, and version.
### Updating unarr
unarr supports three update paths. Pick whichever fits your workflow.
**1. Manual self-update (always available).**
```bash
unarr self-update # interactive update to latest
unarr self-update --force # reinstall same version
unarr self-update --allow-unsigned # accept releases without checksum signature
```
The CLI downloads the new release archive over HTTPS (from
`torrentclaw.com/releases/download/v<ver>/`), verifies SHA-256, swaps the
binary in place (`.backup` kept next to it), and restarts the systemd
user unit if the daemon is running.
**2. Auto-apply on server signal (default, since 0.9.6).**
When you press **"Force update now"** on the web (Settings → Agent → Force
update), the server sets a flag your daemon polls every sync (~3 s). On
the next sync the daemon downloads the new binary, replaces itself, and
exits — `systemd Restart=always` respawns on the new version. No SSH, no
terminal access required. Works headless on NAS / Docker.
The button shows an amber warning if your agent is below 0.9.6 (older
daemons see the signal but only log "run unarr update" — the operator
must run the command manually that one time).
**Opt out of auto-apply.** Some users prefer reviewing CHANGELOG before
applying. Disable in `config.toml`:
```toml
[daemon]
auto_upgrade = false
```
With `auto_upgrade = false`, pressing the web button still flags your
agent (so the daemon logs the new version on next sync), but the daemon
will not download / replace anything — you run `unarr self-update` when
you're ready.
**3. Docker auto-restart with a new tag.**
```bash
docker pull torrentclaw/unarr:latest
docker compose up -d
```
Tags published: `latest`, `0.9`, `0.9.7`, ... — pin to a minor (`0.9`)
for opt-in patch updates without surprises.
## Clean
Remove temporary files, logs, resume data, and other artifacts generated by unarr. Shows what will be removed and asks for confirmation before deleting.
@ -374,6 +476,7 @@ tv_shows_dir = "~/Media/TV Shows"
[daemon]
poll_interval = "30s"
heartbeat_interval = "30s"
auto_upgrade = true # apply server-flagged upgrades in-place (since 0.9.6)
[notifications]
enabled = true
@ -384,24 +487,12 @@ country = "US"
### Streaming reference
The in-browser player on torrentclaw.com streams from the daemon over WebRTC
(low-latency P2P) or HLS (HTTP fragments + ffmpeg transcode for codecs the
browser can't decode natively). Both are enabled by default — a fresh install
"just works" without editing the TOML. Disable surgically only if you have a
reason.
The in-browser player on torrentclaw.com streams from the daemon over HLS
(HTTP fragments + ffmpeg transcode for codecs the browser can't decode
natively). Enabled by default — a fresh install "just works" without editing
the TOML.
```toml
[downloads.webrtc]
enabled = true # master switch
trackers = ["wss://tracker.torrentclaw.com"] # signaling trackers
stun_servers = [ # NAT traversal
"stun:stun.l.google.com:19302",
"stun:stun1.l.google.com:19302",
]
turn_servers = [] # optional TURN relays
turn_user = ""
turn_pass = ""
[downloads.transcode]
enabled = true # master switch
hw_accel = "auto" # auto | none | nvenc | qsv | vaapi | videotoolbox
@ -412,16 +503,6 @@ max_height = 0 # 0 = no cap; e.g. 720 forces 720p max
max_concurrent = 2 # max simultaneous ffmpeg processes
```
#### `[downloads.webrtc]`
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `enabled` | bool | `true` | Browser↔daemon WebRTC peer for the in-browser P2P player. Disable to skip WebRTC tracker signalling (saves ~5MB RAM, blocks WebRTC streaming — HLS still works). |
| `trackers` | `[]string` | `["wss://tracker.torrentclaw.com"]` | Signaling trackers for peer discovery. |
| `stun_servers` | `[]string` | Google public STUN ×2 | ICE candidate gathering. |
| `turn_servers` | `[]string` | `[]` | Optional TURN relays for symmetric-NAT users. |
| `turn_user` / `turn_pass` | string | `""` | Credentials for authed TURN servers. Applied to all `turn_servers`. |
#### `[downloads.transcode]`
| Key | Type | Default | Notes |
@ -438,6 +519,108 @@ If `transcode.enabled = true` but `ffmpeg` / `ffprobe` aren't on PATH, the
daemon logs a warning at startup and HLS sessions are rejected at runtime
with a clear error — install ffmpeg or set `enabled = false`.
#### `[downloads.hls_cache]` — persistent HLS segment cache
```toml
[downloads.hls_cache]
enabled = true # on by default
size_gb = 5 # disk budget; LRU eviction once exceeded
dir = "" # custom path; empty = ~/.cache/unarr/hls-cache
```
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `enabled` | bool | `true` | Persists finished HLS encodes per `(source, quality, audio_index)`. A second play of the same file at the same quality reuses the segments — no ffmpeg, near-zero CPU, instant playback. Set to `false` to delete segments on session close (original behavior). |
| `size_gb` | int | `5` | Cache budget in gigabytes. When exceeded the LRU sweeper evicts the least-recently-used cached encodes hourly. Minimum 1 GB (smaller values are clamped up). |
| `dir` | string | `""` | Custom storage path. Empty defaults to `~/.cache/unarr/hls-cache` (Linux/macOS) or the user cache dir (Windows). |
**What it does.** First play encodes normally (ffmpeg writes segments).
On session close, if every segment is on disk and ffmpeg exited cleanly,
the directory is sealed with a `.complete` marker and kept. Next time the
same source + quality combo is requested, the daemon serves segments
straight from disk — no transcode, no warm-up, no CPU cost.
**Why per (source, quality, audio).** Renaming the file or switching
quality invalidates the entry: the segments are tied to the exact source
bytes and the exact ffmpeg parameters. Re-encoding generates a new key.
**Eviction.** A background goroutine wakes every hour. If total cache size
exceeds `size_gb`, it deletes the oldest entries (by mtime) until under
budget. Active sessions are pinned — they never get evicted mid-play.
**Disable.** Either edit the TOML to set `enabled = false`, or remove the
cache directory manually (it'll be recreated as needed). Disabling does
not delete existing cached segments — drop `dir` (or `~/.cache/unarr/hls-cache`)
to reclaim the space.
#### `[downloads.vpn]`
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `enabled` | bool | `false` | Managed VPN: at startup the daemon fetches a WireGuard config from your account and split-tunnels torrent traffic through it. Needs a PRO+ plan with the VPN add-on. Toggle with `unarr vpn enable` / `disable`. |
| `config_file` | string | `""` | Self-hosted / personal VPN: path to a local WireGuard `.conf`. **Takes precedence over `enabled`** — when set, the daemon uses this file and never calls the API. |
See the [VPN](#vpn) section above for how it works (split-tunnel, no root) and
how to protect your other devices.
#### `[downloads.funnel]` — public HTTPS hostname for the daemon (CloudFlare Quick Tunnel)
```toml
[downloads.funnel]
enabled = false # off by default
```
| Key | Type | Default | Notes |
|-----|------|---------|-------|
| `enabled` | bool | `false` | Spawns `cloudflared tunnel --url http://localhost:<stream_port>` as a child process at daemon startup. Toggle with `unarr funnel on` / `off`. Requires `cloudflared` on PATH. |
**What it does.** Without a tunnel, the daemon is reachable on `localhost`,
your LAN, and (if installed) Tailscale. That covers the same-machine and
Tailscale-connected cases, but the **browser-based player on torrentclaw.com
fails on any other network** because HTTPS pages can't fetch HTTP resources
("mixed content"). Enabling the funnel gives the daemon a public
`https://<random>.trycloudflare.com` hostname so the web player picks it up
and playback works from anywhere — phone on cellular, friend's laptop on a
foreign Wi-Fi, anywhere. The Stremio addon already works cross-network
(native mpv/VLC players ignore CORS), so this is strictly a web-player fix.
**Privacy posture.** Bytes pass through CloudFlare's edge — TorrentClaw never
relays content (we don't see your traffic), CloudFlare does. Quick Tunnels
are **anonymous** (no CF account required); the registration is unauthenticated
and the hostname is a random label, but CF logs request metadata like any CDN
would. If you want zero third-party byte access, use Tailscale instead.
**Limitations (free Quick Tunnels).**
| Aspect | Limit |
|--------|-------|
| Session lifetime | ~6 hours, then the hostname rotates. cloudflared re-registers automatically; the web picks up the new URL on the next sync. In-flight HLS sessions break across the rotation (browser retries). |
| Bandwidth | No documented hard cap, but CF reserves the right to throttle. 1080p HLS (~6 Mbps) is fine; 4K HEVC at 25 Mbps may hit throttling. |
| Latency | +2080 ms vs direct LAN/Tailscale (extra hop browser → CF edge → tunnel). HLS player buffer absorbs it. |
| Concurrency | One tunnel serves N viewers. CF rate-limits ~200 req/s, plenty for HLS segments. |
| TOS | CloudFlare flags Quick Tunnels as "not for production traffic". They can decommission an abusive tunnel without notice. |
For heavy / high-throughput / persistent-URL use cases, switch to a CloudFlare
Named Tunnel (free, needs a CF account) or run your own reverse proxy — both
out of scope for the bundled command.
**Disable.** `unarr funnel off` flips `enabled` to `false` in the TOML and
prompts you to restart the daemon. You can also edit `config.toml` directly:
```toml
[downloads.funnel]
enabled = false
```
**Install cloudflared.**
- Linux: `apt install cloudflared` (after adding CF's apt repo) — see
<https://pkg.cloudflare.com>. Or pull the static binary from
<https://github.com/cloudflare/cloudflared/releases>.
- macOS: `brew install cloudflared`.
- Windows: `winget install --id Cloudflare.cloudflared`.
If `cloudflared` is not on PATH the daemon logs a warning at startup and
falls back to LAN/Tailscale-only reachability.
### Environment variables
Environment variables override config file values:

View file

@ -59,6 +59,50 @@ This project follows these security practices:
- **Non-root Docker** — Container runs as unprivileged user (UID 1000)
- **Dependency scanning** — Automated via Dependabot
## Container Image Vulnerability Scanning
The Docker image (`torrentclaw/unarr`) is scanned by Docker Scout on Docker Hub and
by a CVE gate in CI (see `.github/workflows/`). Two things matter when reading the
Docker Hub vulnerability count:
- **Scanner database differs.** Docker Hub (Scout) matches `package@version` against
NVD/GHSA. Trivy/Alpine `secdb` only lists CVEs Alpine has acknowledged and patched.
A high Scout count with a clean Trivy report is expected, not a contradiction.
- **The bulk comes from the bundled `ffmpeg` codec stack.** Alpine's `ffmpeg`
package pulls ~40 codec/parser libraries (`x264`, `x265`, `libvpx`, `aom`,
`dav1d`, `libtheora`, `libvorbis`, `libwebp`, `libbluray`, `libopenmpt`, …).
Each carries a long NVD history that Alpine does not backport. ffmpeg is a
**functional dependency** — the HLS transcode pipeline shells out to
`ffmpeg`/`ffprobe` to decode untrusted media and re-encode to H.264 + AAC.
### Accepted risk and policy
- **Fixable** CRITICAL/HIGH findings **block** a release (CI CVE gate, `only-fixed`).
- **Unfixed-upstream** codec CVEs are tracked but **accepted**: there is no patched
Alpine package to move to, and dropping codecs would break playback of common
formats. They are mitigated by the hardening below rather than eliminated.
- Images are **rebuilt and re-pushed weekly** (scheduled workflow) so any newly
*fixed* base/ffmpeg/Go patch lands between tagged releases.
### Mitigations (run the container hardened)
Crafted media (torrents are untrusted input) is the realistic attack vector against
ffmpeg's parsers. The shipped `docker-compose.yml` already applies:
- **Non-root** user (UID 1000), **read-only** root filesystem, writable `tmpfs` only.
- **Resource limits** (memory/CPU) to bound a runaway decode.
Recommended additions for exposed deployments:
```yaml
cap_drop: ["ALL"]
security_opt:
- no-new-privileges:true
```
If you do not need HLS transcoding, you can run with transcoding disabled to
avoid feeding untrusted media to ffmpeg at all.
## Disclosure Policy
We follow coordinated disclosure. We will credit reporters in the release notes unless they prefer to remain anonymous.

View file

@ -1,268 +0,0 @@
// wstracker-probe — connects to a WebSocket BitTorrent tracker and either
// (a) advertises a fake info_hash to verify announce signalling, or
// (b) seeds a real file via the WebTorrent protocol so a browser
// webtorrent.js client can fetch it for end-to-end verification.
//
// Modes:
//
// wstracker-probe -tracker wss://tracker.torrentclaw.com
// Announces a random info_hash; exits 0 on TrackerAnnounceSuccessful.
//
// wstracker-probe -tracker wss://… -seed /path/to/file.mp4
// Builds a single-file torrent in memory, seeds forever, prints the
// magnet (with the WSS tracker injected). Ctrl-C to stop.
//
// Useful for browser ↔ unarr e2e — point a webtorrent.js page at the
// printed magnet and the player should pull pieces via WebRTC data channel.
package main
import (
"context"
"crypto/rand"
"flag"
"fmt"
"log"
"net/url"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
alog "github.com/anacrolix/log"
"github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/bencode"
"github.com/anacrolix/torrent/metainfo"
"github.com/anacrolix/torrent/storage"
"github.com/pion/webrtc/v4"
)
func main() {
tracker := flag.String("tracker", "wss://tracker.torrentclaw.com", "WSS tracker URL to probe")
timeout := flag.Duration("timeout", 30*time.Second, "max wait for successful announce (ignored in -seed mode)")
seedPath := flag.String("seed", "", "path to a file to seed (single-file torrent). When set, runs forever instead of exiting on first announce.")
flag.Parse()
if *seedPath != "" {
runSeeder(*seedPath, *tracker)
return
}
runProbe(*tracker, *timeout)
}
// runProbe — single random-hash announce, exits on success/error/timeout.
func runProbe(trackerURL string, timeout time.Duration) {
tmp, err := os.MkdirTemp("", "wstracker-probe-*")
if err != nil {
log.Fatalf("temp dir: %v", err)
}
defer os.RemoveAll(tmp)
cfg := baseClientConfig(tmp)
annSuccess := make(chan struct{}, 1)
annError := make(chan error, 1)
cfg.Callbacks.StatusUpdated = append(
cfg.Callbacks.StatusUpdated,
func(e torrent.StatusUpdatedEvent) {
switch e.Event { //nolint:exhaustive // peer events are noise for tracker probe
case torrent.TrackerConnected:
if e.Error != nil {
fmt.Printf("[probe] tracker connect FAILED: %v\n", e.Error)
} else {
fmt.Printf("[probe] tracker connected: %s\n", e.Url)
}
case torrent.TrackerAnnounceSuccessful:
fmt.Printf("[probe] tracker announce OK: %s ih=%s\n", e.Url, e.InfoHash)
select {
case annSuccess <- struct{}{}:
default:
}
case torrent.TrackerAnnounceError:
fmt.Printf("[probe] tracker announce ERROR: %s ih=%s err=%v\n", e.Url, e.InfoHash, e.Error)
select {
case annError <- e.Error:
default:
}
case torrent.TrackerDisconnected:
fmt.Printf("[probe] tracker disconnected: %s err=%v\n", e.Url, e.Error)
}
},
)
client, err := torrent.NewClient(cfg)
if err != nil {
log.Fatalf("create torrent client: %v", err)
}
defer client.Close()
var ih [20]byte
if _, err := rand.Read(ih[:]); err != nil {
log.Fatalf("random info_hash: %v", err)
}
magnet := fmt.Sprintf("magnet:?xt=urn:btih:%x&tr=%s", ih, trackerURL)
fmt.Printf("[probe] tracker=%s info_hash=%x timeout=%s\n", trackerURL, ih, timeout)
t, err := client.AddMagnet(magnet)
if err != nil {
log.Fatalf("add magnet: %v", err)
}
defer t.Drop()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
select {
case <-annSuccess:
fmt.Println("[probe] OK — tracker announce succeeded")
os.Exit(0)
case err := <-annError:
fmt.Printf("[probe] FAIL — tracker announce error: %v\n", err)
os.Exit(1)
case <-ctx.Done():
fmt.Printf("[probe] FAIL — timeout after %s\n", timeout)
os.Exit(2)
}
}
// runSeeder — builds a single-file torrent for the given path, adds it to
// a WebTorrent-enabled client, and seeds until SIGINT/SIGTERM.
func runSeeder(filePath, trackerURL string) {
abs, err := filepath.Abs(filePath)
if err != nil {
log.Fatalf("resolve seed path: %v", err)
}
st, err := os.Stat(abs)
if err != nil {
log.Fatalf("stat seed file: %v", err)
}
if st.IsDir() {
log.Fatalf("-seed currently supports a single file, not a directory: %s", abs)
}
dataDir := filepath.Dir(abs)
// Build single-file torrent metadata.
info := metainfo.Info{
PieceLength: chooseSeedPieceLength(st.Size()),
Name: filepath.Base(abs),
}
if err := info.BuildFromFilePath(abs); err != nil {
log.Fatalf("build info from file: %v", err)
}
infoBytes, err := bencode.Marshal(info)
if err != nil {
log.Fatalf("marshal info: %v", err)
}
mi := &metainfo.MetaInfo{
InfoBytes: infoBytes,
AnnounceList: metainfo.AnnounceList{{trackerURL}},
CreatedBy: "wstracker-probe",
}
ih := mi.HashInfoBytes()
cfg := baseClientConfig(dataDir)
cfg.Seed = true
cfg.Callbacks.StatusUpdated = append(
cfg.Callbacks.StatusUpdated,
func(e torrent.StatusUpdatedEvent) {
switch e.Event { //nolint:exhaustive
case torrent.TrackerConnected:
if e.Error != nil {
fmt.Printf("[seed] tracker connect FAILED: %v\n", e.Error)
} else {
fmt.Printf("[seed] tracker connected: %s\n", e.Url)
}
case torrent.TrackerAnnounceSuccessful:
fmt.Printf("[seed] tracker announce OK: %s ih=%s\n", e.Url, e.InfoHash)
case torrent.TrackerAnnounceError:
fmt.Printf("[seed] tracker announce ERROR: %s err=%v\n", e.Url, e.Error)
case torrent.TrackerDisconnected:
fmt.Printf("[seed] tracker disconnected: %s err=%v\n", e.Url, e.Error)
}
},
)
client, err := torrent.NewClient(cfg)
if err != nil {
log.Fatalf("create torrent client: %v", err)
}
defer client.Close()
t, err := client.AddTorrent(mi)
if err != nil {
log.Fatalf("add torrent: %v", err)
}
t.DownloadAll()
dn := url.QueryEscape(info.Name)
enc := url.QueryEscape(trackerURL)
magnet := fmt.Sprintf("magnet:?xt=urn:btih:%s&dn=%s&tr=%s", ih.HexString(), dn, enc)
fmt.Printf("[seed] file=%s size=%d bytes piece_length=%d\n", abs, st.Size(), info.PieceLength)
fmt.Printf("[seed] info_hash=%s\n", ih.HexString())
fmt.Printf("[seed] magnet=%s\n", magnet)
fmt.Println("[seed] seeding via WebRTC. Ctrl-C to stop.")
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
statTicker := time.NewTicker(5 * time.Second)
defer statTicker.Stop()
for {
select {
case <-statTicker.C:
s := t.Stats()
fmt.Printf("[seed] peers=%d uploaded=%d bytes seeders=%d leechers=%d\n",
s.ActivePeers, s.BytesWrittenData.Int64(),
s.ConnectedSeeders, s.ActivePeers-s.ConnectedSeeders)
case <-stop:
fmt.Println("[seed] stopping")
return
}
}
}
// baseClientConfig — shared anacrolix client config for both modes.
// WebTorrent is the only transport enabled; TCP/uTP/DHT/IPv6 are disabled
// to keep the moving parts to the minimum required for a WSS-only test.
func baseClientConfig(dataDir string) *torrent.ClientConfig {
cfg := torrent.NewDefaultClientConfig()
cfg.DataDir = dataDir
cfg.DefaultStorage = storage.NewMMap(dataDir)
cfg.NoUpload = false
cfg.DisableTCP = true
cfg.DisableUTP = true
cfg.DisableIPv6 = true
cfg.NoDHT = true
cfg.NoDefaultPortForwarding = true
cfg.ListenPort = 0
cfg.Logger = alog.Default.FilterLevel(alog.Critical)
cfg.DisableWebtorrent = false
cfg.ICEServerList = []webrtc.ICEServer{
{URLs: []string{"stun:stun.l.google.com:19302"}},
{URLs: []string{"stun:stun1.l.google.com:19302"}},
}
return cfg
}
// chooseSeedPieceLength picks a sane piece size for a given file size.
// Mirrors the libtorrent / qBittorrent ladder so the resulting torrent
// is interoperable with mainstream clients.
func chooseSeedPieceLength(size int64) int64 {
switch {
case size < 4*1024*1024: // < 4 MiB
return 16 * 1024 // 16 KiB
case size < 64*1024*1024: // < 64 MiB
return 64 * 1024 // 64 KiB
case size < 512*1024*1024: // < 512 MiB
return 256 * 1024 // 256 KiB
case size < 4*1024*1024*1024: // < 4 GiB
return 1024 * 1024 // 1 MiB
default:
return 4 * 1024 * 1024 // 4 MiB
}
}

11
go.mod
View file

@ -13,11 +13,11 @@ require (
github.com/google/uuid v1.6.0
github.com/huin/goupnp v1.3.0
github.com/olekukonko/tablewriter v1.1.4
github.com/pion/webrtc/v4 v4.2.11
github.com/spf13/cobra v1.10.2
github.com/torrentclaw/go-client v0.2.0
golang.org/x/term v0.43.0
golang.org/x/time v0.15.0
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
)
require (
@ -106,6 +106,7 @@ require (
github.com/pion/stun/v3 v3.1.1 // indirect
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/pion/turn/v4 v4.1.4 // indirect
github.com/pion/webrtc/v4 v4.2.11 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/protolambda/ctxlock v0.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
@ -121,12 +122,14 @@ require (
go.opentelemetry.io/otel v1.42.0 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/otel/trace v1.42.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c // indirect
lukechampine.com/blake3 v1.4.1 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect

26
go.sum
View file

@ -473,8 +473,8 @@ golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnf
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20220428152302-39d4317da171/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
@ -485,8 +485,8 @@ golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTk
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/mod v0.6.0-dev.0.20211013180041-c96bc1413d57/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -500,8 +500,8 @@ golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -542,8 +542,8 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@ -554,12 +554,16 @@ golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.8-0.20211029000441-d6a9af8af023/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
google.golang.org/api v0.3.1/go.mod h1:6wY9I6uQWHQ8EM57III9mq/AjF+i8G65rmVagqKMtkk=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@ -587,6 +591,8 @@ gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI=
gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=

View file

@ -12,8 +12,13 @@ import (
)
// Client communicates with the /api/internal/agent/* endpoints.
//
// The client owns a MirrorPool: when a request fails with a transient
// network error (DNS, refused, timeout, 5xx) it rotates to the next mirror
// and retries up to `len(mirrors)-1` times so a single agent run survives
// a primary-domain takedown without user intervention.
type Client struct {
baseURL string
pool *MirrorPool
apiKey string
httpClient *http.Client
// wakeClient has no built-in timeout — used exclusively for the long-poll
@ -25,11 +30,20 @@ type Client struct {
userAgent string
}
// NewClient creates an agent API client.
// NewClient creates an agent API client targeting a single base URL.
// Equivalent to NewClientWithMirrors(baseURL, nil, ...) — kept for callers
// that don't yet care about mirror failover.
func NewClient(baseURL, apiKey, userAgent string) *Client {
return NewClientWithMirrors(baseURL, nil, apiKey, userAgent)
}
// NewClientWithMirrors creates an agent API client that can fail over from
// the primary base URL to any of the extras when the primary is unreachable.
// The order of `extras` matters: they're tried left-to-right after a failure.
func NewClientWithMirrors(baseURL string, extras []string, apiKey, userAgent string) *Client {
return &Client{
baseURL: baseURL,
apiKey: apiKey,
pool: NewMirrorPool(baseURL, extras),
apiKey: apiKey,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
@ -44,6 +58,18 @@ func NewClient(baseURL, apiKey, userAgent string) *Client {
}
}
// MirrorPool exposes the underlying pool so callers (e.g. the `unarr mirrors`
// subcommand) can swap the list at runtime after fetching /api/v1/mirrors.
func (c *Client) MirrorPool() *MirrorPool {
return c.pool
}
// baseURL returns the currently-active mirror. Routed through this helper so
// future changes (e.g. per-endpoint mirror affinity) only need one edit.
func (c *Client) baseURL() string {
return c.pool.Current()
}
// Register registers the CLI agent with the server and returns user info + features.
func (c *Client) Register(ctx context.Context, req RegisterRequest) (*RegisterResponse, error) {
var resp RegisterResponse
@ -65,6 +91,45 @@ func (c *Client) Deregister(ctx context.Context, agentID string) error {
return nil
}
// ReportUpgradeResult tells the server the outcome of a previously requested
// upgrade so the server can clear `upgrade_requested`. Without this call the
// flag stays sticky and the daemon would re-trigger applyAutoUpgrade on every
// sync after upgrade — even for "already on target version" no-ops.
func (c *Client) ReportUpgradeResult(ctx context.Context, agentID string, success bool, version, errMsg string) error {
req := struct {
AgentID string `json:"agentId"`
Success bool `json:"success"`
Version string `json:"version,omitempty"`
Error string `json:"error,omitempty"`
}{AgentID: agentID, Success: success, Version: version, Error: errMsg}
var resp StatusResponse
if err := c.doPost(ctx, "/api/internal/agent/upgrade-result", req, &resp); err != nil {
return fmt.Errorf("report upgrade result: %w", err)
}
return nil
}
// MarkSessionReady signals the server that the first HLS segment + init.mp4
// landed on disk for the given session. The web side flips
// streaming_session.ready_at = NOW(), which its SSE endpoint emits to
// subscribed players so the "Preparando…" UI ends without polling HEAD
// on /hls/<id>/master.m3u8.
//
// Best-effort: the server is the source of truth for session state and
// will reach the same conclusion via HEAD probes anyway if this call
// fails. We log the error in the caller but don't retry — by the time
// a retry would land the user is likely already playing.
func (c *Client) MarkSessionReady(ctx context.Context, sessionID string) error {
req := struct {
SessionID string `json:"sessionId"`
}{SessionID: sessionID}
var resp StatusResponse
if err := c.doPost(ctx, "/api/internal/agent/session-ready", req, &resp); err != nil {
return fmt.Errorf("mark session ready: %w", err)
}
return nil
}
// ReportStatus reports download progress. Returns server-side flags the CLI must act on.
func (c *Client) ReportStatus(ctx context.Context, update StatusUpdate) (*StatusResponse, error) {
var resp StatusResponse
@ -109,30 +174,35 @@ func (c *Client) SearchNzbs(ctx context.Context, params NzbSearchParams) (*NzbSe
// DownloadNzb downloads the NZB file for the given nzbId.
// Returns the raw NZB XML bytes.
func (c *Client) DownloadNzb(ctx context.Context, nzbID string) ([]byte, error) {
url := fmt.Sprintf("/api/internal/agent/nzb-download?nzbId=%s", nzbID)
path := fmt.Sprintf("/api/internal/agent/nzb-download?nzbId=%s", nzbID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+url, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
c.setHeaders(req)
var out []byte
err := c.withMirrorFailover(func(base string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+path, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
c.setHeaders(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
return nil, fmt.Errorf("nzb download error %d: %s", resp.StatusCode, string(body))
}
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
return &HTTPError{StatusCode: resp.StatusCode, Message: string(body)}
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 100<<20)) // 100MB limit
if err != nil {
return nil, fmt.Errorf("read nzb: %w", err)
}
return data, nil
data, err := io.ReadAll(io.LimitReader(resp.Body, 100<<20)) // 100MB limit
if err != nil {
return fmt.Errorf("read nzb: %w", err)
}
out = data
return nil
})
return out, err
}
// GetUsenetCredentials fetches NNTP connection credentials.
@ -193,31 +263,41 @@ func (c *Client) ReportWatchProgress(ctx context.Context, update WatchProgressUp
// WaitForWake blocks until the server sends a wake signal, the long-poll
// timeout elapses, or ctx is cancelled. Returns true when a wake signal
// was received (caller should sync immediately), false on timeout/cancel.
//
// Wake is a long-poll on a single mirror — failover here would just drop
// the connection and try again immediately, which the server already
// handles with a fresh wait loop. We only retry against the next mirror
// when the current one is definitively unreachable (DNS / refused / TLS).
func (c *Client) WaitForWake(ctx context.Context) (bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/internal/agent/wake", nil)
if err != nil {
return false, fmt.Errorf("create wake request: %w", err)
}
c.setHeaders(req)
var wake bool
err := c.withMirrorFailover(func(base string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/api/internal/agent/wake", nil)
if err != nil {
return fmt.Errorf("create wake request: %w", err)
}
c.setHeaders(req)
resp, err := c.wakeClient.Do(req)
if err != nil {
return false, fmt.Errorf("wake request failed: %w", err)
}
defer resp.Body.Close()
resp, err := c.wakeClient.Do(req)
if err != nil {
return fmt.Errorf("wake request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
return false, &HTTPError{StatusCode: resp.StatusCode, Message: string(body)}
}
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<10))
return &HTTPError{StatusCode: resp.StatusCode, Message: string(body)}
}
var result struct {
Wake bool `json:"wake"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return false, fmt.Errorf("decode wake response: %w", err)
}
return result.Wake, nil
var result struct {
Wake bool `json:"wake"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("decode wake response: %w", err)
}
wake = result.Wake
return nil
})
return wake, err
}
// doPost sends a JSON POST request using the default httpClient and decodes the response.
@ -227,45 +307,89 @@ func (c *Client) doPost(ctx context.Context, path string, body any, dst any) err
// doPostWith sends a JSON POST request using the provided HTTP client and decodes the response.
// Use this to override the default timeout for specific operations (e.g. librarySyncClient).
// Wrapped in withMirrorFailover so a transient connection failure on the
// active mirror retries against the next one.
func (c *Client) doPostWith(ctx context.Context, hc *http.Client, path string, body any, dst any) error {
jsonBody, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(jsonBody))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
return c.withMirrorFailover(func(base string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+path, bytes.NewReader(jsonBody))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
c.setHeaders(req)
req.Header.Set("Content-Type", "application/json")
c.setHeaders(req)
req.Header.Set("Content-Type", "application/json")
resp, err := hc.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
resp, err := hc.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
return c.handleResponse(resp, dst)
return c.handleResponse(resp, dst)
})
}
// doGet sends a GET request and decodes the response.
func (c *Client) doGet(ctx context.Context, path string, dst any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
return c.withMirrorFailover(func(base string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+path, nil)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
c.setHeaders(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
return c.handleResponse(resp, dst)
})
}
// withMirrorFailover runs `fn` against the current mirror; on a transient
// error it rotates the pool and retries up to `len(mirrors)-1` times.
//
// The active mirror is updated on rotation so subsequent unrelated calls
// stick to the working host until that host fails too — this avoids
// hammering a known-bad primary on every request, while still trying it
// again next time the agent reloads (no permanent demotion).
func (c *Client) withMirrorFailover(fn func(base string) error) error {
attempts := c.pool.Len()
if attempts < 1 {
attempts = 1
}
c.setHeaders(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
var lastErr error
for i := 0; i < attempts; i++ {
base := c.baseURL()
err := fn(base)
if err == nil {
return nil
}
lastErr = err
if !IsTransient(err) {
return err
}
// Last attempt: don't bother rotating, just surface the error.
if i == attempts-1 {
break
}
next, rotated := c.pool.Rotate()
if !rotated {
break
}
_ = next // mirror rotation logging is left to higher layers (cmd/) so the
// pool stays log-free for tests.
}
defer resp.Body.Close()
return c.handleResponse(resp, dst)
return lastErr
}
func (c *Client) setHeaders(req *http.Request) {

View file

@ -498,8 +498,8 @@ func TestClient_SlowServer_Timeout(t *testing.T) {
// Crear cliente con timeout muy corto
c := &Client{
baseURL: srv.URL,
apiKey: "test-key",
pool: NewMirrorPool(srv.URL, nil),
apiKey: "test-key",
httpClient: &http.Client{
Timeout: 50 * time.Millisecond,
},

View file

@ -6,10 +6,13 @@ import (
"fmt"
"log"
"os"
"os/exec"
"runtime"
"strings"
"sync/atomic"
"time"
"github.com/torrentclaw/unarr/internal/upgrade"
)
// DaemonConfig holds daemon runtime settings.
@ -25,6 +28,15 @@ type DaemonConfig struct {
ScanPaths []string // configured scan paths for file deletion validation
HWAccel string // detected encoder backend ("nvenc"/"qsv"/"vaapi"/"videotoolbox"/"none")
MaxTranscodeHeight int // resolution cap the agent can transcode comfortably (px)
// Diagnostic data populated by engine.DetectHWAccelDiagnostic at daemon
// start. Surfaced in the web "Diagnose transcoder" modal — lets a user
// see which encoders the ffmpeg binary supports and which devices the
// host exposes without running `unarr probe-hwaccel`.
FFmpegVersion string // first line of `ffmpeg -version`
FFmpegPath string // resolved binary path
HWEncoders []string // HW-class encoder names found in `ffmpeg -encoders`
HWDevices []string // device files + driver bins detected at probe time
AutoUpgrade bool // honor server-flagged upgrades by downloading + restarting (default: true)
}
// Daemon manages agent registration and the sync loop.
@ -37,7 +49,7 @@ type Daemon struct {
// Callbacks — set by cmd/daemon.go before calling Run.
OnTasksClaimed func(tasks []Task)
OnStreamRequested func(req StreamRequest)
OnWebRTCSession func(sess WebRTCSession)
OnStreamSession func(sess StreamSession)
OnControlAction func(action, taskID string, deleteFiles bool)
GetActiveCount func() int // returns number of active downloads (wired from manager)
@ -48,6 +60,16 @@ type Daemon struct {
State DaemonState
lastNotifiedVersion string
// Managed-VPN split-tunnel state, set by cmd/daemon.go before Run and folded
// into DaemonState on every write so external tools (`unarr vpn status`) see it.
vpnActive bool
vpnMode string
vpnServer string
// CloudFlare Quick Tunnel public URL; folded into DaemonState + heartbeat
// so the web can prefer it over Tailscale/LAN for in-browser playback.
funnelURL string
// Watching tracks whether a user is viewing download progress in the web UI.
Watching atomic.Bool
@ -70,6 +92,23 @@ func NewDaemon(cfg DaemonConfig, client *Client) *Daemon {
// SyncClient returns the sync client for external wiring.
func (d *Daemon) SyncClient() *SyncClient { return d.sync }
// SetVPNState records the managed-VPN split-tunnel state so it's reflected in the
// daemon state file (read by `unarr vpn status`). Call before Run.
func (d *Daemon) SetVPNState(active bool, mode, server string) {
d.vpnActive = active
d.vpnMode = mode
d.vpnServer = server
}
// SetFunnelURL records the CloudFlare Quick Tunnel hostname so it's reflected
// in the daemon state file (read by `unarr funnel status`) and in heartbeat
// requests (so the web prefers it over Tailscale/LAN). Pass "" to clear.
func (d *Daemon) SetFunnelURL(url string) {
d.funnelURL = url
d.State.FunnelURL = url
WriteState(&d.State)
}
// UpdateStreamPort updates the stream port reported in sync requests.
func (d *Daemon) UpdateStreamPort(port int) {
d.cfg.StreamPort = port
@ -91,6 +130,14 @@ func (d *Daemon) Register(ctx context.Context) error {
TailscaleIP: d.cfg.TailscaleIP,
HWAccel: d.cfg.HWAccel,
MaxTranscodeHeight: d.cfg.MaxTranscodeHeight,
FFmpegVersion: d.cfg.FFmpegVersion,
FFmpegPath: d.cfg.FFmpegPath,
HWEncoders: d.cfg.HWEncoders,
HWDevices: d.cfg.HWDevices,
VPNActive: d.vpnActive,
VPNMode: d.vpnMode,
VPNServer: d.vpnServer,
FunnelURL: d.funnelURL,
}
if free, total, err := DiskInfo(d.cfg.DownloadDir); err == nil {
req.DiskFreeBytes = free
@ -141,6 +188,10 @@ func (d *Daemon) Register(ctx context.Context) error {
PID: os.Getpid(),
StartedAt: now,
MethodStats: make(map[string]int),
VPNActive: d.vpnActive,
VPNMode: d.vpnMode,
VPNServer: d.vpnServer,
FunnelURL: d.funnelURL,
}
WriteState(&d.State)
@ -158,6 +209,21 @@ func (d *Daemon) Run(ctx context.Context) error {
log.Printf("Agent registered: %s (%s) [%s]", d.User.Name, d.User.Email, d.User.Plan)
log.Printf("Features: torrent=%v debrid=%v usenet=%v", d.Features.Torrent, d.Features.Debrid, d.Features.Usenet)
// Usenet needs par2 (segment repair) + an extractor (RAR/7z) on the host.
// Without par2, a single bad segment corrupts the file silently; without
// an extractor, RAR-packed downloads can't be unpacked. Warn loudly at
// startup so the operator installs them before the first download fails.
if d.Features.Usenet {
if _, err := exec.LookPath("par2"); err != nil {
log.Printf("[usenet] WARNING: par2 not found in PATH — corrupted segments cannot be repaired and extraction may fail. Install par2 (apt install par2 / brew install par2).")
}
_, unrarErr := exec.LookPath("unrar")
_, sevenZErr := exec.LookPath("7z")
if unrarErr != nil && sevenZErr != nil {
log.Printf("[usenet] WARNING: no archive extractor (unrar or 7z) found — RAR-packed downloads cannot be unpacked. Install unrar or 7z.")
}
}
// Wire sync callbacks
d.sync.OnNewTasks = func(tasks []Task) {
if d.OnTasksClaimed != nil {
@ -174,16 +240,22 @@ func (d *Daemon) Run(ctx context.Context) error {
d.OnStreamRequested(req)
}
}
d.sync.OnWebRTCSession = func(sess WebRTCSession) {
if d.OnWebRTCSession != nil {
d.OnWebRTCSession(sess)
d.sync.OnStreamSession = func(sess StreamSession) {
if d.OnStreamSession != nil {
d.OnStreamSession(sess)
}
}
d.sync.OnUpgrade = func(version string) {
if version != d.lastNotifiedVersion {
d.lastNotifiedVersion = version
log.Printf("New version available: %s (run `unarr self-update` to upgrade)", version)
if version == d.lastNotifiedVersion {
return
}
d.lastNotifiedVersion = version
if !d.cfg.AutoUpgrade {
log.Printf("[upgrade] new version available: %s — auto_upgrade=false, run `unarr update` to apply", version)
return
}
log.Printf("[upgrade] new version available: %s — applying auto-upgrade", version)
go d.applyAutoUpgrade(version)
}
d.sync.OnScan = func() {
log.Printf("Library scan requested by server")
@ -195,6 +267,12 @@ func (d *Daemon) Run(ctx context.Context) error {
d.sync.OnWatchingChange = func(watching bool) {
d.Watching.Store(watching)
}
d.sync.GetVPNState = func() (bool, string, string) {
return d.vpnActive, d.vpnMode, d.vpnServer
}
d.sync.GetFunnelURL = func() string {
return d.funnelURL
}
d.sync.OnSyncSuccess = func() {
d.State.LastHeartbeat = time.Now()
if d.GetActiveCount != nil {
@ -224,6 +302,67 @@ func (d *Daemon) Deregister() {
RemoveState()
}
// applyAutoUpgrade downloads the target version and exits so the service
// supervisor (systemd Restart=always on Linux) respawns on the new binary.
// Triggered by the server's upgrade signal — opt-in flag set by the user from
// the web UI; the daemon never auto-upgrades on a passive version bump.
//
// Reports the outcome to /api/internal/agent/upgrade-result so the server
// clears `upgrade_requested`. Without this report the flag stays sticky and
// the daemon would loop on every sync — including the no-op case where it's
// already on the target version.
func (d *Daemon) applyAutoUpgrade(targetVersion string) {
currentClean := strings.TrimPrefix(d.cfg.Version, "v")
targetClean := strings.TrimPrefix(targetVersion, "v")
// No-op: server signal arrived but we're already running the target. This
// happens when the daemon restarts after a previous auto-upgrade before
// reportUpgradeResult cleared the flag, or when the operator manually
// installed the same version off-band. Skip Execute (which would also
// no-op) AND skip os.Exit, but DO clear the flag — otherwise we loop.
if currentClean == targetClean {
log.Printf("[upgrade] already on v%s — clearing server flag", currentClean)
ctxR, cancelR := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelR()
if err := d.client.ReportUpgradeResult(ctxR, d.cfg.AgentID, true, currentClean, ""); err != nil {
log.Printf("[upgrade] report-result failed (will retry on next signal): %v", err)
}
return
}
upgrader := &upgrade.Upgrader{
CurrentVersion: currentClean,
OnProgress: func(msg string) {
log.Printf("[upgrade] %s", msg)
},
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
result := upgrader.Execute(ctx, targetVersion)
if !result.Success {
log.Printf("[upgrade] auto-upgrade failed: %v", result.Error)
errMsg := ""
if result.Error != nil {
errMsg = result.Error.Error()
}
ctxR, cancelR := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelR()
if err := d.client.ReportUpgradeResult(ctxR, d.cfg.AgentID, false, targetClean, errMsg); err != nil {
log.Printf("[upgrade] report-result failed: %v", err)
}
return
}
log.Printf("[upgrade] upgraded v%s → v%s; reporting result + exiting so service supervisor restarts on new binary",
result.OldVersion, result.NewVersion)
ctxR, cancelR := context.WithTimeout(context.Background(), 10*time.Second)
if err := d.client.ReportUpgradeResult(ctxR, d.cfg.AgentID, true, result.NewVersion, ""); err != nil {
log.Printf("[upgrade] report-result failed: %v", err)
}
cancelR()
time.Sleep(500 * time.Millisecond)
os.Exit(0)
}
// isTransientError returns true for errors worth retrying (429, 5xx, network).
func isTransientError(err error) bool {
if err == nil {

View file

@ -0,0 +1,62 @@
package agent
import (
"os"
"path/filepath"
"testing"
)
func TestDirSize(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "a.bin"), make([]byte, 100), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, "sub"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "sub", "b.bin"), make([]byte, 250), 0o644); err != nil {
t.Fatal(err)
}
got, err := DirSize(root)
if err != nil {
t.Fatalf("DirSize error: %v", err)
}
if got != 350 {
t.Errorf("DirSize = %d, want 350", got)
}
}
func TestDirSizeEmpty(t *testing.T) {
got, err := DirSize(t.TempDir())
if err != nil {
t.Fatalf("DirSize empty dir error: %v", err)
}
if got != 0 {
t.Errorf("DirSize empty = %d, want 0", got)
}
}
func TestDirSizeMissing(t *testing.T) {
// Walk skips unreadable entries — missing path returns 0 with no error.
got, err := DirSize("/nonexistent/path/zzz")
if err != nil {
t.Errorf("DirSize on missing path = err %v, want nil", err)
}
if got != 0 {
t.Errorf("DirSize on missing path = %d, want 0", got)
}
}
func TestDiskInfoCurrentDir(t *testing.T) {
free, total, err := DiskInfo(".")
if err != nil {
t.Fatalf("DiskInfo: %v", err)
}
if total <= 0 {
t.Errorf("total bytes should be > 0, got %d", total)
}
if free > total {
t.Errorf("free (%d) should not exceed total (%d)", free, total)
}
}

View file

@ -0,0 +1,232 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// MirrorEntry mirrors the shape of /api/v1/mirrors items on the server.
type MirrorEntry struct {
URL string `json:"url"`
Label string `json:"label"`
Kind string `json:"kind"` // "clearnet" | "tor"
Primary bool `json:"primary"`
}
// MirrorChannel is an out-of-band status channel (Telegram, status page, etc.)
type MirrorChannel struct {
URL string `json:"url"`
Label string `json:"label"`
}
// MirrorsResponse is the JSON document served by /api/v1/mirrors and
// /api/mirrors.
type MirrorsResponse struct {
Revision int `json:"revision"`
Mirrors []MirrorEntry `json:"mirrors"`
Tor *MirrorEntry `json:"tor"`
Channels []MirrorChannel `json:"channels"`
UpdatedAt string `json:"updatedAt"`
}
// DefaultStaticFallbackURLs lists off-domain JSON copies of the mirror list.
// Hard-coded here (not loaded from config) because the whole point is to
// have something to consult when config-driven URLs all fail.
//
// Hosted on IPFS (content-addressed, re-pinnable, no host can take it down
// permanently — same bytes re-pinned anywhere keep the same CID). Multiple
// public gateways are listed so a single gateway being blocked doesn't kill
// the fallback; the /ipfs/<CID>/ path is identical across all gateways.
//
// GitHub Pages was removed 2026-05-17: the whole torrentclaw org is
// shadow-banned (public repos 404 to anonymous users). Do NOT re-add any
// github.io URL. Keep this slice in sync with `STATIC_FALLBACKS` in
// `torrentclaw-web/src/lib/mirrors-config.ts` — when the IPFS CID changes
// (scripts/publish-mirrors-ipfs.sh), update both.
//
// Future hardening: sign mirrors.json with the same ed25519 release key
// (or a sibling) so a hijack of any single static host cannot serve a
// malicious mirror list. Today the only signal is "agreement between
// independent providers" via cross-checking, which we leave to the
// operator.
const mirrorsIPFSCID = "bafybeigwux74fek7uky7nct47z5eqwwnpylakfxppqqnzbuxdw7p3ikfdy"
var DefaultStaticFallbackURLs = []string{
"https://ipfs.io/ipfs/" + mirrorsIPFSCID + "/mirrors.json",
"https://dweb.link/ipfs/" + mirrorsIPFSCID + "/mirrors.json",
"https://gateway.pinata.cloud/ipfs/" + mirrorsIPFSCID + "/mirrors.json",
}
// FetchMirrorsWithFallback pulls the mirror list using FetchMirrors against
// `candidates` first; if every candidate fails, it falls back to the static
// JSON copies on off-domain hosts (GitHub Pages, Cloudflare Pages, …).
//
// This is the function `unarr mirrors update` should call when it wants the
// strongest "give me a working mirror list no matter what" guarantee.
func FetchMirrorsWithFallback(ctx context.Context, candidates []string, userAgent string) (*MirrorsResponse, error) {
resp, err := FetchMirrors(ctx, candidates, userAgent)
if err == nil {
return resp, nil
}
if len(DefaultStaticFallbackURLs) == 0 {
return nil, err
}
// Try the static JSON files directly. They follow the same wire shape so
// we can reuse the same parser — but the URLs already include the JSON
// suffix so we hit them with `fetchMirrorsJSON` instead of FetchMirrors
// (which appends /api/v1/mirrors).
staticResp, staticErr := fetchMirrorsJSON(ctx, DefaultStaticFallbackURLs, userAgent)
if staticErr == nil {
return staticResp, nil
}
return nil, fmt.Errorf("primary failed (%v) and static fallback failed (%v)", err, staticErr)
}
// fetchMirrorsJSON pulls a MirrorsResponse from already-fully-qualified URLs
// (e.g. https://ipfs.io/ipfs/<CID>/mirrors.json). Each candidate is tried
// in order; the first success wins.
func fetchMirrorsJSON(ctx context.Context, urls []string, userAgent string) (*MirrorsResponse, error) {
if len(urls) == 0 {
return nil, fmt.Errorf("no static fallback URLs configured")
}
hc := &http.Client{Timeout: 15 * time.Second}
var lastErr error
for _, url := range urls {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
lastErr = err
continue
}
if userAgent != "" {
req.Header.Set("User-Agent", userAgent)
}
req.Header.Set("Accept", "application/json")
resp, err := hc.Do(req)
if err != nil {
lastErr = err
continue
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
lastErr = readErr
continue
}
if resp.StatusCode >= 400 {
lastErr = fmt.Errorf("%s returned HTTP %d", url, resp.StatusCode)
continue
}
var out MirrorsResponse
if err := json.Unmarshal(body, &out); err != nil {
lastErr = fmt.Errorf("%s: invalid JSON: %w", url, err)
continue
}
if len(out.Mirrors) == 0 {
lastErr = fmt.Errorf("%s returned empty mirror list", url)
continue
}
return &out, nil
}
if lastErr == nil {
lastErr = fmt.Errorf("no reachable static fallback")
}
return nil, lastErr
}
// FetchMirrors pulls the latest mirror list from the server.
//
// The endpoint is intentionally public and unauthenticated: the whole point
// of mirror discovery is that it must work even when the user's API key
// is invalid, expired, or the auth path is unreachable. The function tries
// each candidate base URL in order so a takedown of the primary doesn't
// also kill mirror discovery.
func FetchMirrors(ctx context.Context, candidates []string, userAgent string) (*MirrorsResponse, error) {
if len(candidates) == 0 {
return nil, fmt.Errorf("no mirror discovery URLs configured")
}
hc := &http.Client{Timeout: 15 * time.Second}
var lastErr error
for _, base := range candidates {
if base == "" {
continue
}
url := base + "/api/v1/mirrors"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
lastErr = err
continue
}
if userAgent != "" {
req.Header.Set("User-Agent", userAgent)
}
req.Header.Set("Accept", "application/json")
resp, err := hc.Do(req)
if err != nil {
lastErr = err
continue
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
lastErr = readErr
continue
}
if resp.StatusCode >= 400 {
lastErr = fmt.Errorf("%s returned HTTP %d", base, resp.StatusCode)
continue
}
var out MirrorsResponse
if err := json.Unmarshal(body, &out); err != nil {
lastErr = fmt.Errorf("%s: invalid JSON: %w", base, err)
continue
}
if len(out.Mirrors) == 0 {
lastErr = fmt.Errorf("%s returned empty mirror list", base)
continue
}
return &out, nil
}
if lastErr == nil {
lastErr = fmt.Errorf("no reachable mirror discovery endpoint")
}
return nil, fmt.Errorf("fetch mirrors: %w", lastErr)
}
// ToConfig splits a MirrorsResponse into (primary, extras) suitable for
// rebuilding a MirrorPool or persisting back into config.toml.
//
// The "primary" returned here is whichever entry has primary=true. If none
// are flagged, the first one wins.
func (m *MirrorsResponse) ToConfig() (primary string, extras []string) {
if m == nil {
return "", nil
}
var picked *MirrorEntry
for i := range m.Mirrors {
if m.Mirrors[i].Primary {
picked = &m.Mirrors[i]
break
}
}
if picked == nil && len(m.Mirrors) > 0 {
picked = &m.Mirrors[0]
}
if picked != nil {
primary = picked.URL
}
for _, e := range m.Mirrors {
if e.URL == primary {
continue
}
extras = append(extras, e.URL)
}
return primary, extras
}

View file

@ -0,0 +1,172 @@
package agent
import (
"context"
"errors"
"net"
"net/http"
"net/url"
"strings"
"sync"
)
// MirrorPool holds the ordered list of API base URLs the client is willing to
// fall back to when the current mirror is unreachable. The first entry is
// always the "preferred" mirror configured by the user. Subsequent entries
// are alternate domains we can rotate to without changing any user-visible
// configuration — they exist so a long-lived agent survives a takedown of
// the primary host without needing a new release.
//
// The pool is concurrency-safe; rotation is a fast O(1) index bump under a
// mutex. The previously-active mirror is NEVER removed — it might just be
// temporarily unreachable from one network path.
type MirrorPool struct {
mu sync.RWMutex
mirrors []string
current int
}
// NewMirrorPool builds a pool from the provided base URLs. The primary URL
// is always first; "extras" are appended in order and de-duplicated. Empty
// strings are skipped. Trailing slashes are normalised so callers can concat
// `pool.Current() + "/api/..."` reliably.
func NewMirrorPool(primary string, extras []string) *MirrorPool {
seen := make(map[string]struct{})
var out []string
add := func(raw string) {
raw = strings.TrimRight(strings.TrimSpace(raw), "/")
if raw == "" {
return
}
if _, dup := seen[raw]; dup {
return
}
seen[raw] = struct{}{}
out = append(out, raw)
}
add(primary)
for _, e := range extras {
add(e)
}
if len(out) == 0 {
// Defensive: always return a pool with at least one entry so callers
// can call Current() without nil checks. The empty string would
// produce obvious errors immediately, which is preferable to a panic
// somewhere deep in net/http.
out = []string{""}
}
return &MirrorPool{mirrors: out}
}
// Current returns the active base URL.
func (p *MirrorPool) Current() string {
p.mu.RLock()
defer p.mu.RUnlock()
return p.mirrors[p.current]
}
// Mirrors returns a copy of the configured base URLs in priority order.
func (p *MirrorPool) Mirrors() []string {
p.mu.RLock()
defer p.mu.RUnlock()
out := make([]string, len(p.mirrors))
copy(out, p.mirrors)
return out
}
// Len reports how many mirrors are configured.
func (p *MirrorPool) Len() int {
p.mu.RLock()
defer p.mu.RUnlock()
return len(p.mirrors)
}
// Rotate moves the cursor to the next mirror in the pool, wrapping around.
// Returns the new current mirror and whether a rotation actually happened
// (a single-mirror pool returns false).
func (p *MirrorPool) Rotate() (string, bool) {
p.mu.Lock()
defer p.mu.Unlock()
if len(p.mirrors) <= 1 {
return p.mirrors[p.current], false
}
p.current = (p.current + 1) % len(p.mirrors)
return p.mirrors[p.current], true
}
// Replace swaps the entire mirror set, e.g. after `unarr mirrors update`
// downloaded a fresh list from /api/v1/mirrors. Resets the cursor to 0 so
// the newly-discovered primary is tried first.
func (p *MirrorPool) Replace(primary string, extras []string) {
fresh := NewMirrorPool(primary, extras)
p.mu.Lock()
defer p.mu.Unlock()
p.mirrors = fresh.mirrors
p.current = 0
}
// IsTransient reports whether an error is the kind we should retry against
// another mirror. The intent is conservative: rotate on connection-level
// failures (DNS, refused, TLS, timeouts, 5xx) but NOT on auth or validation
// errors that would just fail again somewhere else.
func IsTransient(err error) bool {
if err == nil {
return false
}
var httpErr *HTTPError
if errors.As(err, &httpErr) {
switch httpErr.StatusCode {
case http.StatusBadGateway,
http.StatusServiceUnavailable,
http.StatusGatewayTimeout,
http.StatusRequestTimeout:
return true
}
// 4xx (auth, rate limit, validation) won't get healthier on another mirror.
return false
}
if errors.Is(err, context.DeadlineExceeded) {
return true
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return true
}
var dnsErr *net.DNSError
if errors.As(err, &dnsErr) {
return true
}
var urlErr *url.Error
if errors.As(err, &urlErr) {
// `connection refused`, `EOF`, `tls: ...` end up as wrapped url.Errors.
msg := urlErr.Error()
if strings.Contains(msg, "connection refused") ||
strings.Contains(msg, "no such host") ||
strings.Contains(msg, "EOF") ||
strings.Contains(msg, "tls:") ||
strings.Contains(msg, "i/o timeout") ||
strings.Contains(msg, "network is unreachable") {
return true
}
}
// Bare strings as last resort — net.OpError messages are unstable across Go versions.
msg := err.Error()
if strings.Contains(msg, "connection refused") ||
strings.Contains(msg, "no such host") ||
strings.Contains(msg, "i/o timeout") ||
strings.Contains(msg, "network is unreachable") {
return true
}
return false
}

View file

@ -0,0 +1,22 @@
//go:build !windows
package agent
import (
"os"
"testing"
)
func TestIsProcessAliveSelf(t *testing.T) {
if !IsProcessAlive(os.Getpid()) {
t.Errorf("self PID should be alive")
}
}
func TestIsProcessAliveBogus(t *testing.T) {
// PID 0 is reserved (signal 0 to PID 0 broadcasts to the whole pgrp).
// Pick a very high PID unlikely to exist.
if IsProcessAlive(0x7FFFFFFE) {
t.Errorf("very high PID should not be alive")
}
}

View file

@ -1,237 +0,0 @@
package agent
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// SignalRole identifies who produced a signalling message. The opposite role
// receives it.
type SignalRole string
const (
SignalRoleBrowser SignalRole = "browser"
SignalRoleAgent SignalRole = "agent"
)
// SignalMessageType matches the server-side z.enum on
// /api/internal/stream/signal/[sessionId] route.
type SignalMessageType string
const (
SignalMsgOffer SignalMessageType = "offer"
SignalMsgAnswer SignalMessageType = "answer"
SignalMsgCandidate SignalMessageType = "candidate"
SignalMsgCandidateEnd SignalMessageType = "candidate-end"
SignalMsgBye SignalMessageType = "bye"
)
// SignalMessage mirrors the bus envelope on the web side.
type SignalMessage struct {
From SignalRole `json:"from"`
Type SignalMessageType `json:"type"`
Payload string `json:"payload"`
TS int64 `json:"ts"`
}
// PostSignal enqueues a signalling message produced by this agent. The
// browser receives it on its next SSE event push.
func (c *Client) PostSignal(ctx context.Context, sessionID string, msg SignalMessage) error {
body := map[string]any{
"from": string(SignalRoleAgent),
"type": string(msg.Type),
"payload": msg.Payload,
}
path := fmt.Sprintf("/api/internal/stream/signal/%s", sessionID)
return c.doPost(ctx, path, body, &struct {
OK bool `json:"ok"`
}{})
}
// SignalEventStream wraps an open SSE connection. Read messages from Events()
// until the channel closes (server timeout or context cancel). Always defer
// Close() to release the underlying response body.
type SignalEventStream struct {
resp *http.Response
cancel context.CancelFunc
events chan SignalMessage
errs chan error
done chan struct{}
}
// Events streams browser-produced messages addressed to the agent.
// The channel closes when the SSE connection ends; the caller should then
// call Close() and reopen if it wants to keep listening.
func (s *SignalEventStream) Events() <-chan SignalMessage { return s.events }
// Err returns the terminating error (if any) once Events() has closed.
func (s *SignalEventStream) Err() error {
select {
case err := <-s.errs:
return err
default:
return nil
}
}
// Close cancels the underlying HTTP request and waits for the reader goroutine
// to drain. Safe to call more than once.
func (s *SignalEventStream) Close() error {
if s.cancel != nil {
s.cancel()
}
if s.resp != nil {
s.resp.Body.Close()
}
<-s.done
return nil
}
// OpenSignalStream opens a long-lived SSE connection to the signal events
// endpoint. Caller MUST cancel ctx (or call Close()) to free resources.
//
// The server caps each response at ~25 s; OpenSignalStream surfaces the
// disconnect by closing the events channel. Caller should reopen until the
// session ends.
func (c *Client) OpenSignalStream(ctx context.Context, sessionID string) (*SignalEventStream, error) {
streamCtx, cancel := context.WithCancel(ctx)
url := fmt.Sprintf("%s/api/internal/stream/signal/%s/events", c.baseURL, sessionID)
req, err := http.NewRequestWithContext(streamCtx, http.MethodGet, url, nil)
if err != nil {
cancel()
return nil, fmt.Errorf("open signal stream: %w", err)
}
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Authorization", "Bearer "+c.apiKey)
req.Header.Set("User-Agent", c.userAgent)
req.Header.Set("Cache-Control", "no-cache")
// Use a per-call client with no timeout (SSE connections are long).
sseClient := &http.Client{}
resp, err := sseClient.Do(req)
if err != nil {
cancel()
return nil, fmt.Errorf("open signal stream: %w", err)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
resp.Body.Close()
cancel()
return nil, fmt.Errorf("open signal stream: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
stream := &SignalEventStream{
resp: resp,
cancel: cancel,
events: make(chan SignalMessage, 8),
errs: make(chan error, 1),
done: make(chan struct{}),
}
go stream.read()
return stream, nil
}
func (s *SignalEventStream) read() {
defer close(s.done)
defer close(s.events)
reader := bufio.NewReaderSize(s.resp.Body, 16*1024)
var dataBuf bytes.Buffer
var eventName string
for {
line, err := reader.ReadString('\n')
if err != nil {
if err != io.EOF {
select {
case s.errs <- err:
default:
}
}
return
}
line = strings.TrimRight(line, "\r\n")
if line == "" {
// End of an event — dispatch if we have data.
if dataBuf.Len() == 0 {
eventName = ""
continue
}
if eventName == "" || eventName == "signal" {
var msg SignalMessage
if err := json.Unmarshal(dataBuf.Bytes(), &msg); err == nil {
select {
case s.events <- msg:
case <-s.resp.Request.Context().Done():
return
}
}
}
dataBuf.Reset()
eventName = ""
continue
}
if strings.HasPrefix(line, ":") {
// SSE comment (heartbeat); ignore.
continue
}
if strings.HasPrefix(line, "event:") {
eventName = strings.TrimSpace(line[len("event:"):])
continue
}
if strings.HasPrefix(line, "data:") {
payload := strings.TrimSpace(line[len("data:"):])
if dataBuf.Len() > 0 {
dataBuf.WriteByte('\n')
}
dataBuf.WriteString(payload)
continue
}
// id:, retry:, anything else — ignore for now.
}
}
// SignalLoop runs an SSE consumer that reconnects automatically on disconnect.
// onMessage is called for every browser-produced message. Returns when ctx is
// cancelled. Reconnect backoff is fixed at 1 s — the server already paces
// reconnects with `retry: 1500` headers so churn is bounded.
func (c *Client) SignalLoop(ctx context.Context, sessionID string, onMessage func(SignalMessage)) error {
for ctx.Err() == nil {
stream, err := c.OpenSignalStream(ctx, sessionID)
if err != nil {
select {
case <-time.After(time.Second):
case <-ctx.Done():
return ctx.Err()
}
continue
}
for msg := range stream.Events() {
onMessage(msg)
}
streamErr := stream.Err()
stream.Close()
if ctx.Err() != nil {
return ctx.Err()
}
// Server closes the SSE every ~25 s; reconnect immediately.
// Hard error → small backoff so we don't hammer.
if streamErr != nil {
select {
case <-time.After(time.Second):
case <-ctx.Done():
return ctx.Err()
}
}
}
return ctx.Err()
}

View file

@ -1,153 +0,0 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
)
// fakeSSEServer streams a fixed set of SSE events then closes the connection.
func fakeSSEServer(t *testing.T, msgs []SignalMessage, holdOpenAfter bool) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-key" {
http.Error(w, "auth", http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
flusher, ok := w.(http.Flusher)
if !ok {
t.Fatal("server: ResponseWriter is not a Flusher")
}
fmt.Fprint(w, "retry: 1500\n\n")
flusher.Flush()
for _, m := range msgs {
data, _ := json.Marshal(m)
fmt.Fprintf(w, "id: %d\nevent: signal\ndata: %s\n\n", m.TS, data)
flusher.Flush()
}
// Send a heartbeat comment to verify it's ignored.
fmt.Fprint(w, ": heartbeat\n\n")
flusher.Flush()
if holdOpenAfter {
// Hold the connection until the client disconnects so the test can
// exercise stream.Close().
<-r.Context().Done()
}
}))
}
func TestSignalStreamReadsMessages(t *testing.T) {
want := []SignalMessage{
{From: SignalRoleBrowser, Type: SignalMsgOffer, Payload: "{sdp:1}", TS: 1},
{From: SignalRoleBrowser, Type: SignalMsgCandidate, Payload: "{cand:1}", TS: 2},
}
srv := fakeSSEServer(t, want, false)
defer srv.Close()
c := NewClient(srv.URL, "test-key", "test-ua")
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
stream, err := c.OpenSignalStream(ctx, "session-1")
if err != nil {
t.Fatalf("open: %v", err)
}
defer stream.Close()
var got []SignalMessage
for m := range stream.Events() {
got = append(got, m)
if len(got) == len(want) {
break
}
}
if len(got) != len(want) {
t.Fatalf("got %d messages, want %d", len(got), len(want))
}
for i, m := range got {
if m.From != want[i].From || m.Type != want[i].Type || m.Payload != want[i].Payload {
t.Errorf("[%d] mismatch: %+v want %+v", i, m, want[i])
}
}
}
func TestSignalStreamPropagatesAuthError(t *testing.T) {
srv := fakeSSEServer(t, nil, false)
defer srv.Close()
c := NewClient(srv.URL, "wrong-key", "test-ua")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_, err := c.OpenSignalStream(ctx, "session-1")
if err == nil {
t.Fatal("expected auth error, got nil")
}
}
func TestSignalStreamCloseCancelsRead(t *testing.T) {
srv := fakeSSEServer(t, nil, true)
defer srv.Close()
c := NewClient(srv.URL, "test-key", "test-ua")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
stream, err := c.OpenSignalStream(ctx, "session-1")
if err != nil {
t.Fatalf("open: %v", err)
}
// Close on a separate goroutine then make sure the events channel drains.
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(50 * time.Millisecond)
stream.Close()
}()
for range stream.Events() {
// drain
}
wg.Wait()
}
func TestPostSignalSendsCorrectBody(t *testing.T) {
var bodySeen map[string]any
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer test-key" {
http.Error(w, "auth", http.StatusUnauthorized)
return
}
_ = json.NewDecoder(r.Body).Decode(&bodySeen)
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"ok":true}`)
}))
defer srv.Close()
c := NewClient(srv.URL, "test-key", "test-ua")
err := c.PostSignal(context.Background(), "sess-x", SignalMessage{
Type: SignalMsgAnswer,
Payload: "{sdp:answer}",
})
if err != nil {
t.Fatalf("post: %v", err)
}
if bodySeen["from"] != string(SignalRoleAgent) {
t.Errorf("expected from=agent, got %v", bodySeen["from"])
}
if bodySeen["type"] != string(SignalMsgAnswer) {
t.Errorf("expected type=answer, got %v", bodySeen["type"])
}
if bodySeen["payload"] != "{sdp:answer}" {
t.Errorf("expected payload mismatch, got %v", bodySeen["payload"])
}
}

View file

@ -2,6 +2,8 @@ package agent
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
@ -9,6 +11,13 @@ import (
"github.com/torrentclaw/unarr/internal/config"
)
// ErrDaemonNotRunning is returned when no daemon state file exists on disk.
// Callers may wrap it with %w; downstream code uses errors.Is to detect it.
// NOTE: the message text is matched by the sentry package (string-match, to
// avoid an import cycle). Keep the prefix "daemon does not appear to be
// running" stable, or update sentry.daemonNotRunningMarker accordingly.
var ErrDaemonNotRunning = errors.New("daemon does not appear to be running (state file not found)")
// DaemonState is written to disk every heartbeat for external tools to read.
type DaemonState struct {
AgentID string `json:"agentId"`
@ -22,6 +31,18 @@ type DaemonState struct {
FailedCount int `json:"failedCount"`
TotalDownloaded int64 `json:"totalDownloaded"`
MethodStats map[string]int `json:"methodStats,omitempty"`
// Managed-VPN split-tunnel state, so `unarr vpn status` can report whether
// torrent traffic is actually being routed through the tunnel (vs. the daemon
// running but the tunnel having failed to come up → downloading in the clear).
VPNActive bool `json:"vpnActive,omitempty"`
VPNMode string `json:"vpnMode,omitempty"` // managed | self-hosted
VPNServer string `json:"vpnServer,omitempty"` // WireGuard endpoint (ip:port)
// CloudFlare Quick Tunnel state, so `unarr funnel status` can report the
// HTTPS hostname the daemon is reachable at from anywhere on the internet.
// Empty when the funnel is off or hasn't registered yet.
FunnelURL string `json:"funnelUrl,omitempty"`
}
// stateFilePathFn is overridable for testing.
@ -45,25 +66,43 @@ func WriteState(state *DaemonState) {
return
}
// Write to temp file then rename for atomicity
// Write to temp file then rename for atomicity. 0o600 keeps the file
// readable only by the owning user — the state contains agentID, PID
// and counters which are useful to a co-tenant on a shared host for
// fingerprinting the daemon, and we already use 0o600 for the config
// file. No need for cross-user readability here.
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
if err := os.WriteFile(tmp, data, 0o600); err != nil {
return
}
os.Rename(tmp, path)
}
// ReadState reads the daemon state from disk. Returns nil if not found.
// ReadState reads the daemon state from disk. Returns nil if not found or
// unreadable. Use LoadState when callers need to distinguish "not running"
// from "state file corrupted".
func ReadState() *DaemonState {
state, _ := LoadState()
return state
}
// LoadState reads the daemon state and returns explicit errors:
// - ErrDaemonNotRunning when the state file does not exist
// - a wrapped json error when the file exists but cannot be decoded
// (a real bug worth reporting to Sentry)
func LoadState() (*DaemonState, error) {
data, err := os.ReadFile(StateFilePath())
if err != nil {
return nil
if errors.Is(err, os.ErrNotExist) {
return nil, ErrDaemonNotRunning
}
return nil, err
}
var state DaemonState
if json.Unmarshal(data, &state) != nil {
return nil
if err := json.Unmarshal(data, &state); err != nil {
return nil, fmt.Errorf("decode daemon state %s: %w", StateFilePath(), err)
}
return &state
return &state, nil
}
// RemoveState deletes the state file (called on clean shutdown).

View file

@ -1,6 +1,7 @@
package agent
import (
"errors"
"os"
"path/filepath"
"testing"
@ -104,3 +105,39 @@ func TestReadStateCorruptedJSON(t *testing.T) {
t.Errorf("ReadState() should return nil for corrupted JSON, got %+v", state)
}
}
func TestLoadStateNotFound(t *testing.T) {
tmpDir := t.TempDir()
origFn := stateFilePathFn
stateFilePathFn = func() string { return filepath.Join(tmpDir, "nonexistent.json") }
defer func() { stateFilePathFn = origFn }()
state, err := LoadState()
if state != nil {
t.Errorf("LoadState() state = %+v, want nil", state)
}
if !errors.Is(err, ErrDaemonNotRunning) {
t.Errorf("LoadState() err = %v, want ErrDaemonNotRunning", err)
}
}
func TestLoadStateCorruptedJSON(t *testing.T) {
tmpDir := t.TempDir()
origFn := stateFilePathFn
path := filepath.Join(tmpDir, "daemon.state.json")
stateFilePathFn = func() string { return path }
defer func() { stateFilePathFn = origFn }()
os.WriteFile(path, []byte("not valid json{{{"), 0o644)
state, err := LoadState()
if state != nil {
t.Errorf("LoadState() state = %+v, want nil", state)
}
if err == nil {
t.Fatal("LoadState() err = nil, want decode error")
}
if errors.Is(err, ErrDaemonNotRunning) {
t.Error("corrupt state must not be reported as ErrDaemonNotRunning — it would be filtered from Sentry")
}
}

View file

@ -29,13 +29,20 @@ type SyncClient struct {
OnNewTasks func(tasks []Task)
OnControl func(action, taskID string, deleteFiles bool)
OnStreamRequest func(req StreamRequest)
OnWebRTCSession func(sess WebRTCSession)
OnStreamSession func(sess StreamSession)
OnUpgrade func(version string)
OnScan func()
OnWatchingChange func(watching bool)
OnSyncSuccess func() // called after each successful sync (e.g. to update state file)
GetFreeSlots func() int
GetTaskStates func() []TaskState // returns current state of all active + recently finished tasks
// GetVPNState returns the live managed-VPN split-tunnel state (whether the
// WireGuard tunnel is up, the mode, and the exit server) so the web can track
// which agent holds the single WG slot.
GetVPNState func() (active bool, mode, server string)
// GetFunnelURL returns the CloudFlare Quick Tunnel public hostname if one
// is active, else "". Sent on every sync so the web picks it up live.
GetFunnelURL func() string
// OnDeleteFiles is called when the server requests file deletion from disk.
// It should delete the files and return the IDs of successfully deleted items.
OnDeleteFiles func(items []LibraryDeleteRequest) []int
@ -155,6 +162,12 @@ func (sc *SyncClient) buildRequest() SyncRequest {
if sc.GetFreeSlots != nil {
req.FreeSlots = sc.GetFreeSlots()
}
if sc.GetVPNState != nil {
req.VPNActive, req.VPNMode, req.VPNServer = sc.GetVPNState()
}
if sc.GetFunnelURL != nil {
req.FunnelURL = sc.GetFunnelURL()
}
// Flush confirmed deletions from previous cycle.
// Once flushed, remove IDs from deleteInFlight — the server will stop sending
// them after this sync, so deduplication protection is no longer needed.
@ -192,10 +205,10 @@ func (sc *SyncClient) processResponse(resp *SyncResponse) {
}
}
// WebRTC streaming sessions
for _, ws := range resp.WebRTCSessions {
if sc.OnWebRTCSession != nil {
sc.OnWebRTCSession(ws)
// HLS streaming sessions.
for _, ws := range resp.StreamSessions {
if sc.OnStreamSession != nil {
sc.OnStreamSession(ws)
}
}

View file

@ -215,3 +215,56 @@ func TestLocalState_EmptySnapshot(t *testing.T) {
t.Errorf("expected 0 tasks, got %d", len(snap))
}
}
func TestTaskStateFromUpdate(t *testing.T) {
u := StatusUpdate{
TaskID: "task-1",
Status: "downloading",
Progress: 42,
DownloadedBytes: 1024,
TotalBytes: 4096,
SpeedBps: 100,
ETA: 30,
ResolvedMethod: "torrent",
FileName: "movie.mkv",
FilePath: "/tmp/movie.mkv",
StreamURL: "http://localhost/stream",
ErrorMessage: "",
}
got := TaskStateFromUpdate(u)
if got.TaskID != "task-1" || got.Status != "downloading" || got.Progress != 42 {
t.Errorf("basic fields wrong: %+v", got)
}
if got.DownloadedBytes != 1024 || got.TotalBytes != 4096 || got.SpeedBps != 100 {
t.Errorf("byte fields wrong: %+v", got)
}
if got.ResolvedMethod != "torrent" || got.FileName != "movie.mkv" {
t.Errorf("method/name fields wrong: %+v", got)
}
}
func TestShortID(t *testing.T) {
if got := ShortID("abcdef1234567890"); got != "abcdef12" {
t.Errorf("ShortID = %q", got)
}
if got := ShortID("short"); got != "short" {
t.Errorf("ShortID short = %q", got)
}
if got := ShortID(""); got != "" {
t.Errorf("ShortID empty = %q", got)
}
}
func TestStateFilePath(t *testing.T) {
if got := StateFilePath(); got == "" {
t.Errorf("StateFilePath should not be empty")
}
}
func TestHTTPError(t *testing.T) {
e := &HTTPError{StatusCode: 404, Message: "not found"}
got := e.Error()
if got == "" || got == "API error 0: " {
t.Errorf("HTTPError.Error() unexpected: %q", got)
}
}

View file

@ -26,6 +26,26 @@ type RegisterRequest struct {
// up to 2160p.
HWAccel string `json:"hwAccel,omitempty"`
MaxTranscodeHeight int `json:"maxTranscodeHeight,omitempty"`
// Diagnostic surface filled by engine.DetectHWAccelDiagnostic at daemon
// start. Surfaced in the web "Diagnose transcoder" modal so users can
// see *why* their HWAccel landed on "none" without running
// `unarr probe-hwaccel` locally — most commonly the ffmpeg binary
// shipped without HW encoders (linuxbrew, brew's default formula).
FFmpegVersion string `json:"ffmpegVersion,omitempty"`
FFmpegPath string `json:"ffmpegPath,omitempty"`
HWEncoders []string `json:"hwEncoders,omitempty"`
HWDevices []string `json:"hwDevices,omitempty"`
// Managed-VPN split-tunnel state. The web tracks which agent holds the single
// WireGuard slot (1 VPNResellers account = 1 WG keypair = 1 concurrent
// connection); other agents are told to use OpenVPN on their host instead.
// VPNActive has no omitempty: false is a meaningful state (tunnel down), not
// "unset" — the server must see it to release the slot.
VPNActive bool `json:"vpnActive"`
VPNMode string `json:"vpnMode,omitempty"` // managed | self-hosted
VPNServer string `json:"vpnServer,omitempty"`
// CloudFlare Quick Tunnel hostname when enabled; the web prefers it over
// Tailscale/LAN for in-browser playback because it works on any network.
FunnelURL string `json:"funnelUrl,omitempty"`
}
// RegisterResponse is returned by the server after registration.
@ -344,6 +364,15 @@ type SyncRequest struct {
Tasks []TaskState `json:"tasks"`
CanDelete bool `json:"canDelete"` // library.allow_delete is enabled
DeleteConfirmed []int `json:"deleteConfirmed,omitempty"` // library item IDs successfully deleted from disk
// Live managed-VPN split-tunnel state, sent every sync so the web sees the
// WireGuard slot owner update in near-realtime (vs. register, once at startup).
// VPNActive has no omitempty: false (tunnel down) must reach the server so it
// releases the slot, not be elided as "unset".
VPNActive bool `json:"vpnActive"`
VPNMode string `json:"vpnMode,omitempty"`
VPNServer string `json:"vpnServer,omitempty"`
// CloudFlare Quick Tunnel hostname when enabled, else empty.
FunnelURL string `json:"funnelUrl,omitempty"`
}
// ControlAction represents a server-side control signal for a task.
@ -359,29 +388,22 @@ type LibraryDeleteRequest struct {
FilePath string `json:"filePath"`
}
// WebRTCSession is a request to open a streaming session for a browser
// player. Transport selects the on-the-wire protocol: empty/"webrtc" runs the
// legacy custom WebRTC DataChannel pipeline; "hls" spawns an HLS session
// (ffmpeg producing fragmented MP4 served over HTTP). The CLI must POST an
// SDP answer to /api/internal/stream/signal/<sessionId> for WebRTC sessions
// and register the HLS session in the StreamServer's HLS registry for HLS
// sessions; either way the source bytes come from FilePath (or, when only
// InfoHash is set, from a download_task on disk).
type WebRTCSession struct {
SessionID string `json:"sessionId"`
// Transport selects the streaming protocol. "" or "webrtc" → legacy
// WebRTC + MSE pipeline (Phase 1). "hls" → HLS over HTTP (Phase 2).
Transport string `json:"transport,omitempty"`
FilePath string `json:"filePath,omitempty"`
InfoHash string `json:"infoHash,omitempty"`
TaskID string `json:"taskId,omitempty"`
FileName string `json:"fileName,omitempty"`
FileSize int64 `json:"fileSize,omitempty"`
// StreamSession is a request to open an HLS streaming session for an
// in-browser player. The CLI registers the HLS session in the StreamServer's
// HLS registry; source bytes come from FilePath (or, when only InfoHash is
// set, from a download_task on disk).
type StreamSession struct {
SessionID string `json:"sessionId"`
FilePath string `json:"filePath,omitempty"`
InfoHash string `json:"infoHash,omitempty"`
TaskID string `json:"taskId,omitempty"`
FileName string `json:"fileName,omitempty"`
FileSize int64 `json:"fileSize,omitempty"`
// Quality target the daemon should aim for when transcoding. One of
// "2160p" | "1080p" | "720p" | "480p" | "original" | "" (defer to config).
Quality string `json:"quality,omitempty"`
// AudioIndex selects the source audio track (-map 0:a:N). -1 means
// "use the default/first track" (HLS) or ignored (WebRTC).
// "use the default/first track".
AudioIndex int `json:"audioIndex,omitempty"`
}
@ -390,7 +412,7 @@ type SyncResponse struct {
NewTasks []Task `json:"newTasks,omitempty"`
Controls []ControlAction `json:"controls,omitempty"`
StreamRequests []StreamRequest `json:"streamRequests,omitempty"`
WebRTCSessions []WebRTCSession `json:"webrtcSessions,omitempty"`
StreamSessions []StreamSession `json:"streamSessions,omitempty"`
Watching bool `json:"watching"`
Upgrade *UpgradeSignal `json:"upgrade,omitempty"`
Scan bool `json:"scan,omitempty"`

View file

@ -0,0 +1,23 @@
package cmd
import (
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config"
)
// newAgentClientFromConfig builds an agent.Client wired with the mirror pool
// from the user's TOML config. Use this instead of agent.NewClient in any
// long-running command (daemon, status loop, etc.) so a `.com` outage rolls
// over to `.to` / .onion without restarting the agent.
//
// The function lives in cmd/ rather than agent/ because it has to know
// about the config struct, and cmd/ is the only place that owns the
// "wire defaults + user overrides" rule.
func newAgentClientFromConfig(cfg config.Config, userAgent string) *agent.Client {
return agent.NewClientWithMirrors(
cfg.Auth.APIURL,
cfg.Auth.Mirrors,
cfg.Auth.APIKey,
userAgent,
)
}

View file

@ -2,6 +2,7 @@ package cmd
import (
"context"
"errors"
"fmt"
"log"
"os"
@ -16,9 +17,11 @@ import (
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/engine"
"github.com/torrentclaw/unarr/internal/funnel"
"github.com/torrentclaw/unarr/internal/library"
"github.com/torrentclaw/unarr/internal/library/mediainfo"
"github.com/torrentclaw/unarr/internal/usenet/download"
"github.com/torrentclaw/unarr/internal/vpn"
)
// newStartCmd creates the top-level `unarr start` command.
@ -140,7 +143,19 @@ func runDaemonStart() error {
// is what the web side uses to decide whether the user should pre-empt
// transcoding by downloading a smaller version (4K source on a software
// libx264-only host is the canonical case where pre-download wins).
hwAccelPick := engine.DetectHWAccel(context.Background(), cfg.Library.FFmpegPath)
//
// Use the full diagnostic (encoders + devices + ffmpeg version) instead
// of just the picked backend — the extra fields ride along in the
// register payload so the web "Diagnose transcoder" modal can show *why*
// libx264 was selected on a host with a GPU (e.g. brew's ffmpeg without
// --enable-nvenc). 10 s ceiling so a hung ffmpeg binary can't stall
// startup forever.
ffmpegResolved, _ := mediainfo.ResolveFFmpeg(cfg.Library.FFmpegPath)
probeCtx, probeCancel := context.WithTimeout(context.Background(), 10*time.Second)
defer probeCancel() // guard against a panic inside DetectHWAccelDiagnostic
hwDiag := engine.DetectHWAccelDiagnostic(probeCtx, ffmpegResolved)
log.Println(hwDiag.LogLine())
hwAccelPick := hwDiag.Pick
maxTranscodeHeight := 1080
if hwAccelPick != engine.HWAccelNone {
maxTranscodeHeight = 2160
@ -159,11 +174,17 @@ func runDaemonStart() error {
ScanPaths: library.ResolveScanPaths(cfg.Download.Dir, cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir, cfg.Library.ScanPath),
HWAccel: string(hwAccelPick),
MaxTranscodeHeight: maxTranscodeHeight,
FFmpegVersion: hwDiag.FFmpegVersion,
FFmpegPath: hwDiag.FFmpegPath,
HWEncoders: hwDiag.Encoders,
HWDevices: hwDiag.Devices,
AutoUpgrade: cfg.Daemon.AutoUpgradeEnabled(),
}
// Create HTTP client — single communication channel
agentClient := agent.NewClient(cfg.Auth.APIURL, cfg.Auth.APIKey, userAgent)
log.Printf("Transport: HTTP sync → %s", cfg.Auth.APIURL)
// Create HTTP client with mirror failover so a `.com` block-out rolls
// over to `.to` / .onion without restarting the daemon.
agentClient := newAgentClientFromConfig(cfg, userAgent)
log.Printf("Transport: HTTP sync → %s (mirrors: %d)", cfg.Auth.APIURL, len(cfg.Auth.Mirrors))
// Create daemon
d := agent.NewDaemon(daemonCfg, agentClient)
@ -192,6 +213,56 @@ func runDaemonStart() error {
reporter := engine.NewProgressReporter(agentClient, statusInterval)
reporter.SetWatchingFunc(func() bool { return d.Watching.Load() })
// Managed-VPN add-on: bring up the in-process WireGuard split-tunnel before
// the torrent client so peer + tracker traffic routes through it. Failure is
// non-fatal — log and download in the clear (better than refusing to run).
var vpnTunnel *vpn.Tunnel
if cfg.Download.VPN.ConfigFile != "" {
// Self-hosted / personal-VPN mode: read a local .conf directly.
raw, rerr := os.ReadFile(cfg.Download.VPN.ConfigFile)
if rerr != nil {
log.Printf("[vpn] could not read config_file %q (%v) — downloading in the clear", cfg.Download.VPN.ConfigFile, rerr)
} else if t, uerr := vpn.Up(string(raw)); uerr != nil {
log.Printf("[vpn] tunnel failed to start from config_file (%v) — downloading in the clear", uerr)
} else {
vpnTunnel = t
defer vpnTunnel.Close()
log.Printf("[vpn] managed VPN active (local config_file) — torrent traffic split-tunnelled through WireGuard")
}
} else if cfg.Download.VPN.Enabled {
apiURL := cfg.Auth.APIURL
if apiURL == "" {
apiURL = "https://torrentclaw.com"
}
fetchCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
conf, ferr := vpn.FetchConfig(fetchCtx, apiURL, cfg.Auth.APIKey, "unarr/"+Version, cfg.Agent.ID, false)
cancel()
var fe *vpn.FetchError
switch {
case ferr != nil && errors.As(ferr, &fe) && fe.Code == vpn.ErrSlotOnDevice:
log.Printf("[vpn] the single WireGuard slot is already held by another unarr agent — this one downloads in the clear. To protect this machine too, set up OpenVPN on it (1 agent uses WireGuard, the rest use OpenVPN — up to 10). See https://torrentclaw.com/vpn")
case ferr != nil:
log.Printf("[vpn] could not enable VPN (%v) — downloading in the clear", ferr)
default:
if t, uerr := vpn.Up(conf); uerr != nil {
log.Printf("[vpn] tunnel failed to start (%v) — downloading in the clear", uerr)
} else {
vpnTunnel = t
defer vpnTunnel.Close()
log.Printf("[vpn] managed VPN active — torrent traffic split-tunnelled through WireGuard")
}
}
}
// Record VPN split-tunnel state for `unarr vpn status`.
if vpnTunnel != nil {
mode := "managed"
if cfg.Download.VPN.ConfigFile != "" {
mode = "self-hosted"
}
d.SetVPNState(true, mode, vpnTunnel.Endpoint)
}
// Create torrent downloader
torrentDl, err := engine.NewTorrentDownloader(engine.TorrentConfig{
DataDir: cfg.Download.Dir,
@ -202,9 +273,7 @@ func runDaemonStart() error {
MaxUploadRate: maxUl,
ListenPort: cfg.Download.ListenPort,
SeedEnabled: false,
WebRTCEnabled: cfg.Download.WebRTC.Enabled,
WebRTCTrackers: cfg.Download.WebRTC.Trackers,
ICEServers: engine.BuildICEServers(cfg.Download.WebRTC),
VPNTunnel: vpnTunnel,
})
if err != nil {
return fmt.Errorf("create torrent downloader: %w", err)
@ -239,17 +308,62 @@ func runDaemonStart() error {
// Create persistent stream server
streamSrv := engine.NewStreamServer(cfg.Download.StreamPort)
streamSrv.SetUPnPEnabled(cfg.Download.EnableUPnP)
// CORS extras = operator config + dynamic mirror list from /api/mirrors.
// Without the mirror merge, a user playing from `torrentclaw.to` (or any
// future mirror) hits the daemon, gets 200 + body, but no
// `Access-Control-Allow-Origin` → browser drops the response → player
// reports "404 todos los canales". Fetching /api/mirrors at startup
// future-proofs against mirror additions without a CLI rebuild.
corsExtras := append([]string(nil), cfg.Download.CORSExtraOrigins...)
corsExtras = append(corsExtras, mirrorCORSOrigins(ctx, cfg, userAgent)...)
streamSrv.SetCORSAllowedOrigins(corsExtras)
// Reap HLS tmpdirs left over from a previous daemon run before we start
// accepting new sessions. The in-memory registry doesn't survive a
// restart, so without this disk usage grows unbounded across restarts.
if err := engine.CleanupHLSOrphanDirs(); err != nil {
log.Printf("[hls] orphan tmpdir cleanup: %v", err)
}
// Persistent HLS segment cache — survives across sessions so re-plays
// of the same file at the same quality skip ffmpeg entirely. Off when
// hls_cache.enabled = false; size cap from hls_cache.size_gb; path from
// hls_cache.dir (defaults to ~/.cache/unarr/hls-cache).
var hlsCache *engine.HLSCache
if cfg.Download.HLSCache.Enabled {
cacheDir := cfg.Download.HLSCache.Dir
if cacheDir == "" {
if base, err := os.UserCacheDir(); err == nil {
cacheDir = filepath.Join(base, "unarr", "hls-cache")
} else {
cacheDir = filepath.Join(os.TempDir(), "unarr-hls-cache")
}
}
c, err := engine.NewHLSCache(cacheDir, cfg.Download.HLSCache.SizeGB)
if err != nil {
log.Printf("[hls_cache] init failed (%v) — falling back to per-session tmpdirs", err)
} else {
hlsCache = c
hlsCache.StartSweeper(ctx, time.Hour)
log.Printf("[hls_cache] enabled: dir=%s budget=%dGB", cacheDir, cfg.Download.HLSCache.SizeGB)
}
} else {
log.Printf("[hls_cache] disabled by config — every play re-encodes from scratch")
}
if err := streamSrv.Listen(ctx); err != nil {
return fmt.Errorf("start stream server: %w", err)
}
d.UpdateStreamPort(streamSrv.Port())
// CloudFlare Quick Tunnel — needs the ACTUAL listening port (the
// configured port may have been busy and bumped). Spawning here ensures
// cloudflared --url points at the right socket. Failures degrade to
// Tailscale/LAN only; the supervisor keeps the tunnel up across CF's
// periodic rotation + transient cloudflared crashes.
if cfg.Download.Funnel.Enabled {
go superviseFunnel(ctx, d, streamSrv.Port())
}
// Warn at startup if transcode is enabled but ffmpeg/ffprobe are missing.
// HLS sessions get rejected at runtime (see daemon.go ~line 455), but
// surfacing it here gives the operator a chance to install ffmpeg before
@ -274,13 +388,7 @@ func runDaemonStart() error {
// Wire: sync receives new tasks → submit to manager or handle stream
d.OnTasksClaimed = func(tasks []agent.Task) {
for _, t := range tasks {
if t.Mode == "seed_file" {
// Browser asked us to wrap an arbitrary on-disk file as
// a single-file torrent + seed it via WebRTC. Runs in
// its own goroutine so a slow / failing seed can't
// stall the rest of the claim batch.
go handleSeedFileTask(t, torrentDl, agentClient)
} else if t.Mode == "stream" {
if t.Mode == "stream" {
if isStreamingTask(t.ID) {
continue
}
@ -441,23 +549,23 @@ func runDaemonStart() error {
}()
}
// Wire: sync receives custom WebRTC streaming session requests.
// Each session is a one-shot browser↔daemon DataChannel. Validate the
// FilePath against allowed dirs to prevent path traversal abuse from a
// compromised server, then spawn the pion peer in its own goroutine.
d.OnWebRTCSession = func(sess agent.WebRTCSession) {
if webrtcRegistry.has(sess.SessionID) {
// Wire: sync receives HLS streaming session requests. Each session spawns
// one ffmpeg process and registers its HLS playlist with the StreamServer.
// Validate FilePath against allowed dirs to prevent path traversal abuse
// from a compromised server.
d.OnStreamSession = func(sess agent.StreamSession) {
if playerSessionRegistry.has(sess.SessionID) {
return // already running
}
filePath := sess.FilePath
if filePath == "" {
log.Printf("webrtc session %s rejected: empty file path", agent.ShortID(sess.SessionID))
log.Printf("[hls %s] rejected: empty file path", agent.ShortID(sess.SessionID))
return
}
filePath = filepath.Clean(filePath)
if !isAllowedStreamPath(filePath, cfg.Download.Dir, cfg.Library.ScanPath,
cfg.Organize.MoviesDir, cfg.Organize.TVShowsDir) {
log.Printf("webrtc session %s rejected: path outside allowed dirs: %s",
log.Printf("[hls %s] rejected: path outside allowed dirs: %s",
agent.ShortID(sess.SessionID), filePath)
return
}
@ -465,74 +573,50 @@ func runDaemonStart() error {
if info, err := os.Stat(filePath); err == nil && info.IsDir() {
found := engine.FindVideoFile(filePath)
if found == "" {
log.Printf("webrtc session %s rejected: no video file in dir %s",
log.Printf("[hls %s] rejected: no video file in dir %s",
agent.ShortID(sess.SessionID), filePath)
return
}
filePath = found
}
// Branch on transport: HLS sessions only need ffmpeg + StreamServer,
// not a WebRTC peer, so they must bypass the WebRTC.Enabled gate.
// Default ("" or "webrtc") runs the DataChannel pipeline and requires it.
if strings.EqualFold(sess.Transport, "hls") {
tcRuntime := buildTranscodeRuntime(ctx, cfg)
if tcRuntime.FFmpegPath == "" || tcRuntime.FFprobePath == "" {
log.Printf("[hls %s] rejected: ffmpeg/ffprobe unavailable", agent.ShortID(sess.SessionID))
return
}
hlsCtx, hlsCancel := context.WithCancel(ctx)
webrtcRegistry.add(sess.SessionID, hlsCancel)
hlsCfg := engine.HLSSessionConfig{
SessionID: sess.SessionID,
SourcePath: filePath,
FileName: sess.FileName,
Quality: sess.Quality,
AudioIndex: sess.AudioIndex,
Transcode: tcRuntime,
}
tcRuntime := buildTranscodeRuntime(ctx, cfg)
if tcRuntime.FFmpegPath == "" || tcRuntime.FFprobePath == "" {
log.Printf("[hls %s] rejected: ffmpeg/ffprobe unavailable", agent.ShortID(sess.SessionID))
return
}
hlsCtx, hlsCancel := context.WithCancel(ctx)
playerSessionRegistry.add(sess.SessionID, hlsCancel)
hlsCfg := engine.HLSSessionConfig{
SessionID: sess.SessionID,
SourcePath: filePath,
FileName: sess.FileName,
Quality: sess.Quality,
AudioIndex: sess.AudioIndex,
Transcode: tcRuntime,
Cache: hlsCache,
}
// StartHLSSession runs ffprobe (15 s cap, typical 0.31 s) before
// returning. Doing this synchronously inside the sync handler holds
// the next sync HTTP cycle until ffprobe is done, so any other
// pending actions (new tasks, deletes) wait too. Hand it off so
// the sync loop returns immediately — browser HEAD probes already
// have a 30 s retry budget that absorbs the gap until
// `streamSrv.HLS().Register` lands.
go func() {
hsess, err := engine.StartHLSSession(hlsCtx, hlsCfg)
if err != nil {
webrtcRegistry.remove(sess.SessionID)
playerSessionRegistry.remove(sess.SessionID)
hlsCancel()
log.Printf("[hls %s] start failed: %v", agent.ShortID(sess.SessionID), err)
return
}
streamSrv.HLS().Register(hsess)
return
}
// Non-HLS transport requires WebRTC peer support.
if !cfg.Download.WebRTC.Enabled {
log.Printf("webrtc session %s rejected: webrtc disabled in config", agent.ShortID(sess.SessionID))
return
}
sessCtx, sessCancel := context.WithCancel(ctx) //nolint:gosec // G118 cancel stored in registry
webrtcRegistry.add(sess.SessionID, sessCancel)
go func() {
defer func() {
webrtcRegistry.remove(sess.SessionID)
sessCancel()
}()
tcRuntime := buildTranscodeRuntime(ctx, cfg)
runCfg := engine.WebRTCStreamConfig{
SessionID: sess.SessionID,
FilePath: filePath,
FileName: sess.FileName,
FileSize: sess.FileSize,
Quality: sess.Quality,
ICEServers: engine.BuildICEServers(cfg.Download.WebRTC),
Signal: agentClient,
Logger: stdLogger{},
Transcode: tcRuntime,
}
log.Printf("[wrtc %s] starting session: %s", agent.ShortID(sess.SessionID), filepath.Base(filePath))
if err := engine.RunWebRTCStream(sessCtx, runCfg); err != nil {
if sessCtx.Err() == nil {
log.Printf("[wrtc %s] ended: %v", agent.ShortID(sess.SessionID), err)
}
}
// Tell the server seg-0 is on disk as soon as it lands so the
// player's SSE subscription flips its "Preparando…" UI without
// waiting for the browser HEAD-probe loop to discover it
// independently. Cache-HIT sessions are ready immediately.
go watchSessionReady(hlsCtx, agentClient, hsess, sess.SessionID)
}()
}
@ -602,7 +686,7 @@ func runDaemonStart() error {
case sig := <-sigCh:
fmt.Printf("\n Received %s, shutting down...\n", sig)
cancelStreamContexts()
cancelAllWebRTCSessions()
cancelAllPlayerSessions()
streamSrv.Shutdown(context.Background())
cancel()
@ -617,7 +701,7 @@ func runDaemonStart() error {
case err := <-errCh:
cancelStreamContexts()
cancelAllWebRTCSessions()
cancelAllPlayerSessions()
streamSrv.Shutdown(context.Background())
cancel()
return err
@ -765,3 +849,144 @@ func runAutoScan(ctx context.Context, cfg config.Config, interval time.Duration,
}
}
}
// superviseFunnel keeps a CloudFlare Quick Tunnel up across cloudflared
// crashes and CF's ~6h tunnel rotation. On a clean exit (cancellation) it
// returns; on a crash it clears the reported URL and respawns with an
// exponential backoff so we don't hammer cloudflared into a tight loop when
// it can't reach the CF edge.
func superviseFunnel(ctx context.Context, d *agent.Daemon, port int) {
backoff := 2 * time.Second
const maxBackoff = 5 * time.Minute
for ctx.Err() == nil {
t, err := funnel.Start(ctx, funnel.Config{Port: port})
if err != nil {
log.Printf("[funnel] could not start CloudFlare tunnel (%v) — retrying in %s", err, backoff)
select {
case <-time.After(backoff):
case <-ctx.Done():
return
}
backoff = min(backoff*2, maxBackoff)
continue
}
log.Printf("[funnel] cloudflared started, waiting for public URL...")
go func() {
url, werr := t.WaitURL(45 * time.Second)
if werr != nil {
log.Printf("[funnel] cloudflared did not emit a URL (%v)", werr)
return
}
log.Printf("[funnel] public URL: %s", url)
d.SetFunnelURL(url)
}()
// Block until cloudflared exits (CF rotation, crash, or shutdown).
exitErr := <-t.Done()
_ = t.Close()
d.SetFunnelURL("")
if ctx.Err() != nil {
return
}
if exitErr != nil {
log.Printf("[funnel] cloudflared exited: %v — restarting in %s", exitErr, backoff)
} else {
log.Printf("[funnel] cloudflared exited cleanly — restarting in %s", backoff)
}
select {
case <-time.After(backoff):
case <-ctx.Done():
return
}
backoff = min(backoff*2, maxBackoff)
}
}
// mirrorCORSOrigins fetches /api/mirrors from the configured primary (+ extra
// mirror candidates + static IPFS fallback) and returns the discovered URLs as
// Origin strings. Best-effort: any failure logs a warning and returns an empty
// slice; the static defaultCORSAllowedOrigins in validate.go covers the known
// mirrors (.com / .to / built-in onion) so the daemon still accepts the
// official surfaces when this call fails.
//
// Bounded to a short timeout so a slow /api/mirrors response can't delay
// daemon startup — every second here is a second the user can't play.
func mirrorCORSOrigins(parent context.Context, cfg config.Config, userAgent string) []string {
ctx, cancel := context.WithTimeout(parent, 10*time.Second)
defer cancel()
candidates := append([]string{cfg.Auth.APIURL}, cfg.Auth.Mirrors...)
resp, err := agent.FetchMirrorsWithFallback(ctx, candidates, userAgent)
if err != nil {
log.Printf("[cors] mirror discovery failed (%v) — using static allowlist only", err)
return nil
}
seen := make(map[string]struct{})
out := make([]string, 0, len(resp.Mirrors))
add := func(rawURL string) {
if rawURL == "" {
return
}
origin := strings.TrimRight(rawURL, "/")
if _, dup := seen[origin]; dup {
return
}
seen[origin] = struct{}{}
out = append(out, origin)
}
for _, m := range resp.Mirrors {
add(m.URL)
}
if resp.Tor != nil {
add(resp.Tor.URL)
}
if len(out) > 0 {
log.Printf("[cors] merged %d mirror origins from /api/mirrors", len(out))
}
return out
}
// watchSessionReady polls HLSSession.ReadyCount until the first segment +
// init.mp4 are on disk, then POSTs /api/internal/agent/session-ready so
// the web side flips streaming_session.ready_at — which its SSE endpoint
// pushes to subscribed players. Cache-HIT sessions are ready the moment
// StartHLSSession returns and POST immediately.
//
// Bounded by a 60 s deadline so a permanently stuck encoder doesn't keep
// a goroutine alive forever; if seg-0 never lands the player falls back
// to its existing HEAD-probe retry path anyway.
func watchSessionReady(ctx context.Context, client *agent.Client, hsess *engine.HLSSession, sessionID string) {
deadline := time.Now().Add(60 * time.Second)
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
for {
// Session torn down through a path that didn't cancel ctx (registry
// replace, idle sweep, internal kill). Bail before polling further —
// without this check the watcher could keep alive for up to 60 s on
// a dead HLSSession that's never going to become ready.
if hsess.IsClosed() {
return
}
// Cache HIT or seg-0 ready → notify + done.
if hsess.FromCache() || hsess.ReadyCount() >= 1 {
// Parent ctx so a session cancel mid-POST (user closed tab,
// daemon shutdown) tears down the in-flight webhook instead of
// blocking the goroutine for up to 10 s on a now-orphan call.
rctx, cancel := context.WithTimeout(ctx, 10*time.Second)
if err := client.MarkSessionReady(rctx, sessionID); err != nil {
log.Printf("[hls %s] mark-ready failed: %v", agent.ShortID(sessionID), err)
}
cancel()
return
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
if time.Now().After(deadline) {
log.Printf("[hls %s] mark-ready: timeout waiting for seg-0", agent.ShortID(sessionID))
return
}
}
}

View file

@ -1,6 +1,7 @@
package cmd
import (
"errors"
"fmt"
"os"
"os/exec"
@ -262,9 +263,12 @@ func runDaemonReload() error {
// stopDaemonByPID reads the state file and sends a graceful stop to the daemon PID.
// Used as fallback on platforms without a service manager (and as Windows implementation).
func stopDaemonByPID() error {
state := agent.ReadState()
if state == nil {
return fmt.Errorf("daemon does not appear to be running (state file not found)")
state, err := agent.LoadState()
if err != nil {
if errors.Is(err, agent.ErrDaemonNotRunning) {
return err
}
return fmt.Errorf("read daemon state: %w", err)
}
return killPID(state.PID)
}

View file

@ -114,9 +114,6 @@ func runDownloadWithDeps(input, method string, deps downloadDeps) error {
StallTimeout: 10 * time.Minute,
MaxTimeout: 0, // unlimited
SeedEnabled: false,
WebRTCEnabled: cfg.Download.WebRTC.Enabled,
WebRTCTrackers: cfg.Download.WebRTC.Trackers,
ICEServers: engine.BuildICEServers(cfg.Download.WebRTC),
})
if err != nil {
return fmt.Errorf("create downloader: %w", err)

165
internal/cmd/funnel.go Normal file
View file

@ -0,0 +1,165 @@
package cmd
import (
"fmt"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config"
)
func newFunnelCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "funnel",
Short: "Expose the daemon over a public HTTPS hostname via CloudFlare Quick Tunnel",
Long: `Turn the CloudFlare Quick Tunnel on/off and check its status.
When on, the daemon spawns cloudflared as a child process and registers a
` + "`https://<random>.trycloudflare.com`" + ` hostname tunnelled to its local
HLS server. The torrentclaw.com / torrentclaw.to web player picks the tunnel
URL first so cross-network playback works from any browser without Tailscale
or port forwarding.
Trade-offs:
Bytes proxy through CloudFlare. We don't relay; CF does. Preserves the
TorrentClaw legal posture but means CF sees your traffic shape.
Quick Tunnels are anonymous no CF account required.
Hostname is random per session and rotates roughly every 6 h.
Requires the cloudflared binary on PATH. Install:
Linux : https://pkg.cloudflare.com (apt) or download from
https://github.com/cloudflare/cloudflared/releases
macOS : brew install cloudflared
Windows: winget install --id Cloudflare.cloudflared`,
Example: ` unarr funnel status # is the tunnel up? what's the URL?
unarr funnel on # turn it on
unarr funnel off # turn it off`,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
cmd.AddCommand(newFunnelStatusCmd(), newFunnelOnCmd(), newFunnelOffCmd())
return cmd
}
func newFunnelStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show CloudFlare tunnel configuration + live URL",
Example: " unarr funnel status",
RunE: func(cmd *cobra.Command, args []string) error {
return runFunnelStatus()
},
}
}
func runFunnelStatus() error {
bold := color.New(color.Bold)
dim := color.New(color.FgHiBlack)
green := color.New(color.FgGreen)
yellow := color.New(color.FgYellow)
cyan := color.New(color.FgCyan)
cfg := loadConfig()
fmt.Println()
bold.Println(" CloudFlare Quick Tunnel")
fmt.Println()
if !cfg.Download.Funnel.Enabled {
dim.Println(" Mode: off")
fmt.Println()
dim.Println(" Enable with `unarr funnel on` to give the daemon a public HTTPS URL")
dim.Println(" so cross-network browser playback works without Tailscale.")
fmt.Println()
return nil
}
cyan.Println(" Mode: on")
state := agent.ReadState()
alive := state != nil && isDaemonAlive(state)
fmt.Println()
switch {
case alive && state.FunnelURL != "":
green.Println(" ✓ Tunnel ACTIVE")
fmt.Printf(" URL: %s\n", state.FunnelURL)
fmt.Println()
dim.Println(" This URL rotates roughly every 6 h. The web player picks it up")
dim.Println(" automatically — no action needed on your side.")
case alive:
yellow.Println(" ⚠ Daemon is running but the tunnel hasn't registered yet.")
dim.Println(" Check `unarr daemon logs` for a [funnel] line. Common cause:")
dim.Println(" cloudflared isn't installed on PATH.")
default:
dim.Println(" Daemon not running — start it (`unarr start`) to bring the tunnel up.")
}
fmt.Println()
return nil
}
func newFunnelOnCmd() *cobra.Command {
return &cobra.Command{
Use: "on",
Short: "Turn the CloudFlare tunnel on",
Example: " unarr funnel on",
RunE: func(cmd *cobra.Command, args []string) error {
return setFunnelEnabled(true)
},
}
}
func newFunnelOffCmd() *cobra.Command {
return &cobra.Command{
Use: "off",
Short: "Turn the CloudFlare tunnel off",
Example: " unarr funnel off",
RunE: func(cmd *cobra.Command, args []string) error {
return setFunnelEnabled(false)
},
}
}
func setFunnelEnabled(enabled bool) error {
green := color.New(color.FgGreen)
dim := color.New(color.FgHiBlack)
cfg := loadConfig()
if cfg.Download.Funnel.Enabled == enabled {
fmt.Println()
dim.Printf(" Tunnel is already %s — nothing to do.\n", onOffWord(enabled))
fmt.Println()
return nil
}
cfg.Download.Funnel.Enabled = enabled
configPath := config.FilePath()
if cfgFile != "" {
configPath = cfgFile
}
if err := config.Save(cfg, configPath); err != nil {
return fmt.Errorf("save config: %w", err)
}
appCfg = cfg
fmt.Println()
green.Printf(" ✓ CloudFlare tunnel %s.\n", onOffWord(enabled))
// Subprocess is launched/torn down by the daemon at startup; a plain config
// reload does not bring it up. Prompt for a restart when the daemon is alive.
if state := agent.ReadState(); state != nil && isDaemonAlive(state) {
fmt.Println()
dim.Println(" The daemon is running. Restart it for this to take effect:")
dim.Println(" unarr daemon restart")
}
fmt.Println()
return nil
}
func onOffWord(enabled bool) string {
if enabled {
return "on"
}
return "off"
}

View file

@ -9,12 +9,21 @@ import (
)
// openBrowser opens a URL in the default browser.
//
// The URL is restricted to http(s) so that a hostile caller cannot trick
// xdg-open/open into interpreting it as a flag (a leading "-" would otherwise
// match a switch on every helper we shell out to). Where the helper supports
// it we also append "--" to terminate switch parsing as belt-and-braces.
func openBrowser(url string) {
if !isSafeBrowserURL(url) {
return
}
var c *exec.Cmd
switch runtime.GOOS {
case "darwin":
c = exec.Command("open", url)
c = exec.Command("open", "--", url)
case "windows":
// rundll32 does not parse switches from positional args.
c = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
default: // linux, freebsd
c = exec.Command("xdg-open", url)
@ -22,6 +31,12 @@ func openBrowser(url string) {
_ = c.Start() // fire and forget; best-effort
}
// isSafeBrowserURL accepts only http(s) URLs. Other schemes (file://, javascript:,
// data:, ...) and flag-shaped strings ("--help") are rejected.
func isSafeBrowserURL(url string) bool {
return strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://")
}
// defaultDownloadDir returns a sensible default download directory.
func defaultDownloadDir() string {
home, _ := os.UserHomeDir()

View file

@ -31,6 +31,32 @@ func TestExpandHome(t *testing.T) {
}
}
func TestIsSafeBrowserURL(t *testing.T) {
good := []string{
"http://localhost:3000",
"https://torrentclaw.com/some/path?q=1",
}
bad := []string{
"--help",
"-version",
"file:///etc/passwd",
"javascript:alert(1)",
"data:text/html,foo",
"ftp://example.com",
"",
}
for _, u := range good {
if !isSafeBrowserURL(u) {
t.Errorf("isSafeBrowserURL(%q) = false, want true", u)
}
}
for _, u := range bad {
if isSafeBrowserURL(u) {
t.Errorf("isSafeBrowserURL(%q) = true, want false", u)
}
}
}
func TestDefaultDownloadDir(t *testing.T) {
dir := defaultDownloadDir()
if dir == "" {

204
internal/cmd/mirrors.go Normal file
View file

@ -0,0 +1,204 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config"
)
// newMirrorsCmd wires `unarr mirrors` and its subcommands.
//
// Mirrors are alternate base URLs the agent can fall back to when the
// primary api_url is unreachable. The pool is consulted on every transient
// network failure (DNS, refused, timeout, 5xx) — see internal/agent/
// mirror_pool.go for the rotation rules.
func newMirrorsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "mirrors",
Short: "Manage TorrentClaw mirror failover list",
Long: `Mirrors are alternate base URLs the agent falls back to when the primary
domain is unreachable. The pool survives DNS blocks, ISP filters, and
short-lived takedowns without restarting the agent.
Examples:
unarr mirrors list Print currently configured mirrors
unarr mirrors update Refresh from the server's canonical list
unarr mirrors test Probe every configured mirror`,
}
cmd.AddCommand(newMirrorsListCmd())
cmd.AddCommand(newMirrorsUpdateCmd())
cmd.AddCommand(newMirrorsTestCmd())
return cmd
}
func newMirrorsListCmd() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "Print currently configured mirrors",
RunE: func(cmd *cobra.Command, args []string) error {
cfg := loadConfig()
pool := agent.NewMirrorPool(cfg.Auth.APIURL, cfg.Auth.Mirrors)
if jsonOut {
out := map[string]any{
"primary": cfg.Auth.APIURL,
"mirrors": pool.Mirrors(),
}
return json.NewEncoder(os.Stdout).Encode(out)
}
fmt.Printf("Primary: %s\n", color.GreenString(cfg.Auth.APIURL))
if len(cfg.Auth.Mirrors) == 0 {
fmt.Println("Fallbacks: (none configured — run `unarr mirrors update`)")
return nil
}
fmt.Println("Fallbacks:")
for i, m := range cfg.Auth.Mirrors {
fmt.Printf(" %d. %s\n", i+1, m)
}
return nil
},
}
}
func newMirrorsUpdateCmd() *cobra.Command {
return &cobra.Command{
Use: "update",
Short: "Refresh the mirror list from the server",
Long: `Fetch /api/v1/mirrors from the configured primary (with fallback to any
currently-known mirrors) and write the resulting list back to config.toml.
This is how long-running agents survive a takedown of the primary domain:
the user runs ` + "`unarr mirrors update`" + ` once a week (or via cron), and
the agent transparently picks up new mirrors without a CLI release.`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg := loadConfig()
// Candidate set: primary + any currently-known mirrors. Order matters —
// we try primary first so the most-trusted endpoint wins.
candidates := append([]string{cfg.Auth.APIURL}, cfg.Auth.Mirrors...)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
fmt.Println("Refreshing mirror list...")
resp, err := agent.FetchMirrorsWithFallback(ctx, candidates, "unarr/"+Version)
if err != nil {
return fmt.Errorf("fetch mirrors: %w", err)
}
primary, extras := resp.ToConfig()
if primary == "" {
return fmt.Errorf("server returned no mirrors")
}
// Track what changed so we can give the user a clear diff.
added, removed := diffMirrors(append([]string{cfg.Auth.APIURL}, cfg.Auth.Mirrors...), append([]string{primary}, extras...))
cfg.Auth.APIURL = primary
cfg.Auth.Mirrors = extras
if err := config.Save(cfg, cfgFile); err != nil {
return fmt.Errorf("save config: %w", err)
}
fmt.Printf("%s revision %d (%d mirror%s)\n",
color.GreenString("✓"), resp.Revision, len(resp.Mirrors), pluralS(len(resp.Mirrors)))
fmt.Printf(" Primary: %s\n", primary)
if len(extras) > 0 {
fmt.Printf(" Fallbacks: %s\n", strings.Join(extras, ", "))
}
if resp.Tor != nil {
fmt.Printf(" Tor: %s\n", resp.Tor.URL)
}
for _, c := range resp.Channels {
fmt.Printf(" Channel: %s — %s\n", c.Label, c.URL)
}
if len(added) > 0 {
fmt.Printf(" %s %s\n", color.GreenString("added:"), strings.Join(added, ", "))
}
if len(removed) > 0 {
fmt.Printf(" %s %s\n", color.YellowString("removed:"), strings.Join(removed, ", "))
}
return nil
},
}
}
func newMirrorsTestCmd() *cobra.Command {
return &cobra.Command{
Use: "test",
Short: "Probe every configured mirror",
Long: `Performs a small unauthenticated HEAD/GET against /api/health on every
configured mirror and reports latency + reachability.`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg := loadConfig()
all := append([]string{cfg.Auth.APIURL}, cfg.Auth.Mirrors...)
if len(all) == 0 {
return fmt.Errorf("no mirrors configured")
}
for _, base := range all {
if base == "" {
continue
}
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
start := time.Now()
_, err := agent.FetchMirrors(ctx, []string{base}, "unarr/"+Version)
cancel()
elapsed := time.Since(start)
if err != nil {
fmt.Printf(" %s %s — %s (%s)\n", color.RedString("✗"), base, err, elapsed.Round(time.Millisecond))
continue
}
fmt.Printf(" %s %s (%s)\n", color.GreenString("✓"), base, elapsed.Round(time.Millisecond))
}
return nil
},
}
}
// diffMirrors returns the URLs added and removed between two ordered lists.
// Used to print a friendly diff after `unarr mirrors update`.
func diffMirrors(old, fresh []string) (added, removed []string) {
oldSet := make(map[string]struct{}, len(old))
for _, m := range old {
if m != "" {
oldSet[m] = struct{}{}
}
}
freshSet := make(map[string]struct{}, len(fresh))
for _, m := range fresh {
if m == "" {
continue
}
freshSet[m] = struct{}{}
if _, ok := oldSet[m]; !ok {
added = append(added, m)
}
}
for _, m := range old {
if m == "" {
continue
}
if _, ok := freshSet[m]; !ok {
removed = append(removed, m)
}
}
return added, removed
}
func pluralS(n int) string {
if n == 1 {
return ""
}
return "s"
}

View file

@ -2,7 +2,6 @@ package cmd
import (
"context"
"log"
"sync"
"github.com/torrentclaw/unarr/internal/config"
@ -10,66 +9,57 @@ import (
"github.com/torrentclaw/unarr/internal/library/mediainfo"
)
// webrtcRegistry tracks per-session cancel funcs for active custom WebRTC
// streams (engine.RunWebRTCStream goroutines). Each session lives only as
// long as its DataChannel; the registry exists so duplicate sync responses
// don't double-spawn the same session and so daemon shutdown can drain.
var webrtcRegistry = &webrtcSessionRegistry{
// playerSessionRegistry tracks per-session cancel funcs for active in-browser
// HLS streaming sessions. Each session lives only as long as its ffmpeg
// process; the registry exists so duplicate sync responses don't double-spawn
// the same session and so daemon shutdown can drain.
var playerSessionRegistry = &playerSessionRegistryT{
cancels: make(map[string]context.CancelFunc),
}
type webrtcSessionRegistry struct {
type playerSessionRegistryT struct {
mu sync.Mutex
cancels map[string]context.CancelFunc
}
func (r *webrtcSessionRegistry) has(sessionID string) bool {
func (r *playerSessionRegistryT) has(sessionID string) bool {
r.mu.Lock()
defer r.mu.Unlock()
_, ok := r.cancels[sessionID]
return ok
}
func (r *webrtcSessionRegistry) add(sessionID string, cancel context.CancelFunc) {
func (r *playerSessionRegistryT) add(sessionID string, cancel context.CancelFunc) {
r.mu.Lock()
defer r.mu.Unlock()
r.cancels[sessionID] = cancel
}
func (r *webrtcSessionRegistry) remove(sessionID string) {
func (r *playerSessionRegistryT) remove(sessionID string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.cancels, sessionID)
}
// cancelAllWebRTCSessions cancels every running session. Called on daemon
// shutdown so pion peers and SSE consumers exit cleanly.
func cancelAllWebRTCSessions() {
webrtcRegistry.mu.Lock()
cancels := make([]context.CancelFunc, 0, len(webrtcRegistry.cancels))
for _, c := range webrtcRegistry.cancels {
// cancelAllPlayerSessions cancels every running session. Called on daemon
// shutdown so the ffmpeg children and SSE consumers exit cleanly.
func cancelAllPlayerSessions() {
playerSessionRegistry.mu.Lock()
cancels := make([]context.CancelFunc, 0, len(playerSessionRegistry.cancels))
for _, c := range playerSessionRegistry.cancels {
cancels = append(cancels, c)
}
webrtcRegistry.cancels = make(map[string]context.CancelFunc)
webrtcRegistry.mu.Unlock()
playerSessionRegistry.cancels = make(map[string]context.CancelFunc)
playerSessionRegistry.mu.Unlock()
for _, c := range cancels {
c()
}
}
// stdLogger is a tiny adapter so engine.RunWebRTCStream can log through the
// standard library logger without pulling in a logging dependency.
type stdLogger struct{}
func (stdLogger) Infof(format string, args ...any) { log.Printf(format, args...) }
func (stdLogger) Warnf(format string, args ...any) { log.Printf("WARN: "+format, args...) }
func (stdLogger) Errorf(format string, args ...any) { log.Printf("ERROR: "+format, args...) }
// buildTranscodeRuntime resolves the ffmpeg/ffprobe binaries + config knobs
// for the WebRTC streaming pipeline. Failure to resolve a binary returns a
// runtime with empty paths so engine.RunWebRTCStream falls back to
// passthrough — the user gets a clearer codec error from the browser than a
// daemon-side abort.
// for the HLS streaming pipeline. Failure to resolve a binary returns a
// runtime with empty paths so the caller can short-circuit instead of
// launching a transcoder that will immediately fail.
func buildTranscodeRuntime(ctx context.Context, cfg config.Config) engine.TranscodeRuntime {
if !cfg.Download.Transcode.Enabled {
return engine.TranscodeRuntime{Disabled: true}

View file

@ -15,7 +15,7 @@ import (
)
// newProbeHWAccelCmd reports the hardware-acceleration capabilities the daemon
// would actually use for HLS/WebRTC transcoding. The motivation: a beefy host
// would actually use for HLS transcoding. The motivation: a beefy host
// (e.g. RTX 3090) can still fall back to software encoding when the installed
// ffmpeg binary was built without nvenc/qsv/vaapi support — Homebrew ffmpeg
// is a common offender. Without this command, users see slow / failing 4K

View file

@ -3,6 +3,7 @@
package cmd
import (
"errors"
"fmt"
"log"
"os"
@ -43,9 +44,12 @@ func startReloadWatcher(rc *ReloadableConfig) {
// sendReloadSignal sends SIGUSR1 to the running daemon process.
func sendReloadSignal() error {
state := agent.ReadState()
if state == nil {
return fmt.Errorf("daemon does not appear to be running (state file not found)")
state, err := agent.LoadState()
if err != nil {
if errors.Is(err, agent.ErrDaemonNotRunning) {
return err
}
return fmt.Errorf("read daemon state: %w", err)
}
p, err := os.FindProcess(state.PID)
if err != nil {

View file

@ -9,6 +9,7 @@ import (
tc "github.com/torrentclaw/go-client"
"github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/sentry"
"github.com/torrentclaw/unarr/internal/upgrade"
)
var (
@ -24,16 +25,20 @@ var (
func init() {
rootCmd = &cobra.Command{
Use: "unarr",
Short: "unarr — torrent search and management",
Long: `unarr is a powerful terminal tool for torrent search and management.
Search 30+ torrent sources, inspect torrent quality, discover popular content,
find streaming providers, and manage your media collection all from your terminal.
Use: "unarr",
Version: Version,
Short: "Terminal torrent + debrid + usenet client — download, stream, transcode",
Long: `unarr is a terminal-native client that downloads torrents, debrid links,
and usenet (NZB) all from the same binary. It streams content straight
to mpv/vlc with sequential piece prioritization, transcodes on the fly via
ffmpeg with hardware acceleration (NVENC, QSV, VA-API, VideoToolbox), and
organizes your library into Movies/TV folders. Run it one-shot or as a
long-running daemon with a built-in WireGuard split-tunnel and remote
playback over Cloudflare Funnel.
Get started:
unarr init First-time configuration wizard
unarr search "breaking bad" Search for content
unarr download <magnet|hash> Grab a torrent one-shot
unarr start Start the download daemon
Documentation: https://torrentclaw.com/cli
@ -42,6 +47,10 @@ Source: https://github.com/torrentclaw/unarr`,
if noColor || os.Getenv("NO_COLOR") != "" {
color.NoColor = true
}
// Self-updater fetches releases from the configured host (default
// torrentclaw.com), not GitHub — so mirrors / onion / staging /
// UNARR_API_URL all route updates correctly.
upgrade.SetBaseURL(loadConfig().Auth.APIURL)
},
SilenceUsage: true,
SilenceErrors: true,
@ -50,7 +59,7 @@ Source: https://github.com/torrentclaw/unarr`,
// Command groups for organized help output
rootCmd.AddGroup(
&cobra.Group{ID: "start", Title: "Getting Started:"},
&cobra.Group{ID: "search", Title: "Search & Discovery:"},
&cobra.Group{ID: "search", Title: "Catalog & Discovery:"},
&cobra.Group{ID: "download", Title: "Downloads & Streaming:"},
&cobra.Group{ID: "daemon", Title: "Daemon Management:"},
&cobra.Group{ID: "system", Title: "System & Diagnostics:"},
@ -98,6 +107,10 @@ Source: https://github.com/torrentclaw/unarr`,
statusCmd.GroupID = "daemon"
daemonCmd := newDaemonCmd()
daemonCmd.GroupID = "daemon"
vpnCmd := newVPNCmd()
vpnCmd.GroupID = "daemon"
funnelCmd := newFunnelCmd()
funnelCmd.GroupID = "daemon"
// System & Diagnostics
statsCmd := newStatsCmd()
@ -108,6 +121,8 @@ Source: https://github.com/torrentclaw/unarr`,
probeHWAccelCmd.GroupID = "system"
cleanCmd := newCleanCmd()
cleanCmd.GroupID = "system"
mirrorsCmd := newMirrorsCmd()
mirrorsCmd.GroupID = "system"
selfUpdateCmd := newSelfUpdateCmd()
selfUpdateCmd.GroupID = "system"
versionCmd := newVersionCmd()
@ -139,11 +154,14 @@ Source: https://github.com/torrentclaw/unarr`,
stopCmd,
statusCmd,
daemonCmd,
vpnCmd,
funnelCmd,
// System & Diagnostics
statsCmd,
doctorCmd,
probeHWAccelCmd,
cleanCmd,
mirrorsCmd,
selfUpdateCmd,
versionCmd,
completionCmd,

View file

@ -241,7 +241,7 @@ func printScanSummary(cache *library.LibraryCache) {
continue
}
res := library.ResolveResolution(item.MediaInfo.Video.Height)
res := library.ResolveResolution(item.MediaInfo.Video.Width, item.MediaInfo.Video.Height)
if res == "" {
res = "other"
}

View file

@ -1,65 +0,0 @@
package cmd
import (
"context"
"log"
"time"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/engine"
)
// handleSeedFileTask wraps an arbitrary on-disk file as a single-file
// torrent and adds it to the existing torrent client so the WebRTC
// peer can serve pieces to a browser. Reports the generated info_hash
// back to the server so the web player can target /stream/<hash>.
//
// Runs in its own goroutine; never blocks the claim batch.
func handleSeedFileTask(t agent.Task, dl *engine.TorrentDownloader, client *agent.Client) {
short := agent.ShortID(t.ID)
if t.FilePath == "" {
log.Printf("[%s] seed_file: missing filePath, marking failed", short)
reportSeedFileFailed(client, t.ID, "Missing filePath")
return
}
log.Printf("[%s] seed_file: building torrent from %s", short, t.FilePath)
hash, err := engine.SeedFileOnDownloader(dl, t.FilePath)
if err != nil {
log.Printf("[%s] seed_file: %v", short, err)
reportSeedFileFailed(client, t.ID, err.Error())
return
}
infoHash := hash.HexString()
log.Printf("[%s] seed_file: seeding ih=%s", short, infoHash)
// Push the info_hash + downloading status (file is on disk; from the
// client's perspective it's already complete). The web side polls
// /api/internal/stream/seed-file/<taskId> waiting for this update.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_, reportErr := client.ReportStatus(ctx, agent.StatusUpdate{
TaskID: t.ID,
Status: "downloading", // semantic: actively serving
InfoHash: infoHash,
FilePath: t.FilePath,
})
if reportErr != nil {
log.Printf("[%s] seed_file: failed to push info_hash: %v", short, reportErr)
}
}
func reportSeedFileFailed(client *agent.Client, taskID, msg string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err := client.ReportStatus(ctx, agent.StatusUpdate{
TaskID: taskID,
Status: "failed",
ErrorMessage: msg,
})
if err != nil {
log.Printf("[%s] seed_file: report-failed itself failed: %v", agent.ShortID(taskID), err)
}
}

View file

@ -13,6 +13,7 @@ import (
func newSelfUpdateCmd() *cobra.Command {
var force bool
var allowUnsigned bool
cmd := &cobra.Command{
Use: "self-update",
@ -26,18 +27,20 @@ If the daemon is running, it is automatically restarted so the new
version is loaded into memory (otherwise heartbeat would keep
reporting the old version until a manual restart).`,
Example: ` unarr self-update
unarr self-update --force`,
unarr self-update --force
unarr self-update --allow-unsigned # accept releases missing checksums.txt.sig`,
RunE: func(cmd *cobra.Command, args []string) error {
return runSelfUpdate(force)
return runSelfUpdate(force, allowUnsigned)
},
}
cmd.Flags().BoolVarP(&force, "force", "f", false, "reinstall even if already up to date")
cmd.Flags().BoolVar(&allowUnsigned, "allow-unsigned", false, "continue with SHA256-only verification when checksums.txt.sig is missing")
return cmd
}
func runSelfUpdate(force bool) error {
func runSelfUpdate(force, allowUnsigned bool) error {
bold := color.New(color.Bold)
green := color.New(color.FgGreen)
yellow := color.New(color.FgYellow)
@ -74,6 +77,7 @@ func runSelfUpdate(force bool) error {
upgrader := &upgrade.Upgrader{
CurrentVersion: currentClean,
AllowUnsigned: allowUnsigned,
OnProgress: func(msg string) {
fmt.Printf(" %s\n", msg)
},

View file

@ -2,6 +2,7 @@ package cmd
import (
"context"
"errors"
"fmt"
"runtime"
"strings"
@ -58,7 +59,7 @@ func runStatus() error {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
ac := agent.NewClient(cfg.Auth.APIURL, cfg.Auth.APIKey, "unarr/"+Version)
ac := newAgentClientFromConfig(cfg, "unarr/"+Version)
resp, err := ac.Register(ctx, agent.RegisterRequest{
AgentID: cfg.Agent.ID,
Name: cfg.Agent.Name,
@ -74,7 +75,17 @@ func runStatus() error {
cyan.Println(" Account")
ar := <-accountCh
if ar.err != nil {
dim.Println(" Could not fetch account info")
var httpErr *agent.HTTPError
switch {
case errors.As(ar.err, &httpErr) && httpErr.StatusCode == 401:
yellow.Println(" API key invalid or revoked")
fmt.Printf(" Run %s to re-authenticate\n", cyan.Sprint("unarr login"))
case errors.As(ar.err, &httpErr) && httpErr.StatusCode == 403:
yellow.Println(" API key lacks permission for this server")
fmt.Printf(" Check plan or run %s\n", cyan.Sprint("unarr login"))
default:
dim.Printf(" Could not fetch account info (%v)\n", ar.err)
}
} else {
fmt.Printf(" User: %s\n", ar.user.Name)
fmt.Printf(" Email: %s\n", ar.user.Email)

View file

@ -7,6 +7,7 @@ import (
// newUpgradeCmd creates the `unarr upgrade` command as an alias for `self-update`.
func newUpgradeCmd() *cobra.Command {
var force bool
var allowUnsigned bool
cmd := &cobra.Command{
Use: "upgrade",
@ -18,13 +19,15 @@ This is an alias for 'unarr self-update'. Checks GitHub for the latest
release, verifies the checksum, and replaces the current binary.
A backup is kept at <binary>.backup.`,
Example: ` unarr upgrade
unarr upgrade --force`,
unarr upgrade --force
unarr upgrade --allow-unsigned`,
RunE: func(cmd *cobra.Command, args []string) error {
return runSelfUpdate(force)
return runSelfUpdate(force, allowUnsigned)
},
}
cmd.Flags().BoolVarP(&force, "force", "f", false, "reinstall even if already up to date")
cmd.Flags().BoolVar(&allowUnsigned, "allow-unsigned", false, "continue with SHA256-only verification when checksums.txt.sig is missing")
return cmd
}

View file

@ -1,4 +1,4 @@
package cmd
// Version is the CLI version. Overridden by goreleaser ldflags at release time.
var Version = "0.8.1"
var Version = "0.9.15"

213
internal/cmd/vpn.go Normal file
View file

@ -0,0 +1,213 @@
package cmd
import (
"context"
"fmt"
"net"
"time"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/vpn"
)
func newVPNCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "vpn",
Short: "Manage the managed-VPN split-tunnel for downloads",
Long: `Enable, disable, and inspect the managed VPN.
When enabled, the daemon fetches a WireGuard config from your TorrentClaw account
at startup and routes ONLY the torrent client's traffic (peers + trackers) through
an in-process WireGuard tunnel no root, no OS routing changes.
This is split-tunnel: your browser and other apps keep using your real IP. Only
your downloads are hidden behind the VPN server.
The VPN requires a PRO+ plan with the VPN add-on. Set it up at
https://torrentclaw.com/vpn and configure your other devices (phone, laptop) with
the OpenVPN credentials from your profile those don't share the agent's tunnel.`,
Example: ` unarr vpn status # is the tunnel up? which server?
unarr vpn enable # turn the managed VPN on
unarr vpn disable # turn it off`,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
cmd.AddCommand(newVPNStatusCmd(), newVPNEnableCmd(), newVPNDisableCmd())
return cmd
}
func newVPNStatusCmd() *cobra.Command {
var check bool
cmd := &cobra.Command{
Use: "status",
Short: "Show VPN configuration and live tunnel state",
Example: " unarr vpn status\n unarr vpn status --check # also verify your account is provisioned",
RunE: func(cmd *cobra.Command, args []string) error {
return runVPNStatus(check)
},
}
cmd.Flags().BoolVar(&check, "check", false, "query the API to verify the VPN is provisioned on your account")
return cmd
}
func runVPNStatus(check bool) error {
bold := color.New(color.Bold)
dim := color.New(color.FgHiBlack)
green := color.New(color.FgGreen)
yellow := color.New(color.FgYellow)
cyan := color.New(color.FgCyan)
cfg := loadConfig()
fmt.Println()
bold.Println(" Managed VPN")
fmt.Println()
// ── Configured mode ──
switch {
case cfg.Download.VPN.ConfigFile != "":
cyan.Println(" Mode: self-hosted (local config_file)")
fmt.Printf(" Config: %s\n", cfg.Download.VPN.ConfigFile)
case cfg.Download.VPN.Enabled:
cyan.Println(" Mode: managed (config fetched from your account)")
default:
dim.Println(" Mode: off")
fmt.Println()
dim.Println(" Enable with `unarr vpn enable` (needs a PRO+ plan with the VPN add-on).")
fmt.Println()
return nil
}
// ── Live tunnel state (from the daemon state file) ──
state := agent.ReadState()
alive := state != nil && isDaemonAlive(state)
fmt.Println()
switch {
case alive && state.VPNActive:
server := state.VPNServer
if host, _, err := net.SplitHostPort(server); err == nil && host != "" {
server = host
}
green.Println(" ✓ Tunnel ACTIVE — torrent traffic is routed through the VPN")
if server != "" {
fmt.Printf(" Exit server: %s\n", server)
}
case alive:
yellow.Println(" ⚠ Daemon is running but the tunnel is NOT up — downloads go in the clear.")
dim.Println(" Check `unarr daemon logs` for a [vpn] line. Common cause: no active")
dim.Println(" VPN on your account (set it up at https://torrentclaw.com/vpn).")
default:
dim.Println(" Daemon not running — start it (`unarr start`) to bring the tunnel up.")
}
// ── Optional live provisioning check ──
if check {
fmt.Println()
if cfg.Auth.APIKey == "" {
yellow.Println(" ⚠ No API key — run `unarr init` first.")
} else {
apiURL := cfg.Auth.APIURL
if apiURL == "" {
apiURL = "https://torrentclaw.com"
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
_, err := vpn.FetchConfig(ctx, apiURL, cfg.Auth.APIKey, "unarr/"+Version, cfg.Agent.ID, true)
cancel()
switch {
case err == nil:
green.Println(" ✓ Account provisioned — a VPN config is available.")
default:
yellow.Printf(" ⚠ %s\n", err)
}
}
}
// ── Split-tunnel reminder ──
fmt.Println()
dim.Println(" Split-tunnel: only your downloads use the VPN. Your browser and other")
dim.Println(" apps keep your real IP — that's by design. Use the OpenVPN credentials in")
dim.Println(" your profile to protect your other devices.")
fmt.Println()
return nil
}
func newVPNEnableCmd() *cobra.Command {
return &cobra.Command{
Use: "enable",
Short: "Turn the managed VPN on",
Example: " unarr vpn enable",
RunE: func(cmd *cobra.Command, args []string) error {
return setVPNEnabled(true)
},
}
}
func newVPNDisableCmd() *cobra.Command {
return &cobra.Command{
Use: "disable",
Short: "Turn the managed VPN off",
Example: " unarr vpn disable",
RunE: func(cmd *cobra.Command, args []string) error {
return setVPNEnabled(false)
},
}
}
func setVPNEnabled(enabled bool) error {
green := color.New(color.FgGreen)
yellow := color.New(color.FgYellow)
dim := color.New(color.FgHiBlack)
cfg := loadConfig()
if enabled && cfg.Auth.APIKey == "" {
return fmt.Errorf("no API key configured — run `unarr init` first (the managed VPN fetches its config from your account)")
}
if cfg.Download.VPN.Enabled == enabled {
fmt.Println()
dim.Printf(" VPN is already %s — nothing to do.\n", enabledWord(enabled))
fmt.Println()
return nil
}
cfg.Download.VPN.Enabled = enabled
configPath := config.FilePath()
if cfgFile != "" {
configPath = cfgFile
}
if err := config.Save(cfg, configPath); err != nil {
return fmt.Errorf("save config: %w", err)
}
appCfg = cfg
fmt.Println()
green.Printf(" ✓ Managed VPN %s.\n", enabledWord(enabled))
if enabled && cfg.Download.VPN.ConfigFile != "" {
yellow.Println(" ⚠ A config_file is set, so self-hosted mode takes precedence and the")
yellow.Println(" managed config from your account is ignored. Clear config_file to use it.")
}
// The tunnel is brought up once at daemon startup; a plain config reload does
// NOT (re)create it. Tell the user to restart the daemon if it's running.
if state := agent.ReadState(); state != nil && isDaemonAlive(state) {
fmt.Println()
dim.Println(" The daemon is running. Restart it for this to take effect:")
dim.Println(" unarr daemon restart")
}
fmt.Println()
return nil
}
func enabledWord(enabled bool) string {
if enabled {
return "enabled"
}
return "disabled"
}

View file

@ -26,6 +26,11 @@ type Config struct {
type AuthConfig struct {
APIKey string `toml:"api_key"`
APIURL string `toml:"api_url"`
// Mirrors lists alternate base URLs the agent will fall back to when the
// primary api_url is unreachable. Ordered by preference. Refreshed at
// runtime by `unarr mirrors update` against /api/v1/mirrors so a long-
// running agent survives a primary takedown without a new release.
Mirrors []string `toml:"mirrors"`
}
type AgentConfig struct {
@ -44,8 +49,46 @@ type DownloadConfig struct {
StallTimeout string `toml:"stall_timeout"` // e.g. "30m", "1h", "0" = unlimited (default: "30m")
ListenPort int `toml:"listen_port"` // fixed port for incoming peer connections (default: 42069, 0 = random)
StreamPort int `toml:"stream_port"` // fixed port for streaming HTTP server (default: 11818)
WebRTC WebRTCConfig `toml:"webrtc"`
EnableUPnP bool `toml:"enable_upnp"` // map StreamPort to the WAN via UPnP/NAT-PMP (default: false; opt-in because it exposes the unauthenticated /stream + /hls endpoints to the public internet)
CORSExtraOrigins []string `toml:"cors_extra_origins"` // extra browser origins added on top of the baked-in allowlist (torrentclaw.com, app.torrentclaw.com, localhost:3030)
Transcode TranscodeConfig `toml:"transcode"`
HLSCache HLSCacheConfig `toml:"hls_cache"`
VPN VPNConfig `toml:"vpn"`
Funnel FunnelConfig `toml:"funnel"`
}
// HLSCacheConfig controls the persistent HLS segment cache. A completed encode
// is kept on disk so a second play of the same file at the same quality skips
// ffmpeg entirely. Old entries are evicted (LRU) once the cache exceeds the
// size budget. Enabled by default — disable to save disk space at the cost of
// re-encoding every play.
type HLSCacheConfig struct {
Enabled bool `toml:"enabled"` // default: true
SizeGB int `toml:"size_gb"` // size budget in gigabytes; default: 5; minimum: 1
Dir string `toml:"dir"` // override storage path; default: ~/.cache/unarr/hls-cache
}
// FunnelConfig gates the optional CloudFlare Quick Tunnel that exposes the
// daemon's HLS server over a public HTTPS hostname (https://<random>.try
// cloudflare.com). Enabling it lets the web player on torrentclaw.com play
// from this daemon across any network without Tailscale or a public IP —
// the cost is that bytes proxy through CloudFlare's network. Off by default.
type FunnelConfig struct {
Enabled bool `toml:"enabled"`
}
// VPNConfig gates the managed-VPN add-on split-tunnel. When enabled, the daemon
// fetches a WireGuard config from the web (/api/internal/agent/vpn-config) and
// routes only the torrent client's peer/tracker traffic through an in-process
// userspace tunnel (no root, no OS routing changes). Requires an active VPN
// add-on on the account; otherwise the daemon logs and downloads in the clear.
type VPNConfig struct {
Enabled bool `toml:"enabled"`
// ConfigFile, when set, makes the daemon read a local WireGuard .conf instead
// of fetching one from the web API. For self-hosted / personal-VPN testing:
// point it at a peer .conf from your own WireGuard server and the torrent
// client split-tunnels through it with no web/provider plumbing.
ConfigFile string `toml:"config_file"`
}
// TranscodeConfig controls real-time transcoding for the in-browser player
@ -53,28 +96,33 @@ type DownloadConfig struct {
// Disabled by default; enabling requires ffmpeg + ffprobe on PATH (or
// explicit paths via the library config).
type TranscodeConfig struct {
Enabled bool `toml:"enabled"` // master switch
HWAccel string `toml:"hw_accel"` // "auto" | "none" | "nvenc" | "qsv" | "vaapi" | "videotoolbox"
Preset string `toml:"preset"` // libx264 preset; "veryfast" by default
Enabled bool `toml:"enabled"` // master switch
HWAccel string `toml:"hw_accel"` // "auto" | "none" | "nvenc" | "qsv" | "vaapi" | "videotoolbox"
// Preset is the encoder speed/quality dial. Only used on software encode
// (libx264) — HW backends (NVENC/QSV/VAAPI/VideoToolbox) use vendor
// presets that don't share libx264's vocabulary and would be rejected
// by ffmpeg if passed here.
//
// Empty (default) → engine picks "superfast" — latency-biased, ~3 s
// first-play on 1080p source on a modern x86 CPU. Marginal quality loss
// at 5-25 Mbps target bitrates.
//
// For better quality at slower first-play (1-2 s slower per seg):
// "veryfast" — previous default; balanced
// "faster" — slight quality bump
// "fast" — meaningful quality bump
// "medium" — libx264 stock default; CPU-bound on 4K
// "slow" / "slower" / "veryslow" — only for batch encodes, not real-time HLS
//
// Or faster:
// "ultrafast" — lowest quality, fastest encode
Preset string `toml:"preset"`
VideoBitrate string `toml:"video_bitrate"` // e.g. "5M"
AudioBitrate string `toml:"audio_bitrate"` // e.g. "192k"
MaxHeight int `toml:"max_height"` // optional downscale cap (e.g. 720)
MaxConcurrent int `toml:"max_concurrent"` // safety cap on simultaneous transcoder processes
}
// WebRTCConfig opts the daemon into acting as a WebTorrent peer so browsers
// can fetch pieces via WebRTC data channels — required by the in-browser
// player on torrentclaw.com. Disabled by default; enabling implies upload
// is allowed for active torrents (browsers can't download otherwise).
type WebRTCConfig struct {
Enabled bool `toml:"enabled"` // master switch
Trackers []string `toml:"trackers"` // wss:// signaling trackers
STUNServers []string `toml:"stun_servers"` // stun:host:port
TURNServers []string `toml:"turn_servers"` // turn:host:port (no auth) — see TURNCredentials for authed
TURNUser string `toml:"turn_user"` // optional, applied to all TURNServers
TURNPass string `toml:"turn_pass"` // optional
}
type OrganizeConfig struct {
Enabled bool `toml:"enabled"`
MoviesDir string `toml:"movies_dir"`
@ -83,8 +131,27 @@ type OrganizeConfig struct {
type DaemonConfig struct {
StatusInterval string `toml:"status_interval"`
// AutoUpgrade gates the daemon's response to a server-flagged upgrade
// (set via the "Force update" button on the web). When true the daemon
// downloads + replaces the binary in-place and exits so the service
// supervisor respawns on the new version. When false the daemon only
// logs "new version available" and the operator must run `unarr update`
// manually. Default: true. Available since unarr 0.9.6.
AutoUpgrade *bool `toml:"auto_upgrade"`
}
// AutoUpgradeEnabled returns the resolved AutoUpgrade flag — defaults to true
// when the user has not set it explicitly. Pointer-vs-bool because Go's
// zero-value bool would collapse "unset" and "false" together.
func (d DaemonConfig) AutoUpgradeEnabled() bool {
if d.AutoUpgrade == nil {
return true
}
return *d.AutoUpgrade
}
func boolPtr(v bool) *bool { return &v }
type NotificationsConfig struct {
Enabled bool `toml:"enabled"`
}
@ -99,7 +166,7 @@ type LibraryConfig struct {
ScanPath string `toml:"scan_path"` // remembered from last scan
Workers int `toml:"workers"` // concurrent ffprobe (default 8)
FFprobePath string `toml:"ffprobe_path"` // optional explicit path
FFmpegPath string `toml:"ffmpeg_path"` // optional explicit path (used by WebRTC streaming transcoder)
FFmpegPath string `toml:"ffmpeg_path"` // optional explicit path (used by the HLS streaming transcoder)
BackupDir string `toml:"backup_dir"` // for replaced files
AutoScan bool `toml:"auto_scan"` // enable daily auto-scan in daemon (default true)
ScanInterval string `toml:"scan_interval"` // e.g. "24h", "12h", "6h" (default "24h")
@ -113,28 +180,52 @@ func Default() Config {
return Config{
Auth: AuthConfig{
APIURL: "https://torrentclaw.com",
// Default mirror list. Kept in sync with src/lib/mirrors-config.ts
// on the server. Users can override with `unarr mirrors update`,
// which pulls the live list from /api/v1/mirrors.
Mirrors: []string{
"https://torrentclaw.to",
},
},
Download: DownloadConfig{
PreferredMethod: "auto",
MaxConcurrent: 3,
StreamPort: 11818,
WebRTC: WebRTCConfig{
Enabled: true,
Trackers: []string{"wss://tracker.torrentclaw.com"},
STUNServers: []string{"stun:stun.l.google.com:19302", "stun:stun1.l.google.com:19302"},
},
Transcode: TranscodeConfig{
Enabled: true,
HWAccel: "auto",
Preset: "veryfast",
// Empty preset → engine.ResolveEncoderProfile picks the
// latency-biased default ("superfast" on libx264). Override
// in config.toml when quality > first-start latency matters.
Preset: "",
AudioBitrate: "192k",
MaxConcurrent: 2,
},
Funnel: FunnelConfig{
// On by default so headless installs (NAS / Docker) get cross-network
// HTTPS playback without anyone having to terminal in. Users who
// don't want bytes proxied through CloudFlare can opt out with
// `unarr funnel off` (sets enabled=false in the TOML).
Enabled: true,
},
HLSCache: HLSCacheConfig{
// On by default — second play of a recently watched file at the
// same quality skips ffmpeg (instant start, near-zero CPU).
// Users can opt out (hls_cache.enabled=false) or shrink the
// budget (hls_cache.size_gb) when disk is tight.
Enabled: true,
SizeGB: 5,
},
},
Daemon: DaemonConfig{
// Pointer-to-true so Default() round-trips through TOML marshal
// as `auto_upgrade = true` instead of an omitted key — keeps the
// freshly-written config aligned with what README documents.
AutoUpgrade: boolPtr(true),
},
Organize: OrganizeConfig{
Enabled: true,
},
Daemon: DaemonConfig{},
Notifications: NotificationsConfig{
Enabled: true,
},
@ -187,6 +278,9 @@ func applyDefaults(cfg *Config, meta toml.MetaData) {
if !meta.IsDefined("auth", "api_url") {
cfg.Auth.APIURL = "https://torrentclaw.com"
}
if !meta.IsDefined("auth", "mirrors") {
cfg.Auth.Mirrors = []string{"https://torrentclaw.to"}
}
if !meta.IsDefined("downloads", "preferred_method") {
cfg.Download.PreferredMethod = "auto"
}
@ -200,19 +294,6 @@ func applyDefaults(cfg *Config, meta toml.MetaData) {
cfg.General.Country = "US"
}
if !meta.IsDefined("downloads", "webrtc", "enabled") {
cfg.Download.WebRTC.Enabled = true
}
if !meta.IsDefined("downloads", "webrtc", "trackers") {
cfg.Download.WebRTC.Trackers = []string{"wss://tracker.torrentclaw.com"}
}
if !meta.IsDefined("downloads", "webrtc", "stun_servers") {
cfg.Download.WebRTC.STUNServers = []string{
"stun:stun.l.google.com:19302",
"stun:stun1.l.google.com:19302",
}
}
if !meta.IsDefined("downloads", "transcode", "enabled") {
cfg.Download.Transcode.Enabled = true
}
@ -220,7 +301,12 @@ func applyDefaults(cfg *Config, meta toml.MetaData) {
cfg.Download.Transcode.HWAccel = "auto"
}
if !meta.IsDefined("downloads", "transcode", "preset") {
cfg.Download.Transcode.Preset = "veryfast"
// Empty = let engine.ResolveEncoderProfile pick the latency-biased
// default ("superfast" on libx264). Users wanting better quality at
// slower first-play can override to "veryfast" / "fast" / "medium" in
// config.toml. Ignored when hw_accel picks NVENC/QSV/VAAPI/VideoToolbox
// (those have built-in vendor presets).
cfg.Download.Transcode.Preset = ""
}
if !meta.IsDefined("downloads", "transcode", "audio_bitrate") {
cfg.Download.Transcode.AudioBitrate = "192k"
@ -228,6 +314,12 @@ func applyDefaults(cfg *Config, meta toml.MetaData) {
if !meta.IsDefined("downloads", "transcode", "max_concurrent") {
cfg.Download.Transcode.MaxConcurrent = 2
}
// NOTE: Funnel default-ON only applies to fresh installs (no config file →
// Default() returns Funnel.Enabled=true straight off). When an existing
// config file lacks `[downloads.funnel]` entirely we intentionally do NOT
// flip it on here — that would silently route an upgraded operator's
// traffic through CloudFlare without their consent. They opt in with
// `unarr funnel on` whenever they're ready.
}
// Save writes config to the default or specified path using atomic write.

View file

@ -208,17 +208,6 @@ name = "Test"
t.Fatalf("Load failed: %v", err)
}
// WebRTC should be on by default for fresh installs.
if !cfg.Download.WebRTC.Enabled {
t.Error("WebRTC.Enabled should default to true when [downloads.webrtc] is absent")
}
if len(cfg.Download.WebRTC.Trackers) == 0 {
t.Error("WebRTC.Trackers should default to torrentclaw tracker when absent")
}
if len(cfg.Download.WebRTC.STUNServers) == 0 {
t.Error("WebRTC.STUNServers should default to public STUN list when absent")
}
// Transcode should be on by default.
if !cfg.Download.Transcode.Enabled {
t.Error("Transcode.Enabled should default to true when [downloads.transcode] is absent")
@ -226,8 +215,11 @@ name = "Test"
if cfg.Download.Transcode.HWAccel != "auto" {
t.Errorf("Transcode.HWAccel = %q, want auto", cfg.Download.Transcode.HWAccel)
}
if cfg.Download.Transcode.Preset != "veryfast" {
t.Errorf("Transcode.Preset = %q, want veryfast", cfg.Download.Transcode.Preset)
if cfg.Download.Transcode.Preset != "" {
// Default is now empty — engine.ResolveEncoderProfile picks
// "superfast" on libx264 for first-start latency. Users
// wanting better quality override in config.toml.
t.Errorf("Transcode.Preset = %q, want empty", cfg.Download.Transcode.Preset)
}
if cfg.Download.Transcode.MaxConcurrent != 2 {
t.Errorf("Transcode.MaxConcurrent = %d, want 2", cfg.Download.Transcode.MaxConcurrent)
@ -238,12 +230,9 @@ func TestLoadRespectsExplicitlyDisabledStreaming(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "config.toml")
// User explicitly opted out of webrtc + transcode. Defaults must NOT
// override them — that would silently re-enable features the user disabled.
os.WriteFile(path, []byte(`[downloads.webrtc]
enabled = false
[downloads.transcode]
// User explicitly opted out of transcode. Defaults must NOT override
// it — that would silently re-enable a feature the user disabled.
os.WriteFile(path, []byte(`[downloads.transcode]
enabled = false
`), 0o644)
@ -252,9 +241,6 @@ enabled = false
t.Fatalf("Load failed: %v", err)
}
if cfg.Download.WebRTC.Enabled {
t.Error("WebRTC.Enabled = true, want false (user explicitly disabled)")
}
if cfg.Download.Transcode.Enabled {
t.Error("Transcode.Enabled = true, want false (user explicitly disabled)")
}

View file

@ -3,9 +3,7 @@
// Browser ↔ daemon over plain HTTP (LAN / Tailscale / UPnP). The daemon runs
// ffmpeg in `-f hls` mode, writing fragmented MP4 segments to a per-session
// tmpdir. Master + media playlists are pre-rendered from the probed source
// duration so the player knows the full timeline before any segment exists,
// which fixes the seek/duration/pause/multi-track problems we hit with the
// raw fMP4-over-WebRTC pipeline.
// duration so the player knows the full timeline before any segment exists.
//
// One HLSSession == one browser playback. Sessions are registered in a
// process-wide map keyed by session ID; the StreamServer routes
@ -34,10 +32,46 @@ import (
"time"
)
// hlsSegmentDuration is the target seconds per HLS fragment. Four seconds is
// the Plex/Apple default — short enough that seek granularity is acceptable,
// long enough that GOP overhead doesn't dominate.
const hlsSegmentDuration = 4
// hlsSegmentDuration is the target seconds per HLS fragment.
//
// We use 2 seconds (not the more common 4-6 s). Trade-off: 2× more segments
// per source (a 2 h movie produces 3600 segments instead of 1800), but the
// player's first-frame wait drops to ~half — ffmpeg only needs to encode
// 2 s before seg-0 lands. For software encodes on 4K this is ~1 s instead
// of ~3 s of cold-cache wait. Well within HLS spec (Apple recommends 6 s,
// but 2-6 s is acceptable; Low-Latency HLS uses 1-2 s segments).
//
// Caveat for existing cached encodes: cache entries from 0.9.9 used 4 s
// segments. After this bump, VerifyComplete (which checks the highest
// expected segment index) returns false for those entries — they're
// invalidated + re-encoded with 2 s segments on next play. Self-healing.
const hlsSegmentDuration = 2
// segmentDurationFor returns the target duration (in whole seconds) for the
// segment at index idx. With uniform-duration segments this is always
// hlsSegmentDuration; the helper exists so a future short-first-segment
// variant can be slotted in here without touching every call site.
func segmentDurationFor(idx int) int {
return hlsSegmentDuration
}
// segmentStartSec returns the wall-clock start time of segment idx. Used
// to compute the `-ss` flag when ffmpeg restarts at a mid-file segment.
func segmentStartSec(idx int) float64 {
if idx <= 0 {
return 0
}
return float64(idx * hlsSegmentDuration)
}
// segmentCountForDuration returns how many segments cover a source of the
// given duration. Always returns at least 1.
func segmentCountForDuration(dur float64) int {
if dur <= 0 {
return 1
}
return int((dur + float64(hlsSegmentDuration) - 1) / float64(hlsSegmentDuration))
}
// hlsSessionTTL is how long a session can sit idle (no segment requests)
// before the manager kills ffmpeg + cleans the tmpdir.
@ -102,6 +136,11 @@ type HLSSessionConfig struct {
Quality string // "2160p"|"1080p"|"720p"|"480p"|"original"|""
AudioIndex int // 0-based ffmpeg audio stream selection (-map 0:a:N). -1 = default.
Transcode TranscodeRuntime
// Cache is an optional persistent segment cache keyed by (source, quality,
// audio). When set, completed encodes are kept across sessions so re-plays
// of the same file at the same quality skip ffmpeg entirely. nil disables
// caching (per-session tmpdir, deleted on Close — original behavior).
Cache *HLSCache
}
// HLSSession owns a tmpdir + ffmpeg subprocess producing HLS fragments.
@ -133,14 +172,29 @@ type HLSSession struct {
restartCount int // bounded auto-restart counter (resets on Close)
lastRestartAt time.Time
// readyCond + readyMax track which segments ffmpeg has finished writing.
// Handlers waiting on a future segment block on readyCond until the
// poller advances readyMax past their index (or ffmpeg exits).
// readyCh + readyMax track how many segments ffmpeg has finished writing.
// readyMax is a COUNT (not an index): readyMax=N means seg-0 … seg-(N-1)
// are fully on disk. A handler waiting on `idx` blocks until
// `idx < readyMax` (segment idx is present). The pollSegments goroutine
// advances readyMax and re-creates readyCh on every step.
readyMu sync.Mutex
readyMax int // highest segment index whose .m4s file is fully written
readyMax int
exitErr error
exited bool
readyCh chan struct{} // closed + replaced each time readyMax advances
// Persistent cache state. cache==nil means caching disabled for this session.
// fromCache=true means the session is replaying a completed encode and no
// ffmpeg subprocess was spawned. writerLockHeld=true means this session
// owns the per-key TryAcquireWriter claim — Close must ReleaseWriter.
// subsDone closes when the subtitle extractor goroutine returns (or is
// nil when the source had no subtitle tracks); MarkComplete waits on it
// so a HIT replay never serves partial .vtt files.
cache *HLSCache
cacheKey string
fromCache bool
writerLockHeld bool
subsDone chan struct{}
}
// hlsSeekAhead is how many segments past the writer's current position the
@ -241,6 +295,9 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
if cfg.SessionID == "" {
return nil, errors.New("hls: empty session id")
}
if !validSessionID.MatchString(cfg.SessionID) {
return nil, errors.New("hls: invalid session id")
}
if cfg.SourcePath == "" {
return nil, errors.New("hls: empty source path")
}
@ -262,18 +319,78 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
return nil, errors.New("hls: source has no duration")
}
tmpDir := filepath.Join(hlsTmpDirRoot(), cfg.SessionID)
// Resolve tmpDir + cache placement. Three states:
// 1. cache disabled → per-session tmpdir, deleted on Close.
// 2. cache HIT (.complete found) → read from cache dir, no ffmpeg, Pin.
// 3. cache MISS, writer-lock OK → ffmpeg writes to cache dir, Pin + writer-lock.
// 4. cache MISS, writer-lock NO → another session already writing this
// key; fall back to private per-session tmpdir
// (no caching for this session — second-writer
// would corrupt the first one's segments).
var (
tmpDir string
cacheKey string
fromCache bool
writerLockHeld bool
)
if cfg.Cache != nil {
cacheKey = cfg.Cache.KeyFor(cfg.SourcePath, cfg.Quality, cfg.AudioIndex)
// Integrity gate: HasComplete just stats the marker. If init.mp4 or
// the last segment vanished (external rm, partial-disk failure), we
// can't actually serve a HIT — drop the dir and re-encode.
segCountForVerify := segmentCountForDuration(probe.DurationSec)
if cfg.Cache.HasComplete(cacheKey) && !cfg.Cache.VerifyComplete(cacheKey, segCountForVerify) {
log.Printf("[hls %s] cache %s sealed but failed integrity check — re-encoding",
shortHLSID(cfg.SessionID), cacheKey)
_ = cfg.Cache.Invalidate(cacheKey)
}
if cfg.Cache.HasComplete(cacheKey) {
// HIT: read-only replay — many concurrent HITs are fine.
tmpDir = cfg.Cache.DirFor(cacheKey)
cfg.Cache.Pin(cacheKey)
fromCache = true
cfg.Cache.RecordHit()
_ = cfg.Cache.Touch(cacheKey)
} else if cfg.Cache.TryAcquireWriter(cacheKey) {
tmpDir = cfg.Cache.DirFor(cacheKey)
cfg.Cache.Pin(cacheKey)
writerLockHeld = true
cfg.Cache.RecordMiss()
} else {
// Another session is writing this key — fall back to private
// dir so we don't trample its segments.
log.Printf("[hls %s] cache key %s busy, falling back to per-session tmpdir",
shortHLSID(cfg.SessionID), cacheKey)
tmpDir = filepath.Join(hlsTmpDirRoot(), cfg.SessionID)
cacheKey = "" // disable caching for this session
cfg.Cache.RecordMiss()
}
} else {
tmpDir = filepath.Join(hlsTmpDirRoot(), cfg.SessionID)
}
cleanupOnError := func() {
if cfg.Cache != nil && cacheKey != "" {
cfg.Cache.Unpin(cacheKey)
if writerLockHeld {
cfg.Cache.ReleaseWriter(cacheKey)
_ = cfg.Cache.Invalidate(cacheKey)
}
} else {
_ = os.RemoveAll(tmpDir)
}
}
if err := os.MkdirAll(filepath.Join(tmpDir, "video"), 0o755); err != nil {
cleanupOnError()
return nil, fmt.Errorf("hls: mkdir video: %w", err)
}
if err := os.MkdirAll(filepath.Join(tmpDir, "subs"), 0o755); err != nil {
cleanupOnError()
return nil, fmt.Errorf("hls: mkdir subs: %w", err)
}
segCount := int((probe.DurationSec + float64(hlsSegmentDuration) - 1) / float64(hlsSegmentDuration))
if segCount < 1 {
segCount = 1
}
segCount := segmentCountForDuration(probe.DurationSec)
s := &HLSSession{
cfg: cfg,
@ -284,10 +401,30 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
startedAt: time.Now(),
lastTouch: time.Now(),
readyCh: make(chan struct{}),
cache: cfg.Cache,
cacheKey: cacheKey,
fromCache: fromCache,
writerLockHeld: writerLockHeld,
}
s.manifestVideo = renderVideoPlaylist(probe.DurationSec, segCount)
s.manifestRoot = renderMasterPlaylist(probe, cfg.Quality)
// Cache HIT: every segment + init.mp4 is already on disk. Skip ffmpeg
// entirely and mark readyMax so handlers don't wait. Background subtitle
// extraction is also unnecessary — subs were extracted on the original run.
if fromCache {
s.readyMu.Lock()
s.readyMax = segCount - 1
s.exited = true
close(s.readyCh)
s.readyCh = nil
s.readyMu.Unlock()
log.Printf("[hls %s] cache HIT %s: %s, %.1fs, %d segs (quality=%s)",
shortHLSID(cfg.SessionID), cacheKey, filepath.Base(cfg.SourcePath),
probe.DurationSec, segCount, coalesce(cfg.Quality, "auto"))
return s, nil
}
// Spawn ffmpeg under a dedicated context so Close() can kill it without
// touching the parent ctx.
ffCtx, cancel := context.WithCancel(context.Background())
@ -297,7 +434,7 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
cmd.Stderr = &hlsStderrCapture{owner: s}
if err := cmd.Start(); err != nil {
cancel()
_ = os.RemoveAll(tmpDir)
cleanupOnError()
return nil, fmt.Errorf("hls: start ffmpeg: %w", err)
}
s.cmd = cmd
@ -306,12 +443,30 @@ func StartHLSSession(ctx context.Context, cfg HLSSessionConfig) (*HLSSession, er
go s.pollSegments(ffCtx)
if len(probe.SubtitleTracks) > 0 {
go s.extractSubtitles(ffCtx)
s.subsDone = make(chan struct{})
go func() {
defer close(s.subsDone)
s.extractSubtitles(ffCtx)
}()
}
log.Printf("[hls %s] started: %s, %.1fs, %d segs (quality=%s)",
cachedNote := ""
if cfg.Cache != nil {
cachedNote = fmt.Sprintf(" (cache-miss %s)", cacheKey)
}
// Surface the encoder profile so a "first-start was slow" report can be
// triaged from the agent log alone — `encoder=libx264 accel=none` means
// the user's ffmpeg has no HW encoders compiled in, which is the most
// common root cause (linuxbrew, default brew formula on macOS).
profile := ResolveEncoderProfile(cfg.Transcode.HWAccel, cfg.Transcode.Preset)
presetNote := ""
if profile.Preset != "" {
presetNote = " preset=" + profile.Preset
}
log.Printf("[hls %s] started: %s, %.1fs, %d segs (quality=%s, encoder=%s accel=%s%s)%s",
shortHLSID(cfg.SessionID), filepath.Base(cfg.SourcePath),
probe.DurationSec, segCount, coalesce(cfg.Quality, "auto"))
probe.DurationSec, segCount, coalesce(cfg.Quality, "auto"),
profile.Codec, string(cfg.Transcode.HWAccel), presetNote, cachedNote)
return s, nil
}
@ -364,6 +519,28 @@ func (s *HLSSession) ProbeInfo() map[string]any {
}
}
// ReadyCount returns how many segments are currently fully on disk.
// Caller can `>= 1` it to check whether seg-0 has landed (and so the
// player can be told to attach). For cache-HIT sessions this is always
// `segmentCount` from the moment StartHLSSession returns.
func (s *HLSSession) ReadyCount() int {
s.readyMu.Lock()
defer s.readyMu.Unlock()
return s.readyMax
}
// FromCache reports whether this session was served from the HLS cache
// (no ffmpeg subprocess spawned). Used by ready-watcher logic to short-
// circuit polling — a cache HIT is ready the moment we return.
func (s *HLSSession) FromCache() bool { return s.fromCache }
// IsClosed reports whether Close() has been invoked. Exposed (vs the
// internal isClosed) so external watchers — the ready-webhook
// goroutine in cmd/daemon.go — can short-circuit polling on a session
// that was torn down through a different code path (registry replace,
// idle sweep) without racing on the unexported helper.
func (s *HLSSession) IsClosed() bool { return s.isClosed() }
// MasterPlaylist returns the rendered master.m3u8 contents.
func (s *HLSSession) MasterPlaylist() string { return s.manifestRoot }
@ -384,8 +561,15 @@ func (s *HLSSession) Touch() {
s.mu.Unlock()
}
// Close stops ffmpeg, deletes the tmpdir, and prevents further requests from
// blocking on segment readiness. Idempotent.
// Close stops ffmpeg and prevents further requests from blocking on segment
// readiness. Idempotent.
//
// Disk lifecycle:
// - cache disabled → delete tmpDir (original behavior).
// - cache enabled + this session was a HIT → keep dir, just unpin.
// - cache enabled + this was a write session → if ffmpeg exited cleanly and
// every segment is on disk, persist with .complete and keep dir. Otherwise
// drop the dir so a half-written cache doesn't survive into the next play.
func (s *HLSSession) Close() error {
s.mu.Lock()
if s.closed {
@ -406,7 +590,47 @@ func (s *HLSSession) Close() error {
s.readyCh = nil
}
s.exited = true
exitErr := s.exitErr
s.readyMu.Unlock()
if s.cache != nil && s.cacheKey != "" {
defer s.cache.Unpin(s.cacheKey)
if s.writerLockHeld {
defer s.cache.ReleaseWriter(s.cacheKey)
}
if s.fromCache {
log.Printf("[hls %s] closed (cache reuse)", shortHLSID(s.cfg.SessionID))
return nil
}
// Wait briefly for the subtitle extractor to finish so a cached
// replay never serves half-written .vtt files. Bounded so a stuck
// extractor can't block Close indefinitely; on timeout we treat
// the cache as incomplete and drop it.
subsOK := true
if s.subsDone != nil {
select {
case <-s.subsDone:
case <-time.After(15 * time.Second):
log.Printf("[hls %s] subtitle extractor timeout — not caching", shortHLSID(s.cfg.SessionID))
subsOK = false
}
}
if subsOK && exitErr == nil && s.allSegmentsPresent() {
if err := s.cache.MarkComplete(s.cacheKey); err == nil {
log.Printf("[hls %s] cache persisted %s", shortHLSID(s.cfg.SessionID), s.cacheKey)
return nil
} else {
log.Printf("[hls %s] cache persist failed: %v", shortHLSID(s.cfg.SessionID), err)
}
}
// Partial / failed → drop so we re-encode next time.
if err := s.cache.Invalidate(s.cacheKey); err != nil {
log.Printf("[hls %s] cache invalidate failed: %v", shortHLSID(s.cfg.SessionID), err)
}
log.Printf("[hls %s] closed (cache discarded)", shortHLSID(s.cfg.SessionID))
return nil
}
if tmpDir != "" {
_ = os.RemoveAll(tmpDir)
}
@ -414,6 +638,31 @@ func (s *HLSSession) Close() error {
return nil
}
// allSegmentsPresent reports whether every expected segment (and init.mp4) is
// on disk AND validated by the segment poller. Used to decide whether a
// finished session is cacheable. We trust readyMax (advanced by pollSegments
// only after the next segment exists, proving the predecessor is fully closed)
// over a naive Size>0 stat that could accept truncated mid-write files.
func (s *HLSSession) allSegmentsPresent() bool {
if fi, err := os.Stat(filepath.Join(s.tmpDir, "video", "init.mp4")); err != nil || fi.Size() == 0 {
return false
}
s.readyMu.Lock()
readyMax := s.readyMax
s.readyMu.Unlock()
if readyMax < s.segmentCount-1 {
return false
}
for i := 0; i < s.segmentCount; i++ {
path := filepath.Join(s.tmpDir, "video", fmt.Sprintf("seg-%d.m4s", i))
fi, err := os.Stat(path)
if err != nil || fi.Size() == 0 {
return false
}
}
return true
}
// waitFFmpeg reaps the ffmpeg process and records its exit error for handlers.
//
// Auto-restart supervisor: if ffmpeg crashes (non-graceful exit) and the
@ -714,8 +963,10 @@ func (s *HLSSession) restartFromSegment(targetIdx int) error {
time.Sleep(50 * time.Millisecond)
}
// Build args for the new ffmpeg with -ss offset.
startSec := float64(targetIdx * hlsSegmentDuration)
// Build args for the new ffmpeg with -ss offset. Segments are non-uniform
// (seg-0 is hlsInitSegmentDuration s, the rest are hlsSegmentDuration s),
// so use segmentStartSec for the seek time instead of multiplying.
startSec := segmentStartSec(targetIdx)
args := buildHLSFFmpegArgsAt(s.cfg, s.probe, s.tmpDir, targetIdx, startSec)
ffCtx, cancel := context.WithCancel(context.Background())
@ -780,23 +1031,77 @@ func buildHLSFFmpegArgs(cfg HLSSessionConfig, probe *StreamProbe, tmpDir string)
return buildHLSFFmpegArgsAt(cfg, probe, tmpDir, 0, 0)
}
// EncoderProfile names the codec + preset + decoder hint combination the HLS
// pipeline picks for the given hardware backend + transcode config. Exposed
// so callers can log the chosen encoder before ffmpeg launches and so both
// the demuxer-side `-hwaccel` flag and the encoder-side argv stay in sync
// (otherwise the two switches in buildHLSFFmpegArgsAt could silently drift
// when adding a new backend).
type EncoderProfile struct {
Codec string // ffmpeg encoder name (e.g. "h264_nvenc", "libx264")
Preset string // preset string, or "" when the codec has no preset knob
DecodeHwAccel string // ffmpeg `-hwaccel` value (e.g. "cuda", "qsv", "vaapi"), or ""
}
// ResolveEncoderProfile mirrors the codec + preset selection inside
// buildHLSFFmpegArgsAt so callers (registry, log lines, diagnostic
// endpoints) can know what ffmpeg will be told to do without parsing argv.
//
// The configured preset is libx264-specific by vocabulary (ultrafast…
// veryslow). Passing it through to NVENC / QSV would have ffmpeg reject
// the argv (NVENC uses p1-p7, QSV uses its own subset). So vendor encoders
// always use their hardcoded vendor preset and ignore configuredPreset.
// VideoToolbox has no preset knob at all.
//
// DecodeHwAccel mirrors the encoder family — `-hwaccel cuda` for NVENC,
// `-hwaccel qsv` for QSV, `-hwaccel vaapi` for VAAPI. We intentionally
// do NOT pass `-hwaccel_output_format vaapi`: that pins decoded frames
// to GPU memory, but our filter chain (scale/format/setparams) runs on
// CPU and can't consume VAAPI surfaces. Keeping output frames on CPU
// makes the filter chain work and the VAAPI encoder still benefits from
// HW-accelerated DECODE on the input side.
func ResolveEncoderProfile(hw HWAccel, configuredPreset string) EncoderProfile {
codec := hw.FFmpegVideoCodec("h264")
switch codec {
case "libx264":
preset := configuredPreset
if preset == "" {
preset = "superfast"
}
return EncoderProfile{Codec: codec, Preset: preset, DecodeHwAccel: ""}
case "h264_nvenc":
return EncoderProfile{Codec: codec, Preset: "p3", DecodeHwAccel: "cuda"}
case "h264_qsv":
return EncoderProfile{Codec: codec, Preset: "veryfast", DecodeHwAccel: "qsv"}
case "h264_vaapi":
return EncoderProfile{Codec: codec, Preset: "", DecodeHwAccel: "vaapi"}
case "h264_videotoolbox":
// No preset knob for VideoToolbox; the speed/quality dial is `-q:v`.
// VideoToolbox uses per-encoder flags rather than a demuxer hint.
return EncoderProfile{Codec: codec, Preset: "", DecodeHwAccel: ""}
}
// Unknown / future codecs: software path.
return EncoderProfile{Codec: codec, Preset: "", DecodeHwAccel: ""}
}
// buildHLSFFmpegArgsAt returns the argv for an HLS encode that starts at the
// given segment index (`-ss <startSec>`) and writes segments numbered from
// startIdx so they slot into the existing manifest at the correct position.
// `-output_ts_offset` keeps the segment PTS aligned with manifest timeline.
func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir string, startIdx int, startSec float64) []string {
hwHint := cfg.Transcode.HWAccel
profile := ResolveEncoderProfile(cfg.Transcode.HWAccel, cfg.Transcode.Preset)
args := []string{"-y", "-hide_banner", "-loglevel", "warning"}
switch hwHint {
case HWAccelNVENC:
args = append(args, "-hwaccel", "cuda")
case HWAccelQSV:
args = append(args, "-hwaccel", "qsv")
case HWAccelVAAPI:
args = append(args, "-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi")
case HWAccelNone, HWAccelVideoToolbox:
// No demuxer-side hint.
// Demuxer-side HW-decode hint. Sourced from the profile so a future
// codec/hint mismatch is impossible — the encoder + decode hint are
// computed once and stay coherent. Notably we do NOT add
// `-hwaccel_output_format vaapi` on the VAAPI path: that pins decoded
// frames to GPU memory but our CPU filter chain (scale, format,
// setparams) can't consume VAAPI surfaces. Letting frames flow on CPU
// keeps the filter chain working; the encoder still gets HW-accelerated
// decode on the input side.
if profile.DecodeHwAccel != "" {
args = append(args, "-hwaccel", profile.DecodeHwAccel)
}
// Seek before -i for fast keyframe-aligned start. The new ffmpeg writes
@ -826,24 +1131,54 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin
}
args = append(args, "-map", fmt.Sprintf("0:a:%d?", audioIdx))
// Video encode.
codec := hwHint.FFmpegVideoCodec("h264")
// Video encode. Codec + preset come from the EncoderProfile resolved at
// the top of this function so the demuxer hint, the encoder, and the
// per-session log line all stay consistent.
//
// Defaults are biased for FIRST-START LATENCY over quality — the player
// blocks on seg-0 before the first frame paints, and a slow seg-0 is
// what users notice ("preparando sesión" stuck). Users who want better
// quality can override via `download.transcode.preset` in config.toml.
codec := profile.Codec
args = append(args, "-c:v", codec)
// Encoder-specific tuning. Each HW encoder takes a different "preset"
// vocabulary; libx264 uses ultrafast→placebo, NVENC uses p1→p7, QSV uses
// veryfast→veryslow, VAAPI/VideoToolbox don't expose presets.
switch codec {
case "libx264":
preset := cfg.Transcode.Preset
if preset == "" {
preset = "veryfast"
}
args = append(args, "-preset", preset)
// superfast = ~15-20% faster than veryfast at marginal quality loss
// for the bitrates we target (5-25 Mbps). For 4K software encodes
// this is the difference between ~3 s and ~2.5 s per segment on a
// recent x86 CPU. `-threads 0` is libx264's default but explicit
// helps when the user has set GOMAXPROCS.
args = append(args, "-preset", profile.Preset, "-threads", "0")
case "h264_nvenc":
// p4 = balanced quality/speed; p1 fastest, p7 highest quality.
args = append(args, "-preset", "p4", "-rc", "vbr", "-tune", "hq")
// p3 + tune=ll trades ~0.3 dB PSNR for 1.5-2× faster encode vs the
// previous p4 + tune=hq pair — first-segment encode drops from
// ~1.5 s to ~0.8 s on RTX-class hardware.
args = append(args, "-preset", profile.Preset, "-rc", "vbr", "-tune", "ll")
case "h264_qsv":
args = append(args, "-preset", "medium", "-look_ahead", "0")
// veryfast is the fastest realistic QSV preset; medium was too
// conservative for first-start. look_ahead=0 keeps the encoder
// truly low-latency (no rate-control look-ahead window).
args = append(args, "-preset", profile.Preset, "-look_ahead", "0")
case "h264_videotoolbox":
// VideoToolbox has no "preset" knob; `-realtime` flips into the
// low-latency path used by FaceTime. We let the `-b:v / -maxrate
// / -bufsize` block (added later in this function) drive rate
// control — adding `-q:v` here would conflict because ffmpeg's
// videotoolbox encoder treats `-b:v` as authoritative and
// silently ignores `-q:v`, so the constant-quality knob never
// took effect anyway.
args = append(args, "-realtime", "1")
case "h264_vaapi":
// h264_vaapi has no preset knob. Bitrate args (set later) drive
// rate control. Add `-vaapi_device /dev/dri/renderD128` so the
// encoder doesn't fall back to a NULL device on multi-GPU hosts
// where the default render node is a non-VAAPI GPU (an Nvidia
// dGPU's render node, etc.). The filter chain below switches to
// `format=nv12,hwupload` so frames land on the right VAAPI
// surface before the encoder; we intentionally avoid scale_vaapi
// because mesa 25 + Raphael iGPU emits "Cannot allocate memory"
// per session start, polluting logs even though encode succeeds.
args = append(args, "-vaapi_device", "/dev/dri/renderD128")
}
// Derive H.264 level from the actual output height. A fixed "4.0" caps the
// encoder at 1080p — anything taller (1440p, 4K source on quality=original)
@ -860,7 +1195,17 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin
}
args = append(args, "-profile:v", "main", "-level:v", H264LevelForHeight(outputHeight))
bitrate := qcap.VideoBitrate
// Bitrate must match the level libx264 actually picks for outputHeight,
// not the qcap target for the user's requested label. If a user asks for
// "2160p" on a 1080p source, qcap.VideoBitrate is 25 Mbps but the level
// (derived from outputHeight=1080) is 4.0, which rejects bitrates >20 Mbps
// with "VBV bitrate (25000) > level limit (20000)". Re-derive the cap
// from the effective height so the (level, bitrate) pair stays coherent.
effectiveCap := capForHeight(outputHeight)
bitrate := effectiveCap.VideoBitrate
if bitrate == "" {
bitrate = qcap.VideoBitrate
}
if bitrate == "" {
bitrate = cfg.Transcode.VideoBitrate
}
@ -884,14 +1229,32 @@ func buildHLSFFmpegArgsAt(cfg HLSSessionConfig, probe *StreamProbe, tmpDir strin
if maxH == 0 {
maxH = cfg.Transcode.MaxHeight
}
// VAAPI needs frames as nv12 VAAPI surfaces before the encoder. We do
// scale + format conversion on CPU then `hwupload` once at the end —
// skips the mesa 25 + Raphael iGPU "Cannot allocate memory" log spam
// that scale_vaapi triggers per-session-start while still delivering
// the encoder a GPU surface. setparams is dropped because VAAPI
// surfaces don't expose VUI fields the way libx264 does; the encoder
// records its own color metadata via the source PTS chain.
pixFormat := "yuv420p"
hwUploadTail := ""
colorTail := ",setparams=colorspace=bt709:color_trc=bt709:color_primaries=bt709:range=tv"
if codec == "h264_vaapi" {
pixFormat = "nv12"
hwUploadTail = ",hwupload"
colorTail = ""
}
var filterChain string
if maxH > 0 && probe.Height > maxH {
filterChain = fmt.Sprintf(
"scale=-2:%d:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p,setparams=colorspace=bt709:color_trc=bt709:color_primaries=bt709:range=tv",
maxH,
"scale=-2:%d:force_original_aspect_ratio=decrease,scale=trunc(iw/2)*2:trunc(ih/2)*2,format=%s%s%s",
maxH, pixFormat, colorTail, hwUploadTail,
)
} else {
filterChain = "scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p,setparams=colorspace=bt709:color_trc=bt709:color_primaries=bt709:range=tv"
filterChain = fmt.Sprintf(
"scale=trunc(iw/2)*2:trunc(ih/2)*2,format=%s%s%s",
pixFormat, colorTail, hwUploadTail,
)
}
args = append(args, "-vf", filterChain)
@ -964,6 +1327,10 @@ func (s *HLSSession) extractSubtitles(ctx context.Context) {
// renderVideoPlaylist builds the VOD media playlist for the video stream.
// Segment count is derived from the source duration — the player learns the
// total timeline from the manifest before any segment is fetched.
//
// seg-0 is the short init segment (hlsInitSegmentDuration s); seg-1 onward
// are hlsSegmentDuration s each. The last segment may be shorter than the
// nominal duration when (duration - init) doesn't divide evenly.
func renderVideoPlaylist(durationSec float64, segCount int) string {
var b strings.Builder
b.WriteString("#EXTM3U\n")
@ -974,7 +1341,7 @@ func renderVideoPlaylist(durationSec float64, segCount int) string {
b.WriteString(`#EXT-X-MAP:URI="init.mp4"` + "\n")
remaining := durationSec
for i := 0; i < segCount; i++ {
segDur := float64(hlsSegmentDuration)
segDur := float64(segmentDurationFor(i))
if remaining < segDur {
segDur = remaining
}

View file

@ -0,0 +1,410 @@
package engine
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"sync"
"sync/atomic"
"time"
)
// HLSCache persists transcoded HLS segments per (source, quality, audio) so a
// second play of the same file at the same quality skips ffmpeg entirely.
//
// Layout on disk:
//
// {root}/{key}/init.mp4
// {root}/{key}/seg-0.m4s
// {root}/{key}/seg-N.m4s
// {root}/{key}/.complete
//
// Atomicity: the .complete marker is written only when ffmpeg exits 0 AND all
// segments are on disk. A dir without .complete is treated as a partial run —
// next session can reuse the segments already present, ffmpeg fills the gaps.
//
// Concurrency: Pin/Unpin increments a ref counter per key so the LRU sweeper
// never evicts a directory that an active session is reading from.
type HLSCache struct {
root string
maxBytes int64
mu sync.Mutex
refs map[string]int
writers map[string]bool // exclusive ffmpeg writer per key; nil entries are absent
// Counters surfaced via Stats() — useful for /api/internal/agent/cache-stats
// and for the sweeper's daily log line. atomic so RecordHit/RecordMiss are
// safe to call from any goroutine without taking the cache mutex.
hits atomic.Uint64
misses atomic.Uint64
}
const (
hlsCacheCompleteMarker = ".complete"
// hlsCacheMinBudgetGB clamps absurd / zero / negative SizeGB values to
// a sane floor. NOT a guarantee that any single encode fits — a long
// 4K HEVC re-encode can exceed it. Operators should set size_gb based
// on their actual workload.
hlsCacheMinBudgetGB = 1
// hlsCacheStartupOrphanAge: directories without .complete older than
// this are removed on cache startup. Long enough that a daemon crash
// during an in-progress encode (which legitimately leaves a partial
// dir) doesn't get nuked too aggressively if the daemon restarts fast.
hlsCacheStartupOrphanAge = 10 * time.Minute
)
// NewHLSCache creates the cache rooted at the given dir with a size budget in
// gigabytes. A budget < hlsCacheMinBudgetGB is clamped up so a single play
// doesn't get instantly evicted mid-stream.
func NewHLSCache(root string, sizeGB int) (*HLSCache, error) {
if root == "" {
return nil, errors.New("hls_cache: empty root")
}
if sizeGB < hlsCacheMinBudgetGB {
sizeGB = hlsCacheMinBudgetGB
}
if err := os.MkdirAll(root, 0o755); err != nil {
return nil, fmt.Errorf("hls_cache: mkdir root: %w", err)
}
c := &HLSCache{
root: root,
maxBytes: int64(sizeGB) * 1024 * 1024 * 1024,
refs: make(map[string]int),
writers: make(map[string]bool),
}
// Reap dirs left over from a crashed encode. A dir without .complete that
// hasn't been touched recently was almost certainly orphaned by an
// ungraceful daemon exit — keeping it just feeds the unbounded growth
// pattern the hourly LRU is too slow to contain.
if removed, err := c.cleanStartupOrphans(); err != nil {
log.Printf("[hls_cache] startup orphan cleanup: %v", err)
} else if removed > 0 {
log.Printf("[hls_cache] startup: removed %d orphan dir(s) without .complete", removed)
}
return c, nil
}
// cleanStartupOrphans removes cache subdirectories that lack a .complete
// marker AND haven't been modified within hlsCacheStartupOrphanAge. Called
// once at construction. Safe at startup because no sessions are active yet,
// so Pin can't race with us.
func (c *HLSCache) cleanStartupOrphans() (int, error) {
entries, err := os.ReadDir(c.root)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, err
}
cutoff := time.Now().Add(-hlsCacheStartupOrphanAge)
removed := 0
for _, e := range entries {
if !e.IsDir() {
continue
}
dir := filepath.Join(c.root, e.Name())
if _, err := os.Stat(filepath.Join(dir, hlsCacheCompleteMarker)); err == nil {
continue // sealed, keep
}
info, err := e.Info()
if err != nil {
continue
}
if info.ModTime().After(cutoff) {
continue // too recent — might be a daemon that just restarted mid-encode
}
if err := os.RemoveAll(dir); err == nil {
removed++
}
}
return removed, nil
}
// TryAcquireWriter attempts to claim exclusive ffmpeg-write access to a key.
// Returns true on success — the caller is then responsible for ReleaseWriter
// when ffmpeg exits / fails. Returns false if another session is already
// writing this key, in which case the caller must fall back to a private
// per-session tmpdir (no caching for that session).
func (c *HLSCache) TryAcquireWriter(key string) bool {
c.mu.Lock()
defer c.mu.Unlock()
if c.writers[key] {
return false
}
c.writers[key] = true
return true
}
// ReleaseWriter releases the writer claim acquired via TryAcquireWriter.
// Idempotent on unknown keys.
func (c *HLSCache) ReleaseWriter(key string) {
c.mu.Lock()
delete(c.writers, key)
c.mu.Unlock()
}
// KeyFor derives a stable cache key for (source, quality, audioIndex). Using
// the absolute source path means renaming a file invalidates the cache, which
// is correct — segment content is tied to the encoded source.
func (c *HLSCache) KeyFor(sourcePath, quality string, audioIndex int) string {
abs, err := filepath.Abs(sourcePath)
if err != nil {
abs = sourcePath
}
h := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d", abs, quality, audioIndex)))
return hex.EncodeToString(h[:8]) // 16 hex chars — collision-safe enough for per-host cache
}
// DirFor returns the on-disk directory for a cache key. Caller is responsible
// for creating it.
func (c *HLSCache) DirFor(key string) string {
return filepath.Join(c.root, key)
}
// HasComplete returns true when the .complete marker is present, meaning the
// directory holds a full set of segments from a successful encode.
func (c *HLSCache) HasComplete(key string) bool {
if _, err := os.Stat(filepath.Join(c.DirFor(key), hlsCacheCompleteMarker)); err == nil {
return true
}
return false
}
// MarkComplete writes the .complete marker. Call only after verifying ffmpeg
// exited cleanly AND every expected segment is on disk. The dir must already
// exist — StartHLSSession created it on the writer path.
func (c *HLSCache) MarkComplete(key string) error {
return os.WriteFile(filepath.Join(c.DirFor(key), hlsCacheCompleteMarker), nil, 0o644)
}
// RecordHit increments the hit counter; called by StartHLSSession on a
// cache-HIT path.
func (c *HLSCache) RecordHit() { c.hits.Add(1) }
// RecordMiss increments the miss counter; called when a session has to
// encode from scratch (or fails an integrity check on a stale HIT).
func (c *HLSCache) RecordMiss() { c.misses.Add(1) }
// CacheStats is a snapshot of the cache's runtime counters + on-disk size.
// The size fields are best-effort (computed via dirSize) so callers paying
// for them should cache the result, not poll in a hot loop.
type CacheStats struct {
Hits uint64
Misses uint64
EntryCount int
TotalBytes int64
}
// Stats returns a snapshot of the cache counters and size. Walks the root
// to total disk usage — O(N segments). Call at most every few minutes.
func (c *HLSCache) Stats() CacheStats {
s := CacheStats{
Hits: c.hits.Load(),
Misses: c.misses.Load(),
}
entries, err := os.ReadDir(c.root)
if err != nil {
return s
}
for _, e := range entries {
if !e.IsDir() {
continue
}
size, err := dirSize(filepath.Join(c.root, e.Name()))
if err != nil {
continue
}
s.EntryCount++
s.TotalBytes += size
}
return s
}
// hitRatePercent returns the current hit/(hit+miss) percentage rounded to
// the nearest int; 0 when no calls have been recorded.
func (c *HLSCache) hitRatePercent() int {
h := c.hits.Load()
m := c.misses.Load()
total := h + m
if total == 0 {
return 0
}
return int((h*100 + total/2) / total)
}
// VerifyComplete checks that the .complete marker is present AND the
// essential files (init.mp4 + last segment) exist with non-zero size. A
// dir that passes HasComplete but fails VerifyComplete is treated as
// corrupted — typically external `rm` or a partial-disk-failure scenario.
// When it returns false, callers should Invalidate and re-encode.
func (c *HLSCache) VerifyComplete(key string, segmentCount int) bool {
if !c.HasComplete(key) {
return false
}
dir := c.DirFor(key)
if fi, err := os.Stat(filepath.Join(dir, "video", "init.mp4")); err != nil || fi.Size() == 0 {
return false
}
if segmentCount > 0 {
lastSeg := filepath.Join(dir, "video", fmt.Sprintf("seg-%d.m4s", segmentCount-1))
if fi, err := os.Stat(lastSeg); err != nil || fi.Size() == 0 {
return false
}
}
return true
}
// Pin increments the ref counter for a key. The sweeper checks this before
// evicting, so a pinned dir is safe even if its mtime is old.
func (c *HLSCache) Pin(key string) {
c.mu.Lock()
c.refs[key]++
c.mu.Unlock()
}
// Unpin decrements; safe to call on unknown keys (no-op).
func (c *HLSCache) Unpin(key string) {
c.mu.Lock()
if c.refs[key] > 0 {
c.refs[key]--
if c.refs[key] == 0 {
delete(c.refs, key)
}
}
c.mu.Unlock()
}
func (c *HLSCache) isPinned(key string) bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.refs[key] > 0
}
// Touch updates the directory mtime so LRU picks fresher entries as recently
// used. Called when a session starts reading from a cached dir.
func (c *HLSCache) Touch(key string) error {
dir := c.DirFor(key)
now := time.Now()
return os.Chtimes(dir, now, now)
}
// Sweep enforces the size budget by deleting the least-recently-used cache
// dirs (ignoring pinned ones) until the total size is at or below maxBytes.
// Returns the number of bytes freed.
func (c *HLSCache) Sweep() (int64, error) {
entries, err := os.ReadDir(c.root)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, fmt.Errorf("hls_cache: read root: %w", err)
}
type item struct {
key string
path string
size int64
mtime time.Time
}
items := make([]item, 0, len(entries))
var total, pinned int64
for _, e := range entries {
if !e.IsDir() {
continue
}
info, err := e.Info()
if err != nil {
continue
}
key := e.Name()
path := filepath.Join(c.root, key)
size, err := dirSize(path)
if err != nil {
continue
}
items = append(items, item{key: key, path: path, size: size, mtime: info.ModTime()})
total += size
if c.isPinned(key) {
pinned += size
}
}
if total <= c.maxBytes {
return 0, nil
}
if pinned >= c.maxBytes {
// Every pinned byte already exceeds the budget — even evicting
// every unpinned dir won't bring us under. Warn loudly so the
// operator knows to bump size_gb (or kill the long-running session).
log.Printf("[hls_cache] warn: pinned bytes (%.1f MB) exceed budget (%.1f MB) — cannot enforce limit until sessions release",
float64(pinned)/(1024*1024), float64(c.maxBytes)/(1024*1024))
return 0, nil
}
// Oldest first.
sort.Slice(items, func(i, j int) bool {
return items[i].mtime.Before(items[j].mtime)
})
var freed int64
for _, it := range items {
if total-freed <= c.maxBytes {
break
}
if c.isPinned(it.key) {
continue
}
if err := os.RemoveAll(it.path); err != nil {
log.Printf("[hls_cache] evict %s failed: %v", it.key, err)
continue
}
log.Printf("[hls_cache] evicted %s (%.1f MB, age %s)",
it.key, float64(it.size)/(1024*1024), time.Since(it.mtime).Round(time.Second))
freed += it.size
}
return freed, nil
}
// StartSweeper kicks off the LRU sweeper goroutine. Cancels on ctx done.
// In addition to enforcing the size budget, logs a daily summary of hit-rate
// + disk usage so operators can see the cache's value at a glance.
func (c *HLSCache) StartSweeper(ctx context.Context, interval time.Duration) {
if interval <= 0 {
interval = time.Hour
}
go func() {
t := time.NewTicker(interval)
defer t.Stop()
statsTick := time.NewTicker(24 * time.Hour)
defer statsTick.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if _, err := c.Sweep(); err != nil {
log.Printf("[hls_cache] sweep error: %v", err)
}
case <-statsTick.C:
s := c.Stats()
log.Printf("[hls_cache] day-stats: hits=%d misses=%d ratio=%d%% entries=%d size=%.1fMB",
s.Hits, s.Misses, c.hitRatePercent(), s.EntryCount,
float64(s.TotalBytes)/(1024*1024))
}
}
}()
}
// Invalidate removes a cache entry — used when ffmpeg fails to encode the
// source so we don't reuse a half-written dir next time.
func (c *HLSCache) Invalidate(key string) error {
return os.RemoveAll(c.DirFor(key))
}

View file

@ -0,0 +1,134 @@
//go:build smoke
package engine
import (
"context"
"os/exec"
"path/filepath"
"testing"
"time"
)
// TestHLSCacheSmoke exercises the end-to-end cache flow against real ffmpeg:
// - First session encodes a 5s test pattern; expect MISS, ffmpeg runs,
// .complete written, MarkComplete logs.
// - Second session for identical (source, quality, audio); expect HIT,
// no ffmpeg, instant Start.
//
// Build tag `smoke` keeps it out of the default `go test ./...` run because
// it depends on a working ffmpeg/ffprobe and takes ~510 s.
//
// go test -tags=smoke -run TestHLSCacheSmoke -v ./internal/engine/
func TestHLSCacheSmoke(t *testing.T) {
ffmpeg, err := exec.LookPath("ffmpeg")
if err != nil {
t.Skipf("ffmpeg not on PATH: %v", err)
}
ffprobe, err := exec.LookPath("ffprobe")
if err != nil {
t.Skipf("ffprobe not on PATH: %v", err)
}
tmp := t.TempDir()
source := filepath.Join(tmp, "source.mp4")
t.Logf("generating 5 s test pattern → %s", source)
if out, err := exec.Command(ffmpeg,
"-y", "-loglevel", "error",
"-f", "lavfi", "-i", "testsrc=duration=5:size=640x480:rate=30",
"-f", "lavfi", "-i", "sine=frequency=1000:duration=5",
"-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p",
"-c:a", "aac",
source,
).CombinedOutput(); err != nil {
t.Fatalf("ffmpeg generate: %v\n%s", err, out)
}
cacheRoot := filepath.Join(tmp, "cache")
cache, err := NewHLSCache(cacheRoot, 1)
if err != nil {
t.Fatalf("NewHLSCache: %v", err)
}
cfg := HLSSessionConfig{
SessionID: "smoke1",
SourcePath: source,
FileName: "source.mp4",
Quality: "720p",
AudioIndex: 0,
Transcode: TranscodeRuntime{
FFmpegPath: ffmpeg,
FFprobePath: ffprobe,
Preset: "ultrafast",
},
Cache: cache,
}
// First run — expect MISS, ffmpeg runs.
t.Log("session 1: expect MISS")
t0 := time.Now()
s1, err := StartHLSSession(context.Background(), cfg)
if err != nil {
t.Fatalf("StartHLSSession #1: %v", err)
}
if s1.fromCache {
t.Fatal("session 1 reported cache HIT on a fresh cache")
}
// Wait for all segments to land. 5 s source @ 4 s segments → 2 segments.
deadline := time.Now().Add(60 * time.Second)
for {
s1.readyMu.Lock()
ready := s1.readyMax
exited := s1.exited
s1.readyMu.Unlock()
if ready >= s1.segmentCount-1 && exited {
break
}
if time.Now().After(deadline) {
_ = s1.Close()
t.Fatalf("session 1 didn't finish in 60 s (readyMax=%d/%d, exited=%v)",
ready, s1.segmentCount-1, exited)
}
time.Sleep(100 * time.Millisecond)
}
if err := s1.Close(); err != nil {
t.Fatalf("Close #1: %v", err)
}
encodeDur := time.Since(t0)
t.Logf("session 1: MISS completed in %s", encodeDur.Round(time.Millisecond))
key := cache.KeyFor(source, "720p", 0)
if !cache.HasComplete(key) {
t.Fatalf("cache.HasComplete(%s) is false after successful encode", key)
}
// Second run — expect HIT, no ffmpeg.
t.Log("session 2: expect HIT")
cfg.SessionID = "smoke2"
t1 := time.Now()
s2, err := StartHLSSession(context.Background(), cfg)
if err != nil {
t.Fatalf("StartHLSSession #2: %v", err)
}
if !s2.fromCache {
t.Fatal("session 2 should have reported cache HIT")
}
if s2.cmd != nil {
t.Fatal("session 2 should not have spawned ffmpeg (s.cmd != nil)")
}
hitDur := time.Since(t1)
t.Logf("session 2: HIT in %s (%.1f× faster than MISS)",
hitDur.Round(time.Millisecond), float64(encodeDur)/float64(hitDur))
if hitDur > 500*time.Millisecond {
t.Errorf("HIT path too slow: %s — expected <500 ms", hitDur)
}
if err := s2.Close(); err != nil {
t.Fatalf("Close #2: %v", err)
}
// After the HIT session closes, the cache dir + .complete must still exist.
if !cache.HasComplete(key) {
t.Fatal(".complete disappeared after HIT session closed")
}
}

View file

@ -0,0 +1,361 @@
package engine
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
"time"
)
func newTestCache(t *testing.T, sizeGB int) *HLSCache {
t.Helper()
root := t.TempDir()
c, err := NewHLSCache(root, sizeGB)
if err != nil {
t.Fatalf("NewHLSCache: %v", err)
}
return c
}
func TestKeyForStable(t *testing.T) {
c := newTestCache(t, 1)
k1 := c.KeyFor("/a/b/movie.mkv", "1080p", 0)
k2 := c.KeyFor("/a/b/movie.mkv", "1080p", 0)
if k1 != k2 {
t.Fatalf("expected stable keys, got %q vs %q", k1, k2)
}
if c.KeyFor("/a/b/movie.mkv", "720p", 0) == k1 {
t.Fatal("quality should change key")
}
if c.KeyFor("/a/b/movie.mkv", "1080p", 1) == k1 {
t.Fatal("audio index should change key")
}
if c.KeyFor("/x/y/other.mkv", "1080p", 0) == k1 {
t.Fatal("path should change key")
}
}
func TestMarkCompleteAndHas(t *testing.T) {
c := newTestCache(t, 1)
key := "abc123"
if c.HasComplete(key) {
t.Fatal("fresh cache should not report complete")
}
// Production callers create the dir during StartHLSSession; MarkComplete
// trusts that invariant and fails if the dir was wiped meanwhile.
if err := os.MkdirAll(c.DirFor(key), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := c.MarkComplete(key); err != nil {
t.Fatalf("MarkComplete: %v", err)
}
if !c.HasComplete(key) {
t.Fatal("after MarkComplete, HasComplete must be true")
}
}
func TestMarkCompleteFailsWithoutDir(t *testing.T) {
c := newTestCache(t, 1)
if err := c.MarkComplete("never-created"); err == nil {
t.Fatal("MarkComplete should error when dir doesn't exist")
}
}
func TestPinPreventsEviction(t *testing.T) {
c := newTestCache(t, 1) // 1 GB budget, but min clamp keeps it usable
c.maxBytes = 1024 // squeeze budget for the test
// Write two entries past the budget.
for i, key := range []string{"old", "new"} {
dir := c.DirFor(key)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
path := filepath.Join(dir, "seg.bin")
if err := os.WriteFile(path, make([]byte, 800), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
now := time.Now().Add(time.Duration(i) * time.Hour) // "old" mtime < "new"
_ = os.Chtimes(dir, now, now)
}
c.Pin("old") // protect the older one
freed, err := c.Sweep()
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if freed == 0 {
t.Fatal("expected some eviction")
}
if _, err := os.Stat(c.DirFor("old")); err != nil {
t.Fatal("pinned 'old' was evicted")
}
if _, err := os.Stat(c.DirFor("new")); err == nil {
t.Fatal("'new' should have been evicted to make room")
}
}
func TestSweepNoOpUnderBudget(t *testing.T) {
c := newTestCache(t, 1)
dir := c.DirFor("small")
_ = os.MkdirAll(dir, 0o755)
_ = os.WriteFile(filepath.Join(dir, "x"), []byte("tiny"), 0o644)
freed, err := c.Sweep()
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if freed != 0 {
t.Fatalf("expected 0 freed under budget, got %d", freed)
}
if _, err := os.Stat(dir); err != nil {
t.Fatal("under-budget entry was wrongly evicted")
}
}
func TestSweepEmptyRoot(t *testing.T) {
c := newTestCache(t, 1)
freed, err := c.Sweep()
if err != nil {
t.Fatalf("Sweep empty: %v", err)
}
if freed != 0 {
t.Fatalf("freed=%d, want 0", freed)
}
}
func TestInvalidateRemovesDir(t *testing.T) {
c := newTestCache(t, 1)
key := "drop"
dir := c.DirFor(key)
_ = os.MkdirAll(dir, 0o755)
_ = os.WriteFile(filepath.Join(dir, "x"), []byte("y"), 0o644)
if err := c.Invalidate(key); err != nil {
t.Fatalf("Invalidate: %v", err)
}
if _, err := os.Stat(dir); err == nil {
t.Fatal("dir still present after Invalidate")
}
}
func TestTouchUpdatesMtime(t *testing.T) {
c := newTestCache(t, 1)
key := "touch"
dir := c.DirFor(key)
_ = os.MkdirAll(dir, 0o755)
old := time.Now().Add(-2 * time.Hour)
_ = os.Chtimes(dir, old, old)
if err := c.Touch(key); err != nil {
t.Fatalf("Touch: %v", err)
}
info, err := os.Stat(dir)
if err != nil {
t.Fatalf("stat: %v", err)
}
if !info.ModTime().After(old.Add(time.Minute)) {
t.Fatalf("mtime not refreshed: %v", info.ModTime())
}
}
func TestPinUnpinSymmetry(t *testing.T) {
c := newTestCache(t, 1)
c.Pin("k")
c.Pin("k")
if !c.isPinned("k") {
t.Fatal("Pin twice should leave pinned")
}
c.Unpin("k")
if !c.isPinned("k") {
t.Fatal("Unpin once should keep pinned (refs=1)")
}
c.Unpin("k")
if c.isPinned("k") {
t.Fatal("Unpin twice should drop pin")
}
c.Unpin("k") // safe no-op
}
func TestConcurrentPinUnpin(t *testing.T) {
c := newTestCache(t, 1)
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c.Pin("race")
time.Sleep(time.Microsecond)
c.Unpin("race")
}()
}
wg.Wait()
if c.isPinned("race") {
t.Fatal("refs leaked")
}
}
func TestSweeperLoopExits(t *testing.T) {
c := newTestCache(t, 1)
ctx, cancel := context.WithCancel(context.Background())
c.StartSweeper(ctx, 10*time.Millisecond)
time.Sleep(30 * time.Millisecond)
cancel()
// If StartSweeper doesn't exit on cancel the test would leak a goroutine;
// the leak detector in the test runner will surface it.
time.Sleep(20 * time.Millisecond)
}
func TestMinBudgetClamp(t *testing.T) {
root := t.TempDir()
c, err := NewHLSCache(root, 0) // below floor
if err != nil {
t.Fatalf("NewHLSCache: %v", err)
}
if c.maxBytes != int64(hlsCacheMinBudgetGB)*1024*1024*1024 {
t.Fatalf("budget not clamped to min: got %d", c.maxBytes)
}
}
func TestTryAcquireWriterExclusive(t *testing.T) {
c := newTestCache(t, 1)
if !c.TryAcquireWriter("k") {
t.Fatal("first acquire should succeed")
}
if c.TryAcquireWriter("k") {
t.Fatal("second acquire for same key must fail")
}
if !c.TryAcquireWriter("other") {
t.Fatal("different key should not conflict")
}
c.ReleaseWriter("k")
if !c.TryAcquireWriter("k") {
t.Fatal("acquire after release should succeed")
}
c.ReleaseWriter("k")
c.ReleaseWriter("k") // idempotent
}
func TestStartupOrphanCleanup(t *testing.T) {
root := t.TempDir()
// Pre-seed: one sealed dir + one orphan old enough + one orphan fresh.
sealed := filepath.Join(root, "sealed")
_ = os.MkdirAll(sealed, 0o755)
_ = os.WriteFile(filepath.Join(sealed, hlsCacheCompleteMarker), nil, 0o644)
staleOrphan := filepath.Join(root, "stale_orphan")
_ = os.MkdirAll(staleOrphan, 0o755)
old := time.Now().Add(-2 * hlsCacheStartupOrphanAge)
_ = os.Chtimes(staleOrphan, old, old)
freshOrphan := filepath.Join(root, "fresh_orphan")
_ = os.MkdirAll(freshOrphan, 0o755)
if _, err := NewHLSCache(root, 1); err != nil {
t.Fatalf("NewHLSCache: %v", err)
}
if _, err := os.Stat(sealed); err != nil {
t.Fatal("sealed dir was wrongly removed")
}
if _, err := os.Stat(staleOrphan); err == nil {
t.Fatal("stale orphan should have been removed at startup")
}
if _, err := os.Stat(freshOrphan); err != nil {
t.Fatal("fresh orphan should be kept (might be a mid-restart encode)")
}
}
func TestHitMissCounters(t *testing.T) {
c := newTestCache(t, 1)
if s := c.Stats(); s.Hits != 0 || s.Misses != 0 {
t.Fatalf("fresh cache stats not zero: %+v", s)
}
c.RecordHit()
c.RecordHit()
c.RecordMiss()
s := c.Stats()
if s.Hits != 2 || s.Misses != 1 {
t.Fatalf("counters wrong: %+v", s)
}
// 2/3 = 67%
if got := c.hitRatePercent(); got != 67 {
t.Fatalf("hitRatePercent=%d, want 67", got)
}
}
func TestStatsEntryCount(t *testing.T) {
c := newTestCache(t, 1)
for _, k := range []string{"a", "b", "c"} {
dir := c.DirFor(k)
_ = os.MkdirAll(dir, 0o755)
_ = os.WriteFile(filepath.Join(dir, "x"), []byte("hello"), 0o644)
}
s := c.Stats()
if s.EntryCount != 3 {
t.Fatalf("EntryCount=%d, want 3", s.EntryCount)
}
if s.TotalBytes != 15 {
t.Fatalf("TotalBytes=%d, want 15", s.TotalBytes)
}
}
func TestVerifyCompleteRejectsMissingFiles(t *testing.T) {
c := newTestCache(t, 1)
key := "v"
dir := c.DirFor(key)
_ = os.MkdirAll(filepath.Join(dir, "video"), 0o755)
// No .complete yet → reject.
if c.VerifyComplete(key, 2) {
t.Fatal("VerifyComplete should reject without .complete")
}
// Mark complete but no files → reject.
if err := c.MarkComplete(key); err != nil {
t.Fatalf("MarkComplete: %v", err)
}
if c.VerifyComplete(key, 2) {
t.Fatal("VerifyComplete should reject when init.mp4 missing")
}
// Write init.mp4, last seg missing → reject.
_ = os.WriteFile(filepath.Join(dir, "video", "init.mp4"), []byte("..."), 0o644)
if c.VerifyComplete(key, 2) {
t.Fatal("VerifyComplete should reject when last segment missing")
}
// Write last seg → pass.
_ = os.WriteFile(filepath.Join(dir, "video", "seg-1.m4s"), []byte("..."), 0o644)
if !c.VerifyComplete(key, 2) {
t.Fatal("VerifyComplete should pass with all files present")
}
// Zero-size last seg → reject.
_ = os.WriteFile(filepath.Join(dir, "video", "seg-1.m4s"), nil, 0o644)
if c.VerifyComplete(key, 2) {
t.Fatal("VerifyComplete should reject zero-size last segment")
}
}
func TestSweepRespectsPinnedExceedsBudget(t *testing.T) {
c := newTestCache(t, 1)
c.maxBytes = 256 // squeeze
pinned := c.DirFor("pinned")
_ = os.MkdirAll(pinned, 0o755)
_ = os.WriteFile(filepath.Join(pinned, "x"), make([]byte, 1024), 0o644)
c.Pin("pinned")
freed, err := c.Sweep()
if err != nil {
t.Fatalf("Sweep: %v", err)
}
if freed != 0 {
t.Fatalf("nothing should have been freed: got %d", freed)
}
if _, err := os.Stat(pinned); err != nil {
t.Fatal("pinned dir wrongly removed despite over-budget pin")
}
}

294
internal/engine/hls_test.go Normal file
View file

@ -0,0 +1,294 @@
package engine
import (
"path/filepath"
"strings"
"testing"
"time"
)
func TestYnBool(t *testing.T) {
if got := ynBool(true); got != "YES" {
t.Errorf("ynBool(true) = %q, want YES", got)
}
if got := ynBool(false); got != "NO" {
t.Errorf("ynBool(false) = %q, want NO", got)
}
}
func TestBitrateForQuality(t *testing.T) {
cases := map[string]int{
"2160p": 25_000_000,
"1080p": 6_000_000,
"720p": 3_500_000,
"480p": 1_500_000,
"unknown": 6_000_000,
"": 6_000_000,
}
for q, want := range cases {
if got := bitrateForQuality(q); got != want {
t.Errorf("bitrateForQuality(%q) = %d, want %d", q, got, want)
}
}
}
func TestQualityHeight(t *testing.T) {
cases := map[string]int{
"2160p": 2160,
"1080p": 1080,
"720p": 720,
"480p": 480,
"": 0,
"unknown": 0,
}
for q, want := range cases {
if got := qualityHeight(q); got != want {
t.Errorf("qualityHeight(%q) = %d, want %d", q, got, want)
}
}
}
func TestScaledDimensions(t *testing.T) {
tests := []struct {
name string
srcW, srcH, capH int
wantW, wantH int
}{
{"no_cap_returns_source", 1920, 1080, 0, 1920, 1080},
{"under_cap_returns_source", 1280, 720, 1080, 1280, 720},
{"4k_capped_to_1080", 3840, 2160, 1080, 1920, 1080},
{"even_width_stays_even", 1003, 750, 720, 962, 720},
{"odd_width_bumps_up", 1001, 700, 500, 716, 500},
{"invalid_returns_default", 0, 0, 0, 1920, 1080},
{"negative_returns_default", -10, 100, 0, 1920, 1080},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotW, gotH := scaledDimensions(tt.srcW, tt.srcH, tt.capH)
if gotW != tt.wantW || gotH != tt.wantH {
t.Errorf("scaledDimensions(%d,%d,%d) = (%d,%d), want (%d,%d)",
tt.srcW, tt.srcH, tt.capH, gotW, gotH, tt.wantW, tt.wantH)
}
})
}
}
func TestShortHLSID(t *testing.T) {
if got := shortHLSID("abcdef1234567890"); got != "abcdef12" {
t.Errorf("got %q, want abcdef12", got)
}
if got := shortHLSID("short"); got != "short" {
t.Errorf("got %q, want short", got)
}
if got := shortHLSID(""); got != "" {
t.Errorf("got %q, want empty", got)
}
}
func TestHlsTmpDirRoot(t *testing.T) {
root := hlsTmpDirRoot()
if root == "" {
t.Fatal("hlsTmpDirRoot returned empty")
}
if !strings.Contains(root, "hls-sessions") && !strings.Contains(root, "unarr-hls-sessions") {
t.Errorf("expected path to contain hls-sessions, got %q", root)
}
}
func TestRenderVideoPlaylist(t *testing.T) {
out := renderVideoPlaylist(10.0, 3)
required := []string{
"#EXTM3U",
"#EXT-X-VERSION:7",
"#EXT-X-PLAYLIST-TYPE:VOD",
`#EXT-X-MAP:URI="init.mp4"`,
"seg-0.m4s",
"seg-1.m4s",
"seg-2.m4s",
"#EXT-X-ENDLIST",
}
for _, want := range required {
if !strings.Contains(out, want) {
t.Errorf("playlist missing %q\n%s", want, out)
}
}
}
func TestRenderVideoPlaylistShortFinalSegment(t *testing.T) {
// 9.5s total, 2s segments → 5 segs of 2/2/2/2/1.5
segCount := segmentCountForDuration(9.5)
out := renderVideoPlaylist(9.5, segCount)
if !strings.Contains(out, "#EXTINF:1.500,") {
t.Errorf("expected final segment 1.5s in playlist (segCount=%d), got:\n%s", segCount, out)
}
}
func TestRenderMasterPlaylist(t *testing.T) {
probe := &StreamProbe{
Width: 1920,
Height: 1080,
SubtitleTracks: []ProbeSubtitleTrack{
{Index: 0, Lang: "es", Codec: "subrip", Title: "Spanish"},
{Index: 1, Lang: "en", Codec: "subrip", Title: "English", Forced: true},
{Index: 2, Lang: "ja", Codec: "hdmv_pgs_subtitle"}, // bitmap, skipped
},
}
out := renderMasterPlaylist(probe, "1080p")
if !strings.HasPrefix(out, "#EXTM3U") {
t.Errorf("must start with #EXTM3U, got:\n%s", out)
}
if !strings.Contains(out, "BANDWIDTH=6000000") {
t.Errorf("expected 1080p bandwidth, got:\n%s", out)
}
if !strings.Contains(out, "RESOLUTION=1920x1080") {
t.Errorf("expected 1920x1080 resolution, got:\n%s", out)
}
if !strings.Contains(out, `SUBTITLES="subs"`) {
t.Errorf("expected subtitles group attached, got:\n%s", out)
}
if !strings.Contains(out, `LANGUAGE="es"`) || !strings.Contains(out, `LANGUAGE="en"`) {
t.Errorf("expected text subs included, got:\n%s", out)
}
if strings.Contains(out, "hdmv_pgs") || strings.Contains(out, `LANGUAGE="ja"`) {
t.Errorf("bitmap subs should be excluded, got:\n%s", out)
}
if !strings.Contains(out, "(forced)") {
t.Errorf("expected forced suffix on English track, got:\n%s", out)
}
}
func TestRenderMasterPlaylistNoSubs(t *testing.T) {
probe := &StreamProbe{Width: 1280, Height: 720}
out := renderMasterPlaylist(probe, "720p")
if strings.Contains(out, "SUBTITLES=") {
t.Errorf("no subs should produce no SUBTITLES attr, got:\n%s", out)
}
if !strings.Contains(out, "BANDWIDTH=3500000") {
t.Errorf("expected 720p bandwidth, got:\n%s", out)
}
}
func TestHLSSessionRegistry(t *testing.T) {
r := NewHLSSessionRegistry()
if r.Get("missing") != nil {
t.Error("Get on empty registry should return nil")
}
s1 := &HLSSession{cfg: HLSSessionConfig{SessionID: "a"}, lastTouch: time.Now()}
r.Register(s1)
if got := r.Get("a"); got != s1 {
t.Errorf("Get(a) = %v, want %v", got, s1)
}
// Registering a different session evicts (and Closes) the previous one.
s2 := &HLSSession{cfg: HLSSessionConfig{SessionID: "b"}, lastTouch: time.Now()}
r.Register(s2)
if r.Get("a") != nil {
t.Error("registering different session should evict prior entries")
}
if r.Get("b") != s2 {
t.Error("Get(b) should return s2")
}
r.Remove("b")
if r.Get("b") != nil {
t.Error("Remove should drop the session")
}
}
func TestHLSSessionAccessors(t *testing.T) {
probe := &StreamProbe{VideoCodec: "h264", Width: 1280, Height: 720}
s := &HLSSession{
cfg: HLSSessionConfig{SessionID: "abcdef1234"},
probe: probe,
manifestRoot: "MASTER",
manifestVideo: "VIDEO",
durationSec: 42.5,
lastTouch: time.Now().Add(-1 * time.Hour),
}
if s.MasterPlaylist() != "MASTER" {
t.Errorf("MasterPlaylist mismatch")
}
if s.VideoPlaylist() != "VIDEO" {
t.Errorf("VideoPlaylist mismatch")
}
if s.DurationSeconds() != 42.5 {
t.Errorf("DurationSeconds mismatch")
}
if s.Probe() != probe {
t.Errorf("Probe mismatch")
}
old := s.lastTouch
s.Touch()
if !s.lastTouch.After(old) {
t.Errorf("Touch did not advance lastTouch")
}
info := s.ProbeInfo()
if info["videoCodec"] != "h264" || info["width"] != 1280 {
t.Errorf("ProbeInfo missing fields: %v", info)
}
}
func TestHLSSessionProbeInfoNil(t *testing.T) {
s := &HLSSession{}
info := s.ProbeInfo()
if len(info) != 0 {
t.Errorf("nil probe should produce empty info, got %v", info)
}
}
func TestSweepIdle(t *testing.T) {
r := NewHLSSessionRegistry()
idleSession := &HLSSession{
cfg: HLSSessionConfig{SessionID: "old"},
lastTouch: time.Now().Add(-2 * hlsSessionTTL),
}
r.Register(idleSession)
if got := r.SweepIdle(); got != 1 {
t.Errorf("SweepIdle = %d, want 1", got)
}
if r.Get("old") != nil {
t.Errorf("idle session should have been removed")
}
}
func TestCleanupHLSOrphanDirsMissingRoot(t *testing.T) {
// Directory does not exist — should not error.
t.Setenv("XDG_CACHE_HOME", filepath.Join(t.TempDir(), "nonexistent"))
if err := CleanupHLSOrphanDirs(); err != nil {
t.Errorf("CleanupHLSOrphanDirs on missing root = %v, want nil", err)
}
}
func TestValidSessionID(t *testing.T) {
good := []string{
"abc",
"7b8c4f12-9d3e-4a1b-9c2f-aabbccddeeff",
"ABC_123-xyz",
strings.Repeat("a", 128),
}
bad := []string{
"",
"../etc/passwd",
"foo/bar",
"foo\\bar",
"foo.bar",
"with spaces",
"with\nnewline",
strings.Repeat("a", 129),
"héctor", // non-ascii
}
for _, id := range good {
if !validSessionID.MatchString(id) {
t.Errorf("validSessionID rejected good id %q", id)
}
}
for _, id := range bad {
if validSessionID.MatchString(id) {
t.Errorf("validSessionID accepted bad id %q", id)
}
}
}

View file

@ -86,6 +86,117 @@ func listFFmpegEncoders(ctx context.Context, ffmpegPath string) string {
return string(out)
}
// HWAccelDiagnostic bundles what we know about the host's ffmpeg + HW encode
// capabilities so the daemon can log a single coherent line at startup and the
// web side can surface "this agent is software-only" without re-running probes.
type HWAccelDiagnostic struct {
Pick HWAccel // backend selected by DetectHWAccel
FFmpegPath string // resolved ffmpeg binary
FFmpegVersion string // first line of `ffmpeg -version` (e.g. "ffmpeg version 6.1.1")
Encoders []string // HW + libsvtav1/libvpx9-class encoders found in -encoders output
Devices []string // device files / drivers detected at probe time
}
// DetectHWAccelDiagnostic returns the full diagnostic picture for the host's
// transcode pipeline. Unlike DetectHWAccel, this is NOT cached — callers pay
// for an ffmpeg subprocess on each call (one `-encoders`, one `-version`).
// Daemon startup is the natural caller; per-session lookups should keep using
// DetectHWAccel (cached) and only re-probe diagnostics if the user runs an
// explicit doctor command.
func DetectHWAccelDiagnostic(ctx context.Context, ffmpegPath string) HWAccelDiagnostic {
d := HWAccelDiagnostic{Pick: HWAccelNone, FFmpegPath: ffmpegPath}
if ffmpegPath == "" {
return d
}
d.FFmpegVersion = ffmpegVersionLine(ctx, ffmpegPath)
encoders := listFFmpegEncoders(ctx, ffmpegPath)
for _, name := range hwEncoderNames {
if strings.Contains(encoders, name) {
d.Encoders = append(d.Encoders, name)
}
}
// Device-file checks mirror the picks below so the log line tells the
// reader why a present encoder might still have been rejected (e.g. NVENC
// compiled in but /dev/nvidia0 missing inside a container).
if fileExists("/dev/nvidia0") {
d.Devices = append(d.Devices, "/dev/nvidia0")
}
if fileExists("/dev/dri/renderD128") {
d.Devices = append(d.Devices, "/dev/dri/renderD128")
}
if hasNvidiaDriver() {
d.Devices = append(d.Devices, "nvidia-smi")
}
d.Pick = DetectHWAccel(ctx, ffmpegPath)
return d
}
// LogLine returns a one-line human-readable summary of the diagnostic,
// suitable for daemon startup output. Format:
//
// "[transcode] ffmpeg 6.1.1 at /usr/bin/ffmpeg, HW=nvenc (h264_nvenc), devices=/dev/nvidia0,nvidia-smi"
// "[transcode] ffmpeg 6.1.1 at /home/linuxbrew/.../ffmpeg, HW=none (software libx264) — no HW encoders compiled in"
func (d HWAccelDiagnostic) LogLine() string {
var b strings.Builder
b.WriteString("[transcode] ")
if d.FFmpegVersion != "" {
b.WriteString(d.FFmpegVersion)
} else {
b.WriteString("ffmpeg")
}
if d.FFmpegPath != "" {
b.WriteString(" at ")
b.WriteString(d.FFmpegPath)
}
b.WriteString(", HW=")
b.WriteString(string(d.Pick))
if d.Pick == HWAccelNone {
if len(d.Encoders) == 0 {
b.WriteString(" (software libx264) — no HW encoders compiled in")
} else {
b.WriteString(" (software libx264) — encoders found but no matching device: ")
b.WriteString(strings.Join(d.Encoders, ","))
}
} else {
b.WriteString(" (")
b.WriteString(d.Pick.FFmpegVideoCodec("h264"))
b.WriteString(")")
if len(d.Devices) > 0 {
b.WriteString(", devices=")
b.WriteString(strings.Join(d.Devices, ","))
}
}
return b.String()
}
// hwEncoderNames lists the HW-accelerated encoders we care about for the
// startup log. Kept in lookup order so the output reads predictably across
// hosts.
var hwEncoderNames = []string{
"h264_nvenc", "hevc_nvenc",
"h264_qsv", "hevc_qsv",
"h264_vaapi", "hevc_vaapi",
"h264_videotoolbox", "hevc_videotoolbox",
}
// ffmpegVersionLine extracts the "ffmpeg version X.Y.Z" prefix from
// `ffmpeg -version`. Bounded to avoid hanging the daemon on a misbehaving
// binary.
func ffmpegVersionLine(ctx context.Context, ffmpegPath string) string {
cmd := exec.CommandContext(ctx, ffmpegPath, "-hide_banner", "-version")
out, err := cmd.CombinedOutput()
if err != nil || len(out) == 0 {
return ""
}
line, _, _ := strings.Cut(string(out), "\n")
// "ffmpeg version 6.1.1-some-build-suffix Copyright..." → keep up to first
// space after "version 6.x" to avoid spamming build flags into the log.
if idx := strings.Index(line, "Copyright"); idx > 0 {
line = strings.TrimSpace(line[:idx])
}
return strings.TrimSpace(line)
}
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
@ -129,12 +240,13 @@ func (h HWAccel) FFmpegVideoCodec(target string) string {
}
}
// H264LevelForHeight returns the lowest H.264 profile level capable of encoding
// a stream at the given output pixel height (assumes ~16:9, ≤30 fps). The
// previous code used a fixed "4.0" which silently rejects anything above 1080p
// — libx264 logs "frame MB size > level limit" and emits a corrupt stream.
// Returning a tighter level on smaller outputs keeps player compatibility on
// older devices where the encoder can't auto-pick.
// H264LevelForHeight returns the lowest H.264 profile level capable of
// encoding a stream at the given output pixel height. Each tier carries
// enough macroblock headroom to handle ANAMORPHIC content (up to ~2.4:1
// cinemascope) at 30 fps — a fixed 16:9 assumption used to silently bust
// the level on a 720p movie shot in 2.4:1 (1728×720 = 4860 MBs > 3.1's
// 3600 limit; libx264 logs "frame MB size > level limit" and emits a
// corrupt stream).
func H264LevelForHeight(height int) string {
switch {
case height <= 0:
@ -142,11 +254,14 @@ func H264LevelForHeight(height int) string {
// re-introduce the silent-failure mode that motivated this helper.
return "5.1"
case height <= 480:
return "3.0"
case height <= 720:
return "3.1"
case height <= 1080:
case height <= 720:
// 4.0 instead of 3.1: covers 720p anamorphic (e.g. 1728×720) +
// MB rate up to 245k/s (3.1 caps at 108k/s — broken at 24 fps).
return "4.0"
case height <= 1080:
// 4.1 instead of 4.0: covers 1080p anamorphic + 30 fps (~245k MBs/s).
return "4.1"
case height <= 1440:
return "5.0"
case height <= 2160:

View file

@ -1,6 +1,9 @@
package engine
import "testing"
import (
"strings"
"testing"
)
func TestHWAccelFFmpegVideoCodec(t *testing.T) {
cases := []struct {
@ -32,3 +35,122 @@ func TestDetectHWAccelEmptyPathReturnsNone(t *testing.T) {
t.Errorf("got %s, want %s", got, HWAccelNone)
}
}
func TestResolveEncoderProfileDefaults(t *testing.T) {
cases := []struct {
hw HWAccel
configured string
wantCodec string
wantPreset string
wantHint string
}{
// Empty configured preset → pick latency-biased default per backend.
// DecodeHwAccel matches the encoder family for HW encoders; libx264 +
// VideoToolbox have no demuxer hint.
{HWAccelNone, "", "libx264", "superfast", ""},
{HWAccelNVENC, "", "h264_nvenc", "p3", "cuda"},
{HWAccelQSV, "", "h264_qsv", "veryfast", "qsv"},
// VAAPI: decoder hint set, no preset, no `-hwaccel_output_format vaapi`
// (so the CPU filter chain can consume the decoded frames).
{HWAccelVAAPI, "", "h264_vaapi", "", "vaapi"},
// VideoToolbox has no preset knob — Preset should be "" regardless of input.
// VideoToolbox uses per-encoder flags, not a demuxer `-hwaccel` hint.
{HWAccelVideoToolbox, "p4", "h264_videotoolbox", "", ""},
{HWAccelVideoToolbox, "", "h264_videotoolbox", "", ""},
}
for _, tc := range cases {
got := ResolveEncoderProfile(tc.hw, tc.configured)
if got.Codec != tc.wantCodec || got.Preset != tc.wantPreset || got.DecodeHwAccel != tc.wantHint {
t.Errorf("ResolveEncoderProfile(%s, %q) = {codec=%s preset=%s hint=%s}, want {codec=%s preset=%s hint=%s}",
tc.hw, tc.configured,
got.Codec, got.Preset, got.DecodeHwAccel,
tc.wantCodec, tc.wantPreset, tc.wantHint)
}
}
}
func TestResolveEncoderProfileHonoursConfiguredPreset(t *testing.T) {
// Only libx264 honours the configured preset — the libx264 vocabulary
// (ultrafast…veryslow) doesn't apply to vendor encoders. NVENC has its
// own p1-p7 scale; QSV uses a different subset; VideoToolbox has no
// preset knob. Passing a libx264 preset to them would have ffmpeg reject
// the argv, so ResolveEncoderProfile always falls back to the hardcoded
// vendor preset for non-libx264 codecs.
cases := []struct {
hw HWAccel
configured string
wantPreset string
}{
{HWAccelNone, "ultrafast", "ultrafast"}, // libx264 honours
{HWAccelNone, "medium", "medium"}, // libx264 honours
{HWAccelNVENC, "p1", "p3"}, // NVENC ignores, sticks to p3
{HWAccelNVENC, "veryfast", "p3"}, // NVENC ignores libx264 vocab
{HWAccelQSV, "veryslow", "veryfast"}, // QSV ignores, sticks to veryfast
{HWAccelVideoToolbox, "veryfast", ""}, // VideoToolbox has no preset
}
for _, tc := range cases {
got := ResolveEncoderProfile(tc.hw, tc.configured)
if got.Preset != tc.wantPreset {
t.Errorf("ResolveEncoderProfile(%s, %q).Preset = %q, want %q",
tc.hw, tc.configured, got.Preset, tc.wantPreset)
}
}
}
func TestHWAccelDiagnosticLogLineNone(t *testing.T) {
d := HWAccelDiagnostic{
Pick: HWAccelNone,
FFmpegPath: "/usr/local/bin/ffmpeg",
FFmpegVersion: "ffmpeg version 6.1.1",
Encoders: nil,
Devices: nil,
}
line := d.LogLine()
wantSubstrings := []string{
"ffmpeg version 6.1.1",
"/usr/local/bin/ffmpeg",
"HW=none",
"software libx264",
"no HW encoders compiled in",
}
for _, want := range wantSubstrings {
if !strings.Contains(line, want) {
t.Errorf("expected substring %q in log line; got %q", want, line)
}
}
}
func TestHWAccelDiagnosticLogLineNVENCWithDevices(t *testing.T) {
d := HWAccelDiagnostic{
Pick: HWAccelNVENC,
FFmpegPath: "/usr/bin/ffmpeg",
FFmpegVersion: "ffmpeg version 6.0",
Encoders: []string{"h264_nvenc", "hevc_nvenc", "h264_qsv"},
Devices: []string{"/dev/nvidia0", "nvidia-smi"},
}
line := d.LogLine()
for _, want := range []string{"HW=nvenc", "h264_nvenc", "/dev/nvidia0", "nvidia-smi"} {
if !strings.Contains(line, want) {
t.Errorf("expected substring %q in log line; got %q", want, line)
}
}
}
func TestHWAccelDiagnosticLogLineSoftwareButEncodersFound(t *testing.T) {
// Edge case: ffmpeg compiled WITH nvenc but no /dev/nvidia0 (container w/o GPU).
// LogLine should flag the encoders so the user knows where the gap is.
d := HWAccelDiagnostic{
Pick: HWAccelNone,
FFmpegPath: "/usr/bin/ffmpeg",
FFmpegVersion: "ffmpeg version 6.0",
Encoders: []string{"h264_nvenc"},
Devices: nil,
}
line := d.LogLine()
for _, want := range []string{"HW=none", "encoders found but no matching device", "h264_nvenc"} {
if !strings.Contains(line, want) {
t.Errorf("expected substring %q in log line; got %q", want, line)
}
}
}

View file

@ -9,7 +9,7 @@ import (
)
// StreamProbe summarises the codec / container shape of a file as it relates
// to the WebRTC streaming pipeline. It tells the transcoder whether bytes can
// to the HLS streaming pipeline. It tells the transcoder whether bytes can
// be streamed as-is, just remuxed to fragmented MP4, or fully transcoded.
type StreamProbe struct {
// VideoCodec lowercased — e.g. "h264", "hevc", "av1", "vp9", "mpeg4".
@ -88,7 +88,15 @@ const (
)
// ProbeFile runs ffprobe and returns a StreamProbe view of the file.
//
// Result is memoised by (path, mtime, size) for probeCacheTTL — repeat plays
// of the same file at the same quality (the HLS cache HIT path) skip ffprobe
// entirely. ffprobe on a 50 GB MKV can cost 1-3 s; first-segment latency
// shrinks by the same amount on the second play.
func ProbeFile(ctx context.Context, ffprobePath, filePath string) (*StreamProbe, error) {
if cached, ok := lookupProbeCache(filePath); ok {
return cached, nil
}
mi, err := mediainfo.ExtractMediaInfo(ctx, ffprobePath, filePath)
if err != nil {
return nil, fmt.Errorf("probe: %w", err)
@ -136,6 +144,7 @@ func ProbeFile(ctx context.Context, ffprobePath, filePath string) (*StreamProbe,
})
}
}
storeProbeCache(filePath, probe)
return probe, nil
}

View file

@ -0,0 +1,141 @@
package engine
import (
"os"
"sync"
"time"
)
// probeCacheTTL is how long a cached probe stays usable. The cache key
// already incorporates mtime + size, so the TTL is a defense against
// runaway memory growth from stale paths, not a freshness guarantee — a
// rename + recreate at the same inode (rare) would still be caught by the
// mtime delta.
const probeCacheTTL = 30 * time.Minute
// probeCacheJanitorInterval is how often the background sweeper wakes to
// drop expired entries. Lookup-time eviction handles hot paths, but a
// user who browses 5k files and then stops would leak entries until each
// is individually re-touched. 5 min ≈ 6 sweeps per TTL window — enough
// to keep memory bounded without burning CPU.
const probeCacheJanitorInterval = 5 * time.Minute
type probeCacheEntry struct {
probe *StreamProbe
expires time.Time
}
type probeCacheKey struct {
path string
mtime int64 // ModTime().UnixNano()
size int64
}
var (
probeCacheMu sync.RWMutex
probeCache = make(map[probeCacheKey]probeCacheEntry)
probeCacheJanitor sync.Once
)
// startProbeCacheJanitor launches the background sweeper exactly once per
// process. Lazy — fired on first storeProbeCache. Drops expired entries
// every probeCacheJanitorInterval. Idempotent (sync.Once).
func startProbeCacheJanitor() {
probeCacheJanitor.Do(func() {
go func() {
ticker := time.NewTicker(probeCacheJanitorInterval)
defer ticker.Stop()
for range ticker.C {
sweepProbeCache(time.Now())
}
}()
})
}
// sweepProbeCache removes every entry whose expiry is at or before `now`.
// Exposed for tests; production code calls it indirectly via the janitor
// goroutine.
func sweepProbeCache(now time.Time) int {
probeCacheMu.Lock()
defer probeCacheMu.Unlock()
removed := 0
for k, e := range probeCache {
if !now.Before(e.expires) {
delete(probeCache, k)
removed++
}
}
return removed
}
// lookupProbeCache returns the cached StreamProbe for the given path if its
// mtime + size still match the value recorded at insert time, AND the cache
// entry hasn't expired. Any stat failure / mismatch returns (nil, false) so
// the caller falls through to a fresh ffprobe run.
func lookupProbeCache(path string) (*StreamProbe, bool) {
fi, err := os.Stat(path)
if err != nil {
return nil, false
}
key := probeCacheKey{
path: path,
mtime: fi.ModTime().UnixNano(),
size: fi.Size(),
}
probeCacheMu.RLock()
entry, ok := probeCache[key]
probeCacheMu.RUnlock()
if !ok {
return nil, false
}
if time.Now().After(entry.expires) {
// Re-check under the write lock so a concurrent re-insert (same key,
// fresh expiry) isn't accidentally evicted.
probeCacheMu.Lock()
if cur, stillThere := probeCache[key]; stillThere && time.Now().After(cur.expires) {
delete(probeCache, key)
}
probeCacheMu.Unlock()
return nil, false
}
return entry.probe, true
}
// storeProbeCache stashes a fresh probe result under the (path, mtime, size)
// key. A subsequent ffprobe-skipping HIT requires the file to still have the
// same mtime + size — anything else (re-encoded, renamed+recreated at the
// same path, truncated) misses and triggers a re-probe.
func storeProbeCache(path string, probe *StreamProbe) {
fi, err := os.Stat(path)
if err != nil {
return
}
key := probeCacheKey{
path: path,
mtime: fi.ModTime().UnixNano(),
size: fi.Size(),
}
probeCacheMu.Lock()
probeCache[key] = probeCacheEntry{
probe: probe,
expires: time.Now().Add(probeCacheTTL),
}
probeCacheMu.Unlock()
// Lazy janitor — fires once per process. No-op after first call.
startProbeCacheJanitor()
}
// ResetProbeCache clears the in-memory probe cache. Test-only.
func ResetProbeCache() {
probeCacheMu.Lock()
probeCache = make(map[probeCacheKey]probeCacheEntry)
probeCacheMu.Unlock()
}
// ProbeCacheSize returns the number of entries currently cached. Exposed
// for diagnostics + tests.
func ProbeCacheSize() int {
probeCacheMu.RLock()
defer probeCacheMu.RUnlock()
return len(probeCache)
}

View file

@ -0,0 +1,202 @@
package engine
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestProbeCache_LookupMissNonexistent(t *testing.T) {
ResetProbeCache()
t.Cleanup(ResetProbeCache)
if _, ok := lookupProbeCache("/path/that/does/not/exist"); ok {
t.Fatal("expected MISS for non-existent path")
}
}
func TestProbeCache_StoreThenLookupHit(t *testing.T) {
ResetProbeCache()
t.Cleanup(ResetProbeCache)
dir := t.TempDir()
path := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(path, []byte("fake content"), 0o644); err != nil {
t.Fatalf("write tmp file: %v", err)
}
probe := &StreamProbe{VideoCodec: "h264", Width: 1920, Height: 1080, DurationSec: 5400}
storeProbeCache(path, probe)
got, ok := lookupProbeCache(path)
if !ok {
t.Fatal("expected HIT after store")
}
if got != probe {
t.Fatalf("expected pointer-identical probe; got different")
}
}
func TestProbeCache_MtimeChangeInvalidates(t *testing.T) {
ResetProbeCache()
t.Cleanup(ResetProbeCache)
dir := t.TempDir()
path := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(path, []byte("original"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
probe := &StreamProbe{VideoCodec: "h264", DurationSec: 100}
storeProbeCache(path, probe)
// Force mtime change. WriteFile doesn't guarantee a different mtime if
// the filesystem timestamp resolution is coarse, so set it explicitly
// to a value 1 hour in the future.
future := time.Now().Add(1 * time.Hour)
if err := os.Chtimes(path, future, future); err != nil {
t.Fatalf("chtimes: %v", err)
}
if _, ok := lookupProbeCache(path); ok {
t.Fatal("expected MISS after mtime change")
}
}
func TestProbeCache_SizeChangeInvalidates(t *testing.T) {
ResetProbeCache()
t.Cleanup(ResetProbeCache)
dir := t.TempDir()
path := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(path, []byte("aaaaa"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
originalMtime := time.Now().Add(-1 * time.Hour) // stable, in the past
if err := os.Chtimes(path, originalMtime, originalMtime); err != nil {
t.Fatalf("chtimes original: %v", err)
}
probe := &StreamProbe{VideoCodec: "h264", DurationSec: 100}
storeProbeCache(path, probe)
// Truncate to a different size, then reset mtime to the original so
// only `size` differs between store and lookup keys — isolates the
// size-check path. Without the Chtimes, WriteFile bumps mtime and the
// test would pass via mtime invalidation regardless of size logic.
if err := os.WriteFile(path, []byte("a"), 0o644); err != nil {
t.Fatalf("rewrite: %v", err)
}
if err := os.Chtimes(path, originalMtime, originalMtime); err != nil {
t.Fatalf("chtimes restore: %v", err)
}
if _, ok := lookupProbeCache(path); ok {
t.Fatal("expected MISS after size change")
}
}
func TestProbeCache_ExpiryDropsEntry(t *testing.T) {
ResetProbeCache()
t.Cleanup(ResetProbeCache)
dir := t.TempDir()
path := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(path, []byte("content"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
// Stash an entry whose expires is already in the past — simulates TTL
// having elapsed without sleeping for 30 min.
fi, err := os.Stat(path)
if err != nil {
t.Fatalf("stat: %v", err)
}
key := probeCacheKey{path: path, mtime: fi.ModTime().UnixNano(), size: fi.Size()}
probeCacheMu.Lock()
probeCache[key] = probeCacheEntry{
probe: &StreamProbe{VideoCodec: "h264"},
expires: time.Now().Add(-1 * time.Minute),
}
probeCacheMu.Unlock()
if _, ok := lookupProbeCache(path); ok {
t.Fatal("expected MISS for expired entry")
}
// Side-effect: lookup should have evicted the stale entry.
if ProbeCacheSize() != 0 {
t.Fatalf("expected cache size 0 after expiry eviction; got %d", ProbeCacheSize())
}
}
func TestProbeCache_ResetClears(t *testing.T) {
ResetProbeCache()
dir := t.TempDir()
path := filepath.Join(dir, "movie.mkv")
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
storeProbeCache(path, &StreamProbe{VideoCodec: "h264"})
if ProbeCacheSize() != 1 {
t.Fatalf("expected size 1 after store; got %d", ProbeCacheSize())
}
ResetProbeCache()
if ProbeCacheSize() != 0 {
t.Fatalf("expected size 0 after reset; got %d", ProbeCacheSize())
}
}
func TestProbeCache_StoreNonexistentNoOp(t *testing.T) {
ResetProbeCache()
t.Cleanup(ResetProbeCache)
// Store on a non-existent path should silently do nothing (stat fails),
// not panic, and not poison the cache with a zero key.
storeProbeCache("/nope/never/exists.mkv", &StreamProbe{VideoCodec: "h264"})
if ProbeCacheSize() != 0 {
t.Fatalf("expected 0 entries; got %d", ProbeCacheSize())
}
}
func TestProbeCache_SweepDropsExpired(t *testing.T) {
ResetProbeCache()
t.Cleanup(ResetProbeCache)
dir := t.TempDir()
// Two entries: one expired, one fresh.
expiredPath := filepath.Join(dir, "old.mkv")
freshPath := filepath.Join(dir, "new.mkv")
if err := os.WriteFile(expiredPath, []byte("a"), 0o644); err != nil {
t.Fatalf("write expired: %v", err)
}
if err := os.WriteFile(freshPath, []byte("b"), 0o644); err != nil {
t.Fatalf("write fresh: %v", err)
}
now := time.Now()
fiExp, _ := os.Stat(expiredPath)
fiFresh, _ := os.Stat(freshPath)
probeCacheMu.Lock()
probeCache[probeCacheKey{path: expiredPath, mtime: fiExp.ModTime().UnixNano(), size: fiExp.Size()}] = probeCacheEntry{
probe: &StreamProbe{VideoCodec: "h264"},
expires: now.Add(-1 * time.Minute), // expired
}
probeCache[probeCacheKey{path: freshPath, mtime: fiFresh.ModTime().UnixNano(), size: fiFresh.Size()}] = probeCacheEntry{
probe: &StreamProbe{VideoCodec: "h264"},
expires: now.Add(10 * time.Minute), // fresh
}
probeCacheMu.Unlock()
removed := sweepProbeCache(now)
if removed != 1 {
t.Fatalf("expected 1 expired entry removed; got %d", removed)
}
if ProbeCacheSize() != 1 {
t.Fatalf("expected 1 fresh entry kept; got %d", ProbeCacheSize())
}
}

View file

@ -1,138 +0,0 @@
package engine
import (
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/bencode"
"github.com/anacrolix/torrent/metainfo"
)
// SeedFile builds a single-file torrent from an arbitrary on-disk file
// and adds it to an existing torrent client so the WebRTC peer wire
// (already configured on the client) can serve the file to a browser
// that knows the resulting info-hash.
//
// Returns the generated info-hash. The torrent is left attached to the
// client — caller is responsible for keeping it alive while a browser
// is watching. Drop it via Client.RemoveTorrent / Torrent.Drop when
// idle to free resources.
//
// Behaviour notes:
// - The file must already exist; no download is attempted.
// - Piece length follows the libtorrent ladder (16 KiB → 4 MiB).
// - The torrent is "complete" from the agent's POV — it has every
// piece — so the upload-only flow kicks in immediately.
// - WebRTC peer behaviour comes from the client config the caller
// constructed; SeedFile does not toggle DisableWebtorrent itself.
// If the operator's [downloads.webrtc].enabled = false, the file
// is still added but no browser will discover it via WSS tracker.
func SeedFile(client *torrent.Client, filePath string, trackerURLs []string) (metainfo.Hash, error) {
if client == nil {
return metainfo.Hash{}, errors.New("seed_file: torrent client is nil")
}
if filePath == "" {
return metainfo.Hash{}, errors.New("seed_file: filePath is empty")
}
abs, err := filepath.Abs(filePath)
if err != nil {
return metainfo.Hash{}, fmt.Errorf("seed_file: resolve path: %w", err)
}
st, err := os.Stat(abs)
if err != nil {
return metainfo.Hash{}, fmt.Errorf("seed_file: stat: %w", err)
}
if st.IsDir() {
return metainfo.Hash{}, fmt.Errorf("seed_file: only single files are supported, %s is a directory", abs)
}
info := metainfo.Info{
PieceLength: chooseSeedPieceLength(st.Size()),
Name: filepath.Base(abs),
}
if err := info.BuildFromFilePath(abs); err != nil {
return metainfo.Hash{}, fmt.Errorf("seed_file: build info: %w", err)
}
infoBytes, err := bencode.Marshal(info)
if err != nil {
return metainfo.Hash{}, fmt.Errorf("seed_file: marshal info: %w", err)
}
mi := &metainfo.MetaInfo{
InfoBytes: infoBytes,
AnnounceList: makeAnnounceList(trackerURLs),
CreatedBy: "unarr-seed-file",
CreationDate: time.Now().Unix(),
}
ih := mi.HashInfoBytes()
t, err := client.AddTorrent(mi)
if err != nil {
return metainfo.Hash{}, fmt.Errorf("seed_file: add torrent: %w", err)
}
// Mark every piece as needed so the client treats us as a complete
// seeder right away — anacrolix's verifier will hash the file
// asynchronously and flip pieces to "have" as it goes.
t.DownloadAll()
return ih, nil
}
// makeAnnounceList shapes the tracker URL slice into the bencoded
// AnnounceList format anacrolix expects.
func makeAnnounceList(urls []string) metainfo.AnnounceList {
if len(urls) == 0 {
return nil
}
tier := make([]string, 0, len(urls))
for _, u := range urls {
if u == "" {
continue
}
tier = append(tier, u)
}
if len(tier) == 0 {
return nil
}
return metainfo.AnnounceList{tier}
}
// chooseSeedPieceLength picks the piece size for a single-file torrent
// based on the libtorrent / qBittorrent ladder. Mirrored from the
// wstracker-probe seeder so generated torrents are interoperable.
func chooseSeedPieceLength(size int64) int64 {
switch {
case size < 4*1024*1024:
return 16 * 1024
case size < 64*1024*1024:
return 64 * 1024
case size < 512*1024*1024:
return 256 * 1024
case size < 4*1024*1024*1024:
return 1024 * 1024
default:
return 4 * 1024 * 1024
}
}
// SeedFileOnDownloader is a convenience wrapper that pulls the
// underlying anacrolix client out of a TorrentDownloader and forwards
// to SeedFile. trackerURLs default to the downloader's WebRTC
// trackers when nil/empty.
func SeedFileOnDownloader(d *TorrentDownloader, filePath string) (metainfo.Hash, error) {
if d == nil {
return metainfo.Hash{}, errors.New("seed_file: downloader is nil")
}
trackers := d.cfg.WebRTCTrackers
if !d.cfg.WebRTCEnabled {
// We could still build the torrent, but no browser would find
// it via the WSS tracker — bail loud so the operator notices.
return metainfo.Hash{}, errors.New("seed_file: WebRTC peer disabled in config; set [downloads.webrtc].enabled = true to use this feature")
}
return SeedFile(d.client, filePath, trackers)
}

View file

@ -1,164 +0,0 @@
package engine
import (
"context"
"os"
"path/filepath"
"testing"
)
// TestSeedFile_RejectsMissingFile — explicit error rather than crashing
// inside anacrolix when the path doesn't exist.
func TestSeedFile_RejectsMissingFile(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: dir,
ListenPort: 0,
WebRTCEnabled: true,
WebRTCTrackers: []string{"wss://tracker.torrentclaw.com"},
})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
defer dl.Shutdown(context.Background())
if _, err := SeedFile(dl.client, "/nonexistent/path", nil); err == nil {
t.Fatal("expected error for missing file")
}
}
// TestSeedFile_RejectsDirectory — single-file torrents only for now.
func TestSeedFile_RejectsDirectory(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: dir,
ListenPort: 0,
WebRTCEnabled: true,
WebRTCTrackers: []string{"wss://tracker.torrentclaw.com"},
})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
defer dl.Shutdown(context.Background())
subDir := filepath.Join(dir, "sub")
if err := os.Mkdir(subDir, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if _, err := SeedFile(dl.client, subDir, nil); err == nil {
t.Fatal("expected error for directory path")
}
}
// TestSeedFile_BuildsDeterministicInfoHash — the same file should yield
// the same info_hash on every call so the web client can poll for it.
func TestSeedFile_BuildsDeterministicInfoHash(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "data.bin")
payload := []byte("hello world — torrentclaw seed_file test")
if err := os.WriteFile(file, payload, 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
mkClient := func() *TorrentDownloader {
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: t.TempDir(),
ListenPort: 0,
WebRTCEnabled: true,
WebRTCTrackers: []string{"wss://tracker.torrentclaw.com"},
})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
return dl
}
dl1 := mkClient()
defer dl1.Shutdown(context.Background())
hash1, err := SeedFile(dl1.client, file, []string{"wss://tracker.torrentclaw.com"})
if err != nil {
t.Fatalf("first SeedFile: %v", err)
}
dl2 := mkClient()
defer dl2.Shutdown(context.Background())
hash2, err := SeedFile(dl2.client, file, []string{"wss://tracker.torrentclaw.com"})
if err != nil {
t.Fatalf("second SeedFile: %v", err)
}
if hash1 != hash2 {
t.Fatalf("info_hash not deterministic: %s vs %s", hash1.HexString(), hash2.HexString())
}
if hash1.HexString() == "" || len(hash1.HexString()) != 40 {
t.Fatalf("info_hash is not 40 hex chars: %q", hash1.HexString())
}
}
// TestSeedFileOnDownloader_RequiresWebRTC — silent failure mode is the
// worst UX; bail loud when the operator hasn't opted into WebRTC.
func TestSeedFileOnDownloader_RequiresWebRTC(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: dir,
ListenPort: 0,
WebRTCEnabled: false,
})
if err != nil {
t.Fatalf("NewTorrentDownloader: %v", err)
}
defer dl.Shutdown(context.Background())
file := filepath.Join(dir, "data.bin")
if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
if _, err := SeedFileOnDownloader(dl, file); err == nil {
t.Fatal("expected error when WebRTC disabled")
}
}
// TestChooseSeedPieceLength_LadderShape — sanity-check the breakpoints
// stay aligned with the libtorrent reference (16 KiB → 4 MiB).
func TestChooseSeedPieceLength_LadderShape(t *testing.T) {
cases := []struct {
size int64
expect int64
}{
{1, 16 * 1024},
{4 * 1024 * 1024, 64 * 1024},
{64 * 1024 * 1024, 256 * 1024},
{512 * 1024 * 1024, 1024 * 1024},
{4 * 1024 * 1024 * 1024, 4 * 1024 * 1024},
}
for _, c := range cases {
if got := chooseSeedPieceLength(c.size); got != c.expect {
t.Errorf("chooseSeedPieceLength(%d) = %d want %d", c.size, got, c.expect)
}
}
}
// TestMakeAnnounceList_HandlesEmpty — nil/empty in → nil out, so
// AddTorrent doesn't see a dangling tier with no URLs.
func TestMakeAnnounceList_HandlesEmpty(t *testing.T) {
if got := makeAnnounceList(nil); got != nil {
t.Errorf("nil input should yield nil announce list, got %+v", got)
}
if got := makeAnnounceList([]string{}); got != nil {
t.Errorf("empty input should yield nil announce list, got %+v", got)
}
if got := makeAnnounceList([]string{"", " ", ""}); got != nil {
// Empty strings should be filtered; if everything is empty,
// nil is the right answer.
// (We do NOT trim whitespace today — only literal "".)
if len(got) != 1 || len(got[0]) != 1 {
t.Errorf("expected 1 single-element tier, got %+v", got)
}
}
got := makeAnnounceList([]string{"wss://a", "", "wss://b"})
if len(got) != 1 || len(got[0]) != 2 {
t.Fatalf("expected 1 tier of 2 URLs, got %+v", got)
}
}

View file

@ -4,14 +4,23 @@ import (
"fmt"
"os/exec"
"runtime"
"strings"
)
// OpenPlayer attempts to open a media player with the given stream URL.
// Returns the player name and the running command.
// If override is set, it uses that command directly.
//
// The URL is required to be http(s) so a hostile-looking value (e.g. starting
// with `--`) is not interpreted as a switch by mpv/vlc/xdg-open/open. The
// `--` separator is also appended before the URL where the helper supports
// it.
func OpenPlayer(url, override string) (string, *exec.Cmd, error) {
if !isSafePlayerURL(url) {
return "", nil, fmt.Errorf("refusing to open non-http(s) URL")
}
if override != "" {
cmd := exec.Command(override, url)
cmd := exec.Command(override, "--", url)
if err := cmd.Start(); err != nil {
return override, nil, fmt.Errorf("start %s: %w", override, err)
}
@ -20,7 +29,7 @@ func OpenPlayer(url, override string) (string, *exec.Cmd, error) {
// Try mpv first (best streaming support)
if path, err := exec.LookPath("mpv"); err == nil {
cmd := exec.Command(path, "--no-terminal", url)
cmd := exec.Command(path, "--no-terminal", "--", url)
if err := cmd.Start(); err == nil {
return "mpv", cmd, nil
}
@ -28,7 +37,7 @@ func OpenPlayer(url, override string) (string, *exec.Cmd, error) {
// Try VLC
if path, err := exec.LookPath("vlc"); err == nil {
cmd := exec.Command(path, url)
cmd := exec.Command(path, "--", url)
if err := cmd.Start(); err == nil {
return "vlc", cmd, nil
}
@ -36,7 +45,7 @@ func OpenPlayer(url, override string) (string, *exec.Cmd, error) {
// Try cvlc (VLC headless)
if path, err := exec.LookPath("cvlc"); err == nil {
cmd := exec.Command(path, url)
cmd := exec.Command(path, "--", url)
if err := cmd.Start(); err == nil {
return "vlc (headless)", cmd, nil
}
@ -51,6 +60,9 @@ func OpenPlayer(url, override string) (string, *exec.Cmd, error) {
}
func openBrowser(url string) (string, *exec.Cmd, error) {
if !isSafePlayerURL(url) {
return "", nil, fmt.Errorf("refusing to open non-http(s) URL")
}
switch runtime.GOOS {
case "linux":
if path, err := exec.LookPath("xdg-open"); err == nil {
@ -60,7 +72,7 @@ func openBrowser(url string) (string, *exec.Cmd, error) {
}
}
case "darwin":
cmd := exec.Command("/usr/bin/open", url)
cmd := exec.Command("/usr/bin/open", "--", url)
if err := cmd.Start(); err == nil {
return "browser", cmd, nil
}
@ -72,3 +84,9 @@ func openBrowser(url string) (string, *exec.Cmd, error) {
}
return "", nil, fmt.Errorf("no browser opener found")
}
// isSafePlayerURL guards the helpers above against URLs that could be
// interpreted as command-line switches by the launched player.
func isSafePlayerURL(url string) bool {
return strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://")
}

View file

@ -50,7 +50,18 @@ type StreamServer struct {
url string // best single URL (backward compat)
urls StreamURLs // all available URLs by network type
upnpMapping *UPnPMapping
disableUPnP bool
// enableUPnP gates whether Listen() asks the gateway to publish the
// stream port to the WAN. UPnP is opt-in (false by default) because
// /stream and /hls have no auth — exposing them on the public internet
// would let any scanner enumerate active downloads. LAN and Tailscale
// access keep working without UPnP.
enableUPnP bool
// corsExtraOrigins are operator-configured origins added to the default
// allowlist defined in validate.go. Set before Listen().
corsExtraOrigins []string
// corsAllowlist is computed at Listen() time and treated as read-only
// thereafter so per-request reads need no locking.
corsAllowlist map[string]struct{}
hls *HLSSessionRegistry // HLS sessions served on /hls/<id>/...
@ -65,16 +76,73 @@ type StreamServer struct {
// NewStreamServer creates a stream server bound to the given port.
// Call Listen() to start accepting connections, then SetFile() to serve content.
//
// UPnP is opt-in: call SetUPnPEnabled(true) before Listen() to publish the
// stream port on the WAN. Without it, only LAN and Tailscale clients can
// reach the server. This matches the security default — /stream and /hls
// have no auth, so exposing them to the public internet is something the
// operator must explicitly request.
func NewStreamServer(port int) *StreamServer {
return &StreamServer{port: port, hls: NewHLSSessionRegistry()}
}
// SetUPnPEnabled toggles WAN publishing of the stream port. Call before
// Listen(); changes after Listen() are ignored for the active server.
func (ss *StreamServer) SetUPnPEnabled(enabled bool) {
ss.enableUPnP = enabled
}
// SetCORSAllowedOrigins replaces the operator-supplied extra origins. The
// default allowlist (torrentclaw.com / app.torrentclaw.com / localhost dev
// ports) is always merged in. Call before Listen().
func (ss *StreamServer) SetCORSAllowedOrigins(origins []string) {
ss.corsExtraOrigins = origins
}
// writeCORSHeaders writes the per-origin CORS response headers when the
// request carries an Origin header that matches the allowlist. Returns true
// if the handler must short-circuit (preflight OPTIONS). Media-tag requests
// (no Origin header) bypass this entirely.
//
// `Vary: Origin` is emitted whenever an Origin header is present (matched
// or not) so any intermediate cache keys the response per-origin and a
// later request with a different origin cannot be served a stale ACAO.
func (ss *StreamServer) writeCORSHeaders(w http.ResponseWriter, r *http.Request, expose string) (preflight bool) {
origin := r.Header.Get("Origin")
if origin == "" {
return false
}
w.Header().Add("Vary", "Origin")
if _, ok := ss.corsAllowlist[origin]; !ok {
// Unknown origin — do not emit CORS headers so the browser blocks
// the response. Still return without short-circuiting so a non-CORS
// caller (e.g. curl) keeps working.
return false
}
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Range")
if expose != "" {
w.Header().Set("Access-Control-Expose-Headers", expose)
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return true
}
return false
}
// HLS returns the HLS session registry for this server. Daemon code uses it
// to register a session when the backend asks for HLS playback.
func (ss *StreamServer) HLS() *HLSSessionRegistry { return ss.hls }
// Listen starts the HTTP server on the configured port. Call once at daemon startup.
func (ss *StreamServer) Listen(ctx context.Context) error {
// Freeze the CORS allowlist before the first request can land. After
// this point the map is treated as read-only so handlers can probe it
// without locking.
ss.corsAllowlist = buildCORSAllowlist(ss.corsExtraOrigins)
mux := http.NewServeMux()
mux.HandleFunc("/stream", ss.handler)
mux.HandleFunc("/health", ss.healthHandler)
@ -122,11 +190,16 @@ func (ss *StreamServer) Listen(ctx context.Context) error {
if tsIP := TailscaleIP(); tsIP != "" {
ss.urls.Tailscale = fmt.Sprintf("http://%s:%d/stream", tsIP, ss.port)
}
if !ss.disableUPnP {
if mapping, err := SetupUPnP(ss.port); err == nil {
if ss.enableUPnP {
mapping, err := SetupUPnP(ss.port)
if err != nil {
log.Printf("[stream] UPnP setup failed: %v (only LAN/Tailscale clients will reach port %d)", err, ss.port)
} else {
ss.upnpMapping = mapping
ss.urls.Public = fmt.Sprintf("http://%s:%d/stream", mapping.ExternalIP, mapping.ExternalPort)
}
} else {
log.Printf("[stream] UPnP disabled — port %d not published to WAN (set downloads.enable_upnp = true to opt in)", ss.port)
}
// Best single URL for backward compat: Tailscale > LAN > Public > localhost
@ -284,16 +357,8 @@ func (ss *StreamServer) HLSURLsJSON(sessionID string) string {
func (ss *StreamServer) hlsHandler(w http.ResponseWriter, r *http.Request) {
ss.lastActivity.Store(time.Now().UnixNano())
// CORS for app.torrentclaw.com → 127.0.0.1/Tailscale daemon.
if origin := r.Header.Get("Origin"); origin != "" {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Range")
w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if ss.writeCORSHeaders(w, r, "Content-Length, Content-Range, Accept-Ranges") {
return
}
rest := strings.TrimPrefix(r.URL.Path, "/hls/")
@ -303,6 +368,12 @@ func (ss *StreamServer) hlsHandler(w http.ResponseWriter, r *http.Request) {
return
}
sessionID := parts[0]
// Reject malformed IDs with the same 404 we return for unknown sessions —
// no oracle for the accepted format.
if !validSessionID.MatchString(sessionID) {
http.Error(w, "hls session not found", http.StatusNotFound)
return
}
session := ss.hls.Get(sessionID)
if session == nil {
http.Error(w, "hls session not found", http.StatusNotFound)
@ -386,12 +457,26 @@ func (ss *StreamServer) serveSubtitlePlaylist(w http.ResponseWriter, r *http.Req
//
// curl http://<tailscale-ip>:<port>/health
func (ss *StreamServer) healthHandler(w http.ResponseWriter, r *http.Request) {
if ss.writeCORSHeaders(w, r, "") {
return
}
ss.mu.RLock()
provider := ss.provider
taskID := ss.taskID
ss.mu.RUnlock()
clientIP, _, _ := net.SplitHostPort(r.RemoteAddr)
// Only expose filename/taskID/client to loopback callers (local diagnostics).
// Remote callers (LAN, Tailscale, UPnP public) get a minimal probe response
// so that scanners and unauthenticated peers cannot fingerprint the active
// download. The web stream-probe only checks HTTP 200 + Content-Type.
//
// Use net.IP.IsLoopback so we also accept ::ffff:127.0.0.1 (Linux dual-stack
// IPv4-mapped form) and reject the empty-string fallthrough when
// SplitHostPort fails on a malformed RemoteAddr — both would otherwise
// silently bypass the disclosure boundary.
parsedIP := net.ParseIP(clientIP)
isLocal := parsedIP != nil && parsedIP.IsLoopback()
type healthResponse struct {
Status string `json:"status"`
@ -399,19 +484,23 @@ func (ss *StreamServer) healthHandler(w http.ResponseWriter, r *http.Request) {
File string `json:"file,omitempty"`
Task string `json:"task,omitempty"`
Port int `json:"port"`
Client string `json:"client"`
Client string `json:"client,omitempty"`
}
resp := healthResponse{
Status: "ok",
Port: ss.port,
Client: clientIP,
}
if provider != nil {
resp.Streaming = true
resp.File = provider.FileName()
resp.Task = taskID
if len(resp.Task) > 8 {
resp.Task = resp.Task[:8]
}
if isLocal {
resp.Client = clientIP
if provider != nil {
resp.File = provider.FileName()
resp.Task = taskID
if len(resp.Task) > 8 {
resp.Task = resp.Task[:8]
}
}
}
@ -427,15 +516,8 @@ func (ss *StreamServer) healthHandler(w http.ResponseWriter, r *http.Request) {
// VLC fetches this playlist and applies the EXTVLCOPT directives automatically,
// enabling automatic audio/subtitle track selection on all VLC platforms (desktop + mobile).
func (ss *StreamServer) playlistHandler(w http.ResponseWriter, r *http.Request) {
// CORS — handle preflight before doing any work (consistent with handler)
if origin := r.Header.Get("Origin"); origin != "" {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Range")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if ss.writeCORSHeaders(w, r, "") {
return
}
q := r.URL.Query()
@ -505,17 +587,8 @@ func (ss *StreamServer) handler(w http.ResponseWriter, r *http.Request) {
return
}
// CORS headers — only when browser sends Origin (HTTPS site → localhost)
if origin := r.Header.Get("Origin"); origin != "" {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Range")
w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Range, Accept-Ranges")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
if ss.writeCORSHeaders(w, r, "Content-Length, Content-Range, Accept-Ranges") {
return
}
rawReader := provider.NewFileReader(r.Context())

View file

@ -0,0 +1,119 @@
package engine
import (
"context"
"os"
"strings"
"testing"
"time"
)
func TestStreamServerURLsJSON(t *testing.T) {
ss := &StreamServer{}
ss.urls = StreamURLs{LAN: "http://10.0.0.1:8000/stream", Tailscale: "http://100.64.0.1:8000/stream"}
got := ss.URLsJSON()
if !strings.Contains(got, `"lan":"http://10.0.0.1:8000/stream"`) {
t.Errorf("URLsJSON missing LAN: %s", got)
}
if !strings.Contains(got, `"ts":"http://100.64.0.1:8000/stream"`) {
t.Errorf("URLsJSON missing Tailscale: %s", got)
}
}
func TestStreamServerHLSBaseURLs(t *testing.T) {
ss := &StreamServer{}
ss.urls = StreamURLs{
LAN: "http://10.0.0.1:8000/stream",
Tailscale: "http://100.64.0.1:8000/stream",
Public: "http://1.2.3.4:9000/stream",
}
out := ss.hlsBaseURLs("sess-1")
if out.LAN != "http://10.0.0.1:8000/hls/sess-1" {
t.Errorf("LAN swap = %q", out.LAN)
}
if out.Tailscale != "http://100.64.0.1:8000/hls/sess-1" {
t.Errorf("Tailscale swap = %q", out.Tailscale)
}
if out.Public != "http://1.2.3.4:9000/hls/sess-1" {
t.Errorf("Public swap = %q", out.Public)
}
js := ss.HLSURLsJSON("sess-1")
if !strings.Contains(js, "/hls/sess-1") {
t.Errorf("HLSURLsJSON output unexpected: %s", js)
}
}
func TestStreamServerIdleSinceZeroBeforeActivity(t *testing.T) {
ss := &StreamServer{}
if got := ss.IdleSince(); got != 0 {
t.Errorf("IdleSince before any activity = %v, want 0", got)
}
ss.lastActivity.Store(time.Now().Add(-1 * time.Second).UnixNano())
if got := ss.IdleSince(); got <= 0 {
t.Errorf("IdleSince after activity should be > 0, got %v", got)
}
}
func TestDiskFileProvider(t *testing.T) {
tmp := t.TempDir() + "/movie.mp4"
data := []byte("hello stream")
if err := os.WriteFile(tmp, data, 0o644); err != nil {
t.Fatal(err)
}
p := NewDiskFileProvider(tmp)
if got := p.FileName(); got != "movie.mp4" {
t.Errorf("FileName = %q", got)
}
if got := p.FileSize(); got != int64(len(data)) {
t.Errorf("FileSize = %d, want %d", got, len(data))
}
rdr := p.NewFileReader(context.Background())
if rdr == nil {
t.Fatal("NewFileReader = nil")
}
defer rdr.Close()
buf := make([]byte, len(data))
n, _ := rdr.Read(buf)
if string(buf[:n]) != string(data) {
t.Errorf("read = %q, want %q", buf[:n], data)
}
}
func TestDiskFileProviderMissing(t *testing.T) {
p := NewDiskFileProvider("/nonexistent/file.mp4")
if rdr := p.NewFileReader(context.Background()); rdr != nil {
t.Errorf("NewFileReader on missing file should return nil")
}
if got := p.FileSize(); got != 0 {
t.Errorf("FileSize on missing file = %d, want 0", got)
}
}
func TestFindVideoFile(t *testing.T) {
tmp := t.TempDir()
os.WriteFile(tmp+"/readme.txt", make([]byte, 1000), 0o644) //nolint:errcheck
os.WriteFile(tmp+"/sample.mkv", make([]byte, 10*1024*1024), 0o644) //nolint:errcheck
os.WriteFile(tmp+"/clip.mp4", make([]byte, 1024*1024), 0o644) //nolint:errcheck
os.MkdirAll(tmp+"/sub", 0o755) //nolint:errcheck
os.WriteFile(tmp+"/sub/extra.mp4", make([]byte, 5*1024*1024), 0o644) //nolint:errcheck
got := FindVideoFile(tmp)
if !strings.HasSuffix(got, "sample.mkv") {
t.Errorf("FindVideoFile = %q, want largest *.mkv", got)
}
}
func TestFindVideoFileEmpty(t *testing.T) {
tmp := t.TempDir()
if got := FindVideoFile(tmp); got != "" {
t.Errorf("FindVideoFile on empty dir = %q, want ''", got)
}
}
func TestLanIPReturnsValidOrEmpty(t *testing.T) {
ip := LanIP()
if ip != "" && !strings.Contains(ip, ".") && !strings.Contains(ip, ":") {
t.Errorf("LanIP returned non-empty non-IP: %q", ip)
}
}

View file

@ -5,6 +5,7 @@ import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
@ -379,6 +380,149 @@ func TestStreamServer_Health_WithFile(t *testing.T) {
}
}
// TestStreamServer_Health_NonLoopback_NoLeak verifica que /health no revela
// nombre de fichero, taskID ni client IP cuando el caller no es loopback.
// Protección contra reconnaissance vía LAN / UPnP / Tailscale.
func TestStreamServer_Health_NonLoopback_NoLeak(t *testing.T) {
srv := NewStreamServer(0) // UPnP off by default — keep test hermetic
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(ctx)
provider := newFakeProvider("secret.mkv", []byte("data"))
srv.SetFile(provider, "secret-task-id")
cases := []struct {
name string
remoteAddr string
}{
{"lan_ipv4", "192.168.1.50:54321"},
{"empty_host_no_bypass", ":54321"},
{"public_ipv4", "203.0.113.10:443"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
req.RemoteAddr = tc.remoteAddr
srv.healthHandler(rr, req)
body := rr.Body.String()
if !strings.Contains(body, `"status":"ok"`) {
t.Errorf("body missing status:ok: %q", body)
}
if !strings.Contains(body, `"streaming":true`) {
t.Errorf("body should report streaming bool: %q", body)
}
if strings.Contains(body, "secret.mkv") {
t.Errorf("body leaked filename: %q", body)
}
if strings.Contains(body, "secret-t") {
t.Errorf("body leaked task id: %q", body)
}
if strings.Contains(body, "192.168.1.50") || strings.Contains(body, "203.0.113.10") {
t.Errorf("body leaked client ip: %q", body)
}
})
}
}
// TestStreamServer_CORS_Allowlist verifica que sólo los origenes en la
// allowlist reciben Access-Control-Allow-Origin y que ningún otro origen
// es eco-reflejado.
func TestStreamServer_CORS_Allowlist(t *testing.T) {
srv := NewStreamServer(0)
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen: %v", err)
}
defer srv.Shutdown(ctx)
cases := []struct {
origin string
wantAllow bool
}{
{"https://app.torrentclaw.com", true},
{"https://torrentclaw.com", true},
{"http://localhost:3030", true},
{"http://127.0.0.1:3030", true},
{"https://evil.example", false},
{"null", false},
{"", false},
}
for _, tc := range cases {
t.Run(tc.origin, func(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodOptions, "/health", nil)
if tc.origin != "" {
req.Header.Set("Origin", tc.origin)
}
srv.healthHandler(rr, req)
got := rr.Header().Get("Access-Control-Allow-Origin")
if tc.wantAllow {
if got != tc.origin {
t.Errorf("origin %q: ACAO = %q, want %q", tc.origin, got, tc.origin)
}
} else if got != "" {
t.Errorf("origin %q: ACAO leaked as %q, expected empty", tc.origin, got)
}
})
}
}
// TestStreamServer_CORS_ExtraOrigin verifica que SetCORSAllowedOrigins añade
// origins al baseline sin removerlos.
func TestStreamServer_CORS_ExtraOrigin(t *testing.T) {
srv := NewStreamServer(0)
srv.SetCORSAllowedOrigins([]string{"https://custom.example"})
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen: %v", err)
}
defer srv.Shutdown(ctx)
for _, origin := range []string{"https://custom.example", "https://torrentclaw.com"} {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/health", nil)
req.Header.Set("Origin", origin)
srv.healthHandler(rr, req)
if got := rr.Header().Get("Access-Control-Allow-Origin"); got != origin {
t.Errorf("origin %q: ACAO = %q", origin, got)
}
}
}
// TestStreamServer_HLS_InvalidSessionID verifica que el hlsHandler rechaza
// session IDs con caracteres ilegales devolviendo 404 (uniforme con sesión
// inexistente) para no filtrar el formato aceptado a un attacker.
func TestStreamServer_HLS_InvalidSessionID(t *testing.T) {
srv := NewStreamServer(0) // UPnP off by default — keep test hermetic
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("Listen() error: %v", err)
}
defer srv.Shutdown(ctx)
bad := []string{
"/hls/..%2Fetc%2Fpasswd/master.m3u8",
"/hls/foo.bar/master.m3u8",
"/hls/foo%20bar/master.m3u8",
"/hls/foo%2Fbar/master.m3u8",
}
for _, path := range bad {
t.Run(path, func(t *testing.T) {
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, path, nil)
srv.hlsHandler(rr, req)
if rr.Code != http.StatusNotFound {
t.Errorf("path %q: status = %d, want 404", path, rr.Code)
}
})
}
}
// TestStreamServer_MKV_ContentType verifica que el Content-Type para .mkv
// es el correcto.
func TestStreamServer_MKV_ContentType(t *testing.T) {

View file

@ -12,7 +12,7 @@ import (
"time"
)
// streamSource abstracts the byte source served over the WebRTC DataChannel.
// streamSource abstracts the byte source consumed by the HLS transcoder.
// Two implementations:
// - diskFileSource — direct passthrough of the on-disk file.
// - transcodeSource — ffmpeg writes a fragmented MP4 to a temp file in

View file

@ -0,0 +1,90 @@
package engine
import (
"os"
"path/filepath"
"testing"
)
func TestParseBitrateKbps(t *testing.T) {
cases := []struct {
in string
fb int
want int
}{
{"", 5000, 5000},
{"192k", 0, 192},
{"192K", 0, 192},
{"5M", 0, 5000},
{"5m", 0, 5000},
{"4500", 0, 4500},
{"bogus", 100, 100},
{"0k", 100, 100},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
if got := parseBitrateKbps(tc.in, tc.fb); got != tc.want {
t.Errorf("parseBitrateKbps(%q,%d) = %d, want %d", tc.in, tc.fb, got, tc.want)
}
})
}
}
func TestEstimateOutputSize(t *testing.T) {
if got := estimateOutputSize(nil, TranscodeOpts{}); got != 0 {
t.Errorf("nil probe -> 0, got %d", got)
}
if got := estimateOutputSize(&StreamProbe{}, TranscodeOpts{}); got != 0 {
t.Errorf("zero duration -> 0, got %d", got)
}
probe := &StreamProbe{DurationSec: 60}
opts := TranscodeOpts{VideoBitrate: "5M", AudioBitrate: "192k"}
// (5000 + 192) * 1000 / 8 = 649_000 bytes/s; *60 = 38_940_000
got := estimateOutputSize(probe, opts)
if got != 38_940_000 {
t.Errorf("estimateOutputSize = %d, want 38_940_000", got)
}
}
func TestDiskFileSourceLifecycle(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "movie.bin")
data := []byte("hello world")
if err := os.WriteFile(path, data, 0o644); err != nil {
t.Fatal(err)
}
src, err := newDiskFileSource(path)
if err != nil {
t.Fatalf("newDiskFileSource: %v", err)
}
defer src.Close()
if src.Size() != int64(len(data)) {
t.Errorf("Size = %d, want %d", src.Size(), len(data))
}
if src.EstimatedSize() != src.Size() {
t.Errorf("EstimatedSize should equal Size for disk source")
}
if !src.Final() {
t.Errorf("disk source should be Final")
}
if src.Transcoded() {
t.Errorf("disk source should not report Transcoded")
}
if src.FileName() != "movie.bin" {
t.Errorf("FileName = %q", src.FileName())
}
buf := make([]byte, 5)
n, err := src.ReadAt(buf, 6)
if err != nil || n != 5 || string(buf) != "world" {
t.Errorf("ReadAt = (%d,%v,%q), want (5,nil,'world')", n, err, buf)
}
}
func TestDiskFileSourceMissing(t *testing.T) {
if _, err := newDiskFileSource("/nonexistent/movie.bin"); err == nil {
t.Error("expected error opening nonexistent file")
}
}

View file

@ -4,6 +4,7 @@ import (
"fmt"
"sync"
"time"
"unicode/utf8"
"github.com/torrentclaw/unarr/internal/agent"
)
@ -229,10 +230,25 @@ func (t *Task) ToStatusUpdate() agent.StatusUpdate {
FileName: t.FileName,
FilePath: t.FilePath,
StreamURL: t.StreamURL,
ErrorMessage: t.ErrorMessage,
// Cap to the server's stored length. A failed extract can carry a
// multi-KB unrar/par2 dump; sending it raw made /agent/status 400
// the whole report, leaving the task stuck non-terminal.
ErrorMessage: truncateMsg(t.ErrorMessage, 2000),
}
}
// truncateMsg caps s to at most max bytes without splitting a UTF-8 rune.
func truncateMsg(s string, max int) string {
if len(s) <= max {
return s
}
cut := max
for cut > 0 && !utf8.RuneStart(s[cut]) {
cut--
}
return s[:cut]
}
// MagnetURI builds a magnet link from the info hash.
func (t *Task) MagnetURI() string {
return "magnet:?xt=urn:btih:" + t.InfoHash

View file

@ -16,8 +16,8 @@ import (
alog "github.com/anacrolix/log"
"github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/storage"
"github.com/pion/webrtc/v4"
"github.com/torrentclaw/unarr/internal/config"
"github.com/torrentclaw/unarr/internal/vpn"
"golang.org/x/term"
"golang.org/x/time/rate"
)
@ -72,13 +72,10 @@ type TorrentConfig struct {
SeedRatio float64 // target seed ratio (default 0, meaning seed until SeedTime)
SeedTime time.Duration // min seed time after completion (default 0)
// WebRTC peer (WebTorrent protocol) for browser ↔ unarr P2P streaming.
// When enabled, anacrolix/torrent's built-in webtorrent package handles
// the WSS signaling + WebRTC data channels. Implies upload allowed for
// every torrent in the client (browsers can't pull pieces otherwise).
WebRTCEnabled bool
WebRTCTrackers []string // wss://… signaling trackers added to every magnet
ICEServers []webrtc.ICEServer // STUN + TURN servers for NAT traversal
// VPNTunnel, when set, split-tunnels the torrent client's peer + tracker
// traffic through an in-process userspace WireGuard tunnel (managed-VPN
// add-on). nil = downloads in the clear. Brought up by the daemon.
VPNTunnel *vpn.Tunnel
}
// TorrentDownloader downloads torrents via BitTorrent P2P.
@ -105,26 +102,11 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
tcfg := torrent.NewDefaultClientConfig()
tcfg.DataDir = cfg.DataDir
tcfg.Seed = cfg.SeedEnabled
// WebRTC peers (browsers) can only pull pieces from us if upload is
// enabled. We honour SeedEnabled for the long-tail seed-after-complete
// behaviour but unconditionally allow upload while WebRTC is on so an
// active download can still serve to a watching browser.
tcfg.NoUpload = !cfg.SeedEnabled && !cfg.WebRTCEnabled
tcfg.Logger = alog.Default.FilterLevel(alog.Warning) // bumped from Critical for WebRTC peer + tracker announce visibility
tcfg.NoUpload = !cfg.SeedEnabled
tcfg.Logger = alog.Default.FilterLevel(alog.Warning)
// WebRTC / WebTorrent peer: anacrolix auto-routes ws://+wss:// trackers
// to the bundled webtorrent.TrackerClient. We only need to populate the
// ICE server list so the SDP offers we send carry usable candidates.
if cfg.WebRTCEnabled {
tcfg.DisableWebtorrent = false
if len(cfg.ICEServers) > 0 {
tcfg.ICEServerList = cfg.ICEServers
}
log.Printf("[torrent] WebRTC peer enabled (trackers=%d ice_servers=%d)",
len(cfg.WebRTCTrackers), len(cfg.ICEServers))
} else {
tcfg.DisableWebtorrent = true
}
// No browser-facing WebTorrent peer; daemon never seeds via WSS.
tcfg.DisableWebtorrent = true
// --- Performance optimizations ---
@ -218,6 +200,20 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
// Re-announce active torrents to DHT periodically (keeps routing table healthy).
tcfg.PeriodicallyAnnounceTorrentsToDht = true
// --- Managed-VPN split-tunnel ---
// Route the torrent client's outbound peer + tracker traffic through the
// in-process WireGuard tunnel so the swarm + trackers see the VPN IP, not
// the user's. unarr's control plane keeps using the normal net. uTP (UDP
// peers) is disabled — TCP peers + HTTP/UDP tracker announces are tunnelled;
// inbound peers don't apply (leech-only, no port forward).
if cfg.VPNTunnel != nil {
tcfg.DisableUTP = true
tcfg.TrackerDialContext = cfg.VPNTunnel.Net.DialContext
tcfg.HTTPDialContext = cfg.VPNTunnel.Net.DialContext
tcfg.TrackerListenPacket = cfg.VPNTunnel.ListenPacket
log.Printf("[torrent] VPN split-tunnel enabled (peer + tracker traffic routed through WireGuard)")
}
// Try to create client; if the port is in use, try the next few ports.
var client *torrent.Client
var err error
@ -239,6 +235,12 @@ func NewTorrentDownloader(cfg TorrentConfig) (*TorrentDownloader, error) {
log.Printf("[torrent] listening on port %d (configured: %d was busy)", tcfg.ListenPort, listenPort)
}
// Route outgoing peer dials through the VPN tunnel (TCP). Added after client
// creation; DialForPeerConns defaults to true so this is used for peers.
if cfg.VPNTunnel != nil {
client.AddDialer(torrent.NetworkDialer{Network: "tcp", Dialer: cfg.VPNTunnel.Net})
}
// Restore DHT nodes with full node IDs (direct routing table insertion, no async pings).
for _, s := range client.DhtServers() {
if w, ok := s.(torrent.AnacrolixDhtServerWrapper); ok {
@ -631,30 +633,17 @@ func (d *TorrentDownloader) selectFiles(t *torrent.Torrent, taskID string) (tota
return totalBytes, fileName
}
// buildMagnet composes a magnet URI for the info hash. extraTrackers (e.g.
// wss://… for WebRTC peer signaling) are prepended so anacrolix's
// webtorrent.TrackerClient picks them up first; the static UDP list
// follows. Empty / whitespace entries in extraTrackers are skipped.
func buildMagnet(infoHash string, extraTrackers ...string) string {
// buildMagnet composes a magnet URI for the info hash with the static
// tracker list.
func buildMagnet(infoHash string) string {
params := []string{"xt=urn:btih:" + infoHash}
for _, t := range extraTrackers {
t = strings.TrimSpace(t)
if t == "" {
continue
}
params = append(params, "tr="+url.QueryEscape(t))
}
for _, tracker := range defaultTrackers {
params = append(params, "tr="+url.QueryEscape(tracker))
}
return "magnet:?" + strings.Join(params, "&")
}
// buildMagnet on the downloader injects its WebRTC trackers when enabled.
func (d *TorrentDownloader) buildMagnet(infoHash string) string {
if d != nil && d.cfg.WebRTCEnabled {
return buildMagnet(infoHash, d.cfg.WebRTCTrackers...)
}
return buildMagnet(infoHash)
}

View file

@ -0,0 +1,64 @@
package engine
// TranscodeRuntime carries the resolved ffmpeg/ffprobe paths + tunables so
// each session can decide whether to passthrough or pipe through ffmpeg.
type TranscodeRuntime struct {
FFmpegPath string
FFprobePath string
HWAccel HWAccel
Preset string
VideoBitrate string
AudioBitrate string
MaxHeight int
// Disabled forces passthrough for every file even when codecs are not
// browser-friendly. Useful when the user explicitly turns transcoding
// off in config.
Disabled bool
}
// qualityCap maps a session's Quality label to a (MaxHeight, VideoBitrate)
// pair. An empty label or "original" returns zero-values, signalling "no
// override" to the caller.
type qualityCap struct {
MaxHeight int
VideoBitrate string // ffmpeg -b:v string, e.g. "3500k"
}
func resolveQualityCap(label string) qualityCap {
switch label {
case "2160p":
return qualityCap{MaxHeight: 2160, VideoBitrate: "25000k"}
case "1080p":
return qualityCap{MaxHeight: 1080, VideoBitrate: "6000k"}
case "720p":
return qualityCap{MaxHeight: 720, VideoBitrate: "3500k"}
case "480p":
return qualityCap{MaxHeight: 480, VideoBitrate: "1500k"}
default:
// "original", "auto", "" → defer to config.
return qualityCap{}
}
}
// capForHeight returns the bitrate-cap pair appropriate for an effective
// output height. Used after clamping outputHeight to the source's resolution:
// asking ffmpeg for "2160p" bitrate (25 Mbps) on a 1080p source overshoots
// the H.264 level we derived from the EFFECTIVE height (4.0, max 20 Mbps) and
// makes libx264 refuse with "VBV bitrate > level limit". This helper picks
// the bitrate that matches the level libx264 will actually accept.
func capForHeight(height int) qualityCap {
switch {
case height <= 0:
return qualityCap{}
case height <= 480:
return qualityCap{MaxHeight: 480, VideoBitrate: "1500k"}
case height <= 720:
return qualityCap{MaxHeight: 720, VideoBitrate: "3500k"}
case height <= 1080:
return qualityCap{MaxHeight: 1080, VideoBitrate: "6000k"}
case height <= 1440:
return qualityCap{MaxHeight: 1440, VideoBitrate: "12000k"}
default:
return qualityCap{MaxHeight: 2160, VideoBitrate: "25000k"}
}
}

View file

@ -11,10 +11,9 @@ import (
"time"
)
// TranscodeOpts steers how Transcoder builds its ffmpeg command line. Defaults
// match the project's plan/clever-weaving-dove.md (Fase 2.5):
// TranscodeOpts steers how Transcoder builds its ffmpeg command line.
//
// - Output: fragmented MP4 readable by browser <video> via MSE-less Range.
// - Output: fragmented MP4 chunked into HLS segments by the muxer.
// - Audio: AAC stereo @ 192kbps unless source already AAC (then -c:a copy).
// - Video: copy when h264 8-bit; otherwise transcode to h264 with HW encode
// when available, software fallback at "veryfast" preset.
@ -31,11 +30,11 @@ type TranscodeOpts struct {
}
// Transcoder wraps a long-running ffmpeg child process whose stdout streams
// fragmented MP4 bytes for the WebRTC pump to forward to the browser.
// fragmented MP4 bytes; the HLS muxer slices them into segments served over HTTP.
//
// One Transcoder == one playback position. A seek beyond the buffered window
// requires Close()ing this transcoder and starting a new one with a higher
// StartSeconds (handled in webrtc_stream.go).
// StartSeconds (handled by the HLS session at ffmpeg start time).
//
// A single internal goroutine owns cmd.Wait() — never call cmd.Wait()
// directly from outside (os/exec forbids concurrent Wait callers). Use
@ -269,12 +268,9 @@ func buildFFmpegArgs(filePath string, opts TranscodeOpts) []string {
filterChain = "format=yuv420p,setparams=colorspace=bt709:color_trc=bt709:color_primaries=bt709:range=tv"
}
args = append(args, "-vf", filterChain)
// Force AAC-LC stereo 48 kHz so MSE's CHUNK_DEMUXER accepts the moov.
// 5.1 / 7.1 source streams produce a moov shape that MSE refuses to
// parse (the <video src=blob:> demuxer is more forgiving), so we
// always downmix to stereo and resample to 48 kHz here. Source
// material that's already stereo passes through losslessly aside
// from the re-encode.
// Force AAC-LC stereo 48 kHz so the hls.js demuxer accepts the moov.
// 5.1 / 7.1 source streams produce a moov shape the demuxer refuses
// to parse, so always downmix to stereo + resample to 48 kHz here.
args = append(args,
"-c:a", "aac",
"-b:a", coalesce(opts.AudioBitrate, "192k"),
@ -285,13 +281,12 @@ func buildFFmpegArgs(filePath string, opts TranscodeOpts) []string {
// Common output flags — fragmented MP4 to a single pipe.
//
// * empty_moov + default_base_moof: write a header-only init segment
// up front so MSE can start decoding before the file is finished.
// * frag_duration=1s: cap each moof+mdat at ~1 second of media. Without
// this, ffmpeg only splits at keyframes, which on a high-bitrate
// 1080p stream produces 8 MiB+ mdat boxes — MSE refuses to parse
// the first fragment until the whole mdat lands, so playback never
// starts.
// * empty_moov + default_base_moof: header-only init segment up front
// so the demuxer can start decoding before the file is finished.
// * frag_duration=1s: cap each moof+mdat at ~1 second of media.
// Without it ffmpeg only splits at keyframes; a high-bitrate 1080p
// stream produces 8 MiB+ mdat boxes that delay the first fragment
// until the whole mdat lands and playback never starts.
// * negative_cts_offsets: lets b-frames carry the right pts/dts so
// decoders don't reset the playhead to 0 every fragment.
args = append(args,

View file

@ -132,6 +132,65 @@ func TestBuildFFmpegArgsAddsStartSeek(t *testing.T) {
}
}
func TestTranscoderZeroValueLifecycle(t *testing.T) {
var tr Transcoder
if tr.IsClosing() {
t.Errorf("zero-value Transcoder should not report IsClosing")
}
if tr.Stderr() != "" {
t.Errorf("zero-value Stderr should be empty")
}
if err := tr.WaitErr(); err != nil {
t.Errorf("WaitErr without started cmd should be nil, got %v", err)
}
if err := tr.Close(); err != nil {
t.Errorf("Close without started cmd should be nil, got %v", err)
}
// Second Close is idempotent and must remain nil.
if err := tr.Close(); err != nil {
t.Errorf("repeat Close should be nil, got %v", err)
}
if !tr.IsClosing() {
t.Errorf("after Close, IsClosing should be true")
}
if tr.Done() != nil {
t.Errorf("Done() should be nil for never-started Transcoder")
}
}
func TestErrWriterCapturesStderr(t *testing.T) {
tr := &Transcoder{}
w := &errWriter{t: tr}
n, err := w.Write([]byte("ffmpeg failed: bad codec"))
if err != nil || n != 24 {
t.Errorf("Write returned (%d,%v)", n, err)
}
if got := tr.Stderr(); got != "ffmpeg failed: bad codec" {
t.Errorf("Stderr captured %q", got)
}
}
func TestErrWriterCapsBuffer(t *testing.T) {
tr := &Transcoder{}
w := &errWriter{t: tr}
// Write a chunk under the cap, then a huge chunk: total should stop growing past 64KB.
w.Write(make([]byte, 32*1024)) //nolint:errcheck
w.Write(make([]byte, 32*1024)) //nolint:errcheck
w.Write(make([]byte, 32*1024)) //nolint:errcheck
if got := len(tr.Stderr()); got > 64*1024 {
t.Errorf("stderr exceeded 64KB cap: %d bytes", got)
}
}
func TestCoalesce(t *testing.T) {
if got := coalesce("", "fallback"); got != "fallback" {
t.Errorf("empty -> fallback, got %q", got)
}
if got := coalesce("value", "fallback"); got != "value" {
t.Errorf("non-empty -> value, got %q", got)
}
}
func TestBuildFFmpegArgsDownscale(t *testing.T) {
args := buildFFmpegArgs("/tmp/movie.mkv", TranscodeOpts{
Action: ActionTranscodeVideo,

View file

@ -2,6 +2,7 @@ package engine
import (
"context"
"strings"
"sync"
"testing"
"time"
@ -74,3 +75,63 @@ func TestUsenetDownloader_Pause_NonExistent(t *testing.T) {
t.Errorf("Pause non-existent task = %v, want nil", err)
}
}
func TestUsenetDownloader_MethodAndAvailable(t *testing.T) {
u := NewUsenetDownloader(agent.NewClient("http://localhost", "", "test"))
if got := u.Method(); got != MethodUsenet {
t.Errorf("Method = %v, want %v", got, MethodUsenet)
}
// Disabled → never available, no error.
u.SetEnabled(false)
ok, err := u.Available(context.Background(), &Task{Title: "Foo"})
if err != nil || ok {
t.Errorf("disabled Available = (%v,%v), want (false,nil)", ok, err)
}
u.SetEnabled(true)
// No IMDb / no title → not available, no error.
ok, err = u.Available(context.Background(), &Task{})
if err != nil || ok {
t.Errorf("empty task Available = (%v,%v), want (false,nil)", ok, err)
}
// Pre-resolved NzbID → available immediately.
ok, err = u.Available(context.Background(), &Task{NzbID: "preresolved", Title: "Bar"})
if err != nil || !ok {
t.Errorf("preresolved NzbID Available = (%v,%v), want (true,nil)", ok, err)
}
}
func TestUsenetDownloader_Shutdown(t *testing.T) {
u := NewUsenetDownloader(agent.NewClient("http://localhost", "", "test"))
// Inject a fake active download — Shutdown should cancel it and clear the map.
_, cancel := context.WithCancel(context.Background())
u.active["t1"] = &activeDownload{cancel: cancel}
if err := u.Shutdown(context.Background()); err != nil {
t.Errorf("Shutdown = %v, want nil", err)
}
if len(u.active) != 0 {
t.Errorf("Shutdown should clear active downloads, got %d", len(u.active))
}
}
func TestSanitizeDir(t *testing.T) {
cases := map[string]string{
"": "usenet_download",
"normal_name": "normal_name",
"path/with/slashes": "path_with_slashes",
`win\\bad:name*?"<>|`: "win__bad_name______",
"con:tains/all\\bad?chars*": "con_tains_all_bad_chars_",
}
for in, want := range cases {
if got := sanitizeDir(in); got != want {
t.Errorf("sanitizeDir(%q) = %q, want %q", in, got, want)
}
}
long := strings.Repeat("a", 300)
if got := sanitizeDir(long); len(got) != 200 {
t.Errorf("expected sanitizeDir to truncate to 200, got %d", len(got))
}
}

View file

@ -0,0 +1,97 @@
package engine
import (
"strings"
"testing"
)
func TestBuildHLSFFmpegArgsVAAPI(t *testing.T) {
cfg := HLSSessionConfig{
SessionID: "test",
SourcePath: "/tmp/test.mkv",
Quality: "720p",
AudioIndex: 0,
Transcode: TranscodeRuntime{
FFmpegPath: "/usr/bin/ffmpeg",
FFprobePath: "/usr/bin/ffprobe",
HWAccel: HWAccelVAAPI,
},
}
probe := &StreamProbe{Width: 1920, Height: 1080, DurationSec: 100}
args := buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 0, 0)
got := strings.Join(args, " ")
wants := []string{
"-hwaccel vaapi",
"-vaapi_device /dev/dri/renderD128",
"-c:v h264_vaapi",
"format=nv12",
"hwupload",
}
for _, want := range wants {
if !strings.Contains(got, want) {
t.Errorf("argv missing %q\n%s", want, got)
}
}
if strings.Contains(got, "scale_vaapi") {
t.Errorf("argv unexpectedly contains scale_vaapi (mesa bug): %s", got)
}
if strings.Contains(got, "format=yuv420p") {
t.Errorf("argv contains format=yuv420p (libx264 path) for VAAPI codec: %s", got)
}
}
func TestBuildHLSFFmpegArgsLibx264NoRegression(t *testing.T) {
cfg := HLSSessionConfig{
SessionID: "test",
SourcePath: "/tmp/test.mkv",
Quality: "720p",
AudioIndex: 0,
Transcode: TranscodeRuntime{
FFmpegPath: "/usr/bin/ffmpeg",
FFprobePath: "/usr/bin/ffprobe",
HWAccel: HWAccelNone,
},
}
probe := &StreamProbe{Width: 1920, Height: 1080, DurationSec: 100}
args := buildHLSFFmpegArgsAt(cfg, probe, "/tmp/tmpdir", 0, 0)
got := strings.Join(args, " ")
for _, want := range []string{"-c:v libx264", "format=yuv420p", "setparams=colorspace=bt709"} {
if !strings.Contains(got, want) {
t.Errorf("libx264 argv missing %q: %s", want, got)
}
}
for _, bad := range []string{"-vaapi_device", "format=nv12", "hwupload"} {
if strings.Contains(got, bad) {
t.Errorf("libx264 argv unexpectedly contains %q: %s", bad, got)
}
}
}
// TestBuildHLSFFmpegArgsVAAPIDump prints the full argv buildHLSFFmpegArgsAt
// emits for a typical VAAPI session. Mimics the daemon spawn step so the
// operator can verify the ffmpeg command-line shape without booting the
// stack — equivalent to `journalctl --user -u unarr-dev | grep ffmpeg`
// but without waiting for a real player session.
func TestBuildHLSFFmpegArgsVAAPIDump(t *testing.T) {
cfg := HLSSessionConfig{
SessionID: "vaapi-smoke",
SourcePath: "/mnt/nas/peliculas/sample.mkv",
Quality: "720p",
AudioIndex: -1,
Transcode: TranscodeRuntime{
FFmpegPath: "/usr/bin/ffmpeg",
FFprobePath: "/usr/bin/ffprobe",
HWAccel: HWAccelVAAPI,
},
}
probe := &StreamProbe{
VideoCodec: "hevc",
Width: 3840,
Height: 2160,
DurationSec: 5400,
AudioTracks: []ProbeAudioTrack{{Index: 0, Lang: "en", Codec: "ac3"}},
}
args := buildHLSFFmpegArgsAt(cfg, probe, "/tmp/smoke-tmpdir", 0, 0)
t.Logf("ffmpeg %s", strings.Join(args, " "))
}

View file

@ -0,0 +1,63 @@
// Package engine — validate.go centralises input validators used by the
// stream/HLS HTTP handlers and the daemon glue. Keep new validators in this
// file so a future reviewer can audit the trust boundary in one place.
package engine
import "regexp"
// validSessionID restricts session IDs to characters safe for use as a single
// filesystem path component. Server-issued UUIDs and hex strings match this;
// anything containing slashes, dots, or path separators is rejected so a
// compromised or buggy server cannot escape hlsTmpDirRoot via os.MkdirAll.
var validSessionID = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,128}$`)
// defaultCORSAllowedOrigins is the baseline of browser origins that may
// XHR-probe `/health` and friends on the local daemon. Production hosts are
// hardcoded; localhost on the dev port used by torrentclaw-web is included
// so dev builds work without extra configuration. Operators may add more
// origins via the [downloads] cors_extra_origins TOML key.
//
// The dev port matches `next dev -p 3030` in torrentclaw-web/package.json.
// 127.0.0.1 is listed in addition to localhost because some browsers treat
// them as distinct origins for CORS.
//
// Mirrors (`.to`, `staging.torrentclaw.com`, `www.`) are listed so a user
// playing from any official mirror succeeds the HEAD probe; without these
// the browser drops the response for "missing ACAO" and the player reports
// "404 todos los canales" even though the daemon returned 200.
//
// Note: media tags (<video src>, <audio src>) do not send the Origin
// header so they are not gated by CORS at all; this allowlist only
// affects fetch()/XHR.
var defaultCORSAllowedOrigins = []string{
"https://torrentclaw.com",
"https://www.torrentclaw.com",
"https://app.torrentclaw.com",
"https://staging.torrentclaw.com",
"https://torrentclaw.to",
"https://www.torrentclaw.to",
// Tor mirror — Tor Browser sends `Origin: http://<addr>.onion` (plain
// http, no port). Mirror address is the BUILT_IN_ONION constant from
// torrentclaw-web/src/lib/mirrors-config.ts; rotates rarely, kept in
// sync by hand. Daemon also dynamically merges /api/mirrors at startup
// (see daemon.go) so a new key doesn't need a CLI rebuild.
"http://torrentf3aifidcsaaanmnmuhv2s53r6hqsl3zkmfidiaxainkeqk5id.onion",
"http://localhost:3030",
"http://127.0.0.1:3030",
}
// buildCORSAllowlist merges the default origins with any extras supplied by
// the operator. Returned map is intended to be installed once at Listen()
// and treated as read-only afterwards.
func buildCORSAllowlist(extra []string) map[string]struct{} {
out := make(map[string]struct{}, len(defaultCORSAllowedOrigins)+len(extra))
for _, o := range defaultCORSAllowedOrigins {
out[o] = struct{}{}
}
for _, o := range extra {
if o != "" {
out[o] = struct{}{}
}
}
return out
}

View file

@ -2,10 +2,16 @@ package engine
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"sync/atomic"
"testing"
"time"
"github.com/torrentclaw/unarr/internal/agent"
)
// ---------------------------------------------------------------------------
@ -69,6 +75,105 @@ func TestMaxByteOffsetNeverRegresses(t *testing.T) {
// End-to-end: real HTTP server with Range requests
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// WatchReporter.sendReport via the agent API
// ---------------------------------------------------------------------------
func TestWatchReporter_NewWatchReporter(t *testing.T) {
c := agent.NewClient("http://localhost", "", "test")
ss := &StreamServer{}
wr := NewWatchReporter(c, ss, "task-1")
if wr.taskID != "task-1" || wr.client != c || wr.server != ss {
t.Errorf("NewWatchReporter fields not wired: %+v", wr)
}
}
func TestWatchReporter_sendReportSkipsZeroProgress(t *testing.T) {
var hits atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hits.Add(1)
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true})
}))
defer srv.Close()
ss := &StreamServer{}
// totalFileSize == 0 → EstimatedProgress returns (0, 0) → sendReport skips.
c := agent.NewClient(srv.URL, "", "test")
wr := NewWatchReporter(c, ss, "task-1")
wr.sendReport(context.Background())
if hits.Load() != 0 {
t.Errorf("expected no API calls when progress=0, got %d", hits.Load())
}
}
func TestWatchReporter_sendReportPostsProgress(t *testing.T) {
var captured atomic.Pointer[agent.WatchProgressUpdate]
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var update agent.WatchProgressUpdate
_ = json.NewDecoder(r.Body).Decode(&update)
captured.Store(&update)
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
ss := &StreamServer{}
ss.totalFileSize.Store(1000)
ss.maxByteOffset.Store(250) // 25%
ss.durationSec.Store(120)
c := agent.NewClient(srv.URL, "", "test")
wr := NewWatchReporter(c, ss, "task-12345678")
wr.sendReport(context.Background())
got := captured.Load()
if got == nil {
t.Fatal("expected a watch-progress POST")
}
if got.TaskID != "task-12345678" {
t.Errorf("TaskID = %q", got.TaskID)
}
if got.Progress == nil || *got.Progress != 25 {
t.Errorf("Progress = %v, want 25", got.Progress)
}
if got.Duration == nil || *got.Duration != 120 {
t.Errorf("Duration = %v, want 120", got.Duration)
}
if got.Position == nil || *got.Position != 30 {
t.Errorf("Position = %v, want 30", got.Position)
}
// Repeat report at same percentage — should NOT POST again.
captured.Store(nil)
wr.sendReport(context.Background())
if captured.Load() != nil {
t.Errorf("repeat sendReport at same pct should be a no-op")
}
}
func TestWatchReporter_RunStopsOnContextCancel(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"ok":true}`))
}))
defer srv.Close()
ss := &StreamServer{}
c := agent.NewClient(srv.URL, "", "test")
wr := NewWatchReporter(c, ss, "task-x")
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
wr.Run(ctx)
close(done)
}()
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Run did not return after context cancellation")
}
}
func TestStreamServerByteTracking(t *testing.T) {
// Create temp file (10 KB)
tmpFile := t.TempDir() + "/test.mp4"
@ -80,8 +185,7 @@ func TestStreamServerByteTracking(t *testing.T) {
t.Fatal(err)
}
srv := NewStreamServer(0)
srv.disableUPnP = true
srv := NewStreamServer(0) // UPnP off by default — keep test hermetic
ctx := context.Background()
if err := srv.Listen(ctx); err != nil {
t.Fatalf("listen: %v", err)

View file

@ -1,36 +0,0 @@
package engine
import (
"github.com/pion/webrtc/v4"
"github.com/torrentclaw/unarr/internal/config"
)
// BuildICEServers converts a config.WebRTCConfig into the
// []webrtc.ICEServer slice that anacrolix/torrent's webtorrent client
// needs. STUN entries become bare URLs; TURN entries inherit the shared
// TURNUser / TURNPass credentials. Returns nil when WebRTC is disabled.
func BuildICEServers(cfg config.WebRTCConfig) []webrtc.ICEServer {
if !cfg.Enabled {
return nil
}
var servers []webrtc.ICEServer
for _, s := range cfg.STUNServers {
if s == "" {
continue
}
servers = append(servers, webrtc.ICEServer{URLs: []string{s}})
}
for _, t := range cfg.TURNServers {
if t == "" {
continue
}
entry := webrtc.ICEServer{URLs: []string{t}}
if cfg.TURNUser != "" {
entry.Username = cfg.TURNUser
entry.Credential = cfg.TURNPass
entry.CredentialType = webrtc.ICECredentialTypePassword
}
servers = append(servers, entry)
}
return servers
}

View file

@ -1,784 +0,0 @@
// Package engine — webrtc_stream.go implements the daemon side of the custom
// WebRTC byte-streaming protocol. The browser opens an RTCDataChannel via
// SDP exchange (signalled over the web's HTTP + SSE relay); this code:
//
// 1. Parses the browser's SDP offer.
// 2. Creates a pion PeerConnection bound to the configured ICE servers.
// 3. Answers + trickles its own ICE candidates back through the signal client.
// 4. On DataChannel open, sends a HELLO frame describing the file.
// 5. Services RangeReq frames by reading from disk and emitting RangeData
// chunks (16 KiB each) followed by a RangeEnd.
// 6. Honours app-level backpressure via SetBufferedAmountLowThreshold +
// OnBufferedAmountLow — Chromium closes a DataChannel when bufferedAmount
// exceeds 16 MiB, so we MUST pause the writer.
//
// No anacrolix, no torrent metadata. Just a peer-to-peer file server over
// WebRTC. Pass-through path; transcoding lives in transcoder.go (Fase 2.5).
package engine
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"path/filepath"
"sync"
"sync/atomic"
"time"
"github.com/pion/webrtc/v4"
"github.com/torrentclaw/unarr/internal/agent"
"github.com/torrentclaw/unarr/internal/engine/wire"
)
// Tunables — values match the protocol spec in plan/clever-weaving-dove.md.
const (
// dcChunkPayload is the per-frame application payload size. Must match
// wire.MaxChunkPayload so RangeData frames fit one SCTP message.
dcChunkPayload = wire.MaxChunkPayload
// dcHighWatermark is the bufferedAmount cap above which the writer pauses.
// Chromium closes DCs above 16 MiB; pause well below.
dcHighWatermark = 8 << 20
// dcLowWatermark triggers OnBufferedAmountLow → resume the writer.
dcLowWatermark = 1 << 20
// rangeReqConcurrency is the cap on in-flight range responses per session.
rangeReqConcurrency = 4
// helloDeadline is the max wait for the DataChannel to open after answer.
helloDeadline = 30 * time.Second
)
// WebRTCStreamConfig describes a single browser ↔ daemon stream session.
type WebRTCStreamConfig struct {
SessionID string
FilePath string
FileName string
FileSize int64
ICEServers []webrtc.ICEServer
Signal *agent.Client
// Logger receives diagnostic events; a nil logger swallows everything.
Logger StreamLogger
// Transcode steers on-the-fly transcoding when source codecs are not
// browser-decodable (HEVC/AV1/AC3/DTS). Empty FFmpegPath disables it.
Transcode TranscodeRuntime
// Quality overrides the cap from Transcode for this session. One of
// "2160p" | "1080p" | "720p" | "480p" | "original" | "" (= defer to
// Transcode defaults).
Quality string
}
// TranscodeRuntime carries the resolved ffmpeg/ffprobe paths + tunables so
// each session can decide whether to passthrough or pipe through ffmpeg.
type TranscodeRuntime struct {
FFmpegPath string
FFprobePath string
HWAccel HWAccel
Preset string
VideoBitrate string
AudioBitrate string
MaxHeight int
// Disabled forces passthrough for every file even when codecs are not
// browser-friendly. Useful when the user explicitly turns transcoding
// off in config.
Disabled bool
}
// StreamLogger is an injectable logger so tests can capture events.
type StreamLogger interface {
Infof(format string, args ...any)
Warnf(format string, args ...any)
Errorf(format string, args ...any)
}
type nopLogger struct{}
func (nopLogger) Infof(string, ...any) {}
func (nopLogger) Warnf(string, ...any) {}
func (nopLogger) Errorf(string, ...any) {}
func logger(l StreamLogger) StreamLogger {
if l == nil {
return nopLogger{}
}
return l
}
// qualityCap maps a session's Quality label to a (MaxHeight, VideoBitrate)
// pair. An empty label or "original" returns zero-values, signalling "no
// override" to the caller.
type qualityCap struct {
MaxHeight int
VideoBitrate string // ffmpeg -b:v string, e.g. "3500k"
}
func resolveQualityCap(label string) qualityCap {
switch label {
case "2160p":
return qualityCap{MaxHeight: 2160, VideoBitrate: "25000k"}
case "1080p":
return qualityCap{MaxHeight: 1080, VideoBitrate: "6000k"}
case "720p":
return qualityCap{MaxHeight: 720, VideoBitrate: "3500k"}
case "480p":
return qualityCap{MaxHeight: 480, VideoBitrate: "1500k"}
default:
// "original", "auto", "" → defer to config.
return qualityCap{}
}
}
// buildStreamSource picks between passthrough and transcoded source. ffprobe
// failure or missing ffmpeg falls back to passthrough — the browser surfaces
// a clearer codec error than us refusing to start.
//
// Quality override (cfg.Quality) can force a downscale even when the source
// codec is browser-friendly: a 4K h264 file watched on a phone with quality
// "720p" must transcode (otherwise we'd ship 4K bytes for a 6" screen).
func buildStreamSource(
ctx context.Context,
abs string,
displayName string,
cfg WebRTCStreamConfig,
log StreamLogger,
) (streamSource, error) {
tc := cfg.Transcode
qcap := resolveQualityCap(cfg.Quality)
if tc.Disabled || tc.FFmpegPath == "" || tc.FFprobePath == "" {
return newDiskFileSource(abs)
}
probe, err := ProbeFile(ctx, tc.FFprobePath, abs)
if err != nil {
log.Warnf("[wrtc %s] probe failed (%v) — passthrough", agent.ShortID(cfg.SessionID), err)
return newDiskFileSource(abs)
}
action := DecideAction(probe)
// Quality cap can promote a passthrough/remux decision into a full video
// transcode when the source resolution exceeds the requested cap.
if qcap.MaxHeight > 0 && probe.Height > 0 && probe.Height > qcap.MaxHeight && action != ActionTranscodeVideo {
log.Infof("[wrtc %s] quality=%s caps height %d→%d — forcing video transcode",
agent.ShortID(cfg.SessionID), cfg.Quality, probe.Height, qcap.MaxHeight)
action = ActionTranscodeVideo
}
if action == ActionPassthrough {
log.Infof("[wrtc %s] codec passthrough (%s + %s in %s)",
agent.ShortID(cfg.SessionID), probe.VideoCodec, probe.AudioCodec, probe.Container)
return newDiskFileSource(abs)
}
log.Infof("[wrtc %s] transcoding %s/%s/%s → h264+aac (%s, quality=%s)",
agent.ShortID(cfg.SessionID), probe.Container, probe.VideoCodec, probe.AudioCodec,
action, coalesce(cfg.Quality, "default"))
maxHeight := tc.MaxHeight
videoBitrate := tc.VideoBitrate
if qcap.MaxHeight > 0 {
maxHeight = qcap.MaxHeight
videoBitrate = qcap.VideoBitrate
}
opts := TranscodeOpts{
Action: action,
HWAccel: tc.HWAccel,
Preset: tc.Preset,
VideoBitrate: videoBitrate,
AudioBitrate: tc.AudioBitrate,
MaxHeight: maxHeight,
SourceHeight: probe.Height,
FFmpegPath: tc.FFmpegPath,
}
return newTranscodeSource(ctx, abs, probe, action, opts, displayName)
}
// RunWebRTCStream blocks until the session ends — either the DataChannel
// closes, the peer connection drops, or ctx is cancelled. Always returns a
// non-nil error explaining the termination reason.
func RunWebRTCStream(ctx context.Context, cfg WebRTCStreamConfig) error {
log := logger(cfg.Logger)
if cfg.SessionID == "" {
return errors.New("webrtc_stream: empty SessionID")
}
if cfg.FilePath == "" {
return errors.New("webrtc_stream: empty FilePath")
}
abs, err := filepath.Abs(cfg.FilePath)
if err != nil {
return fmt.Errorf("webrtc_stream: resolve path: %w", err)
}
displayName := cfg.FileName
if displayName == "" {
displayName = filepath.Base(abs)
}
// Decide passthrough vs transcoding. Probe is best-effort: if ffprobe
// is missing or fails we fall back to passthrough (the browser will
// surface a clearer error than us guessing wrong).
source, err := buildStreamSource(ctx, abs, displayName, cfg, log)
if err != nil {
return fmt.Errorf("webrtc_stream: build source: %w", err)
}
defer source.Close()
// 1. Build PeerConnection.
api := webrtc.NewAPI()
pc, err := api.NewPeerConnection(webrtc.Configuration{
ICEServers: cfg.ICEServers,
})
if err != nil {
return fmt.Errorf("webrtc_stream: new peer connection: %w", err)
}
defer pc.Close()
sessionCtx, cancelSession := context.WithCancel(ctx)
defer cancelSession()
// Stop the session when ICE drops permanently. "Disconnected" is
// transient per RFC 8445 (NAT rebind, brief packet loss) — wait for
// "Failed" or "Closed" before tearing down.
pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
log.Infof("[wrtc %s] ice=%s", agent.ShortID(cfg.SessionID), state.String())
switch state {
case webrtc.ICEConnectionStateFailed,
webrtc.ICEConnectionStateClosed:
cancelSession()
case webrtc.ICEConnectionStateUnknown,
webrtc.ICEConnectionStateNew,
webrtc.ICEConnectionStateChecking,
webrtc.ICEConnectionStateConnected,
webrtc.ICEConnectionStateCompleted,
webrtc.ICEConnectionStateDisconnected:
// Disconnected is transient (RFC 8445 — NAT rebind / packet loss);
// the others are normal progress states. Don't tear the session down.
}
})
// Trickle our ICE candidates back to the browser.
// PostSignal runs on its own goroutine so a slow signal server can't
// stall pion's ICE-gathering thread.
pc.OnICECandidate(func(c *webrtc.ICECandidate) {
if c == nil {
go func() {
_ = cfg.Signal.PostSignal(sessionCtx, cfg.SessionID, agent.SignalMessage{
Type: agent.SignalMsgCandidateEnd,
Payload: "",
})
}()
return
}
init := c.ToJSON()
payload, _ := json.Marshal(init)
go func() {
_ = cfg.Signal.PostSignal(sessionCtx, cfg.SessionID, agent.SignalMessage{
Type: agent.SignalMsgCandidate,
Payload: string(payload),
})
}()
})
// Browser is the offerer — we react to the DataChannel it creates.
dcReady := make(chan *webrtc.DataChannel, 1)
pc.OnDataChannel(func(dc *webrtc.DataChannel) {
log.Infof("[wrtc %s] data channel '%s' open", agent.ShortID(cfg.SessionID), dc.Label())
select {
case dcReady <- dc:
default:
// Browser opened a second DC — ignore, we only serve one.
log.Warnf("[wrtc %s] extra data channel ignored", agent.ShortID(cfg.SessionID))
}
})
// 2. Drive the SDP exchange. Any error from the loop (browser sent
// "bye", signal stream closed, etc.) cancels the session so we don't
// dangle on the DC waiting for a peer that's already gone.
sdpDone := make(chan error, 1)
go func() {
err := runSDPExchange(sessionCtx, pc, cfg)
sdpDone <- err
if err != nil && sessionCtx.Err() == nil {
log.Infof("[wrtc %s] signal loop ended: %v", agent.ShortID(cfg.SessionID), err)
cancelSession()
}
}()
// 3. Wait for either SDP error or DataChannel open.
var dc *webrtc.DataChannel
select {
case err := <-sdpDone:
if err != nil {
return fmt.Errorf("sdp exchange: %w", err)
}
// SDP complete — wait for the DC.
select {
case dc = <-dcReady:
case <-time.After(helloDeadline):
return errors.New("webrtc_stream: data channel never opened")
case <-sessionCtx.Done():
return sessionCtx.Err()
}
case dc = <-dcReady:
// DC opened before SDP loop reported done (typical: the loop keeps
// running to ferry remote ICE candidates).
case <-sessionCtx.Done():
return sessionCtx.Err()
}
// 4. Wire up the data channel pump.
pump := newDataChannelPump(dc, source, log, cancelSession)
dc.OnOpen(pump.onOpen)
dc.OnMessage(pump.onMessage)
dc.OnClose(func() {
log.Infof("[wrtc %s] data channel closed", agent.ShortID(cfg.SessionID))
cancelSession()
})
<-sessionCtx.Done()
pump.shutdown()
return sessionCtx.Err()
}
// runSDPExchange consumes signal events from the browser and answers the SDP
// offer. Keeps running for the lifetime of sessionCtx so trickle candidates
// flow in both directions. Reopens the SSE stream on every clean close — the
// server caps each response at ~25 s.
func runSDPExchange(ctx context.Context, pc *webrtc.PeerConnection, cfg WebRTCStreamConfig) error {
gotOffer := false
for ctx.Err() == nil {
stream, err := cfg.Signal.OpenSignalStream(ctx, cfg.SessionID)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return fmt.Errorf("open signal stream: %w", err)
}
err = consumeSignalStream(ctx, pc, cfg, stream, &gotOffer)
stream.Close()
if err != nil {
return err
}
}
return ctx.Err()
}
// consumeSignalStream drains a single SSE connection until it closes or
// produces a hard error. Returns nil on a clean server-side disconnect so the
// caller can reopen.
func consumeSignalStream(
ctx context.Context,
pc *webrtc.PeerConnection,
cfg WebRTCStreamConfig,
stream *agent.SignalEventStream,
gotOffer *bool,
) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case msg, ok := <-stream.Events():
if !ok {
if err := stream.Err(); err != nil {
return fmt.Errorf("signal stream: %w", err)
}
return nil
}
if err := handleSignal(ctx, pc, cfg, msg, gotOffer); err != nil {
return err
}
}
}
}
func handleSignal(
ctx context.Context,
pc *webrtc.PeerConnection,
cfg WebRTCStreamConfig,
msg agent.SignalMessage,
gotOffer *bool,
) error {
switch msg.Type {
case agent.SignalMsgAnswer:
// Browser is the offerer in our protocol — we never expect an answer
// from the other side. Drop silently (also satisfies exhaustive lint).
return nil
case agent.SignalMsgOffer:
if *gotOffer {
return nil // ignore duplicates
}
var offer webrtc.SessionDescription
if err := json.Unmarshal([]byte(msg.Payload), &offer); err != nil {
return fmt.Errorf("decode offer: %w", err)
}
if err := pc.SetRemoteDescription(offer); err != nil {
return fmt.Errorf("set remote description: %w", err)
}
answer, err := pc.CreateAnswer(nil)
if err != nil {
return fmt.Errorf("create answer: %w", err)
}
if err := pc.SetLocalDescription(answer); err != nil {
return fmt.Errorf("set local description: %w", err)
}
// Send back the local description *with* gathered candidates so far —
// remaining candidates trickle separately via OnICECandidate.
ld := pc.LocalDescription()
payload, _ := json.Marshal(ld)
if err := cfg.Signal.PostSignal(ctx, cfg.SessionID, agent.SignalMessage{
Type: agent.SignalMsgAnswer,
Payload: string(payload),
}); err != nil {
return fmt.Errorf("post answer: %w", err)
}
*gotOffer = true
case agent.SignalMsgCandidate:
if !*gotOffer {
// Browser may trickle candidates before we've seen the offer in
// rare race conditions — drop. Browser will retransmit.
return nil
}
var init webrtc.ICECandidateInit
if err := json.Unmarshal([]byte(msg.Payload), &init); err != nil {
return fmt.Errorf("decode candidate: %w", err)
}
if err := pc.AddICECandidate(init); err != nil {
return fmt.Errorf("add ice candidate: %w", err)
}
case agent.SignalMsgCandidateEnd:
// No-op — pion gathers complete on its own.
case agent.SignalMsgBye:
return errors.New("browser sent bye")
}
return nil
}
// dataChannelPump owns the DC + stream source and serves wire-protocol frames.
type dataChannelPump struct {
dc *webrtc.DataChannel
source streamSource
log StreamLogger
cancel context.CancelFunc
// Flow control: writers wait on resumeCh when bufferedAmount goes high.
paused atomic.Bool
resumeCh chan struct{}
// Active range responses keyed by stream_id so CANCEL frames can stop them.
activeMu sync.Mutex
active map[uint32]context.CancelFunc
// Bound concurrent in-flight responses.
sem chan struct{}
// closed once shutdown() has been called.
closed atomic.Bool
}
func newDataChannelPump(
dc *webrtc.DataChannel,
source streamSource,
log StreamLogger,
cancel context.CancelFunc,
) *dataChannelPump {
p := &dataChannelPump{
dc: dc,
source: source,
log: log,
cancel: cancel,
resumeCh: make(chan struct{}, 1),
active: make(map[uint32]context.CancelFunc),
sem: make(chan struct{}, rangeReqConcurrency),
}
dc.SetBufferedAmountLowThreshold(dcLowWatermark)
dc.OnBufferedAmountLow(p.onBufferedAmountLow)
return p
}
func (p *dataChannelPump) onOpen() {
// Use estimated size for transcoded streams so the browser scrubber has
// something to anchor on. Real size is reflected by Range responses as
// ffmpeg writes more bytes; the estimate just bootstraps the UI.
announceSize := p.source.EstimatedSize()
transcoding := p.source.Transcoded()
// Browsers refuse to start playback when Content-Length is 0. If we don't
// have a duration estimate (e.g. ffprobe couldn't tag the source), declare
// a large sentinel so the browser issues range requests; the Transcoding
// flag tells it the value is provisional.
if transcoding && announceSize <= 0 {
announceSize = math.MaxInt64
}
// Seekable=true even for transcoded sources because we read from a tmp
// file (random access). Seek backwards just works; seek forward beyond
// what ffmpeg has produced will block briefly inside ReadAt.
seekable := true
hello := wire.HelloPayload{
FileSize: uint64(announceSize),
Transcoding: transcoding,
Seekable: seekable,
FileName: p.source.FileName(),
}
payload := wire.EncodeHello(hello)
frame := wire.EncodeFrame(wire.Header{
Type: wire.FrameHello,
Flags: wire.HelloFlags(transcoding, seekable),
StreamID: 0,
Length: uint32(len(payload)),
}, payload)
if err := p.dc.Send(frame); err != nil {
p.log.Errorf("send hello: %v", err)
p.cancel()
}
}
func (p *dataChannelPump) onMessage(msg webrtc.DataChannelMessage) {
if len(msg.Data) < wire.HeaderSize {
p.log.Warnf("dc: short frame %d bytes", len(msg.Data))
return
}
hdr, err := wire.DecodeHeader(msg.Data[:wire.HeaderSize])
if err != nil {
p.log.Warnf("dc: bad header: %v", err)
return
}
payload := msg.Data[wire.HeaderSize:]
if uint32(len(payload)) != hdr.Length {
p.log.Warnf("dc: payload length mismatch: hdr=%d got=%d", hdr.Length, len(payload))
return
}
switch hdr.Type {
case wire.FrameRangeReq:
req, err := wire.DecodeRangeReq(payload)
if err != nil {
p.log.Warnf("dc: bad range_req: %v", err)
return
}
go p.serveRange(hdr.StreamID, req)
case wire.FrameCancel:
p.cancelStream(hdr.StreamID)
case wire.FramePing:
p.sendSimpleFrame(wire.FramePong, hdr.StreamID, nil)
case wire.FramePong:
// no-op
default:
p.log.Warnf("dc: unknown frame type 0x%02x", hdr.Type)
}
}
func (p *dataChannelPump) cancelStream(streamID uint32) {
p.activeMu.Lock()
cancel, ok := p.active[streamID]
delete(p.active, streamID)
p.activeMu.Unlock()
if ok {
cancel()
}
}
func (p *dataChannelPump) sendSimpleFrame(t wire.FrameType, streamID uint32, payload []byte) {
frame := wire.EncodeFrame(wire.Header{
Type: t,
StreamID: streamID,
Length: uint32(len(payload)),
}, payload)
if err := p.dc.Send(frame); err != nil {
p.log.Warnf("dc: send type=0x%02x: %v", t, err)
}
}
func (p *dataChannelPump) serveRange(streamID uint32, req wire.RangeReqPayload) {
if p.closed.Load() {
return
}
// Bound concurrency.
select {
case p.sem <- struct{}{}:
case <-time.After(5 * time.Second):
p.log.Warnf("dc: range_req sid=%d dropped (concurrency cap)", streamID)
p.sendRangeEnd(streamID, 1)
return
}
defer func() { <-p.sem }()
// Reject offsets above MaxInt64 — uint64→int64 narrowing would wrap to a
// negative value and bypass the bounds check, then ReadAt would be called
// with a negative offset.
currentSize := p.source.Size()
finalSize := p.source.EstimatedSize()
if req.Offset > math.MaxInt64 {
p.sendRangeEnd(streamID, 2) // out of range
return
}
// For transcoded streams `currentSize` grows over time; only reject when
// the offset is past the *estimated* final size.
if int64(req.Offset) >= finalSize && p.source.Final() {
p.sendRangeEnd(streamID, 2)
return
}
want := int64(req.Length)
if req.Length > math.MaxInt64 {
want = 0 // treat absurd length as "remainder of file"
}
// Cap by *final* size, not currentSize. For a still-transcoding stream
// currentSize grows over time and ReadAt below already blocks until
// ffmpeg produces the requested bytes (with a deadline). If we cap
// `want` by currentSize here we'll send an empty RangeEnd whenever the
// browser asks for bytes faster than ffmpeg writes them — which is
// always true on the first few seconds — and the browser then aborts
// playback with "Format error".
cap := finalSize
if !p.source.Final() && cap < int64(req.Offset)+1 {
// Estimate too small: serve as much as the browser asked for and
// let ReadAt block.
cap = int64(req.Offset) + want
}
if int64(req.Offset) >= cap && p.source.Final() {
// Past true end of a finished file.
p.sendRangeEnd(streamID, 0)
return
}
remaining := cap - int64(req.Offset)
if remaining < 0 {
remaining = 0
}
if want <= 0 || want > remaining {
want = remaining
}
p.log.Infof("dc: range_req sid=%d offset=%d wantReq=%d wantServe=%d currentSize=%d final=%v",
streamID, req.Offset, req.Length, want, currentSize, p.source.Final())
if want <= 0 {
// Only happens for a finished file when offset is at/past EOF.
p.sendRangeEnd(streamID, 0)
return
}
ctx, cancel := context.WithCancel(context.Background())
p.activeMu.Lock()
if p.active == nil {
p.activeMu.Unlock()
cancel()
p.sendRangeEnd(streamID, 3)
return
}
p.active[streamID] = cancel
p.activeMu.Unlock()
defer func() {
p.activeMu.Lock()
delete(p.active, streamID)
p.activeMu.Unlock()
cancel()
}()
buf := make([]byte, dcChunkPayload)
offset := int64(req.Offset)
end := offset + want
for offset < end {
if ctx.Err() != nil || p.closed.Load() {
return
}
// Wait if the DC is buffering too much.
if err := p.waitForLowWater(ctx); err != nil {
return
}
chunkLen := int64(len(buf))
if end-offset < chunkLen {
chunkLen = end - offset
}
n, rerr := p.source.ReadAt(buf[:chunkLen], offset)
if n > 0 {
// EOF on a short read means this is the final chunk — flag it so the
// browser doesn't wait for more data before processing RangeEnd.
isLast := offset+int64(n) >= end || rerr == io.EOF
if err := p.sendRangeData(streamID, buf[:n], isLast); err != nil {
p.log.Warnf("dc: send range_data sid=%d: %v", streamID, err)
return
}
offset += int64(n)
}
if rerr != nil {
if rerr == io.EOF {
break
}
p.log.Errorf("dc: read sid=%d: %v", streamID, rerr)
p.sendRangeEnd(streamID, 3)
return
}
}
p.sendRangeEnd(streamID, 0)
}
func (p *dataChannelPump) sendRangeData(streamID uint32, data []byte, last bool) error {
var flags uint8
if last {
flags |= wire.FlagLastChunk
}
frame := wire.EncodeFrame(wire.Header{
Type: wire.FrameRangeData,
Flags: flags,
StreamID: streamID,
Length: uint32(len(data)),
}, data)
return p.dc.Send(frame)
}
func (p *dataChannelPump) sendRangeEnd(streamID uint32, status uint32) {
payload := wire.EncodeRangeEnd(wire.RangeEndPayload{Status: status})
p.sendSimpleFrame(wire.FrameRangeEnd, streamID, payload)
}
func (p *dataChannelPump) waitForLowWater(ctx context.Context) error {
if p.dc.BufferedAmount() < dcHighWatermark {
return nil
}
p.paused.Store(true)
for {
// Drain any stale resume signal first.
select {
case <-p.resumeCh:
default:
}
if p.dc.BufferedAmount() < dcHighWatermark {
p.paused.Store(false)
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-p.resumeCh:
case <-time.After(500 * time.Millisecond):
// Belt-and-braces poll in case OnBufferedAmountLow misses a fire.
}
}
}
func (p *dataChannelPump) onBufferedAmountLow() {
if !p.paused.Load() {
return
}
select {
case p.resumeCh <- struct{}{}:
default:
}
}
func (p *dataChannelPump) shutdown() {
if !p.closed.CompareAndSwap(false, true) {
return
}
p.activeMu.Lock()
for _, cancel := range p.active {
cancel()
}
p.active = nil
p.activeMu.Unlock()
}

View file

@ -1,177 +0,0 @@
package engine
import (
"context"
"net/url"
"strings"
"testing"
"github.com/pion/webrtc/v4"
"github.com/torrentclaw/unarr/internal/config"
)
const validHash = "aaf2c71b0e0a03d3f9b2a3e1d5c6b7a8f0e1d2c3"
// TestBuildMagnet_NoExtras verifies the legacy free-function path keeps
// emitting only the static defaultTrackers list.
func TestBuildMagnet_NoExtras(t *testing.T) {
got := buildMagnet(validHash)
if !strings.HasPrefix(got, "magnet:?xt=urn:btih:"+validHash) {
t.Fatalf("magnet missing xt: %s", got)
}
if !strings.Contains(got, url.QueryEscape("udp://tracker.opentrackr.org:1337/announce")) {
t.Fatal("expected default UDP tracker absent")
}
if strings.Contains(got, "wss%3A") {
t.Fatalf("unexpected WSS tracker leaked when none requested: %s", got)
}
}
// TestBuildMagnet_WithExtraTrackers verifies extraTrackers (e.g. WebRTC
// WSS endpoints) are prepended before the defaults and properly URL-encoded.
func TestBuildMagnet_WithExtraTrackers(t *testing.T) {
got := buildMagnet(validHash, "wss://tracker.torrentclaw.com")
encWss := url.QueryEscape("wss://tracker.torrentclaw.com")
encUDP := url.QueryEscape("udp://tracker.opentrackr.org:1337/announce")
if !strings.Contains(got, "tr="+encWss) {
t.Fatalf("WSS tracker missing: %s", got)
}
wssIdx := strings.Index(got, encWss)
udpIdx := strings.Index(got, encUDP)
if wssIdx < 0 || udpIdx < 0 || wssIdx > udpIdx {
t.Fatalf("WSS tracker should appear BEFORE UDP defaults: wss=%d udp=%d", wssIdx, udpIdx)
}
}
// TestBuildMagnet_TrimsAndSkipsEmpty makes sure callers passing config-derived
// slices with stray whitespace or empty strings don't get malformed magnets.
func TestBuildMagnet_TrimsAndSkipsEmpty(t *testing.T) {
got := buildMagnet(validHash, " wss://tracker.torrentclaw.com ", "", " ")
encWss := url.QueryEscape("wss://tracker.torrentclaw.com")
if !strings.Contains(got, "tr="+encWss) {
t.Fatalf("trimmed WSS tracker missing: %s", got)
}
if strings.Contains(got, "tr=&") || strings.HasSuffix(got, "tr=") {
t.Fatalf("empty tracker emitted: %s", got)
}
}
// TestTorrentDownloader_buildMagnet_WebRTCDisabled confirms the downloader
// method does NOT inject WebRTCTrackers when WebRTCEnabled is false.
func TestTorrentDownloader_buildMagnet_WebRTCDisabled(t *testing.T) {
d := &TorrentDownloader{cfg: TorrentConfig{
WebRTCEnabled: false,
WebRTCTrackers: []string{"wss://tracker.torrentclaw.com"},
}}
got := d.buildMagnet(validHash)
if strings.Contains(got, "wss%3A") {
t.Fatalf("WSS tracker leaked while WebRTCEnabled=false: %s", got)
}
}
// TestTorrentDownloader_buildMagnet_WebRTCEnabled confirms the WSS trackers
// are present when WebRTCEnabled is true.
func TestTorrentDownloader_buildMagnet_WebRTCEnabled(t *testing.T) {
d := &TorrentDownloader{cfg: TorrentConfig{
WebRTCEnabled: true,
WebRTCTrackers: []string{"wss://tracker.torrentclaw.com", "wss://tracker2.example.com"},
}}
got := d.buildMagnet(validHash)
for _, want := range []string{
"wss://tracker.torrentclaw.com",
"wss://tracker2.example.com",
} {
if !strings.Contains(got, url.QueryEscape(want)) {
t.Fatalf("expected tracker %q missing in magnet: %s", want, got)
}
}
}
// TestBuildICEServers_DisabledReturnsNil ensures we don't leak STUN/TURN
// configuration into the torrent client when the user has WebRTC off.
func TestBuildICEServers_DisabledReturnsNil(t *testing.T) {
got := BuildICEServers(config.WebRTCConfig{
Enabled: false,
STUNServers: []string{"stun:stun.l.google.com:19302"},
})
if got != nil {
t.Fatalf("expected nil ICE servers when disabled, got %+v", got)
}
}
// TestBuildICEServers_STUNOnly converts STUN entries to bare ICEServer
// records with no credentials.
func TestBuildICEServers_STUNOnly(t *testing.T) {
got := BuildICEServers(config.WebRTCConfig{
Enabled: true,
STUNServers: []string{"stun:stun.l.google.com:19302", "", "stun:stun1.l.google.com:19302"},
})
if len(got) != 2 {
t.Fatalf("expected 2 STUN servers (empty skipped), got %d (%+v)", len(got), got)
}
if got[0].URLs[0] != "stun:stun.l.google.com:19302" {
t.Fatalf("first server unexpected: %+v", got[0])
}
if got[0].Username != "" || got[0].Credential != nil {
t.Fatalf("STUN entry should have no credentials, got %+v", got[0])
}
}
// TestNewTorrentDownloader_WebRTCEnabled creates a downloader with the
// WebRTC peer fully wired up and confirms the constructor doesn't error
// (anacrolix accepts the ICE server list, port binds, etc.).
func TestNewTorrentDownloader_WebRTCEnabled(t *testing.T) {
dir := t.TempDir()
dl, err := NewTorrentDownloader(TorrentConfig{
DataDir: dir,
ListenPort: 0, // let the OS pick — avoid clashes in CI
WebRTCEnabled: true,
WebRTCTrackers: []string{"wss://tracker.torrentclaw.com"},
ICEServers: BuildICEServers(config.WebRTCConfig{
Enabled: true,
STUNServers: []string{"stun:stun.l.google.com:19302"},
}),
})
if err != nil {
t.Fatalf("WebRTC-enabled downloader failed to start: %v", err)
}
defer func() {
if err := dl.Shutdown(context.Background()); err != nil {
t.Logf("shutdown: %v", err)
}
}()
// Magnet for any task should now contain the WSS tracker.
got := dl.buildMagnet(validHash)
if !strings.Contains(got, "wss%3A%2F%2Ftracker.torrentclaw.com") {
t.Fatalf("WebRTC magnet missing WSS tracker: %s", got)
}
}
// TestBuildICEServers_TURNWithCreds applies TURNUser/TURNPass to every TURN
// entry so the operator only specifies them once.
func TestBuildICEServers_TURNWithCreds(t *testing.T) {
got := BuildICEServers(config.WebRTCConfig{
Enabled: true,
STUNServers: []string{"stun:stun.l.google.com:19302"},
TURNServers: []string{"turn:turn.example.com:3478"},
TURNUser: "alice",
TURNPass: "s3cr3t",
})
if len(got) != 2 {
t.Fatalf("expected 1 STUN + 1 TURN, got %d", len(got))
}
turn := got[1]
if turn.URLs[0] != "turn:turn.example.com:3478" {
t.Fatalf("TURN URL wrong: %+v", turn)
}
if turn.Username != "alice" {
t.Fatalf("TURN username wrong: %s", turn.Username)
}
if turn.Credential != "s3cr3t" {
t.Fatalf("TURN credential wrong: %v", turn.Credential)
}
if turn.CredentialType != webrtc.ICECredentialTypePassword {
t.Fatalf("TURN credential type wrong: %v", turn.CredentialType)
}
}

View file

@ -1,254 +0,0 @@
// Package wire implements the binary frame format used over the WebRTC
// DataChannel between the unarr daemon and the browser stream player.
//
// Header (12 bytes, big-endian):
//
// u8 Type
// u8 Flags
// u16 _reserved
// u32 StreamID -- multiplex range requests
// u32 Length -- payload bytes following the header
//
// Each side encodes one Frame at a time and writes it as a single SCTP
// message (DataChannel send). Browsers cap message size at 64 KiB-ish, so
// callers MUST split RANGE_DATA payloads into chunks <= MaxChunkPayload.
package wire
import (
"encoding/binary"
"errors"
"fmt"
"io"
)
// FrameType identifies the wire message kind.
type FrameType uint8
const (
FrameHello FrameType = 0x00
FrameRangeReq FrameType = 0x01
FrameRangeData FrameType = 0x02
FrameRangeEnd FrameType = 0x03
FrameCancel FrameType = 0x04
FramePing FrameType = 0x05
FramePong FrameType = 0x06
FrameSeekHint FrameType = 0x07
)
// Flag bits — interpretation depends on FrameType.
const (
// FlagLastChunk on a RangeData frame marks the final chunk for a stream_id.
FlagLastChunk uint8 = 1 << 0
// FlagTranscoding on a Hello frame indicates the daemon will transcode.
FlagTranscoding uint8 = 1 << 1
// FlagSeekable on a Hello frame indicates random-access is supported.
FlagSeekable uint8 = 1 << 2
)
// HeaderSize is the fixed length of every frame header.
const HeaderSize = 12
// MaxChunkPayload is the safe per-frame payload cap that works on every
// browser implementation (Chromium fragments at 16 KiB internally above).
// Callers MUST chunk RangeData payloads to <= this size.
const MaxChunkPayload = 16 * 1024
// MaxFrameSize is the largest frame the parser will accept. Anything bigger
// is treated as a corrupted stream — close the channel.
const MaxFrameSize = HeaderSize + 64*1024
// Header is the parsed 12-byte frame header.
type Header struct {
Type FrameType
Flags uint8
StreamID uint32
Length uint32
}
// EncodeHeader writes h to dst (must be at least HeaderSize bytes).
func EncodeHeader(dst []byte, h Header) {
if len(dst) < HeaderSize {
panic("wire: dst too small for header")
}
dst[0] = byte(h.Type)
dst[1] = h.Flags
dst[2] = 0
dst[3] = 0
binary.BigEndian.PutUint32(dst[4:8], h.StreamID)
binary.BigEndian.PutUint32(dst[8:12], h.Length)
}
// DecodeHeader parses src (must be at least HeaderSize bytes) into h.
func DecodeHeader(src []byte) (Header, error) {
if len(src) < HeaderSize {
return Header{}, fmt.Errorf("wire: header needs %d bytes, got %d", HeaderSize, len(src))
}
h := Header{
Type: FrameType(src[0]),
Flags: src[1],
StreamID: binary.BigEndian.Uint32(src[4:8]),
Length: binary.BigEndian.Uint32(src[8:12]),
}
if h.Length > MaxFrameSize-HeaderSize {
return Header{}, fmt.Errorf("wire: payload length %d exceeds max %d", h.Length, MaxFrameSize-HeaderSize)
}
return h, nil
}
// EncodeFrame allocates and returns a complete frame (header + payload).
// Use this for one-shot sends; for hot-path RangeData prefer EncodeHeader
// into a pre-allocated buffer to avoid per-frame allocations.
func EncodeFrame(h Header, payload []byte) []byte {
if int(h.Length) != len(payload) {
panic(fmt.Sprintf("wire: header length %d != payload len %d", h.Length, len(payload)))
}
buf := make([]byte, HeaderSize+len(payload))
EncodeHeader(buf[:HeaderSize], h)
copy(buf[HeaderSize:], payload)
return buf
}
// ReadFrame reads one full frame from r. Returns the parsed header and a
// freshly allocated payload slice. On any size violation the connection
// must be closed — the protocol has no resync.
func ReadFrame(r io.Reader) (Header, []byte, error) {
headerBuf := make([]byte, HeaderSize)
if _, err := io.ReadFull(r, headerBuf); err != nil {
return Header{}, nil, err
}
h, err := DecodeHeader(headerBuf)
if err != nil {
return Header{}, nil, err
}
if h.Length == 0 {
return h, nil, nil
}
payload := make([]byte, h.Length)
if _, err := io.ReadFull(r, payload); err != nil {
return Header{}, nil, err
}
return h, payload, nil
}
// HelloPayload describes the file the daemon is about to serve. It is the
// first frame the daemon writes after the DataChannel opens.
type HelloPayload struct {
FileSize uint64
Transcoding bool
Seekable bool
FileName string
}
// EncodeHello marshals h into a payload byte slice.
//
// Layout: u64 file_size | u32 name_len | name_bytes
func EncodeHello(h HelloPayload) []byte {
nameBytes := []byte(h.FileName)
buf := make([]byte, 8+4+len(nameBytes))
binary.BigEndian.PutUint64(buf[0:8], h.FileSize)
binary.BigEndian.PutUint32(buf[8:12], uint32(len(nameBytes)))
copy(buf[12:], nameBytes)
return buf
}
// DecodeHello parses a Hello payload. The transcoding/seekable bits live in
// the frame Flags byte, not the payload — pass them in.
func DecodeHello(payload []byte, flags uint8) (HelloPayload, error) {
if len(payload) < 12 {
return HelloPayload{}, errors.New("wire: hello payload too short")
}
size := binary.BigEndian.Uint64(payload[0:8])
nameLen := binary.BigEndian.Uint32(payload[8:12])
if int(nameLen) > len(payload)-12 {
return HelloPayload{}, fmt.Errorf("wire: hello name_len %d exceeds payload", nameLen)
}
return HelloPayload{
FileSize: size,
Transcoding: flags&FlagTranscoding != 0,
Seekable: flags&FlagSeekable != 0,
FileName: string(payload[12 : 12+nameLen]),
}, nil
}
// HelloFlags returns the flag byte for a Hello frame given the booleans.
func HelloFlags(transcoding, seekable bool) uint8 {
var f uint8
if transcoding {
f |= FlagTranscoding
}
if seekable {
f |= FlagSeekable
}
return f
}
// RangeReqPayload is the browser → daemon request for bytes [Offset, Offset+Length).
type RangeReqPayload struct {
Offset uint64
Length uint64
}
// EncodeRangeReq marshals p. Layout: u64 offset | u64 length.
func EncodeRangeReq(p RangeReqPayload) []byte {
buf := make([]byte, 16)
binary.BigEndian.PutUint64(buf[0:8], p.Offset)
binary.BigEndian.PutUint64(buf[8:16], p.Length)
return buf
}
// DecodeRangeReq parses a 16-byte range request payload.
func DecodeRangeReq(payload []byte) (RangeReqPayload, error) {
if len(payload) != 16 {
return RangeReqPayload{}, fmt.Errorf("wire: range_req payload must be 16 bytes, got %d", len(payload))
}
return RangeReqPayload{
Offset: binary.BigEndian.Uint64(payload[0:8]),
Length: binary.BigEndian.Uint64(payload[8:16]),
}, nil
}
// RangeEndPayload signals end-of-response for a stream_id with a status code.
// Status 0 == OK; non-zero values are app-defined error codes.
type RangeEndPayload struct {
Status uint32
}
// EncodeRangeEnd marshals p.
func EncodeRangeEnd(p RangeEndPayload) []byte {
buf := make([]byte, 4)
binary.BigEndian.PutUint32(buf[0:4], p.Status)
return buf
}
// DecodeRangeEnd parses a 4-byte range_end payload.
func DecodeRangeEnd(payload []byte) (RangeEndPayload, error) {
if len(payload) != 4 {
return RangeEndPayload{}, fmt.Errorf("wire: range_end payload must be 4 bytes, got %d", len(payload))
}
return RangeEndPayload{
Status: binary.BigEndian.Uint32(payload[0:4]),
}, nil
}
// SeekHintPayload tells the daemon a seek to timestamp_ms is imminent so it
// can pre-warm a transcoder pipeline before bytes are requested.
type SeekHintPayload struct {
TimestampMs uint64
}
// EncodeSeekHint marshals p.
func EncodeSeekHint(p SeekHintPayload) []byte {
buf := make([]byte, 8)
binary.BigEndian.PutUint64(buf[0:8], p.TimestampMs)
return buf
}
// DecodeSeekHint parses an 8-byte seek_hint payload.
func DecodeSeekHint(payload []byte) (SeekHintPayload, error) {
if len(payload) != 8 {
return SeekHintPayload{}, fmt.Errorf("wire: seek_hint payload must be 8 bytes, got %d", len(payload))
}
return SeekHintPayload{
TimestampMs: binary.BigEndian.Uint64(payload[0:8]),
}, nil
}

View file

@ -1,193 +0,0 @@
package wire
import (
"bytes"
"testing"
)
func TestHeaderRoundtrip(t *testing.T) {
cases := []Header{
{Type: FrameHello, Flags: FlagSeekable, StreamID: 0, Length: 32},
{Type: FrameRangeReq, Flags: 0, StreamID: 7, Length: 16},
{Type: FrameRangeData, Flags: FlagLastChunk, StreamID: 4242, Length: 16380},
{Type: FrameRangeEnd, Flags: 0, StreamID: 1, Length: 4},
{Type: FrameCancel, Flags: 0, StreamID: 9, Length: 0},
{Type: FramePing, Flags: 0, StreamID: 0, Length: 0},
}
for _, want := range cases {
buf := make([]byte, HeaderSize)
EncodeHeader(buf, want)
got, err := DecodeHeader(buf)
if err != nil {
t.Fatalf("decode: %v (want %+v)", err, want)
}
if got != want {
t.Errorf("roundtrip mismatch: got %+v want %+v", got, want)
}
}
}
func TestDecodeHeaderShort(t *testing.T) {
if _, err := DecodeHeader([]byte{0, 0, 0}); err == nil {
t.Fatal("expected error on short header")
}
}
func TestDecodeHeaderRejectsHugeLength(t *testing.T) {
// Synthesize a header with payload length above MaxFrameSize.
buf := make([]byte, HeaderSize)
buf[0] = byte(FrameRangeData)
buf[8] = 0xff
buf[9] = 0xff
buf[10] = 0xff
buf[11] = 0xff
if _, err := DecodeHeader(buf); err == nil {
t.Fatal("expected error on oversized payload length")
}
}
func TestEncodeFramePanicsOnLengthMismatch(t *testing.T) {
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic on header length / payload mismatch")
}
}()
EncodeFrame(Header{Type: FrameRangeData, Length: 5}, []byte{1, 2, 3})
}
func TestReadFrameRoundtrip(t *testing.T) {
want := Header{Type: FrameRangeData, Flags: FlagLastChunk, StreamID: 99, Length: 5}
payload := []byte{0xde, 0xad, 0xbe, 0xef, 0x42}
frame := EncodeFrame(want, payload)
r := bytes.NewReader(frame)
got, gotPayload, err := ReadFrame(r)
if err != nil {
t.Fatalf("read: %v", err)
}
if got != want {
t.Errorf("header mismatch: %+v want %+v", got, want)
}
if !bytes.Equal(gotPayload, payload) {
t.Errorf("payload mismatch: %x want %x", gotPayload, payload)
}
}
func TestReadFrameZeroPayload(t *testing.T) {
want := Header{Type: FrameCancel, StreamID: 7}
frame := EncodeFrame(want, nil)
got, payload, err := ReadFrame(bytes.NewReader(frame))
if err != nil {
t.Fatalf("read: %v", err)
}
if got != want {
t.Errorf("header mismatch: %+v want %+v", got, want)
}
if len(payload) != 0 {
t.Errorf("expected empty payload, got %d bytes", len(payload))
}
}
func TestHelloRoundtrip(t *testing.T) {
want := HelloPayload{
FileSize: 1<<32 + 12345,
Transcoding: false,
Seekable: true,
FileName: "Tangled.Ever.After.2025.1080p.WEB-DL.h264.mp4",
}
flags := HelloFlags(want.Transcoding, want.Seekable)
payload := EncodeHello(want)
got, err := DecodeHello(payload, flags)
if err != nil {
t.Fatalf("decode: %v", err)
}
if got != want {
t.Errorf("hello mismatch: %+v want %+v", got, want)
}
}
func TestHelloRejectsTruncatedPayload(t *testing.T) {
if _, err := DecodeHello([]byte{1, 2, 3}, 0); err == nil {
t.Fatal("expected error on truncated hello")
}
}
func TestHelloRejectsNameLenOverrun(t *testing.T) {
// file_size + name_len=999 but no name bytes → should fail.
buf := make([]byte, 12)
buf[8], buf[9], buf[10], buf[11] = 0, 0, 0x03, 0xe7 // 999
if _, err := DecodeHello(buf, 0); err == nil {
t.Fatal("expected error on name_len overrun")
}
}
func TestRangeReqRoundtrip(t *testing.T) {
want := RangeReqPayload{Offset: 1 << 30, Length: 1 << 20}
got, err := DecodeRangeReq(EncodeRangeReq(want))
if err != nil {
t.Fatalf("decode: %v", err)
}
if got != want {
t.Errorf("range_req mismatch: %+v want %+v", got, want)
}
}
func TestRangeReqRejectsWrongLength(t *testing.T) {
if _, err := DecodeRangeReq(make([]byte, 15)); err == nil {
t.Fatal("expected error on 15-byte payload")
}
if _, err := DecodeRangeReq(make([]byte, 17)); err == nil {
t.Fatal("expected error on 17-byte payload")
}
}
func TestRangeEndRoundtrip(t *testing.T) {
want := RangeEndPayload{Status: 42}
got, err := DecodeRangeEnd(EncodeRangeEnd(want))
if err != nil {
t.Fatalf("decode: %v", err)
}
if got != want {
t.Errorf("range_end mismatch: %+v want %+v", got, want)
}
if _, err := DecodeRangeEnd(make([]byte, 3)); err == nil {
t.Fatal("expected error on short range_end payload")
}
}
func TestSeekHintRoundtrip(t *testing.T) {
want := SeekHintPayload{TimestampMs: 123_456}
got, err := DecodeSeekHint(EncodeSeekHint(want))
if err != nil {
t.Fatalf("decode: %v", err)
}
if got != want {
t.Errorf("seek_hint mismatch: %+v want %+v", got, want)
}
if _, err := DecodeSeekHint(make([]byte, 7)); err == nil {
t.Fatal("expected error on short seek_hint payload")
}
}
func TestHelloFlagsHelper(t *testing.T) {
if HelloFlags(false, false) != 0 {
t.Error("expected 0 for both false")
}
if HelloFlags(true, false) != FlagTranscoding {
t.Error("expected FlagTranscoding only")
}
if HelloFlags(false, true) != FlagSeekable {
t.Error("expected FlagSeekable only")
}
if HelloFlags(true, true) != (FlagTranscoding | FlagSeekable) {
t.Error("expected both flags")
}
}
// Sanity check that MaxChunkPayload + HeaderSize fits inside MaxFrameSize so
// callers can rely on the chunk cap without their own bookkeeping.
func TestMaxChunkFitsInMaxFrame(t *testing.T) {
if MaxChunkPayload+HeaderSize > MaxFrameSize {
t.Fatalf("chunk %d + hdr %d > max frame %d", MaxChunkPayload, HeaderSize, MaxFrameSize)
}
}

199
internal/funnel/funnel.go Normal file
View file

@ -0,0 +1,199 @@
// Package funnel manages the optional CloudFlare Quick Tunnel subprocess
// that gives the daemon a public HTTPS hostname for cross-network playback
// from browser-based clients (web player on torrentclaw.com / torrentclaw.to).
//
// Why: HTTPS pages can't fetch HTTP resources (mixed content). Without a
// tunnel the daemon is only reachable from the same machine (localhost is
// exempt) or via Tailscale (which users can install themselves but most
// won't). CF Quick Tunnels are anonymous — no CF account, no DNS, no port
// forwarding — and assign a one-shot `https://<random>.trycloudflare.com`
// URL. Bytes flow through CF, never through our infra (legal posture: we
// don't relay; CF does).
//
// Lifecycle:
//
// t, err := funnel.Start(ctx, funnel.Config{Port: 11819})
// defer t.Close()
// url, err := t.WaitURL(30 * time.Second) // blocks until cloudflared emits the URL
//
// The tunnel runs until the context is cancelled or t.Close() is called.
package funnel
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"os/exec"
"regexp"
"sync"
"time"
)
// urlPattern matches the `https://<random>.trycloudflare.com` URL cloudflared
// prints when a Quick Tunnel is registered. The hostname has a random
// hyphen-separated label followed by .trycloudflare.com.
var urlPattern = regexp.MustCompile(`https://[a-z0-9-]+\.trycloudflare\.com`)
// Config controls how the tunnel is launched.
type Config struct {
// Port is the local upstream port cloudflared will tunnel to. Required.
Port int
// Binary is the cloudflared executable path. When empty the package looks
// it up via $PATH.
Binary string
}
// Tunnel is a handle on a running cloudflared Quick Tunnel.
type Tunnel struct {
cmd *exec.Cmd
cancel context.CancelFunc
urlCh chan string
exitCh chan error
mu sync.Mutex
url string
stopped bool
}
// Start launches cloudflared as a subprocess. The returned *Tunnel exposes the
// public URL via WaitURL once cloudflared registers it (usually 25 s).
//
// The subprocess inherits the cancellation of the supplied context. Closing
// the *Tunnel sends SIGTERM and waits for the subprocess to exit.
func Start(ctx context.Context, cfg Config) (*Tunnel, error) {
if cfg.Port <= 0 {
return nil, fmt.Errorf("funnel: invalid Port %d", cfg.Port)
}
binary := cfg.Binary
if binary == "" {
resolved, err := ResolveBinary()
if err != nil {
return nil, err
}
binary = resolved
}
subCtx, cancel := context.WithCancel(ctx)
// `--no-autoupdate` disables cloudflared's daily self-update check (the
// daemon manages binary rotation). `--metrics 127.0.0.1:0` suppresses the
// default `:9090` listener that would collide on a shared box.
cmd := exec.CommandContext(subCtx, binary,
"tunnel",
"--no-autoupdate",
"--metrics", "127.0.0.1:0",
"--url", fmt.Sprintf("http://localhost:%d", cfg.Port),
)
// cloudflared writes the connect log + assigned URL to stderr.
stderr, err := cmd.StderrPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("funnel: pipe stderr: %w", err)
}
cmd.Stdout = io.Discard // quick tunnels print nothing useful on stdout
if err := cmd.Start(); err != nil {
cancel()
return nil, fmt.Errorf("funnel: start cloudflared: %w", err)
}
t := &Tunnel{
cmd: cmd,
cancel: cancel,
urlCh: make(chan string, 1),
exitCh: make(chan error, 1),
}
// Reader goroutine: scan cloudflared's stderr for the URL, surface the
// rest as a single string we don't try to interpret.
go t.scanStderr(stderr)
// Waiter goroutine: signal exit so callers can react (e.g. restart).
go func() {
t.exitCh <- cmd.Wait()
}()
return t, nil
}
// WaitURL blocks until cloudflared has registered the tunnel and emitted the
// public URL, or `timeout` elapses, or the subprocess exits. The returned URL
// has the form `https://<random>.trycloudflare.com`.
func (t *Tunnel) WaitURL(timeout time.Duration) (string, error) {
t.mu.Lock()
if t.url != "" {
u := t.url
t.mu.Unlock()
return u, nil
}
t.mu.Unlock()
select {
case u := <-t.urlCh:
return u, nil
case err := <-t.exitCh:
if err == nil {
return "", errors.New("funnel: cloudflared exited before URL")
}
return "", fmt.Errorf("funnel: cloudflared exited: %w", err)
case <-time.After(timeout):
return "", fmt.Errorf("funnel: timed out waiting for URL after %s", timeout)
}
}
// URL returns the assigned tunnel URL, or "" if not yet emitted.
func (t *Tunnel) URL() string {
t.mu.Lock()
defer t.mu.Unlock()
return t.url
}
// Done returns a channel that closes once the subprocess exits. The error sent
// before close describes the exit reason (nil = clean shutdown via Close).
func (t *Tunnel) Done() <-chan error {
return t.exitCh
}
// Close terminates the subprocess and waits for it to exit. Safe to call
// multiple times.
func (t *Tunnel) Close() error {
t.mu.Lock()
if t.stopped {
t.mu.Unlock()
return nil
}
t.stopped = true
t.mu.Unlock()
t.cancel()
// Drain the exit channel so the Wait goroutine doesn't leak.
select {
case <-t.exitCh:
case <-time.After(5 * time.Second):
}
return nil
}
func (t *Tunnel) scanStderr(r io.Reader) {
scanner := bufio.NewScanner(r)
// Some cloudflared lines exceed the default 64KiB scanner buffer (when it
// prints connection diagnostics). Bump to 1MiB.
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if t.URL() == "" {
if m := urlPattern.FindString(line); m != "" {
t.mu.Lock()
t.url = m
t.mu.Unlock()
// Non-blocking send: if no one is listening, just drop —
// the URL field carries the value for any later WaitURL call.
select {
case t.urlCh <- m:
default:
}
}
}
}
}

167
internal/funnel/install.go Normal file
View file

@ -0,0 +1,167 @@
package funnel
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"time"
"github.com/torrentclaw/unarr/internal/config"
)
// ResolveBinary returns the path to a usable cloudflared executable, downloading
// one into the unarr data dir if neither $PATH nor the cached location has it.
// This makes the funnel feature usable on headless installs (NAS / Docker)
// where the user can't easily install cloudflared via the OS package manager.
//
// Resolution order:
//
// 1. cloudflared on $PATH (operator already installed it)
// 2. <data-dir>/bin/cloudflared (we cached it on a previous run)
// 3. download from GitHub releases (Linux-only fallback; macOS / Windows
// return a clear error pointing at brew / winget)
func ResolveBinary() (string, error) {
if p, err := exec.LookPath("cloudflared"); err == nil {
return p, nil
}
cached := cachedBinaryPath()
if _, err := os.Stat(cached); err == nil {
return cached, nil
}
return downloadCloudflared(cached)
}
func cachedBinaryPath() string {
name := "cloudflared"
if runtime.GOOS == "windows" {
name += ".exe"
}
return filepath.Join(config.DataDir(), "bin", name)
}
// downloadCloudflared fetches the latest cloudflared release asset matching
// the current GOOS/GOARCH into `dest`. Linux only — macOS/Windows return a
// pointer at the OS package manager.
//
// Supply-chain caveat: we trust GitHub-over-TLS + cloudflare/cloudflared
// repo integrity. The fetch is over HTTPS to api.github.com's release-asset
// redirector, so a network MITM is bounded by Let's Encrypt + GitHub's cert
// chain. We additionally verify the file is an ELF binary (Linux magic
// bytes) so a generic 404 HTML page or a wrong-arch tarball is rejected at
// rest. We do NOT verify a signature because Cloudflare doesn't sign release
// assets at the moment — if you need stricter integrity, install cloudflared
// from your distro's package manager (apt/brew/winget) and unarr will use
// the PATH copy.
func downloadCloudflared(dest string) (string, error) {
if runtime.GOOS != "linux" {
return "", fmt.Errorf("funnel: auto-download not supported on %s — install cloudflared manually or drop a binary at %s", runtime.GOOS, dest)
}
var asset string
switch runtime.GOARCH {
case "amd64":
asset = "cloudflared-linux-amd64"
case "arm64":
asset = "cloudflared-linux-arm64"
case "arm":
asset = "cloudflared-linux-armhf"
case "386":
asset = "cloudflared-linux-386"
default:
return "", fmt.Errorf("funnel: unsupported linux arch %q — install cloudflared manually", runtime.GOARCH)
}
url := "https://github.com/cloudflare/cloudflared/releases/latest/download/" + asset
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return "", fmt.Errorf("funnel: create bin dir: %w", err)
}
// O_EXCL so concurrent unarr-dev / prod daemons don't clobber each
// other's partial download. The loser gets EEXIST → falls back to
// polling for the winner to finish.
tmp := dest + ".partial"
out, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o755)
if err != nil {
if errors.Is(err, os.ErrExist) {
// Another process is downloading. Wait briefly for them to finish.
for range 60 {
time.Sleep(time.Second)
if _, statErr := os.Stat(dest); statErr == nil {
return dest, nil
}
}
return "", fmt.Errorf("funnel: another download in progress at %s (timed out)", tmp)
}
return "", fmt.Errorf("funnel: open dest: %w", err)
}
client := &http.Client{Timeout: 5 * time.Minute}
resp, err := client.Get(url)
if err != nil {
_ = out.Close()
_ = os.Remove(tmp)
return "", fmt.Errorf("funnel: download cloudflared: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
_ = out.Close()
_ = os.Remove(tmp)
return "", fmt.Errorf("funnel: download cloudflared: HTTP %d from %s", resp.StatusCode, url)
}
if _, err := io.Copy(out, resp.Body); err != nil {
_ = out.Close()
_ = os.Remove(tmp)
return "", fmt.Errorf("funnel: write dest: %w", err)
}
if err := out.Close(); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("funnel: close dest: %w", err)
}
// Sanity check before promoting <partial> to <dest>: must be a Linux
// ELF executable (rejects 404 HTML pages or wrong-arch payloads) and at
// least 1 MB (real cloudflared is ~50 MB; anything smaller is corrupt).
if err := verifyLinuxElf(tmp); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("funnel: downloaded file failed sanity check: %w", err)
}
if err := os.Rename(tmp, dest); err != nil {
_ = os.Remove(tmp)
return "", fmt.Errorf("funnel: rename dest: %w", err)
}
return dest, nil
}
// verifyLinuxElf returns nil when the file at `path` starts with the ELF
// magic bytes and is at least 1 MB. Used as a low-cost guard against
// downloading an HTML error page or a wrong-arch payload.
func verifyLinuxElf(path string) error {
st, err := os.Stat(path)
if err != nil {
return err
}
if st.Size() < 1024*1024 {
return errors.New("file is suspiciously small (<1 MB)")
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
head := make([]byte, 4)
if _, err := io.ReadFull(f, head); err != nil {
return fmt.Errorf("read magic bytes: %w", err)
}
if !bytes.Equal(head, []byte{0x7f, 'E', 'L', 'F'}) {
return errors.New("not an ELF binary")
}
return nil
}

View file

@ -18,7 +18,7 @@ import (
// 5. Previously downloaded in the unarr cache dir
// 6. Auto-download static binary as last resort (~50MB, slow start)
//
// ffmpeg is required for the WebRTC streaming pipeline; ffprobe alone can't
// ffmpeg is required for the HLS streaming pipeline; ffprobe alone can't
// transcode HEVC/MKV to browser-friendly H.264/MP4 fragments.
func ResolveFFmpeg(explicit string) (string, error) {
if explicit != "" {

View file

@ -13,8 +13,17 @@ var (
altEpRegex = regexp.MustCompile(`(?i)(\d{1,2})x(\d{2})`)
)
// ResolveResolution maps a pixel height to a standard resolution label.
func ResolveResolution(height int) string {
// ResolveResolution maps video dimensions to a standard resolution label.
// Uses both width and height so cinematic aspect ratios (2.35:1, 2.39:1, 21:9)
// are not misclassified — e.g. a 1080p source presented as 1920×804 letterboxed
// would fall to 720p if classified by height alone.
func ResolveResolution(width, height int) string {
byHeight := resolutionByHeight(height)
byWidth := resolutionByWidth(width)
return maxResolution(byHeight, byWidth)
}
func resolutionByHeight(height int) string {
switch {
case height >= 2000:
return "2160p"
@ -29,6 +38,36 @@ func ResolveResolution(height int) string {
}
}
func resolutionByWidth(width int) string {
switch {
case width >= 3400:
return "2160p"
case width >= 1800:
return "1080p"
case width >= 1200:
return "720p"
case width >= 800:
return "480p"
default:
return ""
}
}
var resolutionRank = map[string]int{
"": 0,
"480p": 1,
"720p": 2,
"1080p": 3,
"2160p": 4,
}
func maxResolution(a, b string) string {
if resolutionRank[a] >= resolutionRank[b] {
return a
}
return b
}
// DeriveContentType guesses "movie" or "show" from parsed metadata.
func DeriveContentType(item LibraryItem) string {
if item.Season > 0 || item.Episode > 0 {

View file

@ -8,28 +8,31 @@ import (
func TestResolveResolution(t *testing.T) {
tests := []struct {
name string
width int
height int
want string
}{
{2160, "2160p"},
{2000, "2160p"},
{1080, "1080p"},
{1920, "1080p"}, // 1920 is width, not height — height for 1080p is ~1080
{900, "1080p"},
{720, "720p"},
{600, "720p"},
{576, "480p"},
{480, "480p"},
{400, "480p"},
{360, ""},
{0, ""},
{"4K square", 3840, 2160, "2160p"},
{"4K low height", 3840, 1600, "2160p"},
{"1080p square", 1920, 1080, "1080p"},
{"1080p cinematic 2.39:1", 1920, 804, "1080p"}, // anamorphic widescreen — must not fall to 720p
{"1080p cinematic 2.35:1", 1920, 818, "1080p"},
{"1080p 21:9", 2560, 1080, "1080p"},
{"720p square", 1280, 720, "720p"},
{"720p widescreen", 1280, 540, "720p"},
{"480p", 854, 480, "480p"},
{"sub-480", 640, 360, ""},
{"zero", 0, 0, ""},
}
for _, tt := range tests {
got := ResolveResolution(tt.height)
if got != tt.want {
t.Errorf("ResolveResolution(%d) = %q, want %q", tt.height, got, tt.want)
}
t.Run(tt.name, func(t *testing.T) {
got := ResolveResolution(tt.width, tt.height)
if got != tt.want {
t.Errorf("ResolveResolution(%d, %d) = %q, want %q", tt.width, tt.height, got, tt.want)
}
})
}
}

View file

@ -23,7 +23,7 @@ func BuildSyncItems(cache *LibraryCache) []agent.LibrarySyncItem {
if item.MediaInfo != nil {
if item.MediaInfo.Video != nil {
si.Resolution = ResolveResolution(item.MediaInfo.Video.Height)
si.Resolution = ResolveResolution(item.MediaInfo.Video.Width, item.MediaInfo.Video.Height)
si.VideoCodec = item.MediaInfo.Video.Codec
si.HDR = item.MediaInfo.Video.HDR
si.BitDepth = item.MediaInfo.Video.BitDepth

View file

@ -1,12 +1,14 @@
package sentry
import (
"errors"
"os"
"runtime"
"strings"
"time"
gosentry "github.com/getsentry/sentry-go"
"github.com/spf13/pflag"
)
// dsn is injected at build time via ldflags. If empty, Sentry is disabled.
@ -44,9 +46,16 @@ func Close() {
gosentry.Flush(flushTimeout)
}
// daemonNotRunningMarker matches the message of agent.ErrDaemonNotRunning
// without importing the agent package — avoids a sentry → agent dependency
// that would risk a cycle if agent ever needed to report errors itself.
const daemonNotRunningMarker = "daemon does not appear to be running"
// CaptureError sends a non-fatal error to Sentry with optional command context.
// Expected non-bug errors (bad CLI input, daemon not running) are skipped to
// keep the issue feed signal-heavy.
func CaptureError(err error, command string) {
if err == nil {
if err == nil || shouldSkipSentry(err) {
return
}
@ -58,6 +67,21 @@ func CaptureError(err error, command string) {
})
}
func shouldSkipSentry(err error) bool {
var notExist *pflag.NotExistError
var valueReq *pflag.ValueRequiredError
var invalidVal *pflag.InvalidValueError
var invalidSyn *pflag.InvalidSyntaxError
if errors.As(err, &notExist) || errors.As(err, &valueReq) ||
errors.As(err, &invalidVal) || errors.As(err, &invalidSyn) {
return true
}
msg := err.Error()
return strings.HasPrefix(msg, "unknown command ") ||
strings.HasPrefix(msg, "required flag(s)") ||
strings.Contains(msg, daemonNotRunningMarker)
}
// RecoverPanic captures a panic and re-panics after reporting.
// Usage: defer sentry.RecoverPanic()
func RecoverPanic() {

View file

@ -1,6 +1,10 @@
package sentry
import "testing"
import (
"errors"
"fmt"
"testing"
)
func TestEnvironment(t *testing.T) {
tests := []struct {
@ -45,3 +49,16 @@ func TestSetUser(t *testing.T) {
// Should not panic without initialization
SetUser("agent-123")
}
func TestShouldSkipSentryDaemonNotRunning(t *testing.T) {
// String must stay in sync with agent.ErrDaemonNotRunning. If that sentinel
// is reworded, this test fails loudly so the marker can be updated.
err := errors.New("daemon does not appear to be running (state file not found)")
if !shouldSkipSentry(err) {
t.Error("ErrDaemonNotRunning message should be skipped")
}
wrapped := fmt.Errorf("read daemon state: %w", err)
if !shouldSkipSentry(wrapped) {
t.Error("wrapped ErrDaemonNotRunning message should be skipped")
}
}

View file

@ -2,10 +2,10 @@ package upgrade
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
@ -88,7 +88,23 @@ func download(ctx context.Context, version string) (string, error) {
}
// verifyChecksum downloads checksums.txt and verifies the archive's SHA256.
// When a release public key is embedded at build time (releasePubKeyBase64),
// the function also verifies an ed25519 signature over checksums.txt before
// trusting any hash inside it — this turns the checksum file from a passive
// integrity check into an authenticated artifact that a maintainer or CI key
// compromise cannot trivially forge.
func verifyChecksum(ctx context.Context, version, archivePath string) error {
return verifyChecksumWithOptions(ctx, version, archivePath, true)
}
// verifyChecksumOnly skips the ed25519 signature step. Used by Upgrader
// when --allow-unsigned is set and the release is known to predate signing
// (or when a release accidentally shipped without a .sig file).
func verifyChecksumOnly(ctx context.Context, version, archivePath string) error {
return verifyChecksumWithOptions(ctx, version, archivePath, false)
}
func verifyChecksumWithOptions(ctx context.Context, version, archivePath string, verifySignature bool) error {
// Download checksums.txt
url := releaseURL(version, "checksums.txt")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
@ -107,11 +123,28 @@ func verifyChecksum(ctx context.Context, version, archivePath string) error {
return fmt.Errorf("fetch checksums: HTTP %d", resp.StatusCode)
}
// Read the entire checksums.txt content first so we can both parse and
// verify the signature over the same bytes.
checksumsContent, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return fmt.Errorf("read checksums: %w", err)
}
// Verify ed25519 signature over checksums.txt before trusting its
// contents. Skipped silently when no key is embedded (handled by the
// caller via SignatureVerificationConfigured) or when the caller
// explicitly opts out via --allow-unsigned.
if verifySignature {
if err := verifyChecksumsSignature(ctx, version, checksumsContent); err != nil {
return fmt.Errorf("verify signature: %w", err)
}
}
// Parse checksums.txt — format: "<sha256> <filename>"
expectedName := archiveName(version)
var expectedHash string
scanner := bufio.NewScanner(resp.Body)
scanner := bufio.NewScanner(bytes.NewReader(checksumsContent))
for scanner.Scan() {
line := scanner.Text()
parts := strings.Fields(line)
@ -148,36 +181,35 @@ func verifyChecksum(ctx context.Context, version, archivePath string) error {
return nil
}
// fetchLatestVersion queries GitHub API for the latest release tag.
// fetchLatestVersion queries the TorrentClaw release endpoint (/version) for the
// latest version string (e.g. "0.8.1"). No GitHub dependency.
func fetchLatestVersion(ctx context.Context) (string, error) {
url := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", githubRepo)
url := updateBaseURL + "/version"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", "unarr-updater")
resp, err := httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("fetch latest release: %w", err)
return "", fmt.Errorf("fetch latest version: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("GitHub API: HTTP %d", resp.StatusCode)
return "", fmt.Errorf("version endpoint: HTTP %d", resp.StatusCode)
}
var release struct {
TagName string `json:"tag_name"`
}
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return "", fmt.Errorf("decode response: %w", err)
body, err := io.ReadAll(io.LimitReader(resp.Body, 64))
if err != nil {
return "", fmt.Errorf("read version: %w", err)
}
if release.TagName == "" {
return "", fmt.Errorf("empty tag_name in release")
version := strings.TrimPrefix(strings.TrimSpace(string(body)), "v")
if version == "" {
return "", fmt.Errorf("empty version from %s", url)
}
return strings.TrimPrefix(release.TagName, "v"), nil
return version, nil
}

View file

@ -7,6 +7,7 @@ import (
"fmt"
"io"
"os"
"path"
"path/filepath"
"runtime"
"strings"
@ -85,15 +86,22 @@ func extractZip(archivePath, destDir string) (string, error) {
target := binaryName + ".exe"
for _, f := range r.File {
name := filepath.Base(f.Name)
// Resolve destDir to its absolute form once so the ZIP-slip check below
// can compare canonical paths instead of fragile substring matches.
absDest, err := filepath.Abs(destDir)
if err != nil {
return "", fmt.Errorf("resolve dest: %w", err)
}
// Guard against path traversal
if strings.Contains(f.Name, "..") {
for _, f := range r.File {
if f.FileInfo().IsDir() {
continue
}
if name != target {
if filepath.Base(f.Name) != target {
continue
}
absDst, ok := safeZipPath(f.Name, target, absDest)
if !ok {
continue
}
@ -102,8 +110,7 @@ func extractZip(archivePath, destDir string) (string, error) {
return "", err
}
dst := filepath.Join(destDir, target)
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
out, err := os.OpenFile(absDst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o755)
if err != nil {
rc.Close()
return "", err
@ -116,8 +123,41 @@ func extractZip(archivePath, destDir string) (string, error) {
}
out.Close()
rc.Close()
return dst, nil
return absDst, nil
}
return "", fmt.Errorf("binary %q not found in archive", target)
}
// safeZipPath validates that a ZIP entry name is safe to extract under
// absDest, then returns the absolute destination path (always
// absDest/target, never the raw entry name — we still only extract files
// matched by Base name).
//
// Rejected: absolute paths, paths that resolve to "..", paths containing
// a "../" or "..\\" component, and any entry whose final destination
// would land outside absDest. The check uses path.Clean on the entry's
// native separator (ZIP uses forward slashes by spec, but some authors
// emit backslashes — we treat both as separators here so a hostile entry
// on Linux can't bypass the substring scan).
func safeZipPath(entryName, target, absDest string) (string, bool) {
// Normalise both separators to "/" so the check works on Linux too,
// where filepath.Separator is "/" and a hostile "..\\foo" string is
// otherwise treated as a single filename component by filepath.Clean.
normalised := strings.ReplaceAll(entryName, `\`, "/")
cleaned := path.Clean(normalised)
if cleaned == ".." ||
strings.HasPrefix(cleaned, "../") ||
strings.Contains(cleaned, "/../") ||
path.IsAbs(cleaned) {
return "", false
}
absDst, err := filepath.Abs(filepath.Join(absDest, target))
if err != nil {
return "", false
}
if !strings.HasPrefix(absDst+string(filepath.Separator), absDest+string(filepath.Separator)) {
return "", false
}
return absDst, true
}

View file

@ -0,0 +1,112 @@
package upgrade
import (
"context"
"crypto/ed25519"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"strings"
)
// releasePubKeyBase64 is the base64-encoded ed25519 public key used to verify
// `checksums.txt.sig` against `checksums.txt` during self-update.
//
// It is overridable at link time via ldflags so the same source compiles for
// users who do not yet have a release-signing keypair in their CI:
//
// -X github.com/torrentclaw/unarr/internal/upgrade.releasePubKeyBase64=<base64-pubkey>
//
// When the variable is empty, signature verification is skipped and a warning
// is logged — checksum-only verification remains in force. This is the
// transitional default until the keypair is provisioned; flip to a non-empty
// value (and enable the corresponding CI signing step) to make signature
// verification mandatory.
var releasePubKeyBase64 = ""
// ErrMissingSignature indicates the release does not ship a `.sig` file even
// though signature verification is required by an embedded public key.
var ErrMissingSignature = errors.New("release signature file is missing")
// verifyChecksumsSignature downloads `checksums.txt.sig` (raw 64-byte ed25519
// signature over the checksums.txt content) and verifies it with the embedded
// public key. Returns nil if verification succeeds or if no public key has
// been embedded yet (caller is expected to surface a warning in that case).
func verifyChecksumsSignature(ctx context.Context, version string, checksumsContent []byte) error {
pubKey, err := loadReleasePubKey()
if err != nil {
return fmt.Errorf("load release pubkey: %w", err)
}
if pubKey == nil {
// Signature verification not configured; caller decides what to do.
return nil
}
url := releaseURL(version, "checksums.txt.sig")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "unarr-updater")
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("fetch signature: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return ErrMissingSignature
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("fetch signature: HTTP %d", resp.StatusCode)
}
// Signature file is base64(signature)\n — small and bounded.
rawSig, err := io.ReadAll(io.LimitReader(resp.Body, 8*1024))
if err != nil {
return fmt.Errorf("read signature: %w", err)
}
sig, err := decodeSignature(rawSig)
if err != nil {
return fmt.Errorf("decode signature: %w", err)
}
if len(sig) != ed25519.SignatureSize {
return fmt.Errorf("signature size %d, expected %d", len(sig), ed25519.SignatureSize)
}
if !ed25519.Verify(pubKey, checksumsContent, sig) {
return errors.New("ed25519 signature verification failed")
}
return nil
}
// SignatureVerificationConfigured reports whether the build has a release
// public key embedded. The CLI surfaces this so users running a non-signed
// build get a clear warning rather than silent trust.
func SignatureVerificationConfigured() bool {
pubKey, err := loadReleasePubKey()
return err == nil && pubKey != nil
}
func loadReleasePubKey() (ed25519.PublicKey, error) {
v := strings.TrimSpace(releasePubKeyBase64)
if v == "" {
return nil, nil
}
raw, err := base64.StdEncoding.DecodeString(v)
if err != nil {
return nil, fmt.Errorf("base64 decode: %w", err)
}
if len(raw) != ed25519.PublicKeySize {
return nil, fmt.Errorf("pubkey size %d, expected %d", len(raw), ed25519.PublicKeySize)
}
return ed25519.PublicKey(raw), nil
}
// decodeSignature parses the base64-encoded signature emitted by
// scripts/sign-checksums (always base64 + trailing newline). A single
// expected format keeps the surface area minimal — a stricter parser is
// less likely to accept a hostile mirror's coincidentally-sized payload.
func decodeSignature(raw []byte) ([]byte, error) {
return base64.StdEncoding.DecodeString(strings.TrimSpace(string(raw)))
}

View file

@ -0,0 +1,134 @@
package upgrade
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// withReleasePubKey temporarily swaps the embedded release public key and
// restores the previous value on test exit.
func withReleasePubKey(t *testing.T, encoded string) {
t.Helper()
prev := releasePubKeyBase64
releasePubKeyBase64 = encoded
t.Cleanup(func() { releasePubKeyBase64 = prev })
}
func TestSignatureVerificationDisabledByDefault(t *testing.T) {
withReleasePubKey(t, "")
if SignatureVerificationConfigured() {
t.Fatal("expected SignatureVerificationConfigured() to be false when pubkey is empty")
}
// verifyChecksumsSignature should be a no-op when no key is embedded.
if err := verifyChecksumsSignature(context.Background(), "0.0.0", []byte("anything")); err != nil {
t.Fatalf("expected nil when pubkey is empty, got %v", err)
}
}
func TestSignatureRejectsMalformedPubKey(t *testing.T) {
withReleasePubKey(t, "not-base64!!")
if _, err := loadReleasePubKey(); err == nil {
t.Fatal("expected error from malformed base64")
}
}
func TestSignatureRejectsWrongSizePubKey(t *testing.T) {
withReleasePubKey(t, base64.StdEncoding.EncodeToString([]byte("too-short")))
if _, err := loadReleasePubKey(); err == nil {
t.Fatal("expected error from wrong-size pubkey")
}
}
func TestSignatureVerifiesGoodSignature(t *testing.T) {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate keypair: %v", err)
}
withReleasePubKey(t, base64.StdEncoding.EncodeToString(pub))
checksumsBody := []byte("deadbeef unarr_0.0.0_linux_amd64.tar.gz\n")
signature := ed25519.Sign(priv, checksumsBody)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "checksums.txt.sig") {
http.NotFound(w, r)
return
}
fmt.Fprintln(w, base64.StdEncoding.EncodeToString(signature))
}))
defer srv.Close()
prevHost := updateBaseURL
updateBaseURL = srv.URL
t.Cleanup(func() { updateBaseURL = prevHost })
if err := verifyChecksumsSignature(context.Background(), "0.0.0", checksumsBody); err != nil {
t.Fatalf("verifyChecksumsSignature(good) = %v, want nil", err)
}
}
func TestSignatureRejectsBadSignature(t *testing.T) {
pub, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("generate keypair: %v", err)
}
withReleasePubKey(t, base64.StdEncoding.EncodeToString(pub))
// Sign with a DIFFERENT private key — should be rejected.
_, other, _ := ed25519.GenerateKey(rand.Reader)
body := []byte("checksum-line\n")
badSig := ed25519.Sign(other, body)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, base64.StdEncoding.EncodeToString(badSig))
}))
defer srv.Close()
prevHost := updateBaseURL
updateBaseURL = srv.URL
t.Cleanup(func() { updateBaseURL = prevHost })
err = verifyChecksumsSignature(context.Background(), "0.0.0", body)
if err == nil || !strings.Contains(err.Error(), "verification failed") {
t.Fatalf("expected verification failure, got %v", err)
}
}
func TestSignatureMissingFile(t *testing.T) {
pub, _, _ := ed25519.GenerateKey(rand.Reader)
withReleasePubKey(t, base64.StdEncoding.EncodeToString(pub))
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
defer srv.Close()
prevHost := updateBaseURL
updateBaseURL = srv.URL
t.Cleanup(func() { updateBaseURL = prevHost })
err := verifyChecksumsSignature(context.Background(), "0.0.0", []byte("body"))
if !errors.Is(err, ErrMissingSignature) {
t.Fatalf("expected ErrMissingSignature, got %v", err)
}
}
func TestDecodeSignatureRejectsRaw(t *testing.T) {
// 64-byte payload that happens NOT to be valid base64 must error rather
// than be silently accepted as a raw signature — the only legitimate
// shape is base64-encoded text.
raw := make([]byte, ed25519.SignatureSize)
for i := range raw {
raw[i] = 0xff
}
if _, err := decodeSignature(raw); err == nil {
t.Fatal("expected error from non-base64 64-byte payload")
}
}

View file

@ -13,6 +13,7 @@ package upgrade
import (
"context"
"errors"
"fmt"
"log"
"os"
@ -24,7 +25,6 @@ import (
)
const (
githubRepo = "torrentclaw/unarr"
binaryName = "unarr"
smokeTestTO = 5 * time.Second
)
@ -43,6 +43,13 @@ type Upgrader struct {
CurrentVersion string
// OnProgress is called with status messages during the upgrade process.
OnProgress func(msg string)
// AllowUnsigned downgrades a missing checksums.txt.sig to a warning and
// continues with SHA256-only verification. Required to downgrade to a
// release published before signing was introduced, or to recover from
// an accidental release where the workflow's signing step was skipped.
// Default false — signature missing is a hard failure when a public
// key is embedded.
AllowUnsigned bool
}
func (u *Upgrader) log(msg string) {
@ -89,10 +96,21 @@ func (u *Upgrader) Execute(ctx context.Context, targetVersion string) Result {
}
defer os.Remove(archivePath)
// 5. Verify checksum
u.log("Verifying checksum...")
// 5. Verify checksum (and signature, if configured)
if SignatureVerificationConfigured() {
u.log("Verifying checksum + ed25519 signature...")
} else {
u.log("Verifying checksum (release signature verification not configured for this build)...")
}
if err := verifyChecksum(ctx, targetVersion, archivePath); err != nil {
return u.fail("checksum: %v", err)
if errors.Is(err, ErrMissingSignature) && u.AllowUnsigned {
u.log("WARNING: release is unsigned and --allow-unsigned was passed; continuing with SHA256-only verification")
if err := verifyChecksumOnly(ctx, targetVersion, archivePath); err != nil {
return u.fail("checksum: %v", err)
}
} else {
return u.fail("checksum: %v", err)
}
}
// 6. Extract binary
@ -224,7 +242,26 @@ func archiveName(version string) string {
return fmt.Sprintf("%s_%s_%s_%s.%s", binaryName, version, runtime.GOOS, runtime.GOARCH, ext)
}
// releaseURL returns the download URL for a release asset.
func releaseURL(version, filename string) string {
return fmt.Sprintf("https://github.com/%s/releases/download/v%s/%s", githubRepo, version, filename)
// updateBaseURL is the base URL the self-updater fetches releases from —
// TorrentClaw's own app, no GitHub dependency (the org is shadow-banned, so
// GitHub releases/raw/API all 404 to anonymous clients). Defaults to the
// production apex; SetBaseURL points it at the configured host (cfg.Auth.APIURL)
// so mirrors / onion / staging work, and tests can point it at an httptest.Server.
var updateBaseURL = "https://torrentclaw.com"
// SetBaseURL overrides the release endpoint base (trailing slash trimmed).
// No-op for empty input so a blank config can't break the default.
func SetBaseURL(base string) {
if base != "" {
updateBaseURL = strings.TrimRight(base, "/")
}
}
// releaseURL returns the download URL for a release asset:
//
// {base}/releases/download/v{version}/{filename}
//
// served by the app's src/app/releases/download/[...seg] route handler.
func releaseURL(version, filename string) string {
return fmt.Sprintf("%s/releases/download/v%s/%s", updateBaseURL, version, filename)
}

View file

@ -57,7 +57,7 @@ func TestArchiveName(t *testing.T) {
func TestReleaseURL(t *testing.T) {
url := releaseURL("0.3.0", "unarr_0.3.0_linux_amd64.tar.gz")
want := "https://github.com/torrentclaw/unarr/releases/download/v0.3.0/unarr_0.3.0_linux_amd64.tar.gz"
want := "https://torrentclaw.com/releases/download/v0.3.0/unarr_0.3.0_linux_amd64.tar.gz"
if url != want {
t.Errorf("releaseURL = %q, want %q", url, want)
}
@ -289,21 +289,24 @@ func TestUpgraderSameVersionWithPrefix(t *testing.T) {
func TestFetchLatestVersionMockServer(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"tag_name":"v2.5.1","published_at":"2025-01-01T00:00:00Z"}`)
if r.URL.Path != "/version" {
http.NotFound(w, r)
return
}
fmt.Fprintln(w, "v2.5.1")
}))
defer srv.Close()
// We can't directly test fetchLatestVersion because it uses a hardcoded URL.
// But we can test the JSON parsing logic by calling the endpoint ourselves.
resp, err := http.Get(srv.URL)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
prev := updateBaseURL
updateBaseURL = srv.URL
t.Cleanup(func() { updateBaseURL = prev })
if resp.StatusCode != 200 {
t.Errorf("status = %d, want 200", resp.StatusCode)
ver, err := fetchLatestVersion(context.Background())
if err != nil {
t.Fatalf("fetchLatestVersion() = %v", err)
}
if ver != "2.5.1" {
t.Errorf("fetchLatestVersion() = %q, want %q", ver, "2.5.1")
}
}
@ -403,19 +406,19 @@ func TestReleaseURLEdgeCases(t *testing.T) {
name: "pre-release version",
version: "2.0.0-beta.1",
filename: "unarr_2.0.0-beta.1_darwin_arm64.tar.gz",
wantURL: "https://github.com/torrentclaw/unarr/releases/download/v2.0.0-beta.1/unarr_2.0.0-beta.1_darwin_arm64.tar.gz",
wantURL: "https://torrentclaw.com/releases/download/v2.0.0-beta.1/unarr_2.0.0-beta.1_darwin_arm64.tar.gz",
},
{
name: "checksums file",
version: "3.0.0",
filename: "checksums.txt",
wantURL: "https://github.com/torrentclaw/unarr/releases/download/v3.0.0/checksums.txt",
wantURL: "https://torrentclaw.com/releases/download/v3.0.0/checksums.txt",
},
{
name: "windows zip",
version: "1.2.3",
filename: "unarr_1.2.3_windows_amd64.zip",
wantURL: "https://github.com/torrentclaw/unarr/releases/download/v1.2.3/unarr_1.2.3_windows_amd64.zip",
wantURL: "https://torrentclaw.com/releases/download/v1.2.3/unarr_1.2.3_windows_amd64.zip",
},
}
for _, tt := range tests {
@ -530,19 +533,19 @@ func TestFetchLatestVersionWithHTTPTest(t *testing.T) {
}{
{
name: "valid response",
body: `{"tag_name":"v3.1.4"}`,
body: "v3.1.4\n",
statusCode: 200,
wantVer: "3.1.4",
},
{
name: "valid response without v prefix",
body: `{"tag_name":"2.0.0"}`,
body: "2.0.0",
statusCode: 200,
wantVer: "2.0.0",
},
{
name: "empty tag_name",
body: `{"tag_name":""}`,
name: "empty body",
body: "",
statusCode: 200,
wantErr: true,
},
@ -553,8 +556,8 @@ func TestFetchLatestVersionWithHTTPTest(t *testing.T) {
wantErr: true,
},
{
name: "invalid json",
body: `{invalid`,
name: "whitespace only",
body: " \n",
statusCode: 200,
wantErr: true,
},
@ -1085,3 +1088,40 @@ func TestDownloadSetsUserAgent(t *testing.T) {
t.Errorf("User-Agent = %q, want 'unarr-updater'", gotUA)
}
}
func TestSafeZipPath(t *testing.T) {
dest := t.TempDir()
absDest, err := filepath.Abs(dest)
if err != nil {
t.Fatalf("abs dest: %v", err)
}
// Names that must extract successfully.
good := []string{
"unarr.exe",
"bin/unarr.exe",
"./unarr.exe",
"folder/sub/unarr.exe",
}
for _, name := range good {
if _, ok := safeZipPath(name, "unarr.exe", absDest); !ok {
t.Errorf("safeZipPath(%q) = ok:false, want ok:true", name)
}
}
// Names that must be rejected for path-traversal reasons.
bad := []string{
"../unarr.exe",
"..",
"foo/../../unarr.exe",
"/etc/passwd",
"/abs/unarr.exe",
`..\..\windows\system32\unarr.exe`, // backslash entries that escape
"../../bin/unarr.exe",
}
for _, name := range bad {
if _, ok := safeZipPath(name, "unarr.exe", absDest); ok {
t.Errorf("safeZipPath(%q) = ok:true, want ok:false", name)
}
}
}

Some files were not shown because too many files have changed in this diff Show more